Jelajahi Sumber

feat(pl): require contract package spec on plan stop save

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 bulan lalu
induk
melakukan
ded6aeaa85

+ 39 - 0
backend/app/services/pl_contract_plan_qty.py

@@ -937,6 +937,45 @@ def build_contract_qty_reminder_payload(
     return {"items": reminders}
     return {"items": reminders}
 
 
 
 
+def validate_stop_products_package_against_contract(
+    *,
+    contract_products: Iterable[Any],
+    stop_products: Iterable[Any],
+) -> tuple[bool, Optional[str]]:
+    """
+    绑合同时:计划段产品包装必填,且必须属于本合同该产品的包装集合。
+    返回 (ok, error_message)。
+    """
+    allowed_by_pid: DefaultDict[str, Set[str]] = defaultdict(set)
+    contract_pids: Set[str] = set()
+    for row in contract_products or []:
+        pid = str(getattr(row, "product_id", None) or "").strip()
+        if not pid:
+            continue
+        contract_pids.add(pid)
+        pkg = normalize_package_spec(getattr(row, "package_spec", None))
+        if pkg:
+            allowed_by_pid[pid].add(pkg)
+
+    for row in stop_products or []:
+        pid = str(getattr(row, "product_id", None) or "").strip()
+        if not pid or pid not in contract_pids:
+            continue
+        allowed = allowed_by_pid.get(pid) or set()
+        if not allowed:
+            continue
+        pkg = normalize_package_spec(getattr(row, "package_spec", None))
+        name = (
+            str(getattr(row, "product_name", None) or "").strip()
+            or pid
+        )
+        if not pkg:
+            return False, f"请选择包装规格:{name}"
+        if pkg not in allowed:
+            return False, f"包装规格不在合同明细中:{name}({pkg})"
+    return True, None
+
+
 def get_contract_product_arrange_qty_detail(
 def get_contract_product_arrange_qty_detail(
     db: Session,
     db: Session,
     *,
     *,

+ 30 - 2
backend/app/services/pl_plan_stop_service.py

@@ -496,8 +496,8 @@ def _assert_product_rows_in_contract_allowed(
             )
             )
 
 
 
 
-def _contract_allowed_product_ids(db: Session, tenant_no: str, contract_id: str) -> set[str]:
-    rows = (
+def _contract_product_rows(db: Session, tenant_no: str, contract_id: str) -> list:
+    return (
         db.query(CrmContractProduct)
         db.query(CrmContractProduct)
         .filter(
         .filter(
             CrmContractProduct.contract_id == contract_id,
             CrmContractProduct.contract_id == contract_id,
@@ -506,6 +506,10 @@ def _contract_allowed_product_ids(db: Session, tenant_no: str, contract_id: str)
         )
         )
         .all()
         .all()
     )
     )
+
+
+def _contract_allowed_product_ids(db: Session, tenant_no: str, contract_id: str) -> set[str]:
+    rows = _contract_product_rows(db, tenant_no, contract_id)
     return {
     return {
         str(r.product_id).strip()
         str(r.product_id).strip()
         for r in rows
         for r in rows
@@ -513,6 +517,25 @@ def _contract_allowed_product_ids(db: Session, tenant_no: str, contract_id: str)
     }
     }
 
 
 
 
+def _assert_stop_products_package_for_contract(
+    db: Session,
+    tenant_no: str,
+    contract_id: str,
+    stop_products: Optional[list],
+) -> None:
+    from app.services.pl_contract_plan_qty import (
+        validate_stop_products_package_against_contract,
+    )
+
+    rows = _contract_product_rows(db, tenant_no, contract_id)
+    ok, err = validate_stop_products_package_against_contract(
+        contract_products=rows,
+        stop_products=stop_products or [],
+    )
+    if not ok:
+        raise HTTPException(status_code=422, detail=err or "包装规格不符合合同明细")
+
+
 def assert_load_products_in_selected_contract(
 def assert_load_products_in_selected_contract(
     db: Session,
     db: Session,
     tenant_no: str,
     tenant_no: str,
@@ -530,6 +553,8 @@ def assert_load_products_in_selected_contract(
     allowed = _contract_allowed_product_ids(db, tenant_no, contract_id)
     allowed = _contract_allowed_product_ids(db, tenant_no, contract_id)
     _assert_product_rows_in_contract_allowed(load.products, allowed, row_label="装货产品")
     _assert_product_rows_in_contract_allowed(load.products, allowed, row_label="装货产品")
     _assert_product_rows_in_contract_allowed(plan_products, allowed, row_label="装货产品")
     _assert_product_rows_in_contract_allowed(plan_products, allowed, row_label="装货产品")
+    merged = list(load.products or []) + list(plan_products or [])
+    _assert_stop_products_package_for_contract(db, tenant_no, contract_id, merged)
 
 
 
 
 def assert_delivery_products_in_selected_contract(
 def assert_delivery_products_in_selected_contract(
@@ -552,6 +577,9 @@ def assert_delivery_products_in_selected_contract(
         allowed,
         allowed,
         row_label=f"送货段#{seq}产品",
         row_label=f"送货段#{seq}产品",
     )
     )
+    _assert_stop_products_package_for_contract(
+        db, tenant_no, contract_id, delivery.products
+    )
 
 
 
 
 def assert_stop_contract_required(
 def assert_stop_contract_required(

+ 66 - 0
backend/tests/test_pl_plan_package_validate.py

@@ -0,0 +1,66 @@
+"""绑合同时计划段产品包装规格校验。"""
+from types import SimpleNamespace
+
+from app.services.pl_contract_plan_qty import validate_stop_products_package_against_contract
+
+
+def test_require_package_when_contract_bound():
+    contract_products = [
+        SimpleNamespace(product_id="p1", package_spec="25kg/桶"),
+        SimpleNamespace(product_id="p1", package_spec="槽罐车"),
+    ]
+    ok, err = validate_stop_products_package_against_contract(
+        contract_products=contract_products,
+        stop_products=[SimpleNamespace(product_id="p1", package_spec="", product_name="次氯酸钠")],
+    )
+    assert ok is False
+    assert err and "包装" in err
+
+
+def test_package_must_be_in_contract_set():
+    contract_products = [
+        SimpleNamespace(product_id="p1", package_spec="25kg/桶"),
+        SimpleNamespace(product_id="p1", package_spec="槽罐车"),
+    ]
+    ok, err = validate_stop_products_package_against_contract(
+        contract_products=contract_products,
+        stop_products=[SimpleNamespace(product_id="p1", package_spec="吨桶", product_name="次氯酸钠")],
+    )
+    assert ok is False
+    assert err and "合同" in err
+
+
+def test_valid_package_passes():
+    contract_products = [
+        SimpleNamespace(product_id="p1", package_spec="25kg/桶"),
+        SimpleNamespace(product_id="p1", package_spec="槽罐车"),
+    ]
+    ok, err = validate_stop_products_package_against_contract(
+        contract_products=contract_products,
+        stop_products=[SimpleNamespace(product_id="p1", package_spec="槽罐车")],
+    )
+    assert ok is True
+    assert err is None
+
+
+def test_product_not_on_contract_skipped():
+    contract_products = [
+        SimpleNamespace(product_id="p1", package_spec="槽罐车"),
+    ]
+    ok, err = validate_stop_products_package_against_contract(
+        contract_products=contract_products,
+        stop_products=[SimpleNamespace(product_id="p2", package_spec="")],
+    )
+    assert ok is True
+
+
+def test_contract_empty_package_allows_empty_plan():
+    """脏数据防御:合同行包装为空时不强制计划填包装。"""
+    contract_products = [
+        SimpleNamespace(product_id="p1", package_spec=""),
+    ]
+    ok, err = validate_stop_products_package_against_contract(
+        contract_products=contract_products,
+        stop_products=[SimpleNamespace(product_id="p1", package_spec="")],
+    )
+    assert ok is True