From ff96f2ec2529fb8f10527acdaec315371b18dc31 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Thu, 10 Sep 2026 18:00:10 +0800 Subject: [PATCH] feat(pl): arrange-qty and overflow checks use product+package key Co-authored-by: Cursor --- backend/app/api/pl/plan.py | 2 + backend/app/services/pl_contract_plan_qty.py | 63 ++++++++++++++++---- backend/tests/test_pl_contract_plan_qty.py | 30 ++++++++++ 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/backend/app/api/pl/plan.py b/backend/app/api/pl/plan.py index e4ad2e5f0..16b6234b7 100644 --- a/backend/app/api/pl/plan.py +++ b/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, ) diff --git a/backend/app/services/pl_contract_plan_qty.py b/backend/app/services/pl_contract_plan_qty.py index 6d8933f64..8c145b39e 100644 --- a/backend/app/services/pl_contract_plan_qty.py +++ b/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, diff --git a/backend/tests/test_pl_contract_plan_qty.py b/backend/tests/test_pl_contract_plan_qty.py index 2da347b24..ea370aeb3 100644 --- a/backend/tests/test_pl_contract_plan_qty.py +++ b/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