Просмотр исходного кода

feat(pl): key contract plan qty by product and package spec

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 месяц назад
Родитель
Сommit
2fc14b1291
2 измененных файлов с 120 добавлено и 40 удалено
  1. 45 24
      backend/app/services/pl_contract_plan_qty.py
  2. 75 16
      backend/tests/test_pl_contract_plan_qty.py

+ 45 - 24
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,

+ 75 - 16
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: