From 680661a80bc9f7c57b637f0941857cef4adf6a58 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Thu, 15 Oct 2026 18:31:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(allocation):=20=E4=BA=8C=E6=AC=A1=E5=88=86?= =?UTF-8?q?=E9=85=8D=E6=96=B9=E6=A1=88=E5=BD=95=E5=85=A5/=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2/=E7=BB=88=E6=80=81=E9=97=A8=E6=A7=9B=EF=BC=88?= =?UTF-8?q?=E5=88=86=E6=91=8A=E8=AE=A1=E7=AE=97=E5=BE=85=E6=BE=84=E6=B8=85?= =?UTF-8?q?=E5=8F=A6=E6=89=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SecondaryAllocationIn/Out:比例走金额红线口径(str 入参、两位小数、 超 100 拒绝不静默舍入);参与人 schema 层去重(uk 前置友好提示) - create/update 同事务写入(replace_allocations,先删后 flush 再插—— uk 表防御写法);create 放编号重试循环内,rollback 后方案随业务重来 - 人员在职复用 ensure_active(label=分摊参与),personnel_name 冗余固化; FK RESTRICT:被方案引用人员禁删,方案回显不静默失联 - 合计不强制 100%(需求原文):详情 allocation_hint 只展示合计/余额, 余额归属待澄清 #2 不落库 - 单测 13 例(比例边界/去重)+ 集成 9 例(回显/替换清空/离职 400/ 终态禁改/RESTRICT 保护) Co-Authored-By: Claude Code --- .../app/repositories/business_repository.py | 30 +++ backend/app/schemas/business.py | 63 +++++- backend/app/services/business_service.py | 63 +++++- .../integration/test_secondary_allocation.py | 211 ++++++++++++++++++ .../unit/test_secondary_allocation_schema.py | 111 +++++++++ 5 files changed, 475 insertions(+), 3 deletions(-) create mode 100644 backend/tests/integration/test_secondary_allocation.py create mode 100644 backend/tests/unit/test_secondary_allocation_schema.py diff --git a/backend/app/repositories/business_repository.py b/backend/app/repositories/business_repository.py index 87f138f..f73c7f3 100644 --- a/backend/app/repositories/business_repository.py +++ b/backend/app/repositories/business_repository.py @@ -18,6 +18,7 @@ from app.models import ( OwnershipChangeLog, Personnel, Receipt, + SecondaryAllocation, Shipment, ) from app.models.customer import OWNER_TYPE_BUSINESS @@ -198,6 +199,35 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem]) session.flush() +def list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]: + """二次分配方案(详情回显;空列表=未设置方案)。""" + return list( + session.execute( + select(SecondaryAllocation) + .where(SecondaryAllocation.business_id == business_id) + .order_by(SecondaryAllocation.id) + ).scalars() + ) + + +def replace_allocations( + session: Session, business_id: int, rows: list[SecondaryAllocation] +) -> None: + """全量替换二次分配方案(不 commit,事务边界在服务层)。 + + 本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的 + 防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。 + """ + session.execute( + delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id) + ) + session.flush() + for row in rows: + row.business_id = business_id + session.add(row) + session.flush() + + def add_status_log(session: Session, log: BusinessStatusLog) -> None: """追加状态留痕(不 commit)。""" session.add(log) diff --git a/backend/app/schemas/business.py b/backend/app/schemas/business.py index eabd6b0..da08797 100644 --- a/backend/app/schemas/business.py +++ b/backend/app/schemas/business.py @@ -151,10 +151,53 @@ class BusinessOut(BaseModel): remark: str | None = None +class SecondaryAllocationIn(BaseModel): + """二次分配参与人入参(2026-10-15 补充需求「二次分配方案」)。 + + 比例**不要求合计 100%**(需求原文):余额归属待业务澄清(待澄清 #2), + 服务层仅以 hint 展示合计/余额,不做强制校验。ratio 沿金额红线口径: + 字符串入参、两位小数、超限拒绝不静默舍入。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + personnel_id: int = Field(ge=1, description="参与业务员ID(personnel.id)") + ratio: str = Field(description="分摊比例(0-100,两位小数)") + + @field_validator("ratio") + @classmethod + def _check_ratio(cls, value: str) -> str: + return _check_amount(value, "分摊比例", Decimal("100"), allow_positive_only=True) or value + + +class SecondaryAllocationOut(BaseModel): + """二次分配参与人响应(personnel_name 冗余固化,离职后回显有名)。""" + + model_config = ConfigDict(from_attributes=True) + + personnel_id: int + personnel_name: str + ratio: str + + +def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None: + """同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示 + (让 400 文案说「重复参与人」而不是裸唯一键冲突)。""" + seen: set[int] = set() + for item in items: + if item.personnel_id in seen: + raise ValueError(f"二次分配参与人重复:人员ID {item.personnel_id}") + seen.add(item.personnel_id) + + class BusinessDetailOut(BusinessOut): - """业务详情:附产品明细行。""" + """业务详情:附产品明细行与二次分配方案。""" items: list[BusinessItemOut] = Field(default_factory=list) + secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list) + allocation_hint: str | None = Field( + default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)" + ) class BusinessCreateIn(BaseModel): @@ -175,6 +218,9 @@ class BusinessCreateIn(BaseModel): is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否") special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明") items: list[BusinessItemIn] = Field(min_length=1, description="产品明细行,至少 1 行") + secondary_allocations: list[SecondaryAllocationIn] = Field( + default_factory=list, description="二次分配方案(只出现在合同刚建立时;可空)" + ) remark: str | None = Field(default=None, max_length=255) @model_validator(mode="after") @@ -184,9 +230,14 @@ class BusinessCreateIn(BaseModel): raise ValueError("特殊业务必须填写说明") return self + @model_validator(mode="after") + def _check_allocations(self) -> "BusinessCreateIn": + _ensure_allocations_unique(self.secondary_allocations) + return self + class BusinessUpdateIn(BaseModel): - """编辑业务(全量替换明细;归属不在此端点调整,走 /ownership)。""" + """编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。""" model_config = ConfigDict(str_strip_whitespace=True) @@ -207,6 +258,9 @@ class BusinessUpdateIn(BaseModel): items: list[BusinessItemIn] = Field( min_length=1, description="产品明细行,至少 1 行(全量替换)" ) + secondary_allocations: list[SecondaryAllocationIn] = Field( + default_factory=list, description="二次分配方案(全量替换;空数组=清空方案)" + ) remark: str | None = Field(default=None, max_length=255) @model_validator(mode="after") @@ -215,6 +269,11 @@ class BusinessUpdateIn(BaseModel): raise ValueError("特殊业务必须填写说明") return self + @model_validator(mode="after") + def _check_allocations(self) -> "BusinessUpdateIn": + _ensure_allocations_unique(self.secondary_allocations) + return self + class BusinessStatusIn(BaseModel): """状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。""" diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index 1cb9591..a5a29c6 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -41,6 +41,7 @@ from app.models import ( OwnershipChangeLog, Personnel, Receipt, + SecondaryAllocation, Shipment, User, ) @@ -65,6 +66,8 @@ from app.schemas.business import ( GrossProfitOut, GrossProfitRowOut, OwnershipChangeLogOut, + SecondaryAllocationIn, + SecondaryAllocationOut, ) from app.services import audit_service, param_service, personnel_service from app.services.audit_service import AuditRecord, RequestMeta @@ -257,7 +260,7 @@ def list_businesses(session: Session, query: BusinessQuery) -> PageResult[Busine def get_detail(session: Session, business_id: int) -> BusinessDetailOut: - """业务详情(附明细行,供编辑抽屉回显)。 + """业务详情(附明细行与二次分配方案,供编辑抽屉回显)。 :raises NotFoundError: 业务不存在 """ @@ -268,9 +271,53 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut: out.items = [ _item_to_out(item) for item in business_repository.list_items(session, business_id) ] + allocations = business_repository.list_allocations(session, business_id) + out.secondary_allocations = [ + SecondaryAllocationOut( + personnel_id=a.personnel_id, personnel_name=a.personnel_name, ratio=str(a.ratio) + ) + for a in allocations + ] + out.allocation_hint = _allocation_hint(allocations) return out +def _build_allocation_rows( + session: Session, business_id: int, items: list[SecondaryAllocationIn] +) -> list[SecondaryAllocation]: + """构造二次分配明细行:人员在职校验 + 姓名冗余固化 + 比例转 Decimal。 + + :raises BadRequestError: 参与人员不存在或已离职 + """ + rows: list[SecondaryAllocation] = [] + for item in items: + person = _ensure_personnel_active(session, item.personnel_id, "分摊参与") + rows.append( + SecondaryAllocation( + business_id=business_id, + personnel_id=item.personnel_id, + personnel_name=person.personnel_name, + ratio=Decimal(item.ratio), + ) + ) + return rows + + +def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None: + """方案合计/余额提示(不强制合计满额是需求原文;余额归属待澄清 #2)。 + + 无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。 + """ + if not allocations: + return None + total = sum((a.ratio for a in allocations), Decimal("0")) + total_text = total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if total == Decimal("100"): + return f"二次分配合计 {total_text}%" + rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return f"二次分配合计 {total_text}%,余额 {rest}%(归属待业务确认)" + + def create_business( session: Session, payload: BusinessCreateIn, @@ -335,6 +382,13 @@ def create_business( operator_name=operator.username, ), ) + # 二次分配方案与业务同事务(需求:只出现在合同刚建立时); + # 放在重试循环内——rollback 后方案行随业务一并重来 + business_repository.replace_allocations( + session, + business.id, + _build_allocation_rows(session, business.id, payload.secondary_allocations), + ) customer = customer_repository.get_by_id(session, business.customer_id) out = _to_out((business, customer.company_name if customer else None, None, None)) new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM @@ -408,6 +462,13 @@ def update_business( business_repository.replace_items( session, business_id, _build_item_rows(business_id, payload.items) ) + # 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的 + # 前置场景(月结在 ④,快照固化后按同一哲学隔离) + business_repository.replace_allocations( + session, + business_id, + _build_allocation_rows(session, business_id, payload.secondary_allocations), + ) _recalc_totals(session, business) # 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段 # 从首查 row 取(换客户时 _ensure_customer 已给出新公司名) diff --git a/backend/tests/integration/test_secondary_allocation.py b/backend/tests/integration/test_secondary_allocation.py new file mode 100644 index 0000000..476781d --- /dev/null +++ b/backend/tests/integration/test_secondary_allocation.py @@ -0,0 +1,211 @@ +"""二次分配方案录入(业务创建/编辑同事务)集成测试。 + +守的是(计划 T3 验收): +- 创建带方案 → 详情回显含 personnel_name 冗余与比例 +- 编辑全量替换(空数组=清空) +- 离职/不存在人员 → 400「分摊参与人员不存在或已离职」 +- 终态业务禁改方案(与编辑终态门槛同一口径) +- 合计不强制 100%:hint 展示合计与余额(余额归属待澄清 #2) +- 删除保护:被方案引用的人员 RESTRICT 禁删(400 而非静默剥离) +- 人员删除后方案回显仍有名(personnel_name 固化的意义) + +人员造数走 /api/v1/base/personnel(种子营销员岗位);离职用状态端点。 +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient + +from tests.integration.factories import business_payload, customer + +pytestmark = pytest.mark.integration + +_BUSINESSES = "/api/v1/business/businesses" +_PERSONNEL = "/api/v1/base/personnel" + + +def _person(client: TestClient, headers: dict, **overrides: object) -> dict: + role_id = overrides.pop("business_role_id", None) + body = { + "personnel_name": f"t_分摊_{uuid.uuid4().hex[:8]}", + "business_role_id": role_id or _any_role_id(), + "salary_standard": "3000.00", + **overrides, + } + resp = client.post(_PERSONNEL, json=body, headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _any_role_id() -> int: + """种子营销员岗位 ID(内置启用)。""" + from sqlalchemy import select + + from app.core.constants import BUSINESS_ROLE_SALESPERSON + from app.core.db import get_session_factory + from app.models import BusinessRole + + factory = get_session_factory() + with factory() as session: + return int( + session.execute( + select(BusinessRole.id).where( + BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON, + BusinessRole.status == 1, + ) + ).scalar_one() + ) + + +def _create_with_allocations(client: TestClient, headers: dict, allocations: list[dict]) -> dict: + cust = customer(client, headers) + resp = client.post( + _BUSINESSES, + json=business_payload(cust["id"], secondary_allocations=allocations), + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _detail(client: TestClient, headers: dict, business_id: int) -> dict: + resp = client.get(f"{_BUSINESSES}/{business_id}", headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +class TestCreateAndEcho: + """创建带方案与详情回显。""" + + def test_带方案创建_详情回显姓名与比例(self, client: TestClient, admin: dict) -> None: + p1 = _person(client, admin["headers"]) + p2 = _person(client, admin["headers"]) + data = _create_with_allocations( + client, + admin["headers"], + [ + {"personnel_id": p1["id"], "ratio": "30.00"}, + {"personnel_id": p2["id"], "ratio": "20.00"}, + ], + ) + detail = _detail(client, admin["headers"], data["id"]) + assert len(detail["secondary_allocations"]) == 2 + names = {a["personnel_name"] for a in detail["secondary_allocations"]} + assert p1["personnel_name"] in names and p2["personnel_name"] in names + ratios = sorted(a["ratio"] for a in detail["secondary_allocations"]) + assert ratios == ["20.00", "30.00"] + # 合计 50%,余额 50% —— 只提示不落库(待澄清 #2) + assert "50.00" in (detail["allocation_hint"] or "") + assert "余额" in (detail["allocation_hint"] or "") + + def test_不建方案_详情为空且无hint(self, client: TestClient, admin: dict) -> None: + data = _create_with_allocations(client, admin["headers"], []) + detail = _detail(client, admin["headers"], data["id"]) + assert detail["secondary_allocations"] == [] + assert detail["allocation_hint"] is None + + def test_合计满额_hint只报合计(self, client: TestClient, admin: dict) -> None: + p1 = _person(client, admin["headers"]) + p2 = _person(client, admin["headers"]) + data = _create_with_allocations( + client, + admin["headers"], + [ + {"personnel_id": p1["id"], "ratio": "60"}, + {"personnel_id": p2["id"], "ratio": "40.00"}, + ], + ) + hint = _detail(client, admin["headers"], data["id"])["allocation_hint"] + assert hint is not None and "余额" not in hint + + +class TestGuards: + """校验链:在职/终态/去重已由 schema 层覆盖,此处打服务层口径。""" + + def test_离职人员参与被拒(self, client: TestClient, admin: dict) -> None: + p = _person(client, admin["headers"]) + resp = client.put( + f"{_PERSONNEL}/{p['id']}/status", json={"status": 0}, headers=admin["headers"] + ) + assert resp.status_code == 200, resp.text + + cust = customer(client, admin["headers"]) + resp = client.post( + _BUSINESSES, + json=business_payload( + cust["id"], + secondary_allocations=[{"personnel_id": p["id"], "ratio": "50.00"}], + ), + headers=admin["headers"], + ) + assert resp.status_code == 400 + assert "分摊参与" in resp.json()["message"] + + def test_终态业务禁改方案(self, client: TestClient, admin: dict) -> None: + p = _person(client, admin["headers"]) + data = _create_with_allocations( + client, admin["headers"], [{"personnel_id": p["id"], "ratio": "100"}] + ) + resp = client.put( + f"{_BUSINESSES}/{data['id']}/status", + json={"status": 2}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + + detail = _detail(client, admin["headers"], data["id"]) + payload = business_payload(detail["customer_id"], secondary_allocations=[]) + for key in ("business_no", "status", "owner_personnel_id", "total_sale_amount"): + payload.pop(key, None) + resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) + assert resp.status_code == 400 + assert "禁止编辑" in resp.json()["message"] + + def test_编辑全量替换与清空(self, client: TestClient, admin: dict) -> None: + p1 = _person(client, admin["headers"]) + p2 = _person(client, admin["headers"]) + data = _create_with_allocations( + client, admin["headers"], [{"personnel_id": p1["id"], "ratio": "40.00"}] + ) + + detail = _detail(client, admin["headers"], data["id"]) + payload = business_payload( + detail["customer_id"], + secondary_allocations=[{"personnel_id": p2["id"], "ratio": "70.00"}], + ) + resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) + assert resp.status_code == 200, resp.text + + replaced = _detail(client, admin["headers"], data["id"]) + assert len(replaced["secondary_allocations"]) == 1 + assert replaced["secondary_allocations"][0]["personnel_id"] == p2["id"] + + payload = business_payload(detail["customer_id"], secondary_allocations=[]) + resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) + assert resp.status_code == 200, resp.text + assert _detail(client, admin["headers"], data["id"])["secondary_allocations"] == [] + + +class TestDeleteProtection: + """RESTRICT 外键:被方案引用的人员禁删,方案随业务删除而清。""" + + def test_被方案引用的人员禁删(self, client: TestClient, admin: dict) -> None: + p = _person(client, admin["headers"]) + data = _create_with_allocations( + client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}] + ) + resp = client.delete(f"{_PERSONNEL}/{p['id']}", headers=admin["headers"]) + assert resp.status_code == 400 + # 方案仍在,回显有名(RESTRICT 保证未静默剥离) + detail = _detail(client, admin["headers"], data["id"]) + assert detail["secondary_allocations"][0]["personnel_name"] == p["personnel_name"] + + def test_删除业务后可删人员(self, client: TestClient, admin: dict) -> None: + p = _person(client, admin["headers"]) + data = _create_with_allocations( + client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}] + ) + resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]) + assert resp.status_code == 200, resp.text # 方案 CASCADE 随业务删除 + assert client.delete(f"{_PERSONNEL}/{p['id']}", headers=admin["headers"]).status_code == 200 diff --git a/backend/tests/unit/test_secondary_allocation_schema.py b/backend/tests/unit/test_secondary_allocation_schema.py new file mode 100644 index 0000000..d4d657e --- /dev/null +++ b/backend/tests/unit/test_secondary_allocation_schema.py @@ -0,0 +1,111 @@ +"""二次分配方案入参校验(schema 层,秒级)。 + +守的是:比例红线(字符串入参、两位小数、超限拒绝不静默舍入)与 +参与人去重——uk(business_id, personnel_id) 的友好前置校验。 +明细行同批创建的正常路径由集成测试覆盖,这里集中打边界。 +""" + +import pytest +from pydantic import ValidationError + +from app.schemas.business import SecondaryAllocationIn + + +def _make(personnel_id: int = 1, ratio: str = "50.00") -> dict: + return {"personnel_id": personnel_id, "ratio": ratio} + + +class TestRatioBoundary: + """比例边界:0 / 满额 / 超限 / 精度 / 非数字。""" + + @pytest.mark.parametrize( + ("ratio", "ok"), + [ + ("0", False), # 必须大于 0 + ("0.01", True), + ("33.33", True), + ("100", True), + ("100.00", True), + ("100.01", False), # 超上限 + ("-5", False), # 负数 + ("33.333", False), # 三位小数:拒绝不静默舍入 + ("abc", False), # 非数字 + ("", False), # 空串 + ], + ) + def test_比例边界(self, ratio: str, *, ok: bool) -> None: + if ok: + assert SecondaryAllocationIn(**_make(ratio=ratio)).ratio == ratio + else: + with pytest.raises(ValidationError): + SecondaryAllocationIn(**_make(ratio=ratio)) + + def test_人员id必须为正整数(self) -> None: + with pytest.raises(ValidationError): + SecondaryAllocationIn(**_make(personnel_id=0)) + with pytest.raises(ValidationError): + SecondaryAllocationIn(**_make(personnel_id=-1)) + + +class TestDuplicate: + """参与人去重(Create/Update 两个入参模型同款校验)。""" + + def test_创建入参_重复参与人被拒(self) -> None: + from app.schemas.business import BusinessCreateIn + + payload = { + "customer_id": 1, + "items": [ + { + "product_name": "t_产品", + "product_category": "municipal_water", + "quantity": "1.00", + "sale_price": "10.00", + } + ], + "secondary_allocations": [ + _make(personnel_id=5, ratio="30.00"), + _make(personnel_id=5, ratio="20.00"), + ], + } + with pytest.raises(ValidationError, match="重复"): + BusinessCreateIn(**payload) + + def test_编辑入参_重复参与人被拒(self) -> None: + from app.schemas.business import BusinessUpdateIn + + payload = { + "customer_id": 1, + "items": [ + { + "product_name": "t_产品", + "product_category": "municipal_water", + "quantity": "1.00", + "sale_price": "10.00", + } + ], + "secondary_allocations": [_make(personnel_id=5), _make(personnel_id=5)], + } + with pytest.raises(ValidationError, match="重复"): + BusinessUpdateIn(**payload) + + def test_不同参与人合法(self) -> None: + from app.schemas.business import BusinessCreateIn + + payload = { + "customer_id": 1, + "items": [ + { + "product_name": "t_产品", + "product_category": "municipal_water", + "quantity": "1.00", + "sale_price": "10.00", + } + ], + "secondary_allocations": [ + _make(personnel_id=5, ratio="30.00"), + _make(personnel_id=6, ratio="20.00"), + ], + } + model = BusinessCreateIn(**payload) + assert len(model.secondary_allocations) == 2