Files
shanxia2.0/docs/superpowers/plans/2026-10-15-plan-list-segment-export.md
T
2026-10-19 18:28:02 +08:00

18 KiB
Raw Blame History

运输计划 / 库存列表导出 · 段次×产品行 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: 实现展开逻辑(最小可用)

核心行为:

  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

参考片段(伪代码):

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: 手动验收要点
  1. 打开运输计划列表、入库单、出库单,各导出一次(筛选含一装多送计划更佳)。
  2. Excel:计划编号后有「段次」;一装多送多行;装货行送货客户空、送货行装货客户空;计划量为本段。
  3. 文件名仍为 入库单_*.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)

  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: 标为「用户要求时」以符合仓库「未明确要求不提交」规则。