feat(pl): key contract plan qty by product and package spec
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
3e6ae41e4b
commit
2fc14b1291
2 files changed
+120
-40
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user