test(base): 基础数据管理模块集成用例(48 例)与审计盲区修复

- 覆盖:401/引用校验/删除保护/子表全量替换/类别与组合唯一/归属变更留痕/审计双记录
- 缺陷修复:personnel/product/customer 三 service 的前置校验移入审计上下文,
  校验失败(400)同样留痕(成功与失败都记的既定原则)
- conftest 兜底清理扩展到 M2 五表(留痕无外键,按 operator_name + 前缀双轨清理)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-13 11:22:34 +08:00
1 parent 15de81dd2c
commit ca88ee7a0b
12 files changed
+1090 -29

No files matched your search

+3 -1
View File
@@ -137,5 +137,7 @@ def replace_plans(
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""全量替换拓展计划,空列表即清空。"""
personnel_service.replace_plans(session, personnel_id, payload, operator=current_user, meta=meta)
personnel_service.replace_plans(
session, personnel_id, payload, operator=current_user, meta=meta
)
return ok(message="已保存")
+1 -3
View File
@@ -97,9 +97,7 @@ def delete_product(
return ok(message="产品已删除")
@router.put(
"/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品"
)
@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品")
def set_status(
session: DbSession,
current_user: CurrentUser,
@@ -21,9 +21,7 @@ def get_by_id(session: Session, product_id: int, *, for_update: bool = False) ->
return session.get(Product, product_id)
def get_by_name_spec(
session: Session, product_name: str, spec_model: str
) -> Product | None:
def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Product | None:
"""按 (名称, 规格型号) 组合查询。
组合是 uk_products_name_spec 唯一键的键值,先查用于给出即时业务提示;
+3 -1
View File
@@ -70,7 +70,9 @@ class PersonnelCreateIn(BaseModel):
phone: str | None = Field(default=None, max_length=20, description="联系电话")
hire_date: date | None = Field(default=None, description="入职日期,ISO 格式")
is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否")
salary_standard: str | None = Field(default=None, description="月工资标准(元),字符串形式的数字")
salary_standard: str | None = Field(
default=None, description="月工资标准(元),字符串形式的数字"
)
remark: str | None = Field(default=None, max_length=255)
@field_validator("salary_standard")
+3 -1
View File
@@ -58,7 +58,9 @@ class ProductCreateIn(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
product_name: str = Field(min_length=1, max_length=128, description="产品名称")
spec_model: str | None = Field(default=None, max_length=128, description="规格型号,空归一为空串")
spec_model: str | None = Field(
default=None, max_length=128, description="规格型号,空归一为空串"
)
unit: str | None = Field(default=None, max_length=16, description="计量单位")
stock_quantity: str | None = Field(default=None, description="数量,非负数字字符串")
sale_price: str | None = Field(default=None, description="销售单价(元),非负数字字符串")
+8 -5
View File
@@ -166,10 +166,6 @@ def create_customer(
:raises BadRequestError: 客户分类不合法 / 归属人员不存在或已离职 /
「公司名称+负责人」已存在(含并发兜底)
"""
_ensure_customer_type(payload.customer_type)
owner_name: str | None = None
if payload.owner_personnel_id is not None:
owner_name = _ensure_owner_personnel(session, payload.owner_personnel_id).personnel_name
with audit_service.audit(
session,
operator=operator,
@@ -181,6 +177,12 @@ def create_customer(
),
meta=meta,
):
# 值域与归属校验在审计上下文内:失败(400)也要留痕 ——
# 与 personnel_service._validate_refs 移入上下文的口径一致
_ensure_customer_type(payload.customer_type)
owner_name: str | None = None
if payload.owner_personnel_id is not None:
owner_name = _ensure_owner_personnel(session, payload.owner_personnel_id).personnel_name
if (
customer_repository.get_by_company_owner(
session, payload.company_name, payload.owner_name
@@ -233,7 +235,6 @@ def update_customer(
:raises NotFoundError: 客户不存在
:raises BadRequestError: 客户分类不合法 / 「公司名称+负责人」已存在(含并发兜底)
"""
_ensure_customer_type(payload.customer_type)
# 只读预取在审计上下文之前:目标不存在(NotFound 失败)时也能留下可读记录,
# 摘要与回包都要用客户行;归属姓名本端点不变更,一并带出
row = customer_repository.get_with_owner_name(session, customer_id)
@@ -249,6 +250,8 @@ def update_customer(
),
meta=meta,
):
# 值域校验在审计上下文内:分类非法的 400 也要留痕(同 create 口径)
_ensure_customer_type(payload.customer_type)
if row is None:
raise NotFoundError("客户不存在")
customer, owner_name = row
+16 -7
View File
@@ -67,7 +67,10 @@ def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[Person
business_role_id=query.business_role_id,
)
return PageResult[PersonnelOut](
records=[_to_out(personnel, role_name, manager_name) for personnel, role_name, manager_name in rows],
records=[
_to_out(personnel, role_name, manager_name)
for personnel, role_name, manager_name in rows
],
total=total,
page_num=query.page_num,
page_size=query.page_size,
@@ -107,7 +110,6 @@ def create_personnel(
:raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法
"""
role, manager_name = _validate_refs(session, payload.business_role_id, payload.manager_id, None)
with audit_service.audit(
session,
operator=operator,
@@ -119,6 +121,12 @@ def create_personnel(
),
meta=meta,
):
# 引用校验放在审计上下文内:失败同样要留痕(与 update_personnel 口径
# 一致)——「谁在何时试图把人挂到停用岗位/离职经理上」是审计对象,
# 校验留在上下文外会让这类失败操作无据可查
role, manager_name = _validate_refs(
session, payload.business_role_id, payload.manager_id, None
)
personnel = Personnel(
personnel_name=payload.personnel_name,
business_role_id=payload.business_role_id,
@@ -221,10 +229,9 @@ 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):
raise BadRequestError(_DELETE_BLOCKED)
name = personnel.personnel_name # 固化:对象随删除失效
@@ -302,7 +309,9 @@ def replace_regions(
if row is None:
raise NotFoundError("人员不存在")
rows = [
PersonnelRegion(personnel_id=personnel_id, region_name=item.region_name, remark=item.remark)
PersonnelRegion(
personnel_id=personnel_id, region_name=item.region_name, remark=item.remark
)
for item in payload.items
]
personnel_repository.replace_regions(session, personnel_id, rows)
+9 -6
View File
@@ -146,7 +146,6 @@ def create_product(
:raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品
"""
_ensure_category(payload.product_category)
with audit_service.audit(
session,
operator=operator,
@@ -158,8 +157,13 @@ def create_product(
),
meta=meta,
):
# 值域校验在审计上下文内:类别非法的 400 也要留痕(成功与失败都记)
_ensure_category(payload.product_category)
spec_model = payload.spec_model or "" # 归一空串:组合唯一键对 NULL 不去重
if product_repository.get_by_name_spec(session, payload.product_name, spec_model) is not None:
if (
product_repository.get_by_name_spec(session, payload.product_name, spec_model)
is not None
):
raise BadRequestError("已存在同名称同规格的产品")
product = Product(
@@ -182,9 +186,7 @@ def create_product(
session.rollback()
_raise_if_duplicate(exc)
logger.info(
"新增产品 | product_id=%s | category=%s", product.id, payload.product_category
)
logger.info("新增产品 | product_id=%s | category=%s", product.id, payload.product_category)
return out
@@ -202,7 +204,6 @@ def update_product(
:raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品
"""
product = product_repository.get_by_id(session, product_id)
_ensure_category(payload.product_category)
with audit_service.audit(
session,
operator=operator,
@@ -214,6 +215,8 @@ def update_product(
),
meta=meta,
):
# 值域校验在审计上下文内:类别非法的 400 也要留痕(同 create 口径)
_ensure_category(payload.product_category)
if product is None:
raise NotFoundError("产品不存在")