fix(business): 删除保护补齐与单价小数位口径修复(e2e 发现)
- 客户删除保护:被业务引用 → 400(T4 占位注释在业务表就位后漏补,FK 500 暴露) - 人员删除保护:业务归属(负责/协助)引用纳入检查(同样漏检 500) - schema 单价类列(DECIMAL 14,4)小数位放行至 4 位:回显 '5000.0000' 原样提交被两位校验永久拒收(编辑任何带精度单价的历史行都失败) - 补 2 个集成用例锁定;文案同步「客户归属或业务归属引用」 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
299b72483c
commit
fba8cfc215
@ -23,6 +23,19 @@ def get_by_id(session: Session, customer_id: int) -> Customer | None:
|
|||||||
return session.get(Customer, customer_id)
|
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:
|
def get_with_owner_name(session: Session, customer_id: int) -> Row | None:
|
||||||
"""查单个客户并带出归属人员姓名(详情、编辑回显与归属变更共用)。"""
|
"""查单个客户并带出归属人员姓名(详情、编辑回显与归属变更共用)。"""
|
||||||
stmt = _base_select().where(Customer.id == customer_id)
|
stmt = _base_select().where(Customer.id == customer_id)
|
||||||
|
|||||||
@ -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()}
|
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]:
|
def list_regions(session: Session, personnel_id: int) -> list[PersonnelRegion]:
|
||||||
"""查人员的全部辐射区域,按插入顺序返回。"""
|
"""查人员的全部辐射区域,按插入顺序返回。"""
|
||||||
stmt = (
|
stmt = (
|
||||||
|
|||||||
@ -24,11 +24,18 @@ PRICE_MAX = Decimal("9999999999.9999")
|
|||||||
|
|
||||||
|
|
||||||
def _check_amount(
|
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:
|
) -> str | None:
|
||||||
"""字符串金额的通用校验:数字/有限/非负/容量/两位小数。
|
"""字符串金额的通用校验:数字/有限/非负/容量/小数位上限。
|
||||||
|
|
||||||
小数位超过两位直接拒绝而非静默舍入 —— 舍入即算错钱(工资系统红线)。
|
小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱(工资系统红线)。
|
||||||
|
``scale`` 按列口径区分:金额/数量列 DECIMAL(14,2) 为 2;单价类列
|
||||||
|
DECIMAL(14,4) 为 4(回显 "5000.0000" 必须能原样提交,否则任何带
|
||||||
|
精度的历史单价都被编辑端点永远拒收)。
|
||||||
"""
|
"""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@ -45,8 +52,8 @@ def _check_amount(
|
|||||||
raise ValueError(f"{label}不能为负数")
|
raise ValueError(f"{label}不能为负数")
|
||||||
if amount > cap:
|
if amount > cap:
|
||||||
raise ValueError(f"{label}超出可录入上限")
|
raise ValueError(f"{label}超出可录入上限")
|
||||||
if -amount.as_tuple().exponent > 2:
|
if -amount.as_tuple().exponent > scale:
|
||||||
raise ValueError(f"{label}最多两位小数")
|
raise ValueError(f"{label}最多{'两' if scale == 2 else '四'}位小数")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@ -88,10 +95,16 @@ class BusinessItemIn(BaseModel):
|
|||||||
def _check_quantity(cls, value: str) -> str:
|
def _check_quantity(cls, value: str) -> str:
|
||||||
return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value
|
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
|
@classmethod
|
||||||
def _check_prices(cls, value: str | None) -> str | None:
|
def _check_unit_prices(cls, value: str | None) -> str | None:
|
||||||
return _check_amount(value, "金额", PRICE_MAX)
|
"""单价类字段按 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):
|
class BusinessItemOut(BaseModel):
|
||||||
|
|||||||
@ -40,7 +40,8 @@ class ShipmentItemIn(BaseModel):
|
|||||||
@field_validator("sale_price")
|
@field_validator("sale_price")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _check_price(cls, value: str | None) -> str | None:
|
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):
|
class ShipmentItemOut(BaseModel):
|
||||||
|
|||||||
@ -294,14 +294,14 @@ def delete_customer(
|
|||||||
operator: User,
|
operator: User,
|
||||||
meta: RequestMeta | None = None,
|
meta: RequestMeta | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""删除客户(物理删除)。
|
"""删除客户(物理删除):被业务引用 → 400 引导处理业务单据。
|
||||||
|
|
||||||
**被业务数据引用的保护检查待业务数据表(V5)就位后补充**:customers
|
businesses.customer_id 为 RESTRICT 外键,引用检查必须先于删除——
|
||||||
当前仅被 ownership_change_logs 以无外键的固化 ID 引用,而留痕本就要求
|
否则并发窗口内的 FK 冲突会以 500 暴露(T4 时该检查占位、业务表就位
|
||||||
「被引用方删除后记录仍完整可读」(见 models/customer.py 的固化论证),
|
后由本处补齐,留痕表是无外键固化不参与拦截)。
|
||||||
故现在删除不会产生悬挂外键。
|
|
||||||
|
|
||||||
:raises NotFoundError: 客户不存在
|
:raises NotFoundError: 客户不存在
|
||||||
|
:raises BadRequestError: 客户已被业务单据引用
|
||||||
"""
|
"""
|
||||||
customer = customer_repository.get_by_id(session, customer_id)
|
customer = customer_repository.get_by_id(session, customer_id)
|
||||||
with audit_service.audit(
|
with audit_service.audit(
|
||||||
@ -316,10 +316,17 @@ def delete_customer(
|
|||||||
):
|
):
|
||||||
if customer is None:
|
if customer is None:
|
||||||
raise NotFoundError("客户不存在")
|
raise NotFoundError("客户不存在")
|
||||||
|
if customer_repository.count_business_refs(session, customer_id):
|
||||||
|
raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据")
|
||||||
|
|
||||||
company_name = customer.company_name # 固化:对象随删除失效
|
company_name = customer.company_name # 固化:对象随删除失效
|
||||||
customer_repository.delete(session, customer)
|
try:
|
||||||
session.commit()
|
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)
|
logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -46,8 +46,9 @@ from app.services.audit_service import AuditRecord, RequestMeta
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 删除保护的统一文案:两种引用共享同一提示,引导管理员走「离职」而非删除
|
# 删除保护的统一文案:三种引用(团队成员/客户归属/业务归属)共享同一提示,
|
||||||
_DELETE_BLOCKED = "存在团队成员或客户归属引用,请改为离职而非删除"
|
# 引导管理员走「离职」而非删除
|
||||||
|
_DELETE_BLOCKED = "存在团队成员、客户归属或业务归属引用,请改为离职而非删除"
|
||||||
|
|
||||||
|
|
||||||
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
|
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
|
||||||
@ -229,9 +230,13 @@ def delete_personnel(
|
|||||||
raise NotFoundError("人员不存在")
|
raise NotFoundError("人员不存在")
|
||||||
|
|
||||||
ids = [personnel_id]
|
ids = [personnel_id]
|
||||||
if personnel_repository.count_team_members(
|
if (
|
||||||
session, ids
|
personnel_repository.count_team_members(session, ids)
|
||||||
) or personnel_repository.count_customer_ownerships(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)
|
raise BadRequestError(_DELETE_BLOCKED)
|
||||||
|
|
||||||
name = personnel.personnel_name # 固化:对象随删除失效
|
name = personnel.personnel_name # 固化:对象随删除失效
|
||||||
|
|||||||
@ -22,6 +22,7 @@ from app.core.constants import (
|
|||||||
CUSTOMER_TYPE_ENTERPRISE,
|
CUSTOMER_TYPE_ENTERPRISE,
|
||||||
CUSTOMER_TYPE_PERSONAL,
|
CUSTOMER_TYPE_PERSONAL,
|
||||||
MODULE_CUSTOMER,
|
MODULE_CUSTOMER,
|
||||||
|
PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
||||||
)
|
)
|
||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
from app.models import BusinessRole, OperationLog, OwnershipChangeLog
|
from app.models import BusinessRole, OperationLog, OwnershipChangeLog
|
||||||
@ -31,6 +32,7 @@ pytestmark = pytest.mark.integration
|
|||||||
|
|
||||||
_PERSONNEL = "/api/v1/base/personnel"
|
_PERSONNEL = "/api/v1/base/personnel"
|
||||||
_CUSTOMERS = "/api/v1/base/customers"
|
_CUSTOMERS = "/api/v1/base/customers"
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
|
||||||
|
|
||||||
def _company() -> str:
|
def _company() -> str:
|
||||||
@ -149,6 +151,32 @@ class TestCrudFlow:
|
|||||||
assert dup.status_code == 400, dup.text
|
assert dup.status_code == 400, dup.text
|
||||||
assert "该公司名称与负责人的客户已存在" in dup.json()["message"]
|
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:
|
def test_客户分类非法_400(self, client: TestClient, normal: dict) -> None:
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
_CUSTOMERS,
|
_CUSTOMERS,
|
||||||
|
|||||||
@ -29,6 +29,7 @@ pytestmark = pytest.mark.integration
|
|||||||
|
|
||||||
_PERSONNEL = "/api/v1/base/personnel"
|
_PERSONNEL = "/api/v1/base/personnel"
|
||||||
_CUSTOMERS = "/api/v1/base/customers"
|
_CUSTOMERS = "/api/v1/base/customers"
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
|
||||||
|
|
||||||
def _uname() -> str:
|
def _uname() -> str:
|
||||||
@ -234,7 +235,7 @@ class TestDeleteProtection:
|
|||||||
member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用
|
member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用
|
||||||
blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers)
|
blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers)
|
||||||
assert blocked.status_code == 400, blocked.text
|
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)
|
client.delete(f"{_PERSONNEL}/{member['id']}", headers=headers)
|
||||||
freed = client.delete(f"{_PERSONNEL}/{manager['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)
|
blocked = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
|
||||||
assert blocked.status_code == 400, blocked.text
|
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)
|
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
|
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
|
assert client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user