From 2fc14b129105929dfa831f403c197b3e5233bef3 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Thu, 10 Sep 2026 17:52:57 +0800 Subject: [PATCH] feat(pl): key contract plan qty by product and package spec Co-authored-by: Cursor --- backend/app/services/pl_contract_plan_qty.py | 69 +++++++++------ backend/tests/test_pl_contract_plan_qty.py | 91 ++++++++++++++++---- 2 files changed, 120 insertions(+), 40 deletions(-) diff --git a/backend/app/services/pl_contract_plan_qty.py b/backend/app/services/pl_contract_plan_qty.py index 6fd5dbab8..6d8933f64 100644 --- a/backend/app/services/pl_contract_plan_qty.py +++ b/backend/app/services/pl_contract_plan_qty.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections import defaultdict -from typing import Any, DefaultDict, Dict, Iterable, Optional, Set +from typing import Any, DefaultDict, Dict, Iterable, Optional, Set, Tuple from sqlalchemy.orm import Session @@ -25,6 +25,21 @@ from app.services.pl_plan_stop_service import ( # 待安排 / 等通知 / 作废:不计入「已安排」(见 _bucket_for_plan) +ProductPackageKey = Tuple[str, str] + + +def normalize_package_spec(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def product_package_key(product_id: Any, package_spec: Any = None) -> Optional[ProductPackageKey]: + pid = str(product_id or "").strip() + if not pid: + return None + return (pid, normalize_package_spec(package_spec)) + def _to_float(value: Any) -> float: if value is None: @@ -52,16 +67,16 @@ def _bucket_for_plan(*, archive_status: Optional[str], follow_status: Optional[s def merge_product_qty_maps( *, - archived: Dict[str, float], - arranged_unarchived: Dict[str, float], -) -> Dict[str, Dict[str, float]]: - """product_id -> {archivedQty, arrangedUnarchivedQty, arrangedTotalQty}""" - product_ids = set(archived) | set(arranged_unarchived) - out: Dict[str, Dict[str, float]] = {} - for pid in product_ids: - a = round(float(archived.get(pid, 0.0)), 3) - u = round(float(arranged_unarchived.get(pid, 0.0)), 3) - out[pid] = { + archived: Dict[ProductPackageKey, float], + arranged_unarchived: Dict[ProductPackageKey, float], +) -> Dict[ProductPackageKey, Dict[str, float]]: + """(product_id, package_spec) -> {archivedQty, arrangedUnarchivedQty, arrangedTotalQty}""" + keys = set(archived) | set(arranged_unarchived) + out: Dict[ProductPackageKey, Dict[str, float]] = {} + for key in keys: + a = round(float(archived.get(key, 0.0)), 3) + u = round(float(arranged_unarchived.get(key, 0.0)), 3) + out[key] = { "archivedQty": a, "arrangedUnarchivedQty": u, "arrangedTotalQty": round(a + u, 3), @@ -91,19 +106,22 @@ def _resolve_stop_product_qty( def accumulate_stop_products( - target: DefaultDict[str, float], + target: DefaultDict[ProductPackageKey, float], products: Iterable[Any], *, stop_kind: str = "load", qty_mode: str = QTY_MODE_PLAN, ) -> None: for row in products or []: - pid = str(getattr(row, "product_id", None) or "").strip() - if not pid: + key = product_package_key( + getattr(row, "product_id", None), + getattr(row, "package_spec", None), + ) + if not key: continue qty = _resolve_stop_product_qty(row, stop_kind=stop_kind, qty_mode=qty_mode) if qty: - target[pid] += qty + target[key] += qty def resolve_linked_plan_scopes( @@ -171,8 +189,8 @@ def sum_qtys_for_scope( all_delivery_ids: Set[str], scope: Dict[str, Any], qty_mode: str = QTY_MODE_PLAN, -) -> Dict[str, float]: - acc: DefaultDict[str, float] = defaultdict(float) +) -> Dict[ProductPackageKey, float]: + acc: DefaultDict[ProductPackageKey, float] = defaultdict(float) if scope.get("use_all_stops"): # 遗留:优先送货段数量;无送货则用装货,避免装+送双计 @@ -239,8 +257,8 @@ def aggregate_contract_product_plan_qtys( *, tenant_no: str, contract_id: str, -) -> Dict[str, Dict[str, float]]: - """返回 productId -> 三数量字段。""" +) -> Dict[ProductPackageKey, Dict[str, float]]: + """返回 (productId, packageSpec) -> 三数量字段。""" cid = (contract_id or "").strip() if not cid: return {} @@ -338,8 +356,8 @@ def aggregate_contract_product_plan_qtys( ): delivery_products_by_delivery[prod.delivery_id].append(prod) - archived: DefaultDict[str, float] = defaultdict(float) - arranged_unarchived: DefaultDict[str, float] = defaultdict(float) + archived: DefaultDict[ProductPackageKey, float] = defaultdict(float) + arranged_unarchived: DefaultDict[ProductPackageKey, float] = defaultdict(float) for plan_id, scope in scopes.items(): plan = plan_by_id.get(plan_id) @@ -372,13 +390,16 @@ def aggregate_contract_product_plan_qtys( def attach_plan_qty_fields_to_products( products: list, - qty_by_product_id: Dict[str, Dict[str, float]], + qty_by_product_id: Dict[ProductPackageKey, Dict[str, float]], ) -> None: for item in products or []: if not isinstance(item, dict): continue - pid = str(item.get("productId") or "").strip() - stats = qty_by_product_id.get(pid) or { + key = product_package_key( + item.get("productId"), + item.get("packageSpec") or item.get("package_spec"), + ) + stats = (qty_by_product_id.get(key) if key else None) or { "archivedQty": 0.0, "arrangedUnarchivedQty": 0.0, "arrangedTotalQty": 0.0, diff --git a/backend/tests/test_pl_contract_plan_qty.py b/backend/tests/test_pl_contract_plan_qty.py index cb2f7a576..2da347b24 100644 --- a/backend/tests/test_pl_contract_plan_qty.py +++ b/backend/tests/test_pl_contract_plan_qty.py @@ -1,14 +1,17 @@ """合同产品运输安排数量汇总单元测试。""" from types import SimpleNamespace +from collections import defaultdict from app.services.pl_contract_plan_qty import ( _bucket_for_plan, accumulate_stop_products, + attach_plan_qty_fields_to_products, merge_product_qty_maps, + normalize_package_spec, + product_package_key, resolve_linked_plan_scopes, sum_qtys_for_scope, ) -from collections import defaultdict def test_bucket_archived(): @@ -52,16 +55,72 @@ def test_bucket_skips_pending_and_cancelled(): ) +def test_normalize_package_spec(): + from app.services.pl_contract_plan_qty import normalize_package_spec + + assert normalize_package_spec(None) == "" + assert normalize_package_spec(" ") == "" + assert normalize_package_spec(" 槽罐车 ") == "槽罐车" + assert normalize_package_spec("25kg/桶") != normalize_package_spec("25Kg/袋") + + +def test_accumulate_splits_same_product_different_package(): + acc = defaultdict(float) + accumulate_stop_products( + acc, + [ + SimpleNamespace(product_id="SXWL007344", package_spec="25kg/桶", plan_qty=15), + SimpleNamespace(product_id="SXWL007344", package_spec="槽罐车", plan_qty=700), + SimpleNamespace(product_id="SXWL007344", package_spec=" 槽罐车 ", plan_qty=10), + ], + ) + k_bucket = ("SXWL007344", "25kg/桶") + k_tank = ("SXWL007344", "槽罐车") + assert acc[k_bucket] == 15.0 + assert acc[k_tank] == 710.0 + + +def test_merge_product_qty_maps_composite_keys(): + out = merge_product_qty_maps( + archived={("p1", "A"): 10.0}, + arranged_unarchived={("p1", "B"): 20.0, ("p1", "A"): 5.0}, + ) + assert out[("p1", "A")]["arrangedTotalQty"] == 15.0 + assert out[("p1", "B")]["arrangedTotalQty"] == 20.0 + + +def test_attach_plan_qty_fields_matches_package(): + qty_map = { + ("pid1", "25kg/桶"): { + "archivedQty": 1.0, + "arrangedUnarchivedQty": 2.0, + "arrangedTotalQty": 3.0, + }, + ("pid1", "槽罐车"): { + "archivedQty": 10.0, + "arrangedUnarchivedQty": 20.0, + "arrangedTotalQty": 30.0, + }, + } + products = [ + {"productId": "pid1", "packageSpec": "25kg/桶"}, + {"productId": "pid1", "packageSpec": "槽罐车"}, + ] + attach_plan_qty_fields_to_products(products, qty_map) + assert products[0]["arrangedTotalQty"] == 3.0 + assert products[1]["arrangedTotalQty"] == 30.0 + + def test_merge_product_qty_maps_totals(): out = merge_product_qty_maps( - archived={"p1": 120.0, "p2": 50.0}, - arranged_unarchived={"p1": 80.0, "p3": 100.0}, + archived={("p1", ""): 120.0, ("p2", ""): 50.0}, + arranged_unarchived={("p1", ""): 80.0, ("p3", ""): 100.0}, ) - assert out["p1"]["archivedQty"] == 120.0 - assert out["p1"]["arrangedUnarchivedQty"] == 80.0 - assert out["p1"]["arrangedTotalQty"] == 200.0 - assert out["p2"]["arrangedTotalQty"] == 50.0 - assert out["p3"]["arrangedTotalQty"] == 100.0 + assert out[("p1", "")]["archivedQty"] == 120.0 + assert out[("p1", "")]["arrangedUnarchivedQty"] == 80.0 + assert out[("p1", "")]["arrangedTotalQty"] == 200.0 + assert out[("p2", "")]["arrangedTotalQty"] == 50.0 + assert out[("p3", "")]["arrangedTotalQty"] == 100.0 def test_sum_qtys_explicit_delivery_stop_only(): @@ -79,7 +138,7 @@ def test_sum_qtys_explicit_delivery_stop_only(): all_delivery_ids={"D1", "D2"}, scope={"load_ids": set(), "delivery_ids": {"D1"}, "use_all_stops": False}, ) - assert qtys == {"p1": 40.0} + assert qtys == {("p1", ""): 40.0} def test_legacy_use_all_stops_prefers_delivery(): @@ -96,7 +155,7 @@ def test_legacy_use_all_stops_prefers_delivery(): all_delivery_ids={"D1"}, scope={"load_ids": set(), "delivery_ids": set(), "use_all_stops": True}, ) - assert qtys == {"p1": 40.0} + assert qtys == {("p1", ""): 40.0} def test_resolve_scopes_marks_legacy(): @@ -119,7 +178,7 @@ def test_accumulate_stop_products(): SimpleNamespace(product_id="", plan_qty=9), ], ) - assert acc["p1"] == 20.0 + assert acc[("p1", "")] == 20.0 def test_accumulate_archive_actual_load_uses_inbound_qty(): @@ -134,7 +193,7 @@ def test_accumulate_archive_actual_load_uses_inbound_qty(): qty_mode="archive_actual", ) # 88 + 回退 plan_qty 50 - assert acc["p1"] == 138.0 + assert acc[("p1", "")] == 138.0 def test_accumulate_archive_actual_delivery_uses_outbound_qty(): @@ -145,7 +204,7 @@ def test_accumulate_archive_actual_delivery_uses_outbound_qty(): stop_kind="delivery", qty_mode="archive_actual", ) - assert acc["p1"] == 66.0 + assert acc[("p1", "")] == 66.0 def test_accumulate_archive_actual_zero_does_not_fallback(): @@ -156,7 +215,7 @@ def test_accumulate_archive_actual_zero_does_not_fallback(): stop_kind="load", qty_mode="archive_actual", ) - assert acc["p1"] == 0.0 + assert acc[("p1", "")] == 0.0 def test_sum_qtys_archive_actual_mode_on_scoped_stops(): @@ -174,7 +233,7 @@ def test_sum_qtys_archive_actual_mode_on_scoped_stops(): scope={"load_ids": {"L1"}, "delivery_ids": {"D1"}, "use_all_stops": False}, qty_mode="archive_actual", ) - assert qtys == {"p1": 40.0} # 7 + 33 + assert qtys == {("p1", ""): 40.0} # 7 + 33 def test_sum_qtys_default_mode_ignores_actual_fields(): @@ -191,7 +250,7 @@ def test_sum_qtys_default_mode_ignores_actual_fields(): all_delivery_ids={"D1"}, scope={"load_ids": set(), "delivery_ids": {"D1"}, "use_all_stops": False}, ) - assert qtys == {"p1": 40.0} + assert qtys == {("p1", ""): 40.0} class DecimalLike: