Files
shanxia2.0/docs/superpowers/plans/2026-09-06-plan-arrange-upsert-cascade.md
T

20 KiB
Raw Blame History

运输计划调整 Upsert 级联 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.

二期:已由 2026-09-05 计划覆盖,跳过(段/产品稳定 ID upsert 已落地:replace_plan_stops + resolve_plan_product_id_for_upsert + 前端回传 id;相关单测通过)。

Goal: 已安排运输计划调整时,地磅出入库/过磅单业务字段与月计划按「有 id 更新、无 id 创建」原地级联;出入库 id 不变;称重数字不动。

Architecture: 在 pl_dibang_bridge_service._update_existing_stop_inventory 去掉「已过磅则跳过正文」;统一回写出入库正文,并同步关联过磅单正文;产品/往来变更时对月计划 find-or-create 改挂。段稳定 ID(二期)复用既有计划 2026-09-05-pl-stop-stable-id-reuse-links.md,本计划不重复造轮子。

Tech Stack: FastAPI · SQLAlchemy · Pydantic v2 · pytest

Spec: docs/superpowers/specs/2026-09-06-plan-arrange-upsert-cascade-design.md

注意: 工作区可能有无关未提交改动。只改本计划列出的文件,提交时勿夹带无关 diff。生产部署后必须重启 backend,否则仍跑旧 revoke 逻辑。


文件结构

操作 文件 职责
Modify backend/app/services/pl_dibang_bridge_service.py 强制正文同步、过磅单正文、月计划改挂
Modify backend/tests/test_pl_dibang_upsert_on_arrange.py 已过磅改车牌/产品、月计划改挂用例
Verify backend/app/api/pl/transport.py 确认安排路径无 revoke_regeneratable
Follow docs/superpowers/plans/2026-09-05-pl-stop-stable-id-reuse-links.md 二期:段/产品稳定 id(若未完成则续做)

Task 1: 失败测试 — 已过磅时仍同步出入库车牌与过磅单正文

Files:

  • Modify: backend/tests/test_pl_dibang_upsert_on_arrange.py

  • Test: 同文件

  • Step 1: 追加失败测试

在 backend/tests/test_pl_dibang_upsert_on_arrange.py 文件末尾追加:

from app.models.db_models import DbWeighingSlip


