# 运输计划 / 库存列表导出 · 段次×产品行 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:** 让运输计划列表与库存入库/出库导出的 Excel 行模型对齐列表展平(先装货产品行,再各送货段产品行),每行带「段次」与本段计划量/实际量。 **Architecture:** 仍走现有 plan 导出 API;改造 `_build_plan_list_export_rows`,按 `stops_by_plan` 段×产品展开(对齐前端 `flattenPlanArchivePairRows`)。复用归档侧已有语义:`_stop_kind_label` / `_get_stop_archive_actual_qty`(或同名内联)。不改归档配对宽表。 **Tech Stack:** Python / FastAPI、pytest、Vue 3(`plan/index.vue` 导出列) **Spec:** `docs/superpowers/specs/2026-10-15-plan-list-segment-export-design.md` --- ## File map | 文件 | 职责 | |------|------| | `backend/tests/test_pl_plan_list_export.py` | 段次展开、占位、legacy、列过滤用例 | | `backend/app/api/pl/plan.py` | `PLAN_EXPORT_COLUMNS` + `_build_plan_list_export_rows` + stop 字段 helper | | `web-admin/src/views/pl/plan/index.vue` | `planExportColumns` 固定注入 `stopSegment`(库存页复用同一组件) | 可选(若 `plan.py` 内联过长):`backend/app/services/pl/plan_list_export_rows.py` 抽出展开逻辑;默认优先内联以最小改动为准。 --- ### Task 1: 失败测试 — 一装多送段次展开 **Files:** - Modify: `backend/tests/test_pl_plan_list_export.py` - [ ] **Step 1: 追加一装多送用例(期望当前失败)** 在 `test_pl_plan_list_export.py` 末尾追加(可复用文件内已有 patch 模式): ```python def test_build_plan_list_export_rows_expands_stop_segments(): """一装多送:先装货产品行,再各送货段产品行;侧栏字段按行填写。""" plan = SimpleNamespace( id="p-multi", plan_no="PO20261009016", follow_status="arranged", plan_date="2026-10-11", plan_type="inbound", supplier_name="供方", customer_name="需方", license_plate="皖A12345", trailer_no=None, driver_name="司机", transport_status="arranged", archive_status="unarchived", erp_entered=0, remark="", products=[], ) db = MagicMock() query = MagicMock() db.query.return_value = query query.filter.return_value = query query.order_by.return_value = query # 有段结构时不应依赖计划级产品展开;返回空列表即可 query.all.return_value = [] load_product = SimpleNamespace( id="lp1", plan_product_id="pp1", product_id="prod1", product_name="尿素别名", model="工业级", package_spec="吨袋", unit="ton", plan_qty=30, inbound_qty=29, outbound_qty=None, brand_mode=None, brand_name=None, ) del1_product = SimpleNamespace( id="dp1", plan_product_id="pp1", product_id="prod1", product_name="尿素别名", model="工业级", package_spec="吨袋", unit="ton", plan_qty=20, inbound_qty=None, outbound_qty=19, brand_mode=None, brand_name=None, ) del2_product = SimpleNamespace( id="dp2", plan_product_id="pp1", product_id="prod1", product_name="尿素别名", model="工业级", package_spec="吨袋", unit="ton", plan_qty=10, inbound_qty=None, outbound_qty=9, brand_mode=None, brand_name=None, ) load_stop = SimpleNamespace( customer_name="装货客户A", address="装货路1号", load_time="2026-10-11", generate_dibang=1, dibang_direction="outbound", contracts=[SimpleNamespace(contract_no="HT-L")], products=[load_product], ) delivery1 = SimpleNamespace( seq=1, customer_name="送货客户B1", address="送货路B1", delivery_time="2026-10-12", generate_dibang=1, dibang_direction="inbound", contracts=[SimpleNamespace(contract_no="HT-B1")], products=[del1_product], ) delivery2 = SimpleNamespace( seq=2, customer_name="送货客户B2", address="送货路B2", delivery_time="2026-10-13", generate_dibang=0, dibang_direction=None, contracts=[SimpleNamespace(contract_no="HT-B2")], products=[del2_product], ) fake_dump = { "planNo": "PO20261009016", "belongsCompany": "shanxia", "followStatus": "arranged", "planDate": "2026-10-11", "planType": "inbound", "supplierName": "供方", "customerName": "需方", "licensePlate": "皖A12345", "trailerNo": None, "personnelSummary": "司机张三", "followUserName": "跟进人李四", "driverName": "司机", "transportStatus": "arranged", "archiveStatus": "unarchived", "erpEntered": 0, "remark": "", "products": [], } with patch( "app.api.pl.plan.PlanWithProducts.model_validate", return_value=MagicMock(model_dump=MagicMock(return_value=dict(fake_dump))), ), patch( "app.api.pl.plan.load_crm_product_alias_maps", return_value=({"prod1": "尿素"}, {"prod1": ["尿素别名"]}), ): rows = _build_plan_list_export_rows( db, tenant_no="t1", plans=[plan], stops_by_plan={"p-multi": (load_stop, [delivery1, delivery2])}, ) assert len(rows) == 3 assert [r["stopSegment"] for r in rows] == ["装货", "送货 #1", "送货 #2"] assert [r["planQty"] for r in rows] == [30, 20, 10] assert [r["actualQty"] for r in rows] == [29, 19, 9] assert rows[0]["loadCustomer"] == "装货客户A" assert rows[0]["deliveryCustomer"] == "" assert rows[0]["productContract"] == "HT-L" assert rows[1]["loadCustomer"] == "" assert rows[1]["deliveryCustomer"] == "送货客户B1" assert rows[1]["deliveryTimes"] == "2026-10-12" assert rows[1]["productContract"] == "HT-B1" assert rows[2]["deliveryCustomer"] == "送货客户B2" assert rows[2]["deliveryDibang"] == "" assert rows[0]["productName"] == "尿素" ``` - [ ] **Step 2: 运行确认失败** ```bash cd backend && source venv/bin/activate && pytest tests/test_pl_plan_list_export.py::test_build_plan_list_export_rows_expands_stop_segments -v ``` Expected: FAIL(当前按计划级产品展开,行数/字段不符;或无 `stopSegment`) - [ ] **Step 3: 追加空产品占位与 legacy 用例** ```python def test_build_plan_list_export_rows_empty_delivery_products_placeholder(): plan = SimpleNamespace(id="p-empty", plan_no="PL-E", products=[]) # …同模式 fake_dump / patches… load_stop = SimpleNamespace( customer_name="装A", address="L", load_time="2026-10-11", generate_dibang=0, dibang_direction=None, contracts=[], products=[ SimpleNamespace( id="lp", plan_product_id=None, product_id="prod1", product_name="尿素", model="", package_spec="", unit="ton", plan_qty=5, inbound_qty=None, outbound_qty=None, ) ], ) delivery = SimpleNamespace( seq=1, customer_name="送B", address="D", delivery_time="2026-10-12", generate_dibang=0, dibang_direction=None, contracts=[], products=[], # 空 → 占位一行 ) # stops_by_plan={"p-empty": (load_stop, [delivery])} # assert len(rows)==2; rows[1]["stopSegment"]=="送货 #1"; rows[1]["productName"]==""; rows[1]["planQty"] is None def test_build_plan_list_export_rows_legacy_without_stops(): """无段结构:回退计划级产品;stopSegment 为空。""" # 复用现有 test_build_plan_list_export_rows_expands_products_with_crm_names 的产品 mock # stops_by_plan={"p1": (None, [])} 或缺省 # assert rows[0]["stopSegment"] == "" # assert rows[0]["planQty"] == 10 # 计划级 quantity ``` (实现时把占位/legacy 写成完整可运行断言,勿留注释占位。) - [ ] **Step 4: 运行整文件确认新用例失败、旧用例仍绿或一并待修** ```bash cd backend && source venv/bin/activate && pytest tests/test_pl_plan_list_export.py -v ``` --- ### Task 2: 实现列定义与按段 stop 字段 helper **Files:** - Modify: `backend/app/api/pl/plan.py`(约 `PLAN_EXPORT_COLUMNS`、`_apply_plan_list_export_stop_fields`) - [ ] **Step 1: `PLAN_EXPORT_COLUMNS` 在 `planNo` 后插入段次** ```python PLAN_EXPORT_COLUMNS = [ ExportColumn(title="计划编号", field="planNo"), ExportColumn(title="段次", field="stopSegment"), ExportColumn(title="归属公司", field="belongsCompany"), # …其余保持不变… ] ``` - [ ] **Step 2: 将整单拼接的 `_apply_plan_list_export_stop_fields` 改为按单侧填写** 替换为(或新增并改调用方): ```python def _format_stop_segment_label(kind: str, delivery_seq: Optional[int] = None) -> str: if kind == "load": return "装货" return f"送货 #{delivery_seq or 1}" def _apply_plan_list_export_segment_stop_fields( data: dict, *, kind: str, load_read: Any = None, delivery_read: Any = None, delivery_seq: Optional[int] = None, ) -> None: """按当前导出行所属段填写装/送侧字段;对侧留空。""" data["stopSegment"] = _format_stop_segment_label(kind, delivery_seq) data["loadCustomer"] = "" data["deliveryCustomer"] = "" data["loadAddressTime"] = "" data["deliveryAddressTime"] = "" data["deliveryTimes"] = "" data["productContract"] = "" data["loadDibang"] = "" data["deliveryDibang"] = "" if kind == "load" and load_read is not None: data["loadCustomer"] = (getattr(load_read, "customer_name", None) or "").strip() data["loadAddressTime"] = _format_stop_address_time( getattr(load_read, "address", None), getattr(load_read, "load_time", None), ) data["loadDibang"] = _format_stop_dibang_label(load_read) data["productContract"] = " / ".join(_collect_stop_contract_nos([load_read])) return if kind == "delivery" and delivery_read is not None: data["deliveryCustomer"] = ( getattr(delivery_read, "customer_name", None) or "" ).strip() data["deliveryAddressTime"] = _format_stop_address_time( getattr(delivery_read, "address", None), getattr(delivery_read, "delivery_time", None), ) data["deliveryTimes"] = _format_export_date_text( getattr(delivery_read, "delivery_time", None) ) data["deliveryDibang"] = _format_stop_dibang_label(delivery_read) data["productContract"] = " / ".join( _collect_stop_contract_nos([delivery_read]) ) ``` 可删除或保留旧 `_apply_plan_list_export_stop_fields`(若无引用则删除,避免死代码)。 - [ ] **Step 3: Commit(若用户要求提交再执行;否则跳过本步)** ```bash git add backend/app/api/pl/plan.py backend/tests/test_pl_plan_list_export.py git commit -m "$(cat <<'EOF' feat(pl): add stopSegment column helpers for list export Prepare segment-scoped stop fields for plan/stock list Excel export. EOF )" ``` --- ### Task 3: 重写 `_build_plan_list_export_rows` 段次展开 **Files:** - Modify: `backend/app/api/pl/plan.py`(`_build_plan_list_export_rows`) - [ ] **Step 1: 实现展开逻辑(最小可用)** 核心行为: 1. 构建计划公共 `data`(与现有相同:`PlanWithProducts` dump、标签、车牌、ERP 是/否)。 2. `load_read, delivery_reads = stops_by_plan.get(plan.id, (None, []))`。 3. **若** `load_read is None and not delivery_reads`:走 **legacy**——查 `products_by_plan`,按计划级产品展开;每行 `stopSegment=""`;`planQty`/`actualQty` 用计划级 `quantity`/`actual_qty`;stop 侧字段清空或留空。 4. **否则**(有段): - 批量加载计划级产品,仅用于品牌 lookup(`plan_product_id` / `product_id` → `brand_mode`/`brand_name`),对齐归档 `_lookup_plan_product` + `_format_plan_list_export_brand`。 - CRM 别名:收集段产品 `product_id` 调用 `load_crm_product_alias_maps`。 - 装货:`products = list(load_read.products or []) or [None]`(空则占位);每项一行,`kind=load`。 - 送货:若 `delivery_reads` 为空,视为 `[None]` 占位一段(与列表 `deliveries.length ? … : [null]` 一致);否则按顺序;每段 `products or [None]`;`delivery_seq = delivery.seq or index+1`。 - 每行: - `row = dict(data)` - `_apply_plan_list_export_segment_stop_fields(...)` - 若 `product is None`:产品列空,`planQty`/`actualQty` = `None` - 否则:`resolve_product_display_names`;`planQty = product.plan_qty`;`actualQty = product.inbound_qty if kind=="load" else product.outbound_qty`(对齐列表 `getRowActualQty`) - 品牌:经 plan product lookup 后 `_format_plan_list_export_brand` 参考片段(伪代码): ```python def _iter_segment_product_slots(load_read, delivery_reads): if load_read is not None: load_products = list(getattr(load_read, "products", None) or []) or [None] for product in load_products: yield ("load", load_read, None, None, product) deliveries = list(delivery_reads or []) or ([None] if load_read is not None else []) for index, delivery in enumerate(deliveries): seq = getattr(delivery, "seq", None) if delivery is not None else (index + 1) if seq is None: seq = index + 1 products = list(getattr(delivery, "products", None) or []) if delivery else [] if not products: products = [None] for product in products: yield ("delivery", None, delivery, seq, product) ``` 注意:仅有送货无装货时仍展开送货段;仅有装货无送货时,`deliveries = [None]` 出送货占位行(与列表一致)。 - [ ] **Step 2: 跑测试至全绿** ```bash cd backend && source venv/bin/activate && pytest tests/test_pl_plan_list_export.py -v ``` Expected: PASS(含旧用例;旧用例 `stops_by_plan` 有单装单送时需改为段展开——**更新** `test_build_plan_list_export_rows_expands_products_with_crm_names`: - 给 `load_stop` / `delivery_stop` 挂上 `products=[product_as_stop]`(字段用 `plan_qty`/`inbound_qty`/`outbound_qty`) - 断言 `stopSegment`、装货行/送货行侧栏空非空 - 或拆成「有段单装单送」+ 保留 legacy 断言 - [ ] **Step 3: 归档导出回归(确认未误伤)** ```bash cd backend && source venv/bin/activate && pytest tests/test_pl_plan_archive_export.py -v --tb=line ``` Expected: PASS - [ ] **Step 4: Commit(用户要求时)** ```bash git commit -m "$(cat <<'EOF' feat(pl): expand plan list export by stop segment and product Align Excel rows with flattenPlanArchivePairRows for stock and plan lists. EOF )" ``` --- ### Task 4: 前端导出列注入「段次」 **Files:** - Modify: `web-admin/src/views/pl/plan/index.vue`(`planExportColumns`) 说明:列表无「段次」可见列;导出按可见列过滤时会被丢掉。固定在导出列中注入,保证入库/出库/计划列表 Excel 都有该列。 - [ ] **Step 1: 修改 `planExportColumns`** ```typescript const planExportColumns = computed(() => { const skip = new Set(['arrangeCopy', 'planAttachments']) const cols = [...visibleColumns.value] .filter((col) => !skip.has(col)) .map((col) => PLAN_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: 手动验收要点** 1. 打开运输计划列表、入库单、出库单,各导出一次(筛选含一装多送计划更佳)。 2. Excel:计划编号后有「段次」;一装多送多行;装货行送货客户空、送货行装货客户空;计划量为本段。 3. 文件名仍为 `入库单_*.xlsx` / `出库单_*.xlsx` / `计划列表_*.xlsx`。 - [ ] **Step 3: Commit(用户要求时)** ```bash git commit -m "$(cat <<'EOF' feat(web-admin): always include stopSegment in plan list export columns Ensure stock inbound/outbound and plan list Excel show segment labels. EOF )" ``` --- ### Task 5: 收尾核对 - [ ] **Step 1: 对照 spec 清单** | Spec 项 | 对应 Task | |---------|-----------| | D1 段次×产品多行 | Task 3 | | D2 三入口共用 | 同一 API + Task 4 | | D3 段次列文案 | Task 2–3 | | D4 侧栏按行、不拼接 | Task 2–3 | | D5 计划字段重复 | Task 3 | | D6 归档不改 | Task 3 回归 | | D7 文件名不变 | 已有逻辑 + 旧测 | | 空产品占位 / legacy | Task 1+3 | | 实际量 inbound/outbound | Task 3 | - [ ] **Step 2: 最终测试** ```bash cd backend && source venv/bin/activate && pytest tests/test_pl_plan_list_export.py tests/test_pl_plan_archive_export.py -v ``` Expected: 全部 PASS --- ## Self-review (plan author) 1. **Spec coverage:** D1–D7、占位、legacy、实际量语义、前端注入均有 Task。 2. **No placeholders:** 测试与 helper 给出可粘贴代码;legacy/占位在 Task 1 Step 3 要求写成完整断言。 3. **Types:** field 统一 `stopSegment`;实际量装货 `inbound_qty`、送货 `outbound_qty`。 4. **Commits:** 标为「用户要求时」以符合仓库「未明确要求不提交」规则。