feat(pl): require load products to belong to selected contract
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
eb1bfc5732
commit
fbcd27b213
2 files changed
+138
-2
No files matched your search
@@ -10,6 +10,7 @@ from fastapi import HTTPException
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.utils import generate_uuid
|
from app.core.utils import generate_uuid
|
||||||
|
from app.models.crm_models import CrmContractProduct
|
||||||
from app.models.pl_models import (
|
from app.models.pl_models import (
|
||||||
PlPlan,
|
PlPlan,
|
||||||
PlPlanContract,
|
PlPlanContract,
|
||||||
@@ -479,6 +480,43 @@ def assert_at_most_one_contract(
|
|||||||
return links
|
return links
|
||||||
|
|
||||||
|
|
||||||
|
def assert_load_products_in_selected_contract(
|
||||||
|
db: Session,
|
||||||
|
tenant_no: str,
|
||||||
|
load: PlanLoadBody,
|
||||||
|
) -> None:
|
||||||
|
if is_internal_customer_name(load.customer_name):
|
||||||
|
return
|
||||||
|
links = _normalize_contract_links(load.contracts)
|
||||||
|
if not links:
|
||||||
|
return
|
||||||
|
contract_id = (links[0].contract_id or "").strip()
|
||||||
|
if not contract_id:
|
||||||
|
return
|
||||||
|
rows = (
|
||||||
|
db.query(CrmContractProduct)
|
||||||
|
.filter(
|
||||||
|
CrmContractProduct.contract_id == contract_id,
|
||||||
|
CrmContractProduct.tenant_no == tenant_no,
|
||||||
|
CrmContractProduct.del_flag == 0,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
allowed = {
|
||||||
|
str(r.product_id).strip()
|
||||||
|
for r in rows
|
||||||
|
if getattr(r, "product_id", None) and str(r.product_id).strip()
|
||||||
|
}
|
||||||
|
for prod in load.products or []:
|
||||||
|
pid = str(getattr(prod, "product_id", None) or "").strip()
|
||||||
|
name = (getattr(prod, "product_name", None) or pid or "未命名").strip()
|
||||||
|
if not pid or pid not in allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"装货产品「{name}」不在所选合同明细中",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_stop_contracts(
|
def _resolve_stop_contracts(
|
||||||
db: Session,
|
db: Session,
|
||||||
tenant_no: str,
|
tenant_no: str,
|
||||||
@@ -551,6 +589,7 @@ def validate_plan_stops_write(
|
|||||||
resolved_deliveries.append(delivery)
|
resolved_deliveries.append(delivery)
|
||||||
|
|
||||||
enrich_stop_addresses_from_library(db, tenant_no, load, resolved_deliveries)
|
enrich_stop_addresses_from_library(db, tenant_no, load, resolved_deliveries)
|
||||||
|
assert_load_products_in_selected_contract(db, tenant_no, load)
|
||||||
return load, resolved_deliveries
|
return load, resolved_deliveries
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from app.schemas.pl.plan import PlanContractLink
|
from app.schemas.pl.plan import PlanContractLink, PlanLoadBody, PlanStopProductBody
|
||||||
from app.services.pl_plan_stop_service import assert_at_most_one_contract
|
from app.services.pl_plan_stop_service import (
|
||||||
|
assert_at_most_one_contract,
|
||||||
|
assert_load_products_in_selected_contract,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_assert_at_most_one_contract_allows_zero_or_one():
|
def test_assert_at_most_one_contract_allows_zero_or_one():
|
||||||
@@ -20,3 +24,96 @@ def test_assert_at_most_one_contract_rejects_two():
|
|||||||
assert_at_most_one_contract(links, "装货合同")
|
assert_at_most_one_contract(links, "装货合同")
|
||||||
assert exc.value.status_code == 422
|
assert exc.value.status_code == 422
|
||||||
assert "只能选择 1 份" in str(exc.value.detail)
|
assert "只能选择 1 份" in str(exc.value.detail)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def filter(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDb:
|
||||||
|
def __init__(self, product_ids):
|
||||||
|
self._rows = [SimpleNamespace(product_id=pid) for pid in product_ids]
|
||||||
|
|
||||||
|
def query(self, model):
|
||||||
|
return _FakeQuery(self._rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_with_products(product_ids, contract_id="c1"):
|
||||||
|
return PlanLoadBody(
|
||||||
|
customerId="cust-1",
|
||||||
|
customerName="外部客户A",
|
||||||
|
contracts=[PlanContractLink(contractId=contract_id, contractNo="N1")],
|
||||||
|
products=[
|
||||||
|
PlanStopProductBody(
|
||||||
|
productId=pid,
|
||||||
|
productNo=pid,
|
||||||
|
productName=f"产品{pid}",
|
||||||
|
unit="吨",
|
||||||
|
planQty=1,
|
||||||
|
)
|
||||||
|
for pid in product_ids
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_load_products_in_contract_passes_when_all_match():
|
||||||
|
db = _FakeDb(["p1", "p2"])
|
||||||
|
load = _load_with_products(["p1"])
|
||||||
|
assert_load_products_in_selected_contract(db, "shanxia2.0", load)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_load_products_in_contract_rejects_outside():
|
||||||
|
db = _FakeDb(["p1"])
|
||||||
|
load = _load_with_products(["p2"])
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
assert_load_products_in_selected_contract(db, "shanxia2.0", load)
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
assert "不在所选合同明细中" in str(exc.value.detail)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_load_products_skips_when_no_contract():
|
||||||
|
db = _FakeDb([])
|
||||||
|
load = PlanLoadBody(
|
||||||
|
customerId="cust-1",
|
||||||
|
customerName="外部客户A",
|
||||||
|
contracts=[],
|
||||||
|
products=[
|
||||||
|
PlanStopProductBody(
|
||||||
|
productId="p1",
|
||||||
|
productNo="p1",
|
||||||
|
productName="产品p1",
|
||||||
|
unit="吨",
|
||||||
|
planQty=1,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert_load_products_in_selected_contract(db, "shanxia2.0", load)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_load_products_skips_internal_customer():
|
||||||
|
from app.services.pl_internal_customer import INTERNAL_CUSTOMER_NAMES
|
||||||
|
|
||||||
|
internal = next(iter(INTERNAL_CUSTOMER_NAMES))
|
||||||
|
db = _FakeDb([])
|
||||||
|
load = PlanLoadBody(
|
||||||
|
customerId="cust-1",
|
||||||
|
customerName=internal,
|
||||||
|
contracts=[],
|
||||||
|
products=[
|
||||||
|
PlanStopProductBody(
|
||||||
|
productId="p1",
|
||||||
|
productNo="p1",
|
||||||
|
productName="产品p1",
|
||||||
|
unit="吨",
|
||||||
|
planQty=1,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert_load_products_in_selected_contract(db, "shanxia2.0", load)
|
||||||
Reference in new issue
Block a user