17 次代码提交
作者 SHA1 备注 提交日期
liuwenxin和Claude Code 94c49138bf refactor(e2e): 六个列表页 UI helper 收口 common.py
open_menu 时分组参数化、fill_filter :text-is 精确匹配、messagebox 带
:visible(base_data 实现为准)——两脚本此前各持一份且匹配精度漂移
(has_text 子串 vs :text-is),使用者误以为两者等价;登录/系统管理/
基础数据/业务数据四链路 e2e 复跑全过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 11:01:31 +08:00
liuwenxin和Claude Code f75a9d534e refactor(test): 集成工厂上提共用,submit_change 拆校验段,审计重试断言对齐全局兜底
- tests/integration/factories.py:回款/发货两文件逐字重复的
  _customer/_bitem/_bpayload/_create_business 上提(t_ 前缀与默认行
  口径单点维护);test_audit_retry 的持续冲突断言由 500 更新为
  400「数据完整性冲突」——全局 IntegrityError handler(批 6)收敛
  行为属预期改进,非回归
- param_service.submit_change 拆出 _normalize_items(校验段独立,
  主函数降至 50 行约定内);e2e 六个列表页 helper 收口 common.py
  (base_data 版为准:分组参数化、:text-is 精确匹配),两脚本变薄
- 全量回归 625 passed(基线 603 + 新增 22);e2e 四链路(登录 8/
  系统管理 13/基础数据 13/业务 16)全过
- 测试 README 补 2026-10-15 运行记录与 10 条评审缺陷(预览千分比/
  审计假成功/字典序 MAX/无锁编辑/删产品 500/schema 口径/quantize/
  负天数外插/评级并发/大小写不对称)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 10:58:44 +08:00
liuwenxin和Claude Code 0ef3adec21 refactor(frontend): 金额正则/分页/抽屉状态/行编辑与下拉收口
- 金额形态正则收进 utils/validate.ts 单点导出(此前四页各写,精度
  调整漏改一页即「A 页拦得住 B 页放行后端 400」或历史行无法编辑)
- params 版本/历史两抽屉的手写分页五件套换 usePagedList(白得请求
  序号防竞态——快速翻页旧响应覆盖新页的手写坑不再复现)
- 业务抽屉三份 id 状态收为 drawerRowId computed(单一状态源,消除
  「标题 A 业务、留痕表 B 业务」的失同步面)
- useEditingRow(三页逐字相同的 computed)与 useDropdownOptions
  (取数失败不阻断列表的骨架)收口,映射函数由调用方给出
- e2e 冒烟:基础数据 13 步 + 业务全链路 16 步全过

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 10:08:24 +08:00
liuwenxin和Claude Code 60f6af6494 refactor(repos): 归属留痕共享数据访问,人员在职校验收口单点
- ownership_log_repository:客户/业务两侧逐行同构的 list/count/add
  收口(owner_type 与引用列参数化),双键倒序与被删人员兜底口径
  单点维护;两侧 repository 变薄包装,service 层零改动
- PERSONNEL_STATUS_ACTIVE 单点导出于 models/personnel(对照
  BUSINESS_STATUS_* 先例);业务/客户两侧的「存在且在职」校验收口
  于 personnel_service.ensure_active——此前两份常量+校验仅靠注释
  维持同步,在职判定调整时会两入口口径分叉;校验放 service 层遵守
  「repositories 不抛业务异常」红线

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 09:49:53 +08:00
liuwenxin和Claude Code 409e26614f refactor(business): 编辑出参取校验返回值省重查,submit_change 批量预载
- update_receipt 取 _ensure_shipment_of_business 返回值组装出参
  (shipment_id 已赋为 payload 新值,与 get_with_refs 重查同源),
  省一次三表 join;update_shipment 同理取锁定行的 business_no
- 删 business_repository.count_items(无任何调用的死代码,删除保护
  实际走 has_docs)
- submit_change 校验段定义与岗位各一条 IN 批量预载(逐条查询在批次
  扩大时是写侧 N+1);岗位字典键 casefold 归一保持与单条版 ci 校对
  等价的命中语义;旧值留痕保持逐条(窗口函数优化留待 M3 一并设计)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 09:32:35 +08:00
