feat/m3-increments #1

Merged
liuwenxin merged 100 commits from feat/m3-increments into main 2026-10-19 04:59:57 -04:00
8 changed files with 167 additions and 23 deletions
Showing only changes of commit fba8cfc215 - Show all commits

View File

@ -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)

View File

@ -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 = (

View File

@ -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):

View File

@ -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):

View File

@ -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)

View File

@ -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 # 固化:对象随删除失效

View File

@ -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,

View File

@ -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