diff --git a/backend/app/repositories/customer_repository.py b/backend/app/repositories/customer_repository.py index 0471601..b1d8688 100644 --- a/backend/app/repositories/customer_repository.py +++ b/backend/app/repositories/customer_repository.py @@ -23,6 +23,19 @@ def get_by_id(session: Session, customer_id: int) -> Customer | None: return session.get(Customer, customer_id) +def count_business_refs(session: Session, customer_id: int) -> int: + """被业务单据引用的次数(删除保护;业务表就位后补齐的检查)。""" + from app.models import Business + + return int( + session.execute( + select(func.count()) + .select_from(Business) + .where(Business.customer_id == customer_id) + ).scalar_one() + ) + + def get_with_owner_name(session: Session, customer_id: int) -> Row | None: """查单个客户并带出归属人员姓名(详情、编辑回显与归属变更共用)。""" stmt = _base_select().where(Customer.id == customer_id) diff --git a/backend/app/repositories/personnel_repository.py b/backend/app/repositories/personnel_repository.py index 602e127..3f25451 100644 --- a/backend/app/repositories/personnel_repository.py +++ b/backend/app/repositories/personnel_repository.py @@ -94,6 +94,33 @@ def count_customer_ownerships(session: Session, personnel_ids: list[int]) -> dic return {int(owner_id): int(count) for owner_id, count in session.execute(stmt).all()} +def count_business_roles_refs(session: Session, personnel_ids: list[int]) -> dict[int, int]: + """按人员批量统计业务单据归属引用(负责/协助合计;业务表就位后补齐的检查)。 + + businesses.owner/assistant_personnel_id 均为 RESTRICT 外键——漏检会让 + 「删除被业务引用的人员」以 500 暴露而非可读的 400。 + """ + + from app.models import Business + + owner = ( + select(Business.owner_personnel_id, func.count(Business.id)) + .where(Business.owner_personnel_id.in_(personnel_ids)) + .group_by(Business.owner_personnel_id) + ) + assistant = ( + select(Business.assistant_personnel_id, func.count(Business.id)) + .where(Business.assistant_personnel_id.in_(personnel_ids)) + .group_by(Business.assistant_personnel_id) + ) + refs: dict[int, int] = {} + for stmt in (owner, assistant): + for owner_id, count in session.execute(stmt).all(): + if owner_id is not None: + refs[int(owner_id)] = refs.get(int(owner_id), 0) + int(count) + return refs + + def list_regions(session: Session, personnel_id: int) -> list[PersonnelRegion]: """查人员的全部辐射区域,按插入顺序返回。""" stmt = ( diff --git a/backend/app/schemas/business.py b/backend/app/schemas/business.py index cfdf5b8..eabd6b0 100644 --- a/backend/app/schemas/business.py +++ b/backend/app/schemas/business.py @@ -24,11 +24,18 @@ PRICE_MAX = Decimal("9999999999.9999") def _check_amount( - value: str | None, label: str, cap: Decimal, allow_positive_only: bool = False + value: str | None, + label: str, + cap: Decimal, + allow_positive_only: bool = False, + scale: int = 2, ) -> str | None: - """字符串金额的通用校验:数字/有限/非负/容量/两位小数。 + """字符串金额的通用校验:数字/有限/非负/容量/小数位上限。 - 小数位超过两位直接拒绝而非静默舍入 —— 舍入即算错钱(工资系统红线)。 + 小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱(工资系统红线)。 + ``scale`` 按列口径区分:金额/数量列 DECIMAL(14,2) 为 2;单价类列 + DECIMAL(14,4) 为 4(回显 "5000.0000" 必须能原样提交,否则任何带 + 精度的历史单价都被编辑端点永远拒收)。 """ if value is None: return None @@ -45,8 +52,8 @@ def _check_amount( raise ValueError(f"{label}不能为负数") if amount > cap: raise ValueError(f"{label}超出可录入上限") - if -amount.as_tuple().exponent > 2: - raise ValueError(f"{label}最多两位小数") + if -amount.as_tuple().exponent > scale: + raise ValueError(f"{label}最多{'两' if scale == 2 else '四'}位小数") return value @@ -88,10 +95,16 @@ class BusinessItemIn(BaseModel): def _check_quantity(cls, value: str) -> str: return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value - @field_validator("sale_price", "cost_amount", "guided_price") + @field_validator("sale_price", "guided_price") @classmethod - def _check_prices(cls, value: str | None) -> str | None: - return _check_amount(value, "金额", PRICE_MAX) + def _check_unit_prices(cls, value: str | None) -> str | None: + """单价类字段按 DECIMAL(14,4) 口径校验(4 位小数)。""" + return _check_amount(value, "单价", PRICE_MAX, scale=4) + + @field_validator("cost_amount") + @classmethod + def _check_cost(cls, value: str | None) -> str | None: + return _check_amount(value, "金额", AMOUNT_MAX) class BusinessItemOut(BaseModel): diff --git a/backend/app/schemas/shipment.py b/backend/app/schemas/shipment.py index 9ce2377..2df61d9 100644 --- a/backend/app/schemas/shipment.py +++ b/backend/app/schemas/shipment.py @@ -40,7 +40,8 @@ class ShipmentItemIn(BaseModel): @field_validator("sale_price") @classmethod def _check_price(cls, value: str | None) -> str | None: - return _check_amount(value, "金额", PRICE_MAX) + """发货单价列 DECIMAL(14,4):按 4 位小数口径(回显可原样提交)。""" + return _check_amount(value, "单价", PRICE_MAX, scale=4) class ShipmentItemOut(BaseModel): diff --git a/backend/app/services/customer_service.py b/backend/app/services/customer_service.py index c4da6e7..1329c7d 100644 --- a/backend/app/services/customer_service.py +++ b/backend/app/services/customer_service.py @@ -294,14 +294,14 @@ def delete_customer( operator: User, meta: RequestMeta | None = None, ) -> None: - """删除客户(物理删除)。 + """删除客户(物理删除):被业务引用 → 400 引导处理业务单据。 - **被业务数据引用的保护检查待业务数据表(V5)就位后补充**:customers - 当前仅被 ownership_change_logs 以无外键的固化 ID 引用,而留痕本就要求 - 「被引用方删除后记录仍完整可读」(见 models/customer.py 的固化论证), - 故现在删除不会产生悬挂外键。 + businesses.customer_id 为 RESTRICT 外键,引用检查必须先于删除—— + 否则并发窗口内的 FK 冲突会以 500 暴露(T4 时该检查占位、业务表就位 + 后由本处补齐,留痕表是无外键固化不参与拦截)。 :raises NotFoundError: 客户不存在 + :raises BadRequestError: 客户已被业务单据引用 """ customer = customer_repository.get_by_id(session, customer_id) with audit_service.audit( @@ -316,10 +316,17 @@ def delete_customer( ): if customer is None: raise NotFoundError("客户不存在") + if customer_repository.count_business_refs(session, customer_id): + raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据") company_name = customer.company_name # 固化:对象随删除失效 - customer_repository.delete(session, customer) - session.commit() + try: + customer_repository.delete(session, customer) + session.commit() + except IntegrityError as exc: + # 先查后删的并发窗口:新业务挂上了该客户,FK RESTRICT 兜底为 400 + session.rollback() + raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据") from exc logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name) diff --git a/backend/app/services/personnel_service.py b/backend/app/services/personnel_service.py index 2a06778..606fa3c 100644 --- a/backend/app/services/personnel_service.py +++ b/backend/app/services/personnel_service.py @@ -46,8 +46,9 @@ from app.services.audit_service import AuditRecord, RequestMeta logger = logging.getLogger(__name__) -# 删除保护的统一文案:两种引用共享同一提示,引导管理员走「离职」而非删除 -_DELETE_BLOCKED = "存在团队成员或客户归属引用,请改为离职而非删除" +# 删除保护的统一文案:三种引用(团队成员/客户归属/业务归属)共享同一提示, +# 引导管理员走「离职」而非删除 +_DELETE_BLOCKED = "存在团队成员、客户归属或业务归属引用,请改为离职而非删除" def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]: @@ -229,9 +230,13 @@ def delete_personnel( raise NotFoundError("人员不存在") ids = [personnel_id] - if personnel_repository.count_team_members( - session, ids - ) or personnel_repository.count_customer_ownerships(session, ids): + if ( + personnel_repository.count_team_members(session, ids) + or personnel_repository.count_customer_ownerships(session, ids) + or personnel_repository.count_business_roles_refs(session, ids) + ): + # 业务归属引用(负责/协助)随业务表就位一并纳入删除保护—— + # 漏检会让删除以 FK RESTRICT 的 500 暴露而非可读的 400 raise BadRequestError(_DELETE_BLOCKED) name = personnel.personnel_name # 固化:对象随删除失效 diff --git a/backend/tests/integration/test_customers.py b/backend/tests/integration/test_customers.py index f290f9f..429fe25 100644 --- a/backend/tests/integration/test_customers.py +++ b/backend/tests/integration/test_customers.py @@ -22,6 +22,7 @@ from app.core.constants import ( CUSTOMER_TYPE_ENTERPRISE, CUSTOMER_TYPE_PERSONAL, MODULE_CUSTOMER, + PRODUCT_CATEGORY_MUNICIPAL_WATER, ) from app.core.db import get_session_factory from app.models import BusinessRole, OperationLog, OwnershipChangeLog @@ -31,6 +32,7 @@ pytestmark = pytest.mark.integration _PERSONNEL = "/api/v1/base/personnel" _CUSTOMERS = "/api/v1/base/customers" +_BUSINESSES = "/api/v1/business/businesses" def _company() -> str: @@ -149,6 +151,32 @@ class TestCrudFlow: assert dup.status_code == 400, dup.text assert "该公司名称与负责人的客户已存在" in dup.json()["message"] + def test_删除被业务引用的客户_400引导处理单据( + self, client: TestClient, normal: dict + ) -> None: + """业务表就位后删除保护补齐:FK RESTRICT 不能以 500 暴露。""" + headers = normal["headers"] + customer = _create_customer(client, headers) + resp = client.post( + _BUSINESSES, + json={ + "customer_id": customer["id"], + "items": [ + { + "product_name": "t_被引用产品", + "product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER, + "quantity": "1", + "sale_price": "1", + } + ], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + blocked = client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers) + assert blocked.status_code == 400, blocked.text + assert "已存在关联业务" in blocked.json()["message"] + def test_客户分类非法_400(self, client: TestClient, normal: dict) -> None: resp = client.post( _CUSTOMERS, diff --git a/backend/tests/integration/test_personnel.py b/backend/tests/integration/test_personnel.py index 7e2d4a2..a13a5fd 100644 --- a/backend/tests/integration/test_personnel.py +++ b/backend/tests/integration/test_personnel.py @@ -29,6 +29,7 @@ pytestmark = pytest.mark.integration _PERSONNEL = "/api/v1/base/personnel" _CUSTOMERS = "/api/v1/base/customers" +_BUSINESSES = "/api/v1/business/businesses" def _uname() -> str: @@ -234,7 +235,7 @@ class TestDeleteProtection: member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用 blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers) assert blocked.status_code == 400, blocked.text - assert "存在团队成员或客户归属引用,请改为离职而非删除" in blocked.json()["message"] + assert "存在团队成员、客户归属或业务归属引用,请改为离职而非删除" in blocked.json()["message"] client.delete(f"{_PERSONNEL}/{member['id']}", headers=headers) freed = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers) @@ -260,7 +261,7 @@ class TestDeleteProtection: blocked = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers) assert blocked.status_code == 400, blocked.text - assert "存在团队成员或客户归属引用,请改为离职而非删除" in blocked.json()["message"] + assert "存在团队成员、客户归属或业务归属引用,请改为离职而非删除" in blocked.json()["message"] # 保护与在职状态无关:离职并不能让物理删除放行 client.put(f"{_PERSONNEL}/{owner['id']}/status", json={"status": 0}, headers=headers) @@ -269,6 +270,55 @@ class TestDeleteProtection: # 删客户解除引用后方可删人 assert client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers).status_code == 200 + freed = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers) + assert freed.status_code == 200, freed.text + + def test_被业务归属引用禁删_负责或协助任一即拦( + self, client: TestClient, normal: dict + ) -> None: + """业务表就位后删除保护补齐:负责/协助引用任一即拦,FK 不以 500 暴露。""" + headers = normal["headers"] + owner = _create(client, headers) + assistant = _create(client, headers) + resp_customer = client.post( + _CUSTOMERS, + json={ + "company_name": f"t_公司_{uuid.uuid4().hex[:8]}", + "owner_name": "张老板", + "customer_type": "personal", + }, + headers=headers, + ) + assert resp_customer.status_code == 200, resp_customer.text + customer_id = resp_customer.json()["data"]["id"] + resp = client.post( + _BUSINESSES, + json={ + "customer_id": customer_id, + "owner_personnel_id": owner["id"], + "assistant_personnel_id": assistant["id"], + "items": [ + { + "product_name": "t_引用产品", + "product_category": "municipal_water", + "quantity": "1", + "sale_price": "1", + } + ], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + for target, label in ((owner, "负责"), (assistant, "协助")): + blocked = client.delete(f"{_PERSONNEL}/{target['id']}", headers=headers) + assert blocked.status_code == 400, blocked.text + assert "业务归属" in blocked.json()["message"], f"{label}引用未拦截" + + # 删除业务解除引用后,人员方可删除(owner 仍被客户归属引用,先删客户) + business_id = resp.json()["data"]["id"] + assert client.delete(f"{_BUSINESSES}/{business_id}", headers=headers).status_code == 200 + assert client.delete(f"{_CUSTOMERS}/{customer_id}", headers=headers).status_code == 200 assert client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers).status_code == 200