liuwenxin和Claude Code 5ef48abf47 test(schema-sync): 迁移链按序重放 ALTER,比对基准由历史并集改为终态
早前实现把全部 V*.sql 的 CREATE 列做并集:某表日后被 ALTER DROP
COLUMN 时并集里旧列名永存,init-dev.sql 必须保留死列才能过测——
守卫会主动背书一份与生产结构漂移的开发库 DDL。按文件序重放
ADD/DROP COLUMN 后比对基准为迁移链终态;列改名(CHANGE)出现时
重放报差异属人工核对时机(改名同时牵动 init-dev 与 ORM)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 09:24:34 +08:00
liuwenxin和Claude Code 7f8f88c148 fix(frontend): 错误提示单一化/成功提示后置/日志页首次成功拉取
- request.ts blob 失败分支:先解析真实文案再统一提示——此前先弹兜底
  「导出失败」再异步补弹真实 message(双提示),401 时跳转提示与
  实际原因错位;401 语义文案在导出场景不再失真
- useConfirmAction:收尾(列表刷新)成功后才弹成功提示——此前先弹
  「已删除」再刷新,after 失败被吞时成功/失败提示同屏矛盾
- 日志页登录 tab 首次成功加载后不再重复拉取(loginLoadedOk 由
  fetcher 成功返回置位,失败切回可重拉,不复活「失败停空表」旧坑)
- login_flow.py 密码改从 E2E_ADMIN_PASSWORD 读(与 system_management
  同一约定),硬编码默认值在他机上会假绿为「凭据错误」
- e2e 冒烟:登录 8 项 + 系统管理 13 项全过(拦截器全局改动验证)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 09:22:12 +08:00
liuwenxin和Claude Code c2b462df3b fix(rating): 区间重叠判定改用锁定读取,防并发重叠双提交
先查后写无锁时,两管理员并发提交重叠区间互判不重叠而双双入库
(区间重叠无唯一键可拦,同一总分命中多等级且结果取决于插入顺序);
判重叠的 list_all 加 for_update 锁全表行串行化并发判定,只读直出
list_grades 不加锁。SQL 形态用例锁定两条口径。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 19:01:11 +08:00
liuwenxin和Claude Code e6c2c71d1b fix(business): 行金额乘积显式两位固化,计提比例对负天数钳制
- 明细行金额(数量2位×单价4位可产 6 位小数)直写 DECIMAL(14,2) 被
  MySQL 静默舍入且回显漂移(创建响应读内存值、复查读库值不一致);
  显式 quantize HALF_UP 固化,舍入方向与 MySQL 一致(业务/发货同改)
- 先款后货回款(payment_days 为负)线性外插会算出高于起始值的计提
  比例(多算绩效);钳制到 0 天按起始比例计提,payment_days 出参仍
  照实输出负值(口径:2026-10-14 用户裁决)
- 新增 formula 负天数钳制单测与 quantize 回显/库值一致性集成用例

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 18:58:30 +08:00
liuwenxin和Claude Code 91fc6f6293 fix(product): 删除引用保护/单价小数位校验,IntegrityError 全局兜底转 400
- delete_product 补 count_refs 引用检查(业务明细+发货明细 RESTRICT
  外键)与并发窗口 IntegrityError→400 兜底(customer 先例同构),
  docstring 与两个 api 层过时注释同步更新
- 产品 schema 复用 schemas/business._check_amount:sale_price 走
  (PRICE_MAX, scale=4)、stock_quantity 走 AMOUNT_MAX——此前私写
  _parse_non_negative 不查小数位与容量,5 位小数被 Numeric(14,4)
  静默截断(舍入即算错钱);新增 6 个秒级 schema 单测锁定口径
