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

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

No files matched your search

@@ -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,
*,
+30 -2
View File
@@ -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(
@@ -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