@patch("app.services.pl_dibang_bridge_service._inventory_has_weighing_slip", return_value=True)
@patch("app.services.pl_dibang_bridge_service.resolve_crm_product")
def test_upsert_updates_inventory_and_weighing_plate_when_already_weighed(mock_crm, _mock_weighing):
    """已过磅:仍强制回写出入库与过磅单车牌;称重不动;出入库 id 不变。"""
    mock_crm.return_value = MagicMock(
        product_id="prod-1",
        product_name="硫酸",
        specification="98%",
    )
    plan = PlPlan(
        id="plan-1",
        plan_no="PO1",
        plan_type="outbound",
        plan_date=date(2026, 9, 1),
        pickup_time=datetime(2026, 9, 1, 0, 0),
        license_plate="皖HA7429",
        tenant_no="t1",
    )
    transport = PlTransport(
        id="tr-1", tenant_no="t1", license_plate="皖HA7429", del_flag=0
    )
    load = PlPlanLoad(
        id="load-1",
        plan_id="plan-1",
        customer_name="山峡",
        generate_dibang=1,
        dibang_direction="outbound",
        load_time=datetime(2026, 9, 1, 0, 0),
        tenant_no="t1",
        del_flag=0,
    )
    delivery = PlPlanDelivery(
        id="del-1",
        plan_id="plan-1",
        customer_name="江峰",
        seq=1,
        generate_dibang=0,
        tenant_no="t1",
        del_flag=0,
    )
    stop_product = PlPlanLoadProduct(
        id="lp-1",
        load_id="load-1",
        plan_product_id="pp-1",
        product_id="prod-1",
        product_name="硫酸",
        model="98%",
        unit="吨",
        plan_qty=Decimal("22"),
        tenant_no="t1",
        del_flag=0,
        order_num=1,
    )
    inventory = DbInboundOutbound(
        id="2861",
        tenant_no="t1",
        date=date(2026, 9, 6),
        partner_company="江峰",
        business_type="出库",
        product_id="prod-1",
        product="硫酸",
        specification="98%",
        quantity=22.0,
        batch_no="CRKDH0002488",
        vehicle_no="皖H28926",
        status="待出库",
        purchase_plan_id="dbp-1",
        plan_no="SX-1",
        del_flag=0,
    )
    slip = DbWeighingSlip(
        id="ws-1",
        tenant_no="t1",
        vehicle_no="皖H28926",
        plan_no="SX-1",
        cargo_name="硫酸",
        specification="98%",
        business_type="出库",
        batch_no="CRKDH0002488",
        supplier="",
        consignee="江峰",
        gross_weight=0.0,
        tare_weight=18730.0,
        net_weight=-18730.0,
        inbound_outbound_id="2861",
        purchase_plan_id="dbp-1",
        is_voided=0,
        del_flag=0,
    )
    link = PlTransportDbLink(
        id="link-1",
        tenant_no="t1",
        plan_id="plan-1",
        transport_id="tr-1",
        plan_product_id="pp-1",
        stop_type="load",
        stop_id="load-1",
        db_purchase_plan_id="dbp-1",
        db_inventory_id="2861",
        contributed_qty=Decimal("22"),
        del_flag=0,
    )
    db = MagicMock()

    def link_find_q(m):
        filtered = MagicMock()
        filtered.first.return_value = link
        m.filter.return_value = filtered
        return m

    def load_q(m):
        m.filter.return_value.first.return_value = load
        return m

    def delivery_q(m):
        m.filter.return_value.order_by.return_value.all.return_value = [delivery]
        return m

    def load_prod_q(m):
        m.filter.return_value.order_by.return_value.all.return_value = [stop_product]
        return m

    def delivery_prod_q(m):
        m.filter.return_value.order_by.return_value.all.return_value = []
        return m

    def inv_q(m):
        m.filter.return_value.first.return_value = inventory
        return m

    def slip_q(m):
        filtered = MagicMock()
        filtered.all.return_value = [slip]
        m.filter.return_value = filtered
        return m

    def db_plan_q(m):
        filtered = MagicMock()
        filtered.first.return_value = None
        m.filter.return_value = filtered
        return m

    db.query.side_effect = _make_query_router(
        {
            PlTransportDbLink: link_find_q,
            PlPlanLoad: load_q,
            PlPlanDelivery: delivery_q,
            PlPlanLoadProduct: load_prod_q,
            PlPlanDeliveryProduct: delivery_prod_q,
            DbInboundOutbound: inv_q,
            DbWeighingSlip: slip_q,
            DbPurchasePlan: db_plan_q,
        }
    )

    results, warnings = upsert_stop_dibang_links(
        db,
        tenant_no="t1",
        plan=plan,
        transport=transport,
        stop_options=[
            StopDibangOption(stop_type="load", stop_id="load-1", generate_dibang=True),
            StopDibangOption(stop_type="delivery", stop_id="del-1", generate_dibang=False),
        ],
        user_id="u1",
        dept_id="d1",
    )

    assert results and results[0].db_inventory_id == "2861"
    assert inventory.vehicle_no == "皖HA7429"
    assert slip.vehicle_no == "皖HA7429"
    assert slip.tare_weight == 18730.0
    assert slip.net_weight == -18730.0
    assert not any("未同步" in w for w in warnings)
  • Step 2: 运行测试确认失败

Run:

cd backend && source venv/bin/activate && pytest tests/test_pl_dibang_upsert_on_arrange.py::test_upsert_updates_inventory_and_weighing_plate_when_already_weighed -v

Expected: FAIL(当前已过磅会走「未同步…」分支,车牌仍为 皖H28926,或断言失败)

  • Step 3: Commit 测试
git add backend/tests/test_pl_dibang_upsert_on_arrange.py
git commit -m "test(pl): expect dibang plate sync even after weighing"

Task 2: 实现 — 已过磅仍强制同步出入库正文 + 过磅单正文

Files:

  • Modify: backend/app/services/pl_dibang_bridge_service.py

  • Step 1: 新增过磅单正文同步辅助函数

