feat(pl): arrange-qty and overflow checks use product+package key

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-10 18:00:10 +08:00
1 parent 2fc14b1291
commit ff96f2ec25
3 files changed
+84 -11

No files matched your search

+2
View File
@@ -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,
)
+52 -11
View File
@@ -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,
@@ -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