# 运输计划相关列表导出扩展 · 段次×产品行 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 危化品证、运输归档、合同完善导出对齐列表段次×产品行(方案 A);出库单已完成仅验收。 **Architecture:** 复用基线 `_iter_segment_product_slots` / `_format_stop_segment_label`;归档改造 `build_plan_archive_export_rows` 为段次多行 + `planQty`/`actualQty`/`unitPrice`/`amount`;合同完善 `export_contract_gaps` 对缺口计划做段次展开;证件仅前端注入 `stopSegment`。 **Tech Stack:** Python/FastAPI、pytest、Vue 3 **Spec:** `docs/superpowers/specs/2026-10-15-pl-segment-export-extend-design.md` **Do NOT git commit unless user explicitly asks.** --- ## File map | 文件 | 职责 | |------|------| | `web-admin/src/views/pl/plan-cert/index.vue` | 导出列注入 `stopSegment` | | `backend/app/api/pl/plan.py` | `PLAN_ARCHIVE_EXPORT_COLUMNS`、`ARCHIVE_PRICE_EXPORT_FIELDS`、`CONTRACT_GAP_EXPORT_COLUMNS`、`export_contract_gaps` | | `backend/app/services/pl_plan_archive_export.py` | 段次展开替换配对宽表默认路径 | | `backend/tests/test_pl_plan_archive_export.py` | 归档段次导出用例 | | `backend/tests/test_pl_plan_contract_gap_export.py`(新建或扩现有) | 合同完善段次导出用例 | | `web-admin/src/views/pl/plan-archive/index.vue` | 导出可见列映射对齐新 field(`stopSegment`、去掉旧宽列 prop) | | `web-admin/src/views/pl/plan-contract/index.vue` | `contractGapExportColumns` 对齐新列 | --- ### Task 1: 危化品证上传 — 注入 stopSegment **Files:** - Modify: `web-admin/src/views/pl/plan-cert/index.vue` - [ ] **Step 1: 改 `planCertExportColumns`** 对照运输计划列表写法,在 `planNo` 后插入 `stopSegment`: ```typescript const planCertExportColumns = computed(() => { const skip = new Set([ 'loadCustomer', 'deliveryCustomer', 'loadAddressTime', 'deliveryAddressTime' ]) const cols = visibleColumns.value .filter((col) => !skip.has(col)) .map((col) => PLAN_CERT_EXPORT_COLUMN_FIELD_MAP[col] || col) if (!cols.includes('planNo')) cols.unshift('planNo') const planNoIdx = cols.indexOf('planNo') if (!cols.includes('stopSegment')) { cols.splice(planNoIdx + 1, 0, 'stopSegment') } return cols }) ``` - [ ] **Step 2: 自检** — 读文件确认 splice 存在;无需后端改动。 --- ### Task 2: 归档导出 — 失败测试(段次×产品) **Files:** - Modify: `backend/tests/test_pl_plan_archive_export.py` - [ ] **Step 1: 新增/改写主用例为段次多行** 将 `test_build_plan_archive_export_rows_pair_mode_and_single_side` 的「默认 stop_kind=None → 1 行配对」改为期望: ```python # stop_kind=None → 段次展开:装货 1 行 + 送货 1 行 = 2 行 rows = build_plan_archive_export_rows(..., stop_kind=None) assert len(rows) == 2 assert [r["stopSegment"] for r in rows] == ["装货", "送货 #2"] assert rows[0]["loadCustomer"] == internal assert rows[0]["deliveryCustomer"] == "" assert rows[0]["planQty"] == ... # load product plan_qty assert rows[0]["actualQty"] == Decimal("10") # inbound assert rows[0]["unitPrice"] == Decimal("100") assert rows[1]["deliveryCustomer"] == "外部客户" assert rows[1]["loadCustomer"] == "" assert rows[1]["actualQty"] == Decimal("9") # 不得再有 loadPlanQty + deliveryPlanQty 同排有值的配对语义 assert "loadPlanQty" not in rows[0] or rows[0].get("loadPlanQty") is None # 更好:列定义已无此 field,行 dict 可不含 ``` `stop_kind="load"` / `"delivery"` 仍只输出对应侧行(可用 `stopSegment` 断言,兼容旧 `stopKind` 可删)。 - [ ] **Step 2: 改写依赖配对的测试** - `test_append_paired_product_rows_*`:若配对函数保留仅供内部/删除,则删除或改为测新 append helper。 - 品牌/别名用例改为走 `build_plan_archive_export_rows` 段次路径断言。 - [ ] **Step 3: 跑测确认 RED** ```bash cd backend && . venv/bin/activate && PYTHONPATH=. pytest tests/test_pl_plan_archive_export.py -v --tb=line ``` --- ### Task 3: 归档导出 — 实现段次列与行构建 **Files:** - Modify: `backend/app/api/pl/plan.py`(`PLAN_ARCHIVE_EXPORT_COLUMNS`、`ARCHIVE_PRICE_EXPORT_FIELDS`) - Modify: `backend/app/services/pl_plan_archive_export.py` - [ ] **Step 1: 替换 `PLAN_ARCHIVE_EXPORT_COLUMNS`** ```python PLAN_ARCHIVE_EXPORT_COLUMNS = [ ExportColumn(title="序号", field="rowNo"), ExportColumn(title="计划编号", field="planNo"), ExportColumn(title="段次", field="stopSegment"), ExportColumn(title="合同编号", field="contractNo"), ExportColumn(title="计划日期", field="planDate"), ExportColumn(title="归档状态", field="archiveStatus"), ExportColumn(title="跟进状态", field="followStatus"), ExportColumn(title="单据类型", field="planType"), ExportColumn(title="装货客户", field="loadCustomer"), ExportColumn(title="装货地址", field="loadAddress"), ExportColumn(title="装货时间", field="loadTime"), ExportColumn(title="送货客户", field="deliveryCustomer"), ExportColumn(title="送货地址", field="deliveryAddress"), ExportColumn(title="送货时间", field="deliveryTime"), ExportColumn(title="产品名称", field="productName"), ExportColumn(title="品牌", field="brandName"), ExportColumn(title="别名", field="aliasName"), ExportColumn(title="型号/规格", field="model"), ExportColumn(title="包装", field="packageSpec"), ExportColumn(title="单位", field="unit", dict_type="crm_product_unit"), ExportColumn(title="计划量", field="planQty"), ExportColumn(title="实际量", field="actualQty"), ExportColumn(title="单价(元)", field="unitPrice"), ExportColumn(title="金额(元)", field="amount"), ExportColumn(title="车牌号", field="licensePlate"), ExportColumn(title="运费单位", field="freightType"), ExportColumn(title="运费单价", field="freightPrice"), ExportColumn(title="运费金额", field="freightAmount"), ExportColumn(title="ERP", field="erpEntered"), ExportColumn(title="备注", field="remark"), ] ``` `ARCHIVE_PRICE_EXPORT_FIELDS` 保留 `unitPrice`/`amount`/`freightPrice`/`freightAmount`;可移除已删除的 `loadUnitPrice` 等(过滤仍安全)。 - [ ] **Step 2: 重写 `build_plan_archive_export_rows` 默认路径** 伪逻辑: ```python from app.api.pl.plan import _iter_segment_product_slots, _format_stop_segment_label # 避免循环 import:把 _iter_segment_product_slots / _format_stop_segment_label # 抽到 app/services/pl/plan_list_export_segments.py 更干净(推荐本 Task 内抽取) ``` **推荐抽取**(若 `plan.py` ↔ `pl_plan_archive_export.py` 循环导入): - Create: `backend/app/services/pl/plan_list_export_segments.py` - `iter_segment_product_slots` - `format_stop_segment_label` - Update `plan.py` 改为从该模块 import(保持 `_iter_segment_product_slots` 别名或直接替换调用) - Archive/contract 从该模块 import 对每个 plan: 1. `plan_common = _plan_common_export_fields(...)` 2. 取 stops;legacy 无段 → 旧 `_append_legacy_plan_product_rows` 但补 `stopSegment=""`、`planQty`/`actualQty` 3. 有段:`slots = iter_segment_product_slots(load, deliveries)`;若 `stop_kind=="load"` 只保留 load;`=="delivery"` 只保留 delivery 4. 每槽位一行: - `stopSegment = format_stop_segment_label(kind, seq)` - 装货行:填 loadCustomer/Address/Time,delivery 侧空;送货行相反 - `contractNo` = 本段合同 label(`format_stop_contract_export_label`) - 产品 identity + `planQty`/`actualQty`/`unitPrice`/`amount` 用现有 `_get_stop_archive_*` 5. `stop_customer_keywords`:保留——装或送客户匹配即保留该行(按行侧客户过滤) 可删除或停用 `_append_paired_product_rows`(若测试不再引用)。 - [ ] **Step 3: 测试全绿** ```bash cd backend && . venv/bin/activate && PYTHONPATH=. pytest tests/test_pl_plan_archive_export.py tests/test_pl_plan_list_export.py -v --tb=line ``` - [ ] **Step 4: 前端归档导出列** `plan-archive/index.vue`:导出 columns 映射增加 `stopSegment`;若前端曾请求 `loadPlanQty` 等,改为 `planQty`/`actualQty`/`unitPrice`/`amount`(与列表 prop 一致)。找到构建 export columns 的 computed,对齐新 `PLAN_ARCHIVE_EXPORT_COLUMNS`。 --- ### Task 4: 合同完善导出 — 测试 + 实现 **Files:** - Create/Modify: `backend/tests/test_pl_plan_contract_gap_export.py`(或扩现有相关测) - Modify: `backend/app/api/pl/plan.py`(`CONTRACT_GAP_EXPORT_COLUMNS`、`export_contract_gaps`) - Modify: `web-admin/src/views/pl/plan-contract/index.vue` - [ ] **Step 1: 写失败测试** 对 `export_contract_gaps` 的行构建逻辑抽函数更易测,例如: ```python def build_contract_gap_export_rows(db, tenant_no, gap_items, stops_by_plan) -> list[dict]: ... ``` 测试:一计划,装货缺合同 + 两送货段有产品 → 多行;装货行 `gapType=="缺供方合同"`(或与现文案一致的短标签);送货行若无缺口则 `gapType==""`;`stopSegment` 正确。 缺口判定复用 `stop_contract_gap(customer_name, len(contracts))`(`pl_plan_contract_gap.py`)。 - [ ] **Step 2: 更新列定义** ```python CONTRACT_GAP_EXPORT_COLUMNS = [ ExportColumn(title="计划编号", field="planNo"), ExportColumn(title="段次", field="stopSegment"), ExportColumn(title="计划日期", field="planDate"), ExportColumn(title="送货时间", field="deliveryTimes"), ExportColumn(title="单据类型", field="planType"), ExportColumn(title="跟进状态", field="followStatus"), ExportColumn(title="装货客户", field="loadCustomer"), ExportColumn(title="送货客户", field="deliveryCustomer"), ExportColumn(title="产品名称", field="productName"), ExportColumn(title="供方", field="supplierName"), ExportColumn(title="需方", field="customerName"), ExportColumn(title="缺口类型", field="gapType"), ] ``` - [ ] **Step 3: 实现 `export_contract_gaps`** 1. 仍 `list_plan_contract_gaps(...)` 得 items(含 plan id) 2. `load_plan_stops_grouped` for those ids 3. 对每个 gap item / plan:展开 slots;填计划级字段 + 段字段 + 产品名 + `gapType` 4. followStatus/planType 中文标签保持现有 - [ ] **Step 4: 前端 `contractGapExportColumns`** ```typescript const contractGapExportColumns = [ 'planNo', 'stopSegment', 'planDate', 'deliveryTimes', 'planType', 'followStatus', 'loadCustomer', 'deliveryCustomer', 'productName', 'supplierName', 'customerName', 'gapType' ] ``` - [ ] **Step 5: 跑测** ```bash cd backend && . venv/bin/activate && PYTHONPATH=. pytest tests/test_pl_plan_contract_gap_export.py tests/test_pl_plan_list_export.py tests/test_pl_plan_archive_export.py -v --tb=line ``` --- ### Task 5: 收尾 - [ ] **Step 1: 全量相关测** ```bash cd backend && . venv/bin/activate && PYTHONPATH=. pytest tests/test_pl_plan_list_export.py tests/test_pl_plan_archive_export.py tests/test_pl_plan_contract_gap_export.py -v ``` - [ ] **Step 2: Spec 对照** E1–E6 均有落点;更新 graphify(改过的 backend/web-admin) - [ ] **Step 3: 验收清单(人工)** - 证件 / 归档 / 合同完善 / 出库单各导出一次一装多送计划 --- ## Self-review 1. Spec E1–E6 均有 Task。 2. 循环 import 风险已用「抽取 `plan_list_export_segments.py`」处理。 3. 价格字段过滤与 zip 同源行模型已写明。 4. Commit 步骤省略(用户未要求不提交)。