在 pl_dibang_bridge_service.py 中 _inventory_has_weighing_slip 附近增加:

def _sync_weighing_slip_business_fields(
    db: Session,
    *,
    inventory: DbInboundOutbound,
    user_id: Optional[str],
) -> None:
    """按出入库正文回写关联过磅单业务字段;称重数字一律不动。"""
    slips = (
        db.query(DbWeighingSlip)
        .filter(
            DbWeighingSlip.inbound_outbound_id == inventory.id,
            DbWeighingSlip.del_flag == 0,
            DbWeighingSlip.is_voided == 0,
        )
        .all()
    )
    if not slips:
        return

    partner = (inventory.partner_company or "").strip()
    business_type = (inventory.business_type or "").strip()
    for slip in slips:
        plate = (inventory.vehicle_no or "").strip()
        if plate:
            slip.vehicle_no = plate
        if inventory.product_id:
            slip.product_id = inventory.product_id
        if inventory.product:
            slip.cargo_name = inventory.product
        slip.specification = inventory.specification
        if business_type:
            slip.business_type = business_type
        if inventory.plan_no:
            slip.plan_no = inventory.plan_no
        if inventory.batch_no:
            slip.batch_no = inventory.batch_no
        if inventory.purchase_plan_id:
            slip.purchase_plan_id = inventory.purchase_plan_id
        if partner:
            if business_type == "入库":
                slip.supplier = partner
            else:
                slip.consignee = partner
        slip.update_by = user_id

确保文件顶部已 import DbWeighingSlip(若已有则不动)。

  • Step 2: 改写 _update_existing_stop_inventory — 去掉「不可编辑则跳过正文」

删除(或绕过)这段逻辑:

editable = _inventory_mutable_editable(db, inventory)
if not editable:
    warnings.append(...未同步...)
    return DibangInventoryLinkResult(...)

改为:始终执行正文更新(车牌/往来/数量/日期),然后调用 _sync_weighing_slip_business_fields。

最小结构:

def _update_existing_stop_inventory(...):
    label = inventory.batch_no or inventory.id

    # 1) 往来
    if partner and (inventory.partner_company or "").strip() != partner:
        inventory.partner_company = partner

    # 2) 车牌
    plate = (license_plate or "").strip()
    if plate and (inventory.vehicle_no or "").strip() != plate:
        inventory.vehicle_no = plate

    # 3) 产品快照(允许换产品;月计划改挂见 Task 3)
    #    先用 resolve_crm_product + apply_product_snapshot_to_inventory
    #    若 product.product_id 有值且与 inventory 不同,先记录 old 用于 Task 3 改挂

    # 4) 数量 / 日期:保留现有 delta 与跨月改挂逻辑

    desired_remark = f"运输计划 {plan.plan_no}"
    inventory.remark = desired_remark
    link.remark = desired_remark
    inventory.update_by = user_id
    link.update_by = user_id

    _sync_weighing_slip_business_fields(db, inventory=inventory, user_id=user_id)

    return DibangInventoryLinkResult(...)

注意: 删除「产品变更直接 raise ValueError」硬阻断(与 D6 冲突)。产品变更改挂放到 Task 3。

  • Step 3: 运行 Task 1 测试
cd backend && source venv/bin/activate && pytest tests/test_pl_dibang_upsert_on_arrange.py::test_upsert_updates_inventory_and_weighing_plate_when_already_weighed -v

Expected: PASS

  • Step 4: 跑既有 upsert 套件
cd backend && source venv/bin/activate && pytest tests/test_pl_dibang_upsert_on_arrange.py -v

Expected: 全部 PASS(含「取消勾选已过磅仍阻断」)

  • Step 5: Commit
git add backend/app/services/pl_dibang_bridge_service.py backend/tests/test_pl_dibang_upsert_on_arrange.py
git commit -m "fix(pl): sync dibang inventory and weighing fields after rearrange"

Task 3: 失败测试 + 实现 — 产品/往来变更时月计划利旧或新建并改挂

Files:

  • Modify: backend/app/services/pl_dibang_bridge_service.py

  • Modify: backend/tests/test_pl_dibang_upsert_on_arrange.py

  • Step 1: 写失败测试

