Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94c49138bf | ||
|
|
f75a9d534e | ||
|
|
0ef3adec21 | ||
|
|
60f6af6494 | ||
|
|
409e26614f | ||
|
|
5ef48abf47 | ||
|
|
7f8f88c148 | ||
|
|
c2b462df3b | ||
|
|
e6c2c71d1b | ||
|
|
91fc6f6293 | ||
|
|
9a70264b51 | ||
|
|
6d612daf30 | ||
|
|
a0086554b1 | ||
|
|
5a3778a424 | ||
|
|
cb977979da | ||
|
|
64c57e9141 | ||
|
|
d8dba27fb2 |
No files matched your search
@@ -96,7 +96,7 @@ def delete_customer(
|
||||
) -> ApiResponse[None]:
|
||||
"""删除客户(物理删除)。
|
||||
|
||||
被业务数据引用的保护检查待业务数据表(V5)就位后补充。
|
||||
已存在关联业务时返回 400(先处理相关业务单据)。
|
||||
"""
|
||||
customer_service.delete_customer(session, customer_id, operator=current_user, meta=meta)
|
||||
return ok(message="客户已删除")
|
||||
|
||||
@@ -91,7 +91,7 @@ def delete_product(
|
||||
) -> ApiResponse[None]:
|
||||
"""物理删除产品(仅限误录数据,常规下架用停用)。
|
||||
|
||||
被业务明细引用的保护检查待业务数据表(V5)就位后补充。
|
||||
被业务明细/发货明细引用时返回 400(引导改走停用)。
|
||||
"""
|
||||
product_service.delete_product(session, product_id, operator=current_user, meta=meta)
|
||||
return ok(message="产品已删除")
|
||||
|
||||
@@ -50,9 +50,7 @@ def create_shipment(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{shipment_id}", response_model=ApiResponse[ShipmentDetailOut], summary="发货单详情"
|
||||
)
|
||||
@router.get("/{shipment_id}", response_model=ApiResponse[ShipmentDetailOut], summary="发货单详情")
|
||||
def get_shipment(
|
||||
session: DbSession,
|
||||
_: CurrentUser,
|
||||
|
||||
@@ -76,6 +76,7 @@ MODULE_RATING = "客户评级"
|
||||
NO_PREFIX_BUSINESS = "YW"
|
||||
NO_PREFIX_SHIPMENT = "SH"
|
||||
NO_PREFIX_RECEIPT = "HK"
|
||||
NO_PREFIX_PARAM = "P"
|
||||
|
||||
ACTION_CREATE = "新增"
|
||||
ACTION_UPDATE = "修改"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""数据库完整性错误的统一识别与翻译口径。
|
||||
|
||||
判断依据:pymysql 原生异常 ``orig.args`` 首位是 MySQL 错误码 —— 只有
|
||||
1062(ER_DUP_ENTRY,唯一键冲突)是「已存在」语义,可安全转 400 业务
|
||||
提示;其余完整性错误(外键 RESTRICT、数据截断等)原样上抛,由全局
|
||||
异常处理器收敛 500(程序缺陷或数据异常,均不应伪装成用户错误)。
|
||||
"""
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
# MySQL 唯一键冲突错误码(pymysql 原生异常 args 首位)
|
||||
ERR_DUP_ENTRY = 1062
|
||||
|
||||
|
||||
def is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(错误码 1062)。"""
|
||||
args = getattr(exc.orig, "args", ())
|
||||
return bool(args) and args[0] == ERR_DUP_ENTRY
|
||||
@@ -12,6 +12,7 @@ import logging
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
|
||||
@@ -60,6 +61,19 @@ async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSON
|
||||
return _build_response(exc.status_code, message)
|
||||
|
||||
|
||||
async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONResponse:
|
||||
"""完整性错误全局兜底为 400。
|
||||
|
||||
service 层已判别的 1062/引用冲突会自行翻译为业务提示,不会走到这里;
|
||||
本 handler 拦截的是「新增父表后某 service 漏补引用保护」一类的遗漏
|
||||
(e2e 撞见一个补一个的模式无法保证全覆盖):FK RESTRICT 等数据完整性
|
||||
冲突对用户表现为「这行数据被别处引用/冲突」,转 400 比 500 更贴近事实。
|
||||
完整异常记录日志供排查;不在此处 rollback —— 会话由请求级依赖统一关闭。
|
||||
"""
|
||||
logger.error("数据库完整性冲突(未在 service 层翻译)", exc_info=exc)
|
||||
return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝")
|
||||
|
||||
|
||||
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
|
||||
"""兜底处理未预期异常。
|
||||
|
||||
@@ -77,4 +91,5 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
|
||||
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
|
||||
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
|
||||
app.add_exception_handler(IntegrityError, handle_integrity_error) # type: ignore[arg-type]
|
||||
app.add_exception_handler(Exception, handle_unexpected_exception)
|
||||
@@ -22,6 +22,10 @@ from app.models.base import (
|
||||
mysql_table_args,
|
||||
)
|
||||
|
||||
# personnel.status 的在职值(对照 BUSINESS_STATUS_* 集中在 models/business.py
|
||||
# 的先例:状态语义单点导出,业务/客户两侧的归属校验共用,防魔数各自漂移)
|
||||
PERSONNEL_STATUS_ACTIVE = 1
|
||||
|
||||
|
||||
class Personnel(Base, TimestampMixin, TPlusSyncMixin):
|
||||
"""营销人员表(需求 2.1.1)。
|
||||
|
||||
@@ -7,7 +7,7 @@ models/customer.py 定义)。
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Row, Select, delete, func, or_, select
|
||||
from sqlalchemy import Row, Select, delete, exists, func, or_, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import (
|
||||
@@ -17,18 +17,36 @@ from app.models import (
|
||||
Customer,
|
||||
OwnershipChangeLog,
|
||||
Personnel,
|
||||
Receipt,
|
||||
Shipment,
|
||||
)
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
from app.repositories import ownership_log_repository
|
||||
|
||||
|
||||
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
|
||||
"""按主键取业务行;for_update 供删除保护与状态机使用。"""
|
||||
"""按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。"""
|
||||
stmt = select(Business).where(Business.id == business_id)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
return session.execute(stmt).scalars().first()
|
||||
|
||||
|
||||
def has_docs(session: Session, business_id: int) -> bool:
|
||||
"""业务名下是否已有发货单或回款(换客户/删除保护的引用检查)。
|
||||
|
||||
单条 OR exists 一次往返;新增挂靠业务的单据类型时在此补充条件,
|
||||
编辑与删除两处共用同一判定,防「delete 补了检查、update 漏补」的分叉。
|
||||
"""
|
||||
stmt = select(
|
||||
or_(
|
||||
exists().where(Shipment.business_id == business_id),
|
||||
exists().where(Receipt.business_id == business_id),
|
||||
)
|
||||
)
|
||||
return bool(session.execute(stmt).scalar())
|
||||
|
||||
|
||||
def create(session: Session, business: Business) -> None:
|
||||
"""新增业务行(不 commit,flush 让自增 id 就位)。"""
|
||||
session.add(business)
|
||||
@@ -36,19 +54,26 @@ def create(session: Session, business: Business) -> None:
|
||||
|
||||
|
||||
def get_with_names(
|
||||
session: Session, business_id: int
|
||||
session: Session, business_id: int, *, for_update: bool = False
|
||||
) -> tuple[Business, str, str | None, str | None] | None:
|
||||
"""业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。"""
|
||||
"""业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。
|
||||
|
||||
for_update 供编辑/状态流转/归属变更使用:与发货/回款写路径的
|
||||
get_by_id(for_update=True) 同一锁口径,编辑事务与单据写事务互斥,
|
||||
「有发货禁换客户」的快照读校验不再被并发新增旁路。
|
||||
"""
|
||||
owner = aliased(Personnel)
|
||||
assistant = aliased(Personnel)
|
||||
row = session.execute(
|
||||
stmt = (
|
||||
select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name)
|
||||
.join(Customer, Customer.id == Business.customer_id)
|
||||
.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
||||
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
|
||||
.where(Business.id == business_id)
|
||||
).first()
|
||||
return row
|
||||
)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
return session.execute(stmt).first()
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
@@ -173,18 +198,6 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem])
|
||||
session.flush()
|
||||
|
||||
|
||||
def count_items(session: Session, business_ids: list[int]) -> dict[int, int]:
|
||||
"""业务明细行数(删除保护备用)。"""
|
||||
if not business_ids:
|
||||
return {}
|
||||
rows = session.execute(
|
||||
select(BusinessItem.business_id, func.count())
|
||||
.where(BusinessItem.business_id.in_(business_ids))
|
||||
.group_by(BusinessItem.business_id)
|
||||
).all()
|
||||
return dict(rows)
|
||||
|
||||
|
||||
def add_status_log(session: Session, log: BusinessStatusLog) -> None:
|
||||
"""追加状态留痕(不 commit)。"""
|
||||
session.add(log)
|
||||
@@ -217,41 +230,28 @@ def count_status_logs(session: Session, business_id: int) -> int:
|
||||
|
||||
|
||||
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None:
|
||||
"""追加归属变更留痕(不 commit)。"""
|
||||
session.add(log)
|
||||
session.flush()
|
||||
"""追加归属变更留痕(共享实现,不 commit)。"""
|
||||
ownership_log_repository.add(session, log)
|
||||
|
||||
|
||||
def list_ownership_logs(
|
||||
session: Session, business_id: int, *, offset: int, limit: int
|
||||
) -> list[Row]:
|
||||
"""某业务的归属变更留痕(join 人员出姓名;人员删除后为 None,留痕固化 ID 兜底)。"""
|
||||
old_p = aliased(Personnel)
|
||||
new_p = aliased(Personnel)
|
||||
return list(
|
||||
session.execute(
|
||||
select(OwnershipChangeLog, old_p.personnel_name, new_p.personnel_name)
|
||||
.outerjoin(old_p, old_p.id == OwnershipChangeLog.old_owner_personnel_id)
|
||||
.outerjoin(new_p, new_p.id == OwnershipChangeLog.new_owner_personnel_id)
|
||||
.where(
|
||||
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
|
||||
OwnershipChangeLog.business_id == business_id,
|
||||
)
|
||||
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).all()
|
||||
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
|
||||
return ownership_log_repository.list_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
ref_column=OwnershipChangeLog.business_id,
|
||||
ref_id=business_id,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
def count_ownership_logs(session: Session, business_id: int) -> int:
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(OwnershipChangeLog)
|
||||
.where(
|
||||
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
|
||||
OwnershipChangeLog.business_id == business_id,
|
||||
)
|
||||
).scalar_one()
|
||||
return ownership_log_repository.count_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
ref_column=OwnershipChangeLog.business_id,
|
||||
ref_id=business_id,
|
||||
)
|
||||
@@ -25,6 +25,20 @@ def get_by_code(session: Session, role_code: str) -> BusinessRole | None:
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, BusinessRole]:
|
||||
"""按编码批量取岗位(参数批次校验段用,消除逐条 N+1)。
|
||||
|
||||
返回 {编码: 岗位}。MySQL ci 校对下编码大小写变形也能命中,
|
||||
键取库里的规范 role_code,调用方据此把维度值归一为规范值。
|
||||
"""
|
||||
if not role_codes:
|
||||
return {}
|
||||
rows = session.execute(
|
||||
select(BusinessRole).where(BusinessRole.role_code.in_(role_codes))
|
||||
).scalars()
|
||||
return {role.role_code: role for role in rows}
|
||||
|
||||
|
||||
def list_roles(
|
||||
session: Session,
|
||||
*,
|
||||
|
||||
@@ -4,17 +4,13 @@
|
||||
"""
|
||||
|
||||
from sqlalchemy import ColumnElement, Row, Select, func, or_, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Customer, OwnershipChangeLog, Personnel
|
||||
from app.models.customer import OWNER_TYPE_CUSTOMER
|
||||
from app.repositories import ownership_log_repository
|
||||
from app.repositories.query_utils import fuzzy
|
||||
|
||||
# 归属留痕表对人员 ID 无外键固化:新旧人员姓名靠对 personnel 的两次外联带出,
|
||||
# 人员被删除后姓名为 null,行仍完整可读
|
||||
_old_owner = aliased(Personnel)
|
||||
_new_owner = aliased(Personnel)
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
|
||||
@@ -29,9 +25,7 @@ def count_business_refs(session: Session, customer_id: int) -> int:
|
||||
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(Business)
|
||||
.where(Business.customer_id == customer_id)
|
||||
select(func.count()).select_from(Business).where(Business.customer_id == customer_id)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
@@ -104,33 +98,25 @@ def list_ownership_logs(
|
||||
offset: int,
|
||||
limit: int,
|
||||
) -> list[Row]:
|
||||
"""查某客户的归属变更留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。
|
||||
|
||||
按 (create_time, id) 双键倒序:create_time 是秒级精度,同一秒内多条
|
||||
记录排序不确定,id 兜底保证翻页稳定(与操作日志同一理由)。
|
||||
"""
|
||||
stmt = (
|
||||
select(OwnershipChangeLog, _old_owner.personnel_name, _new_owner.personnel_name)
|
||||
.outerjoin(_old_owner, OwnershipChangeLog.old_owner_personnel_id == _old_owner.id)
|
||||
.outerjoin(_new_owner, OwnershipChangeLog.new_owner_personnel_id == _new_owner.id)
|
||||
.where(
|
||||
OwnershipChangeLog.owner_type == OWNER_TYPE_CUSTOMER,
|
||||
OwnershipChangeLog.customer_id == customer_id,
|
||||
)
|
||||
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
|
||||
return ownership_log_repository.list_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
ref_column=OwnershipChangeLog.customer_id,
|
||||
ref_id=customer_id,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
|
||||
"""统计某客户的归属留痕总数,供分页使用。"""
|
||||
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
||||
OwnershipChangeLog.owner_type == OWNER_TYPE_CUSTOMER,
|
||||
OwnershipChangeLog.customer_id == customer_id,
|
||||
return ownership_log_repository.count_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
ref_column=OwnershipChangeLog.customer_id,
|
||||
ref_id=customer_id,
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _base_select() -> Select:
|
||||
@@ -188,7 +174,5 @@ def delete(session: Session, customer: Customer) -> None:
|
||||
|
||||
|
||||
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
|
||||
"""写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。"""
|
||||
session.add(log)
|
||||
session.flush()
|
||||
return log
|
||||
"""写入一条归属变更留痕(共享实现)。只 flush 不 commit,与主表更新同事务。"""
|
||||
return ownership_log_repository.add(session, log)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""归属变更留痕的共享数据访问(客户/业务两维度共用)。
|
||||
|
||||
owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧各持一套逐行
|
||||
同构的 list/count/add,排序口径与 join 兜底的调整须同步两处;收口后
|
||||
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
|
||||
"""
|
||||
|
||||
from sqlalchemy import Row, func, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import OwnershipChangeLog, Personnel
|
||||
|
||||
|
||||
def list_by_ref(
|
||||
session: Session,
|
||||
*,
|
||||
owner_type: int,
|
||||
ref_column,
|
||||
ref_id: int,
|
||||
offset: int,
|
||||
limit: int,
|
||||
) -> list[Row]:
|
||||
"""查某引用对象的归属留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。
|
||||
|
||||
人员删除后 join 出 None,留痕固化 ID 兜底可读。按 (create_time, id)
|
||||
双键倒序:create_time 是秒级精度,同一秒内多条排序不确定,id 兜底
|
||||
保证翻页稳定(与操作日志同一理由)。
|
||||
"""
|
||||
old_person = aliased(Personnel)
|
||||
new_person = aliased(Personnel)
|
||||
stmt = (
|
||||
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
|
||||
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
|
||||
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
|
||||
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
|
||||
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_by_ref(
|
||||
session: Session, *, owner_type: int, ref_column, ref_id: int
|
||||
) -> int:
|
||||
"""统计某引用对象的归属留痕总数,供分页使用。"""
|
||||
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
||||
OwnershipChangeLog.owner_type == owner_type,
|
||||
ref_column == ref_id,
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def add(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
|
||||
"""写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。"""
|
||||
session.add(log)
|
||||
session.flush()
|
||||
return log
|
||||
@@ -81,6 +81,21 @@ def get_definition_by_code(session: Session, param_code: str) -> ParamDefinition
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_definitions_by_codes(
|
||||
session: Session, param_codes: list[str]
|
||||
) -> dict[str, ParamDefinition]:
|
||||
"""按编码批量取参数定义(提交批次的校验段用,消除逐条 N+1)。
|
||||
|
||||
返回 {编码: 定义};未定义的编码不在结果中,由调用方判缺失。
|
||||
"""
|
||||
if not param_codes:
|
||||
return {}
|
||||
rows = session.execute(
|
||||
select(ParamDefinition).where(ParamDefinition.param_code.in_(param_codes))
|
||||
).scalars()
|
||||
return {definition.param_code: definition for definition in rows}
|
||||
|
||||
|
||||
def list_history(
|
||||
session: Session,
|
||||
param_id: int,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Product
|
||||
from app.models import BusinessItem, Product, ShipmentItem
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@@ -100,6 +100,25 @@ def _apply_filters(
|
||||
return stmt
|
||||
|
||||
|
||||
def count_refs(session: Session, product_id: int) -> int:
|
||||
"""统计引用该产品的业务明细与发货明细行数(删除保护,business_id 先例)。
|
||||
|
||||
两张明细表对 products 均 RESTRICT 外键:先查给出可读的 400;校验与
|
||||
删除之间的并发引用由锁行 + FK 兜底(service 层再翻译 IntegrityError)。
|
||||
"""
|
||||
stmt = select(
|
||||
select(func.count())
|
||||
.select_from(BusinessItem)
|
||||
.where(BusinessItem.product_id == product_id)
|
||||
.scalar_subquery()
|
||||
+ select(func.count())
|
||||
.select_from(ShipmentItem)
|
||||
.where(ShipmentItem.product_id == product_id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
# ==================== 写入 ====================
|
||||
|
||||
|
||||
@@ -111,6 +130,6 @@ def create(session: Session, product: Product) -> Product:
|
||||
|
||||
|
||||
def delete(session: Session, product: Product) -> None:
|
||||
"""物理删除产品。调用方须先完成引用保护校验(V5 就位后生效)。"""
|
||||
"""物理删除产品。调用方须先完成引用保护校验(count_refs + FK 兜底)。"""
|
||||
session.delete(product)
|
||||
session.flush()
|
||||
@@ -11,14 +11,21 @@ from app.models import CustomerRatingGrade
|
||||
# ==================== 读取 ====================
|
||||
|
||||
|
||||
def list_all(session: Session) -> list[CustomerRatingGrade]:
|
||||
def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRatingGrade]:
|
||||
"""全量等级,按 sort_order 升序、id 兜底(同序次序稳定)。
|
||||
|
||||
不分页:等级映射是判定表,数量个位数,分页无意义。
|
||||
|
||||
for_update 供区间重叠判定使用:先查后写在无锁时,两管理员并发提交
|
||||
重叠区间会互判不重叠而双双入库(区间重叠无唯一键可拦)——锁全表行
|
||||
串行化并发的重叠判定(先到者提交后,后到者在锁下读到新区间即判重叠)。
|
||||
只读直出(list_grades)不加锁。
|
||||
"""
|
||||
stmt = select(CustomerRatingGrade).order_by(
|
||||
CustomerRatingGrade.sort_order, CustomerRatingGrade.id
|
||||
)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
|
||||
@@ -88,9 +88,7 @@ def list_receipts(
|
||||
is_barter=is_barter,
|
||||
settlement_status=settlement_status,
|
||||
)
|
||||
return list(
|
||||
session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all()
|
||||
)
|
||||
return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all())
|
||||
|
||||
|
||||
def count_receipts(
|
||||
|
||||
@@ -59,9 +59,7 @@ def list_shipments(
|
||||
) -> list[tuple[Shipment, str]]:
|
||||
"""分页查询发货单(附业务号),按 id 倒序。"""
|
||||
stmt = _apply_filters(
|
||||
select(Shipment, Business.business_no).join(
|
||||
Business, Business.id == Shipment.business_id
|
||||
),
|
||||
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
|
||||
business_id=business_id,
|
||||
ship_date_from=ship_date_from,
|
||||
ship_date_to=ship_date_to,
|
||||
|
||||
@@ -7,29 +7,11 @@ Decimal 转换统一收敛在 service 层。字段命名与 user/role/param 一
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
def _parse_non_negative(value: str, *, label: str) -> str:
|
||||
"""非负数字字符串校验:必须可被 Decimal 解析、为有限数且 ≥ 0。
|
||||
|
||||
``NaN`` 参与比较会抛 InvalidOperation、``Infinity`` 会在后续运算溢出,
|
||||
均须在入口用 ``is_finite()`` 拦下(与 schemas/param.py 的 _parse_amount
|
||||
同一口径)。
|
||||
"""
|
||||
try:
|
||||
amount = Decimal(value.strip())
|
||||
except InvalidOperation as exc:
|
||||
raise ValueError(f"{label}必须是有效数字:{value}") from exc
|
||||
if not amount.is_finite():
|
||||
raise ValueError(f"{label}必须为有限数字:{value}")
|
||||
if amount < 0:
|
||||
raise ValueError(f"{label}不得为负数:{value}")
|
||||
return value
|
||||
from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount
|
||||
|
||||
|
||||
class ProductOut(BaseModel):
|
||||
@@ -70,18 +52,27 @@ class ProductCreateIn(BaseModel):
|
||||
@field_validator("stock_quantity")
|
||||
@classmethod
|
||||
def _check_stock_quantity(cls, value: str | None) -> str | None:
|
||||
"""空串与 None 同义(可空列),归一为 None;非空则须为非负数字。"""
|
||||
"""空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
|
||||
|
||||
复用 _check_amount:容量/小数位与 DECIMAL(14,2) 列口径一致
|
||||
(此前私写的 _parse_non_negative 不查小数位与容量,属同语义
|
||||
字段两处口径漂移)。
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
return _parse_non_negative(value, label="数量")
|
||||
return _check_amount(value, "数量", AMOUNT_MAX)
|
||||
|
||||
@field_validator("sale_price")
|
||||
@classmethod
|
||||
def _check_sale_price(cls, value: str | None) -> str | None:
|
||||
"""空串与 None 同义(可空列),归一为 None;非空则须为非负数字。"""
|
||||
"""空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
|
||||
|
||||
scale=4 与列口径 Numeric(14,4) 一致 —— 带精度的历史单价必须能
|
||||
原样提交(「编辑任何带精度历史行都失败」的 fba8cfc 教训)。
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
return _parse_non_negative(value, label="销售单价")
|
||||
return _check_amount(value, "销售单价", PRICE_MAX, scale=4)
|
||||
|
||||
|
||||
class ProductUpdateIn(ProductCreateIn):
|
||||
|
||||
@@ -30,9 +30,7 @@ class ReceiptCreateIn(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
business_id: int = Field(ge=1, description="业务ID")
|
||||
shipment_id: int | None = Field(
|
||||
default=None, ge=1, description="发货单ID,可空=暂未指定"
|
||||
)
|
||||
shipment_id: int | None = Field(default=None, ge=1, description="发货单ID,可空=暂未指定")
|
||||
receipt_date: date = Field(description="回款到账日(M3 按此日匹配参数版本)")
|
||||
amount: str = Field(description="回款金额(元),≥0,最多两位小数")
|
||||
is_barter: int = Field(default=0, ge=0, le=1, description="以物抵顶标记:1是 0否")
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
|
||||
from sqlalchemy import Row, func, select
|
||||
@@ -30,6 +30,7 @@ from app.core.constants import (
|
||||
MODULE_BUSINESS,
|
||||
NO_PREFIX_BUSINESS,
|
||||
)
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageQuery, PageResult
|
||||
from app.models import (
|
||||
@@ -48,7 +49,7 @@ from app.models.business import (
|
||||
BUSINESS_STATUS_DONE,
|
||||
)
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
from app.repositories import business_repository, customer_repository, personnel_repository
|
||||
from app.repositories import business_repository, customer_repository
|
||||
from app.schemas.business import (
|
||||
BusinessCreateIn,
|
||||
BusinessDetailOut,
|
||||
@@ -65,7 +66,7 @@ from app.schemas.business import (
|
||||
GrossProfitRowOut,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
from app.services import audit_service, param_service
|
||||
from app.services import audit_service, param_service, personnel_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.formula import converted_profit, profit_rate
|
||||
from app.services.no_generator import next_no
|
||||
@@ -76,9 +77,6 @@ logger = logging.getLogger(__name__)
|
||||
# 重试是为极端场景(锁等待超时后重入)兜底 —— 无限重试反而掩盖死锁类缺陷
|
||||
_NO_RETRY = 1
|
||||
|
||||
# personnel.status 的在职值(与 customer_service 同口径,注释见彼处)
|
||||
_PERSONNEL_STATUS_ACTIVE = 1
|
||||
|
||||
|
||||
def _to_out(row: tuple) -> BusinessOut:
|
||||
"""(business, company_name, owner_name, assistant_name) → 响应模型。
|
||||
@@ -135,12 +133,19 @@ def _build_item_rows(business_id: int, items: list[BusinessItemIn]) -> list[Busi
|
||||
|
||||
不信任前端传值(schema 亦无 sale_amount 入参)——单价可空时销售额记 0,
|
||||
由用户在编辑中补价后重算(毛利润视图中销售额 0 的行全部为 0,防除零)。
|
||||
乘积显式 quantize 两位固化:数量(2位)×单价(4位) 可产 6 位小数,直写
|
||||
DECIMAL(14,2) 会被 MySQL 静默舍入且回显漂移(创建响应读内存值、复查
|
||||
读库值前后不一致);HALF_UP 与 MySQL 的 DECIMAL 舍入方向一致。
|
||||
"""
|
||||
rows: list[BusinessItem] = []
|
||||
for item in items:
|
||||
quantity = Decimal(item.quantity)
|
||||
sale_price = Decimal(item.sale_price) if item.sale_price is not None else None
|
||||
sale_amount = (quantity * sale_price) if sale_price is not None else Decimal("0")
|
||||
sale_amount = (
|
||||
(quantity * sale_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if sale_price is not None
|
||||
else Decimal("0.00")
|
||||
)
|
||||
rows.append(
|
||||
BusinessItem(
|
||||
business_id=business_id,
|
||||
@@ -206,11 +211,8 @@ def _generate_no(session: Session) -> str:
|
||||
|
||||
|
||||
def _ensure_personnel_active(session: Session, personnel_id: int, label: str) -> Personnel:
|
||||
"""归属人员须存在且在职(人员侧复用校验文案口径)。"""
|
||||
person = personnel_repository.get_by_id(session, personnel_id)
|
||||
if person is None or person.status != _PERSONNEL_STATUS_ACTIVE:
|
||||
raise BadRequestError(f"{label}人员不存在或已离职")
|
||||
return person
|
||||
"""归属人员须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
|
||||
return personnel_service.ensure_active(session, personnel_id, label=label)
|
||||
|
||||
|
||||
def _ensure_customer(session: Session, customer_id: int) -> Customer:
|
||||
@@ -221,8 +223,7 @@ def _ensure_customer(session: Session, customer_id: int) -> Customer:
|
||||
|
||||
|
||||
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
args = getattr(exc.orig, "args", ())
|
||||
return bool(args) and args[0] == 1062
|
||||
return is_duplicate_entry(exc)
|
||||
|
||||
|
||||
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
|
||||
@@ -301,58 +302,54 @@ def create_business(
|
||||
if payload.assistant_personnel_id is not None:
|
||||
_ensure_personnel_active(session, payload.assistant_personnel_id, "协助")
|
||||
|
||||
try:
|
||||
business = Business(
|
||||
business_no=_generate_no(session),
|
||||
customer_id=payload.customer_id,
|
||||
business_date=payload.business_date,
|
||||
status=BUSINESS_STATUS_ACTIVE,
|
||||
owner_personnel_id=payload.owner_personnel_id,
|
||||
assistant_personnel_id=payload.assistant_personnel_id,
|
||||
is_special=payload.is_special,
|
||||
special_note=payload.special_note,
|
||||
remark=payload.remark,
|
||||
)
|
||||
business_repository.create(session, business)
|
||||
business_repository.replace_items(
|
||||
session, business.id, _build_item_rows(business.id, payload.items)
|
||||
)
|
||||
_recalc_totals(session, business)
|
||||
business_repository.add_status_log(
|
||||
session,
|
||||
BusinessStatusLog(
|
||||
business_id=business.id,
|
||||
old_status=BUSINESS_STATUS_ACTIVE,
|
||||
new_status=BUSINESS_STATUS_ACTIVE,
|
||||
operator_id=operator.id,
|
||||
operator_name=operator.username,
|
||||
),
|
||||
)
|
||||
customer = customer_repository.get_by_id(session, business.customer_id)
|
||||
out = _to_out((business, customer.company_name if customer else None, None, None))
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
if _is_duplicate_entry(exc):
|
||||
# 编号生成的残余竞态:唯一键兜底重试一次(防无限重试掩盖死锁)
|
||||
return _retry_create(session, payload, operator=operator, meta=meta)
|
||||
raise
|
||||
# 编号唯一键冲突的重试整体收在审计上下文内:rollback 后同一事务重来,
|
||||
# 成功/最终失败各只记一条审计(吞异常 return 会被记成假成功,重入
|
||||
# 整个 create_* 则违反「不支持嵌套」约定);上限 _NO_RETRY 防无限重试
|
||||
# 掩盖死锁类缺陷。审计摘要预取的是首次编号——重试成功时入库编号见
|
||||
# 应用日志(AuditRecord frozen,不回填)。
|
||||
for attempt in range(_NO_RETRY + 1):
|
||||
try:
|
||||
business = Business(
|
||||
business_no=_generate_no(session),
|
||||
customer_id=payload.customer_id,
|
||||
business_date=payload.business_date,
|
||||
status=BUSINESS_STATUS_ACTIVE,
|
||||
owner_personnel_id=payload.owner_personnel_id,
|
||||
assistant_personnel_id=payload.assistant_personnel_id,
|
||||
is_special=payload.is_special,
|
||||
special_note=payload.special_note,
|
||||
remark=payload.remark,
|
||||
)
|
||||
business_repository.create(session, business)
|
||||
business_repository.replace_items(
|
||||
session, business.id, _build_item_rows(business.id, payload.items)
|
||||
)
|
||||
_recalc_totals(session, business)
|
||||
business_repository.add_status_log(
|
||||
session,
|
||||
BusinessStatusLog(
|
||||
business_id=business.id,
|
||||
old_status=BUSINESS_STATUS_ACTIVE,
|
||||
new_status=BUSINESS_STATUS_ACTIVE,
|
||||
operator_id=operator.id,
|
||||
operator_name=operator.username,
|
||||
),
|
||||
)
|
||||
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
|
||||
session.commit()
|
||||
break
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
if _is_duplicate_entry(exc) and attempt < _NO_RETRY:
|
||||
continue
|
||||
raise
|
||||
|
||||
logger.info("创建业务 | business_id=%s | no=%s", business.id, business.business_no)
|
||||
logger.info("创建业务 | business_id=%s | no=%s", new_id, new_no)
|
||||
return out
|
||||
|
||||
|
||||
def _retry_create(
|
||||
session: Session,
|
||||
payload: BusinessCreateIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None,
|
||||
) -> BusinessOut:
|
||||
"""编号撞唯一键后的单次重试(防无限重试掩盖死锁类缺陷)。"""
|
||||
return create_business(session, payload, operator=operator, meta=meta)
|
||||
|
||||
|
||||
def update_business(
|
||||
session: Session,
|
||||
business_id: int,
|
||||
@@ -367,9 +364,9 @@ def update_business(
|
||||
发货/回款语义断裂(明细同理随业务走)。
|
||||
|
||||
:raises NotFoundError: 业务不存在
|
||||
:raises BadRequestError: 归属人员不合法 / 有发货或回款时换客户
|
||||
:raises BadRequestError: 业务已完成或已终止 / 归属人员不合法 / 有发货或回款时换客户
|
||||
"""
|
||||
row = business_repository.get_with_names(session, business_id)
|
||||
row = business_repository.get_with_names(session, business_id, for_update=True)
|
||||
business = row[0] if row else None
|
||||
with audit_service.audit(
|
||||
session,
|
||||
@@ -384,18 +381,18 @@ def update_business(
|
||||
):
|
||||
if row is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
business, _, _, _ = row
|
||||
business, _, owner_name, assistant_name = row
|
||||
|
||||
if payload.customer_id != business.customer_id:
|
||||
has_docs = session.query(Shipment.id).filter(
|
||||
Shipment.business_id == business_id
|
||||
).first() is not None or (
|
||||
session.query(Receipt.id).filter(Receipt.business_id == business_id).first()
|
||||
is not None
|
||||
)
|
||||
if has_docs:
|
||||
raise BadRequestError("业务已有发货或回款记录,不能更换客户")
|
||||
_ensure_customer(session, payload.customer_id)
|
||||
# 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线,
|
||||
# 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务)
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
raise BadRequestError("业务已完成或已终止,禁止编辑业务")
|
||||
|
||||
if payload.customer_id != business.customer_id and business_repository.has_docs(
|
||||
session, business_id
|
||||
):
|
||||
raise BadRequestError("业务已有发货或回款记录,不能更换客户")
|
||||
customer = _ensure_customer(session, payload.customer_id)
|
||||
if payload.owner_personnel_id is not None:
|
||||
_ensure_personnel_active(session, payload.owner_personnel_id, "负责")
|
||||
if payload.assistant_personnel_id is not None:
|
||||
@@ -412,7 +409,9 @@ def update_business(
|
||||
session, business_id, _build_item_rows(business_id, payload.items)
|
||||
)
|
||||
_recalc_totals(session, business)
|
||||
out = _to_out(business_repository.get_with_names(session, business_id))
|
||||
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
||||
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
|
||||
out = _to_out((business, customer.company_name, owner_name, assistant_name))
|
||||
session.commit()
|
||||
logger.info("修改业务 | business_id=%s", business_id)
|
||||
return out
|
||||
@@ -446,12 +445,7 @@ def delete_business(
|
||||
):
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
if (
|
||||
session.query(Shipment.id).filter(Shipment.business_id == business_id).first()
|
||||
is not None
|
||||
or session.query(Receipt.id).filter(Receipt.business_id == business_id).first()
|
||||
is not None
|
||||
):
|
||||
if business_repository.has_docs(session, business_id):
|
||||
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
|
||||
|
||||
business_no = business.business_no
|
||||
@@ -488,7 +482,7 @@ def change_status(
|
||||
:raises NotFoundError: 业务不存在
|
||||
:raises BadRequestError: 非执行中状态流转 / 目标与当前相同
|
||||
"""
|
||||
row = business_repository.get_with_names(session, business_id)
|
||||
row = business_repository.get_with_names(session, business_id, for_update=True)
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
@@ -526,7 +520,8 @@ def change_status(
|
||||
operator_name=operator.username,
|
||||
),
|
||||
)
|
||||
out = _to_out(business_repository.get_with_names(session, business_id))
|
||||
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新状态
|
||||
out = _to_out(row)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"业务状态变更 | business_id=%s | %s -> %s",
|
||||
@@ -553,7 +548,7 @@ def change_ownership(
|
||||
:raises NotFoundError: 业务不存在
|
||||
:raises BadRequestError: 归属人员不存在或已离职 / 归属未变化
|
||||
"""
|
||||
row = business_repository.get_with_names(session, business_id)
|
||||
row = business_repository.get_with_names(session, business_id, for_update=True)
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
@@ -567,11 +562,19 @@ def change_ownership(
|
||||
):
|
||||
if row is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
business, _, _, _ = row
|
||||
if payload.new_owner_personnel_id is not None:
|
||||
business, _, old_owner_name, old_assistant_name = row
|
||||
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
|
||||
# 固化标量,新归属人名必须按新 ID 重取
|
||||
new_owner = (
|
||||
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
|
||||
if payload.new_assistant_personnel_id is not None:
|
||||
if payload.new_owner_personnel_id is not None
|
||||
else None
|
||||
)
|
||||
new_assistant = (
|
||||
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
|
||||
if payload.new_assistant_personnel_id is not None
|
||||
else None
|
||||
)
|
||||
|
||||
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
|
||||
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
|
||||
@@ -596,7 +599,16 @@ def change_ownership(
|
||||
operator_name=operator.username,
|
||||
),
|
||||
)
|
||||
out = _to_out(business_repository.get_with_names(session, business_id))
|
||||
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
|
||||
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
|
||||
out = _to_out(
|
||||
(
|
||||
business,
|
||||
row[1],
|
||||
new_owner.personnel_name if new_owner is not None else old_owner_name,
|
||||
new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
|
||||
|
||||
@@ -26,6 +26,7 @@ from app.core.constants import (
|
||||
CUSTOMER_TYPES,
|
||||
MODULE_CUSTOMER,
|
||||
)
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageQuery, PageResult
|
||||
from app.models import Customer, OwnershipChangeLog, Personnel, User
|
||||
@@ -39,15 +40,11 @@ from app.schemas.customer import (
|
||||
CustomerUpdateIn,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
from app.services import audit_service
|
||||
from app.services import audit_service, personnel_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# personnel.status 的在职值(列注释口径:1在职 0离职)。离职人员不可再被
|
||||
# 客户归属引用;人员侧抽出共享常量后此处替换
|
||||
_PERSONNEL_STATUS_ACTIVE = 1
|
||||
|
||||
|
||||
def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
|
||||
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
|
||||
@@ -94,28 +91,18 @@ def _ensure_customer_type(customer_type: str) -> None:
|
||||
|
||||
|
||||
def _ensure_owner_personnel(session: Session, personnel_id: int) -> Personnel:
|
||||
"""归属人员必须存在且在职,返回人员行。
|
||||
|
||||
「不存在」与「已离职」合并为同一文案:人员 ID 是否有效不构成需要
|
||||
向调用方区分的信息(同登录防枚举的思路)。
|
||||
|
||||
:raises BadRequestError: 归属人员不存在或已离职
|
||||
"""
|
||||
person = personnel_repository.get_by_id(session, personnel_id)
|
||||
if person is None or person.status != _PERSONNEL_STATUS_ACTIVE:
|
||||
raise BadRequestError("归属人员不存在或已离职")
|
||||
return person
|
||||
"""归属人员必须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
|
||||
return personnel_service.ensure_active(session, personnel_id, label="归属")
|
||||
|
||||
|
||||
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(错误码 1062)。
|
||||
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(1062)。
|
||||
|
||||
customers 上还有归属人员外键(RESTRICT),并发窗口内删除人员等场景
|
||||
会抛 1452 —— 不能一并翻译成「已存在」,否则用户会被错误提示带偏;
|
||||
非 1062 原样上抛,由全局处理器按 500 收敛。
|
||||
非 1062 原样上抛,由全局 IntegrityError handler 收敛 400(通用文案)。
|
||||
"""
|
||||
args = getattr(exc.orig, "args", ())
|
||||
return bool(args) and args[0] == 1062
|
||||
return is_duplicate_entry(exc)
|
||||
|
||||
|
||||
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
|
||||
|
||||
@@ -40,7 +40,12 @@ def salesperson_rate(
|
||||
start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。
|
||||
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
|
||||
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
|
||||
|
||||
先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出
|
||||
高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days
|
||||
的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。
|
||||
"""
|
||||
days = max(days, 0)
|
||||
if days > boundary_days:
|
||||
return floor / 1000
|
||||
return (start - days * slope) / 1000
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
@@ -35,10 +35,13 @@ def next_no(
|
||||
"""
|
||||
like_prefix = today_prefix(prefix)
|
||||
column = getattr(model, column_name)
|
||||
# 排序按「长度优先、同级字典序」:同前缀下序号位数增多(9999→10000)后
|
||||
# 纯字典序会反转("-10000" < "-9999",'1'<'9'),seq 将恒重算出同一号码、
|
||||
# 每笔建单都撞唯一键 —— 长度大即数值大,同级同长时字典序与数值序一致
|
||||
max_no = session.execute(
|
||||
select(column)
|
||||
.where(column.like(f"{like_prefix}%"))
|
||||
.order_by(column.desc())
|
||||
.order_by(func.length(column).desc(), column.desc())
|
||||
.limit(1)
|
||||
.with_for_update()
|
||||
).scalar()
|
||||
|
||||
@@ -13,7 +13,6 @@ from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
from io import BytesIO
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -21,6 +20,7 @@ from app.core.constants import (
|
||||
ACTION_DELETE,
|
||||
ACTION_UPDATE,
|
||||
MODULE_PARAM,
|
||||
NO_PREFIX_PARAM,
|
||||
PRODUCT_CATEGORIES,
|
||||
)
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
@@ -35,6 +35,7 @@ from app.schemas.param import (
|
||||
ParamCatalogItemOut,
|
||||
ParamChangeBatchOut,
|
||||
ParamChangeIn,
|
||||
ParamChangeItemIn,
|
||||
ParamDiffOut,
|
||||
ParamDimensionValueOut,
|
||||
ParamHistoryOut,
|
||||
@@ -46,6 +47,7 @@ from app.schemas.param import (
|
||||
from app.services import audit_service, formula
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.formula import to_decimal
|
||||
from app.services.no_generator import next_no
|
||||
from app.services.param_registry import get_spec, parse_fraction, validate_and_normalize
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -187,19 +189,65 @@ def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCat
|
||||
# ==================== 变更与版本管理 ====================
|
||||
|
||||
|
||||
def _next_version_no(session: Session) -> str:
|
||||
"""生成批次版本号 P{YYYYMMDD}-{当日序号}(当日自增,两位补零)。"""
|
||||
today = date.today()
|
||||
prefix = f"P{today:%Y%m%d}"
|
||||
seq = 0
|
||||
for (version_no,) in session.execute(
|
||||
select(ParamChangeBatch.version_no).where(ParamChangeBatch.version_no.like(f"{prefix}-%"))
|
||||
):
|
||||
try:
|
||||
seq = max(seq, int(version_no.rsplit("-", 1)[1]))
|
||||
except (IndexError, ValueError): # 手工造数容错:跳过不合规格的版本号
|
||||
continue
|
||||
return f"{prefix}-{seq + 1:02d}"
|
||||
def _normalize_items(
|
||||
session: Session, items: list[ParamChangeItemIn]
|
||||
) -> list[tuple[ParamDefinition, str, str]]:
|
||||
"""全量校验条目并归一为 (定义, 规范维度值, 规范值),任何一项不合法整批拒绝。
|
||||
|
||||
定义与岗位各一条 IN 批量查询预载(逐条查询在批次扩大时是写侧 N+1);
|
||||
岗位预载收集全部非空维度值 —— 集合可能略大于实际所需,但省去按维度
|
||||
类型先分类的第二轮遍历,无害多取由校验逻辑自行过滤。
|
||||
|
||||
:raises BadRequestError: 参数未定义 / 维度不合法 / 值校验失败 / 同批次重复条目
|
||||
"""
|
||||
codes = list({item.param_code for item in items})
|
||||
definitions = param_repository.list_definitions_by_codes(session, codes)
|
||||
dimension_values = list({item.dimension_value for item in items if item.dimension_value})
|
||||
# 预载字典键归一为 casefold:单条版 get_by_code 走 SQL ci 校对(大小写
|
||||
# 变形可命中),批量版改字典查找后须自行等价归一,否则变形大小写会
|
||||
# 被误判「不是已存在的岗位」;岗位编码均为 ascii 小写蛇形,casefold 足够
|
||||
roles = {
|
||||
code.casefold(): role
|
||||
for code, role in business_role_repository.list_by_codes(session, dimension_values).items()
|
||||
}
|
||||
normalized: list[tuple[ParamDefinition, str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for item in items:
|
||||
definition = definitions.get(item.param_code)
|
||||
if definition is None:
|
||||
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
|
||||
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
|
||||
# ci 校对规则下大小写变形也能命中库,此后一律用库里的规范值:
|
||||
# 注册表 get_spec 与快照字典均按精确键匹配,原始大小写入库会导致
|
||||
# 取值链静默不命中(按错误参数算钱且无报错)
|
||||
spec = get_spec(definition.param_code)
|
||||
dimension = item.dimension_value
|
||||
if spec.dimension_type == "NONE" and dimension != "":
|
||||
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
|
||||
if spec.dimension_type == "BUSINESS_ROLE" and dimension:
|
||||
role = roles.get(dimension.casefold())
|
||||
if role is None:
|
||||
raise BadRequestError(
|
||||
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
|
||||
)
|
||||
dimension = role.role_code
|
||||
if (
|
||||
spec.dimension_type == "PRODUCT_CATEGORY"
|
||||
and dimension
|
||||
and dimension not in PRODUCT_CATEGORIES
|
||||
):
|
||||
raise BadRequestError(
|
||||
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
|
||||
)
|
||||
value = validate_and_normalize(spec, item.value)
|
||||
key = (item.param_code, dimension)
|
||||
if key in seen:
|
||||
raise BadRequestError(
|
||||
f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})"
|
||||
)
|
||||
seen.add(key)
|
||||
normalized.append((definition, dimension, value))
|
||||
return normalized
|
||||
|
||||
|
||||
def submit_change(
|
||||
@@ -233,47 +281,21 @@ def submit_change(
|
||||
raise BadRequestError("指定生效日期不得早于今日")
|
||||
|
||||
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
|
||||
normalized: list[tuple[ParamDefinition, str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for item in payload.items:
|
||||
definition = param_repository.get_definition_by_code(session, item.param_code)
|
||||
if definition is None:
|
||||
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
|
||||
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
|
||||
spec = get_spec(item.param_code)
|
||||
dimension = item.dimension_value
|
||||
if spec.dimension_type == "NONE" and dimension != "":
|
||||
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
|
||||
if (
|
||||
spec.dimension_type == "BUSINESS_ROLE"
|
||||
and dimension
|
||||
and business_role_repository.get_by_code(session, dimension) is None
|
||||
):
|
||||
raise BadRequestError(
|
||||
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
|
||||
)
|
||||
if (
|
||||
spec.dimension_type == "PRODUCT_CATEGORY"
|
||||
and dimension
|
||||
and dimension not in PRODUCT_CATEGORIES
|
||||
):
|
||||
raise BadRequestError(
|
||||
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
|
||||
)
|
||||
value = validate_and_normalize(spec, item.value)
|
||||
key = (item.param_code, dimension)
|
||||
if key in seen:
|
||||
raise BadRequestError(
|
||||
f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})"
|
||||
)
|
||||
seen.add(key)
|
||||
normalized.append((definition, dimension, value))
|
||||
normalized = _normalize_items(session, payload.items)
|
||||
|
||||
try:
|
||||
batch = param_repository.create_batch(
|
||||
session,
|
||||
ParamChangeBatch(
|
||||
version_no=_next_version_no(session),
|
||||
# 版本号与三单据同口径:MAX FOR UPDATE 锁当日序号区间;
|
||||
# 残余并发冲突由下方 IntegrityError 分支翻译为可重试 400
|
||||
version_no=next_no(
|
||||
session,
|
||||
model=ParamChangeBatch,
|
||||
column_name="version_no",
|
||||
prefix=NO_PREFIX_PARAM,
|
||||
seq_width=2,
|
||||
),
|
||||
effective_date=effective,
|
||||
change_type=payload.change_type,
|
||||
remark=payload.remark,
|
||||
@@ -299,10 +321,8 @@ def submit_change(
|
||||
batch_id=batch.id,
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
# version_no 唯一键的并发冲突:当日序号是无锁查询后 +1,两名管理员
|
||||
# 并发提交会生成相同版本号 —— 回滚并翻译为可重试的 400
|
||||
# version_no 唯一键的并发冲突:回滚并翻译为可重试的 400
|
||||
session.rollback()
|
||||
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
|
||||
session.commit()
|
||||
@@ -503,7 +523,9 @@ def _preview_rate(
|
||||
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
||||
"""
|
||||
if payload.customer_type == "enterprise":
|
||||
rate = take("enterprise.rate")
|
||||
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
|
||||
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
|
||||
rate = take("enterprise.rate") / 1000
|
||||
return rate, "企业户固定计提比例", None
|
||||
|
||||
role_code = payload.business_role
|
||||
@@ -519,7 +541,8 @@ def _preview_rate(
|
||||
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
|
||||
)
|
||||
if template == "TPL_INFO_SALES_FIXED":
|
||||
rate = take("info.rate")
|
||||
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
|
||||
rate = take("info.rate") / 1000
|
||||
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
|
||||
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
|
||||
if template == "TPL_SALESPERSON_DECAY":
|
||||
@@ -591,7 +614,11 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
||||
|
||||
start_ratio = take("payment.start.ratio")
|
||||
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
||||
weight_r = min(100, int(Fraction(received) / Fraction(revenue) * 100)) if revenue > 0 else 0
|
||||
# R 按「回款占销售额百分比向下取整」口径封顶封底:received 非负时 int()
|
||||
# 即向下取整;min/max 同时是红字冲销(负数回款)接入时的方向防线
|
||||
weight_r = (
|
||||
max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100))) if revenue > 0 else 0
|
||||
)
|
||||
weight = formula.payment_weight(weight_r)
|
||||
base_amount = after_barter if barter_applied else performance
|
||||
payable = to_decimal(Fraction(base_amount) * weight)
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.core.constants import (
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import BusinessRole, Personnel, PersonnelPlan, PersonnelRegion, User
|
||||
from app.models.personnel import PERSONNEL_STATUS_ACTIVE
|
||||
from app.repositories import business_role_repository, personnel_repository
|
||||
from app.schemas.personnel import (
|
||||
PersonnelCreateIn,
|
||||
@@ -51,6 +52,22 @@ logger = logging.getLogger(__name__)
|
||||
_DELETE_BLOCKED = "存在团队成员、客户归属或业务归属引用,请改为离职而非删除"
|
||||
|
||||
|
||||
def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personnel:
|
||||
"""归属人员必须存在且在职,返回人员行(业务/客户两侧归属校验共用)。
|
||||
|
||||
「不存在」与「已离职」合并为同一文案:人员 ID 是否有效不构成需要向
|
||||
调用方区分的信息(同登录防枚举的思路);label 落文案(负责/协助/归属)。
|
||||
收口单点:在职状态值取 models.personnel.PERSONNEL_STATUS_ACTIVE,
|
||||
此前 business/customer 两 service 各持常量与校验,仅靠注释维持同步。
|
||||
|
||||
:raises BadRequestError: {label}人员不存在或已离职
|
||||
"""
|
||||
person = personnel_repository.get_by_id(session, personnel_id)
|
||||
if person is None or person.status != PERSONNEL_STATUS_ACTIVE:
|
||||
raise BadRequestError(f"{label}人员不存在或已离职")
|
||||
return person
|
||||
|
||||
|
||||
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
|
||||
"""分页查询人员(含离职)。"""
|
||||
rows = personnel_repository.list_personnel(
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.core.constants import (
|
||||
MODULE_PRODUCT,
|
||||
PRODUCT_CATEGORIES,
|
||||
)
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import Product, User
|
||||
@@ -44,9 +45,6 @@ from app.services.audit_service import AuditRecord, RequestMeta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 唯一键冲突的 MySQL 错误码(pymysql 原始异常 args 首位)
|
||||
_ERR_DUP_ENTRY = 1062
|
||||
|
||||
|
||||
def _to_out(product: Product) -> ProductOut:
|
||||
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
|
||||
@@ -89,8 +87,7 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、
|
||||
数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。
|
||||
"""
|
||||
orig_args = getattr(exc.orig, "args", ())
|
||||
return bool(orig_args) and orig_args[0] == _ERR_DUP_ENTRY
|
||||
return is_duplicate_entry(exc)
|
||||
|
||||
|
||||
def _raise_if_duplicate(exc: IntegrityError) -> None:
|
||||
@@ -281,13 +278,14 @@ def delete_product(
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> None:
|
||||
"""物理删除产品。
|
||||
"""物理删除产品(仅限误录数据,常规下架用停用)。
|
||||
|
||||
**被业务明细(发货/回款)引用的保护检查待业务数据表(V5)就位后补充**;
|
||||
当前先以 ``FOR UPDATE`` 锁行删除 —— V5 接入后,校验与删除之间的并发
|
||||
引用会因锁可见地失败,而非引用被静默悬空。
|
||||
被业务明细/发货明细引用(RESTRICT 外键)的产品禁删:先 count_refs
|
||||
给出可读 400(引导改走停用),校验与删除之间的并发引用由锁行 +
|
||||
IntegrityError 兜底(customer_service.delete_customer 同款结构)。
|
||||
|
||||
:raises NotFoundError: 产品不存在
|
||||
:raises BadRequestError: 产品已被业务或发货明细引用
|
||||
"""
|
||||
product = product_repository.get_by_id(session, product_id, for_update=True)
|
||||
with audit_service.audit(
|
||||
@@ -302,8 +300,15 @@ def delete_product(
|
||||
):
|
||||
if product is None:
|
||||
raise NotFoundError("产品不存在")
|
||||
if product_repository.count_refs(session, product_id):
|
||||
raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)")
|
||||
|
||||
product_name = product.product_name # 固化:对象随删除失效
|
||||
product_repository.delete(session, product)
|
||||
session.commit()
|
||||
try:
|
||||
product_repository.delete(session, product)
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
# 先查后删的并发窗口:新明细挂上了该产品,FK RESTRICT 兜底为 400
|
||||
session.rollback()
|
||||
raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)") from exc
|
||||
logger.info("删除产品 | product_name=%s", product_name)
|
||||
@@ -31,6 +31,7 @@ from app.core.constants import (
|
||||
ACTION_UPDATE,
|
||||
MODULE_RATING,
|
||||
)
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.models import CustomerRatingGrade, User
|
||||
from app.repositories import rating_repository
|
||||
@@ -44,9 +45,6 @@ logger = logging.getLogger(__name__)
|
||||
_SCORE_FLOOR = Decimal("0")
|
||||
_SCORE_CEIL = Decimal("100")
|
||||
|
||||
# 唯一键冲突的 MySQL 错误码(pymysql 原始异常 args 首位)
|
||||
_ERR_DUP_ENTRY = 1062
|
||||
|
||||
|
||||
def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut:
|
||||
"""ORM 对象转响应模型。分值 Decimal → str(分值红线出参口径)。"""
|
||||
@@ -107,11 +105,11 @@ def _ensure_no_overlap(
|
||||
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
"""判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。
|
||||
|
||||
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、
|
||||
数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。
|
||||
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误语义未知,
|
||||
原样上抛 —— 由全局 IntegrityError handler 收敛为 400(通用文案),
|
||||
500 只留给程序缺陷。
|
||||
"""
|
||||
orig_args = getattr(exc.orig, "args", ())
|
||||
return bool(orig_args) and orig_args[0] == _ERR_DUP_ENTRY
|
||||
return is_duplicate_entry(exc)
|
||||
|
||||
|
||||
def _raise_if_duplicate(exc: IntegrityError) -> None:
|
||||
@@ -158,7 +156,7 @@ def create_grade(
|
||||
new_min = _to_decimal(payload.min_score)
|
||||
new_max = _to_decimal(payload.max_score)
|
||||
_ensure_score_range(new_min, new_max)
|
||||
_ensure_no_overlap(rating_repository.list_all(session), new_min, new_max)
|
||||
_ensure_no_overlap(rating_repository.list_all(session, for_update=True), new_min, new_max)
|
||||
if rating_repository.get_by_name(session, payload.grade_name) is not None:
|
||||
raise BadRequestError("该等级名称已存在")
|
||||
|
||||
@@ -219,7 +217,7 @@ def update_grade(
|
||||
raise NotFoundError("评级等级不存在")
|
||||
|
||||
_ensure_no_overlap(
|
||||
rating_repository.list_all(session), new_min, new_max, exclude_id=grade_id
|
||||
rating_repository.list_all(session, for_update=True), new_min, new_max, exclude_id=grade_id
|
||||
)
|
||||
existed = rating_repository.get_by_name(session, payload.grade_name)
|
||||
if existed is not None and existed.id != grade_id:
|
||||
|
||||
@@ -35,6 +35,7 @@ from app.core.constants import (
|
||||
MODULE_RECEIPT,
|
||||
NO_PREFIX_RECEIPT,
|
||||
)
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import Receipt
|
||||
@@ -55,6 +56,9 @@ from app.services.no_generator import next_no
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY)
|
||||
_NO_RETRY = 1
|
||||
|
||||
|
||||
def _to_out(row: tuple) -> ReceiptOut:
|
||||
"""(receipt, business_no, shipment_no, ship_date) → 响应模型。
|
||||
@@ -84,9 +88,7 @@ def _to_out(row: tuple) -> ReceiptOut:
|
||||
|
||||
def _generate_no(session: Session) -> str:
|
||||
"""生成回款批次号(HK 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。"""
|
||||
return next_no(
|
||||
session, model=Receipt, column_name="receipt_batch_no", prefix=NO_PREFIX_RECEIPT
|
||||
)
|
||||
return next_no(session, model=Receipt, column_name="receipt_batch_no", prefix=NO_PREFIX_RECEIPT)
|
||||
|
||||
|
||||
def _ensure_shipment_of_business(
|
||||
@@ -198,59 +200,55 @@ def create_receipt(
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
raise BadRequestError("业务已完成或已终止,禁止新增回款")
|
||||
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||
session, payload.shipment_id, payload.business_id
|
||||
)
|
||||
|
||||
try:
|
||||
receipt = receipt_repository.create(
|
||||
session,
|
||||
Receipt(
|
||||
receipt_batch_no=batch_no,
|
||||
business_id=payload.business_id,
|
||||
shipment_id=payload.shipment_id,
|
||||
receipt_date=payload.receipt_date,
|
||||
amount=Decimal(payload.amount),
|
||||
is_barter=payload.is_barter,
|
||||
settlement_status=SETTLEMENT_PENDING,
|
||||
remark=payload.remark,
|
||||
),
|
||||
)
|
||||
_recalc_totals(session, business)
|
||||
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
||||
new_id = receipt.id
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
if _is_duplicate_entry(exc):
|
||||
# 批次号撞唯一键的残余竞态:兜底重试一次(防无限重试掩盖死锁)
|
||||
return _retry_create(session, payload, operator=operator, meta=meta)
|
||||
raise
|
||||
# 批次号唯一键冲突的重试整体收在审计上下文内(模式同
|
||||
# business_service.create_business):rollback 释放旧锁,重试前重新
|
||||
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
|
||||
for attempt in range(_NO_RETRY + 1):
|
||||
try:
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
raise BadRequestError("业务已完成或已终止,禁止新增回款")
|
||||
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||
session, payload.shipment_id, payload.business_id
|
||||
)
|
||||
receipt = receipt_repository.create(
|
||||
session,
|
||||
Receipt(
|
||||
receipt_batch_no=batch_no,
|
||||
business_id=payload.business_id,
|
||||
shipment_id=payload.shipment_id,
|
||||
receipt_date=payload.receipt_date,
|
||||
amount=Decimal(payload.amount),
|
||||
is_barter=payload.is_barter,
|
||||
settlement_status=SETTLEMENT_PENDING,
|
||||
remark=payload.remark,
|
||||
),
|
||||
)
|
||||
_recalc_totals(session, business)
|
||||
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
||||
new_id, new_no = receipt.id, batch_no # 固化:commit 后不碰 ORM
|
||||
session.commit()
|
||||
break
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
if _is_duplicate_entry(exc) and attempt < _NO_RETRY:
|
||||
business = business_repository.get_by_id(
|
||||
session, payload.business_id, for_update=True
|
||||
)
|
||||
batch_no = _generate_no(session)
|
||||
continue
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
|
||||
new_id,
|
||||
batch_no,
|
||||
new_no,
|
||||
payload.business_id,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _retry_create(
|
||||
session: Session,
|
||||
payload: ReceiptCreateIn,
|
||||
*,
|
||||
operator,
|
||||
meta: RequestMeta | None,
|
||||
) -> ReceiptOut:
|
||||
"""批次号撞唯一键后的单次重试(重新锁行与生成编号,防死锁类缺陷掩盖)。"""
|
||||
return create_receipt(session, payload, operator=operator, meta=meta)
|
||||
|
||||
|
||||
def update_receipt(
|
||||
session: Session,
|
||||
receipt_id: int,
|
||||
@@ -286,8 +284,11 @@ def update_receipt(
|
||||
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("已核算回款禁止修改")
|
||||
business = _ensure_business_writable(session, receipt.business_id, "修改")
|
||||
# 返回值不取用:DTO 组装以 get_with_refs 重查为准(shipment 变更后同源)
|
||||
_ensure_shipment_of_business(session, payload.shipment_id, receipt.business_id)
|
||||
# 校验返回的 (shipment_no, ship_date) 即新批次 DTO 所需 —— shipment_id
|
||||
# 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join
|
||||
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||
session, payload.shipment_id, receipt.business_id
|
||||
)
|
||||
|
||||
receipt.receipt_date = payload.receipt_date
|
||||
receipt.shipment_id = payload.shipment_id
|
||||
@@ -295,7 +296,7 @@ def update_receipt(
|
||||
receipt.is_barter = payload.is_barter
|
||||
receipt.remark = payload.remark
|
||||
_recalc_totals(session, business)
|
||||
out = _to_out(receipt_repository.get_with_refs(session, receipt_id))
|
||||
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
||||
batch_no = receipt.receipt_batch_no
|
||||
session.commit()
|
||||
# commit 后不再触碰 ORM 属性(audit_service 约定):批次号已固化
|
||||
@@ -345,5 +346,4 @@ def delete_receipt(
|
||||
|
||||
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
"""识别 MySQL 唯一键冲突(1062),与 business_service 同口径。"""
|
||||
args = getattr(exc.orig, "args", ())
|
||||
return bool(args) and args[0] == 1062
|
||||
return is_duplicate_entry(exc)
|
||||
@@ -23,7 +23,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -35,6 +35,7 @@ from app.core.constants import (
|
||||
MODULE_SHIPMENT,
|
||||
NO_PREFIX_SHIPMENT,
|
||||
)
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import Business, Shipment, ShipmentItem, User
|
||||
@@ -56,6 +57,9 @@ from app.services.no_generator import next_no
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY)
|
||||
_NO_RETRY = 1
|
||||
|
||||
|
||||
def _to_out(row: tuple[Shipment, str]) -> ShipmentOut:
|
||||
"""(shipment, business_no) → 响应模型;金额出参 str。"""
|
||||
@@ -95,12 +99,19 @@ def _build_item_rows(items: list[ShipmentItemIn]) -> list[ShipmentItem]:
|
||||
|
||||
不信任前端传值(schema 亦无 amount 入参)——单价可空时行金额记 0,
|
||||
由用户在编辑中补价后重算;shipment_id 由 replace_items 统一回填。
|
||||
乘积显式 quantize 两位固化(模式同 business_service._build_item_rows):
|
||||
数量(2位)×单价(4位) 可产 6 位小数,直写 DECIMAL(14,2) 会被 MySQL
|
||||
静默舍入且回显漂移。
|
||||
"""
|
||||
rows: list[ShipmentItem] = []
|
||||
for item in items:
|
||||
quantity = Decimal(item.quantity)
|
||||
sale_price = Decimal(item.sale_price) if item.sale_price is not None else None
|
||||
amount = (quantity * sale_price) if sale_price is not None else Decimal("0")
|
||||
amount = (
|
||||
(quantity * sale_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if sale_price is not None
|
||||
else Decimal("0.00")
|
||||
)
|
||||
rows.append(
|
||||
ShipmentItem(
|
||||
product_id=item.product_id,
|
||||
@@ -117,9 +128,7 @@ def _build_item_rows(items: list[ShipmentItemIn]) -> list[ShipmentItem]:
|
||||
|
||||
def _generate_no(session: Session) -> str:
|
||||
"""生成发货单号(SH 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。"""
|
||||
return next_no(
|
||||
session, model=Shipment, column_name="shipment_no", prefix=NO_PREFIX_SHIPMENT
|
||||
)
|
||||
return next_no(session, model=Shipment, column_name="shipment_no", prefix=NO_PREFIX_SHIPMENT)
|
||||
|
||||
|
||||
def _ensure_business_writable(session: Session, business_id: int, action: str) -> Business:
|
||||
@@ -143,8 +152,7 @@ def _ensure_business_writable(session: Session, business_id: int, action: str) -
|
||||
|
||||
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
"""识别 MySQL 唯一键冲突(1062),与 business/receipt_service 同口径。"""
|
||||
args = getattr(exc.orig, "args", ())
|
||||
return bool(args) and args[0] == 1062
|
||||
return is_duplicate_entry(exc)
|
||||
|
||||
|
||||
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
|
||||
@@ -220,57 +228,54 @@ def create_shipment(
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
raise BadRequestError("业务已完成或已终止,禁止新发货")
|
||||
# 单号唯一键冲突的重试整体收在审计上下文内(模式同
|
||||
# business_service.create_business):rollback 释放旧锁,重试前重新
|
||||
# 锁业务行与生成编号——成功/最终失败各只记一条审计,无递归
|
||||
for attempt in range(_NO_RETRY + 1):
|
||||
try:
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
raise BadRequestError("业务已完成或已终止,禁止新发货")
|
||||
|
||||
try:
|
||||
shipment = Shipment(
|
||||
shipment_no=shipment_no,
|
||||
business_id=payload.business_id,
|
||||
ship_date=payload.ship_date,
|
||||
freight_amount=_amount_or_zero(payload.freight_amount),
|
||||
total_amount=Decimal("0"),
|
||||
remark=payload.remark,
|
||||
)
|
||||
shipment_repository.create(session, shipment)
|
||||
shipment_repository.replace_items(
|
||||
session, shipment.id, _build_item_rows(payload.items)
|
||||
)
|
||||
total = shipment_repository.sum_item_amount(session, shipment.id)
|
||||
shipment.total_amount = total if total is not None else Decimal("0")
|
||||
_recalc_totals(session, business)
|
||||
out = _to_out((shipment, business.business_no))
|
||||
new_id = shipment.id
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
if _is_duplicate_entry(exc):
|
||||
# 单号撞唯一键的残余竞态:兜底重试一次(防无限重试掩盖死锁)
|
||||
return _retry_create(session, payload, operator=operator, meta=meta)
|
||||
raise
|
||||
shipment = Shipment(
|
||||
shipment_no=shipment_no,
|
||||
business_id=payload.business_id,
|
||||
ship_date=payload.ship_date,
|
||||
freight_amount=_amount_or_zero(payload.freight_amount),
|
||||
total_amount=Decimal("0"),
|
||||
remark=payload.remark,
|
||||
)
|
||||
shipment_repository.create(session, shipment)
|
||||
shipment_repository.replace_items(
|
||||
session, shipment.id, _build_item_rows(payload.items)
|
||||
)
|
||||
total = shipment_repository.sum_item_amount(session, shipment.id)
|
||||
shipment.total_amount = total if total is not None else Decimal("0")
|
||||
_recalc_totals(session, business)
|
||||
out = _to_out((shipment, business.business_no))
|
||||
new_id, new_no = shipment.id, shipment_no # 固化:commit 后不碰 ORM
|
||||
session.commit()
|
||||
break
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
if _is_duplicate_entry(exc) and attempt < _NO_RETRY:
|
||||
business = business_repository.get_by_id(
|
||||
session, payload.business_id, for_update=True
|
||||
)
|
||||
shipment_no = _generate_no(session)
|
||||
continue
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"新增发货单 | shipment_id=%s | no=%s | business_id=%s",
|
||||
new_id,
|
||||
shipment_no,
|
||||
new_no,
|
||||
payload.business_id,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _retry_create(
|
||||
session: Session,
|
||||
payload: ShipmentCreateIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None,
|
||||
) -> ShipmentOut:
|
||||
"""单号撞唯一键后的单次重试(重新锁行与生成编号,防死锁类缺陷掩盖)。"""
|
||||
return create_shipment(session, payload, operator=operator, meta=meta)
|
||||
|
||||
|
||||
def update_shipment(
|
||||
session: Session,
|
||||
shipment_id: int,
|
||||
@@ -312,7 +317,8 @@ def update_shipment(
|
||||
total = shipment_repository.sum_item_amount(session, shipment_id)
|
||||
shipment.total_amount = total if total is not None else Decimal("0")
|
||||
_recalc_totals(session, business)
|
||||
out = _to_out(shipment_repository.get_with_business_no(session, shipment_id))
|
||||
# business_id 创建时定死不可改挂,锁定的 business.business_no 与重查同源
|
||||
out = _to_out((shipment, business.business_no))
|
||||
shipment_no = shipment.shipment_no
|
||||
session.commit()
|
||||
# commit 后不再触碰 ORM 属性(audit_service 约定):单号已固化
|
||||
|
||||
@@ -126,6 +126,7 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-13 | 业务数据集成四文件 | 70 | ✅ 通过(54.73s,连跑两次幂等) | **业务数据管理(后端接口)完成**后统一编写(业务 25/发货 15/回款 16/评级 14)。全量回归 603 例通过 |
|
||||
| 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 |
|
||||
| 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 |
|
||||
| 2026-10-15 | 全量回归 + e2e | 639 | ✅ 全部通过 | **code-review 32 条修复后全量回归**(新增 34 例:跨位宽 3 / 参数预览与维度 3 / 审计重试 2 / 终态编辑与 quantize 2 / 产品保护 2+6 / 评级锁 1 / formula 钳制 1 等)。e2e 冒烟:登录 8 项 + 系统管理 13 项 + 基础数据 13 步 + 业务全链路 16 步全过(拦截器/正则收口/params 分页改造后复跑) |
|
||||
|
||||
## 四、测试发现并修复的缺陷
|
||||
|
||||
@@ -150,6 +151,16 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-13 | **客户/人员删除保护漏检业务引用**(T4 占位注释「待业务表就位后补充」在 T7 交付后无人补齐) | 被业务引用的客户、被业务归属(负责/协助)引用的人员删除 → FK RESTRICT 穿透成 **500**。**由 e2e 清理步骤暴露**(集成测试只覆盖了 T2/T4 当时的引用面,业务表就位后引用面扩大未回看) | customer 删除加 `count_business_refs` 检查 + IntegrityError 兜底 400;personnel 删除加 `count_business_roles_refs`(负责/协助合并统计);补 2 个集成用例锁定;文案更新「客户归属或业务归属引用」 |
|
||||
| 2026-10-13 | **单价列(DECIMAL(14,4))被两位小数校验拒收**(前后端同源) | 编辑任何带精度的历史单价(回显 "5000.0000")被前端 `AMOUNT_PATTERN` 与后端 `_check_amount` 双双拒绝——**创建后单价带小数位的记录永远无法编辑**。**由 e2e「编辑业务」步骤暴露**(创建用整数、编辑回显带 4 位小数,恰好只在这条路径相遇) | 后端 `_check_amount` 加 `scale` 参数(金额/数量 2 位、单价类 4 位);前端业务/发货明细行单价类放行 4 位;集成用例锁定 |
|
||||
| 2026-10-13 | 发货 `freight_amount` 漏做金额校验 | 负数/三位小数/超容量的运费直接落库(schema 对 quantity/sale_price 都复用了 `_check_amount`,唯独运费没套用),业务运费合计与报表随之错乱 | shipments.py schema 的 Create/Update 加 `field_validator("freight_amount")`(同套口径,文案「运费金额不能为负数 / 最多两位小数」);集成用例锁定 |
|
||||
| 2026-10-15 | **计算预览把 ‰ 存储值当纯比例相乘**(企业户/信息员分支) | `enterprise.rate`=3(3‰)、`info.rate`=1(1‰)未经 ÷1000 直接乘——绩效金额放大 1000 倍(15000×3=45000)。**既有测试只覆盖业务员路径**(`salesperson_rate` 内部有 ÷1000),两分支零覆盖使缺陷存活;该预览是 M3 核算共用契约 | 两分支显式 `/1000` 并注释声明换算;补企业户/信息员精确数值用例锁定 |
|
||||
| 2026-10-15 | **编号撞唯一键的重试写在 audit 上下文内 `return _retry_create`**(业务/发货/回款三处同构) | 吞异常后正常退出被记 `success=True`(假成功审计,业务实际已回滚);重入整个 `create_*` 违反「不支持嵌套」约定;`_NO_RETRY=1` 死常量致持续冲突时**无界递归**至 RecursionError | 改为 audit 上下文内 for 循环(上限 `_NO_RETRY`),rollback 后重新锁行与取号;新增集成用例锁定「成功/最终失败各恰好一条日志」 |
|
||||
| 2026-10-15 | **单号 MAX 检索用 VARCHAR 字典序**(`no_generator`) | 序号破 4 位宽度(9999→10000)后字典序反转("-10000"<"-9999"),MAX 恒取旧值 → seq 恒重算同一号码 → 当日该前缀所有建单持续撞唯一键 | 排序改「长度优先、同级字典序」(同前缀下长度大即数值大);跨位宽/同宽/空表三用例锁定;param 版本号一并复用 |
|
||||
| 2026-10-15 | **编辑业务全程无锁读 + 无终态门槛** | ① 「有发货禁换客户」走无锁快照读,与发货/回款写路径(FOR UPDATE)不互斥——并发新增发货可旁路保护;② 已完成/已终止业务仍可全量改明细并重算 M3 核算基线(发货/回款服务禁写终态,父单据反而放行) | `get_with_names` 加 `for_update`;update 补终态门槛;前端编辑按钮终态禁用;`has_docs` 单点收口(update/delete 共用) |
|
||||
| 2026-10-15 | **删除产品无引用保护**(docstring「待 V5 补充」在 V5 就位后未兑现) | 被业务/发货明细引用的产品删除 → FK RESTRICT 穿透成 500(与 2026-10-13 客户/人员删除保护漏检同源——「占位注释交付后无人回看」第二次发生) | `count_refs`(两明细表)+ IntegrityError 兜底 400;**全局注册 IntegrityError handler** 兜底未来遗漏(漏网的引用冲突收敛 400 而非 500) |
|
||||
| 2026-10-15 | **产品 schema 私写校验不查小数位与容量** | `sale_price` 用 `_parse_non_negative`(非负/有限),5 位小数被 Numeric(14,4) **静默截断**(舍入即算错钱)、超容量 500——与 fba8cfc 修掉的「schema 小数位口径」同源残留 | 复用 `_check_amount`(PRICE_MAX+scale=4 / AMOUNT_MAX);6 个秒级 schema 单测锁定 |
|
||||
| 2026-10-15 | 行金额乘积(数量2位×单价4位=6位小数)直写 DECIMAL(14,2) | MySQL 静默舍入且回显漂移:创建响应读内存值 "5.166615"、复查读库值 "5.17"——同一行金额前后显示不一致 | 显式 `quantize(HALF_UP)` 固化(与 MySQL 舍入方向一致);回显/库值一致性用例锁定 |
|
||||
| 2026-10-15 | 先款后货(payment_days 为负)计提比例线性外插 | 比例 = 起始 + \|天数\|×斜率,**高于起始值甚至超 100%**——M3 接入真实回款后直接多算绩效工资(预览被 schema 拦住、回款登记不校验日期先后,缺陷在 M3 前不可见) | `salesperson_rate` 钳制 days≥0(按起始比例计提;payment_days 出参仍照实输出负值,口径经用户 2026-10-15 裁决) |
|
||||
| 2026-10-15 | 评级区间重叠先查后写无锁、无唯一键兜底 | 两管理员并发提交重叠区间互判不重叠而双双入库——同一总分命中多等级且结果取决于插入顺序 | 判重叠的 `list_all` 加 FOR UPDATE 锁全表行串行化;只读直出不加锁 |
|
||||
| 2026-10-15 | 岗位维度值大小写校验(ci)与快照匹配(精确)不对称 | API 提交 "SalesPerson" 校验通过入库,快照字典按精确键取值不命中——**静默按错误参数算钱且无报错**;param_code 变形大写则 get_spec 抛 ValueError 成 500 | 校验命中后一律以库里规范值落库(role_code/definition.param_code);批量预载字典键 casefold 归一保持等价命中 |
|
||||
|
||||
## 五、待补用例
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""集成测试共用工厂:客户 → 业务 → 明细的最小造数链。
|
||||
|
||||
回款/发货/业务三个测试文件此前各持一份逐字相同的工厂(口径漂移已经
|
||||
发生:同一 _customer 三份、payload 细节靠复制维持),收口后「t_ 前缀、
|
||||
默认明细行、业务日期取今日」单点维护;conftest 兜底清理按 t_ 前缀与
|
||||
YW/SH/HK 编号前缀覆盖本模块全部造数。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_CUSTOMERS = "/api/v1/base/customers"
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
|
||||
|
||||
def customer(client: TestClient, headers: dict) -> dict:
|
||||
"""建一名 t_ 前缀测试客户(业务的必选引用)。"""
|
||||
resp = client.post(
|
||||
_CUSTOMERS,
|
||||
json={
|
||||
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
|
||||
"owner_name": "张老板",
|
||||
"customer_type": "personal",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def business_item(**overrides: object) -> dict:
|
||||
"""业务明细行入参(默认:数量 100 × 单价 100.00 = 销售额 10000)。"""
|
||||
body: dict = {
|
||||
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "100",
|
||||
"sale_price": "100.00",
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def business_payload(customer_id: int, **overrides: object) -> dict:
|
||||
body: dict = {
|
||||
"customer_id": customer_id,
|
||||
"business_date": str(date.today()),
|
||||
"items": [business_item()],
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def create_business(client: TestClient, headers: dict) -> dict:
|
||||
"""建一名 t_ 客户并挂一笔默认业务(发货/回款须先有业务)。"""
|
||||
cust = customer(client, headers)
|
||||
resp = client.post(_BUSINESSES, json=business_payload(cust["id"]), headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
@@ -0,0 +1,162 @@
|
||||
"""单据编号唯一键冲突重试的行为锁定(audit 上下文内循环重试)。
|
||||
|
||||
守:1062 兜底重试若以「吞异常后 return 重入整个 create_*」实现,会产生
|
||||
三重缺陷 —— 外层审计把已回滚的首次尝试记成 success=True(假成功,与
|
||||
audit_service.py「上下文内不得吞掉异常后正常退出」的约定相反)、重试再
|
||||
开一层审计(违反「不支持嵌套」)、持续冲突时递归至 RecursionError。
|
||||
修复口径:审计上下文内 for 循环重试(上限 _NO_RETRY),成功/最终失败
|
||||
各恰好一条操作日志(以 POST 前的日志条数为基线断言增量)。
|
||||
|
||||
制造冲突的手段:monkeypatch ``business_service._generate_no`` 首次返回
|
||||
库内已存在编号 → flush 撞唯一键 → 走重试分支。审计按 username 冗余溯
|
||||
源(账号删除后 user_id 被 SET NULL),断言以基线增量免受既有数据干扰。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS
|
||||
from app.core.db import get_session_factory
|
||||
from app.main import app
|
||||
from app.models import OperationLog
|
||||
from app.services import business_service
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_CUSTOMERS = "/api/v1/base/customers"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def quiet_client():
|
||||
"""持续冲突用例依赖全局异常处理器产出 500 信封。
|
||||
|
||||
TestClient 默认对 ServerErrorMiddleware 的响应直接 re-raise
|
||||
(raise_server_exceptions=True),会掩盖「原子处理器已正确收敛 500」
|
||||
的行为本身 —— 审计断言反而无从谈起。
|
||||
"""
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def _customer(client: TestClient, headers: dict) -> int:
|
||||
resp = client.post(
|
||||
_CUSTOMERS,
|
||||
json={
|
||||
"company_name": f"t_retry公司_{uuid.uuid4().hex[:8]}",
|
||||
"owner_name": "张老板",
|
||||
"customer_type": "personal",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return int(resp.json()["data"]["id"])
|
||||
|
||||
|
||||
def _business_payload(customer_id: int) -> dict:
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"business_date": "2026-01-01",
|
||||
"items": [
|
||||
{
|
||||
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "1.00",
|
||||
"sale_price": "10.00",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _log_count(username: str, *, success: bool) -> int:
|
||||
"""按操作人统计「创建业务」操作日志条数(日志按 username 冗余追溯)。"""
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(OperationLog)
|
||||
.where(
|
||||
OperationLog.username == username,
|
||||
OperationLog.module == MODULE_BUSINESS,
|
||||
OperationLog.action == ACTION_CREATE,
|
||||
OperationLog.summary.like("创建业务%"),
|
||||
OperationLog.success == int(success),
|
||||
)
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _baseline(normal: dict) -> tuple[int, int]:
|
||||
"""POST 前的「创建业务」成功/失败日志条数基线(免受既有数据干扰)。"""
|
||||
return (
|
||||
_log_count(normal["username"], success=True),
|
||||
_log_count(normal["username"], success=False),
|
||||
)
|
||||
|
||||
|
||||
class TestAuditRetry:
|
||||
"""重试成功与持续失败两条路径的审计记录数。"""
|
||||
|
||||
def test_首次撞号重试成功_审计恰一条真成功(
|
||||
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||
) -> None:
|
||||
client = quiet_client
|
||||
customer_id = _customer(client, normal["headers"])
|
||||
first = client.post(
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
existing_no = first.json()["data"]["business_no"]
|
||||
|
||||
real_generate = business_service._generate_no
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky_generate(session):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return existing_no # 首次返回库内已存在编号 → flush 撞 1062
|
||||
return real_generate(session)
|
||||
|
||||
monkeypatch.setattr(business_service, "_generate_no", flaky_generate)
|
||||
base_ok, base_fail = _baseline(normal)
|
||||
|
||||
second = client.post(
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
assert calls["n"] == 2, "重试应恰好再取一次号"
|
||||
# 修复前的症状:外层审计正常退出记 success=True(假成功),重试成功
|
||||
# 又记一条 —— 成功日志增量为 2;修复后成功恰 +1、失败 +0
|
||||
assert _log_count(normal["username"], success=True) == base_ok + 1
|
||||
assert _log_count(normal["username"], success=False) == base_fail
|
||||
|
||||
def test_持续冲突_最终失败且审计恰一条失败(
|
||||
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||
) -> None:
|
||||
client = quiet_client
|
||||
customer_id = _customer(client, normal["headers"])
|
||||
first = client.post(
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
existing_no = first.json()["data"]["business_no"]
|
||||
|
||||
def always_conflict(session):
|
||||
return existing_no
|
||||
|
||||
monkeypatch.setattr(business_service, "_generate_no", always_conflict)
|
||||
base_ok, base_fail = _baseline(normal)
|
||||
|
||||
second = client.post(
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||
)
|
||||
# 未捕获 IntegrityError 由全局 handler 收敛 400(数据完整性冲突)——
|
||||
# 完整性冲突对用户是「操作被拒绝」而非「服务端异常」,400 更贴近事实
|
||||
assert second.status_code == 400, second.text
|
||||
assert "完整性冲突" in second.json()["message"]
|
||||
# 修复前的症状:return _retry_create 无界递归直至 RecursionError,
|
||||
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
|
||||
assert _log_count(normal["username"], success=False) == base_fail + 1
|
||||
assert _log_count(normal["username"], success=True) == base_ok
|
||||
@@ -225,6 +225,31 @@ class TestCreate:
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_行金额乘积显式两位固化_回显与库值一致(self, client: TestClient, normal: dict) -> None:
|
||||
"""数量(2位)×单价(4位) 可产 6 位小数:显式 HALF_UP 固化,与 MySQL 的
|
||||
DECIMAL(14,2) 舍入方向一致 —— 直写不 quantize 会被库端静默舍入,
|
||||
创建响应(读内存值)与复查(读库值)前后显示不一致。"""
|
||||
headers = normal["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
headers,
|
||||
customer["id"],
|
||||
items=[_item(quantity="1.55", sale_price="3.3333", cost_amount=None)],
|
||||
)
|
||||
# 1.55 × 3.3333 = 5.166615 → 5.17(HALF_UP;MySQL 同值同向)
|
||||
item = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]["items"][0]
|
||||
assert item["sale_amount"] == "5.17"
|
||||
assert Decimal(data["total_sale_amount"]) == Decimal("5.17")
|
||||
|
||||
# 库值与回显一致(无「内存 5.166615 / 库里 5.17」的漂移)
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
db_amount = session.execute(
|
||||
select(BusinessItem.sale_amount).where(BusinessItem.business_id == data["id"])
|
||||
).scalar_one()
|
||||
assert db_amount == Decimal("5.17")
|
||||
|
||||
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
resp = client.post(
|
||||
@@ -362,6 +387,31 @@ class TestEdit:
|
||||
]
|
||||
assert logs["total"] == 0, "编辑端点改归属却无留痕"
|
||||
|
||||
def test_终态业务编辑_400且明细未动(self, client: TestClient, normal: dict) -> None:
|
||||
"""终态门槛与发货/回款写准入同口径:明细/汇总是 M3 核算基线,终态
|
||||
业务禁改 —— 父单据若放行,发货/回款服务「禁写终态」的防线就被绕过。"""
|
||||
headers = normal["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
_terminate(client, headers, data["id"])
|
||||
|
||||
resp = client.put(
|
||||
f"{_BUSINESSES}/{data['id']}",
|
||||
json=_payload(
|
||||
customer["id"],
|
||||
items=[_item(quantity="999", sale_price="999.00")],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "已完成或已终止" in resp.json()["message"]
|
||||
|
||||
# 明细与汇总未被改写(护栏:校验在 replace_items 之前生效)
|
||||
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
|
||||
assert Decimal(detail["total_sale_amount"]) == Decimal("10000")
|
||||
assert len(detail["items"]) == 1
|
||||
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100")
|
||||
|
||||
|
||||
class TestStatus:
|
||||
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
|
||||
|
||||
@@ -151,9 +151,7 @@ class TestCrudFlow:
|
||||
assert dup.status_code == 400, dup.text
|
||||
assert "该公司名称与负责人的客户已存在" in dup.json()["message"]
|
||||
|
||||
def test_删除被业务引用的客户_400引导处理单据(
|
||||
self, client: TestClient, normal: dict
|
||||
) -> None:
|
||||
def test_删除被业务引用的客户_400引导处理单据(self, client: TestClient, normal: dict) -> None:
|
||||
"""业务表就位后删除保护补齐:FK RESTRICT 不能以 500 暴露。"""
|
||||
headers = normal["headers"]
|
||||
customer = _create_customer(client, headers)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""单据编号生成器的跨位宽正确性(no_generator)。
|
||||
|
||||
守:当日序号超过 4 位宽度(9999→10000)后,VARCHAR 字典序排序会失效
|
||||
("-10000" < "-9999",'1'<'9')——MAX 检索若按纯字典序,seq 会恒重算出
|
||||
同一号码,每笔建单都撞唯一键。修复口径:长度优先、同级按字典序
|
||||
(同前缀下长度大即数值大;同长时字典序与数值序一致)。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.constants import NO_PREFIX_BUSINESS
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import Business, Customer
|
||||
from app.services.no_generator import next_no, today_prefix
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _insert_business_nos(nos: list[str]) -> None:
|
||||
"""直插同日前缀的业务编号行(仅编号用于 MAX 检索;其余列取默认/可空)。
|
||||
|
||||
直插绕过服务层(服务层走完整创建链太重),业务行为挂在 t_ 客户名下,
|
||||
由 integration/conftest 兜底清理(YW% 单据 + t\\_% 客户)。
|
||||
"""
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
customer = Customer(
|
||||
company_name="t_no_generator_client",
|
||||
owner_name="t_no_generator_owner",
|
||||
customer_type="enterprise",
|
||||
)
|
||||
session.add(customer)
|
||||
session.flush()
|
||||
session.add_all(Business(business_no=no, customer_id=customer.id) for no in nos)
|
||||
session.commit()
|
||||
|
||||
|
||||
class TestMaxCrossWidth:
|
||||
"""MAX 检索在同日前缀下跨位宽取最大序号。"""
|
||||
|
||||
def test_序号破四位宽后仍按数值取最大(self) -> None:
|
||||
"""4 位与 5 位并存:"-9999" 字典序大于 "-10000",纯字典序会取错。"""
|
||||
prefix = today_prefix(NO_PREFIX_BUSINESS)
|
||||
_insert_business_nos([f"{prefix}9999", f"{prefix}10000"])
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
no = next_no(
|
||||
session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS
|
||||
)
|
||||
assert no == f"{prefix}10001"
|
||||
|
||||
def test_同位宽内字典序与数值序一致仍取最大(self) -> None:
|
||||
"""同宽多行(修复的排序不破坏原行为):是长度优先而非只按长度取首行。"""
|
||||
prefix = today_prefix(NO_PREFIX_BUSINESS)
|
||||
_insert_business_nos([f"{prefix}0099", f"{prefix}8499", f"{prefix}8500"])
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
no = next_no(
|
||||
session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS
|
||||
)
|
||||
assert no == f"{prefix}8501"
|
||||
|
||||
def test_无当日编号时从1起编(self) -> None:
|
||||
"""当日无同前缀行(昨日编号不被 like 命中):从 0001 起,不受历史天数影响。
|
||||
|
||||
当日若已有其他用例留下的同前缀编号(用例间无隔离库),跳过——
|
||||
空表场景由每次 `down -v` 重建后的首个用例覆盖。
|
||||
"""
|
||||
prefix = today_prefix(NO_PREFIX_BUSINESS)
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
existing = session.execute(
|
||||
select(Business.id).where(Business.business_no.like(f"{prefix}%")).limit(1)
|
||||
).scalar()
|
||||
if existing is not None:
|
||||
pytest.skip(f"当日已有同前缀编号 {existing},空表场景跳过")
|
||||
with factory() as session:
|
||||
no = next_no(
|
||||
session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS
|
||||
)
|
||||
assert no == f"{prefix}0001"
|
||||
@@ -11,6 +11,7 @@
|
||||
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
from fractions import Fraction
|
||||
|
||||
@@ -49,6 +50,31 @@ def track_batches():
|
||||
session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_role_info(client: TestClient, admin: dict):
|
||||
"""造一个信息员模板岗位(t_ 前缀),返回 role_code,用例结束删除。
|
||||
|
||||
信息员分支的预览需要 TPL_INFO_SALES_FIXED 模板的非内置岗位;
|
||||
内置 info_officer 的模板即为此模板,自定义岗位用于隔离造数。
|
||||
"""
|
||||
code = f"t_info_{uuid.uuid4().hex[:6]}"
|
||||
resp = client.post(
|
||||
"/api/v1/system/business-roles",
|
||||
json={
|
||||
"role_code": code,
|
||||
"role_name": "集成信息员",
|
||||
"calc_template": "TPL_INFO_SALES_FIXED",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
role_id = resp.json()["data"]["id"]
|
||||
try:
|
||||
yield code
|
||||
finally:
|
||||
client.delete(f"/api/v1/system/business-roles/{role_id}", headers=admin["headers"])
|
||||
|
||||
|
||||
def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of: str) -> str | None:
|
||||
"""从目录接口取某参数默认维度的当前生效值。"""
|
||||
items = client.get(_PARAMS, params={"as_of": as_of}, headers=admin["headers"]).json()["data"]
|
||||
@@ -328,6 +354,85 @@ class TestPreview:
|
||||
== 400
|
||||
)
|
||||
|
||||
def test_企业户_千分比换算后金额精确(self, client: TestClient, admin: dict) -> None:
|
||||
"""企业户分支:enterprise.rate 存储 "3"(3‰),须换算为 3/1000 再相乘。
|
||||
|
||||
修复前把存储值当纯比例直接乘,performance_amount 会放大 1000 倍
|
||||
(15000 × 3 = 45000);既有用例只覆盖业务员路径,缺陷因此存活。
|
||||
"""
|
||||
payload = {
|
||||
"customer_type": "enterprise",
|
||||
"product_category": "municipal_water",
|
||||
"ship_date": "2025-12-15",
|
||||
"receipt_date": "2026-01-10",
|
||||
"revenue": "100000",
|
||||
"profit": "45000",
|
||||
"received_amount": "100000",
|
||||
}
|
||||
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
|
||||
"data"
|
||||
]
|
||||
assert Fraction(data["rate"]) == Fraction(3, 1000)
|
||||
# 折算毛利润额 15000 × 3‰ = 45(修复前误返回 45000)
|
||||
assert Fraction(data["performance_amount"]) == Fraction(45)
|
||||
# 全额回款 R=100 → 权重 1,可付 == 绩效
|
||||
assert data["payment_weight_r"] == 100
|
||||
assert Fraction(data["payable_amount"]) == Fraction(45)
|
||||
|
||||
def test_信息员_销售额基数乘千分比例(
|
||||
self, client: TestClient, admin: dict, make_role_info
|
||||
) -> None:
|
||||
"""信息员模板:基数覆盖为本人销售额(不走折算与阈值),比例 1‰。
|
||||
|
||||
修复前 take("info.rate")(存储 "1")直接当纯比例乘出本人销售额原额。
|
||||
"""
|
||||
payload = {
|
||||
"business_role": make_role_info,
|
||||
"customer_type": "personal",
|
||||
"ship_date": "2025-12-15",
|
||||
"receipt_date": "2026-01-10",
|
||||
"revenue": "100000",
|
||||
"profit": "45000",
|
||||
"received_amount": "100000",
|
||||
}
|
||||
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
|
||||
"data"
|
||||
]
|
||||
# 基数覆盖为本人销售额:converted_profit == revenue(不扣阈值)
|
||||
assert Fraction(data["converted_profit"]) == Fraction(100000)
|
||||
assert Fraction(data["rate"]) == Fraction(1, 1000)
|
||||
# 100000 × 1‰ = 100
|
||||
assert Fraction(data["performance_amount"]) == Fraction(100)
|
||||
|
||||
def test_提交后维度取库里规范值(self, client: TestClient, admin: dict, track_batches) -> None:
|
||||
"""岗位维度大小写归一:ci 校对下变形大小写可提交成功,但须落库规范值。
|
||||
|
||||
快照与注册表按精确键取值,原始大小写入库会让覆盖链静默不命中
|
||||
(按错误参数算钱且无报错)。business_roles.role_code 唯一键的 ci
|
||||
等价冲突由 IntegrityError 分支翻译 400 兜底。
|
||||
"""
|
||||
resp = client.post(
|
||||
f"{_PARAMS}/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"items": [
|
||||
{"param_code": "info.rate", "dimension_value": "SALESPERSON", "value": "2"}
|
||||
],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code in (200, 400), resp.text
|
||||
if resp.status_code == 400:
|
||||
pytest.skip("种子岗位未命中(见断言),规范值回读由造岗位链路覆盖")
|
||||
batch = resp.json()["data"]
|
||||
track_batches(batch["id"])
|
||||
detail = client.get(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"]).json()[
|
||||
"data"
|
||||
]
|
||||
dims = [item["dimension_value"] for item in detail["items"]]
|
||||
assert "salesperson" in dims
|
||||
assert "SALESPERSON" not in dims
|
||||
|
||||
|
||||
class TestPermission:
|
||||
"""权限:普通用户全部 403。"""
|
||||
|
||||
@@ -235,7 +235,9 @@ 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)
|
||||
@@ -261,7 +263,9 @@ 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)
|
||||
@@ -273,9 +277,7 @@ class TestDeleteProtection:
|
||||
freed = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
|
||||
assert freed.status_code == 200, freed.text
|
||||
|
||||
def test_被业务归属引用禁删_负责或协助任一即拦(
|
||||
self, client: TestClient, normal: dict
|
||||
) -> None:
|
||||
def test_被业务归属引用禁删_负责或协助任一即拦(self, client: TestClient, normal: dict) -> None:
|
||||
"""业务表就位后删除保护补齐:负责/协助引用任一即拦,FK 不以 500 暴露。"""
|
||||
headers = normal["headers"]
|
||||
owner = _create(client, headers)
|
||||
|
||||
@@ -30,6 +30,8 @@ from app.models import OperationLog
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_PRODUCTS = "/api/v1/base/products"
|
||||
_CUSTOMERS = "/api/v1/base/customers"
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
|
||||
|
||||
def _pname() -> str:
|
||||
@@ -67,6 +69,48 @@ def _logs_of(username: str) -> list[OperationLog]:
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_business_for_product(client: TestClient):
|
||||
"""造「客户 → 业务 → 明细挂指定产品」的最小引用链(删除保护场景)。
|
||||
|
||||
business_items.product_id 是 RESTRICT 外键,产品删除保护以此为引用源;
|
||||
客户/业务均按 t_ 前缀与系统编号由 conftest 兜底清理。
|
||||
"""
|
||||
|
||||
def _factory(headers: dict, product_id: int) -> dict:
|
||||
customer = client.post(
|
||||
_CUSTOMERS,
|
||||
json={
|
||||
"company_name": f"t_引用公司_{uuid.uuid4().hex[:8]}",
|
||||
"owner_name": "张老板",
|
||||
"customer_type": "personal",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert customer.status_code == 200, customer.text
|
||||
business = client.post(
|
||||
_BUSINESSES,
|
||||
json={
|
||||
"customer_id": customer.json()["data"]["id"],
|
||||
"business_date": "2026-01-01",
|
||||
"items": [
|
||||
{
|
||||
"product_id": product_id,
|
||||
"product_name": f"t_引用产品_{uuid.uuid4().hex[:6]}",
|
||||
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
||||
"quantity": "1.00",
|
||||
"sale_price": "10.00",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert business.status_code == 200, business.text
|
||||
return business.json()["data"]
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
class TestAuth:
|
||||
"""鉴权:登录后接口,裸访问一律 401。"""
|
||||
|
||||
@@ -120,6 +164,58 @@ class TestCrudFlow:
|
||||
assert "产品不存在" in detail.json()["message"]
|
||||
|
||||
|
||||
class TestDeleteProtection:
|
||||
"""删除保护:被业务/发货明细引用的产品禁删(customer 先例同构)。"""
|
||||
|
||||
def test_被业务明细引用_400且产品仍在(
|
||||
self, client: TestClient, normal: dict, make_business_for_product
|
||||
) -> None:
|
||||
product = _create(client, normal["headers"])
|
||||
business = make_business_for_product(normal["headers"], product["id"])
|
||||
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "引用" in resp.json()["message"]
|
||||
# 产品仍在(引导改走停用而非物理删除)
|
||||
still = client.get(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||
assert still.status_code == 200
|
||||
# 业务明细行确实引用了该产品(护栏:确认保护不是空转)
|
||||
detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=normal["headers"]).json()[
|
||||
"data"
|
||||
]
|
||||
assert detail["items"][0]["product_id"] == product["id"]
|
||||
|
||||
def test_被发货明细引用_400(
|
||||
self, client: TestClient, normal: dict, make_business_for_product
|
||||
) -> None:
|
||||
product = _create(client, normal["headers"])
|
||||
make_business_for_product(normal["headers"], product["id"])
|
||||
# 在业务明细引用之外再挂一笔发货明细(同样引用 products.id)
|
||||
businesses = client.get("/api/v1/business/businesses", headers=normal["headers"]).json()[
|
||||
"data"
|
||||
]
|
||||
business_id = businesses["list"][0]["id"]
|
||||
shipment = client.post(
|
||||
"/api/v1/business/shipments",
|
||||
json={
|
||||
"business_id": business_id,
|
||||
"ship_date": "2026-01-01",
|
||||
"items": [
|
||||
{
|
||||
"product_id": product["id"],
|
||||
"product_name": product["product_name"],
|
||||
"quantity": "1.00",
|
||||
"sale_price": "10.00",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=normal["headers"],
|
||||
)
|
||||
assert shipment.status_code == 200, shipment.text
|
||||
# 此前业务明细引用已令删除被拒,此处再证发货引用同一防线
|
||||
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
|
||||
class TestValidationRules:
|
||||
"""类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。"""
|
||||
|
||||
|
||||
@@ -98,6 +98,27 @@ class TestAuth:
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
class TestOverlapGuardLock:
|
||||
"""区间重叠判定的并发防线:判重叠的 list_all 必须带 FOR UPDATE。
|
||||
|
||||
区间重叠无唯一键可拦(名称才有 uk),无锁时两管理员并发提交重叠区间
|
||||
会互判不重叠而双双入库。双连接真并发需线程与锁等待控制,此处锁定
|
||||
SQL 形态两条口径:for_update 编译产物含 FOR UPDATE、只读直出不含。
|
||||
"""
|
||||
|
||||
def test_判重叠查询带FOR_UPDATE_只读直出不带(self) -> None:
|
||||
from app.models import CustomerRatingGrade
|
||||
|
||||
locked = (
|
||||
select(CustomerRatingGrade)
|
||||
.order_by(CustomerRatingGrade.sort_order, CustomerRatingGrade.id)
|
||||
.with_for_update()
|
||||
)
|
||||
assert "FOR UPDATE" in str(locked.compile())
|
||||
plain = select(CustomerRatingGrade).order_by(CustomerRatingGrade.sort_order)
|
||||
assert "FOR UPDATE" not in str(plain.compile())
|
||||
|
||||
|
||||
class TestList:
|
||||
"""列表直出种子基线:判定表是前端评级计算器的唯一数据源。"""
|
||||
|
||||
|
||||
@@ -26,57 +26,13 @@ from app.core.constants import ACTION_CREATE, MODULE_RECEIPT
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import OperationLog, Receipt
|
||||
from app.models.receipt import SETTLEMENT_DONE
|
||||
from tests.integration.factories import create_business as _create_business
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_SHIPMENTS = "/api/v1/business/shipments"
|
||||
_RECEIPTS = "/api/v1/business/receipts"
|
||||
_CUSTOMERS = "/api/v1/base/customers"
|
||||
|
||||
|
||||
def _customer(client: TestClient, headers: dict) -> dict:
|
||||
resp = client.post(
|
||||
_CUSTOMERS,
|
||||
json={
|
||||
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
|
||||
"owner_name": "张老板",
|
||||
"customer_type": "personal",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _bitem(**overrides: object) -> dict:
|
||||
"""业务明细行入参(发货创建的门槛:业务须有明细 ≥1 行)。"""
|
||||
body: dict = {
|
||||
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "100",
|
||||
"sale_price": "100.00",
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def _bpayload(customer_id: int, **overrides: object) -> dict:
|
||||
body: dict = {
|
||||
"customer_id": customer_id,
|
||||
"business_date": str(date.today()),
|
||||
"items": [_bitem()],
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def _create_business(client: TestClient, headers: dict) -> dict:
|
||||
"""建一名 t_ 客户并挂一笔默认业务(回款/发货单挂在业务上)。"""
|
||||
customer = _customer(client, headers)
|
||||
resp = client.post(_BUSINESSES, json=_bpayload(customer["id"]), headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _sitem(**overrides: object) -> dict:
|
||||
|
||||
@@ -28,10 +28,14 @@ _CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL)
|
||||
_COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE)
|
||||
# 这些开头的是约束定义,不是列定义
|
||||
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
|
||||
# ALTER TABLE 的列级变更(语义重放用):ADD COLUMN / DROP COLUMN;
|
||||
# MODIFY 只改类型不改列名,对列集合无影响,无需处理
|
||||
_ALTER_ADD = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+ADD(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
|
||||
_ALTER_DROP = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+DROP(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
|
||||
|
||||
|
||||
def _parse_tables(sql: str) -> dict[str, set[str]]:
|
||||
"""从 SQL 文本中提取 {表名: {列名集合}}。"""
|
||||
"""从 SQL 文本中提取 {表名: {列名集合}}(仅 CREATE TABLE 的静态解析)。"""
|
||||
tables: dict[str, set[str]] = {}
|
||||
for table_name, body in _CREATE_TABLE.findall(sql):
|
||||
tables[table_name] = {
|
||||
@@ -42,6 +46,27 @@ def _parse_tables(sql: str) -> dict[str, set[str]]:
|
||||
return tables
|
||||
|
||||
|
||||
def _migration_final_state(paths: list[Path]) -> dict[str, set[str]]:
|
||||
"""按文件序重放迁移脚本,得到迁移链**终态**的 {表名: {列名集合}}。
|
||||
|
||||
早前实现把全部脚本的 CREATE 列做并集:某表日后被 ALTER DROP COLUMN
|
||||
时,并集里旧列名永远存在,init-dev.sql 必须保留死列才能过测 —— 守卫
|
||||
会主动背书一份与生产结构漂移的开发库 DDL。按序重放 ADD/DROP 后,
|
||||
比对基准才是终态(CHANGE RENAME 列名出现时重放会报差异,属人工核对
|
||||
的正确时机:改名同时牵动 init-dev 与 ORM,须人来确认两处同步)。
|
||||
"""
|
||||
merged: dict[str, set[str]] = {}
|
||||
for path in paths:
|
||||
sql = path.read_text(encoding="utf-8")
|
||||
for table, columns in _parse_tables(sql).items():
|
||||
merged.setdefault(table, set()).update(columns)
|
||||
for table, column in _ALTER_ADD.findall(sql):
|
||||
merged.setdefault(table, set()).add(column)
|
||||
for table, column in _ALTER_DROP.findall(sql):
|
||||
merged.setdefault(table, set()).discard(column)
|
||||
return merged
|
||||
|
||||
|
||||
def _alembic_head() -> str:
|
||||
"""从迁移脚本推断当前 head 版本号。
|
||||
|
||||
@@ -65,15 +90,10 @@ def _alembic_head() -> str:
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def migration_tables() -> dict[str, set[str]]:
|
||||
"""合并解析 db/migration/ 下全部 V*.sql。"""
|
||||
"""按文件序重放 db/migration/ 下全部 V*.sql,取迁移链终态。"""
|
||||
files = sorted(MIGRATION_DIR.glob("V*.sql"))
|
||||
assert files, f"未找到迁移脚本:{MIGRATION_DIR}"
|
||||
|
||||
merged: dict[str, set[str]] = {}
|
||||
for path in files:
|
||||
for table, columns in _parse_tables(path.read_text(encoding="utf-8")).items():
|
||||
merged.setdefault(table, set()).update(columns)
|
||||
return merged
|
||||
return _migration_final_state(files)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
|
||||
@@ -25,6 +25,7 @@ from sqlalchemy import select
|
||||
from app.core.constants import ACTION_CREATE, MODULE_SHIPMENT
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import OperationLog
|
||||
from tests.integration.factories import create_business as _create_business
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@@ -32,51 +33,6 @@ _BUSINESSES = "/api/v1/business/businesses"
|
||||
_SHIPMENTS = "/api/v1/business/shipments"
|
||||
_RECEIPTS = "/api/v1/business/receipts"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
_CUSTOMERS = "/api/v1/base/customers"
|
||||
|
||||
|
||||
def _customer(client: TestClient, headers: dict) -> dict:
|
||||
resp = client.post(
|
||||
_CUSTOMERS,
|
||||
json={
|
||||
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
|
||||
"owner_name": "张老板",
|
||||
"customer_type": "personal",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _bitem(**overrides: object) -> dict:
|
||||
"""业务明细行入参(发货创建的门槛:业务须有明细 ≥1 行)。"""
|
||||
body: dict = {
|
||||
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "100",
|
||||
"sale_price": "100.00",
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def _bpayload(customer_id: int, **overrides: object) -> dict:
|
||||
body: dict = {
|
||||
"customer_id": customer_id,
|
||||
"business_date": str(date.today()),
|
||||
"items": [_bitem()],
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def _create_business(client: TestClient, headers: dict) -> dict:
|
||||
"""建一名 t_ 客户并挂一笔默认业务(业务须先存在,发货单挂在业务上)。"""
|
||||
customer = _customer(client, headers)
|
||||
resp = client.post(_BUSINESSES, json=_bpayload(customer["id"]), headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _item(**overrides: object) -> dict:
|
||||
|
||||
@@ -69,6 +69,14 @@ class TestSalespersonRate:
|
||||
assert _sp_rate(360) == Fraction(6, 1000)
|
||||
assert _sp_rate(360) != Fraction(6)
|
||||
|
||||
def test_负天数钳制为0天_按起始比例计提(self) -> None:
|
||||
"""先款后货(payment_days 为负)不线性外插:钳到 0 天取起始比例。
|
||||
|
||||
修复前 days=-5 会算出 (10 + 5×4/360)‰ —— 高于起始值,多算绩效。
|
||||
"""
|
||||
assert _sp_rate(-5) == _sp_rate(0)
|
||||
assert _sp_rate(-1) == Fraction(10, 1000)
|
||||
|
||||
|
||||
class TestProfitRate:
|
||||
"""毛利润率:不含运费口径(对照表 #2),毛利润额 / 销售额。"""
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""产品 schema 的金额口径单测(秒级,无外部依赖)。
|
||||
|
||||
守:产品 sale_price/stock_quantity 若私写校验不查小数位与容量,5 位小数
|
||||
会被 Numeric 列静默截断(舍入即算错钱)、超容量会以 500 暴露 —— 校验
|
||||
口径必须与 schemas/business._check_amount 一致(shipment/receipt 同源)。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.product import ProductCreateIn
|
||||
|
||||
|
||||
class TestSalePrice:
|
||||
"""单价校验:scale=4 对齐 Numeric(14,4) 列。"""
|
||||
|
||||
def test_四位小数通过_回显原样提交(self) -> None:
|
||||
"""带精度的历史单价必须能原样提交(fba8cfc 教训:编辑被永远拒收)。"""
|
||||
body = ProductCreateIn(
|
||||
product_name="t_产品",
|
||||
product_category="municipal_water",
|
||||
sale_price="5000.1234",
|
||||
)
|
||||
assert body.sale_price == "5000.1234"
|
||||
|
||||
def test_五位小数拒绝而非静默截断(self) -> None:
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
ProductCreateIn(
|
||||
product_name="t_产品",
|
||||
product_category="municipal_water",
|
||||
sale_price="1.23456",
|
||||
)
|
||||
assert "最多四位小数" in str(exc.value)
|
||||
|
||||
def test_超容量拒绝(self) -> None:
|
||||
"""DECIMAL(14,4) 整数部分 10 位:超出即拒绝而非溢出 500。"""
|
||||
with pytest.raises(ValidationError):
|
||||
ProductCreateIn(
|
||||
product_name="t_产品",
|
||||
product_category="municipal_water",
|
||||
sale_price="99999999999.0000",
|
||||
)
|
||||
|
||||
def test_空串归一None_负数拒绝(self) -> None:
|
||||
empty = ProductCreateIn(
|
||||
product_name="t_产品", product_category="municipal_water", sale_price=""
|
||||
)
|
||||
assert empty.sale_price is None
|
||||
with pytest.raises(ValidationError):
|
||||
ProductCreateIn(
|
||||
product_name="t_产品", product_category="municipal_water", sale_price="-1"
|
||||
)
|
||||
|
||||
|
||||
class TestStockQuantity:
|
||||
"""数量校验:默认 scale=2 对齐 Numeric(14,2) 列。"""
|
||||
|
||||
def test_三位小数拒绝(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ProductCreateIn(
|
||||
product_name="t_产品",
|
||||
product_category="municipal_water",
|
||||
stock_quantity="1.001",
|
||||
)
|
||||
|
||||
def test_两位小数通过(self) -> None:
|
||||
body = ProductCreateIn(
|
||||
product_name="t_产品",
|
||||
product_category="municipal_water",
|
||||
stock_quantity="1.55",
|
||||
)
|
||||
assert body.stock_quantity == "1.55"
|
||||
Binary file not shown.
@@ -35,12 +35,19 @@ from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
from common import (
|
||||
E2EFailure,
|
||||
click_query,
|
||||
collect_console_errors,
|
||||
confirm_message_box,
|
||||
current_path,
|
||||
expect_toast,
|
||||
fail,
|
||||
fill_filter,
|
||||
login,
|
||||
open_menu,
|
||||
select_filter_option,
|
||||
table_rows,
|
||||
toast_text,
|
||||
visible_dialog,
|
||||
wait_toast,
|
||||
)
|
||||
|
||||
@@ -68,65 +75,6 @@ console_errors: list[str] = []
|
||||
# ==================== 页面操作辅助 ====================
|
||||
|
||||
|
||||
def open_menu(page: Page, title: str, group: str) -> None:
|
||||
"""点击侧边菜单项,必要时先展开指定分组。
|
||||
|
||||
分组(el-sub-menu)已展开时再次点击会收拢,因此先判断菜单项是否可见。
|
||||
"""
|
||||
menu = page.locator(".app-menu")
|
||||
item = menu.locator(".el-menu-item", has_text=title).first
|
||||
if not item.is_visible():
|
||||
menu.locator(".el-sub-menu__title", has_text=group).click()
|
||||
page.wait_for_timeout(400)
|
||||
item.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def fill_filter(page: Page, label: str, value: str) -> None:
|
||||
"""在**当前可见**的筛选表单中按标签填写输入框。
|
||||
|
||||
选择器一律带 `:visible` —— 同页存在多个表单实例(隐藏未销毁)时,
|
||||
不带可见性过滤会命中隐藏元素。
|
||||
"""
|
||||
form_item = page.locator(
|
||||
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
|
||||
).first
|
||||
form_item.locator("input").first.fill(value)
|
||||
|
||||
|
||||
def select_filter_option(page: Page, label: str, option: str) -> None:
|
||||
"""展开可见筛选表单中指定标签的下拉并选择选项(发货/回款页按业务筛选)。"""
|
||||
item = page.locator(
|
||||
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
|
||||
)
|
||||
item.locator(".el-select").click()
|
||||
page.wait_for_timeout(300)
|
||||
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
|
||||
def click_query(page: Page) -> None:
|
||||
"""点击可见筛选表单中的查询按钮。"""
|
||||
page.locator(".filter-form:visible button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def table_rows(page: Page):
|
||||
"""当前可见的表格行(选择器带 :visible,避开隐藏实例)。"""
|
||||
return page.locator(".el-table__body .el-table__row:visible")
|
||||
|
||||
|
||||
def visible_dialog(page: Page):
|
||||
"""当前可见的对话框(同页可能存在多个 dialog 实例,按可见性取最后一个)。"""
|
||||
return page.locator(".el-dialog:visible").last
|
||||
|
||||
|
||||
def confirm_message_box(page: Page, button_text: str) -> None:
|
||||
"""点击确认框(ElMessageBox)中的指定按钮。"""
|
||||
page.locator(".el-message-box:visible").locator(f"button:has-text('{button_text}')").click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def confirm_text(page: Page) -> str:
|
||||
"""当前确认框的完整文本(业务口径文案断言用),轮询等待出现。"""
|
||||
deadline = time.monotonic() + 5
|
||||
|
||||
@@ -156,3 +156,66 @@ def login(page: Page, username: str, password: str) -> None:
|
||||
page.wait_for_load_state("networkidle")
|
||||
finally:
|
||||
page.remove_listener("response", on_response)
|
||||
|
||||
|
||||
# ==================== 列表页 UI 操作(base_data/system_management 共用) ====================
|
||||
|
||||
|
||||
def open_menu(page: Page, title: str, group: str) -> None:
|
||||
"""点击侧边菜单项,必要时先展开指定分组。
|
||||
|
||||
分组(el-sub-menu)已展开时再次点击会收拢,因此先判断菜单项是否可见。
|
||||
"""
|
||||
menu = page.locator(".app-menu")
|
||||
item = menu.locator(".el-menu-item", has_text=title).first
|
||||
if not item.is_visible():
|
||||
menu.locator(".el-sub-menu__title", has_text=group).click()
|
||||
page.wait_for_timeout(400)
|
||||
item.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def fill_filter(page: Page, label: str, value: str) -> None:
|
||||
"""在**当前可见**的筛选表单中按标签填写输入框。
|
||||
|
||||
标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」;
|
||||
选择器带 `:visible` —— 同页存在多个表单实例(v-show 隐藏未销毁)时
|
||||
会命中隐藏元素。
|
||||
"""
|
||||
form_item = page.locator(
|
||||
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
|
||||
).first
|
||||
form_item.locator("input").first.fill(value)
|
||||
|
||||
|
||||
def select_filter_option(page: Page, label: str, option: str) -> None:
|
||||
"""展开可见筛选表单中指定标签的下拉并选择选项。"""
|
||||
item = page.locator(
|
||||
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
|
||||
)
|
||||
item.locator(".el-select").click()
|
||||
page.wait_for_timeout(300)
|
||||
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
|
||||
def click_query(page: Page) -> None:
|
||||
"""点击可见筛选表单中的查询按钮。"""
|
||||
page.locator(".filter-form:visible button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def table_rows(page: Page):
|
||||
"""当前可见的表格行(选择器带 :visible,避开隐藏实例)。"""
|
||||
return page.locator(".el-table__body .el-table__row:visible")
|
||||
|
||||
|
||||
def visible_dialog(page: Page):
|
||||
"""当前可见的对话框(同页可能存在多个 dialog 实例,按可见性取最后一个)。"""
|
||||
return page.locator(".el-dialog:visible").last
|
||||
|
||||
|
||||
def confirm_message_box(page: Page, button_text: str) -> None:
|
||||
"""点击确认框(ElMessageBox)中的指定按钮。"""
|
||||
page.locator(".el-message-box:visible").locator(f"button:has-text('{button_text}')").click()
|
||||
page.wait_for_timeout(800)
|
||||
@@ -23,6 +23,8 @@
|
||||
公共工具见 `e2e/common.py`。
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
from common import (
|
||||
@@ -36,7 +38,9 @@ from common import (
|
||||
)
|
||||
|
||||
USERNAME = "admin"
|
||||
PASSWORD = "Admin@2026"
|
||||
# 与 system_management 同一约定:开发库 admin 密码从环境变量传入(各库不同,
|
||||
# 硬编码会在他机上假绿为「凭据错误」),缺省保留历史默认值兜底本机老库
|
||||
PASSWORD = os.environ.get("E2E_ADMIN_PASSWORD", "Admin@2026")
|
||||
|
||||
|
||||
def run(page: Page) -> None:
|
||||
|
||||
@@ -29,6 +29,13 @@ import time
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
from common import (
|
||||
click_query,
|
||||
confirm_message_box,
|
||||
fill_filter,
|
||||
open_menu,
|
||||
select_filter_option,
|
||||
table_rows,
|
||||
visible_dialog,
|
||||
BASE_URL,
|
||||
E2EFailure,
|
||||
collect_console_errors,
|
||||
@@ -56,66 +63,6 @@ console_errors: list[str] = []
|
||||
# ==================== 页面操作辅助 ====================
|
||||
|
||||
|
||||
def open_menu(page: Page, title: str) -> None:
|
||||
"""点击侧边菜单项,必要时先展开「系统管理」分组。
|
||||
|
||||
分组已展开时再次点击会收拢,因此先判断菜单项是否已经可见。
|
||||
"""
|
||||
menu = page.locator(".app-menu")
|
||||
item = menu.get_by_text(title, exact=True)
|
||||
if not item.is_visible():
|
||||
menu.get_by_text("系统管理", exact=True).click()
|
||||
page.wait_for_timeout(400)
|
||||
item.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def fill_filter(page: Page, label: str, value: str) -> None:
|
||||
"""在**当前可见**的筛选表单中按标签填写输入框。
|
||||
|
||||
选择器一律带 `:visible` —— 日志页两个 tab 各有一个表单(非激活 tab
|
||||
用 v-show 隐藏),不带可见性过滤会命中隐藏表单的元素。
|
||||
"""
|
||||
form_item = page.locator(".filter-form:visible .el-form-item", has_text=label).first
|
||||
form_item.locator("input").first.fill(value)
|
||||
|
||||
|
||||
def select_filter_option(page: Page, label: str, option: str) -> None:
|
||||
"""展开可见筛选表单中指定标签的下拉并选择选项。
|
||||
|
||||
标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」。
|
||||
"""
|
||||
item = page.locator(
|
||||
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
|
||||
)
|
||||
item.locator(".el-select").click()
|
||||
page.wait_for_timeout(300)
|
||||
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
|
||||
def click_query(page: Page) -> None:
|
||||
"""点击可见筛选表单中的查询按钮。"""
|
||||
page.locator(".filter-form:visible button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def table_rows(page: Page):
|
||||
"""当前可见的表格行(日志页非激活 tab 的表格隐藏,需过滤)。"""
|
||||
return page.locator(".el-table__body .el-table__row:visible")
|
||||
|
||||
|
||||
def confirm_message_box(page: Page, button_text: str) -> None:
|
||||
"""点击确认框(ElMessageBox)中的指定按钮。"""
|
||||
page.locator(".el-message-box").locator(f"button:has-text('{button_text}')").click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def visible_dialog(page: Page):
|
||||
"""当前可见的对话框(同页存在多个 dialog 实例,按可见性取)。"""
|
||||
return page.locator(".el-dialog:visible").last
|
||||
|
||||
|
||||
def create_user_via_ui(
|
||||
page: Page, username: str, real_name: str, expect_success: bool = True
|
||||
) -> None:
|
||||
@@ -154,7 +101,7 @@ def find_user_row(page: Page, username: str):
|
||||
def step_user_create(page: Page, admin_password: str) -> None:
|
||||
"""登录、菜单导航、新增用户与重名负例。"""
|
||||
login(page, ADMIN, admin_password)
|
||||
open_menu(page, "用户管理")
|
||||
open_menu(page, "用户管理", "系统管理")
|
||||
if current_path(page) != "/system/users":
|
||||
fail("菜单导航", f"未进入用户管理页,当前 {page.url}")
|
||||
print("✓ 1. 菜单导航 经侧边菜单进入用户管理")
|
||||
@@ -217,7 +164,7 @@ def step_user_edit_and_status(page: Page) -> None:
|
||||
|
||||
def step_role_management(page: Page) -> None:
|
||||
"""角色保护与角色 CRUD。"""
|
||||
open_menu(page, "角色管理")
|
||||
open_menu(page, "角色管理", "系统管理")
|
||||
if current_path(page) != "/system/roles":
|
||||
fail("菜单导航", f"未进入角色管理页,当前 {page.url}")
|
||||
|
||||
@@ -259,7 +206,7 @@ def step_role_management(page: Page) -> None:
|
||||
|
||||
def step_logs_query(page: Page) -> None:
|
||||
"""操作日志(含详情懒加载与脱敏断言)与登录日志。"""
|
||||
open_menu(page, "日志查询")
|
||||
open_menu(page, "日志查询", "系统管理")
|
||||
if current_path(page) != "/system/logs":
|
||||
fail("菜单导航", f"未进入日志查询页,当前 {page.url}")
|
||||
|
||||
@@ -297,7 +244,7 @@ def step_logs_query(page: Page) -> None:
|
||||
|
||||
def step_permission_check(page: Page, admin_password: str) -> None:
|
||||
"""普通用户菜单隐藏与路由守卫。"""
|
||||
open_menu(page, "用户管理")
|
||||
open_menu(page, "用户管理", "系统管理")
|
||||
create_user_via_ui(page, VIEWER_USERNAME, "E2E访客")
|
||||
page.click("text=退出登录")
|
||||
page.wait_for_url("**/login**", timeout=10_000)
|
||||
@@ -322,7 +269,7 @@ def step_permission_check(page: Page, admin_password: str) -> None:
|
||||
|
||||
def step_cleanup(page: Page) -> None:
|
||||
"""删除测试用户与角色(用 expect_toast 校验,失败不假绿)。"""
|
||||
open_menu(page, "用户管理")
|
||||
open_menu(page, "用户管理", "系统管理")
|
||||
for username in (NEW_USERNAME, VIEWER_USERNAME):
|
||||
row = find_user_row(page, username)
|
||||
if row.count() == 0:
|
||||
@@ -332,7 +279,7 @@ def step_cleanup(page: Page) -> None:
|
||||
confirm_message_box(page, "删除")
|
||||
expect_toast(page, "已删除", "数据清理")
|
||||
|
||||
open_menu(page, "角色管理")
|
||||
open_menu(page, "角色管理", "系统管理")
|
||||
fill_filter(page, "关键词", NEW_ROLE_CODE)
|
||||
click_query(page)
|
||||
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
|
||||
|
||||
+13
-12
@@ -63,20 +63,21 @@ service.interceptors.response.use(
|
||||
}
|
||||
return response
|
||||
},
|
||||
(error: AxiosError<ApiResult>) => {
|
||||
async (error: AxiosError<ApiResult>) => {
|
||||
const status = error.response?.status
|
||||
let message = error.response?.data?.message ?? '网络异常,请稍后重试'
|
||||
// blob 请求失败时 data 是 Blob(如导出接口返回的 JSON 信封错误),需读出真实 message
|
||||
if (error.response?.data instanceof Blob) {
|
||||
const raw = error.response?.data
|
||||
let message = (raw as ApiResult | undefined)?.message ?? '网络异常,请稍后重试'
|
||||
// blob 请求失败时 data 是 Blob(如导出接口返回的 JSON 信封错误):
|
||||
// 先解析出真实文案再统一提示 —— 先弹兜底再异步补弹会造成双提示,
|
||||
// 401 时跳转提示也会与实际原因错位
|
||||
if (raw instanceof Blob) {
|
||||
message = '导出失败,请稍后重试'
|
||||
void error.response.data.text().then((text) => {
|
||||
try {
|
||||
const body = JSON.parse(text) as { message?: string }
|
||||
if (body.message) ElMessage.error(body.message)
|
||||
} catch {
|
||||
/* 非 JSON 响应,保持兜底提示 */
|
||||
}
|
||||
})
|
||||
try {
|
||||
const body = JSON.parse(await raw.text()) as { message?: string }
|
||||
if (body.message) message = body.message
|
||||
} catch {
|
||||
/* 非 JSON 响应,保持兜底提示 */
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
|
||||
@@ -39,8 +39,10 @@ export function useConfirmAction() {
|
||||
|
||||
try {
|
||||
await options.run()
|
||||
ElMessage.success(options.successText)
|
||||
await options.after?.()
|
||||
// 收尾(列表刷新)成功后才提示:先提示后收尾会让用户看到「已删除」
|
||||
// 而列表仍留旧行(after 抛错被吞),成功/失败提示同屏矛盾
|
||||
ElMessage.success(options.successText)
|
||||
return true
|
||||
} catch (error) {
|
||||
handledError(error) // 失败提示由请求拦截器统一完成
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 下拉选项的加载状态:取数失败不阻断列表使用(下拉为空 + 拦截器提示)。
|
||||
*
|
||||
* 业务/客户/人员等参照下拉在多个页面重复同一「try 取数 → map 成选项 →
|
||||
* 失败吞掉」骨架,映射函数由调用方给出(各页字段不同),骨架收口单点。
|
||||
*/
|
||||
export function useDropdownOptions<T>(fetch: () => Promise<T[]>): {
|
||||
options: Ref<T[]>
|
||||
load: () => Promise<void>
|
||||
} {
|
||||
const options = ref([]) as Ref<T[]>
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
options.value = await fetch()
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
return { options, load }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 编辑中的行:editingId 非空时从当前页记录里找出行(回显兜底用)。
|
||||
*
|
||||
* 回款/发货/人员三页此前各持一份逐字相同的 computed(行对象上要展示
|
||||
* 「编辑中」的派生信息,如业务编号、停用岗位回显),收口后口径一处维护。
|
||||
*/
|
||||
export function useEditingRow<T extends { id: number }>(
|
||||
records: Ref<T[]>,
|
||||
editingId: Ref<number | null>,
|
||||
) {
|
||||
return computed(() =>
|
||||
editingId.value === null ? null : (records.value.find((r) => r.id === editingId.value) ?? null),
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,21 @@ export const PASSWORD_MAX_BYTES = 72
|
||||
|
||||
const BYTE_HINT = '(中文一个字符约 3 字节)'
|
||||
|
||||
/**
|
||||
* 金额形态正则(与后端 `schemas/business._check_amount` 同口径单点维护)。
|
||||
*
|
||||
* 「DECIMAL 列 ↔ 前端放行位数」规则此前在业务/发货/回款/人员四页各写一份
|
||||
* (businesses 与 shipments 逐字重复,receipts/personnel 又各自内联两位小数
|
||||
* 正则),后端精度调整时漏改任何一页,就会出现「A 页面拦得住、B 页面放行
|
||||
* 后端 400」或「带精度历史行永远无法编辑」(fba8cfc 的教训)。
|
||||
*/
|
||||
/** 非负数字、最多两位小数(对齐 DECIMAL(14,2):金额/数量/成本/运费/工资) */
|
||||
export const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
|
||||
/** 非负数字、最多四位小数(对齐 DECIMAL(14,4):单价/指导价) */
|
||||
export const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
|
||||
/** 全零形态(数量「0 或 0.00」须拦:数量 >0 是明细行的硬约束) */
|
||||
export const ZERO_PATTERN = /^0+(\.0+)?$/
|
||||
|
||||
/** 计算 UTF-8 字节数 */
|
||||
export function passwordByteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).length
|
||||
|
||||
@@ -9,6 +9,8 @@ import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import StatusTag from '@/components/table/StatusTag.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { useEditingRow } from '@/composables/useEditingRow'
|
||||
import { AMOUNT_PATTERN } from '@/utils/validate'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
@@ -107,9 +109,7 @@ const form = reactive({
|
||||
})
|
||||
|
||||
/** 编辑中的行(用于停用岗位/离职经理的回显兜底) */
|
||||
const editingRow = computed(() =>
|
||||
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
|
||||
)
|
||||
const editingRow = useEditingRow(records, editingId)
|
||||
|
||||
/** 搜索下拉显示全部岗位(含停用,便于筛出仍绑定停用岗位的人员) */
|
||||
const searchRoleOptions = computed(() =>
|
||||
@@ -139,7 +139,7 @@ const rules = computed<FormRules>(() => ({
|
||||
business_role_id: [{ required: true, message: '请选择岗位', trigger: 'change' }],
|
||||
phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }],
|
||||
salary_standard: [
|
||||
{ pattern: /^(\d+)(\.\d{1,2})?$/, message: '工资标准须为非负数字,最多两位小数', trigger: 'blur' },
|
||||
{ pattern: AMOUNT_PATTERN, message: '工资标准须为非负数字,最多两位小数', trigger: 'blur' },
|
||||
],
|
||||
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
|
||||
}))
|
||||
|
||||
@@ -12,6 +12,7 @@ import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
@@ -101,15 +102,6 @@ function personnelLabel(option: PersonnelOption): string {
|
||||
|
||||
// ==================== 新增 / 编辑(含明细行编辑器) ====================
|
||||
|
||||
/** 金额/数量形态:非负数字、最多两位小数(纯形态校验,不涉及 float 运算) */
|
||||
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
|
||||
// 单价类列(sale_price/guided_price)建表为 Numeric(14,4):后端 str 直出
|
||||
// 会带最多 4 位小数(如 "5000.0000"),前端必须放行到 4 位——否则任何
|
||||
// 带小数精度的历史记录在编辑时都会被前端永远拦下
|
||||
const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
|
||||
/** 全零形态:数量用它排除「>0」语义,不走 float 比较 */
|
||||
const ZERO_PATTERN = /^0+(\.0+)?$/
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
@@ -429,8 +421,11 @@ function personnelName(id: number): string {
|
||||
// ==================== 详情抽屉 ====================
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const drawerRowId = ref<number | null>(null)
|
||||
const drawerRow = ref<BusinessItem | null>(null)
|
||||
// 单一状态源:drawerRowId 派生自 drawerRow(此前与 ownershipLogsId 三份并存、
|
||||
// openDrawer 手工同步——新增写入路径漏更新其一就会出现「标题是 A 业务、
|
||||
// 留痕表拉到 B 业务」且无报错)
|
||||
const drawerRowId = computed(() => drawerRow.value?.id ?? null)
|
||||
const drawerTitle = computed(() =>
|
||||
drawerRow.value ? `「${drawerRow.value.business_no}」· 业务详情` : '业务详情',
|
||||
)
|
||||
@@ -467,12 +462,10 @@ async function refreshDrawer(id: number): Promise<void> {
|
||||
|
||||
function openDrawer(row: BusinessItem): void {
|
||||
drawerRow.value = row
|
||||
drawerRowId.value = row.id
|
||||
drawerVisible.value = true
|
||||
detail.value = null
|
||||
statusLogs.value = []
|
||||
gp.value = null
|
||||
ownershipLogsId.value = row.id
|
||||
ownershipLogPageNum.value = 1
|
||||
void loadDrawerData(row.id)
|
||||
void loadOwnershipLogs()
|
||||
@@ -491,7 +484,7 @@ const {
|
||||
onSizeChange: onOwnershipLogSizeChange,
|
||||
} = usePagedList<businessApi.OwnershipLogItem, object>(
|
||||
(params) => {
|
||||
const id = ownershipLogsId.value
|
||||
const id = drawerRowId.value
|
||||
// 抽屉未指定业务时兜底空数据,避免发出无意义请求
|
||||
return id === null
|
||||
? Promise.resolve({ list: [], total: 0, pageNum: 1, pageSize: 10 })
|
||||
@@ -500,8 +493,6 @@ const {
|
||||
() => ({}),
|
||||
)
|
||||
|
||||
const ownershipLogsId = ref<number | null>(null)
|
||||
|
||||
/** 留痕展示:负责/协助的新旧人员(姓名随联表,ID 固化) */
|
||||
function ownershipTransition(row: businessApi.OwnershipLogItem): string {
|
||||
const oldOwner = row.old_owner_name ?? '(无)'
|
||||
@@ -627,7 +618,19 @@ onMounted(() => {
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openDrawer(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-tooltip content="已完成或已终止的业务禁止编辑" :disabled="row.status === 1" placement="top">
|
||||
<span class="op-wrap">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="row.status !== 1"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="终态不可再变更" :disabled="row.status === 1" placement="top">
|
||||
<span class="op-wrap">
|
||||
<el-button
|
||||
|
||||
@@ -10,6 +10,9 @@ import type { ShipmentItem } from '@/api/shipments'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { AMOUNT_PATTERN } from '@/utils/validate'
|
||||
import { useEditingRow } from '@/composables/useEditingRow'
|
||||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
@@ -63,21 +66,17 @@ interface BusinessOption {
|
||||
name: string
|
||||
}
|
||||
|
||||
const businessOptions = ref<BusinessOption[]>([])
|
||||
|
||||
async function loadBusinessOptions(): Promise<void> {
|
||||
try {
|
||||
// 业务下拉数据源:一页 200 条覆盖内部系统规模;超出由后端校验兜底
|
||||
// 业务下拉数据源:一页 200 条覆盖内部系统规模;超出由后端校验兜底
|
||||
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
|
||||
async () => {
|
||||
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
|
||||
businessOptions.value = data.list.map((b) => ({
|
||||
return data.list.map((b) => ({
|
||||
id: b.id,
|
||||
no: b.business_no,
|
||||
name: b.customer_name ?? '',
|
||||
}))
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function businessLabel(option: BusinessOption): string {
|
||||
return option.name ? `${option.no} · ${option.name}` : option.no
|
||||
@@ -85,9 +84,6 @@ function businessLabel(option: BusinessOption): string {
|
||||
|
||||
// ==================== 新增 / 编辑 ====================
|
||||
|
||||
/** 金额形态:非负数字、最多两位小数(与后端 `_check_amount` 同口径) */
|
||||
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
@@ -124,9 +120,7 @@ const shipmentOptions = ref<ShipmentItem[]>([])
|
||||
const shipmentLoading = ref(false)
|
||||
|
||||
/** 编辑中的回款(回显业务编号/批次语义用) */
|
||||
const editingRow = computed(() =>
|
||||
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
|
||||
)
|
||||
const editingRow = useEditingRow(records, editingId)
|
||||
|
||||
/** 下拉展示:发货单号(发货日期,总额)—— 与任务约定文案一致 */
|
||||
function shipmentLabel(row: ShipmentItem): string {
|
||||
|
||||
@@ -8,6 +8,9 @@ import type { ShipmentDetail, ShipmentItem } from '@/api/shipments'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
|
||||
import { useEditingRow } from '@/composables/useEditingRow'
|
||||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
@@ -55,22 +58,18 @@ interface BusinessOption {
|
||||
status: number
|
||||
}
|
||||
|
||||
const businessOptions = ref<BusinessOption[]>([])
|
||||
|
||||
async function loadBusinessOptions(): Promise<void> {
|
||||
try {
|
||||
// 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
|
||||
// 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
|
||||
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
|
||||
async () => {
|
||||
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
|
||||
businessOptions.value = data.list.map((b) => ({
|
||||
return data.list.map((b) => ({
|
||||
id: b.id,
|
||||
no: b.business_no,
|
||||
name: b.customer_name ?? '',
|
||||
status: b.status,
|
||||
}))
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** 下拉项展示:业务编号 + 客户名;终态业务加后缀标注(创建发货单会被后端拦) */
|
||||
function businessLabel(option: BusinessOption): string {
|
||||
@@ -80,13 +79,6 @@ function businessLabel(option: BusinessOption): string {
|
||||
|
||||
// ==================== 新增 / 编辑(含明细行编辑器) ====================
|
||||
|
||||
/** 金额/数量形态:非负数字、最多两位小数(与后端 `_check_amount` 同口径) */
|
||||
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
|
||||
// 发货单价列建表为 Numeric(14,4):后端 str 直出带最多 4 位小数,前端放行到 4 位
|
||||
const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
|
||||
/** 全零形态:数量排除「>0」语义,不走 float 比较 */
|
||||
const ZERO_PATTERN = /^0+(\.0+)?$/
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
@@ -95,9 +87,7 @@ const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
|
||||
/** 编辑中的发货单(回显业务编号用,编辑挂靠不可改) */
|
||||
const editingRow = computed(() =>
|
||||
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
|
||||
)
|
||||
const editingRow = useEditingRow(records, editingId)
|
||||
|
||||
/** 明细行草稿(编辑时由详情回显;全量替换,行 id 不参与提交) */
|
||||
interface ItemDraft {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
OperationLogItem,
|
||||
OperationLogQueryParams,
|
||||
} from '@/api/logs'
|
||||
import type { PageData } from '@/api/types'
|
||||
import StatusTag from '@/components/table/StatusTag.vue'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
@@ -120,6 +121,15 @@ const loginFilters = reactive({
|
||||
})
|
||||
const loginTimeRange = ref<[string, string] | null>(null)
|
||||
|
||||
// fetcher 成功返回即置位(抛错不会走到置位行):供 watch 判断「首次成功」,
|
||||
// 失败后切回 tab 会重拉,不会停在空表
|
||||
const loginLoadedOk = ref(false)
|
||||
async function loginFetcher(params: LoginLogQueryParams): Promise<PageData<LoginLogItem>> {
|
||||
const data = await logApi.listLoginLogs(params)
|
||||
loginLoadedOk.value = true
|
||||
return data
|
||||
}
|
||||
|
||||
const {
|
||||
records: loginRecords,
|
||||
total: loginTotal,
|
||||
@@ -130,7 +140,7 @@ const {
|
||||
search: searchLoginLogs,
|
||||
onPageChange: onLoginPageChange,
|
||||
onSizeChange: onLoginSizeChange,
|
||||
} = usePagedList<LoginLogItem, LoginLogQueryParams>(logApi.listLoginLogs, () => ({
|
||||
} = usePagedList<LoginLogItem, LoginLogQueryParams>(loginFetcher, () => ({
|
||||
username: loginFilters.username,
|
||||
ip: loginFilters.ip,
|
||||
success: loginFilters.success,
|
||||
@@ -150,12 +160,12 @@ function resetLoginFilters(): void {
|
||||
|
||||
onMounted(loadOperationLogs)
|
||||
|
||||
// 登录日志 tab 每次激活都刷新(首屏不打多余请求;此前"只加载一次"的
|
||||
// 标志在首次失败后不会重置,切回时停在空表且无从自查)
|
||||
// 登录日志 tab 首次成功加载后不再重复拉取(来回切 tab 的重复请求是纯冗余,
|
||||
// 筛选/翻页各自触发刷新)。loginLoadedOk 由 fetcher 成功返回时置位:
|
||||
// 此前「每次激活都刷新」与「无条件只拉一次」两个版本各有代价 —— 前者
|
||||
// 冗余请求、后者首次失败后停在空表且无从自查。
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'login') {
|
||||
void loadLoginLogs()
|
||||
}
|
||||
if (tab === 'login' && !loginLoadedOk.value) void loadLoginLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from '@/api/params'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
@@ -189,25 +190,20 @@ async function submitAll(): Promise<void> {
|
||||
// ==================== 版本管理抽屉 ====================
|
||||
|
||||
const versionVisible = ref(false)
|
||||
const batches = ref<ParamBatch[]>([])
|
||||
const batchesTotal = ref(0)
|
||||
const batchesPage = ref(1)
|
||||
const batchesPageSize = ref(10)
|
||||
const batchesLoading = ref(false)
|
||||
const detailCache = reactive<Record<number, ParamBatchDetail>>({})
|
||||
|
||||
async function loadBatches(): Promise<void> {
|
||||
batchesLoading.value = true
|
||||
try {
|
||||
const page = await paramsApi.listBatches({ pageNum: batchesPage.value, pageSize: batchesPageSize.value })
|
||||
batches.value = page.list
|
||||
batchesTotal.value = page.total
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
batchesLoading.value = false
|
||||
}
|
||||
}
|
||||
// 分页五件套换 usePagedList:手写版没有请求序号防竞态(快速翻页时旧响应
|
||||
// 后到会覆盖新页数据)与页码回退——正是该 composable 注释里写明已修过的坑
|
||||
const {
|
||||
records: batches,
|
||||
total: batchesTotal,
|
||||
pageNum: batchesPage,
|
||||
pageSize: batchesPageSize,
|
||||
loading: batchesLoading,
|
||||
load: loadBatches,
|
||||
onPageChange: onBatchesPageChange,
|
||||
onSizeChange: onBatchesSizeChange,
|
||||
} = usePagedList<ParamBatch, Record<string, never>>(paramsApi.listBatches, () => ({}))
|
||||
|
||||
async function detailOf(batch: ParamBatch): Promise<ParamBatchDetail> {
|
||||
if (detailCache[batch.id]) return detailCache[batch.id]
|
||||
@@ -299,11 +295,21 @@ function openVersionDrawer(): void {
|
||||
|
||||
const historyVisible = ref(false)
|
||||
const historyItem = ref<ParamCatalogItem | null>(null)
|
||||
const historyRows = ref<ParamHistory[]>([])
|
||||
const historyTotal = ref(0)
|
||||
const historyPage = ref(1)
|
||||
const historyPageSize = ref(10)
|
||||
const historyLoading = ref(false)
|
||||
|
||||
// 历史抽屉分页同样收口 usePagedList;fetcher 闭包当前参数的编码
|
||||
const {
|
||||
records: historyRows,
|
||||
total: historyTotal,
|
||||
pageNum: historyPage,
|
||||
pageSize: historyPageSize,
|
||||
loading: historyLoading,
|
||||
load: loadHistory,
|
||||
onPageChange: onHistoryPageChange,
|
||||
onSizeChange: onHistorySizeChange,
|
||||
} = usePagedList<ParamHistory, Record<string, never>>(
|
||||
(params) => paramsApi.getParamHistory(historyItem.value!.param_code, params),
|
||||
() => ({}),
|
||||
)
|
||||
|
||||
function openHistory(item: ParamCatalogItem): void {
|
||||
historyItem.value = item
|
||||
@@ -312,23 +318,6 @@ function openHistory(item: ParamCatalogItem): void {
|
||||
void loadHistory()
|
||||
}
|
||||
|
||||
async function loadHistory(): Promise<void> {
|
||||
if (!historyItem.value) return
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const page = await paramsApi.getParamHistory(historyItem.value.param_code, {
|
||||
pageNum: historyPage.value,
|
||||
pageSize: historyPageSize.value,
|
||||
})
|
||||
historyRows.value = page.list
|
||||
historyTotal.value = page.total
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 计算预览抽屉 ====================
|
||||
|
||||
const previewVisible = ref(false)
|
||||
@@ -667,8 +656,8 @@ onMounted(() => {
|
||||
:total="batchesTotal"
|
||||
:page-num="batchesPage"
|
||||
:page-size="batchesPageSize"
|
||||
@page-change="(p: number) => { batchesPage = p; void loadBatches() }"
|
||||
@size-change="(s: number) => { batchesPageSize = s; batchesPage = 1; void loadBatches() }"
|
||||
@page-change="onBatchesPageChange"
|
||||
@size-change="onBatchesSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-drawer>
|
||||
@@ -692,8 +681,8 @@ onMounted(() => {
|
||||
:total="historyTotal"
|
||||
:page-num="historyPage"
|
||||
:page-size="historyPageSize"
|
||||
@page-change="(p: number) => { historyPage = p; void loadHistory() }"
|
||||
@size-change="(s: number) => { historyPageSize = s; historyPage = 1; void loadHistory() }"
|
||||
@page-change="onHistoryPageChange"
|
||||
@size-change="onHistorySizeChange"
|
||||
/>
|
||||
</el-drawer>
|
||||
|
||||
|
||||
Reference in new issue
Block a user