|
|
@@ -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,
|