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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-10 17:52:57 +08:00
1 parent 3e6ae41e4b
commit 2fc14b1291
2 files changed
+120 -40

No files matched your search

+45 -24
View File
@@ -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
View File
@@ -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: