Jelajahi Sumber

feat(pl): arrange-qty and overflow checks use product+package key

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 bulan lalu
induk
melakukan
ff96f2ec25

+ 2 - 0
backend/app/api/pl/plan.py

@@ -847,6 +847,7 @@ def count_plans_by_contract(
 def get_contract_product_arrange_qty(
     contract_id: str = Query(..., alias="contractId"),
     product_id: str = Query(..., alias="productId"),
+    package_spec: Optional[str] = Query(default=None, alias="packageSpec"),
     exclude_plan_id: Optional[str] = Query(default=None, alias="excludePlanId"),
     db: Session = Depends(get_db),
     user: DbUserContext = Depends(get_db_current_user),
@@ -860,6 +861,7 @@ def get_contract_product_arrange_qty(
         tenant_no=tenant_no,
         contract_id=contract_id,
         product_id=product_id,
+        package_spec=package_spec,
         exclude_plan_id=exclude_plan_id,
     )
 

+ 52 - 11
backend/app/services/pl_contract_plan_qty.py

@@ -41,6 +41,40 @@ def product_package_key(product_id: Any, package_spec: Any = None) -> Optional[P
     return (pid, normalize_package_spec(package_spec))
 
 
+def match_contract_product_for_key(
+    products: Iterable[Any],
+    key: Optional[ProductPackageKey],
+) -> Optional[Any]:
+    """按复合键匹配合同产品行;同键多行时 quantity 相加(防御)。"""
+    if not key:
+        return None
+    matched = []
+    for p in products or []:
+        k = product_package_key(
+            getattr(p, "product_id", None),
+            getattr(p, "package_spec", None),
+        )
+        if k == key:
+            matched.append(p)
+    if not matched:
+        return None
+    if len(matched) == 1:
+        return matched[0]
+    from types import SimpleNamespace
+
+    total_qty = sum(_to_float(getattr(p, "quantity", None)) for p in matched)
+    base = matched[0]
+    return SimpleNamespace(
+        product_id=getattr(base, "product_id", None),
+        package_spec=getattr(base, "package_spec", None),
+        product_name=getattr(base, "product_name", None),
+        model=getattr(base, "model", None),
+        unit=getattr(base, "unit", None),
+        quantity=total_qty,
+        quantity_mode=getattr(base, "quantity_mode", None),
+    )
+
+
 def _to_float(value: Any) -> float:
     if value is None:
         return 0.0
@@ -782,24 +816,24 @@ def collect_plan_contract_qty_checks(
             )
             .all()
         )
-        products_by_pid = {
-            str(p.product_id or "").strip(): p for p in products if p.product_id
-        }
         stop_kind = _stop_kind_from_scope(scope)
         contract_type = (contract.contract_type or "").strip()
         contract_no = contract.contract_no or ""
         customer_name = (contract.customer_name or "").strip()
 
-        for pid, new_qty_raw in plan_qtys.items():
+        for key, new_qty_raw in plan_qtys.items():
             new_qty = _to_float(new_qty_raw)
             if new_qty <= 0:
                 continue
-            cp = products_by_pid.get(pid)
+            if not isinstance(key, tuple) or len(key) != 2:
+                continue
+            pid, pkg = key[0], key[1]
+            cp = match_contract_product_for_key(products, key)
             # 计划产品不在合同明细:不校验数量(归属另有规则)
             if not cp:
                 continue
 
-            arranged_total = _to_float((qty_map.get(pid) or {}).get("arrangedTotalQty"))
+            arranged_total = _to_float((qty_map.get(key) or {}).get("arrangedTotalQty"))
             if in_bucket:
                 old_qty = new_qty
                 # arranged 已含当前库内量;effective = arranged - old + new = arranged
@@ -828,6 +862,7 @@ def collect_plan_contract_qty_checks(
                 "customerName": customer_name,
                 "stopKind": stop_kind,
                 "productId": pid,
+                "packageSpec": pkg or None,
                 "productName": cp.product_name or "",
                 "model": (cp.model or "").strip() or None,
                 "unit": (cp.unit or "").strip() or None,
@@ -908,6 +943,7 @@ def get_contract_product_arrange_qty_detail(
     tenant_no: str,
     contract_id: str,
     product_id: str,
+    package_spec: Optional[str] = None,
     exclude_plan_id: Optional[str] = None,
 ) -> Dict[str, Any]:
     """
@@ -918,12 +954,15 @@ def get_contract_product_arrange_qty_detail(
 
     cid = (contract_id or "").strip()
     pid = (product_id or "").strip()
+    pkg = normalize_package_spec(package_spec)
+    key = product_package_key(pid, pkg)
     exclude_id = (exclude_plan_id or "").strip() or None
     empty = {
         "contractId": cid,
         "contractNo": None,
         "customerName": None,
         "productId": pid,
+        "packageSpec": pkg or None,
         "productName": None,
         "hasFixedQty": False,
         "contractQty": None,
@@ -931,7 +970,7 @@ def get_contract_product_arrange_qty_detail(
         "availableQty": None,
         "plans": [],
     }
-    if not cid or not pid:
+    if not cid or not pid or not key:
         return empty
 
     contract = (
@@ -946,7 +985,7 @@ def get_contract_product_arrange_qty_detail(
     if not contract:
         return empty
 
-    cp = (
+    candidates = (
         db.query(CrmContractProduct)
         .filter(
             CrmContractProduct.contract_id == cid,
@@ -954,8 +993,9 @@ def get_contract_product_arrange_qty_detail(
             CrmContractProduct.tenant_no == tenant_no,
             CrmContractProduct.del_flag == 0,
         )
-        .first()
+        .all()
     )
+    cp = match_contract_product_for_key(candidates, key)
     contract_type = (contract.contract_type or "").strip()
     has_fixed = False
     contract_qty: Optional[float] = None
@@ -971,7 +1011,7 @@ def get_contract_product_arrange_qty_detail(
     qty_map = aggregate_contract_product_plan_qtys(
         db, tenant_no=tenant_no, contract_id=cid
     )
-    arranged_including = _to_float((qty_map.get(pid) or {}).get("arrangedTotalQty"))
+    arranged_including = _to_float((qty_map.get(key) or {}).get("arrangedTotalQty"))
 
     link_rows = (
         db.query(PlPlanContract)
@@ -1086,7 +1126,7 @@ def get_contract_product_arrange_qty_detail(
                 scope=scope,
                 qty_mode=qty_mode,
             )
-            pq = _to_float(qtys.get(pid))
+            pq = _to_float(qtys.get(key))
             if pq <= 0:
                 continue
             if exclude_id and str(plan_id) == exclude_id:
@@ -1127,6 +1167,7 @@ def get_contract_product_arrange_qty_detail(
         "contractNo": contract.contract_no or "",
         "customerName": (contract.customer_name or "").strip() or None,
         "productId": pid,
+        "packageSpec": pkg or None,
         "productName": (cp.product_name if cp else None) or None,
         "hasFixedQty": has_fixed,
         "contractQty": contract_qty,

+ 30 - 0
backend/tests/test_pl_contract_plan_qty.py

@@ -6,6 +6,7 @@ from app.services.pl_contract_plan_qty import (
     _bucket_for_plan,
     accumulate_stop_products,
     attach_plan_qty_fields_to_products,
+    match_contract_product_for_key,
     merge_product_qty_maps,
     normalize_package_spec,
     product_package_key,
@@ -253,6 +254,35 @@ def test_sum_qtys_default_mode_ignores_actual_fields():
     assert qtys == {("p1", ""): 40.0}
 
 
+def test_match_contract_product_for_key_picks_package():
+    products = [
+        SimpleNamespace(product_id="SXWL007344", package_spec="25kg/桶", quantity=15),
+        SimpleNamespace(product_id="SXWL007344", package_spec="槽罐车", quantity=700),
+    ]
+    bucket = match_contract_product_for_key(
+        products, product_package_key("SXWL007344", "25kg/桶")
+    )
+    tank = match_contract_product_for_key(
+        products, product_package_key("SXWL007344", " 槽罐车 ")
+    )
+    assert bucket is not None and float(bucket.quantity) == 15.0
+    assert tank is not None and float(tank.quantity) == 700.0
+
+
+def test_match_contract_product_for_key_sums_duplicate_keys():
+    products = [
+        SimpleNamespace(product_id="p1", package_spec="槽罐车", quantity=100),
+        SimpleNamespace(product_id="p1", package_spec="槽罐车", quantity=50),
+    ]
+    row = match_contract_product_for_key(products, ("p1", "槽罐车"))
+    assert row is not None and float(row.quantity) == 150.0
+
+
+def test_match_contract_product_for_key_missing():
+    products = [SimpleNamespace(product_id="p1", package_spec="槽罐车", quantity=10)]
+    assert match_contract_product_for_key(products, ("p1", "25kg/桶")) is None
+
+
 class DecimalLike:
     def __init__(self, v):
         self.v = v