Преглед на файлове

feat(pl): remount dibang monthly plan when partner or product changes

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu преди 1 месец
родител
ревизия
503b388a3f
променени са 2 файла, в които са добавени 247 реда и са изтрити 8 реда
  1. 66 8
      backend/app/services/pl_dibang_bridge_service.py
  2. 181 0
      backend/tests/test_pl_dibang_upsert_on_arrange.py

+ 66 - 8
backend/app/services/pl_dibang_bridge_service.py

@@ -698,21 +698,29 @@ def _update_existing_stop_inventory(
     """就地强制同步出入库正文(含已过磅),并回写关联过磅单业务字段。"""
     label = inventory.batch_no or inventory.id
 
-    # —— 强制更新 partner / vehicle / 产品快照 / quantity / date(跨月改挂)——
-    if partner and (inventory.partner_company or "").strip() != partner:
+    old_partner = (inventory.partner_company or "").strip()
+    old_product_id = (inventory.product_id or "").strip()
+    old_spec = (inventory.specification or "").strip()
+    old_plate = (inventory.vehicle_no or "").strip()
+    old_date = _normalize_to_date(inventory.date)
+
+    # —— 强制更新 partner / vehicle / 产品快照 / quantity ——
+    if partner and old_partner != partner:
         inventory.partner_company = partner
 
     plate = (license_plate or "").strip()
-    if plate and (inventory.vehicle_no or "").strip() != plate:
+    plate_changed = bool(plate and old_plate != plate)
+    if plate_changed:
         inventory.vehicle_no = plate
 
-    new_product_id = (product.product_id or "").strip()
-    if new_product_id:
+    incoming_product_id = (product.product_id or "").strip()
+    if incoming_product_id:
         snapshot = resolve_crm_product(
-            db, tenant_no, new_product_id, require_on_shelf=False
+            db, tenant_no, incoming_product_id, require_on_shelf=False
         )
         apply_product_snapshot_to_inventory(inventory, snapshot)
 
+    # 数量差量先作用于当前挂靠月计划(改挂前);改挂时会整额扣回旧计划
     new_qty = float(product.quantity)
     old_qty = _resolve_link_contributed_qty(link, inventory)
     if abs(new_qty - old_qty) > 1e-9:
@@ -732,7 +740,57 @@ def _update_existing_stop_inventory(
         inventory.quantity = Decimal(str(new_qty))
         link.contributed_qty = Decimal(str(new_qty))
 
-    old_date = _normalize_to_date(inventory.date)
+    new_partner = (inventory.partner_company or "").strip()
+    new_product_id = (inventory.product_id or "").strip()
+    new_spec = (inventory.specification or "").strip()
+    need_remount = (
+        (partner and old_partner != new_partner)
+        or (old_product_id and new_product_id and old_product_id != new_product_id)
+        or (old_spec != new_spec and bool(new_product_id or old_product_id))
+    )
+
+    remounted = False
+    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,
+        )
+        try:
+            new_db_plan = _ensure_monthly_db_plan_for_inventory(
+                db,
+                tenant_no=tenant_no,
+                plan=plan,
+                inventory=inventory,
+                dibang_date=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
+            remounted = True
+        except ValueError as exc:
+            warnings.append(f"出入库单 {label} 往来/产品改挂失败:{exc}")
+    elif plate_changed and 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:
+            db_plan.vehicle_no = plate
+            db_plan.update_by = user_id
+
+    # —— date:同月仅改日期;跨月改挂(本轮已因往来/产品改挂则不再二次扣量)——
     if old_date != dibang_date:
         same_month = (
             old_date is not None
@@ -740,7 +798,7 @@ def _update_existing_stop_inventory(
             and old_date.month == dibang_date.month
         )
         inventory.date = dibang_date
-        if not same_month:
+        if not same_month and not remounted:
             qty = _resolve_link_contributed_qty(link, inventory)
             _subtract_plan_contribution(
                 db,

+ 181 - 0
backend/tests/test_pl_dibang_upsert_on_arrange.py

@@ -458,3 +458,184 @@ def test_upsert_updates_inventory_and_weighing_plate_when_already_weighed(
     assert slip.tare_weight == 18730.0
     assert slip.net_weight == -18730.0
     assert not any("未同步" in w for w in warnings)
+
+
+@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 不变。"""
+    mock_crm.return_value = MagicMock(
+        product_id="prod-1",
+        product_name="硫酸",
+        specification="98%",
+    )
+    old_partner = "客户旧"
+    new_partner = "客户新"
+    old_plan = DbPurchasePlan(
+        id="dbp-old",
+        tenant_no="t1",
+        plan_no="SX-OLD",
+        customer_name=old_partner,
+        planned_quantity=30.0,
+        del_flag=0,
+    )
+    new_plan = DbPurchasePlan(
+        id="dbp-new",
+        tenant_no="t1",
+        plan_no="SX-NEW",
+        customer_name=new_partner,
+        planned_quantity=0.0,
+        del_flag=0,
+    )
+    mock_find_plan.return_value = new_plan
+
+    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="皖A12345",
+        tenant_no="t1",
+    )
+    transport = PlTransport(
+        id="tr-1", tenant_no="t1", license_plate="皖A12345", 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=new_partner,
+        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("30"),
+        tenant_no="t1",
+        del_flag=0,
+        order_num=1,
+    )
+    inventory = DbInboundOutbound(
+        id="inv-1",
+        tenant_no="t1",
+        date=date(2026, 9, 1),
+        partner_company=old_partner,
+        business_type="出库",
+        product_id="prod-1",
+        product="硫酸",
+        specification="98%",
+        quantity=30.0,
+        batch_no="CRKDH0000001",
+        vehicle_no="皖A12345",
+        status="待出库",
+        purchase_plan_id="dbp-old",
+        plan_no="SX-OLD",
+        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-old",
+        db_inventory_id="inv-1",
+        contributed_qty=Decimal("30"),
+        del_flag=0,
+    )
+
+    db = MagicMock()
+
+    def link_q(m):
+        filtered = MagicMock()
+        filtered.all.return_value = [link]
+        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 plan_q(m):
+        # _subtract_plan_contribution 查旧计划;后续可能再查
+        m.filter.return_value.first.return_value = old_plan
+        return m
+
+    db.query.side_effect = _make_query_router(
+        {
+            PlTransportDbLink: link_q,
+            PlPlanLoad: load_q,
+            PlPlanDelivery: delivery_q,
+            PlPlanLoadProduct: load_prod_q,
+            PlPlanDeliveryProduct: delivery_prod_q,
+            DbInboundOutbound: inv_q,
+            DbPurchasePlan: 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),
+        ],
+        user_id="u1",
+        dept_id="d1",
+    )
+
+    assert warnings == []
+    assert len(results) == 1
+    assert inventory.id == "inv-1"
+    assert inventory.partner_company == new_partner
+    assert inventory.purchase_plan_id == new_plan.id
+    assert link.db_purchase_plan_id == new_plan.id
+    assert inventory.plan_no == "SX-NEW"
+    mock_find_plan.assert_called()
+    mock_create_plan.assert_not_called()