18 KiB
运输计划 / 库存列表导出 · 段次×产品行 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 模式):
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: 运行确认失败
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 用例
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: 运行整文件确认新用例失败、旧用例仍绿或一并待修
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后插入段次
PLAN_EXPORT_COLUMNS = [
ExportColumn(title="计划编号", field="planNo"),
ExportColumn(title="段次", field="stopSegment"),
ExportColumn(title="归属公司", field="belongsCompany"),
# …其余保持不变…
]
- Step 2: 将整单拼接的
_apply_plan_list_export_stop_fields改为按单侧填写
替换为(或新增并改调用方):
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(若用户要求提交再执行;否则跳过本步)
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: 实现展开逻辑(最小可用)
核心行为:
- 构建计划公共
data(与现有相同:PlanWithProductsdump、标签、车牌、ERP 是/否)。 load_read, delivery_reads = stops_by_plan.get(plan.id, (None, []))。- 若
load_read is None and not delivery_reads:走 legacy——查products_by_plan,按计划级产品展开;每行stopSegment="";planQty/actualQty用计划级quantity/actual_qty;stop 侧字段清空或留空。 - 否则(有段):
- 批量加载计划级产品,仅用于品牌 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
- 批量加载计划级产品,仅用于品牌 lookup(
参考片段(伪代码):
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: 跑测试至全绿
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: 归档导出回归(确认未误伤)
cd backend && source venv/bin/activate && pytest tests/test_pl_plan_archive_export.py -v --tb=line
Expected: PASS
- Step 4: Commit(用户要求时)
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
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: 手动验收要点
- 打开运输计划列表、入库单、出库单,各导出一次(筛选含一装多送计划更佳)。
- Excel:计划编号后有「段次」;一装多送多行;装货行送货客户空、送货行装货客户空;计划量为本段。
- 文件名仍为
入库单_*.xlsx/出库单_*.xlsx/计划列表_*.xlsx。
- Step 3: Commit(用户要求时)
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: 最终测试
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)
- Spec coverage: D1–D7、占位、legacy、实际量语义、前端注入均有 Task。
- No placeholders: 测试与 helper 给出可粘贴代码;legacy/占位在 Task 1 Step 3 要求写成完整断言。
- Types: field 统一
stopSegment;实际量装货inbound_qty、送货outbound_qty。 - Commits: 标为「用户要求时」以符合仓库「未明确要求不提交」规则。