@patch("app.services.pl_dibang_bridge_service._inventory_has_weighing_slip", return_value=False)
@patch("app.services.pl_dibang_bridge_service._create_db_plan")
@patch("app.services.pl_dibang_bridge_service.find_monthly_db_plan")
@patch("app.services.pl_dibang_bridge_service.resolve_crm_product")
def test_upsert_remounts_monthly_plan_when_partner_changes(
    mock_crm, mock_find_plan, mock_create_plan, _mock_weighing
):
    """往来变更:出入库 id 不变;purchase_plan_id 改挂到利旧/新建月计划。"""
    mock_crm.return_value = MagicMock(
        product_id="prod-1", product_name="硫酸", specification="98%"
    )
    old_plan = DbPurchasePlan(
        id="dbp-old",
        tenant_no="t1",
        plan_no="SX-OLD",
        planned_quantity=22.0,
        customer_name="旧往来",
        product_id="prod-1",
        del_flag=0,
    )
    new_plan = DbPurchasePlan(
        id="dbp-new",
        tenant_no="t1",
        plan_no="SX-NEW",
        planned_quantity=0.0,
        customer_name="新往来",
        product_id="prod-1",
        del_flag=0,
    )
    mock_find_plan.return_value = new_plan

    # ... 构造 plan/transport/load/delivery/stop_product/inventory/link
    # inventory.partner_company = "旧往来"
    # inventory.purchase_plan_id = "dbp-old"
    # partner 解析结果为「新往来」(装货段用送货客户等,按现有 resolve_stop_dibang_partner)
    # upsert_stop_dibang_links(... generate_dibang=True ...)

    assert inventory.id == "inv-1"
    assert inventory.partner_company == "新往来"
    assert inventory.purchase_plan_id == "dbp-new"
    assert link.db_purchase_plan_id == "dbp-new"

(实现时按文件内既有 mock 风格补全 router;mock_create_plan 不应被调用当 find 命中。)

  • Step 2: 跑测试确认失败
cd backend && source venv/bin/activate && pytest tests/test_pl_dibang_upsert_on_arrange.py::test_upsert_remounts_monthly_plan_when_partner_changes -v

Expected: FAIL

  • Step 3: 在 _update_existing_stop_inventory 中实现同月产品/往来改挂

在更新 partner_company / 产品快照之后:

need_remount = False
# partner 已变 或 product_id/specification 已变 → need_remount = True

if need_remount:
    qty = _resolve_link_contributed_qty(link, inventory)
    _subtract_plan_contribution(
        db, tenant_no=tenant_no, link=link, inventory=inventory, user_id=user_id
    )
    new_db_plan = _ensure_monthly_db_plan_for_inventory(
        db,
        tenant_no=tenant_no,
        plan=plan,
        inventory=inventory,
        dibang_date=_normalize_to_date(inventory.date) or dibang_date,
        contributed_qty=qty,
        user_id=user_id,
        dept_id=dept_id or "",
    )
    link.db_purchase_plan_id = new_db_plan.id
    inventory.purchase_plan_id = new_db_plan.id
    inventory.plan_no = (new_db_plan.plan_no or "").strip() or inventory.plan_no
elif plate:
    # 仅车牌:回写当前月计划 vehicle_no(若存在)
    if link.db_purchase_plan_id:
        db_plan = (
            db.query(DbPurchasePlan)
            .filter(
                DbPurchasePlan.id == link.db_purchase_plan_id,
                DbPurchasePlan.tenant_no == tenant_no,
                DbPurchasePlan.del_flag == 0,
            )
            .first()
        )
        if db_plan and (db_plan.vehicle_no or "").strip() != plate:
            db_plan.vehicle_no = plate
            db_plan.update_by = user_id

然后再次 _sync_weighing_slip_business_fields(确保过磅单 purchase_plan_id/plan_no 跟上)。

复用已有 _ensure_monthly_db_plan_for_inventory / find_monthly_db_plan(内部已是利旧或 _create_db_plan)。

  • Step 4: 跑测试
cd backend && source venv/bin/activate && pytest tests/test_pl_dibang_upsert_on_arrange.py -v

Expected: PASS

  • Step 5: Commit