- 全局注册 IntegrityError handler:漏补引用保护的删除类操作收敛为
  400 通用文案(e2e 撞见一个补一个的模式无法保证全覆盖),完整
  异常记日志;rating/customer 的「非 1062 按 500」注释同步更正

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 18:48:32 +08:00
liuwenxin和Claude Code 9a70264b51 style(backend): ruff format 统一此前批次的排版残留
纯排版无行为变化(ruff format 重排 7 个此前批次的文件),
涉及模块集成回归全绿(67 passed)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 18:46:43 +08:00
liuwenxin和Claude Code 6d612daf30 fix(business): 编辑/流转/归属加锁与终态编辑门槛,has_docs 单点收口
- get_with_names 加 for_update 参数:update/change_status/change_ownership
  以锁定读取行,与发货/回款写路径同一锁口径——「有发货禁换客户」的
  快照读校验不再被并发新增发货旁路(此前 delete 有锁、唯独编辑无锁)
- update_business 补终态门槛(明细/汇总是 M3 核算基线,与发货/回款
  服务禁写终态同口径);前端编辑按钮按终态禁用并加 tooltip
- 新增 business_repository.has_docs(单条 OR exists):替换 update
  换客户与 delete 两处内联双查询,M3 新增挂靠单据类型时只改一处
- 出参组装复用首查 row:change_ownership 的姓名侧按「变更项取校验
  函数返回的新人名、未变项沿用首查名」处理(首查名是变更前快照)
- 新增终态编辑 400 且明细未动的集成用例

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 17:30:21 +08:00
liuwenxin和Claude Code a0086554b1 refactor(core): 1062 唯一键冲突判定收口至 core/db_errors
六个 service 各自复制的 _is_duplicate_entry(口径已漂移:rating/product
用 _ERR_DUP_ENTRY 常量、其余四处写字面量 1062)收敛为共享函数;
is_duplicate_entry 语义与文案注释各处保留,非 1062 完整性错误仍原样
上抛由全局处理器收敛。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 15:17:00 +08:00
liuwenxin和Claude Code 5a3778a424 fix(business): 唯一键冲突重试改为审计上下文内循环,单条审计无递归
三处 create(业务/发货/回款)的 return _retry_create 是吞异常后正常
退出:外层审计把已回滚的首次尝试记成 success(假成功),重入整个
create_* 违反 audit_service「不支持嵌套」约定,且 _NO_RETRY 死常量
导致持续冲突时无界递归至 RecursionError。改为 audit 内 for 循环
(上限 _NO_RETRY),rollback 后重新锁行与取号,成功/最终失败各恰好
一条审计。新增集成用例锁定两条路径的日志增量。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 15:09:05 +08:00
liuwenxin和Claude Code cb977979da fix(param): 预览千分比换算/版本号复用 next_no/维度规范值落库/冗余提交清理
- 企业户与信息员预览分支把 ‰ 存储值当纯比例直接相乘,绩效金额放大
  1000 倍;补两个岗位路径的精确数值用例(既有用例仅覆盖业务员路径)
- 批次版本号改用 no_generator.next_no(MAX FOR UPDATE 锁当日区间),
  移除无锁全拉重造的 _next_version_no(依赖前一笔的长度优先排序)
- 岗位维度值经 ci 校对命中后以库里规范 role_code 落库,防快照字典
  精确匹配静默不命中;变形大小写提交补回读断言
- 删正常路径冗余双 commit;支付权重 R 封底封顶口径注释声明

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 14:20:05 +08:00
liuwenxin和Claude Code 64c57e9141 fix(no_generator): 编号 MAX 检索改长度优先排序,破解位宽进位后字典序失效
跨位宽(-9999→-10000)后纯字典序反转,MAX 恒取旧值导致当日建单
持续撞唯一键;同前缀下长度大即数值大,同级同长时字典序与数值一致。
新增跨位宽/同宽/空表三个集成用例。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 14:09:30 +08:00
liuwenxin和Claude Code d8dba27fb2 docs(requirements): 模块页面功能清单(开发交付视角:14 页面已交付/M3+M4 待开发)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 18:00:23 +08:00
共修改 59 个文件,包含 1542 行新增和 759 行删除

没有与搜索条件相匹配的文件

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