feat(pl): arrange-qty and overflow checks use product+package key
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
2fc14b1291
commit
ff96f2ec25
3 files changed
+84
-11
No files matched your search
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user