git add backend/app/services/pl_dibang_bridge_service.py backend/tests/test_pl_dibang_upsert_on_arrange.py
git commit -m "feat(pl): remount dibang monthly plan when partner or product changes"

Task 4: 校验安排入口无整批 revoke + 冒烟

Files:

  • Verify: backend/app/api/pl/transport.py

  • Test: backend/tests/test_pl_dibang_upsert_on_arrange.py

  • Step 1: 确认代码

在 transport.py 的 arrange_transport 中确认:

  • 存在注释「地磅关联改为按段 upsert,不再在调整前整批 revoke 再建」
  • 没有对 revoke_regeneratable_dibang_for_transport 的调用

若仍有 revoke 调用:删除该分支,改为仅 sync_dibang_for_plan_stops / upsert_stop_dibang_links。

  • Step 2: 增加回归断言测试(可选轻量)

在测试文件增加:

def test_arrange_transport_source_has_no_revoke_call():
    import inspect
    from app.api.pl import transport as transport_api

    src = inspect.getsource(transport_api.arrange_transport)
    assert "revoke_regeneratable_dibang_for_transport" not in src
  • Step 3: 跑相关测试
cd backend && source venv/bin/activate && pytest tests/test_pl_dibang_upsert_on_arrange.py tests/test_pl_dibang_date_sync_on_plan_save.py -v

Expected: PASS

  • Step 4: Commit(若有改动)
git add backend/app/api/pl/transport.py backend/tests/test_pl_dibang_upsert_on_arrange.py
git commit -m "chore(pl): guard arrange path against dibang revoke rebuild"

Task 5: 二期衔接 — 段/产品稳定 ID

Files:

  • Follow: docs/superpowers/plans/2026-09-05-pl-stop-stable-id-reuse-links.md

  • Step 1: 检查 09-05 计划未完成任务

对照仓库:送货段已按 payload.id upsert(resolve_delivery_id_for_upsert),装货段 _resolve_or_create_plan_load 原地更新;计划产品行 resolve_plan_product_id_for_upsert。段产品行(PlPlan*Product)仍软删重建,但 plan_product_id / stop id 稳定,满足 link 命中。无需再续做 09-05 Task 1–4。

  • Step 2: 验收交叉点

单测:tests/test_pl_plan_stop_upsert.py、tests/test_pl_plan_product_upsert.py、tests/test_pl_plan_stop_service.py(14 passed)。plan_product_id / delivery id 保留后,Task 2/3 正文同步可命中既有 link。

  • Step 3: 若二期已全部完成

在本计划文件头部标注「二期:已由 2026-09-05 计划覆盖,跳过」。


Task 6: 部署检查清单(人工)

  • Step 1: 合并/发布后重启生产
cd /iflytek/shanxia2.0/backend/
PORT=9111 WORKERS=1 ENV_FILE=./.env.production ./start-prod.sh restart
  • Step 2: 用类似 PO20260903001 场景回归
  1. 已安排 + 已有出入库(可先未过磅)改车 → 出入库车牌变、id 不变
  2. 已过磅改车 → 出入库+过磅单车牌变、称重不变
  3. 新勾选地磅 → 新建出入库
  4. 改往来/产品 → 月计划改挂
  5. 取消已过磅地磅勾选 → 仍阻断
  • Step 3: 确认 API 不再返回

无法随运输安排自动重建


Spec 覆盖自检

Spec 项 Task
D2/D3 有 id 更新、出入库 id 稳定 Task 2
D4 过磅改正文、称重不动 Task 1–2
D5 新地磅需求新建出入库 既有 upsert 新建分支;Task 4 冒烟
D6 月计划利旧/新建改挂 Task 3
D7 警告非整单 400;废除安排 revoke Task 2(不再因已过磅跳过)、Task 4
二期段稳定 ID Task 5 → 09-05 计划
三期审计摘要 本期不做(YAGNI,spec 三期)

执行交接

Plan complete and saved to docs/superpowers/plans/2026-09-06-plan-arrange-upsert-cascade.md.

两种执行方式:

  1. Subagent-Driven(推荐) — 每个 Task 开新子代理,Task 间复查
  2. Inline Execution — 本会话按 executing-plans 连续做,设检查点

选哪种?