diff --git a/backend/app/services/pl_contract_plan_qty.py b/backend/app/services/pl_contract_plan_qty.py index 8c145b39e..ecf9c2643 100644 --- a/backend/app/services/pl_contract_plan_qty.py +++ b/backend/app/services/pl_contract_plan_qty.py @@ -937,6 +937,45 @@ def build_contract_qty_reminder_payload( 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( db: Session, *, diff --git a/backend/app/services/pl_plan_stop_service.py b/backend/app/services/pl_plan_stop_service.py index 49da7d5d3..5297d6009 100644 --- a/backend/app/services/pl_plan_stop_service.py +++ b/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) .filter( CrmContractProduct.contract_id == contract_id, @@ -506,6 +506,10 @@ def _contract_allowed_product_ids(db: Session, tenant_no: str, contract_id: str) ) .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 { str(r.product_id).strip() 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( db: Session, tenant_no: str, @@ -530,6 +553,8 @@ def assert_load_products_in_selected_contract( 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(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( @@ -552,6 +577,9 @@ def assert_delivery_products_in_selected_contract( allowed, row_label=f"送货段#{seq}产品", ) + _assert_stop_products_package_for_contract( + db, tenant_no, contract_id, delivery.products + ) def assert_stop_contract_required( diff --git a/backend/tests/test_pl_plan_package_validate.py b/backend/tests/test_pl_plan_package_validate.py new file mode 100644 index 000000000..0d8d6b0d6 --- /dev/null +++ b/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