Author SHA1 Message Date
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude 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
liuwenxinandClaude Code d8dba27fb2 docs(requirements): 模块页面功能清单(开发交付视角:14 页面已交付/M3+M4 待开发)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 18:00:23 +08:00
liuwenxinandClaude Code d77728265b docs(business): M2 收尾——e2e 记录、缺陷表、AI_CONTEXT 摘要更新
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:13:21 +08:00
liuwenxinandClaude Code 8c4acb82fe fix(business): e2e 链路验证与两处修复
- wait_toast 超时返回最后见到的非空提示(错误 toast 一闪而过时诊断信息全丢)
- 业务/发货明细行单价类字段放行 4 位小数(与后端 DECIMAL(14,4) 对齐)
- 清理步骤适配删除保护(被终态业务/引用拦截时留档而非失败)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:06:46 +08:00
liuwenxinandClaude Code fba8cfc215 fix(business): 删除保护补齐与单价小数位口径修复(e2e 发现)
- 客户删除保护:被业务引用 → 400(T4 占位注释在业务表就位后漏补,FK 500 暴露)
- 人员删除保护:业务归属(负责/协助)引用纳入检查(同样漏检 500)
- schema 单价类列(DECIMAL 14,4)小数位放行至 4 位:回显 '5000.0000'
  原样提交被两位校验永久拒收(编辑任何带精度单价的历史行都失败)
- 补 2 个集成用例锁定;文案同步「客户归属或业务归属引用」

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:06:46 +08:00
liuwenxinandClaude Code 299b72483c test(business): M2 基础数据与业务数据两个 e2e 链路脚本(13+16 步)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 14:17:28 +08:00
liuwenxinandClaude Code c512cb77fb docs: 后端接口清单补基础/业务数据两节与 M2 关键约定;前端 e2e 清单预登记
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 13:43:17 +08:00
liuwenxinandClaude Code ca4cc72eea test(business): 业务数据管理模块集成用例(70 例)与两处缺陷修复
- 覆盖:编号格式与序号递增/状态机终态拦截/三处汇总冗余一致性/跨业务批次
  400/已核算禁改禁删/毛利润金标准与防除零/asOf 回溯/评级重叠/审计双记录
- 缺陷修复①:change_status 留痕 old_status 取到赋值后属性(时间线失真 3→3)
- 缺陷修复②:freight_amount 漏金额校验(负数/三位小数可落库污染运费合计)
- conftest 扩展业务六表清理(编号前缀 + FK 逆序收口)与评级种子基线恢复
- 全量回归 603 passed(单元 381 / 集成 222)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 13:39:09 +08:00
liuwenxinandClaude Code 77e578085a feat(business): 操作日志模块词表补业务数据四模块(与后端 MODULE_* 同步)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:51:20 +08:00
liuwenxinandClaude Code 06cdf4141f feat(business): 前端业务/发货/回款管理三页
- 业务页:明细行编辑器、详情抽屉五分区(含状态时间线与毛利润卡片)
- 回款页:业务→发货批次级联指定(可清空)、已核算行禁改禁删
- 金额/比率字符串直出;毛利润卡片注明不含运费口径

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:50:10 +08:00
liuwenxinandClaude Code 5207d14daa feat(base): 前端基础数据三页与客户评级计算器(人员/产品/客户/评级)
- 人员页:编辑抽屉内区域/计划子表编辑;产品页:类别阈值提示条;
  客户页:归属变更二次确认含「不回溯历史」+变更记录抽屉
- 评级页:五项评分实时出等级(纯前端判定,不落库 #25)+ 等级映射维护抽屉
- 菜单两组:基础数据(8-10)/业务数据(14);金额字符串直出禁 float

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:25:45 +08:00
liuwenxinandClaude Code 09831dbb7f feat(business): 挂载发货/回款路由 /api/v1/business/*
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:25:34 +08:00
liuwenxinandClaude Code a02b6c3ec8 feat(business): 回款管理接口(批次号/手动指定批次/已核算禁改/合计回写)
- 批次 HK 生成;shipment_id 同业务强校验(统一失败面防探测)
- 已核算回款禁改禁删(M3 防重锚点);payment_days 派生(发货日首日=第 1 天)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:25:34 +08:00
liuwenxinandClaude Code 4aea2be103 feat(business): 发货管理接口(发货单/明细/运费/汇总回写/删除保护)
- FOR UPDATE 锁业务行防发货/回款并发丢失更新;终态业务禁新发货
- 行金额服务层按数量×单价固化;明细行 remark 按设计定稿移除(冒烟修复)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:25:33 +08:00
liuwenxinandClaude Code 0cdc6246c4 feat(business): 业务管理接口(编号生成/明细/状态机/归属留痕/毛利润视图)
- 编号 YW{日期}-{序号}:MAX+FOR UPDATE 并发安全 + 1062 兜底重试(no_generator 三单据共用)
- 明细全量替换重算五项汇总冗余;销售额服务层按数量×单价固化不信任前端
- 状态机终态拦截、终止必填原因、创建写首条留痕;归属变更同事务留痕防旁路
- 毛利润视图按行实时计算(不含运费 #2),阈值按 asOf 取 M1 快照,率输出
  Fraction 分数字符串同 M1 预览口径;成本缺失行标注无法计算
- 入参契约统一为蛇形直出(撤回 customer/business 的驼峰 alias,与前端
  businessRoles.ts 模式对齐)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:01:48 +08:00
liuwenxinandClaude Code 1625907380 feat(business): 客户评级等级映射配置接口(区间重叠判定/名称唯一)
- 判定=按 sort_order 升序首个 min_score≤总分,边界相邻不算重叠
- 新增同样执行重叠判定(判定表语义下插出重叠即脏配置)
- 等级配置无下游引用(评级为纯计算器,对照表 #25),允许清空

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:01:47 +08:00
liuwenxinandClaude Code 899bf3afa5 docs(test): 基础数据模块测试记录(48 例/审计盲区缺陷/待补用例更新)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 11:24:29 +08:00
liuwenxinandClaude Code 20fcf06f51 feat(business): 新增业务数据七表及 V5 迁移(业务/明细/状态留痕/发货/回款/评级配置)
- businesses 五项汇总冗余列服务层事务内维护;毛利润不落库(派生值,M3 台账固化)
- 行级 product_category 折算锚点(#14);回款 settlement_status 预置为 M3 防重锚点
- 评级等级种子四条(迁移+init-dev 双写);编号前缀常量 YW/SH/HK
- 双轨 SQL 同步(V4/V5 重导,schema_sync+test_models 164 例全绿)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 11:22:34 +08:00
liuwenxinandClaude Code ca88ee7a0b test(base): 基础数据管理模块集成用例(48 例)与审计盲区修复
- 覆盖:401/引用校验/删除保护/子表全量替换/类别与组合唯一/归属变更留痕/审计双记录
- 缺陷修复:personnel/product/customer 三 service 的前置校验移入审计上下文,
  校验失败(400)同样留痕(成功与失败都记的既定原则)
- conftest 兜底清理扩展到 M2 五表(留痕无外键,按 operator_name + 前缀双轨清理)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 11:22:34 +08:00
liuwenxinandClaude Code 15de81dd2c feat(base): 挂载基础数据路由 /api/v1/base/*
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 10:35:04 +08:00
liuwenxinandClaude Code b8ba9e4626 feat(base): 客户管理接口与归属变更留痕(不回溯历史)
- 归属变更同事务更新主表+插留痕;归属调整唯一入口防旁路

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 10:35:04 +08:00
liuwenxinandClaude Code 7eeed85c96 feat(base): 产品管理接口(类别校验/组合唯一/启停)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 10:35:03 +08:00
liuwenxinandClaude Code a694570dd1 feat(base): 人员管理接口(岗位/工资标准/团队/辐射区域/拓展计划)
- 删除保护:被团队成员/客户归属引用禁删,引导改离职
- 子表全量替换采用 clear 后立即 flush 防御写法
- 工资标准字符串进出,超两位小数拒绝而非静默舍入

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 10:35:02 +08:00
liuwenxinandClaude Code f22d14e439 feat(base): 新增基础数据六表及 V4 迁移(人员/辐射区域/拓展计划/产品/客户/归属留痕)
- TPlusSyncMixin 统一 T+ 同步预留四列(可空+普通索引,唯一键待 IT 定键后升级)
- 留痕表实体 ID 无外键冗余固化,删除保护由服务层承担
- init-dev.sql 同步六表 DDL,alembic_version 标记更新

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 09:49:05 +08:00
liuwenxinandClaude Code 4552f2e703 docs(plan): M2 基础数据与业务数据实施计划(表结构评审定稿)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 09:26:39 +08:00
106 changed files with 19906 additions and 206 deletions

No files matched your search

+42
View File
@@ -71,6 +71,41 @@ app/
| GET | `/api/v1/system/operation-logs/{id}` | 操作日志详情(含已脱敏的请求参数,展开行懒加载) |
| GET | `/api/v1/system/login-logs` | 登录日志查询(用户名/IP/结果/时间范围) |
### 基础数据接口(需求 2.1,登录即可 —— 业务操作不要求管理员)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET / POST | `/api/v1/base/personnel` | 人员列表(姓名模糊/状态/岗位筛选,附岗位与经理名)/ 新增(岗位须启用;经理非自己且在职) |
| GET / PUT / DELETE | `/api/v1/base/personnel/{id}` | 详情(附区域与计划)/ 编辑 / 删除(被团队成员或客户归属引用 → 400 引导改离职) |
| PUT | `/api/v1/base/personnel/{id}/status` | 在职 / 离职切换 |
| PUT | `/api/v1/base/personnel/{id}/regions` | 辐射区域全量替换(空列表即清空) |
| PUT | `/api/v1/base/personnel/{id}/plans` | 市场拓展计划全量替换 |
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
### 业务数据接口(需求 2.2,登录即可)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET / POST | `/api/v1/business/businesses` | 业务列表(编号/公司名模糊、状态/客户/归属/日期筛选)/ 创建(编号 `YW{日期}-{序号}` 自动生成;明细 ≥1 行;销售额服务层固化) |
| GET / PUT / DELETE | `/api/v1/business/businesses/{id}` | 详情(附明细)/ 编辑(明细全量替换 + 五项汇总重算;**归属不在此端点**)/ 删除(有发货或回款 → 400 引导改已终止) |
| PUT | `/api/v1/business/businesses/{id}/status` | 状态流转(1→2/3;终态不可再变更;终止必填原因;写状态留痕) |
| PUT | `/api/v1/business/businesses/{id}/ownership` | 负责/协助归属变更(同事务写留痕,owner_type=2) |
| GET | `/api/v1/business/businesses/{id}/ownership-logs` | 业务归属变更留痕分页 |
| GET | `/api/v1/business/businesses/{id}/status-logs` | 状态流转时间线(创建写首条 old=new=1) |
| GET | `/api/v1/business/businesses/{id}/gross-profit` | **毛利润数据视图**(只读实时计算,不落库:行级率/折算额 + 类别汇总;不含运费 #2;阈值按 `as_of` 取参数快照;成本缺失行标注无法计算) |
| GET / POST | `/api/v1/business/shipments` | 发货单列表 / 创建(编号 `SH…`;业务须执行中;行金额服务层固化;业务级发货/运费汇总回写) |
| GET / PUT / DELETE | `/api/v1/business/shipments/{id}` | 详情 / 编辑(终态业务禁改)/ 删除(被回款批次指定 → 400) |
| GET / POST | `/api/v1/business/receipts` | 回款列表(含 **payment_days 派生**:发货日首日=第 1 天 #1)/ 新增(编号 `HK…`;**shipment_id 同业务强校验**;业务须执行中) |
| GET / PUT / DELETE | `/api/v1/business/receipts/{id}` | 详情 / 编辑 / 删除(**已核算回款禁改禁删**——M3 防重锚点) |
| GET / POST | `/api/v1/business/rating-grades` | 评级等级全量列表(sort_order 升序)/ 新增(区间值域 + **与既有等级重叠 → 400**) |
| PUT / DELETE | `/api/v1/business/rating-grades/{id}` | 编辑(重叠判定)/ 删除(无下游引用,允许清空) |
全部写操作自动写入操作日志(成功与失败都记,机制见下)。
统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
@@ -154,6 +189,13 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
**角色管理保护规则**(后端是唯一防线,前端按钮禁用只是体验):admin 角色禁删禁停;有用户绑定的角色禁删(`user_roles` 外键是 CASCADE;删除前走 `SELECT ... FOR UPDATE` 锁行,堵住并发绑定窗口);`role_code` 创建后不可改;**停用角色不可再分配**(`_resolve_roles` 校验 status,停用只影响新分配、不影响既有授权)。
### M2 业务数据的四个关键约定(2026-10-13 定稿,改动前先读)
- **单据编号**(`services/no_generator.py`):`{前缀}{YYYYMMDD}-{当日4位序号}`,业务 YW / 发货 SH / 回款 HK(`core/constants.py` 的 `NO_PREFIX_*`)。`SELECT MAX ... FOR UPDATE` 锁当日区间防并发重号;残余竞态以唯一键 1062 冲突**重试一次**兜底(无限重试会掩盖死锁)。序号超 4 位按实际位数输出(VARCHAR(32) 余量足够)。
- **五项汇总冗余**(`businesses.total_*`):明细/发货/回款的任何写路径统一收口到 `business_service._recalc_totals` 单点重算,禁止各处手写 SUM 回写;发货与回款服务写操作前**必须 `get_by_id(for_update=True)` 锁业务行**(防两模块并发写同一业务的丢失更新)。毛利润率/折算额**不落库**——它们是明细金额 + 时点化阈值的派生值,落库会在成本调整或参数改版后漂移;视图接口(`/gross-profit`)实时算,M3 核算台账才是法定固化点。
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
@@ -0,0 +1,696 @@
"""业务数据管理七表:业务/业务明细/状态留痕/发货单/发货明细/回款/评级等级配置(M2)
- businesses:业务编号自动生成(报表 2.4.1「合同编号」),五项汇总冗余列
由服务层事务内维护;毛利润相关值不落库(派生值,M3 台账才是固化点)
- business_items:行级 product_category 为折算锚点(对照表 #14),指导价
人工录入、T+ 同步不覆盖
- business_status_logs:append-only,创建时写首条(old=new=1)
- shipments/shipment_items:发货日为回款天数基准日(对照表 #1)
- receipts:手动指定发货批次(#18);settlement_status 为 M3 核算防重锚点
- customer_rating_grades:等级映射可配置,种子四条随迁移初始化
Revision ID: 8efc189b131f
Revises: e1133a7c670a
Create Date: 2026-10-13 11:14:17.545320
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "8efc189b131f"
down_revision: str | Sequence[str] | None = "e1133a7c670a"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# 评级等级种子(与 init-dev.sql 双写一致;口径见模型 docstring)
_GRADE_SEED = [
{"grade_name": "A", "min_score": 90, "max_score": 100, "sort_order": 1, "remark": "优秀"},
{"grade_name": "B", "min_score": 75, "max_score": 89, "sort_order": 2, "remark": "良好"},
{"grade_name": "C", "min_score": 60, "max_score": 74, "sort_order": 3, "remark": "合格"},
{"grade_name": "D", "min_score": 0, "max_score": 59, "sort_order": 4, "remark": "待改进"},
]
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"customer_rating_grades",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"grade_name", sa.String(length=32), nullable=False, comment="等级名称(如 A/优秀)"
),
sa.Column(
"min_score",
sa.Numeric(precision=5, scale=1),
nullable=False,
comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分",
),
sa.Column(
"max_score",
sa.Numeric(precision=5, scale=1),
nullable=False,
comment="总分上限(展示用)",
),
sa.Column(
"sort_order",
sa.Integer(),
server_default="0",
nullable=False,
comment="排序(判定顺序,升序)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="说明"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_rating_grades")),
sa.UniqueConstraint("grade_name", name=op.f("uk_customer_rating_grades_grade_name")),
comment="客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"business_status_logs",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(固化,无外键;删除保护由服务层承担)",
),
sa.Column(
"old_status", mysql.TINYINT(), nullable=False, comment="变更前状态(创建首条为 1)"
),
sa.Column("new_status", mysql.TINYINT(), nullable=False, comment="变更后状态"),
sa.Column(
"change_reason", sa.String(length=255), nullable=True, comment="变更原因(终止时必填)"
),
sa.Column(
"operator_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="操作人ID(账号删除后置空)",
),
sa.Column(
"operator_name",
sa.String(length=64),
nullable=False,
comment="操作人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["operator_id"],
["users.id"],
name=op.f("fk_business_status_logs_operator_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_status_logs")),
comment="业务状态变更记录表(append-only)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_business_status_logs_business_id",
"business_status_logs",
["business_id"],
unique=False,
)
op.create_index(
"idx_business_status_logs_create_time",
"business_status_logs",
["create_time"],
unique=False,
)
op.create_table(
"businesses",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"business_no",
sa.String(length=32),
nullable=False,
comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)",
),
sa.Column(
"customer_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="客户ID(customers.id)",
),
sa.Column("business_date", sa.Date(), nullable=True, comment="业务日期(T+ 同步口径)"),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)",
),
sa.Column(
"owner_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)",
),
sa.Column(
"assistant_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)",
),
sa.Column(
"is_special",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="特殊业务标记:1是 0否(公司参与等)",
),
sa.Column("special_note", sa.String(length=255), nullable=True, comment="特殊业务说明"),
sa.Column(
"total_sale_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)",
),
sa.Column(
"total_cost_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)",
),
sa.Column(
"total_ship_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)",
),
sa.Column(
"total_received_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)",
),
sa.Column(
"total_freight_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["assistant_personnel_id"],
["personnel.id"],
name=op.f("fk_businesses_assistant_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["customer_id"],
["customers.id"],
name=op.f("fk_businesses_customer_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["owner_personnel_id"],
["personnel.id"],
name=op.f("fk_businesses_owner_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_businesses")),
sa.UniqueConstraint("business_no", name=op.f("uk_businesses_business_no")),
comment="业务表(业务编号自动生成;T+同步不覆盖业务状态)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_businesses_business_date", "businesses", ["business_date"], unique=False)
op.create_index("idx_businesses_customer_id", "businesses", ["customer_id"], unique=False)
op.create_index(
"idx_businesses_owner_personnel_id", "businesses", ["owner_personnel_id"], unique=False
)
op.create_index(op.f("idx_businesses_source_no"), "businesses", ["source_no"], unique=False)
op.create_index("idx_businesses_status", "businesses", ["status"], unique=False)
op.create_table(
"business_items",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(businesses.id)",
),
sa.Column(
"product_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="产品ID(products.id;NULL=自由录入,未入产品库)",
),
sa.Column(
"product_name",
sa.String(length=128),
nullable=False,
comment="产品名称(引用产品时冗余固化)",
),
sa.Column(
"spec_model",
sa.String(length=128),
server_default="",
nullable=False,
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
),
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
sa.Column(
"product_category",
sa.String(length=32),
nullable=False,
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
),
sa.Column(
"quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="数量(>0)"
),
sa.Column(
"sale_price",
sa.Numeric(precision=14, scale=4),
nullable=True,
comment="销售单价(元;T+ 同步或人工)",
),
sa.Column(
"sale_amount",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)",
),
sa.Column(
"cost_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)",
),
sa.Column(
"guided_price",
sa.Numeric(precision=14, scale=4),
nullable=True,
comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["business_id"],
["businesses.id"],
name=op.f("fk_business_items_business_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["product_id"],
["products.id"],
name=op.f("fk_business_items_product_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_items")),
comment="业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_business_items_business_id", "business_items", ["business_id"], unique=False
)
op.create_index(
"idx_business_items_product_category", "business_items", ["product_category"], unique=False
)
op.create_index("idx_business_items_product_id", "business_items", ["product_id"], unique=False)
op.create_table(
"shipments",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"shipment_no",
sa.String(length=32),
nullable=False,
comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)",
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(businesses.id;终态业务禁止新发货)",
),
sa.Column(
"ship_date",
sa.Date(),
nullable=False,
comment="发货日期(回款天数基准日,发货日首日=第 1 天)",
),
sa.Column(
"freight_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=False,
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
),
sa.Column(
"total_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=False,
comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["business_id"],
["businesses.id"],
name=op.f("fk_shipments_business_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_shipments")),
sa.UniqueConstraint("shipment_no", name=op.f("uk_shipments_shipment_no")),
comment="发货单表(发货日为回款天数基准日,发货日首日计第 1 天)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_shipments_business_id", "shipments", ["business_id"], unique=False)
op.create_index("idx_shipments_ship_date", "shipments", ["ship_date"], unique=False)
op.create_index(op.f("idx_shipments_source_no"), "shipments", ["source_no"], unique=False)
op.create_table(
"receipts",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"receipt_batch_no",
sa.String(length=32),
nullable=False,
comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)",
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(businesses.id)",
),
sa.Column(
"shipment_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)",
),
sa.Column(
"receipt_date",
sa.Date(),
nullable=False,
comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)",
),
sa.Column(
"amount",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="回款金额(≥0;一期禁负数/红字)",
),
sa.Column(
"is_barter",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)",
),
sa.Column(
"settlement_status",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["business_id"],
["businesses.id"],
name=op.f("fk_receipts_business_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["shipment_id"],
["shipments.id"],
name=op.f("fk_receipts_shipment_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_receipts")),
sa.UniqueConstraint("receipt_batch_no", name=op.f("uk_receipts_receipt_batch_no")),
comment="回款表(手动指定发货批次;已核算回款禁改禁删)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_receipts_business_id", "receipts", ["business_id"], unique=False)
op.create_index("idx_receipts_receipt_date", "receipts", ["receipt_date"], unique=False)
op.create_index(
"idx_receipts_settlement_status", "receipts", ["settlement_status"], unique=False
)
op.create_index("idx_receipts_shipment_id", "receipts", ["shipment_id"], unique=False)
op.create_index(op.f("idx_receipts_source_no"), "receipts", ["source_no"], unique=False)
op.create_table(
"shipment_items",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"shipment_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="发货单ID(shipments.id)",
),
sa.Column(
"product_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="产品ID(products.id;NULL=自由录入)",
),
sa.Column(
"product_name",
sa.String(length=128),
nullable=False,
comment="产品名称(引用产品时冗余固化)",
),
sa.Column(
"spec_model",
sa.String(length=128),
server_default="",
nullable=False,
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
),
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
sa.Column(
"quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="发货数量(>0)"
),
sa.Column(
"sale_price",
sa.Numeric(precision=14, scale=4),
nullable=True,
comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)",
),
sa.Column(
"amount",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["product_id"],
["products.id"],
name=op.f("fk_shipment_items_product_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["shipment_id"],
["shipments.id"],
name=op.f("fk_shipment_items_shipment_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_shipment_items")),
comment="发货产品明细表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_shipment_items_product_id", "shipment_items", ["product_id"], unique=False)
op.create_index(
"idx_shipment_items_shipment_id", "shipment_items", ["shipment_id"], unique=False
)
# ### end Alembic commands ###
# 评级等级种子(等级映射可配置;双轨:init-dev.sql 同步写入)
op.bulk_insert(
sa.table(
"customer_rating_grades",
sa.column("grade_name", sa.String),
sa.column("min_score", sa.Numeric),
sa.column("max_score", sa.Numeric),
sa.column("sort_order", sa.Integer),
sa.column("remark", sa.String),
),
_GRADE_SEED,
)
def downgrade() -> None:
"""回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
# 整表回滚只保留 drop_table(DROP TABLE 连带删除索引与外键);
# 逐条 drop_index 会先于 drop_table 摘掉外键支撑索引而报 MySQL 1553
op.drop_table("shipment_items")
op.drop_table("receipts")
op.drop_table("shipments")
op.drop_table("business_items")
op.drop_table("businesses")
op.drop_table("business_status_logs")
op.drop_table("customer_rating_grades")
@@ -0,0 +1,490 @@
"""基础数据管理六表:人员/辐射区域/拓展计划/产品/客户/归属变更留痕(M2)
- personnel / personnel_regions / personnel_plans:人员档案及其 1:N 子表
- products:产品主数据(类别锚定折算阈值参数)
- customers:客户档案(公司名称+负责人为业务唯一标识)
- ownership_change_logs:归属变更留痕(append-only,不回溯历史)
- 六张表的 T+ 同步预留列(source_no 等)全部可空、不设唯一键(对照表 #26)
Revision ID: e1133a7c670a
Revises: bfbda9c3191d
Create Date: 2026-10-13 09:36:02.219248
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "e1133a7c670a"
down_revision: str | Sequence[str] | None = "bfbda9c3191d"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"products",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column("product_name", sa.String(length=128), nullable=False, comment="产品名称"),
sa.Column(
"spec_model",
sa.String(length=128),
server_default="",
nullable=False,
comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)",
),
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
sa.Column(
"stock_quantity",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="数量(T+ 同步原样存储,语义待 IT 确认)",
),
sa.Column(
"sale_price",
sa.Numeric(precision=14, scale=4),
nullable=True,
comment="销售单价(元,T+ 同步)",
),
sa.Column(
"product_category",
sa.String(length=32),
nullable=False,
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1启用 0停用",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_products")),
sa.UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"),
comment="产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(op.f("idx_products_source_no"), "products", ["source_no"], unique=False)
op.create_table(
"ownership_change_logs",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"owner_type", mysql.TINYINT(), nullable=False, comment="归属类型:1客户归属 2业务归属"
),
sa.Column(
"customer_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="客户ID(固化,owner_type=1 时非空)",
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="业务ID(固化,owner_type=2 时非空)",
),
sa.Column(
"old_owner_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="变更前负责营销人员ID(固化)",
),
sa.Column(
"new_owner_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="变更后负责营销人员ID(固化)",
),
sa.Column(
"old_assistant_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="变更前协助营销人员ID(固化,仅业务归属使用)",
),
sa.Column(
"new_assistant_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="变更后协助营销人员ID(固化,仅业务归属使用)",
),
sa.Column("change_reason", sa.String(length=255), nullable=True, comment="变更原因"),
sa.Column(
"operator_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="操作人ID(账号删除后置空)",
),
sa.Column(
"operator_name",
sa.String(length=64),
nullable=False,
comment="操作人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["operator_id"],
["users.id"],
name=op.f("fk_ownership_change_logs_operator_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_ownership_change_logs")),
comment="归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_ownership_change_logs_customer_id"),
"ownership_change_logs",
["customer_id"],
unique=False,
)
op.create_index(
op.f("idx_ownership_change_logs_business_id"),
"ownership_change_logs",
["business_id"],
unique=False,
)
op.create_index(
op.f("idx_ownership_change_logs_create_time"),
"ownership_change_logs",
["create_time"],
unique=False,
)
op.create_table(
"personnel",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column("personnel_name", sa.String(length=64), nullable=False, comment="姓名"),
sa.Column(
"business_role_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)",
),
sa.Column(
"manager_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)",
),
sa.Column("phone", sa.String(length=20), nullable=True, comment="联系电话"),
sa.Column("hire_date", sa.Date(), nullable=True, comment="入职日期"),
sa.Column(
"is_probation",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="是否试用期:1是 0否(试用期绩效照算不发放)",
),
sa.Column(
"salary_standard",
sa.Numeric(precision=12, scale=2),
nullable=True,
comment="月工资标准(元;T+同步不覆盖)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["business_role_id"],
["business_roles.id"],
name=op.f("fk_personnel_business_role_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["manager_id"],
["personnel.id"],
name=op.f("fk_personnel_manager_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel")),
comment="营销人员表(T+同步不覆盖岗位与工资标准)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_personnel_business_role_id"), "personnel", ["business_role_id"], unique=False
)
op.create_index(op.f("idx_personnel_manager_id"), "personnel", ["manager_id"], unique=False)
op.create_index(op.f("idx_personnel_source_no"), "personnel", ["source_no"], unique=False)
op.create_table(
"customers",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column("company_name", sa.String(length=128), nullable=False, comment="公司名称"),
sa.Column(
"owner_name",
sa.String(length=64),
nullable=False,
comment="客户负责人姓名(客户公司负责人的人名,非系统人员)",
),
sa.Column("contact_person", sa.String(length=64), nullable=True, comment="联系人"),
sa.Column("contact_phone", sa.String(length=20), nullable=True, comment="联系电话"),
sa.Column("address", sa.String(length=255), nullable=True, comment="地址"),
sa.Column("industry", sa.String(length=64), nullable=True, comment="所属行业(自由文本)"),
sa.Column(
"customer_type",
sa.String(length=32),
server_default="personal",
nullable=False,
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
),
sa.Column(
"owner_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["owner_personnel_id"],
["personnel.id"],
name=op.f("fk_customers_owner_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_customers")),
sa.UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"),
comment="客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_customers_customer_type"), "customers", ["customer_type"], unique=False
)
op.create_index(
op.f("idx_customers_owner_personnel_id"), "customers", ["owner_personnel_id"], unique=False
)
op.create_index(op.f("idx_customers_source_no"), "customers", ["source_no"], unique=False)
op.create_table(
"personnel_plans",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="人员ID(personnel.id)",
),
sa.Column(
"plan_period",
sa.String(length=32),
nullable=False,
comment="计划期间(如 2026Q1、2026-05)",
),
sa.Column("plan_content", sa.String(length=500), nullable=False, comment="计划内容"),
sa.Column(
"target_desc", sa.String(length=255), nullable=True, comment="指标说明(文字描述)"
),
sa.Column(
"source_no", sa.String(length=64), nullable=True, comment="T+ 原始条目号(预留)"
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_personnel_plans_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_plans")),
comment="市场拓展计划表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_personnel_plans_personnel_id"), "personnel_plans", ["personnel_id"], unique=False
)
op.create_table(
"personnel_regions",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="人员ID(personnel.id)",
),
sa.Column(
"region_name",
sa.String(length=128),
nullable=False,
comment="区域名称(产品辐射地理范围)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_personnel_regions_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_regions")),
comment="产品辐射区域表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_personnel_regions_personnel_id"),
"personnel_regions",
["personnel_id"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
"""回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
op.drop_table("personnel_regions")
op.drop_table("personnel_plans")
op.drop_table("customers")
op.drop_table("personnel")
op.drop_table("ownership_change_logs")
op.drop_table("products")
+22
View File
@@ -8,6 +8,13 @@ from fastapi import APIRouter
from app.api import health
from app.api.v1 import auth
from app.api.v1.base import customers as base_customers
from app.api.v1.base import personnel as base_personnel
from app.api.v1.base import products as base_products
from app.api.v1.business import businesses as business_businesses
from app.api.v1.business import ratings as business_ratings
from app.api.v1.business import receipts as business_receipts
from app.api.v1.business import shipments as business_shipments
from app.api.v1.system import business_roles as system_business_roles
from app.api.v1.system import logs as system_logs
from app.api.v1.system import params as system_params
@@ -33,4 +40,19 @@ system_router.include_router(system_logs.operation_router)
system_router.include_router(system_logs.login_router)
v1_router.include_router(system_router)
# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问
base_router = APIRouter(prefix="/base")
base_router.include_router(base_personnel.router)
base_router.include_router(base_products.router)
base_router.include_router(base_customers.router)
v1_router.include_router(base_router)
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
business_router = APIRouter(prefix="/business")
business_router.include_router(business_businesses.router)
business_router.include_router(business_shipments.router)
business_router.include_router(business_receipts.router)
business_router.include_router(business_ratings.router)
v1_router.include_router(business_router)
api_router.include_router(v1_router)
+8
View File
@@ -0,0 +1,8 @@
"""基础数据管理接口(需求 2.1)。
挂载于 `/api/v1/base/*`,含人员管理、辐射区域、拓展计划等基础数据的维护入口。
与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
由 service 层写入操作日志。
"""
+142
View File
@@ -0,0 +1,142 @@
"""客户管理接口(需求 2.1.3)。
归属调整有专属端点 ``PUT /{id}/ownership``:它是唯一写归属留痕的入口,
编辑端点的 schema 不含归属字段 —— 防止出现「改了归属却无留痕」的旁路
(原因详见 `schemas.customer.CustomerUpdateIn`)。
与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
由 service 层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import User
from app.schemas.customer import (
CustomerCreateIn,
CustomerOut,
CustomerOwnershipIn,
CustomerQuery,
CustomerUpdateIn,
OwnershipChangeLogOut,
)
from app.services import customer_service
router = APIRouter(prefix="/customers", tags=["客户管理"])
# 基础数据不要求管理员(见模块 docstring),登录用户即可操作
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get("", response_model=ApiResponse[PageResult[CustomerOut]], summary="客户列表")
def list_customers(
session: DbSession,
_: CurrentUser,
query: Annotated[CustomerQuery, Query()],
) -> ApiResponse[PageResult[CustomerOut]]:
"""分页查询客户(keyword 对公司名称/客户负责人模糊匹配,附归属人员姓名)。"""
return ok(customer_service.list_customers(session, query))
@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户")
def create_customer(
session: DbSession,
current_user: CurrentUser,
payload: CustomerCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""新增客户。客户分类须在注册表内;「公司名称+负责人」组合唯一。"""
return ok(
customer_service.create_customer(session, payload, operator=current_user, meta=meta),
message="客户已创建",
)
@router.get("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="客户详情")
def get_customer(
session: DbSession,
_: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[CustomerOut]:
"""按主键查询客户详情。"""
return ok(customer_service.get_customer(session, customer_id))
@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户")
def update_customer(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""编辑客户档案。
**不含归属** —— 归属调整统一走 ``PUT /{id}/ownership``;编辑表单即使
提交 ownerPersonnelId 也会被忽略,归属以归属端点为准。
"""
return ok(
customer_service.update_customer(
session, customer_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户")
def delete_customer(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除客户(物理删除)。
已存在关联业务时返回 400(先处理相关业务单据)。
"""
customer_service.delete_customer(session, customer_id, operator=current_user, meta=meta)
return ok(message="客户已删除")
@router.put(
"/{customer_id}/ownership",
response_model=ApiResponse[CustomerOut],
summary="归属变更",
)
def change_ownership(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerOwnershipIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""调整客户归属(唯一写归属留痕的入口)。
只影响变更后核算,不回溯历史(对照表 #19);新旧归属人员ID任一为 null
表示清空归属。新旧归属相同报「归属未变化」。
"""
return ok(
customer_service.change_ownership(
session, customer_id, payload, operator=current_user, meta=meta
),
message="归属已调整",
)
@router.get(
"/{customer_id}/ownership-logs",
response_model=ApiResponse[PageResult[OwnershipChangeLogOut]],
summary="归属变更留痕",
)
def list_ownership_logs(
session: DbSession,
_: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
query: Annotated[PageQuery, Query()],
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
"""查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。"""
return ok(customer_service.list_ownership_logs(session, customer_id, query))
+143
View File
@@ -0,0 +1,143 @@
"""人员管理接口(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。
基础数据属业务操作,**登录即可访问**(不要求管理员,对照系统管理模块)。
校验规则(岗位/经理合法性、删除保护、子表全量替换的防御写法)见
`personnel_service` 模块 docstring —— 前端提示之外,后端校验是唯一防线。
写操作经 `RequestMetaDep` 带入请求元信息由 service 层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.personnel import (
PersonnelCreateIn,
PersonnelDetailOut,
PersonnelOut,
PersonnelQuery,
PersonnelStatusIn,
PlanReplaceIn,
RegionReplaceIn,
)
from app.services import personnel_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/personnel", tags=["人员管理"])
@router.get("", response_model=ApiResponse[PageResult[PersonnelOut]], summary="人员列表")
def list_personnel(
session: DbSession,
_: CurrentUser,
query: Annotated[PersonnelQuery, Query()],
) -> ApiResponse[PageResult[PersonnelOut]]:
"""分页查询人员(含离职)。姓名模糊匹配,可按状态与岗位筛选。"""
return ok(personnel_service.list_personnel(session, query))
@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员")
def create_personnel(
session: DbSession,
current_user: CurrentUser,
payload: PersonnelCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]:
"""新增人员,默认在职。岗位须存在且启用;直属经理不能是自己且须在职。"""
return ok(
personnel_service.create_personnel(session, payload, operator=current_user, meta=meta),
message="人员已创建",
)
@router.get("/{personnel_id}", response_model=ApiResponse[PersonnelDetailOut], summary="人员详情")
def get_personnel(
session: DbSession,
_: CurrentUser,
personnel_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[PersonnelDetailOut]:
"""人员详情,附辐射区域与拓展计划,供编辑抽屉一次回显。"""
return ok(personnel_service.get_detail(session, personnel_id))
@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员")
def update_personnel(
session: DbSession,
current_user: CurrentUser,
personnel_id: Annotated[int, Path(ge=1)],
payload: PersonnelCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]:
"""编辑人员(全量提交,不含状态)。校验规则同新增。"""
return ok(
personnel_service.update_personnel(
session, personnel_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员")
def delete_personnel(
session: DbSession,
current_user: CurrentUser,
personnel_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除人员。被团队成员或客户归属引用时禁删,应改为离职。"""
personnel_service.delete_personnel(session, personnel_id, operator=current_user, meta=meta)
return ok(message="人员已删除")
@router.put(
"/{personnel_id}/status",
response_model=ApiResponse[PersonnelOut],
summary="设为在职/离职",
)
def set_status(
session: DbSession,
current_user: CurrentUser,
personnel_id: Annotated[int, Path(ge=1)],
payload: PersonnelStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]:
"""在职/离职切换。离职人员不可再被业务与客户归属引用。"""
result = personnel_service.set_status(
session, personnel_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已设为在职" if payload.status == 1 else "已设为离职")
@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域")
def replace_regions(
session: DbSession,
current_user: CurrentUser,
personnel_id: Annotated[int, Path(ge=1)],
payload: RegionReplaceIn,
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""全量替换辐射区域,空列表即清空。"""
personnel_service.replace_regions(
session, personnel_id, payload, operator=current_user, meta=meta
)
return ok(message="已保存")
@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划")
def replace_plans(
session: DbSession,
current_user: CurrentUser,
personnel_id: Annotated[int, Path(ge=1)],
payload: PlanReplaceIn,
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""全量替换拓展计划,空列表即清空。"""
personnel_service.replace_plans(
session, personnel_id, payload, operator=current_user, meta=meta
)
return ok(message="已保存")
+112
View File
@@ -0,0 +1,112 @@
"""产品管理接口(需求 2.1.2:产品主数据维护)。
产品是折算阈值的行级锚点(product_category 决定 30%/20% 折算阈值),
类别值域由后端校验 —— 前端下拉只是展示约束,后端校验是唯一防线。
**数据提取(POST /products/extract)暂不提供**:它依赖业务明细表
(V5 迁移),待业务管理任务完成后补充。
基础数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.product import (
ProductCreateIn,
ProductOut,
ProductQuery,
ProductStatusIn,
ProductUpdateIn,
)
from app.services import product_service
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/products", tags=["产品管理"])
@router.get("", response_model=ApiResponse[PageResult[ProductOut]], summary="产品列表")
def list_products(
session: DbSession,
_: CurrentUser,
query: Annotated[ProductQuery, Query()],
) -> ApiResponse[PageResult[ProductOut]]:
"""分页查询产品(含停用)。keyword 对名称或规格模糊匹配。"""
return ok(product_service.list_products(session, query))
@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品")
def create_product(
session: DbSession,
current_user: CurrentUser,
payload: ProductCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ProductOut]:
"""新增产品。类别须在合法值域内;新产品默认启用。"""
return ok(
product_service.create_product(session, payload, operator=current_user, meta=meta),
message="产品已创建",
)
@router.get("/{product_id}", response_model=ApiResponse[ProductOut], summary="产品详情")
def get_product(
session: DbSession,
_: CurrentUser,
product_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ProductOut]:
"""按主键查询产品详情。"""
return ok(product_service.get_product(session, product_id))
@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品")
def update_product(
session: DbSession,
current_user: CurrentUser,
product_id: Annotated[int, Path(ge=1)],
payload: ProductUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[ProductOut]:
"""编辑产品基本信息(全量替换,不含状态;启用/停用走独立端点)。"""
return ok(
product_service.update_product(
session, product_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品")
def delete_product(
session: DbSession,
current_user: CurrentUser,
product_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""物理删除产品(仅限误录数据,常规下架用停用)。
被业务明细/发货明细引用时返回 400(引导改走停用)。
"""
product_service.delete_product(session, product_id, operator=current_user, meta=meta)
return ok(message="产品已删除")
@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品")
def set_status(
session: DbSession,
current_user: CurrentUser,
product_id: Annotated[int, Path(ge=1)],
payload: ProductStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[ProductOut]:
"""启用或停用产品(1启用 0停用)。"""
result = product_service.set_status(
session, product_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已启用" if payload.status == 1 else "已停用")
+8
View File
@@ -0,0 +1,8 @@
"""业务数据管理接口(需求 2.2)。
挂载于 `/api/v1/business/*`,含业务单据维护、客户评级等级配置等业务数据入口。
与 `api/v1/system` 的权限分工:业务数据是**业务操作**,登录即可访问
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
由 service 层写入操作日志。
"""
+177
View File
@@ -0,0 +1,177 @@
"""业务管理接口(需求 2.2.1/2.2.4)。
校验规则(编号生成、状态机、归属留痕、汇总重算)见 `business_service`
模块 docstring;基础数据属业务操作,登录即可访问。
"""
from datetime import date
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import User
from app.schemas.business import (
BusinessCreateIn,
BusinessDetailOut,
BusinessOut,
BusinessOwnershipIn,
BusinessQuery,
BusinessStatusIn,
BusinessStatusLogOut,
BusinessUpdateIn,
GrossProfitOut,
OwnershipChangeLogOut,
)
from app.services import business_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/businesses", tags=["业务管理"])
@router.get("", response_model=ApiResponse[PageResult[BusinessOut]], summary="业务列表")
def list_businesses(
session: DbSession,
_: CurrentUser,
query: Annotated[BusinessQuery, Query()],
) -> ApiResponse[PageResult[BusinessOut]]:
"""分页查询业务。编号/公司名称模糊,可按状态/客户/归属人员/日期范围筛选。"""
return ok(business_service.list_businesses(session, query))
@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务")
def create_business(
session: DbSession,
current_user: CurrentUser,
payload: BusinessCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]:
"""创建业务 + 明细行(≥1)。业务编号自动生成;创建写首条状态留痕。"""
return ok(
business_service.create_business(session, payload, operator=current_user, meta=meta),
message="业务已创建",
)
@router.get("/{business_id}", response_model=ApiResponse[BusinessDetailOut], summary="业务详情")
def get_business(
session: DbSession,
_: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[BusinessDetailOut]:
"""业务详情,附产品明细行,供编辑抽屉一次回显。"""
return ok(business_service.get_detail(session, business_id))
@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务")
def update_business(
session: DbSession,
current_user: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]:
"""编辑业务(明细全量替换并重算汇总)。归属不在此端点调整,走 /ownership。"""
return ok(
business_service.update_business(
session, business_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务")
def delete_business(
session: DbSession,
current_user: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除业务。有发货或回款记录时禁删,应改为已终止。"""
business_service.delete_business(session, business_id, operator=current_user, meta=meta)
return ok(message="业务已删除")
@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转")
def change_status(
session: DbSession,
current_user: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]:
"""状态流转:执行中→已完成/已终止;终态不可再变更;终止必填原因。"""
return ok(
business_service.change_status(
session, business_id, payload, operator=current_user, meta=meta
),
message="状态已变更",
)
@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属")
def change_ownership(
session: DbSession,
current_user: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessOwnershipIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]:
"""调整负责/协助营销人员,同事务写归属变更留痕(不回溯历史)。"""
return ok(
business_service.change_ownership(
session, business_id, payload, operator=current_user, meta=meta
),
message="归属已调整",
)
@router.get(
"/{business_id}/ownership-logs",
response_model=ApiResponse[PageResult[OwnershipChangeLogOut]],
summary="归属变更记录",
)
def list_ownership_logs(
session: DbSession,
_: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
query: Annotated[PageQuery, Query()],
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
"""某业务的归属变更留痕(分页,按时间倒序)。"""
return ok(business_service.list_ownership_logs(session, business_id, query))
@router.get(
"/{business_id}/status-logs",
response_model=ApiResponse[PageResult[BusinessStatusLogOut]],
summary="状态流转记录",
)
def list_status_logs(
session: DbSession,
_: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
query: Annotated[PageQuery, Query()],
) -> ApiResponse[PageResult[BusinessStatusLogOut]]:
"""某业务的状态流转时间线(分页,按时间正序,从创建起展示)。"""
return ok(business_service.list_status_logs(session, business_id, query))
@router.get(
"/{business_id}/gross-profit",
response_model=ApiResponse[GrossProfitOut],
summary="毛利润数据视图",
)
def gross_profit(
session: DbSession,
_: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
as_of: Annotated[date | None, Query()] = None,
) -> ApiResponse[GrossProfitOut]:
"""按产品逐行实时计算毛利润与折算额(不含运费口径,对照表 #2)。
阈值按 asOf(默认今日)取参数快照;M3 核算实际按回款到账日取参。
"""
return ok(business_service.gross_profit(session, business_id, as_of))
+74
View File
@@ -0,0 +1,74 @@
"""客户评级等级配置接口(需求 2.2.5)。
评级计算器的等级映射判定表:判定按 sort_order 升序取首个 min_score ≤ 总分
的等级,max_score 仅展示。区间合法(0≤min≤max≤100)与互不重叠由后端校验
—— 前端表单只是展示约束,后端校验是唯一防线。
列表不分页全量返回:等级映射是判定表,数量个位数,分页无意义。
业务数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
from app.services import rating_service
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/rating-grades", tags=["客户评级"])
@router.get("", response_model=ApiResponse[list[RatingGradeOut]], summary="评级等级列表")
def list_grades(
session: DbSession,
_: CurrentUser,
) -> ApiResponse[list[RatingGradeOut]]:
"""全量等级列表,按判定顺序(sort_order 升序)返回,不分页。"""
return ok(rating_service.list_grades(session))
@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级")
def create_grade(
session: DbSession,
current_user: CurrentUser,
payload: RatingGradeCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[RatingGradeOut]:
"""新增评级等级。区间须 0≤min≤max≤100 且与既有等级不重叠。"""
return ok(
rating_service.create_grade(session, payload, operator=current_user, meta=meta),
message="评级等级已创建",
)
@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级")
def update_grade(
session: DbSession,
current_user: CurrentUser,
grade_id: Annotated[int, Path(ge=1)],
payload: RatingGradeUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[RatingGradeOut]:
"""编辑评级等级(全量替换)。区间须合法且与其他等级不重叠。"""
return ok(
rating_service.update_grade(session, grade_id, payload, operator=current_user, meta=meta),
message="已保存",
)
@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级")
def delete_grade(
session: DbSession,
current_user: CurrentUser,
grade_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""物理删除评级等级(等级表无下游引用,允许清空,理由见 service 说明)。"""
rating_service.delete_grade(session, grade_id, operator=current_user, meta=meta)
return ok(message="评级等级已删除")
+89
View File
@@ -0,0 +1,89 @@
"""回款管理接口(需求 2.2.3)。
回款按业务登记到账款项,批次号自动生成(HK 前缀)。校验规则(发货
批次同业务强校验、已核算禁改禁删、汇总重算)见 `receipt_service` 模块
docstring;基础数据属业务操作,登录即可访问。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.receipt import (
ReceiptCreateIn,
ReceiptOut,
ReceiptQuery,
ReceiptUpdateIn,
)
from app.services import receipt_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/receipts", tags=["回款管理"])
@router.get("", response_model=ApiResponse[PageResult[ReceiptOut]], summary="回款列表")
def list_receipts(
session: DbSession,
_: CurrentUser,
query: Annotated[ReceiptQuery, Query()],
) -> ApiResponse[PageResult[ReceiptOut]]:
"""分页查询回款。附业务号/发货单号与回款天数派生值,可按业务/日期/抵顶/核算状态筛选。"""
return ok(receipt_service.list_receipts(session, query))
@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款")
def create_receipt(
session: DbSession,
current_user: CurrentUser,
payload: ReceiptCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ReceiptOut]:
"""新增回款。批次号自动生成;发货批次须与本回款同属一个业务;业务须执行中。"""
return ok(
receipt_service.create_receipt(session, payload, operator=current_user, meta=meta),
message="回款已登记",
)
@router.get("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="回款详情")
def get_receipt(
session: DbSession,
_: CurrentUser,
receipt_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ReceiptOut]:
"""回款详情(含回款天数派生值,未指定发货批次时为空)。"""
return ok(receipt_service.get_detail(session, receipt_id))
@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款")
def update_receipt(
session: DbSession,
current_user: CurrentUser,
receipt_id: Annotated[int, Path(ge=1)],
payload: ReceiptUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[ReceiptOut]:
"""编辑回款(全量替换并重算合计)。已核算回款禁止修改;发货批次重新校验同业务。"""
return ok(
receipt_service.update_receipt(
session, receipt_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款")
def delete_receipt(
session: DbSession,
current_user: CurrentUser,
receipt_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除回款。已核算回款禁止删除;业务已完成或已终止时禁删。"""
receipt_service.delete_receipt(session, receipt_id, operator=current_user, meta=meta)
return ok(message="回款已删除")
+89
View File
@@ -0,0 +1,89 @@
"""发货管理接口(需求 2.2.2)。
校验规则(状态门槛、行金额固化、双重汇总回写、删除保护)见
`shipment_service` 模块 docstring;业务数据属业务操作,登录即可访问。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.shipment import (
ShipmentCreateIn,
ShipmentDetailOut,
ShipmentOut,
ShipmentQuery,
ShipmentUpdateIn,
)
from app.services import shipment_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/shipments", tags=["发货管理"])
@router.get("", response_model=ApiResponse[PageResult[ShipmentOut]], summary="发货单列表")
def list_shipments(
session: DbSession,
_: CurrentUser,
query: Annotated[ShipmentQuery, Query()],
) -> ApiResponse[PageResult[ShipmentOut]]:
"""分页查询发货单(附业务编号),可按业务/发货日期范围筛选。"""
return ok(shipment_service.list_shipments(session, query))
@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单")
def create_shipment(
session: DbSession,
current_user: CurrentUser,
payload: ShipmentCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ShipmentOut]:
"""创建发货单(明细 ≥1)。业务须处于执行中;锁业务行后生成 SH 编号。"""
return ok(
shipment_service.create_shipment(session, payload, operator=current_user, meta=meta),
message="发货单已创建",
)
@router.get("/{shipment_id}", response_model=ApiResponse[ShipmentDetailOut], summary="发货单详情")
def get_shipment(
session: DbSession,
_: CurrentUser,
shipment_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ShipmentDetailOut]:
"""发货单详情,附产品明细行,供编辑抽屉一次回显。"""
return ok(shipment_service.get_detail(session, shipment_id))
@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单")
def update_shipment(
session: DbSession,
current_user: CurrentUser,
shipment_id: Annotated[int, Path(ge=1)],
payload: ShipmentUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[ShipmentOut]:
"""编辑发货单(明细全量替换并重算汇总)。终态业务的发货单禁改。"""
return ok(
shipment_service.update_shipment(
session, shipment_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单")
def delete_shipment(
session: DbSession,
current_user: CurrentUser,
shipment_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除发货单(连带明细行并回写业务汇总)。被回款批次指定时禁删。"""
shipment_service.delete_shipment(session, shipment_id, operator=current_user, meta=meta)
return ok(message="发货单已删除")
+24
View File
@@ -47,12 +47,36 @@ PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water"
PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT}
# ===== 客户分类码(customers.customer_type,计提规则匹配锚点)=====
CUSTOMER_TYPE_PERSONAL = "personal"
CUSTOMER_TYPE_MANAGER_PERSONAL = "manager_personal"
CUSTOMER_TYPE_ENTERPRISE = "enterprise"
CUSTOMER_TYPES = {
CUSTOMER_TYPE_PERSONAL,
CUSTOMER_TYPE_MANAGER_PERSONAL,
CUSTOMER_TYPE_ENTERPRISE,
}
# ===== 操作日志词表(operation_logs 的 module / action 取值)=====
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
MODULE_USER = "用户管理"
MODULE_ROLE = "角色管理"
MODULE_BUSINESS_ROLE = "岗位角色"
MODULE_PARAM = "计提参数"
MODULE_PERSONNEL = "人员管理"
MODULE_PRODUCT = "产品管理"
MODULE_CUSTOMER = "客户管理"
MODULE_BUSINESS = "业务管理"
MODULE_SHIPMENT = "发货管理"
MODULE_RECEIPT = "回款管理"
MODULE_RATING = "客户评级"
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
NO_PREFIX_BUSINESS = "YW"
NO_PREFIX_SHIPMENT = "SH"
NO_PREFIX_RECEIPT = "HK"
NO_PREFIX_PARAM = "P"
ACTION_CREATE = "新增"
ACTION_UPDATE = "修改"
+18
View File
@@ -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
View File
@@ -12,6 +12,7 @@ import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
@@ -60,6 +61,19 @@ async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSON
return _build_response(exc.status_code, message)
async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONResponse:
"""完整性错误全局兜底为 400。
service 层已判别的 1062/引用冲突会自行翻译为业务提示,不会走到这里;
本 handler 拦截的是「新增父表后某 service 漏补引用保护」一类的遗漏
(e2e 撞见一个补一个的模式无法保证全覆盖):FK RESTRICT 等数据完整性
冲突对用户表现为「这行数据被别处引用/冲突」,转 400 比 500 更贴近事实。
完整异常记录日志供排查;不在此处 rollback —— 会话由请求级依赖统一关闭。
"""
logger.error("数据库完整性冲突(未在 service 层翻译)", exc_info=exc)
return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝")
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
"""兜底处理未预期异常。
@@ -77,4 +91,5 @@ def register_exception_handlers(app: FastAPI) -> None:
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
app.add_exception_handler(IntegrityError, handle_integrity_error) # type: ignore[arg-type]
app.add_exception_handler(Exception, handle_unexpected_exception)
+20
View File
@@ -10,24 +10,44 @@
"""
from app.models.base import Base, TimestampMixin
from app.models.business import Business, BusinessItem, BusinessStatusLog
from app.models.business_role import BusinessRole
from app.models.customer import Customer, OwnershipChangeLog
from app.models.login_log import LoginLog
from app.models.operation_log import OperationLog
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
from app.models.product import Product
from app.models.rating import CustomerRatingGrade
from app.models.receipt import Receipt
from app.models.role import Permission, Role, RolePermission
from app.models.shipment import Shipment, ShipmentItem
from app.models.user import User, UserRole
__all__ = [
"Base",
"Business",
"BusinessItem",
"BusinessRole",
"BusinessStatusLog",
"Customer",
"CustomerRatingGrade",
"LoginLog",
"OperationLog",
"OwnershipChangeLog",
"ParamChangeBatch",
"ParamDefinition",
"ParamValue",
"Permission",
"Personnel",
"PersonnelPlan",
"PersonnelRegion",
"Product",
"Receipt",
"Role",
"RolePermission",
"Shipment",
"ShipmentItem",
"TimestampMixin",
"User",
"UserRole",
+33 -2
View File
@@ -7,7 +7,7 @@
from datetime import datetime
from sqlalchemy import DateTime, MetaData, text
from sqlalchemy import DateTime, MetaData, String, text
from sqlalchemy.dialects.mysql import BIGINT, TINYINT
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -28,7 +28,14 @@ BIGINT_UNSIGNED = BIGINT(unsigned=True)
# 状态类字段统一用 TINYINT(0/1 或少量枚举值)
TINYINT_TYPE = TINYINT
__all__ = ["BIGINT_UNSIGNED", "TINYINT_TYPE", "Base", "TimestampMixin", "mysql_table_args"]
__all__ = [
"BIGINT_UNSIGNED",
"TINYINT_TYPE",
"Base",
"TPlusSyncMixin",
"TimestampMixin",
"mysql_table_args",
]
class Base(DeclarativeBase):
@@ -62,6 +69,30 @@ class TimestampMixin:
)
class TPlusSyncMixin:
"""T+(用友)数据同步预留列,六张业务主表共用。
对照表 #26:T+ 对接的字段映射与幂等键语义未定(IT 牵头),故四列全部
可空、不设唯一键 —— 键语义未定就锁死唯一约束,对接时大概率返工;
source_no 先建普通索引承担查询与去重,IT 定键后以小迁移升级唯一约束。
同步守则(M4 同步模块必须遵守):同步只写 T+ 侧字段与本组列,
**不覆盖人工维护字段**(岗位/工资标准/归属/客户分类/业务状态等,
各表注释已标注)。同步频率:一周一次定时 + 手动触发(对照表 #10)。
"""
source_no: Mapped[str | None] = mapped_column(
String(64),
index=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
)
t_plus_id: Mapped[str | None] = mapped_column(String(64), comment="T+ 系统内部标识")
sync_batch_no: Mapped[str | None] = mapped_column(
String(32), comment="同步批次号(同步模块写入)"
)
synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间")
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
"""构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。
+218
View File
@@ -0,0 +1,218 @@
"""业务数据模型:业务单、产品明细与状态流转留痕。
- 汇总冗余列(total_* 五列)由服务层在明细/发货/回款增删改时**事务内单点
重算**,禁手工直改 —— 列表页与 M3 起付判定直接读冗余列,避免每次 JOIN
聚合;代价是所有写路径必须重算(business_service 统一收口)
- 毛利润相关值(毛利润额/毛利润率/折算毛利润额)**不落库**:它们是
sale_amount/cost_amount + 时点化阈值参数的派生值,落库会在成本调整或
参数改版后漂移出「假快照」;M2 以只读毛利润视图实时计算(复用
formula.profit_rate/converted_profit),M3 核算台账才是法定固化点
- product_category 存在明细行级:混合业务按产品逐条计算(对照表 #14)
- 运费仅记录展示,不计入毛利润口径(对照表 #2);协助营销人员一期核算
不使用(对照表 #15 剔除协助 25% 分成),字段保留仅记录事实(需规 2.2.1)
"""
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
# 业务状态(businesses.status,流转规则见 business_service:终态不可变更)
BUSINESS_STATUS_ACTIVE = 1
BUSINESS_STATUS_DONE = 2
BUSINESS_STATUS_TERMINATED = 3
class Business(Base, TimestampMixin, TPlusSyncMixin):
"""业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。"""
__tablename__ = "businesses"
__table_args__ = mysql_table_args(
"业务表(业务编号自动生成;T+同步不覆盖业务状态)",
Index("idx_businesses_customer_id", "customer_id"),
Index("idx_businesses_owner_personnel_id", "owner_personnel_id"),
Index("idx_businesses_status", "status"),
Index("idx_businesses_business_date", "business_date"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
business_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)",
)
customer_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("customers.id", ondelete="RESTRICT"),
nullable=False,
comment="客户ID(customers.id)",
)
business_date: Mapped[date | None] = mapped_column(Date, comment="业务日期(T+ 同步口径)")
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)",
)
owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)",
)
assistant_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)",
)
is_special: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="特殊业务标记:1是 0否(公司参与等)",
)
special_note: Mapped[str | None] = mapped_column(String(255), comment="特殊业务说明")
total_sale_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)",
)
total_cost_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)",
)
total_ship_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)",
)
total_received_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)",
)
total_freight_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class BusinessItem(Base, TimestampMixin):
"""业务产品明细表(需求 2.2.1/2.2.4):毛利润的行级源数据。
product_id 可空=自由录入产品(配合「数据提取」一键入产品库);
产品名称/规格冗余固化,防产品改名影响历史业务。
sale_amount 由服务层按 数量×单价 计算固化,不信任前端传值。
cost_amount 来源待 T+ 对接确认(可空,人工可录先行)。
"""
__tablename__ = "business_items"
__table_args__ = mysql_table_args(
"业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)",
Index("idx_business_items_business_id", "business_id"),
Index("idx_business_items_product_id", "product_id"),
Index("idx_business_items_product_category", "product_category"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("businesses.id", ondelete="RESTRICT"),
nullable=False,
comment="业务ID(businesses.id)",
)
product_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("products.id", ondelete="RESTRICT"),
comment="产品ID(products.id;NULL=自由录入,未入产品库)",
)
product_name: Mapped[str] = mapped_column(
String(128), nullable=False, comment="产品名称(引用产品时冗余固化)"
)
spec_model: Mapped[str] = mapped_column(
String(128),
nullable=False,
server_default="''",
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
)
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
product_category: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
)
quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)")
sale_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="销售单价(元;T+ 同步或人工)"
)
sale_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)",
)
cost_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)",
)
guided_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class BusinessStatusLog(Base):
"""业务状态变更记录表(append-only,需求 2.2.1「支持状态变更记录」)。
不继承 TimestampMixin(留痕无更新语义);business_id 为无外键的固化 ID
(businesses 建表在本表之后的迁移批次内 —— 实为同批,但留痕统一口径:
被引用方删除后记录仍完整可读,删除保护由服务层检查承担)。
首条记录在业务创建时写入(old=new=1 执行中),详情页时间线从创建起展示。
"""
__tablename__ = "business_status_logs"
__table_args__ = mysql_table_args(
"业务状态变更记录表(append-only)",
Index("idx_business_status_logs_business_id", "business_id"),
Index("idx_business_status_logs_create_time", "create_time"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
comment="业务ID(固化,无外键;删除保护由服务层承担)",
)
old_status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="变更前状态(创建首条为 1)"
)
new_status: Mapped[int] = mapped_column(TINYINT_TYPE, nullable=False, comment="变更后状态")
change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因(终止时必填)")
operator_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="操作人ID(账号删除后置空)",
)
operator_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="操作人用户名(冗余固化)"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)
+121
View File
@@ -0,0 +1,121 @@
"""客户模型:客户档案与归属变更留痕。
- 「公司名称 + 负责人」是业务唯一标识(需规 2.1.3);负责人是客户公司的
负责人**人名**(字符串),不是系统内的营销人员 —— 系统内归属另用
owner_personnel_id 外键表达,两者语义不同
- 归属变更只改主表当前值 + 插一条留痕,**不回溯历史**(对照表 #19):
历史核算结果在 M3 台账已固化,无需 effective_date 时点化设计
- 留痕表(OwnershipChangeLog)对客户/业务/人员 ID 均为**无外键的冗余固化**:
留痕是历史记录,被引用方被删除后记录仍须完整可读;RESTRICT 外键反而会
把「删除客户」逼成 500。业务实体删除保护由服务层检查承担
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
# 归属类型(ownership_change_logs.owner_type)
OWNER_TYPE_CUSTOMER = 1
OWNER_TYPE_BUSINESS = 2
class Customer(Base, TimestampMixin, TPlusSyncMixin):
"""客户表(需求 2.1.3)。customer_type 是计提规则匹配锚点(M3 消费)。"""
__tablename__ = "customers"
__table_args__ = mysql_table_args(
"客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)",
UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
company_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="公司名称")
owner_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="客户负责人姓名(客户公司负责人的人名,非系统人员)"
)
contact_person: Mapped[str | None] = mapped_column(String(64), comment="联系人")
contact_phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
address: Mapped[str | None] = mapped_column(String(255), comment="地址")
industry: Mapped[str | None] = mapped_column(String(64), comment="所属行业(自由文本)")
customer_type: Mapped[str] = mapped_column(
String(32),
nullable=False,
server_default="personal",
index=True,
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
)
owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
index=True,
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class OwnershipChangeLog(Base):
"""归属变更记录表(append-only,需求 2.1.3 + 对照表 #19)。
owner_type 区分客户归属/业务归属:1 → customer_id 非空,2 → business_id
非空,由应用层校验(无外键,见模块 docstring 的固化论证)。
前后值冗余固化(无外键):人员被删除后留痕仍可读。
"""
__tablename__ = "ownership_change_logs"
__table_args__ = mysql_table_args(
"归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)",
Index("idx_ownership_change_logs_customer_id", "customer_id"),
Index("idx_ownership_change_logs_business_id", "business_id"),
Index("idx_ownership_change_logs_create_time", "create_time"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
owner_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="归属类型:1客户归属 2业务归属"
)
customer_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
comment="客户ID(固化,owner_type=1 时非空)",
)
business_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
comment="业务ID(固化,owner_type=2 时非空)",
)
old_owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更前负责营销人员ID(固化)"
)
new_owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更后负责营销人员ID(固化)"
)
old_assistant_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更前协助营销人员ID(固化,仅业务归属使用)"
)
new_assistant_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更后协助营销人员ID(固化,仅业务归属使用)"
)
change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因")
operator_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="操作人ID(账号删除后置空)",
)
operator_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="操作人用户名(冗余固化)"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)
+127
View File
@@ -0,0 +1,127 @@
"""营销人员模型:人员档案、产品辐射区域与市场拓展计划。
- 岗位(business_role_id)决定计提公式模板,与工资标准同属「同步不覆盖」的
人工维护字段(对照表 #26 落地口径,表注释标注供 M4 同步模块遵守)
- manager_id 自引用表达「经理带团队」:NULL=经理本人或未分配;M3 经理
12.5% 聚合、M4 部门汇总均按该关系展开,无需独立团队表
- 一期营销人员不登录系统,不与 users 关联;若后续开放自助查工资再加列
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
# personnel.status 的在职值(对照 BUSINESS_STATUS_* 集中在 models/business.py
# 的先例:状态语义单点导出,业务/客户两侧的归属校验共用,防魔数各自漂移)
PERSONNEL_STATUS_ACTIVE = 1
class Personnel(Base, TimestampMixin, TPlusSyncMixin):
"""营销人员表(需求 2.1.1)。
表名 personnel 为集合名词(单复数同形),本身即复数语义。
试用期人员绩效照算不发放(对照表 #17),is_probation 供 M3 区分。
"""
__tablename__ = "personnel"
__table_args__ = mysql_table_args("营销人员表(T+同步不覆盖岗位与工资标准)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
personnel_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="姓名")
business_role_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("business_roles.id", ondelete="RESTRICT"),
nullable=False,
index=True,
comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)",
)
manager_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
index=True,
comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)",
)
phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
hire_date: Mapped[date | None] = mapped_column(Date, comment="入职日期")
is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="是否试用期:1是 0否(试用期绩效照算不发放)",
)
salary_standard: Mapped[Decimal | None] = mapped_column(
Numeric(12, 2), comment="月工资标准(元;T+同步不覆盖)"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class PersonnelRegion(Base, TimestampMixin):
"""产品辐射区域表(需求 2.1.1):人员负责的产品辐射地理范围,1:N 子表。
随人员编辑整表替换维护,无独立管理诉求,故不加 T+ 预留列。
"""
__tablename__ = "personnel_regions"
__table_args__ = mysql_table_args("产品辐射区域表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
index=True,
comment="人员ID(personnel.id)",
)
region_name: Mapped[str] = mapped_column(
String(128), nullable=False, comment="区域名称(产品辐射地理范围)"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class PersonnelPlan(Base, TimestampMixin):
"""市场拓展计划表(需求 2.1.1):计划期间 + 计划内容 + 指标说明的自由条目。
结构化指标粒度待 T+ 字段映射确认,先以自由条目容器落地;条目最终由
T+ 同步确定,故预留 source_no 单列(无索引:查询按 personnel_id 走)。
"""
__tablename__ = "personnel_plans"
__table_args__ = mysql_table_args("市场拓展计划表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
index=True,
comment="人员ID(personnel.id)",
)
plan_period: Mapped[str] = mapped_column(
String(32), nullable=False, comment="计划期间(如 2026Q1、2026-05)"
)
plan_content: Mapped[str] = mapped_column(String(500), nullable=False, comment="计划内容")
target_desc: Mapped[str | None] = mapped_column(String(255), comment="指标说明(文字描述)")
source_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 原始条目号(预留)")
+58
View File
@@ -0,0 +1,58 @@
"""产品模型:产品主数据。
产品类别(product_category)是**折算阈值的行级锚点**:混合业务按产品逐条
计算(对照表 #14),阈值本身由 M1 参数 `product.threshold` 按 PRODUCT_CATEGORY
维度承担(#28「全部阈值统一在系统设置维护」),本表不建阈值列 —— 两处维护
必然漂移。类别值域与 M1 参数维度共用 PRODUCT_CATEGORIES 常量。
"""
from decimal import Decimal
from sqlalchemy import Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
class Product(Base, TimestampMixin, TPlusSyncMixin):
"""产品表(需求 2.1.2)。停用(status=0)为主,不物理删除。"""
__tablename__ = "products"
__table_args__ = mysql_table_args(
"产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)",
UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
product_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="产品名称")
spec_model: Mapped[str] = mapped_column(
String(128),
nullable=False,
server_default="''",
comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)",
)
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
stock_quantity: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="数量(T+ 同步原样存储,语义待 IT 确认)"
)
sale_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="销售单价(元,T+ 同步)"
)
product_category: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
+46
View File
@@ -0,0 +1,46 @@
"""客户评级等级配置模型(需求 2.2.5)。
评级计算器定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分记录
不落库,等级**映射关系**可配置由本表承载。判定规则:按 sort_order 升序取
第一个 min_score ≤ 总分的等级 —— 单边判定避免区间边界(如 75 分)在
「≥75 且 ≤89」与「>74 且 <75」两套写法间的重叠/遗漏歧义;max_score 仅展示。
"""
from decimal import Decimal
from sqlalchemy import Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class CustomerRatingGrade(Base, TimestampMixin):
"""客户评级等级配置表(需求 2.2.5「等级对应关系可配置」)。
种子四条随迁移初始化:A 优秀 90-100 / B 良好 75-89 / C 合格 60-74 /
D 待改进 0-59。区间不重叠与 0≤min≤max≤100 由应用层校验。
"""
__tablename__ = "customer_rating_grades"
__table_args__ = mysql_table_args(
"客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)"
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
grade_name: Mapped[str] = mapped_column(
String(32), nullable=False, unique=True, comment="等级名称(如 A/优秀)"
)
min_score: Mapped[Decimal] = mapped_column(
Numeric(5, 1),
nullable=False,
comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分",
)
max_score: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, comment="总分上限(展示用)"
)
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0", comment="排序(判定顺序,升序)"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="说明")
+83
View File
@@ -0,0 +1,83 @@
"""回款模型:回款记录与批次追溯。
- shipment_id 为**手动指定**的发货批次(对照表 #18):可空=暂未指定;
必须与 business_id 同属一个业务(服务层强校验,无数据库级约束可表达
跨表同属关系);指定后回款天数方可由 formula.payment_days 派生
- settlement_status 是 **M3 核算防重锚点**(需规 2.3.3「每笔回款仅核算一次,
台账记录核销状态」):预置于本表避免 M3 反向迁移改 M2 表;M3 台账建立后
由核算服务写入,M2 仅维护 0(未核算)
- receipt_date 是 M3 参数版本匹配日(对照表 #11:按回款到账日取时点参数)
- 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
# 绩效核算状态(receipts.settlement_status)
SETTLEMENT_PENDING = 0
SETTLEMENT_DONE = 1
class Receipt(Base, TimestampMixin, TPlusSyncMixin):
"""回款表(需求 2.2.3):按业务登记到账款项,批次号系统内唯一。"""
__tablename__ = "receipts"
__table_args__ = mysql_table_args(
"回款表(手动指定发货批次;已核算回款禁改禁删)",
Index("idx_receipts_business_id", "business_id"),
Index("idx_receipts_receipt_date", "receipt_date"),
Index("idx_receipts_settlement_status", "settlement_status"),
Index("idx_receipts_shipment_id", "shipment_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
receipt_batch_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)",
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("businesses.id", ondelete="RESTRICT"),
nullable=False,
comment="业务ID(businesses.id)",
)
shipment_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("shipments.id", ondelete="RESTRICT"),
comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)",
)
receipt_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)"
)
amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="回款金额(≥0;一期禁负数/红字)"
)
is_barter: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)",
)
settlement_status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
+115
View File
@@ -0,0 +1,115 @@
"""发货模型:发货单与发货产品明细。
- ship_date 是**回款天数计算基准日**(对照表 #1:发货日首日计第 1 天),
回款指定本发货单后由 formula.payment_days 派生回款天数,不在本表落库
- 运费仅记录展示,不计入毛利润口径(对照表 #2)
- total_amount 为明细行汇总冗余,服务层事务内维护
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
class Shipment(Base, TimestampMixin, TPlusSyncMixin):
"""发货单表(需求 2.2.2):每笔发货对应一个业务,支持分批发货。"""
__tablename__ = "shipments"
__table_args__ = mysql_table_args(
"发货单表(发货日为回款天数基准日,发货日首日计第 1 天)",
Index("idx_shipments_business_id", "business_id"),
Index("idx_shipments_ship_date", "ship_date"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
shipment_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)",
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("businesses.id", ondelete="RESTRICT"),
nullable=False,
comment="业务ID(businesses.id;终态业务禁止新发货)",
)
ship_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)"
)
freight_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
server_default="0",
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
)
total_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
server_default="0",
comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class ShipmentItem(Base, TimestampMixin):
"""发货产品明细表(需求 2.2.2):记录发货的产品名称、规格、数量等。
价格口径两可(T+ 供价或人工录入,待 IT 确认):sale_price 可空人工补,
amount 由服务层按 数量×单价 计算固化。
"""
__tablename__ = "shipment_items"
__table_args__ = mysql_table_args(
"发货产品明细表",
Index("idx_shipment_items_shipment_id", "shipment_id"),
Index("idx_shipment_items_product_id", "product_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
shipment_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("shipments.id", ondelete="RESTRICT"),
nullable=False,
comment="发货单ID(shipments.id)",
)
product_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("products.id", ondelete="RESTRICT"),
comment="产品ID(products.id;NULL=自由录入)",
)
product_name: Mapped[str] = mapped_column(
String(128), nullable=False, comment="产品名称(引用产品时冗余固化)"
)
spec_model: Mapped[str] = mapped_column(
String(128),
nullable=False,
server_default="''",
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
)
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
quantity: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="发货数量(>0)"
)
sale_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)"
)
amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)",
)
@@ -0,0 +1,257 @@
"""业务数据仓储:业务单、明细、状态留痕与归属留痕。
只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。
归属留痕复用 ownership_change_logs(owner_type=2 为业务维度,常量在
models/customer.py 定义)。
"""
from datetime import date
from sqlalchemy import Row, Select, delete, exists, func, or_, select
from sqlalchemy.orm import Session, aliased
from app.models import (
Business,
BusinessItem,
BusinessStatusLog,
Customer,
OwnershipChangeLog,
Personnel,
Receipt,
Shipment,
)
from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import ownership_log_repository
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
"""按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。"""
stmt = select(Business).where(Business.id == business_id)
if for_update:
stmt = stmt.with_for_update()
return session.execute(stmt).scalars().first()
def has_docs(session: Session, business_id: int) -> bool:
"""业务名下是否已有发货单或回款(换客户/删除保护的引用检查)。
单条 OR exists 一次往返;新增挂靠业务的单据类型时在此补充条件,
编辑与删除两处共用同一判定,防「delete 补了检查、update 漏补」的分叉。
"""
stmt = select(
or_(
exists().where(Shipment.business_id == business_id),
exists().where(Receipt.business_id == business_id),
)
)
return bool(session.execute(stmt).scalar())
def create(session: Session, business: Business) -> None:
"""新增业务行(不 commit,flush 让自增 id 就位)。"""
session.add(business)
session.flush()
def get_with_names(
session: Session, business_id: int, *, for_update: bool = False
) -> tuple[Business, str, str | None, str | None] | None:
"""业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。
for_update 供编辑/状态流转/归属变更使用:与发货/回款写路径的
get_by_id(for_update=True) 同一锁口径,编辑事务与单据写事务互斥,
「有发货禁换客户」的快照读校验不再被并发新增旁路。
"""
owner = aliased(Personnel)
assistant = aliased(Personnel)
stmt = (
select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name)
.join(Customer, Customer.id == Business.customer_id)
.outerjoin(owner, owner.id == Business.owner_personnel_id)
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
.where(Business.id == business_id)
)
if for_update:
stmt = stmt.with_for_update()
return session.execute(stmt).first()
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
) -> Select:
"""业务列表的筛选条件(调用方须已 join Customer)。"""
if keyword:
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
stmt = stmt.where(
or_(
Business.business_no.contains(keyword, autoescape=True),
Customer.company_name.contains(keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(Business.status == status)
if customer_id is not None:
stmt = stmt.where(Business.customer_id == customer_id)
if owner_personnel_id is not None:
stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id)
if date_from is not None:
stmt = stmt.where(Business.business_date >= date_from)
if date_to is not None:
stmt = stmt.where(Business.business_date <= date_to)
return stmt
def list_businesses(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
) -> list[tuple[Business, str, str | None, str | None]]:
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
owner = aliased(Personnel)
assistant = aliased(Personnel)
stmt = select(
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
).join(Customer, Customer.id == Business.customer_id)
stmt = _apply_filters(
stmt,
keyword=keyword,
status=status,
customer_id=customer_id,
owner_personnel_id=owner_personnel_id,
date_from=date_from,
date_to=date_to,
)
stmt = (
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
.order_by(Business.id.desc())
.offset(offset)
.limit(limit)
)
return list(session.execute(stmt).all())
def count_businesses(
session: Session,
*,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
) -> int:
stmt = (
select(func.count())
.select_from(Business)
.join(Customer, Customer.id == Business.customer_id)
)
stmt = _apply_filters(
stmt,
keyword=keyword,
status=status,
customer_id=customer_id,
owner_personnel_id=owner_personnel_id,
date_from=date_from,
date_to=date_to,
)
return int(session.execute(stmt).scalar_one())
def list_items(session: Session, business_id: int) -> list[BusinessItem]:
"""业务明细行(编辑回显与汇总重算共用)。"""
return list(
session.execute(
select(BusinessItem)
.where(BusinessItem.business_id == business_id)
.order_by(BusinessItem.id)
).scalars()
)
def replace_items(session: Session, business_id: int, rows: list[BusinessItem]) -> None:
"""全量替换明细:先删、立即 flush、再插入(replace_roles 先例的防御写法)。
同一 flush 中 SQLAlchemy 先处理 INSERT 后处理 DELETE,先删后插不落库
会在有唯一键的表上撞键;business_items 虽无唯一键,统一按此写法防御。
"""
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
session.flush()
for row in rows:
row.business_id = business_id
session.add(row)
session.flush()
def add_status_log(session: Session, log: BusinessStatusLog) -> None:
"""追加状态留痕(不 commit)。"""
session.add(log)
session.flush()
def list_status_logs(
session: Session, business_id: int, *, offset: int, limit: int
) -> list[BusinessStatusLog]:
"""状态流转时间线(详情页),按时间正序(时间线从创建起展示)。"""
return list(
session.execute(
select(BusinessStatusLog)
.where(BusinessStatusLog.business_id == business_id)
.order_by(BusinessStatusLog.create_time, BusinessStatusLog.id)
.offset(offset)
.limit(limit)
).scalars()
)
def count_status_logs(session: Session, business_id: int) -> int:
return int(
session.execute(
select(func.count())
.select_from(BusinessStatusLog)
.where(BusinessStatusLog.business_id == business_id)
).scalar_one()
)
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None:
"""追加归属变更留痕(共享实现,不 commit)。"""
ownership_log_repository.add(session, log)
def list_ownership_logs(
session: Session, business_id: int, *, offset: int, limit: int
) -> list[Row]:
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
return ownership_log_repository.list_by_ref(
session,
owner_type=OWNER_TYPE_BUSINESS,
ref_column=OwnershipChangeLog.business_id,
ref_id=business_id,
offset=offset,
limit=limit,
)
def count_ownership_logs(session: Session, business_id: int) -> int:
return ownership_log_repository.count_by_ref(
session,
owner_type=OWNER_TYPE_BUSINESS,
ref_column=OwnershipChangeLog.business_id,
ref_id=business_id,
)
@@ -25,6 +25,20 @@ def get_by_code(session: Session, role_code: str) -> BusinessRole | None:
return session.execute(stmt).scalar_one_or_none()
def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, BusinessRole]:
"""按编码批量取岗位(参数批次校验段用,消除逐条 N+1)。
返回 {编码: 岗位}。MySQL ci 校对下编码大小写变形也能命中,
键取库里的规范 role_code,调用方据此把维度值归一为规范值。
"""
if not role_codes:
return {}
rows = session.execute(
select(BusinessRole).where(BusinessRole.role_code.in_(role_codes))
).scalars()
return {role.role_code: role for role in rows}
def list_roles(
session: Session,
*,
@@ -0,0 +1,178 @@
"""客户数据访问(含归属变更留痕)。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import ColumnElement, Row, Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import Customer, OwnershipChangeLog, Personnel
from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import ownership_log_repository
from app.repositories.query_utils import fuzzy
# ==================== 读取 ====================
def get_by_id(session: Session, customer_id: int) -> Customer | None:
"""按主键查询客户。"""
return session.get(Customer, customer_id)
def count_business_refs(session: Session, customer_id: int) -> int:
"""被业务单据引用的次数(删除保护;业务表就位后补齐的检查)。"""
from app.models import Business
return int(
session.execute(
select(func.count()).select_from(Business).where(Business.customer_id == customer_id)
).scalar_one()
)
def get_with_owner_name(session: Session, customer_id: int) -> Row | None:
"""查单个客户并带出归属人员姓名(详情、编辑回显与归属变更共用)。"""
stmt = _base_select().where(Customer.id == customer_id)
return session.execute(stmt).first()
def get_by_company_owner(session: Session, company_name: str, owner_name: str) -> Customer | None:
"""按「公司名称 + 负责人」查客户(uk_customers_company_owner 的先查后写兜底)。"""
stmt = select(Customer).where(
Customer.company_name == company_name,
Customer.owner_name == owner_name,
)
return session.execute(stmt).scalar_one_or_none()
def list_customers(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> list[Row]:
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
"""
stmt = _apply_filters(
_base_select(),
keyword=keyword,
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).all())
def count_customers(
session: Session,
*,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> int:
"""统计符合条件的客户总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。
"""
stmt = _apply_filters(
select(func.count(Customer.id)),
keyword=keyword,
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
return int(session.execute(stmt).scalar_one())
def list_ownership_logs(
session: Session,
*,
customer_id: int,
offset: int,
limit: int,
) -> list[Row]:
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
return ownership_log_repository.list_by_ref(
session,
owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
offset=offset,
limit=limit,
)
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
"""统计某客户的归属留痕总数,供分页使用。"""
return ownership_log_repository.count_by_ref(
session,
owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
)
def _base_select() -> Select:
"""客户主表外联归属人员姓名。"""
return select(Customer, Personnel.personnel_name).outerjoin(
Personnel, Customer.owner_personnel_id == Personnel.id
)
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
customer_type: str | None,
owner_personnel_id: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if keyword:
stmt = stmt.where(_keyword_clause(keyword))
if customer_type:
stmt = stmt.where(Customer.customer_type == customer_type)
if owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id)
return stmt
def _keyword_clause(keyword: str) -> ColumnElement[bool]:
"""关键词条件:公司名称或客户负责人两列 OR 模糊。
借 ``query_utils.fuzzy`` 在空 select 上生成单列条件后取 whereclause 做
OR 合并 —— LIKE 通配符转义口径单点维护;两处手写 ``contains`` 迟早有
一处漏掉 ``autoescape``(人名含下划线是常态,不转义会当单字符通配执行)。
"""
probe = select(Customer.id)
return or_(
fuzzy(probe, Customer.company_name, keyword).whereclause,
fuzzy(probe, Customer.owner_name, keyword).whereclause,
)
# ==================== 写入 ====================
def create(session: Session, customer: Customer) -> Customer:
"""新增客户。只 flush 以取得自增 id。"""
session.add(customer)
session.flush()
return customer
def delete(session: Session, customer: Customer) -> None:
"""删除客户。调用方须先完成引用保护校验。"""
session.delete(customer)
session.flush()
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
"""写入一条归属变更留痕(共享实现)。只 flush 不 commit,与主表更新同事务。"""
return ownership_log_repository.add(session, log)
@@ -0,0 +1,58 @@
"""归属变更留痕的共享数据访问(客户/业务两维度共用)。
owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧各持一套逐行
同构的 list/count/add,排序口径与 join 兜底的调整须同步两处;收口后
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
"""
from sqlalchemy import Row, func, select
from sqlalchemy.orm import Session, aliased
from app.models import OwnershipChangeLog, Personnel
def list_by_ref(
session: Session,
*,
owner_type: int,
ref_column,
ref_id: int,
offset: int,
limit: int,
) -> list[Row]:
"""查某引用对象的归属留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。
人员删除后 join 出 None,留痕固化 ID 兜底可读。按 (create_time, id)
双键倒序:create_time 是秒级精度,同一秒内多条排序不确定,id 兜底
保证翻页稳定(与操作日志同一理由)。
"""
old_person = aliased(Personnel)
new_person = aliased(Personnel)
stmt = (
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset)
.limit(limit)
)
return list(session.execute(stmt).all())
def count_by_ref(
session: Session, *, owner_type: int, ref_column, ref_id: int
) -> int:
"""统计某引用对象的归属留痕总数,供分页使用。"""
stmt = select(func.count(OwnershipChangeLog.id)).where(
OwnershipChangeLog.owner_type == owner_type,
ref_column == ref_id,
)
return int(session.execute(stmt).scalar_one())
def add(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
"""写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。"""
session.add(log)
session.flush()
return log
@@ -81,6 +81,21 @@ def get_definition_by_code(session: Session, param_code: str) -> ParamDefinition
return session.execute(stmt).scalar_one_or_none()
def list_definitions_by_codes(
session: Session, param_codes: list[str]
) -> dict[str, ParamDefinition]:
"""按编码批量取参数定义(提交批次的校验段用,消除逐条 N+1)。
返回 {编码: 定义};未定义的编码不在结果中,由调用方判缺失。
"""
if not param_codes:
return {}
rows = session.execute(
select(ParamDefinition).where(ParamDefinition.param_code.in_(param_codes))
).scalars()
return {definition.param_code: definition for definition in rows}
def list_history(
session: Session,
param_id: int,
@@ -0,0 +1,209 @@
"""营销人员数据访问(含辐射区域与拓展计划子表)。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import Row, Select, func, select
from sqlalchemy import delete as sa_delete
from sqlalchemy.orm import Session, aliased
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
from app.repositories.query_utils import fuzzy
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
_manager = aliased(Personnel)
# ==================== 读取 ====================
def get_by_id(session: Session, personnel_id: int, *, for_update: bool = False) -> Personnel | None:
"""按主键查询。删除校验走 ``for_update`` 锁行(同 business_role_repository 的理由)。"""
if for_update:
stmt = select(Personnel).where(Personnel.id == personnel_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(Personnel, personnel_id)
def get_with_names(session: Session, personnel_id: int) -> Row | None:
"""查单个人员并带出岗位名称与直属经理姓名(详情/编辑回显共用)。"""
stmt = _base_select().where(Personnel.id == personnel_id)
return session.execute(stmt).first()
def list_personnel(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> list[Row]:
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
"""
stmt = _apply_filters(
_base_select(), keyword=keyword, status=status, business_role_id=business_role_id
)
stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).all())
def count_personnel(
session: Session,
*,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> int:
"""统计符合条件的人员总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。
"""
stmt = _apply_filters(
select(func.count(Personnel.id)),
keyword=keyword,
status=status,
business_role_id=business_role_id,
)
return int(session.execute(stmt).scalar_one())
def count_team_members(session: Session, manager_ids: list[int]) -> dict[int, int]:
"""按直属经理批量统计团队成员数(删除保护的依据)。
一次 GROUP BY 完成,禁止调用方在列表循环内逐行查询。
"""
stmt = (
select(Personnel.manager_id, func.count(Personnel.id))
.where(Personnel.manager_id.in_(manager_ids))
.group_by(Personnel.manager_id)
)
return {int(manager_id): int(count) for manager_id, count in session.execute(stmt).all()}
def count_customer_ownerships(session: Session, personnel_ids: list[int]) -> dict[int, int]:
"""按归属人员批量统计客户数(删除保护的另一半依据)。"""
stmt = (
select(Customer.owner_personnel_id, func.count(Customer.id))
.where(Customer.owner_personnel_id.in_(personnel_ids))
.group_by(Customer.owner_personnel_id)
)
return {int(owner_id): int(count) for owner_id, count in session.execute(stmt).all()}
def count_business_roles_refs(session: Session, personnel_ids: list[int]) -> dict[int, int]:
"""按人员批量统计业务单据归属引用(负责/协助合计;业务表就位后补齐的检查)。
businesses.owner/assistant_personnel_id 均为 RESTRICT 外键——漏检会让
「删除被业务引用的人员」以 500 暴露而非可读的 400。
"""
from app.models import Business
owner = (
select(Business.owner_personnel_id, func.count(Business.id))
.where(Business.owner_personnel_id.in_(personnel_ids))
.group_by(Business.owner_personnel_id)
)
assistant = (
select(Business.assistant_personnel_id, func.count(Business.id))
.where(Business.assistant_personnel_id.in_(personnel_ids))
.group_by(Business.assistant_personnel_id)
)
refs: dict[int, int] = {}
for stmt in (owner, assistant):
for owner_id, count in session.execute(stmt).all():
if owner_id is not None:
refs[int(owner_id)] = refs.get(int(owner_id), 0) + int(count)
return refs
def list_regions(session: Session, personnel_id: int) -> list[PersonnelRegion]:
"""查人员的全部辐射区域,按插入顺序返回。"""
stmt = (
select(PersonnelRegion)
.where(PersonnelRegion.personnel_id == personnel_id)
.order_by(PersonnelRegion.id)
)
return list(session.execute(stmt).scalars())
def list_plans(session: Session, personnel_id: int) -> list[PersonnelPlan]:
"""查人员的全部拓展计划,按插入顺序返回。"""
stmt = (
select(PersonnelPlan)
.where(PersonnelPlan.personnel_id == personnel_id)
.order_by(PersonnelPlan.id)
)
return list(session.execute(stmt).scalars())
def _base_select() -> Select:
"""人员主表联岗位名称与直属经理姓名。
岗位外键非空,但统一用 outerjoin:岗位行被物理删除的脏数据不应让
整个列表查不出来(外键 RESTRICT 正常情况下不会发生)。
"""
return (
select(Personnel, BusinessRole.role_name, _manager.personnel_name)
.outerjoin(BusinessRole, Personnel.business_role_id == BusinessRole.id)
.outerjoin(_manager, Personnel.manager_id == _manager.id)
)
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
status: int | None,
business_role_id: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if keyword:
stmt = fuzzy(stmt, Personnel.personnel_name, keyword)
if status is not None:
stmt = stmt.where(Personnel.status == status)
if business_role_id is not None:
stmt = stmt.where(Personnel.business_role_id == business_role_id)
return stmt
# ==================== 写入 ====================
def create(session: Session, personnel: Personnel) -> Personnel:
"""新增人员。只 flush 以取得自增 id。"""
session.add(personnel)
session.flush()
return personnel
def delete(session: Session, personnel: Personnel) -> None:
"""删除人员。调用方须先完成引用保护校验。"""
session.delete(personnel)
session.flush()
def replace_regions(session: Session, personnel_id: int, rows: list[PersonnelRegion]) -> None:
"""全量替换人员的辐射区域。
**先删、立即 flush、再插入**:同一 flush 内 SQLAlchemy 先执行 INSERT
后执行 DELETE,新旧行并存会在带唯一键的表上撞 1062 —— M1 的
replace_roles 在 e2e 中踩过此坑。本表当前无唯一键,仍统一按防御写法,
避免后续加约束时翻车。
"""
session.execute(sa_delete(PersonnelRegion).where(PersonnelRegion.personnel_id == personnel_id))
session.flush()
session.add_all(rows)
session.flush()
def replace_plans(session: Session, personnel_id: int, rows: list[PersonnelPlan]) -> None:
"""全量替换人员的拓展计划(防御写法同 ``replace_regions``)。"""
session.execute(sa_delete(PersonnelPlan).where(PersonnelPlan.personnel_id == personnel_id))
session.flush()
session.add_all(rows)
session.flush()
@@ -0,0 +1,135 @@
"""产品主数据数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import BusinessItem, Product, ShipmentItem
# ==================== 读取 ====================
def get_by_id(session: Session, product_id: int, *, for_update: bool = False) -> Product | None:
"""按主键查询。删除校验走 ``for_update`` 锁行(同 business_role_repository
的理由:校验与删除之间若发生并发引用,锁行使并发写入可见地失败,
而非引用被静默悬空)。"""
if for_update:
stmt = select(Product).where(Product.id == product_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(Product, product_id)
def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Product | None:
"""按 (名称, 规格型号) 组合查询。
组合是 uk_products_name_spec 唯一键的键值,先查用于给出即时业务提示;
并发窗口仍存在,最终由 IntegrityError 兜底(见 product_service)。
"""
stmt = select(Product).where(
Product.product_name == product_name,
Product.spec_model == spec_model,
)
return session.execute(stmt).scalar_one_or_none()
def list_products(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
product_category: str | None = None,
status: int | None = None,
) -> list[Product]:
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
stmt = _apply_filters(
select(Product),
keyword=keyword,
product_category=product_category,
status=status,
)
stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_products(
session: Session,
*,
keyword: str | None = None,
product_category: str | None = None,
status: int | None = None,
) -> int:
"""统计符合条件的产品总数,供分页使用。"""
stmt = _apply_filters(
select(func.count(Product.id)),
keyword=keyword,
product_category=product_category,
status=status,
)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
product_category: str | None,
status: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
(``contains(autoescape=True)`` 转义 LIKE 通配符并附加 ESCAPE 子句);
因 fuzzy 是单列 where 语义(两次调用会叠加成 AND),双列 OR 在此直接
写 contains —— M1 business_role_repository 的双列关键词同款写法。
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
"""
if keyword:
stmt = stmt.where(
or_(
Product.product_name.contains(keyword, autoescape=True),
Product.spec_model.contains(keyword, autoescape=True),
)
)
if product_category:
stmt = stmt.where(Product.product_category == product_category)
if status is not None:
stmt = stmt.where(Product.status == status)
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())
# ==================== 写入 ====================
def create(session: Session, product: Product) -> Product:
"""新增产品。只 flush 以取得自增 id。"""
session.add(product)
session.flush()
return product
def delete(session: Session, product: Product) -> None:
"""物理删除产品。调用方须先完成引用保护校验(count_refs + FK 兜底)。"""
session.delete(product)
session.flush()
@@ -0,0 +1,65 @@
"""客户评级等级配置数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import CustomerRatingGrade
# ==================== 读取 ====================
def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRatingGrade]:
"""全量等级,按 sort_order 升序、id 兜底(同序次序稳定)。
不分页:等级映射是判定表,数量个位数,分页无意义。
for_update 供区间重叠判定使用:先查后写在无锁时,两管理员并发提交
重叠区间会互判不重叠而双双入库(区间重叠无唯一键可拦)——锁全表行
串行化并发的重叠判定(先到者提交后,后到者在锁下读到新区间即判重叠)。
只读直出(list_grades)不加锁。
"""
stmt = select(CustomerRatingGrade).order_by(
CustomerRatingGrade.sort_order, CustomerRatingGrade.id
)
if for_update:
stmt = stmt.with_for_update()
return list(session.execute(stmt).scalars())
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
"""按主键查询。
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
"""
return session.get(CustomerRatingGrade, grade_id)
def get_by_name(session: Session, grade_name: str) -> CustomerRatingGrade | None:
"""按等级名称查询。
grade_name 是 uk_customer_rating_grades_grade_name 唯一键的键值,先查
用于给出即时业务提示;并发窗口仍存在,最终由 IntegrityError 兜底
(见 rating_service)。
"""
stmt = select(CustomerRatingGrade).where(CustomerRatingGrade.grade_name == grade_name)
return session.execute(stmt).scalar_one_or_none()
# ==================== 写入 ====================
def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
"""新增等级。只 flush 以取得自增 id。"""
session.add(grade)
session.flush()
return grade
def delete(session: Session, grade: CustomerRatingGrade) -> None:
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
session.delete(grade)
session.flush()
@@ -0,0 +1,125 @@
"""回款数据访问:回款行与关联引用(业务号/发货单号/发货日)。
只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。
- 列表与详情统一 join 出 ``business_no / shipment_no / ship_date``,
ship_date 供 service 按 ``formula.payment_days`` 派生回款天数 —— 关联
引用经本层查询一次供给,service 不再对发货单二次查询
- shipment_id 是手动指定(可空),发货单关联一律 outerjoin
"""
from datetime import date
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
from app.models import Business, Receipt, Shipment
def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
"""按主键取回款行(供摘要预取与状态判定;不锁行,写保护锚在业务行)。"""
return session.get(Receipt, receipt_id)
def get_with_refs(
session: Session, receipt_id: int
) -> tuple[Receipt, str, str | None, date | None] | None:
"""回款行 + 业务号 + 发货单号 + 发货日(DTO 与 payment_days 派生共用)。
business 主表为 inner join(FK NOT NULL,业务与回款同存亡);发货单
可未指定,outerjoin 后 shipment_no / ship_date 可能为 None。
"""
row = session.execute(
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
.join(Business, Business.id == Receipt.business_id)
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id)
.where(Receipt.id == receipt_id)
).first()
return row
def get_shipment(session: Session, shipment_id: int) -> Shipment | None:
"""按主键取发货单(同业务强校验的只读预查;FK RESTRICT 为最终防线)。"""
return session.get(Shipment, shipment_id)
def _apply_filters(
stmt: Select,
*,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> Select:
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
if business_id is not None:
stmt = stmt.where(Receipt.business_id == business_id)
if date_from is not None:
stmt = stmt.where(Receipt.receipt_date >= date_from)
if date_to is not None:
stmt = stmt.where(Receipt.receipt_date <= date_to)
if is_barter is not None:
stmt = stmt.where(Receipt.is_barter == is_barter)
if settlement_status is not None:
stmt = stmt.where(Receipt.settlement_status == settlement_status)
return stmt
def list_receipts(
session: Session,
*,
offset: int,
limit: int,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> list[tuple[Receipt, str, str | None, date | None]]:
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
stmt = _apply_filters(
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
.join(Business, Business.id == Receipt.business_id)
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
business_id=business_id,
date_from=date_from,
date_to=date_to,
is_barter=is_barter,
settlement_status=settlement_status,
)
return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all())
def count_receipts(
session: Session,
*,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> int:
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
stmt = _apply_filters(
select(func.count()).select_from(Receipt),
business_id=business_id,
date_from=date_from,
date_to=date_to,
is_barter=is_barter,
settlement_status=settlement_status,
)
return int(session.execute(stmt).scalar_one())
def create(session: Session, receipt: Receipt) -> Receipt:
"""新增回款行。只 flush 以取得自增 id,编号唯一键冲突由 service 兜底。"""
session.add(receipt)
session.flush()
return receipt
def delete(session: Session, receipt: Receipt) -> None:
"""物理删除回款行(已核算与否的准入判定在 service 层)。"""
session.delete(receipt)
session.flush()
@@ -0,0 +1,143 @@
"""发货数据访问:发货单行、明细行与业务号关联引用。
只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。
- 列表与详情统一 join 出 ``business_no``(业务主表 inner join,FK NOT
NULL,业务与发货单同存亡)
- 明细行汇总(sum_item_amount)是发货单 total_amount 重算的唯一数据源,
业务级汇总仍收口 business_service._recalc_totals
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session
from app.models import Business, Receipt, Shipment, ShipmentItem
def get_by_id(session: Session, shipment_id: int) -> Shipment | None:
"""按主键取发货单行(供摘要预取与编辑赋值;不锁行,写保护锚在业务行)。"""
return session.get(Shipment, shipment_id)
def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment, str] | None:
"""发货单行 + 业务号(DTO 与审计摘要共用)。"""
return session.execute(
select(Shipment, Business.business_no)
.join(Business, Business.id == Shipment.business_id)
.where(Shipment.id == shipment_id)
).first()
def _apply_filters(
stmt: Select,
*,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> Select:
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
if business_id is not None:
stmt = stmt.where(Shipment.business_id == business_id)
if ship_date_from is not None:
stmt = stmt.where(Shipment.ship_date >= ship_date_from)
if ship_date_to is not None:
stmt = stmt.where(Shipment.ship_date <= ship_date_to)
return stmt
def list_shipments(
session: Session,
*,
offset: int,
limit: int,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> list[tuple[Shipment, str]]:
"""分页查询发货单(附业务号),按 id 倒序。"""
stmt = _apply_filters(
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
business_id=business_id,
ship_date_from=ship_date_from,
ship_date_to=ship_date_to,
)
return list(
session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all()
)
def count_shipments(
session: Session,
*,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> int:
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
stmt = _apply_filters(
select(func.count())
.select_from(Shipment)
.join(Business, Business.id == Shipment.business_id),
business_id=business_id,
ship_date_from=ship_date_from,
ship_date_to=ship_date_to,
)
return int(session.execute(stmt).scalar_one())
def list_items(session: Session, shipment_id: int) -> list[ShipmentItem]:
"""发货明细行(详情回显与总金额重算共用)。"""
return list(
session.execute(
select(ShipmentItem)
.where(ShipmentItem.shipment_id == shipment_id)
.order_by(ShipmentItem.id)
).scalars()
)
def sum_item_amount(session: Session, shipment_id: int) -> Decimal | None:
"""明细行金额合计(发货单 total_amount 重算的唯一数据源)。"""
return session.execute(
select(func.sum(ShipmentItem.amount)).where(ShipmentItem.shipment_id == shipment_id)
).scalar_one()
def count_receipt_refs(session: Session, shipment_id: int) -> int:
"""被回款批次指定的引用数(删除保护依据,receipts.shipment_id)。"""
return int(
session.execute(
select(func.count()).select_from(Receipt).where(Receipt.shipment_id == shipment_id)
).scalar_one()
)
def create(session: Session, shipment: Shipment) -> Shipment:
"""新增发货单行。只 flush 以取得自增 id,单号唯一键冲突由 service 兜底。"""
session.add(shipment)
session.flush()
return shipment
def replace_items(session: Session, shipment_id: int, rows: list[ShipmentItem]) -> None:
"""全量替换明细:先删、立即 flush、再插入(business_repository 同款防御写法)。
同一 flush 中 SQLAlchemy 先处理 INSERT 后处理 DELETE,先删后插不落库
会在有唯一键的表上撞键;shipment_items 虽无唯一键,统一按此写法防御。
"""
session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id == shipment_id))
session.flush()
for row in rows:
row.shipment_id = shipment_id
session.add(row)
session.flush()
def delete_with_items(session: Session, shipment_id: int) -> None:
"""删除发货单及明细行(按依赖序:明细 → 主表,FK RESTRICT)。"""
session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id == shipment_id))
session.execute(delete(Shipment).where(Shipment.id == shipment_id))
session.flush()
+337
View File
@@ -0,0 +1,337 @@
"""业务管理的请求/响应模型(需求 2.2.1/2.2.4)。
金额/数量入参出参一律 str(服务层 Decimal/Fraction,全程禁 float);
派生值(毛利润额/毛利润率/折算毛利润额)不在写接口入参内 —— 落库列只有
源数据,派生值由毛利润视图实时计算。
"""
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.constants import (
PRODUCT_CATEGORIES,
PRODUCT_CATEGORY_MUNICIPAL_WATER,
PRODUCT_CATEGORY_RAIL_TRANSIT,
)
from app.core.response import PageQuery
# DECIMAL(14,2) 容量上限:整数部分 12 位(同 personnel.py 的金额校验口径)
AMOUNT_MAX = Decimal("999999999999.99")
# DECIMAL(14,4) 容量上限:整数部分 10 位
PRICE_MAX = Decimal("9999999999.9999")
def _check_amount(
value: str | None,
label: str,
cap: Decimal,
allow_positive_only: bool = False,
scale: int = 2,
) -> str | None:
"""字符串金额的通用校验:数字/有限/非负/容量/小数位上限。
小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱(工资系统红线)。
``scale`` 按列口径区分:金额/数量列 DECIMAL(14,2) 为 2;单价类列
DECIMAL(14,4) 为 4(回显 "5000.0000" 必须能原样提交,否则任何带
精度的历史单价都被编辑端点永远拒收)。
"""
if value is None:
return None
try:
amount = Decimal(value)
except InvalidOperation:
raise ValueError(f"{label}须为有效数字") from None
if not amount.is_finite():
raise ValueError(f"{label}须为有效数字")
if allow_positive_only:
if amount <= 0:
raise ValueError(f"{label}必须大于 0")
elif amount < 0:
raise ValueError(f"{label}不能为负数")
if amount > cap:
raise ValueError(f"{label}超出可录入上限")
if -amount.as_tuple().exponent > scale:
raise ValueError(f"{label}最多{'两' if scale == 2 else '四'}位小数")
return value
class BusinessItemIn(BaseModel):
"""业务产品明细行(需求 2.2.1「支持添加多种产品,录入指导价」)。
product_category 保留蛇形:该值与 M1 参数维度值域同源(枚举码本就是
蛇形);其余标识类字段驼峰兼容(snake/驼峰双入,同 customer.py 契约)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
product_id: int | None = Field(default=None, ge=1, description="产品ID;空=自由录入")
product_name: str = Field(min_length=1, max_length=128, description="产品名称")
spec_model: str | None = Field(
default=None, max_length=128, description="规格型号,空=空串归一"
)
unit: str | None = Field(default=None, max_length=16, description="计量单位")
product_category: str = Field(
description="产品类别:municipal_water/rail_transit(行级折算锚点)"
)
quantity: str = Field(description="数量,>0")
sale_price: str | None = Field(default=None, description="销售单价(元)")
cost_amount: str | None = Field(
default=None, description="成本额(元);毛利润额 = 销售额 − 成本额"
)
guided_price: str | None = Field(default=None, description="指导价(元)")
remark: str | None = Field(default=None, max_length=255)
@field_validator("product_category")
@classmethod
def _check_category(cls, value: str) -> str:
if value not in PRODUCT_CATEGORIES:
raise ValueError("产品类别不合法")
return value
@field_validator("quantity")
@classmethod
def _check_quantity(cls, value: str) -> str:
return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value
@field_validator("sale_price", "guided_price")
@classmethod
def _check_unit_prices(cls, value: str | None) -> str | None:
"""单价类字段按 DECIMAL(14,4) 口径校验(4 位小数)。"""
return _check_amount(value, "单价", PRICE_MAX, scale=4)
@field_validator("cost_amount")
@classmethod
def _check_cost(cls, value: str | None) -> str | None:
return _check_amount(value, "金额", AMOUNT_MAX)
class BusinessItemOut(BaseModel):
"""业务产品明细行响应。金额/数量一律 str。"""
model_config = ConfigDict(from_attributes=True)
id: int
product_id: int | None = None
product_name: str
spec_model: str | None = None
unit: str | None = None
product_category: str
quantity: str
sale_price: str | None = None
sale_amount: str
cost_amount: str | None = None
guided_price: str | None = None
remark: str | None = None
class BusinessOut(BaseModel):
"""业务响应(列表与写操作回包共用)。"""
model_config = ConfigDict(from_attributes=True)
id: int
business_no: str
customer_id: int
customer_name: str | None = None
business_date: date | None = None
status: int = 1
owner_personnel_id: int | None = None
owner_personnel_name: str | None = None
assistant_personnel_id: int | None = None
assistant_personnel_name: str | None = None
is_special: int = 0
special_note: str | None = None
total_sale_amount: str | None = None
total_cost_amount: str | None = None
total_ship_amount: str | None = None
total_received_amount: str | None = None
total_freight_amount: str | None = None
remark: str | None = None
class BusinessDetailOut(BusinessOut):
"""业务详情:附产品明细行。"""
items: list[BusinessItemOut] = Field(default_factory=list)
class BusinessCreateIn(BaseModel):
"""创建业务:主表信息 + 明细行数组(明细 ≥1 行)。
字段名全线蛇形直出(M1 既有契约:前端 api 封装与 schemas 同名对应,
仅分页信封 list/pageNum 驼峰)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
customer_id: int = Field(ge=1, description="客户ID")
business_date: date | None = Field(default=None, description="业务日期,ISO 格式")
owner_personnel_id: int | None = Field(
default=None, ge=1, description="负责营销人员ID,可空=待指定"
)
assistant_personnel_id: int | None = Field(default=None, ge=1, description="协助营销人员ID")
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
items: list[BusinessItemIn] = Field(min_length=1, description="产品明细行,至少 1 行")
remark: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def _check_special_note(self) -> "BusinessCreateIn":
"""特殊业务须有说明:标记的意义就在于可追溯「特殊在哪」。"""
if self.is_special == 1 and not self.special_note:
raise ValueError("特殊业务必须填写说明")
return self
class BusinessUpdateIn(BaseModel):
"""编辑业务(全量替换明细;归属不在此端点调整,走 /ownership)。"""
model_config = ConfigDict(str_strip_whitespace=True)
customer_id: int = Field(ge=1, description="客户ID")
business_date: date | None = Field(default=None, description="业务日期,ISO 格式")
owner_personnel_id: int | None = Field(
default=None,
ge=1,
description="负责营销人员ID(编辑端点忽略,仅 /ownership 生效)",
)
assistant_personnel_id: int | None = Field(
default=None,
ge=1,
description="协助营销人员ID(编辑端点忽略)",
)
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
items: list[BusinessItemIn] = Field(
min_length=1, description="产品明细行,至少 1 行(全量替换)"
)
remark: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def _check_special_note(self) -> "BusinessUpdateIn":
if self.is_special == 1 and not self.special_note:
raise ValueError("特殊业务必须填写说明")
return self
class BusinessStatusIn(BaseModel):
"""状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。"""
model_config = ConfigDict(str_strip_whitespace=True)
status: int = Field(ge=1, le=3, description="目标状态:2已完成 3已终止")
reason: str | None = Field(default=None, max_length=255, description="变更原因(终止时必填)")
@model_validator(mode="after")
def _check_reason(self) -> "BusinessStatusIn":
if self.status == 3 and not self.reason:
raise ValueError("终止业务必须填写原因")
return self
class BusinessOwnershipIn(BaseModel):
"""业务归属变更(负责/协助;前后值写入留痕)。"""
model_config = ConfigDict(str_strip_whitespace=True)
new_owner_personnel_id: int | None = Field(default=None, ge=1)
new_assistant_personnel_id: int | None = Field(default=None, ge=1)
reason: str | None = Field(default=None, max_length=255)
class BusinessQuery(PageQuery):
"""业务列表查询条件(筛选参数驼峰 alias,customer.py 同款契约)。"""
keyword: str | None = Field(default=None, max_length=32, description="业务编号/公司名称,模糊")
status: int | None = Field(default=None, ge=1, le=3, description="状态筛选")
customer_id: int | None = Field(default=None, ge=1, description="客户筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="负责营销人员筛选")
date_from: date | None = Field(default=None, description="业务日期起(含)")
date_to: date | None = Field(default=None, description="业务日期止(含)")
class GrossProfitRowOut(BaseModel):
"""毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。"""
item_id: int
product_category: str
product_name: str
spec_model: str | None = None
quantity: str
sale_amount: str
cost_amount: str | None = None
"""成本缺失(未录入)时为 None,派生列同为 None 并置 missing_reason"""
profit_amount: str | None = None
profit_rate: str | None = None
threshold: str
converted_profit: str | None = None
missing_reason: str | None = None
class GrossProfitCategoryOut(BaseModel):
"""按产品类别的毛利润汇总(可计算行聚合;成本缺失行不计入)。"""
product_category: str
sale_amount: str
cost_amount: str
profit_amount: str
profit_rate: str
threshold: str
converted_profit: str
class GrossProfitOut(BaseModel):
"""毛利润数据视图(需求 2.2.4):行级 + 类别汇总。
口径注释(前端须展示):毛利润率 = 毛利润额 / 销售额,**不含运费**
(对照表 #2 作废需规含运费公式);阈值按 asOf 取 M1 参数快照;M3 核算
实际按回款到账日取参,本视图默认今日仅供录入期核对。
"""
business_id: int
as_of: date
rows: list[GrossProfitRowOut] = Field(default_factory=list)
categories: list[GrossProfitCategoryOut] = Field(default_factory=list)
missing_count: int = 0
note: str = "毛利润率与折算额不含运费;M3 核算按回款到账日取参数版本"
class OwnershipChangeLogOut(BaseModel):
"""业务归属变更留痕(人员删除后姓名为 None,固化 ID 兜底可追溯)。"""
id: int
business_id: int
old_owner_personnel_id: int | None = None
new_owner_personnel_id: int | None = None
old_assistant_personnel_id: int | None = None
new_assistant_personnel_id: int | None = None
old_owner_name: str | None = None
new_owner_name: str | None = None
change_reason: str | None = None
operator_id: int | None = None
operator_name: str
create_time: datetime
class BusinessStatusLogOut(BaseModel):
"""业务状态流转记录(详情页时间线;创建首条 old=new=1)。"""
model_config = ConfigDict(from_attributes=True)
id: int
business_id: int
old_status: int
new_status: int
change_reason: str | None = None
operator_id: int | None = None
operator_name: str
create_time: datetime
# 供 schema 外部引用的两类别常量(类别汇总展示用)
CATEGORY_LABELS = {
PRODUCT_CATEGORY_MUNICIPAL_WATER: "市政水利",
PRODUCT_CATEGORY_RAIL_TRANSIT: "轨道交通",
}
+135
View File
@@ -0,0 +1,135 @@
"""客户管理的请求/响应模型(需求 2.1.3)。"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.core.response import PageQuery
class CustomerOut(BaseModel):
"""客户响应(列表、详情与写操作回包共用)。
owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的
脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕
「ID 固化、姓名随联表」同一口径。
"""
model_config = ConfigDict(from_attributes=True)
id: int
company_name: str
owner_name: str
contact_person: str | None = None
contact_phone: str | None = None
address: str | None = None
industry: str | None = None
customer_type: str
owner_personnel_id: int | None = None
owner_personnel_name: str | None = None
remark: str | None = None
class CustomerCreateIn(BaseModel):
"""新增客户。
owner_personnel_id 为**初始归属**,可空(客户先建档、后派归属也合法);
归属的后续调整不走本模型,统一走 ``PUT /{id}/ownership``(见
``CustomerUpdateIn`` 的说明)。字段名蛇形直出(M1 契约:前端 api 封装
与 schema 同名对应,仅分页信封 list/pageNum 驼峰)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
company_name: str = Field(min_length=1, max_length=128, description="公司名称")
owner_name: str = Field(
min_length=1,
max_length=64,
description="客户负责人姓名(客户公司的负责人人名,非系统内营销人员)",
)
contact_person: str | None = Field(default=None, max_length=64, description="联系人")
contact_phone: str | None = Field(default=None, max_length=20, description="联系电话")
address: str | None = Field(default=None, max_length=255, description="地址")
industry: str | None = Field(default=None, max_length=64, description="所属行业")
customer_type: str = Field(
min_length=1,
max_length=32,
description="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户",
)
owner_personnel_id: int | None = Field(
default=None,
ge=1,
description="归属负责营销人员ID(初始归属,可空)",
)
remark: str | None = Field(default=None, max_length=255, description="备注")
class CustomerUpdateIn(BaseModel):
"""编辑客户(全量替换,前端表单全量提交)。
**不含 owner_personnel_id** —— 归属调整统一走 ``PUT /{id}/ownership``,
只有那条路径才写归属变更留痕。若在本端点放开该字段,会出现「改了归属
却无留痕」的旁路,破坏留痕的 append-only 完整性;编辑表单即使提交
ownerPersonnelId,pydantic 默认忽略未声明字段,归属以归属端点为准。
"""
model_config = ConfigDict(str_strip_whitespace=True)
company_name: str = Field(min_length=1, max_length=128, description="公司名称")
owner_name: str = Field(min_length=1, max_length=64, description="客户负责人姓名")
contact_person: str | None = Field(default=None, max_length=64, description="联系人")
contact_phone: str | None = Field(default=None, max_length=20, description="联系电话")
address: str | None = Field(default=None, max_length=255, description="地址")
industry: str | None = Field(default=None, max_length=64, description="所属行业")
customer_type: str = Field(
min_length=1,
max_length=32,
description="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户",
)
remark: str | None = Field(default=None, max_length=255, description="备注")
class CustomerQuery(PageQuery):
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。"""
keyword: str | None = Field(
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
)
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
class CustomerOwnershipIn(BaseModel):
"""归属变更入参。
new_owner_personnel_id 可空:null 表示清空归属(客户暂无负责人也合法)。
"""
new_owner_personnel_id: int | None = Field(
default=None,
ge=1,
description="变更后归属人员ID,null=清空归属",
)
reason: str | None = Field(default=None, max_length=255, description="变更原因")
class OwnershipChangeLogOut(BaseModel):
"""归属变更留痕响应。
新旧归属人员姓名由 LEFT JOIN 带出,人员被删除后为 null;固化的
``*_personnel_id`` 始终原样返回 —— 留痕表对人员 ID 无外键固化
(见 `models/customer.py` 的论证),姓名可随人员消失,ID 不可。
"""
model_config = ConfigDict(from_attributes=True)
id: int
customer_id: int
old_owner_personnel_id: int | None = None
new_owner_personnel_id: int | None = None
old_owner_name: str | None = None
new_owner_name: str | None = None
change_reason: str | None = None
operator_id: int | None = None
operator_name: str
create_time: datetime
+149
View File
@@ -0,0 +1,149 @@
"""营销人员的请求/响应模型(需求 2.1.1)。"""
from datetime import date
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
# salary_standard 列为 Numeric(12,2):整数部分最多 10 位。
# 工资系统金额红线——入参出参一律 str,服务层转 Decimal,全程不经 float
SALARY_MAX = Decimal("9999999999.99")
class PersonnelRegionOut(BaseModel):
"""辐射区域条目响应。"""
model_config = ConfigDict(from_attributes=True)
id: int
region_name: str
remark: str | None = None
class PersonnelPlanOut(BaseModel):
"""拓展计划条目响应。"""
model_config = ConfigDict(from_attributes=True)
id: int
plan_period: str
plan_content: str
target_desc: str | None = None
class PersonnelOut(BaseModel):
"""人员响应(列表与写操作回包共用)。"""
model_config = ConfigDict(from_attributes=True)
id: int
personnel_name: str
business_role_id: int
role_name: str | None = None
manager_id: int | None = None
manager_name: str | None = None
phone: str | None = None
hire_date: date | None = None
is_probation: int = 0
salary_standard: str | None = None
status: int = 1
remark: str | None = None
class PersonnelDetailOut(PersonnelOut):
"""人员详情:附区域与拓展计划,供编辑抽屉一次回显。"""
regions: list[PersonnelRegionOut] = Field(default_factory=list)
plans: list[PersonnelPlanOut] = Field(default_factory=list)
class PersonnelCreateIn(BaseModel):
"""新增人员。"""
model_config = ConfigDict(str_strip_whitespace=True)
personnel_name: str = Field(min_length=1, max_length=64, description="姓名")
business_role_id: int = Field(ge=1, description="岗位角色ID")
manager_id: int | None = Field(default=None, ge=1, description="直属营销经理ID,空=无")
phone: str | None = Field(default=None, max_length=20, description="联系电话")
hire_date: date | None = Field(default=None, description="入职日期,ISO 格式")
is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否")
salary_standard: str | None = Field(
default=None, description="月工资标准(元),字符串形式的数字"
)
remark: str | None = Field(default=None, max_length=255)
@field_validator("salary_standard")
@classmethod
def _check_salary_standard(cls, value: str | None) -> str | None:
"""按 Numeric(12,2) 的容量校验字符串金额。
金额以字符串进出避免 float 精度损失(项目金额红线);
小数位超过两位直接拒绝而非静默舍入——舍入即算错钱。
"""
if value is None:
return None
try:
amount = Decimal(value)
except InvalidOperation:
raise ValueError("工资标准须为有效数字") from None
if not amount.is_finite():
raise ValueError("工资标准须为有效数字")
if amount < 0:
raise ValueError("工资标准不能为负数")
if amount > SALARY_MAX:
raise ValueError("工资标准超出可录入上限")
if -amount.as_tuple().exponent > 2:
raise ValueError("工资标准最多两位小数")
return value
class PersonnelUpdateIn(PersonnelCreateIn):
"""编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。"""
class PersonnelStatusIn(BaseModel):
"""在职/离职切换。"""
status: int = Field(ge=0, le=1, description="1在职 0离职")
class RegionItemIn(BaseModel):
"""辐射区域条目。"""
model_config = ConfigDict(str_strip_whitespace=True)
region_name: str = Field(min_length=1, max_length=128, description="区域名称")
remark: str | None = Field(default=None, max_length=255)
class RegionReplaceIn(BaseModel):
"""辐射区域全量替换。空列表即清空该人员的全部区域。"""
items: list[RegionItemIn] = Field(default_factory=list, description="全量条目")
class PlanItemIn(BaseModel):
"""拓展计划条目。"""
model_config = ConfigDict(str_strip_whitespace=True)
plan_period: str = Field(min_length=1, max_length=32, description="计划期间,如 2026Q1")
plan_content: str = Field(min_length=1, max_length=500, description="计划内容")
target_desc: str | None = Field(default=None, max_length=255, description="指标说明")
class PlanReplaceIn(BaseModel):
"""拓展计划全量替换。空列表即清空该人员的全部计划。"""
items: list[PlanItemIn] = Field(default_factory=list, description="全量条目")
class PersonnelQuery(PageQuery):
"""人员列表查询条件。"""
keyword: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选:1在职 0离职")
business_role_id: int | None = Field(default=None, ge=1, description="岗位筛选")
+97
View File
@@ -0,0 +1,97 @@
"""产品管理的请求/响应模型。
数量与单价(stock_quantity/sale_price)入参出参一律用**字符串**承载:
前端 JS 的 number 无法精确表示全部十进制小数,字符串是金额红线的入口闸门,
Decimal 转换统一收敛在 service 层。字段命名与 user/role/param 一致采用
蛇形直出(前端 TS 接口同构)。
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount
class ProductOut(BaseModel):
"""产品响应(列表与详情共用)。数量/单价按红线以字符串出参。"""
id: int
product_name: str
spec_model: str
unit: str | None = None
stock_quantity: str | None = None
sale_price: str | None = None
product_category: str
status: int = 1
remark: str | None = None
create_time: datetime
update_time: datetime
class ProductCreateIn(BaseModel):
"""新增产品。
spec_model 允许缺省/空:service 层归一为空串(组合唯一键
uk_products_name_spec 对 NULL 不去重,归 NULL 会放行重名同规格数据)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
product_name: str = Field(min_length=1, max_length=128, description="产品名称")
spec_model: str | None = Field(
default=None, max_length=128, description="规格型号,空归一为空串"
)
unit: str | None = Field(default=None, max_length=16, description="计量单位")
stock_quantity: str | None = Field(default=None, description="数量,非负数字字符串")
sale_price: str | None = Field(default=None, description="销售单价(元),非负数字字符串")
product_category: str = Field(min_length=1, max_length=32, description="产品类别")
remark: str | None = Field(default=None, max_length=255)
@field_validator("stock_quantity")
@classmethod
def _check_stock_quantity(cls, value: str | None) -> str | None:
"""空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
复用 _check_amount:容量/小数位与 DECIMAL(14,2) 列口径一致
(此前私写的 _parse_non_negative 不查小数位与容量,属同语义
字段两处口径漂移)。
"""
if not value:
return None
return _check_amount(value, "数量", AMOUNT_MAX)
@field_validator("sale_price")
@classmethod
def _check_sale_price(cls, value: str | None) -> str | None:
"""空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
scale=4 与列口径 Numeric(14,4) 一致 —— 带精度的历史单价必须能
原样提交(「编辑任何带精度历史行都失败」的 fba8cfc 教训)。
"""
if not value:
return None
return _check_amount(value, "销售单价", PRICE_MAX, scale=4)
class ProductUpdateIn(ProductCreateIn):
"""编辑产品。
前端表单全量提交,故为全量替换语义;**不含 status** —— 启用/停用是
独立操作(走 PUT /{id}/status),避免编辑表单误触停用。
"""
class ProductStatusIn(BaseModel):
"""启用/停用产品。"""
status: int = Field(ge=0, le=1, description="1启用 0停用")
class ProductQuery(PageQuery):
"""产品列表查询条件。"""
keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配")
product_category: str | None = Field(default=None, max_length=32, description="按类别筛选")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
+79
View File
@@ -0,0 +1,79 @@
"""客户评级等级配置的请求/响应模型。
分值(min_score/max_score)入参出参一律用**字符串**承载:前端 JS 的 number
无法精确表示全部十进制小数,字符串是分值红线的入口闸门,Decimal 转换统一
收敛在 service 层(与 schemas/product.py 同一口径)。
注意:schema 只拦**格式**(可解析、有限数);0≤min≤max≤100 与区间不重叠
是横跨两字段/多行的业务值域规则,在 service 层审计上下文内统一判定 ——
值域校验失败的 400 也要留痕(2026-10-13 定稿口径)。
"""
from datetime import datetime
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, ConfigDict, Field, field_validator
def _parse_score(value: str, *, label: str) -> str:
"""分值字符串格式校验:必须可被 Decimal 解析且为有限数。
``NaN`` 参与比较抛 InvalidOperation、``Infinity`` 落库即溢出,均须在
入口用 ``is_finite()`` 拦下(与 schemas/product.py 的 _parse_non_negative
同口径);非负与 ≤100 属业务值域,留给 service 层在审计上下文内判定。
"""
try:
score = Decimal(value.strip())
except InvalidOperation as exc:
raise ValueError(f"{label}必须是有效数字:{value}") from exc
if not score.is_finite():
raise ValueError(f"{label}必须为有限数字:{value}")
return value
class RatingGradeOut(BaseModel):
"""评级等级响应(列表直出)。分值按红线以字符串出参。"""
id: int
grade_name: str
min_score: str
max_score: str
sort_order: int
remark: str | None = None
create_time: datetime
update_time: datetime
class RatingGradeCreateIn(BaseModel):
"""新增评级等级。
min_score/max_score 必填:区间是判定表的判定依据,缺省无从判定。
sort_order 缺省 0(列默认值同源),判定按其升序取首个命中等级。
"""
model_config = ConfigDict(str_strip_whitespace=True)
grade_name: str = Field(min_length=1, max_length=32, description="等级名称(如 A/优秀)")
min_score: str = Field(description="总分下限(含),0-100 数字字符串")
max_score: str = Field(description="总分上限(展示用),0-100 数字字符串")
sort_order: int = Field(default=0, ge=0, description="判定顺序,升序")
remark: str | None = Field(default=None, max_length=255, description="说明")
@field_validator("min_score")
@classmethod
def _check_min_score(cls, value: str) -> str:
"""总分下限须为有限数字(值域 0-100 由 service 层判定)。"""
return _parse_score(value, label="总分下限")
@field_validator("max_score")
@classmethod
def _check_max_score(cls, value: str) -> str:
"""总分上限须为有限数字(值域 0-100 由 service 层判定)。"""
return _parse_score(value, label="总分上限")
class RatingGradeUpdateIn(RatingGradeCreateIn):
"""编辑评级等级。
前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。
"""
+102
View File
@@ -0,0 +1,102 @@
"""回款管理的请求/响应模型(需求 2.2.3)。
金额入参出参一律 str(服务层 Decimal,全程禁 float);字段契约**纯蛇形
直出**(2026-10-13 定稿,无驼峰 alias)。
payment_days 是派生值(发货日 → 回款日的天数,对照表 #1),不在写接口
入参内 —— 落库列只有 shipment_id 源数据,派生值实时计算,发货批次变更
后无需回写历史。
"""
from datetime import date
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
# 金额校验是工资系统的红线口径(数字/非负/容量/两位小数拒绝),跨 schema
# 单点复用 T7 定稿工具,禁止各处复制导致口径漂移;AMOUNT_MAX 即
# DECIMAL(14,2) 的容量上限,与回款列容量同源
from app.schemas.business import AMOUNT_MAX, _check_amount
class ReceiptCreateIn(BaseModel):
"""新增回款:业务必选,发货批次可暂不指定(手动指定,对照表 #18)。
shipment_id 指向的发货单必须与本回款同属一个业务(服务层强校验,
数据完整性红线);shipment_id 可空 = 暂未指定,指定后回款天数方可派生。
"""
model_config = ConfigDict(str_strip_whitespace=True)
business_id: int = Field(ge=1, description="业务ID")
shipment_id: int | None = Field(default=None, ge=1, description="发货单ID,可空=暂未指定")
receipt_date: date = Field(description="回款到账日(M3 按此日匹配参数版本)")
amount: str = Field(description="回款金额(元),≥0,最多两位小数")
is_barter: int = Field(default=0, ge=0, le=1, description="以物抵顶标记:1是 0否")
remark: str | None = Field(default=None, max_length=255, description="备注")
@field_validator("amount")
@classmethod
def _check_amount_value(cls, value: str) -> str:
return _check_amount(value, "回款金额", AMOUNT_MAX) or value
class ReceiptUpdateIn(BaseModel):
"""编辑回款(全量替换语义)。
shipment_id 传 None = 清除指定(回到「暂未指定」);amount 是必填项,
全量替换下不存在「不传即不变」的歧义。已核算(settlement_status=1)
的回款在服务层禁改,schema 层不感知该状态。
"""
model_config = ConfigDict(str_strip_whitespace=True)
shipment_id: int | None = Field(
default=None, ge=1, description="发货单ID,可空=暂未指定(传空清除既有指定)"
)
receipt_date: date = Field(description="回款到账日")
amount: str = Field(description="回款金额(元),≥0,最多两位小数")
is_barter: int = Field(default=0, ge=0, le=1, description="以物抵顶标记:1是 0否")
remark: str | None = Field(default=None, max_length=255, description="备注")
@field_validator("amount")
@classmethod
def _check_amount_value(cls, value: str) -> str:
return _check_amount(value, "回款金额", AMOUNT_MAX) or value
class ReceiptOut(BaseModel):
"""回款响应(列表与写操作回包共用)。
business_no/shipment_no 为关联冗余展示;payment_days 为派生值:
未指定发货批次时为 None,指定后 = 发货日到回款日的天数(发货日首日
计第 1 天)。
"""
model_config = ConfigDict(from_attributes=True)
id: int
receipt_batch_no: str
business_id: int
business_no: str
shipment_id: int | None = None
shipment_no: str | None = None
receipt_date: date
amount: str
is_barter: int
settlement_status: int
payment_days: int | None = None
remark: str | None = None
class ReceiptQuery(PageQuery):
"""回款列表查询条件(筛选参数纯蛇形直出,同 BusinessQuery 契约)。"""
business_id: int | None = Field(default=None, ge=1, description="业务筛选")
date_from: date | None = Field(default=None, description="回款到账日起(含)")
date_to: date | None = Field(default=None, description="回款到账日止(含)")
is_barter: int | None = Field(default=None, ge=0, le=1, description="以物抵顶筛选:1是 0否")
settlement_status: int | None = Field(
default=None, ge=0, le=1, description="核算状态筛选:0未核算 1已核算"
)
+138
View File
@@ -0,0 +1,138 @@
"""发货管理的请求/响应模型(需求 2.2.2)。
金额/数量入参出参一律 str(服务层 Decimal 运算,全程禁 float),容量与
小数位校验复用 business.py 的 ``_check_amount``(同一套口径,防两处漂移);
字段契约全线蛇形直出(2026-10-13 定稿:无驼峰 alias)。
amount(行金额)与 total_amount(发货总金额)不在入参内 —— 由服务层按
数量×单价 计算固化,不信任前端传值;运费仅记录展示,不计入毛利润口径。
"""
from datetime import date
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount
class ShipmentItemIn(BaseModel):
"""发货产品明细行(product_id 空=自由录入;价格口径两可,可空人工补)。"""
model_config = ConfigDict(str_strip_whitespace=True)
product_id: int | None = Field(default=None, ge=1, description="产品ID;空=自由录入")
product_name: str = Field(min_length=1, max_length=128, description="产品名称")
spec_model: str | None = Field(
default=None, max_length=128, description="规格型号,空=空串归一"
)
unit: str | None = Field(default=None, max_length=16, description="计量单位")
quantity: str = Field(description="数量,>0")
sale_price: str | None = Field(
default=None, description="发货单价(元),可空=待人工补价,补价前行金额记 0"
)
@field_validator("quantity")
@classmethod
def _check_quantity(cls, value: str) -> str:
return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value
@field_validator("sale_price")
@classmethod
def _check_price(cls, value: str | None) -> str | None:
"""发货单价列 DECIMAL(14,4):按 4 位小数口径(回显可原样提交)。"""
return _check_amount(value, "单价", PRICE_MAX, scale=4)
class ShipmentItemOut(BaseModel):
"""发货产品明细行响应。金额/数量一律 str。"""
model_config = ConfigDict(from_attributes=True)
id: int
product_id: int | None = None
product_name: str
spec_model: str | None = None
unit: str | None = None
quantity: str
sale_price: str | None = None
amount: str
class ShipmentOut(BaseModel):
"""发货单响应(列表与写操作回包共用);business_no 为 join 附带列。"""
model_config = ConfigDict(from_attributes=True)
id: int
shipment_no: str
business_id: int
business_no: str | None = None
ship_date: date
freight_amount: str
total_amount: str
remark: str | None = None
class ShipmentDetailOut(ShipmentOut):
"""发货单详情:附产品明细行。"""
items: list[ShipmentItemOut] = Field(default_factory=list)
class ShipmentCreateIn(BaseModel):
"""创建发货单:业务维度 + 明细行数组(明细 ≥1 行)。
字段名全线蛇形直出(同 business.py 契约,仅分页信封 list/pageNum 驼峰)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
business_id: int = Field(ge=1, description="业务ID")
ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)")
freight_amount: str | None = Field(
default=None, description="运费金额(元),空记 0;仅记录展示,不计入毛利润口径"
)
items: list[ShipmentItemIn] = Field(min_length=1, description="发货明细行,至少 1 行")
remark: str | None = Field(default=None, max_length=255)
@field_validator("freight_amount")
@classmethod
def _check_freight(cls, value: str | None) -> str | None:
"""运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。"""
return _check_amount(value, "运费金额", AMOUNT_MAX)
class ShipmentUpdateIn(BaseModel):
"""编辑发货单(明细全量替换)。
business_id 不在编辑入参内 —— 发货单挂靠的业务创建时定死,改挂会让
已指定本发货单的回款语义断裂。
"""
model_config = ConfigDict(str_strip_whitespace=True)
ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)")
freight_amount: str | None = Field(default=None, description="运费金额(元),空记 0")
items: list[ShipmentItemIn] = Field(
min_length=1, description="发货明细行,至少 1 行(全量替换)"
)
remark: str | None = Field(default=None, max_length=255)
@field_validator("freight_amount")
@classmethod
def _check_freight(cls, value: str | None) -> str | None:
"""运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。"""
return _check_amount(value, "运费金额", AMOUNT_MAX)
class ShipmentQuery(PageQuery):
"""发货单列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
business_id 可空=全量:发货单以业务维度查看为主(一期),但单号可
独立检索,不强制挂业务条件。
"""
business_id: int | None = Field(default=None, ge=1, description="业务筛选,空=全部")
ship_date_from: date | None = Field(default=None, description="发货日期起(含)")
ship_date_to: date | None = Field(default=None, description="发货日期止(含)")
+762
View File
@@ -0,0 +1,762 @@
"""业务管理业务编排(需求 2.2.1/2.2.4)。
事务边界在本层:repository 只 flush,commit/rollback 只写在这里。
- 业务编号自动生成(no_generator.next_no):MAX+FOR UPDATE 锁当日序号区间,
残余竞态以唯一键(1062)冲突重试一次兜底
- 五项汇总冗余(total_sale/cost/ship/received/freight)由 ``_recalc_totals``
单点重算 —— 明细/发货/回款任何写路径收口到此函数,禁止各处手写
- 状态机:1执行中 → 2已完成 / 3已终止;终态不可再变更(一期口径,开放问题 #8);
终止必填原因;每次流转写 business_status_logs(创建写首条 old=new=1)
- 归属变更(负责/协助)写 ownership_change_logs(owner_type=2),同事务一次
commit;只影响变更后核算,不回溯历史(对照表 #19)
- 毛利润视图为只读派生值(复用 formula.profit_rate/converted_profit),
阈值按 asOf 取 M1 参数快照 —— 落库即漂移,M3 台账才是固化点
"""
import logging
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from sqlalchemy import Row, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_UPDATE,
MODULE_BUSINESS,
NO_PREFIX_BUSINESS,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageQuery, PageResult
from app.models import (
Business,
BusinessItem,
BusinessStatusLog,
Customer,
OwnershipChangeLog,
Personnel,
Receipt,
Shipment,
User,
)
from app.models.business import (
BUSINESS_STATUS_ACTIVE,
BUSINESS_STATUS_DONE,
)
from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import business_repository, customer_repository
from app.schemas.business import (
BusinessCreateIn,
BusinessDetailOut,
BusinessItemIn,
BusinessItemOut,
BusinessOut,
BusinessOwnershipIn,
BusinessQuery,
BusinessStatusIn,
BusinessStatusLogOut,
BusinessUpdateIn,
GrossProfitCategoryOut,
GrossProfitOut,
GrossProfitRowOut,
OwnershipChangeLogOut,
)
from app.services import audit_service, param_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import converted_profit, profit_rate
from app.services.no_generator import next_no
logger = logging.getLogger(__name__)
# 编号唯一键冲突后的重试次数:MAX+FOR UPDATE 已覆盖绝大多数竞态,
# 重试是为极端场景(锁等待超时后重入)兜底 —— 无限重试反而掩盖死锁类缺陷
_NO_RETRY = 1
def _to_out(row: tuple) -> BusinessOut:
"""(business, company_name, owner_name, assistant_name) → 响应模型。
金额冗余列出参 str;汇总列可能为 NULL(server_default 只对 INSERT 生效,
存量行可能为 NULL),统一转 "0" 保持契约稳定。
"""
business, company_name, owner_name, assistant_name = row
return BusinessOut(
id=business.id,
business_no=business.business_no,
customer_id=business.customer_id,
customer_name=company_name,
business_date=business.business_date,
status=business.status,
owner_personnel_id=business.owner_personnel_id,
owner_personnel_name=owner_name,
assistant_personnel_id=business.assistant_personnel_id,
assistant_personnel_name=assistant_name,
is_special=business.is_special,
special_note=business.special_note,
total_sale_amount=_str_or_zero(business.total_sale_amount),
total_cost_amount=_str_or_zero(business.total_cost_amount),
total_ship_amount=_str_or_zero(business.total_ship_amount),
total_received_amount=_str_or_zero(business.total_received_amount),
total_freight_amount=_str_or_zero(business.total_freight_amount),
remark=business.remark,
)
def _str_or_zero(value: Decimal | None) -> str:
return str(value) if value is not None else "0"
def _item_to_out(item: BusinessItem) -> BusinessItemOut:
return BusinessItemOut(
id=item.id,
product_id=item.product_id,
product_name=item.product_name,
spec_model=item.spec_model,
unit=item.unit,
product_category=item.product_category,
quantity=str(item.quantity),
sale_price=str(item.sale_price) if item.sale_price is not None else None,
sale_amount=str(item.sale_amount),
cost_amount=str(item.cost_amount) if item.cost_amount is not None else None,
guided_price=str(item.guided_price) if item.guided_price is not None else None,
remark=item.remark,
)
def _build_item_rows(business_id: int, items: list[BusinessItemIn]) -> list[BusinessItem]:
"""入参明细行转 ORM 行:sale_amount 服务层按 数量×单价 计算固化。
不信任前端传值(schema 亦无 sale_amount 入参)——单价可空时销售额记 0,
由用户在编辑中补价后重算(毛利润视图中销售额 0 的行全部为 0,防除零)。
乘积显式 quantize 两位固化:数量(2位)×单价(4位) 可产 6 位小数,直写
DECIMAL(14,2) 会被 MySQL 静默舍入且回显漂移(创建响应读内存值、复查
读库值前后不一致);HALF_UP 与 MySQL 的 DECIMAL 舍入方向一致。
"""
rows: list[BusinessItem] = []
for item in items:
quantity = Decimal(item.quantity)
sale_price = Decimal(item.sale_price) if item.sale_price is not None else None
sale_amount = (
(quantity * sale_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if sale_price is not None
else Decimal("0.00")
)
rows.append(
BusinessItem(
business_id=business_id,
product_id=item.product_id,
product_name=item.product_name,
spec_model=item.spec_model or "",
unit=item.unit,
product_category=item.product_category,
quantity=quantity,
sale_price=sale_price,
sale_amount=sale_amount,
cost_amount=Decimal(item.cost_amount) if item.cost_amount is not None else None,
guided_price=Decimal(item.guided_price) if item.guided_price is not None else None,
remark=item.remark,
)
)
return rows
def _recalc_totals(session: Session, business: Business) -> None:
"""重算业务的五项汇总冗余(明细/发货/回款写路径统一收口)。
五条独立聚合查询(各表单列 SUM,走 business_id 索引)——业务单据量级
为单业务数行,不值得为省查询做单大 JOIN。
"""
sale = session.execute(
_sum(BusinessItem.sale_amount, BusinessItem.business_id, business.id)
).scalar_one()
cost = session.execute(
_sum(BusinessItem.cost_amount, BusinessItem.business_id, business.id)
).scalar_one()
ship = session.execute(_sum_ship_items(business.id)).scalar_one()
freight = session.execute(
_sum(Shipment.freight_amount, Shipment.business_id, business.id)
).scalar_one()
received = session.execute(_sum(Receipt.amount, Receipt.business_id, business.id)).scalar_one()
business.total_sale_amount = sale or Decimal("0")
business.total_cost_amount = cost or Decimal("0")
business.total_ship_amount = ship or Decimal("0")
business.total_freight_amount = freight or Decimal("0")
business.total_received_amount = received or Decimal("0")
def _sum(column, match_column, value) -> object:
return select(func.sum(column)).where(match_column == value)
def _sum_ship_items(business_id: int):
"""发货明细行金额合计(经发货单关联到业务)。"""
from app.models import ShipmentItem
return (
select(func.sum(ShipmentItem.amount))
.select_from(ShipmentItem)
.join(Shipment, Shipment.id == ShipmentItem.shipment_id)
.where(Shipment.business_id == business_id)
)
def _generate_no(session: Session) -> str:
"""生成业务编号(MAX+FOR UPDATE;1062 兜底重试一次,见模块 docstring)。"""
return next_no(session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS)
def _ensure_personnel_active(session: Session, personnel_id: int, label: str) -> Personnel:
"""归属人员须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
return personnel_service.ensure_active(session, personnel_id, label=label)
def _ensure_customer(session: Session, customer_id: int) -> Customer:
customer = customer_repository.get_by_id(session, customer_id)
if customer is None:
raise NotFoundError("客户不存在")
return customer
def _is_duplicate_entry(exc: IntegrityError) -> bool:
return is_duplicate_entry(exc)
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
"""分页查询业务(附客户名与归属人名)。"""
rows = business_repository.list_businesses(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
customer_id=query.customer_id,
owner_personnel_id=query.owner_personnel_id,
date_from=query.date_from,
date_to=query.date_to,
)
total = business_repository.count_businesses(
session,
keyword=query.keyword,
status=query.status,
customer_id=query.customer_id,
owner_personnel_id=query.owner_personnel_id,
date_from=query.date_from,
date_to=query.date_to,
)
return PageResult[BusinessOut](
records=[_to_out(row) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
"""业务详情(附明细行,供编辑抽屉回显)。
:raises NotFoundError: 业务不存在
"""
row = business_repository.get_with_names(session, business_id)
if row is None:
raise NotFoundError("业务不存在")
out = BusinessDetailOut(**_to_out(row).model_dump())
out.items = [
_item_to_out(item) for item in business_repository.list_items(session, business_id)
]
return out
def create_business(
session: Session,
payload: BusinessCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessOut:
"""创建业务 + 明细行 + 首条状态留痕,一个事务。
创建时指定的归属不写归属留痕(留痕记「变更」,创建即初始事实)。
:raises NotFoundError: 客户不存在
:raises BadRequestError: 归属人员不存在或已离职 / 明细校验不通过
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_CREATE,
summary=f"创建业务(客户ID={payload.customer_id},明细 {len(payload.items)} 行)",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
_ensure_customer(session, payload.customer_id)
if payload.owner_personnel_id is not None:
_ensure_personnel_active(session, payload.owner_personnel_id, "负责")
if payload.assistant_personnel_id is not None:
_ensure_personnel_active(session, payload.assistant_personnel_id, "协助")
# 编号唯一键冲突的重试整体收在审计上下文内:rollback 后同一事务重来,
# 成功/最终失败各只记一条审计(吞异常 return 会被记成假成功,重入
# 整个 create_* 则违反「不支持嵌套」约定);上限 _NO_RETRY 防无限重试
# 掩盖死锁类缺陷。审计摘要预取的是首次编号——重试成功时入库编号见
# 应用日志(AuditRecord frozen,不回填)。
for attempt in range(_NO_RETRY + 1):
try:
business = Business(
business_no=_generate_no(session),
customer_id=payload.customer_id,
business_date=payload.business_date,
status=BUSINESS_STATUS_ACTIVE,
owner_personnel_id=payload.owner_personnel_id,
assistant_personnel_id=payload.assistant_personnel_id,
is_special=payload.is_special,
special_note=payload.special_note,
remark=payload.remark,
)
business_repository.create(session, business)
business_repository.replace_items(
session, business.id, _build_item_rows(business.id, payload.items)
)
_recalc_totals(session, business)
business_repository.add_status_log(
session,
BusinessStatusLog(
business_id=business.id,
old_status=BUSINESS_STATUS_ACTIVE,
new_status=BUSINESS_STATUS_ACTIVE,
operator_id=operator.id,
operator_name=operator.username,
),
)
customer = customer_repository.get_by_id(session, business.customer_id)
out = _to_out((business, customer.company_name if customer else None, None, None))
new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM
session.commit()
break
except IntegrityError as exc:
session.rollback()
if _is_duplicate_entry(exc) and attempt < _NO_RETRY:
continue
raise
logger.info("创建业务 | business_id=%s | no=%s", new_id, new_no)
return out
def update_business(
session: Session,
business_id: int,
payload: BusinessUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessOut:
"""编辑业务:全量替换明细 + 重算汇总;归属不在此端点(防旁路)。
客户可改,但有发货或回款记录后禁改 —— 单据 FK 挂在业务上,换客户会让
发货/回款语义断裂(明细同理随业务走)。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 业务已完成或已终止 / 归属人员不合法 / 有发货或回款时换客户
"""
row = business_repository.get_with_names(session, business_id, for_update=True)
business = row[0] if row else None
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_UPDATE,
summary=f"修改业务 {business.business_no if business else f'#{business_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if row is None:
raise NotFoundError("业务不存在")
business, _, owner_name, assistant_name = row
# 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线,
# 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务)
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止编辑业务")
if payload.customer_id != business.customer_id and business_repository.has_docs(
session, business_id
):
raise BadRequestError("业务已有发货或回款记录,不能更换客户")
customer = _ensure_customer(session, payload.customer_id)
if payload.owner_personnel_id is not None:
_ensure_personnel_active(session, payload.owner_personnel_id, "负责")
if payload.assistant_personnel_id is not None:
_ensure_personnel_active(session, payload.assistant_personnel_id, "协助")
business.customer_id = payload.customer_id
business.business_date = payload.business_date
business.is_special = payload.is_special
business.special_note = payload.special_note
business.remark = payload.remark
# 归属刻意不赋值:留痕只由 /ownership 端点产生,编辑路径改归属构成
# 「改归属不留痕」的旁路(与 customers 编辑不触归属同一口径)
business_repository.replace_items(
session, business_id, _build_item_rows(business_id, payload.items)
)
_recalc_totals(session, business)
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
out = _to_out((business, customer.company_name, owner_name, assistant_name))
session.commit()
logger.info("修改业务 | business_id=%s", business_id)
return out
def delete_business(
session: Session,
business_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除业务(物理删除):有发货/回款 → 400 引导走「已终止」。
无单据引用的业务(建错的空单)方可删除;删除时连带清理明细、状态留痕
与业务维度的归属留痕(无外键固化表,须手动清)。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 存在发货或回款记录
"""
business = business_repository.get_by_id(session, business_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_DELETE,
summary=f"删除业务 {business.business_no if business else f'#{business_id}'}",
),
meta=meta,
):
if business is None:
raise NotFoundError("业务不存在")
if business_repository.has_docs(session, business_id):
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
business_no = business.business_no
_hard_delete(session, business_id)
session.commit()
logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no)
def _hard_delete(session: Session, business_id: int) -> None:
"""按依赖序删除:明细 → 状态留痕 → 业务维度归属留痕 → 主表。"""
from sqlalchemy import delete
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
session.execute(delete(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id))
session.execute(
delete(OwnershipChangeLog).where(
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
OwnershipChangeLog.business_id == business_id,
)
)
session.execute(delete(Business).where(Business.id == business_id))
def change_status(
session: Session,
business_id: int,
payload: BusinessStatusIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessOut:
"""状态流转:1→2 / 1→3,终态不可再变更;每次流转写留痕。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 非执行中状态流转 / 目标与当前相同
"""
row = business_repository.get_with_names(session, business_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_UPDATE,
summary=(
f"业务状态变更 {row[0].business_no if row else f'#{business_id}'} → "
f"{'已完成' if payload.status == BUSINESS_STATUS_DONE else '已终止'}"
),
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if row is None:
raise NotFoundError("业务不存在")
business, _, _, _ = row
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("已完成或已终止的业务不可再变更状态")
if business.status == payload.status:
raise BadRequestError("状态未变化")
# 变更前状态须在赋值前固化:ORM 属性赋值后 old_status 就取不到旧值
# (留痕会记成 old==new 失真,时间线无法回溯)
old_status = business.status
business.status = payload.status
business_repository.add_status_log(
session,
BusinessStatusLog(
business_id=business_id,
old_status=old_status,
new_status=payload.status,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
),
)
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新状态
out = _to_out(row)
session.commit()
logger.info(
"业务状态变更 | business_id=%s | %s -> %s",
business_id,
old_status,
payload.status,
)
return out
def change_ownership(
session: Session,
business_id: int,
payload: BusinessOwnershipIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessOut:
"""归属变更(负责/协助):同事务「更新主表 + 插留痕」,一次 commit。
负责与协助之一发生变化即记一条留痕(前后值都含两个字段);两者均未变化
→ 400「归属未变化」。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 归属人员不存在或已离职 / 归属未变化
"""
row = business_repository.get_with_names(session, business_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_UPDATE,
summary=f"调整业务归属 {row[0].business_no if row else f'#{business_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if row is None:
raise NotFoundError("业务不存在")
business, _, old_owner_name, old_assistant_name = row
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
# 固化标量,新归属人名必须按新 ID 重取
new_owner = (
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
if payload.new_owner_personnel_id is not None
else None
)
new_assistant = (
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
if payload.new_assistant_personnel_id is not None
else None
)
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
if not owner_changed and not assistant_changed:
raise BadRequestError("归属未变化")
old_owner_id = business.owner_personnel_id
old_assistant_id = business.assistant_personnel_id
business.owner_personnel_id = payload.new_owner_personnel_id
business.assistant_personnel_id = payload.new_assistant_personnel_id
business_repository.add_ownership_log(
session,
OwnershipChangeLog(
owner_type=OWNER_TYPE_BUSINESS,
business_id=business_id,
old_owner_personnel_id=old_owner_id,
new_owner_personnel_id=payload.new_owner_personnel_id,
old_assistant_personnel_id=old_assistant_id,
new_assistant_personnel_id=payload.new_assistant_personnel_id,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
),
)
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
out = _to_out(
(
business,
row[1],
new_owner.personnel_name if new_owner is not None else old_owner_name,
new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
)
)
session.commit()
logger.info(
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
business_id,
old_owner_id,
payload.new_owner_personnel_id,
old_assistant_id,
payload.new_assistant_personnel_id,
)
return out
def list_ownership_logs(
session: Session, business_id: int, query: PageQuery
) -> PageResult[OwnershipChangeLogOut]:
"""某业务的归属变更留痕(分页)。
:raises NotFoundError: 业务不存在
"""
if business_repository.get_by_id(session, business_id) is None:
raise NotFoundError("业务不存在")
rows = business_repository.list_ownership_logs(
session, business_id, offset=query.offset, limit=query.page_size
)
total = business_repository.count_ownership_logs(session, business_id=business_id)
return PageResult[OwnershipChangeLogOut](
records=[_log_to_out(row) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def _log_to_out(row: Row) -> OwnershipChangeLogOut:
log, old_owner_name, new_owner_name = row
return OwnershipChangeLogOut(
id=log.id,
business_id=log.business_id,
old_owner_personnel_id=log.old_owner_personnel_id,
new_owner_personnel_id=log.new_owner_personnel_id,
old_assistant_personnel_id=log.old_assistant_personnel_id,
new_assistant_personnel_id=log.new_assistant_personnel_id,
old_owner_name=old_owner_name,
new_owner_name=new_owner_name,
change_reason=log.change_reason,
operator_id=log.operator_id,
operator_name=log.operator_name,
create_time=log.create_time,
)
def list_status_logs(
session: Session, business_id: int, query: PageQuery
) -> PageResult[BusinessStatusLogOut]:
"""某业务的状态流转时间线(分页,时间正序)。
:raises NotFoundError: 业务不存在
"""
if business_repository.get_by_id(session, business_id) is None:
raise NotFoundError("业务不存在")
rows = business_repository.list_status_logs(
session, business_id, offset=query.offset, limit=query.page_size
)
total = business_repository.count_status_logs(session, business_id=business_id)
return PageResult[BusinessStatusLogOut](
records=[BusinessStatusLogOut.model_validate(row) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut:
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
阈值按 asOf(默认今日)取 M1 参数快照(product.threshold,按行类别维度,
存储口径为 % 数如 30,直接进 converted_profit);毛利润率按 M1 预览同一
出参口径输出 Fraction 分数字符串(如 "97/10000");成本缺失的行派生列为
None 并标注原因;汇总只聚合可计算行。
:raises NotFoundError: 业务不存在
"""
if business_repository.get_by_id(session, business_id) is None:
raise NotFoundError("业务不存在")
as_of = as_of or date.today()
snapshot = param_service.resolve_snapshot(session, as_of)
def threshold_of(category: str) -> Fraction:
return snapshot.fraction("product.threshold", product_category=category)
rows_out: list[GrossProfitRowOut] = []
missing_count = 0
# 类别聚合容器:{类别: [sale, cost]},成本缺失行不计入该类别聚合
agg: dict[str, list[Decimal]] = {}
for item in business_repository.list_items(session, business_id):
threshold = threshold_of(item.product_category)
row_kwargs: dict = {
"item_id": item.id,
"product_category": item.product_category,
"product_name": item.product_name,
"spec_model": item.spec_model,
"quantity": str(item.quantity),
"sale_amount": str(item.sale_amount),
"threshold": str(threshold),
}
if item.cost_amount is None:
missing_count += 1
row_kwargs.update(
cost_amount=None,
profit_amount=None,
profit_rate=None,
converted_profit=None,
missing_reason="成本缺失,无法计算毛利润",
)
else:
profit = item.sale_amount - item.cost_amount
rate = profit_rate(profit, item.sale_amount)
conv = converted_profit(item.sale_amount, rate, threshold)
row_kwargs.update(
cost_amount=str(item.cost_amount),
profit_amount=str(profit),
profit_rate=str(rate),
converted_profit=str(conv),
missing_reason=None,
)
bucket = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
bucket[0] += item.sale_amount
bucket[1] += item.cost_amount
rows_out.append(GrossProfitRowOut(**row_kwargs))
categories = [
GrossProfitCategoryOut(
product_category=category,
sale_amount=str(sale),
cost_amount=str(cost),
profit_amount=str(sale - cost),
profit_rate=str(profit_rate(sale - cost, sale)),
threshold=str(threshold_of(category)),
converted_profit=str(
converted_profit(sale, profit_rate(sale - cost, sale), threshold_of(category))
),
)
for category, [sale, cost] in agg.items()
]
return GrossProfitOut(
business_id=business_id,
as_of=as_of,
rows=rows_out,
categories=categories,
missing_count=missing_count,
)
+422
View File
@@ -0,0 +1,422 @@
"""客户管理业务编排(需求 2.1.3)。
**事务边界在本层**:repository 只 flush,commit/rollback 只写在这里。
- 「公司名称 + 负责人」组合唯一(uk_customers_company_owner):先查后写不是
原子操作,并发提交撞唯一键时捕 IntegrityError 判 1062 翻译为 400
(同 M1 的并发兜底模式)
- **归属变更的裁决语义是「只影响变更后核算,不回溯历史」(对照表 #19)**:
实现为同一事务内「更新 customers.owner_personnel_id + 插入一条
ownership_change_logs」,一次 commit —— 两步拆开提交时,中途失败会出现
「主表已换人、留痕缺失」的静默不一致,事后追溯无据可查
- 归属调整只有 ``change_ownership`` 一条路径;编辑端点不触归属,
防止出现「改归属不留痕」的旁路
"""
import logging
from sqlalchemy import Row
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_UPDATE,
CUSTOMER_TYPES,
MODULE_CUSTOMER,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageQuery, PageResult
from app.models import Customer, OwnershipChangeLog, Personnel, User
from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import customer_repository, personnel_repository
from app.schemas.customer import (
CustomerCreateIn,
CustomerOut,
CustomerOwnershipIn,
CustomerQuery,
CustomerUpdateIn,
OwnershipChangeLogOut,
)
from app.services import audit_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
return CustomerOut(
id=customer.id,
company_name=customer.company_name,
owner_name=customer.owner_name,
contact_person=customer.contact_person,
contact_phone=customer.contact_phone,
address=customer.address,
industry=customer.industry,
customer_type=customer.customer_type,
owner_personnel_id=customer.owner_personnel_id,
owner_personnel_name=owner_name,
remark=customer.remark,
)
def _to_log_out(row: Row) -> OwnershipChangeLogOut:
"""留痕行 (留痕, 旧归属人名, 新归属人名) 转响应模型。"""
log, old_name, new_name = row
return OwnershipChangeLogOut(
id=log.id,
customer_id=log.customer_id,
old_owner_personnel_id=log.old_owner_personnel_id,
new_owner_personnel_id=log.new_owner_personnel_id,
old_owner_name=old_name,
new_owner_name=new_name,
change_reason=log.change_reason,
operator_id=log.operator_id,
operator_name=log.operator_name,
create_time=log.create_time,
)
def _ensure_customer_type(customer_type: str) -> None:
"""客户分类必须是注册表内的三个值之一(前端为受控下拉,后端仍须校验)。
customer_type 是 M3 计提规则匹配的锚点,脏值会让该客户在核算时匹配不到
任何计提规则而静默漏算。
"""
if customer_type not in CUSTOMER_TYPES:
raise BadRequestError("客户分类不合法")
def _ensure_owner_personnel(session: Session, personnel_id: int) -> Personnel:
"""归属人员必须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
return personnel_service.ensure_active(session, personnel_id, label="归属")
def _is_duplicate_entry(exc: IntegrityError) -> bool:
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(1062)。
customers 上还有归属人员外键(RESTRICT),并发窗口内删除人员等场景
会抛 1452 —— 不能一并翻译成「已存在」,否则用户会被错误提示带偏;
非 1062 原样上抛,由全局 IntegrityError handler 收敛 400(通用文案)。
"""
return is_duplicate_entry(exc)
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
"""分页查询客户(附归属人员姓名)。"""
rows = customer_repository.list_customers(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id,
)
total = customer_repository.count_customers(
session,
keyword=query.keyword,
customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id,
)
return PageResult[CustomerOut](
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_customer(session: Session, customer_id: int) -> CustomerOut:
"""客户详情。
:raises NotFoundError: 客户不存在
"""
row = customer_repository.get_with_owner_name(session, customer_id)
if row is None:
raise NotFoundError("客户不存在")
customer, owner_name = row
return _to_out(customer, owner_name)
def create_customer(
session: Session,
payload: CustomerCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CustomerOut:
"""新增客户。
:raises BadRequestError: 客户分类不合法 / 归属人员不存在或已离职 /
「公司名称+负责人」已存在(含并发兜底)
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CUSTOMER,
action=ACTION_CREATE,
summary=f"新增客户 {payload.company_name}({payload.owner_name})",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 值域与归属校验在审计上下文内:失败(400)也要留痕 ——
# 与 personnel_service._validate_refs 移入上下文的口径一致
_ensure_customer_type(payload.customer_type)
owner_name: str | None = None
if payload.owner_personnel_id is not None:
owner_name = _ensure_owner_personnel(session, payload.owner_personnel_id).personnel_name
if (
customer_repository.get_by_company_owner(
session, payload.company_name, payload.owner_name
)
is not None
):
raise BadRequestError("该公司名称与负责人的客户已存在")
customer = Customer(
company_name=payload.company_name,
owner_name=payload.owner_name,
contact_person=payload.contact_person,
contact_phone=payload.contact_phone,
address=payload.address,
industry=payload.industry,
customer_type=payload.customer_type,
owner_personnel_id=payload.owner_personnel_id,
remark=payload.remark,
)
try:
customer_repository.create(session, customer)
# 响应在 commit 之前构造(同 user_service 顶部说明)
out = _to_out(customer, owner_name)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发提交同一「公司名称+负责人」会撞
# uk_customers_company_owner,回滚并翻译为可读的 400,而非 500
session.rollback()
if _is_duplicate_entry(exc):
raise BadRequestError("该公司名称与负责人的客户已存在") from exc
raise
logger.info("新增客户 | customer_id=%s | company=%s", customer.id, payload.company_name)
return out
def update_customer(
session: Session,
customer_id: int,
payload: CustomerUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CustomerOut:
"""编辑客户档案(全量替换)。
**不触归属**:owner_personnel_id 保持原值,调整统一走
``PUT /{id}/ownership``(唯一写留痕的路径),见 ``CustomerUpdateIn`` 说明。
:raises NotFoundError: 客户不存在
:raises BadRequestError: 客户分类不合法 / 「公司名称+负责人」已存在(含并发兜底)
"""
# 只读预取在审计上下文之前:目标不存在(NotFound 失败)时也能留下可读记录,
# 摘要与回包都要用客户行;归属姓名本端点不变更,一并带出
row = customer_repository.get_with_owner_name(session, customer_id)
customer = row[0] if row else None
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CUSTOMER,
action=ACTION_UPDATE,
summary=f"修改客户 {customer.company_name if customer else f'#{customer_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 值域校验在审计上下文内:分类非法的 400 也要留痕(同 create 口径)
_ensure_customer_type(payload.customer_type)
if row is None:
raise NotFoundError("客户不存在")
customer, owner_name = row
duplicate = customer_repository.get_by_company_owner(
session, payload.company_name, payload.owner_name
)
if duplicate is not None and duplicate.id != customer_id:
raise BadRequestError("该公司名称与负责人的客户已存在")
customer.company_name = payload.company_name
customer.owner_name = payload.owner_name
customer.contact_person = payload.contact_person
customer.contact_phone = payload.contact_phone
customer.address = payload.address
customer.industry = payload.industry
customer.customer_type = payload.customer_type
# owner_personnel_id 刻意不赋值 —— 归属调整只走 /ownership
try:
# 响应在 commit 之前构造(同 user_service 顶部说明)
out = _to_out(customer, owner_name)
session.commit()
except IntegrityError as exc:
# 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写
# 的并发窗口会撞唯一键,兜底同 create_customer
session.rollback()
if _is_duplicate_entry(exc):
raise BadRequestError("该公司名称与负责人的客户已存在") from exc
raise
logger.info("修改客户 | customer_id=%s | company=%s", customer_id, payload.company_name)
return out
def delete_customer(
session: Session,
customer_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除客户(物理删除):被业务引用 → 400 引导处理业务单据。
businesses.customer_id 为 RESTRICT 外键,引用检查必须先于删除——
否则并发窗口内的 FK 冲突会以 500 暴露(T4 时该检查占位、业务表就位
后由本处补齐,留痕表是无外键固化不参与拦截)。
:raises NotFoundError: 客户不存在
:raises BadRequestError: 客户已被业务单据引用
"""
customer = customer_repository.get_by_id(session, customer_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CUSTOMER,
action=ACTION_DELETE,
summary=f"删除客户 {customer.company_name if customer else f'#{customer_id}'}",
),
meta=meta,
):
if customer is None:
raise NotFoundError("客户不存在")
if customer_repository.count_business_refs(session, customer_id):
raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据")
company_name = customer.company_name # 固化:对象随删除失效
try:
customer_repository.delete(session, customer)
session.commit()
except IntegrityError as exc:
# 先查后删的并发窗口:新业务挂上了该客户,FK RESTRICT 兜底为 400
session.rollback()
raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据") from exc
logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name)
def change_ownership(
session: Session,
customer_id: int,
payload: CustomerOwnershipIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CustomerOut:
"""归属变更:同一事务内「改主表归属 + 插留痕」,一次 commit。
裁决口径为「只影响变更后核算,不回溯历史」(对照表 #19):历史核算结果
已由 M3 台账固化,留痕仅承担追溯职责,故无 effective_date 时点化设计。
:raises NotFoundError: 客户不存在
:raises BadRequestError: 归属人员不存在或已离职 / 归属未变化
"""
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
# 上下文内进行,失败同样要留下可追溯的记录
row = customer_repository.get_with_owner_name(session, customer_id)
old_person = (
personnel_repository.get_by_id(session, row[0].owner_personnel_id)
if row is not None and row[0].owner_personnel_id is not None
else None
)
new_person = (
personnel_repository.get_by_id(session, payload.new_owner_personnel_id)
if payload.new_owner_personnel_id is not None
else None
)
old_label = old_person.personnel_name if old_person else "无"
new_label = new_person.personnel_name if new_person else "无"
customer_name = row[0].company_name if row else f"#{customer_id}"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CUSTOMER,
action=ACTION_UPDATE,
summary=f"调整客户归属 {customer_name}:{old_label} → {new_label}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if row is None:
raise NotFoundError("客户不存在")
customer, _ = row
if payload.new_owner_personnel_id is not None:
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
old_owner_id = customer.owner_personnel_id
if old_owner_id == payload.new_owner_personnel_id:
raise BadRequestError("归属未变化")
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
customer.owner_personnel_id = payload.new_owner_personnel_id
customer_repository.add_ownership_log(
session,
OwnershipChangeLog(
owner_type=OWNER_TYPE_CUSTOMER,
customer_id=customer.id,
old_owner_personnel_id=old_owner_id,
new_owner_personnel_id=payload.new_owner_personnel_id,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
),
)
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
out = _to_out(customer, new_person.personnel_name if new_person else None)
session.commit()
logger.info(
"调整客户归属 | customer_id=%s | old=%s | new=%s",
customer_id,
old_owner_id,
payload.new_owner_personnel_id,
)
return out
def list_ownership_logs(
session: Session,
customer_id: int,
query: PageQuery,
) -> PageResult[OwnershipChangeLogOut]:
"""某客户的归属变更留痕(分页,按变更时间倒序)。
:raises NotFoundError: 客户不存在
"""
if customer_repository.get_by_id(session, customer_id) is None:
raise NotFoundError("客户不存在")
rows = customer_repository.list_ownership_logs(
session,
customer_id=customer_id,
offset=query.offset,
limit=query.page_size,
)
total = customer_repository.count_ownership_logs(session, customer_id=customer_id)
return PageResult[OwnershipChangeLogOut](
records=[_to_log_out(row) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
+5
View File
@@ -40,7 +40,12 @@ def salesperson_rate(
start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出
高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days
的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。
"""
days = max(days, 0)
if days > boundary_days:
return floor / 1000
return (start - days * slope) / 1000
+49
View File
@@ -0,0 +1,49 @@
"""单据编号生成器(业务 YW / 发货 SH / 回款 HK 共用)。
规则:``{前缀}{YYYYMMDD}-{当日4位序号}``,序号按自然日重置。
并发安全:``SELECT MAX(no) ... FOR UPDATE`` 锁住当日序号区间 —— 后到的
事务会等待先到者提交后再读到新 MAX,序号单调不重;残余竞态(锁等待超时
等极端场景)由服务层捕唯一键冲突(1062)重试一次兜底。
"""
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.orm import Session
def today_prefix(prefix: str) -> str:
"""当日编号前缀(如 YW20261013-),LIKE 匹配与拼号共用同一来源防漂移。"""
return f"{prefix}{datetime.now().strftime('%Y%m%d')}-"
def next_no(
session: Session,
*,
model: type,
column_name: str,
prefix: str,
seq_width: int = 4,
) -> str:
"""生成下一个编号。
:param model: 编号所在的 ORM 模型(businesses/shipments/receipts)
:param column_name: 编号列名(business_no/shipment_no/receipt_batch_no)
:param prefix: 单据前缀(NO_PREFIX_* 常量)
:param seq_width: 序号位数(默认 4);超出宽度的序号按实际位数输出,
VARCHAR(32) 余量充足、唯一性不受影响
"""
like_prefix = today_prefix(prefix)
column = getattr(model, column_name)
# 排序按「长度优先、同级字典序」:同前缀下序号位数增多(9999→10000)后
# 纯字典序会反转("-10000" < "-9999",'1'<'9'),seq 将恒重算出同一号码、
# 每笔建单都撞唯一键 —— 长度大即数值大,同级同长时字典序与数值序一致
max_no = session.execute(
select(column)
.where(column.like(f"{like_prefix}%"))
.order_by(func.length(column).desc(), column.desc())
.limit(1)
.with_for_update()
).scalar()
seq = int(max_no.rsplit("-", 1)[1]) + 1 if max_no else 1
return f"{like_prefix}{seq:0{seq_width}d}"
+83 -56
View File
@@ -13,7 +13,6 @@ from decimal import Decimal
from fractions import Fraction
from io import BytesIO
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -21,6 +20,7 @@ from app.core.constants import (
ACTION_DELETE,
ACTION_UPDATE,
MODULE_PARAM,
NO_PREFIX_PARAM,
PRODUCT_CATEGORIES,
)
from app.core.exceptions import BadRequestError, NotFoundError
@@ -35,6 +35,7 @@ from app.schemas.param import (
ParamCatalogItemOut,
ParamChangeBatchOut,
ParamChangeIn,
ParamChangeItemIn,
ParamDiffOut,
ParamDimensionValueOut,
ParamHistoryOut,
@@ -46,6 +47,7 @@ from app.schemas.param import (
from app.services import audit_service, formula
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import to_decimal
from app.services.no_generator import next_no
from app.services.param_registry import get_spec, parse_fraction, validate_and_normalize
logger = logging.getLogger(__name__)
@@ -187,19 +189,65 @@ def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCat
# ==================== 变更与版本管理 ====================
def _next_version_no(session: Session) -> str:
"""生成批次版本号 P{YYYYMMDD}-{当日序号}(当日自增,两位补零)。"""
today = date.today()
prefix = f"P{today:%Y%m%d}"
seq = 0
for (version_no,) in session.execute(
select(ParamChangeBatch.version_no).where(ParamChangeBatch.version_no.like(f"{prefix}-%"))
):
try:
seq = max(seq, int(version_no.rsplit("-", 1)[1]))
except (IndexError, ValueError): # 手工造数容错:跳过不合规格的版本号
continue
return f"{prefix}-{seq + 1:02d}"
def _normalize_items(
session: Session, items: list[ParamChangeItemIn]
) -> list[tuple[ParamDefinition, str, str]]:
"""全量校验条目并归一为 (定义, 规范维度值, 规范值),任何一项不合法整批拒绝。
定义与岗位各一条 IN 批量查询预载(逐条查询在批次扩大时是写侧 N+1);
岗位预载收集全部非空维度值 —— 集合可能略大于实际所需,但省去按维度
类型先分类的第二轮遍历,无害多取由校验逻辑自行过滤。
:raises BadRequestError: 参数未定义 / 维度不合法 / 值校验失败 / 同批次重复条目
"""
codes = list({item.param_code for item in items})
definitions = param_repository.list_definitions_by_codes(session, codes)
dimension_values = list({item.dimension_value for item in items if item.dimension_value})
# 预载字典键归一为 casefold:单条版 get_by_code 走 SQL ci 校对(大小写
# 变形可命中),批量版改字典查找后须自行等价归一,否则变形大小写会
# 被误判「不是已存在的岗位」;岗位编码均为 ascii 小写蛇形,casefold 足够
roles = {
code.casefold(): role
for code, role in business_role_repository.list_by_codes(session, dimension_values).items()
}
normalized: list[tuple[ParamDefinition, str, str]] = []
seen: set[tuple[str, str]] = set()
for item in items:
definition = definitions.get(item.param_code)
if definition is None:
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
# ci 校对规则下大小写变形也能命中库,此后一律用库里的规范值:
# 注册表 get_spec 与快照字典均按精确键匹配,原始大小写入库会导致
# 取值链静默不命中(按错误参数算钱且无报错)
spec = get_spec(definition.param_code)
dimension = item.dimension_value
if spec.dimension_type == "NONE" and dimension != "":
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
if spec.dimension_type == "BUSINESS_ROLE" and dimension:
role = roles.get(dimension.casefold())
if role is None:
raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
)
dimension = role.role_code
if (
spec.dimension_type == "PRODUCT_CATEGORY"
and dimension
and dimension not in PRODUCT_CATEGORIES
):
raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
)
value = validate_and_normalize(spec, item.value)
key = (item.param_code, dimension)
if key in seen:
raise BadRequestError(
f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})"
)
seen.add(key)
normalized.append((definition, dimension, value))
return normalized
def submit_change(
@@ -233,47 +281,21 @@ def submit_change(
raise BadRequestError("指定生效日期不得早于今日")
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
normalized: list[tuple[ParamDefinition, str, str]] = []
seen: set[tuple[str, str]] = set()
for item in payload.items:
definition = param_repository.get_definition_by_code(session, item.param_code)
if definition is None:
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
spec = get_spec(item.param_code)
dimension = item.dimension_value
if spec.dimension_type == "NONE" and dimension != "":
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
if (
spec.dimension_type == "BUSINESS_ROLE"
and dimension
and business_role_repository.get_by_code(session, dimension) is None
):
raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
)
if (
spec.dimension_type == "PRODUCT_CATEGORY"
and dimension
and dimension not in PRODUCT_CATEGORIES
):
raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
)
value = validate_and_normalize(spec, item.value)
key = (item.param_code, dimension)
if key in seen:
raise BadRequestError(
f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})"
)
seen.add(key)
normalized.append((definition, dimension, value))
normalized = _normalize_items(session, payload.items)
try:
batch = param_repository.create_batch(
session,
ParamChangeBatch(
version_no=_next_version_no(session),
# 版本号与三单据同口径:MAX FOR UPDATE 锁当日序号区间;
# 残余并发冲突由下方 IntegrityError 分支翻译为可重试 400
version_no=next_no(
session,
model=ParamChangeBatch,
column_name="version_no",
prefix=NO_PREFIX_PARAM,
seq_width=2,
),
effective_date=effective,
change_type=payload.change_type,
remark=payload.remark,
@@ -299,10 +321,8 @@ def submit_change(
batch_id=batch.id,
),
)
session.commit()
except IntegrityError as exc:
# version_no 唯一键的并发冲突:当日序号是无锁查询后 +1,两名管理员
# 并发提交会生成相同版本号 —— 回滚并翻译为可重试的 400
# version_no 唯一键的并发冲突:回滚并翻译为可重试的 400
session.rollback()
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
session.commit()
@@ -503,7 +523,9 @@ def _preview_rate(
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
"""
if payload.customer_type == "enterprise":
rate = take("enterprise.rate")
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
rate = take("enterprise.rate") / 1000
return rate, "企业户固定计提比例", None
role_code = payload.business_role
@@ -519,7 +541,8 @@ def _preview_rate(
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
)
if template == "TPL_INFO_SALES_FIXED":
rate = take("info.rate")
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
rate = take("info.rate") / 1000
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
if template == "TPL_SALESPERSON_DECAY":
@@ -591,7 +614,11 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
start_ratio = take("payment.start.ratio")
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
weight_r = min(100, int(Fraction(received) / Fraction(revenue) * 100)) if revenue > 0 else 0
# R 按「回款占销售额百分比向下取整」口径封顶封底:received 非负时 int()
# 即向下取整;min/max 同时是红字冲销(负数回款)接入时的方向防线
weight_r = (
max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100))) if revenue > 0 else 0
)
weight = formula.payment_weight(weight_r)
base_amount = after_barter if barter_applied else performance
payable = to_decimal(Fraction(base_amount) * weight)
+438
View File
@@ -0,0 +1,438 @@
"""营销人员管理业务编排(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。
业务规则(前端会做提示,**后端校验是唯一防线**):
- 岗位必须存在且启用 —— 岗位决定计提公式模板,挂到停用/不存在的岗位
会让 M3 核算找不到模板
- 直属经理不能是自己、目标须存在且在职 —— 自引用关系是 M3 经理 12.5%
聚合的展开锚点,环引用或悬空引用会让聚合死循环/漏算
- 删除保护:被团队成员(personnel.manager_id)或客户归属
(customers.owner_personnel_id)引用的人员**禁删**,应改为离职 ——
历史核算与客户归属都要能回溯到人,物理删除会留下悬空引用
- 金额红线:salary_standard 以字符串进出、服务层转 Decimal,全程不经 float
辐射区域与拓展计划为整表替换维护:先删、立即 flush、再插入
(防御同一 flush 先 INSERT 后 DELETE 撞唯一键,见 personnel_repository)。
"""
import logging
from decimal import Decimal
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_UPDATE,
MODULE_PERSONNEL,
)
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import BusinessRole, Personnel, PersonnelPlan, PersonnelRegion, User
from app.models.personnel import PERSONNEL_STATUS_ACTIVE
from app.repositories import business_role_repository, personnel_repository
from app.schemas.personnel import (
PersonnelCreateIn,
PersonnelDetailOut,
PersonnelOut,
PersonnelPlanOut,
PersonnelQuery,
PersonnelRegionOut,
PlanReplaceIn,
RegionReplaceIn,
)
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
# 删除保护的统一文案:三种引用(团队成员/客户归属/业务归属)共享同一提示,
# 引导管理员走「离职」而非删除
_DELETE_BLOCKED = "存在团队成员、客户归属或业务归属引用,请改为离职而非删除"
def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personnel:
"""归属人员必须存在且在职,返回人员行(业务/客户两侧归属校验共用)。
「不存在」与「已离职」合并为同一文案:人员 ID 是否有效不构成需要向
调用方区分的信息(同登录防枚举的思路);label 落文案(负责/协助/归属)。
收口单点:在职状态值取 models.personnel.PERSONNEL_STATUS_ACTIVE,
此前 business/customer 两 service 各持常量与校验,仅靠注释维持同步。
:raises BadRequestError: {label}人员不存在或已离职
"""
person = personnel_repository.get_by_id(session, personnel_id)
if person is None or person.status != PERSONNEL_STATUS_ACTIVE:
raise BadRequestError(f"{label}人员不存在或已离职")
return person
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
"""分页查询人员(含离职)。"""
rows = personnel_repository.list_personnel(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
business_role_id=query.business_role_id,
)
total = personnel_repository.count_personnel(
session,
keyword=query.keyword,
status=query.status,
business_role_id=query.business_role_id,
)
return PageResult[PersonnelOut](
records=[
_to_out(personnel, role_name, manager_name)
for personnel, role_name, manager_name in rows
],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_detail(session: Session, personnel_id: int) -> PersonnelDetailOut:
"""人员详情(含区域与拓展计划,供编辑抽屉回显)。
:raises NotFoundError: 人员不存在
"""
row = personnel_repository.get_with_names(session, personnel_id)
if row is None:
raise NotFoundError("人员不存在")
personnel, role_name, manager_name = row
return PersonnelDetailOut(
**_to_out(personnel, role_name, manager_name).model_dump(),
regions=[
PersonnelRegionOut.model_validate(item)
for item in personnel_repository.list_regions(session, personnel_id)
],
plans=[
PersonnelPlanOut.model_validate(item)
for item in personnel_repository.list_plans(session, personnel_id)
],
)
def create_personnel(
session: Session,
payload: PersonnelCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> PersonnelOut:
"""新增人员,默认在职。
:raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PERSONNEL,
action=ACTION_CREATE,
summary=f"新增人员 {payload.personnel_name}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 引用校验放在审计上下文内:失败同样要留痕(与 update_personnel 口径
# 一致)——「谁在何时试图把人挂到停用岗位/离职经理上」是审计对象,
# 校验留在上下文外会让这类失败操作无据可查
role, manager_name = _validate_refs(
session, payload.business_role_id, payload.manager_id, None
)
personnel = Personnel(
personnel_name=payload.personnel_name,
business_role_id=payload.business_role_id,
manager_id=payload.manager_id,
phone=payload.phone,
hire_date=payload.hire_date,
is_probation=payload.is_probation,
salary_standard=_parse_salary(payload.salary_standard),
status=1,
remark=payload.remark,
)
try:
personnel_repository.create(session, personnel)
out = _to_out(personnel, role.role_name, manager_name)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:岗位/经理行被并发删除时 FK RESTRICT 令写入失败
session.rollback()
raise BadRequestError("岗位或直属经理引用已失效,请刷新后重试") from exc
logger.info("新增人员 | personnel_id=%s | name=%s", personnel.id, payload.personnel_name)
return out
def update_personnel(
session: Session,
personnel_id: int,
payload: PersonnelCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> PersonnelOut:
"""编辑人员(全量替换,不含状态)。
经理自引用校验以**被编辑人员的 id** 为准:把经理改成自己是环引用。
:raises NotFoundError: 人员不存在
:raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法
"""
row = personnel_repository.get_with_names(session, personnel_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PERSONNEL,
action=ACTION_UPDATE,
summary=f"修改人员 {row[0].personnel_name if row else f'#{personnel_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if row is None:
raise NotFoundError("人员不存在")
personnel = row[0]
role, manager_name = _validate_refs(
session, payload.business_role_id, payload.manager_id, personnel.id
)
personnel.personnel_name = payload.personnel_name
personnel.business_role_id = payload.business_role_id
personnel.manager_id = payload.manager_id
personnel.phone = payload.phone
personnel.hire_date = payload.hire_date
personnel.is_probation = payload.is_probation
personnel.salary_standard = _parse_salary(payload.salary_standard)
personnel.remark = payload.remark
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
out = _to_out(personnel, role.role_name, manager_name)
session.commit()
logger.info("修改人员 | personnel_id=%s", personnel_id)
return out
def delete_personnel(
session: Session,
personnel_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除人员。有团队成员或客户归属引用时禁删,引导走「离职」。
查询走 ``FOR UPDATE`` 锁行:校验与删除之间若发生并发引用,锁行使
并发外键写入在删除时失败(可见的 400)而非引用被静默悬空。
:raises NotFoundError: 人员不存在
:raises BadRequestError: 存在团队成员或客户归属引用
"""
personnel = personnel_repository.get_by_id(session, personnel_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PERSONNEL,
action=ACTION_DELETE,
summary=f"删除人员 {personnel.personnel_name if personnel else f'#{personnel_id}'}",
),
meta=meta,
):
if personnel is None:
raise NotFoundError("人员不存在")
ids = [personnel_id]
if (
personnel_repository.count_team_members(session, ids)
or personnel_repository.count_customer_ownerships(session, ids)
or personnel_repository.count_business_roles_refs(session, ids)
):
# 业务归属引用(负责/协助)随业务表就位一并纳入删除保护——
# 漏检会让删除以 FK RESTRICT 的 500 暴露而非可读的 400
raise BadRequestError(_DELETE_BLOCKED)
name = personnel.personnel_name # 固化:对象随删除失效
try:
personnel_repository.delete(session, personnel)
session.commit()
except IntegrityError as exc:
# 校验与删除之间并发新增了团队成员/客户归属,FK RESTRICT 令删除失败
session.rollback()
raise BadRequestError(_DELETE_BLOCKED) from exc
logger.info("删除人员 | personnel_id=%s | name=%s", personnel_id, name)
def set_status(
session: Session,
personnel_id: int,
status: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> PersonnelOut:
"""在职/离职切换。
人员语义不用「启用/禁用」词表,action 记 ACTION_UPDATE、摘要写明去向。
:raises NotFoundError: 人员不存在
"""
row = personnel_repository.get_with_names(session, personnel_id)
verb = "设为在职" if status == 1 else "设为离职"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PERSONNEL,
action=ACTION_UPDATE,
summary=f"{verb} {row[0].personnel_name if row else f'#{personnel_id}'}",
request_data={"status": status},
),
meta=meta,
):
if row is None:
raise NotFoundError("人员不存在")
personnel, role_name, manager_name = row
personnel.status = status
out = _to_out(personnel, role_name, manager_name)
session.commit()
logger.info("变更人员状态 | personnel_id=%s | status=%s", personnel_id, status)
return out
def replace_regions(
session: Session,
personnel_id: int,
payload: RegionReplaceIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""全量替换辐射区域,空列表即清空。
:raises NotFoundError: 人员不存在
"""
row = personnel_repository.get_with_names(session, personnel_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PERSONNEL,
action=ACTION_UPDATE,
summary=f"维护辐射区域 {row[0].personnel_name if row else f'#{personnel_id}'}",
request_data=payload.model_dump(),
),
meta=meta,
):
if row is None:
raise NotFoundError("人员不存在")
rows = [
PersonnelRegion(
personnel_id=personnel_id, region_name=item.region_name, remark=item.remark
)
for item in payload.items
]
personnel_repository.replace_regions(session, personnel_id, rows)
session.commit()
logger.info("维护辐射区域 | personnel_id=%s | 条目数=%s", personnel_id, len(rows))
def replace_plans(
session: Session,
personnel_id: int,
payload: PlanReplaceIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""全量替换拓展计划,空列表即清空。
:raises NotFoundError: 人员不存在
"""
row = personnel_repository.get_with_names(session, personnel_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PERSONNEL,
action=ACTION_UPDATE,
summary=f"维护拓展计划 {row[0].personnel_name if row else f'#{personnel_id}'}",
request_data=payload.model_dump(),
),
meta=meta,
):
if row is None:
raise NotFoundError("人员不存在")
rows = [
PersonnelPlan(
personnel_id=personnel_id,
plan_period=item.plan_period,
plan_content=item.plan_content,
target_desc=item.target_desc,
)
for item in payload.items
]
personnel_repository.replace_plans(session, personnel_id, rows)
session.commit()
logger.info("维护拓展计划 | personnel_id=%s | 条目数=%s", personnel_id, len(rows))
def _to_out(personnel: Personnel, role_name: str | None, manager_name: str | None) -> PersonnelOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
return PersonnelOut(
id=personnel.id,
personnel_name=personnel.personnel_name,
business_role_id=personnel.business_role_id,
role_name=role_name,
manager_id=personnel.manager_id,
manager_name=manager_name,
phone=personnel.phone,
hire_date=personnel.hire_date,
is_probation=personnel.is_probation,
salary_standard=(
str(personnel.salary_standard) if personnel.salary_standard is not None else None
),
status=personnel.status,
remark=personnel.remark,
)
def _validate_refs(
session: Session,
business_role_id: int,
manager_id: int | None,
self_id: int | None,
) -> tuple[BusinessRole, str | None]:
"""新增/编辑共用的引用校验:岗位须存在且启用;经理不能是自己且须在职。
``self_id`` 编辑时传被编辑人员的 id、新增时传 None(新增不存在「自己」)。
返回岗位行与经理姓名供响应 DTO 复用,避免提交后再查一次。
:raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法
"""
role = business_role_repository.get_by_id(session, business_role_id)
if role is None or role.status != 1:
raise BadRequestError("岗位不存在或已停用")
manager_name: str | None = None
if manager_id is not None:
if manager_id == self_id:
raise BadRequestError("直属经理不能是自己")
manager = personnel_repository.get_by_id(session, manager_id)
if manager is None or manager.status != 1:
raise BadRequestError("直属经理不存在或已离职")
manager_name = manager.personnel_name
return role, manager_name
def _parse_salary(value: str | None) -> Decimal | None:
"""入参字符串转 Decimal 存库。
格式、非负与容量已在 schema 层校验,此处只做类型转换 ——
金额红线:全程 Decimal,不经 float。
"""
return Decimal(value) if value is not None else None
+314
View File
@@ -0,0 +1,314 @@
"""产品管理业务编排。
产品主数据的两条硬约束(违反会算错钱):
- ``product_category`` 是**折算阈值的行级锚点**:混合业务按产品逐条计算
(对照表 #14),阈值由 M1 参数 ``product.threshold`` 按 PRODUCT_CATEGORY
维度承担。类别游离于合法值域之外时,核算按类别回溯参数会取不到阈值,
故值域校验是业务规则而非格式校验
- 数量/单价全程 ``Decimal``:schema 层已拦格式与非负(金额红线入口),
service 层负责字符串到 Decimal 的转换与落库
另:``spec_model`` 归一为**空串而非 NULL** —— 它参与 uk_products_name_spec
组合唯一键,MySQL 组合索引对 NULL 不去重,归 NULL 会放行重名同规格数据。
删除为物理删除,被业务明细引用的保护检查待业务数据表(V5)就位后补充。
"""
import logging
from decimal import Decimal
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_DISABLE,
ACTION_ENABLE,
ACTION_UPDATE,
MODULE_PRODUCT,
PRODUCT_CATEGORIES,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import Product, User
from app.repositories import product_repository
from app.schemas.product import (
ProductCreateIn,
ProductOut,
ProductQuery,
ProductUpdateIn,
)
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
def _to_out(product: Product) -> ProductOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
return ProductOut(
id=product.id,
product_name=product.product_name,
spec_model=product.spec_model,
unit=product.unit,
stock_quantity=str(product.stock_quantity) if product.stock_quantity is not None else None,
sale_price=str(product.sale_price) if product.sale_price is not None else None,
product_category=product.product_category,
status=product.status,
remark=product.remark,
create_time=product.create_time,
update_time=product.update_time,
)
def _to_decimal(value: str | None) -> Decimal | None:
"""数字字符串转 Decimal(可空列的空串/None 透传 None)。
入参已 str 化再进 Decimal:即使上游口径变化传入数字类型,也不经 float
二进制近似,金额红线在最后一道转换处仍然成立。schema 已保证可解析,
此处不再重复校验。
"""
if not value:
return None
return Decimal(str(value))
def _ensure_category(product_category: str) -> None:
"""类别必须 ∈ PRODUCT_CATEGORIES(前端下拉仅是展示约束,后端校验是唯一防线)。"""
if product_category not in PRODUCT_CATEGORIES:
raise BadRequestError("产品类别不合法")
def _is_duplicate_entry(exc: IntegrityError) -> bool:
"""判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、
数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。
"""
return is_duplicate_entry(exc)
def _raise_if_duplicate(exc: IntegrityError) -> None:
"""唯一键冲突统一转业务提示;其余完整性错误原样重抛。"""
if _is_duplicate_entry(exc):
raise BadRequestError("已存在同名称同规格的产品") from exc
raise exc
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
"""分页查询产品(含停用)。"""
products = product_repository.list_products(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
product_category=query.product_category,
status=query.status,
)
total = product_repository.count_products(
session,
keyword=query.keyword,
product_category=query.product_category,
status=query.status,
)
return PageResult[ProductOut](
records=[_to_out(product) for product in products],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_product(session: Session, product_id: int) -> ProductOut:
"""产品详情。
:raises NotFoundError: 产品不存在
"""
product = product_repository.get_by_id(session, product_id)
if product is None:
raise NotFoundError("产品不存在")
return _to_out(product)
def create_product(
session: Session,
payload: ProductCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> ProductOut:
"""新增产品。类别须在合法值域内,新产品固定启用。
:raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PRODUCT,
action=ACTION_CREATE,
summary=f"新增产品 {payload.product_name}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 值域校验在审计上下文内:类别非法的 400 也要留痕(成功与失败都记)
_ensure_category(payload.product_category)
spec_model = payload.spec_model or "" # 归一空串:组合唯一键对 NULL 不去重
if (
product_repository.get_by_name_spec(session, payload.product_name, spec_model)
is not None
):
raise BadRequestError("已存在同名称同规格的产品")
product = Product(
product_name=payload.product_name,
spec_model=spec_model,
unit=payload.unit,
stock_quantity=_to_decimal(payload.stock_quantity),
sale_price=_to_decimal(payload.sale_price),
product_category=payload.product_category,
status=1,
remark=payload.remark,
)
try:
product_repository.create(session, product)
# 响应与后置动作全部在 commit 之前完成(见 business_role_service 同处说明)
out = _to_out(product)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发提交同名称同规格会撞 uk_products_name_spec
session.rollback()
_raise_if_duplicate(exc)
logger.info("新增产品 | product_id=%s | category=%s", product.id, payload.product_category)
return out
def update_product(
session: Session,
product_id: int,
payload: ProductUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> ProductOut:
"""编辑产品基本信息(全量替换,不含状态;启停走独立端点)。
:raises NotFoundError: 产品不存在
:raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品
"""
product = product_repository.get_by_id(session, product_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PRODUCT,
action=ACTION_UPDATE,
summary=f"修改产品 {product.product_name if product else f'#{product_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 值域校验在审计上下文内:类别非法的 400 也要留痕(同 create 口径)
_ensure_category(payload.product_category)
if product is None:
raise NotFoundError("产品不存在")
product.product_name = payload.product_name
product.spec_model = payload.spec_model or ""
product.unit = payload.unit
product.stock_quantity = _to_decimal(payload.stock_quantity)
product.sale_price = _to_decimal(payload.sale_price)
product.product_category = payload.product_category
product.remark = payload.remark
out = _to_out(product)
try:
session.commit()
except IntegrityError as exc:
# 改名/改规格可能撞 uk_products_name_spec(本行改值与他人既有数据重合)
session.rollback()
_raise_if_duplicate(exc)
logger.info("修改产品 | product_id=%s | category=%s", product_id, payload.product_category)
return out
def set_status(
session: Session,
product_id: int,
status: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> ProductOut:
"""启用或停用产品。停用为主(模型约定),物理删除仅限误录数据。
:raises NotFoundError: 产品不存在
"""
product = product_repository.get_by_id(session, product_id)
verb = "启用" if status == 1 else "停用"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PRODUCT,
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
summary=f"{verb}产品 {product.product_name if product else f'#{product_id}'}",
request_data={"status": status},
),
meta=meta,
):
if product is None:
raise NotFoundError("产品不存在")
product.status = status
out = _to_out(product)
session.commit()
logger.info("变更产品状态 | product_id=%s | status=%s", product_id, status)
return out
def delete_product(
session: Session,
product_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""物理删除产品(仅限误录数据,常规下架用停用)。
被业务明细/发货明细引用(RESTRICT 外键)的产品禁删:先 count_refs
给出可读 400(引导改走停用),校验与删除之间的并发引用由锁行 +
IntegrityError 兜底(customer_service.delete_customer 同款结构)。
:raises NotFoundError: 产品不存在
:raises BadRequestError: 产品已被业务或发货明细引用
"""
product = product_repository.get_by_id(session, product_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PRODUCT,
action=ACTION_DELETE,
summary=f"删除产品 {product.product_name if product else f'#{product_id}'}",
),
meta=meta,
):
if product is None:
raise NotFoundError("产品不存在")
if product_repository.count_refs(session, product_id):
raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)")
product_name = product.product_name # 固化:对象随删除失效
try:
product_repository.delete(session, product)
session.commit()
except IntegrityError as exc:
# 先查后删的并发窗口:新明细挂上了该产品,FK RESTRICT 兜底为 400
session.rollback()
raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)") from exc
logger.info("删除产品 | product_name=%s", product_name)
+277
View File
@@ -0,0 +1,277 @@
"""客户评级等级配置业务编排。
评级计算器定位(对照表 #25):仅打分工具、不影响绩效金额,评分记录不落库
——本表承载的等级映射是**判定表**,判定规则为按 sort_order 升序取首个
min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反会算错等级):
- 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串
到 Decimal 的转换与落库;0≤min≤max≤100 的区间校验在**审计上下文内**
完成,失败的 400 同样留痕(2026-10-13 定稿口径)
- 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表
失效。判定为**闭区间两两相交**:任一其他等级满足
``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。
边界相邻不算重叠(前段 max=89、后段 min=90)—— 种子 A/B/C/D 即按
相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order
靠前的等级,相邻无歧义
删除允许清空:等级表无下游引用(评分记录不落库,判定只读本表现值、不
回溯历史快照),物理删除无悬空风险;全部删空后计算器只显示总分、不出
等级(前端已对应提示),故不强制保留任何一条。
"""
import logging
from decimal import Decimal
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_UPDATE,
MODULE_RATING,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import CustomerRatingGrade, User
from app.repositories import rating_repository
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
# 分值域上下界(Decimal 常量,禁用 float 参与比较)
_SCORE_FLOOR = Decimal("0")
_SCORE_CEIL = Decimal("100")
def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut:
"""ORM 对象转响应模型。分值 Decimal → str(分值红线出参口径)。"""
return RatingGradeOut(
id=grade.id,
grade_name=grade.grade_name,
min_score=str(grade.min_score),
max_score=str(grade.max_score),
sort_order=grade.sort_order,
remark=grade.remark,
create_time=grade.create_time,
update_time=grade.update_time,
)
def _to_decimal(value: str) -> Decimal:
"""数字字符串转 Decimal。
入参已 str 化再进 Decimal:即使上游口径变化传入数字类型,也不经 float
二进制近似,分值红线在最后一道转换处仍然成立。schema 已保证可解析,
此处不再重复校验。
"""
return Decimal(str(value))
def _ensure_score_range(min_score: Decimal, max_score: Decimal) -> None:
"""区间合法性:0 ≤ min_score ≤ max_score ≤ 100。
超界/倒挂任一不满足即「分值区间不合法」——统一一条消息不区分具体
越界方式,前端据此整体提示。须在审计上下文内调用,失败也要留痕。
"""
if not (_SCORE_FLOOR <= min_score <= max_score <= _SCORE_CEIL):
raise BadRequestError("分值区间不合法")
def _ensure_no_overlap(
grades: list[CustomerRatingGrade],
new_min: Decimal,
new_max: Decimal,
*,
exclude_id: int | None = None,
) -> None:
"""新区间与既有等级**不得重叠**(闭区间两两相交判定)。
任一其他等级满足 ``other.min_score <= new_max 且 new_min <= other.max_score``
即重叠,400 并点名冲突等级,便于前端定位到具体行。边界相邻不算重叠
(见模块 docstring)。exclude_id 供编辑时排除自身。
:raises BadRequestError: 分值区间与等级 X 重叠
"""
for other in grades:
if exclude_id is not None and other.id == exclude_id:
continue
if other.min_score <= new_max and new_min <= other.max_score:
raise BadRequestError(f"分值区间与等级 {other.grade_name} 重叠")
def _is_duplicate_entry(exc: IntegrityError) -> bool:
"""判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误语义未知,
原样上抛 —— 由全局 IntegrityError handler 收敛为 400(通用文案),
500 只留给程序缺陷。
"""
return is_duplicate_entry(exc)
def _raise_if_duplicate(exc: IntegrityError) -> None:
"""唯一键冲突统一转业务提示;其余完整性错误原样重抛。"""
if _is_duplicate_entry(exc):
raise BadRequestError("该等级名称已存在") from exc
raise exc
def list_grades(session: Session) -> list[RatingGradeOut]:
"""全量等级列表,按判定顺序(sort_order 升序)直出。
不分页:等级映射是判定表,数量个位数,分页无意义。
"""
return [_to_out(grade) for grade in rating_repository.list_all(session)]
def create_grade(
session: Session,
payload: RatingGradeCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> RatingGradeOut:
"""新增评级等级。
区间须 0≤min≤max≤100 且与既有等级不重叠(新增同样判重叠:判定表
语义下重叠即脏配置,没有「先停用旧区间」之类的两阶段配置手段)。
:raises BadRequestError: 分值区间不合法 / 分值区间与等级 X 重叠 / 该等级名称已存在
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_RATING,
action=ACTION_CREATE,
summary=f"新增评级等级 {payload.grade_name}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 值域校验在审计上下文内:分值非法的 400 也要留痕(成功与失败都记)
new_min = _to_decimal(payload.min_score)
new_max = _to_decimal(payload.max_score)
_ensure_score_range(new_min, new_max)
_ensure_no_overlap(rating_repository.list_all(session, for_update=True), new_min, new_max)
if rating_repository.get_by_name(session, payload.grade_name) is not None:
raise BadRequestError("该等级名称已存在")
grade = CustomerRatingGrade(
grade_name=payload.grade_name,
min_score=new_min,
max_score=new_max,
sort_order=payload.sort_order,
remark=payload.remark,
)
try:
rating_repository.create(session, grade)
# 响应与后置动作全部在 commit 之前完成(见 product_service 同处说明)
out = _to_out(grade)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发提交同名等级会撞 uk_customer_rating_grades_grade_name
session.rollback()
_raise_if_duplicate(exc)
logger.info("新增评级等级 | grade_id=%s | name=%s", grade.id, payload.grade_name)
return out
def update_grade(
session: Session,
grade_id: int,
payload: RatingGradeUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> RatingGradeOut:
"""编辑评级等级(全量替换)。
区间与其他等级不得重叠 —— 重叠判定以**修改后的区间**对全部既有等级
(排除自身)两两相交,命中即 400 并点名冲突等级。
:raises NotFoundError: 评级等级不存在
:raises BadRequestError: 分值区间不合法 / 分值区间与等级 X 重叠 / 该等级名称已存在
"""
grade = rating_repository.get_by_id(session, grade_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_RATING,
action=ACTION_UPDATE,
summary=f"修改评级等级 {grade.grade_name if grade else f'#{grade_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 值域校验在审计上下文内(同 create 口径)
new_min = _to_decimal(payload.min_score)
new_max = _to_decimal(payload.max_score)
_ensure_score_range(new_min, new_max)
if grade is None:
raise NotFoundError("评级等级不存在")
_ensure_no_overlap(
rating_repository.list_all(session, for_update=True), new_min, new_max, exclude_id=grade_id
)
existed = rating_repository.get_by_name(session, payload.grade_name)
if existed is not None and existed.id != grade_id:
raise BadRequestError("该等级名称已存在")
grade.grade_name = payload.grade_name
grade.min_score = new_min
grade.max_score = new_max
grade.sort_order = payload.sort_order
grade.remark = payload.remark
out = _to_out(grade)
try:
session.commit()
except IntegrityError as exc:
# 改名可能撞 uk_customer_rating_grades_grade_name(本行改值与他人既有数据重合)
session.rollback()
_raise_if_duplicate(exc)
logger.info("修改评级等级 | grade_id=%s | name=%s", grade_id, payload.grade_name)
return out
def delete_grade(
session: Session,
grade_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""物理删除评级等级。
等级表无下游引用 —— 评级是纯计算器不落评分记录(对照表 #25),判定
只读本表现值、不回溯历史快照,故可物理删除且无需锁行防并发引用。
**允许清空**:全部删空后计算器只显示总分、不出等级(前端已对应提示),
不强制保留任何一条。
:raises NotFoundError: 评级等级不存在
"""
grade = rating_repository.get_by_id(session, grade_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_RATING,
action=ACTION_DELETE,
summary=f"删除评级等级 {grade.grade_name if grade else f'#{grade_id}'}",
),
meta=meta,
):
if grade is None:
raise NotFoundError("评级等级不存在")
grade_name = grade.grade_name # 固化:对象随删除失效
rating_repository.delete(session, grade)
session.commit()
logger.info("删除评级等级 | grade_name=%s", grade_name)
+349
View File
@@ -0,0 +1,349 @@
"""回款管理业务编排(需求 2.2.3)。
事务边界在本层:repository 只 flush,commit/rollback 只写在这里。
- **写操作前 FOR UPDATE 锁业务行**(delete_business 先例):同业务的
发货/回款/编辑相互串行化,防止并发写后 ``_recalc_totals`` 重算
total_received_amount 互相覆盖(丢失更新);批次号生成(MAX+FOR
UPDATE 锁当日序号区间)与唯一键(1062)冲突重试一次兜底,模式同
business_service
- **shipment_id 同业务强校验**(数据完整性红线):指定的发货单必须存在
且与本回款同属一个业务 —— 跨业务指定会让回款天数与台账归属漂移;
存在性与归属两类失败统一同一文案(防通过响应差异探测发货单存在性,
与登录判定的防枚举同哲学),FK RESTRICT 是最终防线
- **settlement_status 是 M3 核算防重锚点**(需规 2.3.3「每笔回款仅核算
一次」):已核算回款禁改禁删 —— 改金额/删行会让 M3 台账与源数据漂移;
M2 只写入 0(未核算),置 1 由 M3 核算服务负责
- 回款合计回写统一收口 ``business_service._recalc_totals``(增删改后
都重算,禁各处手写 SUM)
- payment_days 为**只读派生值**:shipment_id 非空且发货单在库时按
``formula.payment_days(ship_date, receipt_date)`` 实时计算(发货日首日
计第 1 天),不落库;未指定批次为 None
- 一期禁负数金额(≥0):schema 拒绝;红字/退款冲销需求出现时先补设计
"""
import logging
from decimal import Decimal
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_UPDATE,
MODULE_RECEIPT,
NO_PREFIX_RECEIPT,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import Receipt
from app.models.business import BUSINESS_STATUS_ACTIVE
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
from app.repositories import business_repository, receipt_repository
from app.schemas.receipt import (
ReceiptCreateIn,
ReceiptOut,
ReceiptQuery,
ReceiptUpdateIn,
)
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.business_service import _recalc_totals
from app.services.formula import payment_days
from app.services.no_generator import next_no
logger = logging.getLogger(__name__)
# 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY)
_NO_RETRY = 1
def _to_out(row: tuple) -> ReceiptOut:
"""(receipt, business_no, shipment_no, ship_date) → 响应模型。
金额出参 str;payment_days 为派生值 —— ship_date 为 None(未指定批次)
时输出 None;回款日早于发货日时按公式照实输出负值,忠实反映源数据
(钳制会掩盖录入异常,M3 台账同口径)。
"""
receipt, business_no, shipment_no, ship_date = row
return ReceiptOut(
id=receipt.id,
receipt_batch_no=receipt.receipt_batch_no,
business_id=receipt.business_id,
business_no=business_no,
shipment_id=receipt.shipment_id,
shipment_no=shipment_no,
receipt_date=receipt.receipt_date,
amount=str(receipt.amount),
is_barter=receipt.is_barter,
settlement_status=receipt.settlement_status,
payment_days=(
payment_days(ship_date, receipt.receipt_date) if ship_date is not None else None
),
remark=receipt.remark,
)
def _generate_no(session: Session) -> str:
"""生成回款批次号(HK 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。"""
return next_no(session, model=Receipt, column_name="receipt_batch_no", prefix=NO_PREFIX_RECEIPT)
def _ensure_shipment_of_business(
session: Session, shipment_id: int | None, business_id: int
) -> tuple[str | None, object | None]:
"""发货批次同业务强校验,返回 (shipment_no, ship_date) 供 DTO 组装。
「必须存在且 business_id 相同」两类失败统一同一文案(防探测发货单
存在性);shipment_id 为 None = 暂未指定,直接放行。
:raises BadRequestError: 发货批次不存在或不属于该业务
"""
if shipment_id is None:
return None, None
shipment = receipt_repository.get_shipment(session, shipment_id)
if shipment is None or shipment.business_id != business_id:
raise BadRequestError("发货批次不属于该业务,无法指定")
return shipment.shipment_no, shipment.ship_date
def _ensure_business_writable(session: Session, business_id: int, action: str):
"""锁业务行并要求状态=1 执行中(新增/编辑/删除共用的写准入)。
锁在判定之前取:判定结果基于锁定后的行,配合 _recalc_totals 的重算
同事务提交,并发写不会丢失更新。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 业务已完成或已终止
"""
business = business_repository.get_by_id(session, business_id, for_update=True)
if business is None:
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError(f"业务已完成或已终止,禁止{action}回款")
return business
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
rows = receipt_repository.list_receipts(
session,
offset=query.offset,
limit=query.page_size,
business_id=query.business_id,
date_from=query.date_from,
date_to=query.date_to,
is_barter=query.is_barter,
settlement_status=query.settlement_status,
)
total = receipt_repository.count_receipts(
session,
business_id=query.business_id,
date_from=query.date_from,
date_to=query.date_to,
is_barter=query.is_barter,
settlement_status=query.settlement_status,
)
return PageResult[ReceiptOut](
records=[_to_out(row) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
"""回款详情(同列表 DTO,含 payment_days 派生值)。
:raises NotFoundError: 回款不存在
"""
row = receipt_repository.get_with_refs(session, receipt_id)
if row is None:
raise NotFoundError("回款不存在")
return _to_out(row)
def create_receipt(
session: Session,
payload: ReceiptCreateIn,
*,
operator,
meta: RequestMeta | None = None,
) -> ReceiptOut:
"""新增回款:锁业务行 → 校验 → 建行 + 重算合计,一个事务。
摘要三要素(批次号/业务号/金额)在进入审计前就绪:业务号取自锁行
结果、批次号先行生成(SELECT MAX FOR UPDATE,不产生未提交变更)、
金额来自入参;引用校验(业务状态/发货批次同业务)在审计上下文内,
失败也留痕 —— 失败路径回滚释放已取序号,当日序号出现空洞无害
(唯一性不依赖连续)。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 业务已完成或已终止 / 发货批次不属于该业务
"""
business = business_repository.get_by_id(session, payload.business_id, for_update=True)
batch_no = _generate_no(session)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_RECEIPT,
action=ACTION_CREATE,
summary=(
f"新增回款 {batch_no}"
f"(业务 {business.business_no if business else f'#{payload.business_id}'},"
f"金额 {payload.amount})"
),
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 批次号唯一键冲突的重试整体收在审计上下文内(模式同
# business_service.create_business):rollback 释放旧锁,重试前重新
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
for attempt in range(_NO_RETRY + 1):
try:
if business is None:
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新增回款")
shipment_no, ship_date = _ensure_shipment_of_business(
session, payload.shipment_id, payload.business_id
)
receipt = receipt_repository.create(
session,
Receipt(
receipt_batch_no=batch_no,
business_id=payload.business_id,
shipment_id=payload.shipment_id,
receipt_date=payload.receipt_date,
amount=Decimal(payload.amount),
is_barter=payload.is_barter,
settlement_status=SETTLEMENT_PENDING,
remark=payload.remark,
),
)
_recalc_totals(session, business)
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
new_id, new_no = receipt.id, batch_no # 固化:commit 后不碰 ORM
session.commit()
break
except IntegrityError as exc:
session.rollback()
if _is_duplicate_entry(exc) and attempt < _NO_RETRY:
business = business_repository.get_by_id(
session, payload.business_id, for_update=True
)
batch_no = _generate_no(session)
continue
raise
logger.info(
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
new_id,
new_no,
payload.business_id,
)
return out
def update_receipt(
session: Session,
receipt_id: int,
payload: ReceiptUpdateIn,
*,
operator,
meta: RequestMeta | None = None,
) -> ReceiptOut:
"""编辑回款(全量替换)+ 重算合计,一个事务。
**已核算回款禁止修改**(M3 防重锚点):M3 台账已按该行固化核算,
改金额/到账日/批次指定会让台账与源数据漂移。shipment_id 变更后
重新做同业务强校验(全量替换语义下每次编辑都校验,无「未变即免检」
的旁路)。
:raises NotFoundError: 回款不存在 / 业务不存在
:raises BadRequestError: 已核算禁改 / 业务已完成或已终止 / 发货批次不属于该业务
"""
receipt = receipt_repository.get_by_id(session, receipt_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_RECEIPT,
action=ACTION_UPDATE,
summary=f"修改回款 {receipt.receipt_batch_no if receipt else f'#{receipt_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if receipt is None:
raise NotFoundError("回款不存在")
if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止修改")
business = _ensure_business_writable(session, receipt.business_id, "修改")
# 校验返回的 (shipment_no, ship_date) 即新批次 DTO 所需 —— shipment_id
# 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join
shipment_no, ship_date = _ensure_shipment_of_business(
session, payload.shipment_id, receipt.business_id
)
receipt.receipt_date = payload.receipt_date
receipt.shipment_id = payload.shipment_id
receipt.amount = Decimal(payload.amount)
receipt.is_barter = payload.is_barter
receipt.remark = payload.remark
_recalc_totals(session, business)
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
batch_no = receipt.receipt_batch_no
session.commit()
# commit 后不再触碰 ORM 属性(audit_service 约定):批次号已固化
logger.info("修改回款 | receipt_id=%s | no=%s", receipt_id, batch_no)
return out
def delete_receipt(
session: Session,
receipt_id: int,
*,
operator,
meta: RequestMeta | None = None,
) -> None:
"""删除回款(物理删除)+ 重算合计,一个事务。
**已核算回款禁止删除**(M3 防重锚点,理由同编辑);业务须状态=1
执行中 —— 终态业务的回款是 M3 核算与报表的既成事实,不得删改。
:raises NotFoundError: 回款不存在 / 业务不存在
:raises BadRequestError: 已核算禁删 / 业务已完成或已终止
"""
receipt = receipt_repository.get_by_id(session, receipt_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_RECEIPT,
action=ACTION_DELETE,
summary=f"删除回款 {receipt.receipt_batch_no if receipt else f'#{receipt_id}'}",
),
meta=meta,
):
if receipt is None:
raise NotFoundError("回款不存在")
if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止删除")
business = _ensure_business_writable(session, receipt.business_id, "删除")
batch_no = receipt.receipt_batch_no
receipt_repository.delete(session, receipt)
_recalc_totals(session, business)
session.commit()
# commit 后不再触碰 ORM 属性(audit_service 约定):批次号已固化
logger.info("删除回款 | receipt_id=%s | no=%s", receipt_id, batch_no)
def _is_duplicate_entry(exc: IntegrityError) -> bool:
"""识别 MySQL 唯一键冲突(1062),与 business_service 同口径。"""
return is_duplicate_entry(exc)
+368
View File
@@ -0,0 +1,368 @@
"""发货管理业务编排(需求 2.2.2)。
事务边界在本层:repository 只 flush,commit/rollback 只写在这里。
- 行金额(amount)与发货单总金额(total_amount)服务层按 数量×单价 计算
固化,不信任前端传值;单价可空时行金额记 0(对齐业务明细口径)
- 业务级汇总(发货/运费合计)回写统一收口 ``business_service._recalc_totals``,
本模块不另写聚合逻辑;发货单 total_amount 由 repository 行 SUM 重算
- **写操作前 FOR UPDATE 锁业务行**(receipt_service 同款写准入):发货与
回款并发写同一业务时相互串行化,防 ``_recalc_totals`` 重算互相覆盖
(丢失更新);锁序统一「业务行 → 单据编号区间」,与业务/回款同构
- 业务状态门槛:仅执行中(1)业务可写发货单 —— 终态业务的发货单禁改禁删
(一期保守口径:编辑不影响已核算回款、核算未发生,但会改业务级汇总
基线,设计未定项待业务确认后可放开终态编辑)
- 删除保护:被回款批次指定(receipts.shipment_id)的发货单禁删,先解除
回款的批次指定;删除连带明细行并重算业务级汇总
- 编号 SH 生成复用 no_generator.next_no;残余竞态以唯一键(1062)冲突
重试一次兜底,模式同 business_service.create_business
- 审计:锁行与编号生成放在进入审计前(不产生未提交变更),状态校验在
审计上下文内,失败也留痕 —— 失败回滚释放已取序号,当日序号空洞无害
(唯一性不依赖连续);ship_date 是回款天数基准日,编辑即时生效(M3 按
编辑后基准日实时派生,无固化快照可漂移)
"""
import logging
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_UPDATE,
MODULE_SHIPMENT,
NO_PREFIX_SHIPMENT,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import Business, Shipment, ShipmentItem, User
from app.models.business import BUSINESS_STATUS_ACTIVE
from app.repositories import business_repository, shipment_repository
from app.schemas.shipment import (
ShipmentCreateIn,
ShipmentDetailOut,
ShipmentItemIn,
ShipmentItemOut,
ShipmentOut,
ShipmentQuery,
ShipmentUpdateIn,
)
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.business_service import _recalc_totals
from app.services.no_generator import next_no
logger = logging.getLogger(__name__)
# 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY)
_NO_RETRY = 1
def _to_out(row: tuple[Shipment, str]) -> ShipmentOut:
"""(shipment, business_no) → 响应模型;金额出参 str。"""
shipment, business_no = row
return ShipmentOut(
id=shipment.id,
shipment_no=shipment.shipment_no,
business_id=shipment.business_id,
business_no=business_no,
ship_date=shipment.ship_date,
freight_amount=str(shipment.freight_amount),
total_amount=str(shipment.total_amount),
remark=shipment.remark,
)
def _item_to_out(item: ShipmentItem) -> ShipmentItemOut:
return ShipmentItemOut(
id=item.id,
product_id=item.product_id,
product_name=item.product_name,
spec_model=item.spec_model,
unit=item.unit,
quantity=str(item.quantity),
sale_price=str(item.sale_price) if item.sale_price is not None else None,
amount=str(item.amount),
)
def _amount_or_zero(value: str | None) -> Decimal:
"""可空金额入参转 Decimal,空记 0(运费可缺省的统一归一)。"""
return Decimal(value) if value is not None else Decimal("0")
def _build_item_rows(items: list[ShipmentItemIn]) -> list[ShipmentItem]:
"""入参明细行转 ORM 行:amount 服务层按 数量×单价 计算固化。
不信任前端传值(schema 亦无 amount 入参)——单价可空时行金额记 0,
由用户在编辑中补价后重算;shipment_id 由 replace_items 统一回填。
乘积显式 quantize 两位固化(模式同 business_service._build_item_rows):
数量(2位)×单价(4位) 可产 6 位小数,直写 DECIMAL(14,2) 会被 MySQL
静默舍入且回显漂移。
"""
rows: list[ShipmentItem] = []
for item in items:
quantity = Decimal(item.quantity)
sale_price = Decimal(item.sale_price) if item.sale_price is not None else None
amount = (
(quantity * sale_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if sale_price is not None
else Decimal("0.00")
)
rows.append(
ShipmentItem(
product_id=item.product_id,
product_name=item.product_name,
spec_model=item.spec_model or "",
unit=item.unit,
quantity=quantity,
sale_price=sale_price,
amount=amount,
)
)
return rows
def _generate_no(session: Session) -> str:
"""生成发货单号(SH 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。"""
return next_no(session, model=Shipment, column_name="shipment_no", prefix=NO_PREFIX_SHIPMENT)
def _ensure_business_writable(session: Session, business_id: int, action: str) -> Business:
"""锁业务行并要求状态=1 执行中(新增/编辑/删除共用的写准入)。
锁在判定之前取:判定结果基于锁定后的行,配合 _recalc_totals 的重算
同事务提交,并发写不会丢失更新。与 receipt_service._ensure_business_writable
同构 —— 文案单据名落到发货单(私有函数各自保留,不为一个字符串上提
公共层)。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 业务已完成或已终止
"""
business = business_repository.get_by_id(session, business_id, for_update=True)
if business is None:
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError(f"业务已完成或已终止,禁止{action}")
return business
def _is_duplicate_entry(exc: IntegrityError) -> bool:
"""识别 MySQL 唯一键冲突(1062),与 business/receipt_service 同口径。"""
return is_duplicate_entry(exc)
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
rows = shipment_repository.list_shipments(
session,
offset=query.offset,
limit=query.page_size,
business_id=query.business_id,
ship_date_from=query.ship_date_from,
ship_date_to=query.ship_date_to,
)
total = shipment_repository.count_shipments(
session,
business_id=query.business_id,
ship_date_from=query.ship_date_from,
ship_date_to=query.ship_date_to,
)
return PageResult[ShipmentOut](
records=[_to_out(row) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_detail(session: Session, shipment_id: int) -> ShipmentDetailOut:
"""发货单详情(附明细行,供编辑抽屉回显)。
:raises NotFoundError: 发货单不存在
"""
row = shipment_repository.get_with_business_no(session, shipment_id)
if row is None:
raise NotFoundError("发货单不存在")
out = ShipmentDetailOut(**_to_out(row).model_dump())
out.items = [
_item_to_out(item) for item in shipment_repository.list_items(session, shipment_id)
]
return out
def create_shipment(
session: Session,
payload: ShipmentCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> ShipmentOut:
"""新增发货单:锁业务行 → 校验 → 建单 + 明细 + 双重汇总回写,一个事务。
摘要两号(发货单号/业务号)在进入审计前就绪:业务号取自锁行结果、
发货单号先行生成(SELECT MAX FOR UPDATE,不产生未提交变更);状态
校验在审计上下文内,失败也留痕 —— 失败路径回滚释放已取序号,当日
序号出现空洞无害(唯一性不依赖连续)。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 业务已完成或已终止,禁止新发货
"""
business = business_repository.get_by_id(session, payload.business_id, for_update=True)
shipment_no = _generate_no(session)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SHIPMENT,
action=ACTION_CREATE,
summary=(
f"新增发货单 {shipment_no}"
f"(业务 {business.business_no if business else f'#{payload.business_id}'},"
f"明细 {len(payload.items)} 行)"
),
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 单号唯一键冲突的重试整体收在审计上下文内(模式同
# business_service.create_business):rollback 释放旧锁,重试前重新
# 锁业务行与生成编号——成功/最终失败各只记一条审计,无递归
for attempt in range(_NO_RETRY + 1):
try:
if business is None:
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新发货")
shipment = Shipment(
shipment_no=shipment_no,
business_id=payload.business_id,
ship_date=payload.ship_date,
freight_amount=_amount_or_zero(payload.freight_amount),
total_amount=Decimal("0"),
remark=payload.remark,
)
shipment_repository.create(session, shipment)
shipment_repository.replace_items(
session, shipment.id, _build_item_rows(payload.items)
)
total = shipment_repository.sum_item_amount(session, shipment.id)
shipment.total_amount = total if total is not None else Decimal("0")
_recalc_totals(session, business)
out = _to_out((shipment, business.business_no))
new_id, new_no = shipment.id, shipment_no # 固化:commit 后不碰 ORM
session.commit()
break
except IntegrityError as exc:
session.rollback()
if _is_duplicate_entry(exc) and attempt < _NO_RETRY:
business = business_repository.get_by_id(
session, payload.business_id, for_update=True
)
shipment_no = _generate_no(session)
continue
raise
logger.info(
"新增发货单 | shipment_id=%s | no=%s | business_id=%s",
new_id,
new_no,
payload.business_id,
)
return out
def update_shipment(
session: Session,
shipment_id: int,
payload: ShipmentUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> ShipmentOut:
"""编辑发货单(明细全量替换)+ 双重汇总回写,一个事务。
business_id 不在编辑入参内 —— 发货单挂靠的业务创建时定死,改挂会让
已指定本发货单的回款语义断裂。终态业务的发货单禁改(与回款写准入同
口径;一期保守处理,设计未定项待业务确认后可放开——发货编辑不影响
已核算回款,核算未发生,无固化快照可漂移)。
:raises NotFoundError: 发货单不存在 / 业务不存在
:raises BadRequestError: 业务已完成或已终止
"""
shipment = shipment_repository.get_by_id(session, shipment_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SHIPMENT,
action=ACTION_UPDATE,
summary=f"修改发货单 {shipment.shipment_no if shipment else f'#{shipment_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if shipment is None:
raise NotFoundError("发货单不存在")
business = _ensure_business_writable(session, shipment.business_id, "编辑发货单")
shipment.ship_date = payload.ship_date
shipment.freight_amount = _amount_or_zero(payload.freight_amount)
shipment.remark = payload.remark
shipment_repository.replace_items(session, shipment_id, _build_item_rows(payload.items))
total = shipment_repository.sum_item_amount(session, shipment_id)
shipment.total_amount = total if total is not None else Decimal("0")
_recalc_totals(session, business)
# business_id 创建时定死不可改挂,锁定的 business.business_no 与重查同源
out = _to_out((shipment, business.business_no))
shipment_no = shipment.shipment_no
session.commit()
# commit 后不再触碰 ORM 属性(audit_service 约定):单号已固化
logger.info("修改发货单 | shipment_id=%s | no=%s", shipment_id, shipment_no)
return out
def delete_shipment(
session: Session,
shipment_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除发货单(物理删除,连带明细行)+ 业务级汇总回写,一个事务。
被回款批次指定(receipts.shipment_id 引用)→ 400 引导先解除回款的
批次指定;业务须状态=1 执行中(终态业务的发货单禁删,与回款写准入
同口径)—— 发货单的运费/金额是业务级汇总与 M3 核算的源数据,终态后
不得变动。
:raises NotFoundError: 发货单不存在 / 业务不存在
:raises BadRequestError: 业务已完成或已终止 / 已被回款批次指定
"""
shipment = shipment_repository.get_by_id(session, shipment_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SHIPMENT,
action=ACTION_DELETE,
summary=f"删除发货单 {shipment.shipment_no if shipment else f'#{shipment_id}'}",
),
meta=meta,
):
if shipment is None:
raise NotFoundError("发货单不存在")
business = _ensure_business_writable(session, shipment.business_id, "删除发货单")
if shipment_repository.count_receipt_refs(session, shipment_id):
raise BadRequestError("已被回款批次指定,请先解除回款的批次指定")
shipment_no = shipment.shipment_no
shipment_repository.delete_with_items(session, shipment_id)
_recalc_totals(session, business)
session.commit()
# commit 后不再触碰 ORM 属性(audit_service 约定):单号已固化
logger.info("删除发货单 | shipment_id=%s | no=%s", shipment_id, shipment_no)
+44 -3
View File
@@ -1,6 +1,6 @@
# 后端测试
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 301 个用例全绿(单元 223 / 集成 78)。
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 603 个用例全绿(单元 381 / 集成 222)。
## 一、测试约定
@@ -92,6 +92,27 @@ uv run pytest tests/integration -q # 仅集成
运行:`uv run pytest -q` → **406 passed**(2026-10-13 全量含回归;T5 冒烟另验证提交/生效/撤回/对比/导出/预览全链路)。
### 基础数据管理(2026-10-13 完成)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 |
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕 | 14 |
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。
### 业务数据管理(2026-10-13 完成)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_businesses.py` | 未登录 401;编号 `YW{YYYYMMDD}-{4位}` 与当日序号递增;空明细/数量 0/非法类别/单价非数字 400;**销售额服务层固化**(schema 无 sale_amount 入参,塞了忽略);特殊业务必填说明;编辑全量替换后**三处冗余一致**(明细 SUM == 业务冗余列 == DB SUM);状态机(终止无原因 400 → 留痕 old=1/new=3 → 终态再变更 400);归属变更主表+留痕同生(old/new owner 与 assistant、owner_type=2)、归属未变化 400、创建时归属人员已离职 400;有发货/回款禁换客户禁删;空业务删除连带清理明细/状态留痕/业务归属留痕;毛利润视图金标准(销售 10000/成本 30 → 率 997/1000、市政 30% → 折算 6970;**销售额 0 全 0 防除零**;**成本缺失行**派生为 null 不计类别汇总;**asOf 按业务日期回溯旧阈值**);审计成功/失败双记录 | 25 |
| `tests/integration/test_shipments.py` | 未登录 401;编号 `SH{YYYYMMDD}-{4位}`;业务不存在 404;终态业务禁新发货 400;空明细/数量 0/运费负数/运费三位小数 400;**行金额与发货单总额服务层固化**;业务 total_ship/total_freight 回写一致;编辑后两处汇总同步;被回款指定禁删(解除后可删);删除后业务级汇总回退;终态业务发货单禁改禁删;审计双记录 | 15 |
| `tests/integration/test_receipts.py` | 未登录 401;批次 `HK{YYYYMMDD}-{4位}`;未指定批次可登记且 payment_days 为 null;**指定批次派生「发货日首日计第 1 天」**(2026-10-05 → 2026-11-02 = 29 天,编辑清指定后回 null);业务不存在 404 / 终态禁新增 400;金额负数/三位小数 400;**跨业务指定批次 400**(「发货批次不属于该业务,无法指定」,编辑路同校验);合计回写与删除回退;**已核算(改库 settlement_status=1)禁改禁删**;settlement_status 筛选 0/1 两路;审计双记录 | 16 |
| `tests/integration/test_ratings.py` | 未登录 401;种子四等级全量按 sort_order 升序(A 90-100/B 75-89/C 60-74/D 0-59);区间倒挂/超上限/负下限 → 400「分值区间不合法」;名称唯一 → 400「该等级名称已存在」;**新增与编辑两路重叠 400 点名冲突等级**;**边界相邻不算重叠正例**(删 C 后新增 60-74 / 把 D 编辑为 0-74,74 与 75 相邻放行);编辑改名改区间回显;删除后判定表更新、二次删除 404;审计双记录。种子删改由 conftest 兜底恢复 | 14 |
运行:`uv run pytest tests/integration/test_{businesses,shipments,receipts,ratings}.py -q` → **70 passed**(2026-10-13,连跑两次幂等);全量回归 `uv run pytest -q` → **603 passed**(单元 381 / 集成 222)。
## 三、运行记录
| 日期 | 范围 | 用例数 | 结果 | 备注 |
@@ -101,6 +122,11 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 |
| 2026-10-10 | 全部 | 282 | ✅ 通过(103.81s) | **系统管理模块(用户/角色/日志)完成**后统一编写该模块套件(新增 91 例:单元 48 / 集成 43)。含操作日志审计机制专项;**e2e 发现并修复 `replace_roles` 唯一键缺陷**(见四、缺陷表);集成用例需容器在跑 |
| 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) |
| 2026-10-13 | 基础数据集成三文件 | 48 | ✅ 通过(27.60s) | **基础数据管理模块完成**后统一编写(人员 20/产品 14/客户 14)。审计上下文内外的校验位置差异为本次新发现的缺陷类别(见下) |
| 2026-10-13 | 业务数据集成四文件 | 70 | ✅ 通过(54.73s,连跑两次幂等) | **业务数据管理(后端接口)完成**后统一编写(业务 25/发货 15/回款 16/评级 14)。全量回归 603 例通过 |
| 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 |
| 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 |
| 2026-10-15 | 全量回归 + e2e | 639 | ✅ 全部通过 | **code-review 32 条修复后全量回归**(新增 34 例:跨位宽 3 / 参数预览与维度 3 / 审计重试 2 / 终态编辑与 quantize 2 / 产品保护 2+6 / 评级锁 1 / formula 钳制 1 等)。e2e 冒烟:登录 8 项 + 系统管理 13 项 + 基础数据 13 步 + 业务全链路 16 步全过(拦截器/正则收口/params 分页改造后复跑) |
## 四、测试发现并修复的缺陷
@@ -120,13 +146,28 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-10 | `.gitignore` 的 `logs/` 规则吞掉日志查询页目录 | 规则无前导斜杠、匹配任意层级:`frontend/src/views/system/logs/` 整体被忽略,文件**进不了版本库**——干净克隆构建失败(本地因文件已在磁盘上而一切"正常",隐蔽性极强) | 收窄为 `/backend/logs/`(该规则本意即后端运行日志);以 `git check-ignore` 实证不再命中 |
| 2026-10-10 | 时间筛选未做时区归一 | 混用 aware/naive 抛 TypeError 穿透成 **500**;带时区输入被驱动静默丢弃偏移(查询窗口偏 8 小时且无提示) | `LogQueryBase` 加 field_validator:aware → `astimezone()` 本地 naive、naive 原样;补 4 例专项 |
| 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 |
| 2026-10-13 | 基础数据三 service 的**前置校验在审计上下文之外** | 校验失败(岗位停用/经理自引用/分类不合法等 400)完全不留操作日志,违背「成功与失败都记」的既定原则——与 2026-10-10 审计时序缺陷同源,新写的 service 又犯了(各自独立实现时口径漂移) | personnel/product/customer 三 service 的值域与引用校验统一移入 `audit_service.audit` 上下文内;集成用例锁定(审计成功与失败双记录) |
| 2026-10-13 | `change_status` 状态留痕的 old_status 取到**赋值后**的 ORM 属性 | 时间线记成 `old==new`(如 1→3 的变更记成 3→3)——「变更前状态」无法回溯,状态流转追溯失效。由集成用例断言 `(old,new)==(1,3)` 抓住 | 赋值前固化 `old_status = business.status` 再流转;应用日志一并修正;回归用例锁定 |
| 2026-10-13 | **客户/人员删除保护漏检业务引用**(T4 占位注释「待业务表就位后补充」在 T7 交付后无人补齐) | 被业务引用的客户、被业务归属(负责/协助)引用的人员删除 → FK RESTRICT 穿透成 **500**。**由 e2e 清理步骤暴露**(集成测试只覆盖了 T2/T4 当时的引用面,业务表就位后引用面扩大未回看) | customer 删除加 `count_business_refs` 检查 + IntegrityError 兜底 400;personnel 删除加 `count_business_roles_refs`(负责/协助合并统计);补 2 个集成用例锁定;文案更新「客户归属或业务归属引用」 |
| 2026-10-13 | **单价列(DECIMAL(14,4))被两位小数校验拒收**(前后端同源) | 编辑任何带精度的历史单价(回显 "5000.0000")被前端 `AMOUNT_PATTERN` 与后端 `_check_amount` 双双拒绝——**创建后单价带小数位的记录永远无法编辑**。**由 e2e「编辑业务」步骤暴露**(创建用整数、编辑回显带 4 位小数,恰好只在这条路径相遇) | 后端 `_check_amount` 加 `scale` 参数(金额/数量 2 位、单价类 4 位);前端业务/发货明细行单价类放行 4 位;集成用例锁定 |
| 2026-10-13 | 发货 `freight_amount` 漏做金额校验 | 负数/三位小数/超容量的运费直接落库(schema 对 quantity/sale_price 都复用了 `_check_amount`,唯独运费没套用),业务运费合计与报表随之错乱 | shipments.py schema 的 Create/Update 加 `field_validator("freight_amount")`(同套口径,文案「运费金额不能为负数 / 最多两位小数」);集成用例锁定 |
| 2026-10-15 | **计算预览把 ‰ 存储值当纯比例相乘**(企业户/信息员分支) | `enterprise.rate`=3(3‰)、`info.rate`=1(1‰)未经 ÷1000 直接乘——绩效金额放大 1000 倍(15000×3=45000)。**既有测试只覆盖业务员路径**(`salesperson_rate` 内部有 ÷1000),两分支零覆盖使缺陷存活;该预览是 M3 核算共用契约 | 两分支显式 `/1000` 并注释声明换算;补企业户/信息员精确数值用例锁定 |
| 2026-10-15 | **编号撞唯一键的重试写在 audit 上下文内 `return _retry_create`**(业务/发货/回款三处同构) | 吞异常后正常退出被记 `success=True`(假成功审计,业务实际已回滚);重入整个 `create_*` 违反「不支持嵌套」约定;`_NO_RETRY=1` 死常量致持续冲突时**无界递归**至 RecursionError | 改为 audit 上下文内 for 循环(上限 `_NO_RETRY`),rollback 后重新锁行与取号;新增集成用例锁定「成功/最终失败各恰好一条日志」 |
| 2026-10-15 | **单号 MAX 检索用 VARCHAR 字典序**(`no_generator`) | 序号破 4 位宽度(9999→10000)后字典序反转("-10000"<"-9999"),MAX 恒取旧值 → seq 恒重算同一号码 → 当日该前缀所有建单持续撞唯一键 | 排序改「长度优先、同级字典序」(同前缀下长度大即数值大);跨位宽/同宽/空表三用例锁定;param 版本号一并复用 |
| 2026-10-15 | **编辑业务全程无锁读 + 无终态门槛** | ① 「有发货禁换客户」走无锁快照读,与发货/回款写路径(FOR UPDATE)不互斥——并发新增发货可旁路保护;② 已完成/已终止业务仍可全量改明细并重算 M3 核算基线(发货/回款服务禁写终态,父单据反而放行) | `get_with_names` 加 `for_update`;update 补终态门槛;前端编辑按钮终态禁用;`has_docs` 单点收口(update/delete 共用) |
| 2026-10-15 | **删除产品无引用保护**(docstring「待 V5 补充」在 V5 就位后未兑现) | 被业务/发货明细引用的产品删除 → FK RESTRICT 穿透成 500(与 2026-10-13 客户/人员删除保护漏检同源——「占位注释交付后无人回看」第二次发生) | `count_refs`(两明细表)+ IntegrityError 兜底 400;**全局注册 IntegrityError handler** 兜底未来遗漏(漏网的引用冲突收敛 400 而非 500) |
| 2026-10-15 | **产品 schema 私写校验不查小数位与容量** | `sale_price` 用 `_parse_non_negative`(非负/有限),5 位小数被 Numeric(14,4) **静默截断**(舍入即算错钱)、超容量 500——与 fba8cfc 修掉的「schema 小数位口径」同源残留 | 复用 `_check_amount`(PRICE_MAX+scale=4 / AMOUNT_MAX);6 个秒级 schema 单测锁定 |
| 2026-10-15 | 行金额乘积(数量2位×单价4位=6位小数)直写 DECIMAL(14,2) | MySQL 静默舍入且回显漂移:创建响应读内存值 "5.166615"、复查读库值 "5.17"——同一行金额前后显示不一致 | 显式 `quantize(HALF_UP)` 固化(与 MySQL 舍入方向一致);回显/库值一致性用例锁定 |
| 2026-10-15 | 先款后货(payment_days 为负)计提比例线性外插 | 比例 = 起始 + \|天数\|×斜率,**高于起始值甚至超 100%**——M3 接入真实回款后直接多算绩效工资(预览被 schema 拦住、回款登记不校验日期先后,缺陷在 M3 前不可见) | `salesperson_rate` 钳制 days≥0(按起始比例计提;payment_days 出参仍照实输出负值,口径经用户 2026-10-15 裁决) |
| 2026-10-15 | 评级区间重叠先查后写无锁、无唯一键兜底 | 两管理员并发提交重叠区间互判不重叠而双双入库——同一总分命中多等级且结果取决于插入顺序 | 判重叠的 `list_all` 加 FOR UPDATE 锁全表行串行化;只读直出不加锁 |
| 2026-10-15 | 岗位维度值大小写校验(ci)与快照匹配(精确)不对称 | API 提交 "SalesPerson" 校验通过入库,快照字典按精确键取值不命中——**静默按错误参数算钱且无报错**;param_code 变形大写则 get_spec 抛 ValueError 成 500 | 校验命中后一律以库里规范值落库(role_code/definition.param_code);批量预载字典键 casefold 归一保持等价命中 |
## 五、待补用例
| 模块 | 状态 | 说明 |
| --- | --- | --- |
| 基础数据管理 | 未开始 | 需求已澄清(2026-10-12);依赖 T+ 对接方式确认 |
| 业务数据管理 | 未开始 | 需求已澄清(28 项裁决见 `docs/问题与解决方案对照表_v2.xlsx`) |
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
| 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) |
| 报表统计 | 未开始 | 依赖报表口径确认 |
+117 -2
View File
@@ -7,19 +7,57 @@
- 测试账号/角色统一 ``t_`` 前缀;工厂夹具清理自己创建的对象,另有
autouse 兜底按前缀全量清理(用例经**接口**创建的数据不在工厂名下,
失败中断时会残留并影响后续用例的 total 断言)
- M2 基础数据(人员姓名/产品名/公司名/测试岗位编码)同用 ``t_`` 前缀,
兜底清理按前缀删除;归属留痕对客户/人员 ID 无外键固化、不随主表删除
消失,必须显式清理,否则残留会影响后续用例对留痕条数的断言
- 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id``
会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态
- M2 业务数据(业务/发货/回款)单据编号由系统生成(YW/SH/HK 前缀 + 当日
序号),**没有 t_ 前缀可用**,兜底清理按编号前缀全量清(三类单据表目前
只有测试会写);外键均为 RESTRICT,删除顺序不能倒,且必须在客户/人员
清理之前(FK 会挡住删客户/删人员);评级判定表则恢复种子基线 ——
评级用例会删改种子构造重叠/相邻场景,恢复保证跨文件幂等
"""
import uuid
from decimal import Decimal
import pytest
from sqlalchemy import delete, select
from sqlalchemy import delete, select, update
from app.core.constants import ROLE_ADMIN, ROLE_USER
from app.core.db import get_session_factory
from app.core.security import create_access_token, hash_password
from app.models import OperationLog, Role, User, UserRole
from app.models import (
Business,
BusinessItem,
BusinessRole,
BusinessStatusLog,
Customer,
CustomerRatingGrade,
OperationLog,
OwnershipChangeLog,
Personnel,
PersonnelPlan,
PersonnelRegion,
Product,
Receipt,
Role,
Shipment,
ShipmentItem,
User,
UserRole,
)
from app.models.customer import OWNER_TYPE_BUSINESS
# 评级判定表的种子基线(与 db/migration/V5 一致):判定按 sort_order 升序
# 取首个 min_score ≤ 总分的等级,种子铺满 0-100
_RATING_SEEDS = [
("A", Decimal("90"), Decimal("100"), 1, "优秀"),
("B", Decimal("75"), Decimal("89"), 2, "良好"),
("C", Decimal("60"), Decimal("74"), 3, "合格"),
("D", Decimal("0"), Decimal("59"), 4, "待改进"),
]
TEST_PASSWORD = "TestPwd@2026"
# 模块级预计算一次:bcrypt(rounds=12)单次约 0.2–0.4 秒,逐用例重复哈希
@@ -53,6 +91,83 @@ def _purge_test_data() -> None:
if role_ids:
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
session.execute(delete(Role).where(Role.id.in_(role_ids)))
# ---- M2 业务数据(T11):业务/发货/回款三类单据 ----
# 编号系统生成、无 t_ 前缀可用,按业务编号前缀全量清(三类单据表目前
# 只有测试会写)。外键均为 RESTRICT,删除顺序不能倒:回款 → 发货
# 明细 → 发货单 → 业务明细 → 状态留痕 → 业务维度归属留痕(无外键,
# 须显式清)→ 业务主表;且必须在下方客户/人员清理之前,否则 FK 挡住
# 删客户/删人员(businesses.customer_id / .owner_personnel_id RESTRICT)
business_ids = list(
session.execute(select(Business.id).where(Business.business_no.like("YW%"))).scalars()
)
shipment_ids = list(
session.execute(select(Shipment.id).where(Shipment.shipment_no.like("SH%"))).scalars()
)
if business_ids:
session.execute(delete(Receipt).where(Receipt.business_id.in_(business_ids)))
if shipment_ids:
session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id.in_(shipment_ids)))
session.execute(delete(Shipment).where(Shipment.id.in_(shipment_ids)))
if business_ids:
session.execute(delete(BusinessItem).where(BusinessItem.business_id.in_(business_ids)))
session.execute(
delete(BusinessStatusLog).where(BusinessStatusLog.business_id.in_(business_ids))
)
session.execute(
delete(OwnershipChangeLog).where(
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
OwnershipChangeLog.business_id.in_(business_ids),
)
)
session.execute(delete(Business).where(Business.id.in_(business_ids)))
# 评级判定表恢复种子基线:评级用例会删改种子构造重叠/相邻场景,
# 兜底恢复保证每个用例开始时都是纯种子(判定表无下游引用,全删全插安全)
session.execute(delete(CustomerRatingGrade))
session.add_all(
CustomerRatingGrade(grade_name=n, min_score=lo, max_score=hi, sort_order=so, remark=rm)
for n, lo, hi, so, rm in _RATING_SEEDS
)
# ---- M2 基础数据:客户/归属留痕/人员/产品/测试岗位 ----
# 外键均为 RESTRICT,删除顺序不能倒:留痕无外键先按 customer_id 清,
# 再删客户(子表)→ 人员(先置空 manager_id 自引用、清子表)→ 产品
personnel_ids = list(
session.execute(
select(Personnel.id).where(Personnel.personnel_name.like("t\\_%"))
).scalars()
)
customer_ids = list(
session.execute(
select(Customer.id).where(Customer.company_name.like("t\\_%"))
).scalars()
)
if customer_ids:
session.execute(
delete(OwnershipChangeLog).where(OwnershipChangeLog.customer_id.in_(customer_ids))
)
session.execute(delete(Customer).where(Customer.id.in_(customer_ids)))
# 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条
session.execute(
delete(OwnershipChangeLog).where(OwnershipChangeLog.operator_name.like("t\\_%"))
)
if personnel_ids:
session.execute(
update(Personnel)
.where(Personnel.manager_id.in_(personnel_ids))
.values(manager_id=None)
)
session.execute(
delete(PersonnelRegion).where(PersonnelRegion.personnel_id.in_(personnel_ids))
)
session.execute(
delete(PersonnelPlan).where(PersonnelPlan.personnel_id.in_(personnel_ids))
)
session.execute(delete(Personnel).where(Personnel.id.in_(personnel_ids)))
session.execute(delete(Product).where(Product.product_name.like("t\\_%")))
# 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删
session.execute(delete(BusinessRole).where(BusinessRole.role_code.like("t\\_%")))
session.commit()
+60
View File
@@ -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
@@ -0,0 +1,861 @@
"""业务管理接口集成测试(需要 MySQL 与 Redis)。
守的是业务单的五处汇总冗余一致性与状态机/归属留痕 —— 明细/发货/回款任何
写路径漏算汇总,业务行的 total_* 就会与源数据漂移,M3 起付判定直接读冗余
列,漂移就是算错钱;状态留痕的「变更前状态」若取到赋值后的值(old==new
失真)或归属变更拆成「主表已换、留痕缺失」,事后追溯无据;编辑端点若能
改归属,就绕过留痕产生「改了归属却无留痕」的旁路。毛利润视图为只读派生
值,断言锁定金标准口径(销售额 0 防除零、成本缺失行不进类别汇总)。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;业务/
发货/回款编号系统生成(YW/SH/HK 前缀),conftest 按编号前缀全量清;
删除语义的用例自验 DELETE 端点。
"""
import re
import uuid
from datetime import date
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete, select
from app.core.constants import (
ACTION_CREATE,
BUSINESS_ROLE_SALESPERSON,
MODULE_BUSINESS,
PRODUCT_CATEGORY_MUNICIPAL_WATER,
PRODUCT_CATEGORY_RAIL_TRANSIT,
)
from app.core.db import get_session_factory
from app.models import (
Business,
BusinessItem,
BusinessRole,
OperationLog,
OwnershipChangeLog,
ParamChangeBatch,
ParamValue,
)
from app.models.business import BUSINESS_STATUS_ACTIVE
from app.models.customer import OWNER_TYPE_BUSINESS
pytestmark = pytest.mark.integration
_BUSINESSES = "/api/v1/business/businesses"
_PERSONNEL = "/api/v1/base/personnel"
_CUSTOMERS = "/api/v1/base/customers"
_PARAMS = "/api/v1/system/params"
def _company() -> str:
return f"t_公司_{uuid.uuid4().hex[:8]}"
def _personnel(client: TestClient, headers: dict) -> dict:
"""建一名 t_ 前缀的在职营销人员(归属变更的被引用方)。"""
factory = get_session_factory()
with factory() as session:
role_id = int(
session.execute(
select(BusinessRole.id).where(
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
BusinessRole.status == 1,
)
).scalar_one()
)
resp = client.post(
_PERSONNEL,
json={
"personnel_name": f"t_人员_{uuid.uuid4().hex[:8]}",
"business_role_id": role_id,
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _customer(client: TestClient, headers: dict) -> dict:
"""建一名 t_ 前缀测试客户(业务的必选引用)。"""
resp = client.post(
_CUSTOMERS,
json={
"company_name": _company(),
"owner_name": "张老板",
"customer_type": "personal",
"address": "本溪市平山区",
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _item(**overrides: object) -> dict:
"""业务明细行入参(默认:数量 100 × 单价 100.00 = 销售额 10000,成本 30)。"""
body: dict = {
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
"quantity": "100",
"sale_price": "100.00",
"cost_amount": "30.00",
}
body.update(overrides)
return body
def _payload(customer_id: int, **overrides: object) -> dict:
body: dict = {
"customer_id": customer_id,
"business_date": str(date.today()),
"items": [_item()],
}
body.update(overrides)
return body
def _create(client: TestClient, headers: dict, customer_id: int, **overrides: object) -> dict:
resp = client.post(_BUSINESSES, json=_payload(customer_id, **overrides), headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _terminate(client: TestClient, headers: dict, business_id: int) -> None:
"""把业务流转到已终止(终态禁写场景的造数步骤)。"""
resp = client.put(
f"{_BUSINESSES}/{business_id}/status",
json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"},
headers=headers,
)
assert resp.status_code == 200, resp.text
def _logs_of(username: str) -> list[OperationLog]:
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
factory = get_session_factory()
with factory() as session:
return list(
session.execute(
select(OperationLog)
.where(OperationLog.username == username)
.order_by(OperationLog.id.desc())
).scalars()
)
@pytest.fixture
def threshold_batch(client: TestClient, admin: dict):
"""提交「立即生效」的产品阈值参数变更批次,用例结束整批清理。
走参数接口的真实路径(system params 要求管理员);清理按 batch_id 删
条目与批次头(test_param_management.track_batches 同款)。
"""
created: list[int] = []
def _factory(dimension_value: str, value: str) -> dict:
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"remark": "t_毛利润阈值变更",
"items": [
{
"param_code": "product.threshold",
"dimension_value": dimension_value,
"value": value,
}
],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
batch = resp.json()["data"]
created.append(batch["id"])
return batch
yield _factory
factory = get_session_factory()
with factory() as session:
if created:
session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(created)))
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(created)))
session.commit()
class TestAuth:
"""鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。"""
def test_未登录访问业务列表_401(self, client: TestClient) -> None:
resp = client.get(_BUSINESSES)
assert resp.status_code == 401, resp.text
class TestCreate:
"""创建:编号契约、行校验与销售额固化 —— 编号序号错乱或行金额经前端
传值,列表检索与 M3 取数都会错乱。"""
def test_创建业务_编号格式与当日序号递增(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
first = _create(client, headers, customer["id"])
second = _create(client, headers, customer["id"])
# 编号格式:YW{YYYYMMDD}-{4位当日序号}(日期取当日,序号连续 +1)
expected_prefix = f"YW{date.today():%Y%m%d}-"
for no in (first["business_no"], second["business_no"]):
assert re.fullmatch(r"YW\d{8}-\d{4}", no), no
assert no.startswith(expected_prefix), (no, expected_prefix)
assert int(second["business_no"].rsplit("-", 1)[1]) == (
int(first["business_no"].rsplit("-", 1)[1]) + 1
), (first["business_no"], second["business_no"])
# 回包契约:默认执行中、客户名直出
assert first["status"] == BUSINESS_STATUS_ACTIVE
assert first["customer_name"] == customer["company_name"]
def test_空明细_400(self, client: TestClient, normal: dict) -> None:
customer = _customer(client, normal["headers"])
resp = client.post(
_BUSINESSES, json=_payload(customer["id"], items=[]), headers=normal["headers"]
)
assert resp.status_code == 400, resp.text
def test_行金额乘积显式两位固化_回显与库值一致(self, client: TestClient, normal: dict) -> None:
"""数量(2位)×单价(4位) 可产 6 位小数:显式 HALF_UP 固化,与 MySQL 的
DECIMAL(14,2) 舍入方向一致 —— 直写不 quantize 会被库端静默舍入,
创建响应(读内存值)与复查(读库值)前后显示不一致。"""
headers = normal["headers"]
customer = _customer(client, headers)
data = _create(
client,
headers,
customer["id"],
items=[_item(quantity="1.55", sale_price="3.3333", cost_amount=None)],
)
# 1.55 × 3.3333 = 5.166615 → 5.17(HALF_UP;MySQL 同值同向)
item = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]["items"][0]
assert item["sale_amount"] == "5.17"
assert Decimal(data["total_sale_amount"]) == Decimal("5.17")
# 库值与回显一致(无「内存 5.166615 / 库里 5.17」的漂移)
factory = get_session_factory()
with factory() as session:
db_amount = session.execute(
select(BusinessItem.sale_amount).where(BusinessItem.business_id == data["id"])
).scalar_one()
assert db_amount == Decimal("5.17")
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None:
customer = _customer(client, normal["headers"])
resp = client.post(
_BUSINESSES,
json=_payload(customer["id"], items=[_item(quantity="0")]),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "数量" in resp.json()["message"], resp.text
def test_非法产品类别_400(self, client: TestClient, normal: dict) -> None:
"""类别是折算阈值的行级锚点:脏类别取不到阈值参数。"""
customer = _customer(client, normal["headers"])
resp = client.post(
_BUSINESSES,
json=_payload(customer["id"], items=[_item(product_category="water")]),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "产品类别不合法" in resp.json()["message"], resp.text
def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None:
customer = _customer(client, normal["headers"])
resp = client.post(
_BUSINESSES,
json=_payload(customer["id"], items=[_item(sale_price="abc")]),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "须为有效数字" in resp.json()["message"], resp.text
def test_销售额由服务层固化_入参sale_amount不生效(
self, client: TestClient, normal: dict
) -> None:
"""schema 无 sale_amount 入参,塞了也必须被忽略 —— 行金额只能由
数量×单价计算,否则前端传值直接改业务销售额。"""
headers = normal["headers"]
customer = _customer(client, headers)
item = _item(quantity="3", sale_price="12.34", sale_amount="88888.00")
data = _create(client, headers, customer["id"], items=[item])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
assert Decimal(detail["items"][0]["sale_amount"]) == Decimal("37.02")
assert Decimal(detail["total_sale_amount"]) == Decimal("37.02")
def test_特殊业务缺说明_400(self, client: TestClient, normal: dict) -> None:
customer = _customer(client, normal["headers"])
resp = client.post(
_BUSINESSES,
json=_payload(customer["id"], is_special=1),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "特殊业务必须填写说明" in resp.json()["message"], resp.text
def test_特殊业务有说明创建成功_回显说明(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
data = _create(client, headers, customer["id"], is_special=1, special_note="公司参与结算")
assert data["is_special"] == 1
assert data["special_note"] == "公司参与结算"
class TestEdit:
"""编辑:全量替换明细并单点重算汇总 —— 三处冗余(行 SUM / 业务冗余列 /
数据库 SUM)不一致就说明重算有旁路。"""
def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
data = _create(
client,
headers,
customer["id"],
items=[
_item(quantity="100", sale_price="100.00", cost_amount="30.00"),
_item(quantity="50", sale_price="200.00", cost_amount="40.00"),
],
)
before = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
assert Decimal(before["total_sale_amount"]) == Decimal("20000")
assert Decimal(before["total_cost_amount"]) == Decimal("70")
updated = client.put(
f"{_BUSINESSES}/{data['id']}",
json=_payload(
customer["id"],
items=[_item(quantity="200", sale_price="50.00", cost_amount="30.00")],
),
headers=headers,
)
assert updated.status_code == 200, updated.text
assert Decimal(updated.json()["data"]["total_sale_amount"]) == Decimal("10000")
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
assert len(detail["items"]) == 1, "编辑是全量替换,旧行不得残留"
item_sum_sale = sum(Decimal(i["sale_amount"]) for i in detail["items"])
item_sum_cost = sum(Decimal(i["cost_amount"]) for i in detail["items"] if i["cost_amount"])
assert item_sum_sale == Decimal(detail["total_sale_amount"])
assert item_sum_cost == Decimal(detail["total_cost_amount"])
# 数据库侧再核一次 SUM(接口回包来自内存计算,两处同源才可靠)
factory = get_session_factory()
with factory() as session:
db_business = session.get(Business, data["id"])
db_sale = (
session.execute(
select(BusinessItem.sale_amount).where(BusinessItem.business_id == data["id"])
)
.scalars()
.all()
)
assert sum(db_sale) == db_business.total_sale_amount == Decimal("10000.00")
def test_编辑端点塞owner不生效_归属唯一走ownership(
self, client: TestClient, normal: dict
) -> None:
"""归属刻意不出编辑端点:否则「改归属无留痕」的旁路破坏 append-only。"""
headers = normal["headers"]
customer = _customer(client, headers)
owner = _personnel(client, headers)
someone = _personnel(client, headers)
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
resp = client.put(
f"{_BUSINESSES}/{data['id']}",
json=_payload(customer["id"], owner_personnel_id=someone["id"]),
headers=headers,
)
assert resp.status_code == 200, resp.text
assert resp.json()["data"]["owner_personnel_id"] == owner["id"], resp.text
logs = client.get(f"{_BUSINESSES}/{data['id']}/ownership-logs", headers=headers).json()[
"data"
]
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:
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
def test_终止留痕_终态禁再变更(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
data = _create(client, headers, customer["id"])
# 终止必填原因(schema 层校验,400)
no_reason = client.put(
f"{_BUSINESSES}/{data['id']}/status", json={"status": 3}, headers=headers
)
assert no_reason.status_code == 400, no_reason.text
assert "终止业务必须填写原因" in no_reason.json()["message"], no_reason.text
done = client.put(
f"{_BUSINESSES}/{data['id']}/status",
json={"status": 3, "reason": "客户取消订单"},
headers=headers,
)
assert done.status_code == 200, done.text
assert done.json()["data"]["status"] == 3
# 终态不可再变更
again = client.put(
f"{_BUSINESSES}/{data['id']}/status",
json={"status": 2, "reason": "强行翻转"},
headers=headers,
)
assert again.status_code == 400, again.text
assert "不可再变更状态" in again.json()["message"], again.text
logs = client.get(f"{_BUSINESSES}/{data['id']}/status-logs", headers=headers).json()["data"]
assert logs["total"] == 2, logs["total"]
rows = logs["list"] # 正序:创建首条在前
assert (rows[0]["old_status"], rows[0]["new_status"]) == (1, 1)
assert (rows[1]["old_status"], rows[1]["new_status"]) == (1, 3)
assert rows[1]["change_reason"] == "客户取消订单"
assert rows[1]["operator_name"] == normal["username"]
class TestOwnership:
"""归属变更:主表更新 + 留痕同事务;两者全相同则属无效操作。"""
def test_归属变更_主表更新与留痕同生(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
old_owner = _personnel(client, headers)
data = _create(client, headers, customer["id"], owner_personnel_id=old_owner["id"])
new_owner = _personnel(client, headers)
new_assistant = _personnel(client, headers)
resp = client.put(
f"{_BUSINESSES}/{data['id']}/ownership",
json={
"new_owner_personnel_id": new_owner["id"],
"new_assistant_personnel_id": new_assistant["id"],
"reason": "人员调动",
},
headers=headers,
)
assert resp.status_code == 200, resp.text
body = resp.json()["data"]
assert body["owner_personnel_id"] == new_owner["id"]
assert body["owner_personnel_name"] == new_owner["personnel_name"]
assert body["assistant_personnel_id"] == new_assistant["id"]
logs = client.get(f"{_BUSINESSES}/{data['id']}/ownership-logs", headers=headers).json()[
"data"
]
assert logs["total"] == 1, "首次归属变更只产生一条留痕"
row = logs["list"][0]
assert row["old_owner_personnel_id"] == old_owner["id"]
assert row["new_owner_personnel_id"] == new_owner["id"]
assert row["old_assistant_personnel_id"] is None
assert row["new_assistant_personnel_id"] == new_assistant["id"]
assert row["old_owner_name"] == old_owner["personnel_name"]
assert row["new_owner_name"] == new_owner["personnel_name"]
assert row["change_reason"] == "人员调动"
assert row["operator_name"] == normal["username"]
# owner_type 由 DB 固化断言(响应 DTO 不含该列;2=业务归属)
factory = get_session_factory()
with factory() as session:
log = session.execute(
select(OwnershipChangeLog).where(OwnershipChangeLog.business_id == data["id"])
).scalar_one()
assert log.owner_type == OWNER_TYPE_BUSINESS, (
f"owner_type 应为业务归属(2):{log.owner_type}"
)
def test_归属未变化_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
owner = _personnel(client, headers)
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
resp = client.put(
f"{_BUSINESSES}/{data['id']}/ownership",
json={"new_owner_personnel_id": owner["id"], "reason": "无变化"},
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "归属未变化" in resp.json()["message"], resp.text
def test_创建时负责人员已离职_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
quit_owner = _personnel(client, headers)
off = client.put(
f"{_PERSONNEL}/{quit_owner['id']}/status", json={"status": 0}, headers=headers
)
assert off.status_code == 200, off.text
customer = _customer(client, headers)
resp = client.post(
_BUSINESSES,
json=_payload(customer["id"], owner_personnel_id=quit_owner["id"]),
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "负责人员不存在或已离职" in resp.json()["message"], resp.text
def test_归属端点负责人员不存在_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
data = _create(client, headers, customer["id"])
resp = client.put(
f"{_BUSINESSES}/{data['id']}/ownership",
json={"new_owner_personnel_id": 999999999, "reason": "填错人"},
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "负责人员不存在或已离职" in resp.json()["message"], resp.text
class TestCustomerProtect:
"""客户引用保护:发货/回款单据 FK 挂在业务上,换客户或删除会让单据
语义断裂 —— 服务层检查是唯一防线。"""
def test_有发货或回款时换客户_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
other = _customer(client, headers)
data = _create(client, headers, customer["id"])
# 有发货记录:禁换
ship = client.post(
"/api/v1/business/shipments",
json={
"business_id": data["id"],
"ship_date": str(date.today()),
"items": [_item(product_name="t_货", quantity="1", sale_price="10.00")],
},
headers=headers,
)
assert ship.status_code == 200, ship.text
blocked_ship = client.put(
f"{_BUSINESSES}/{data['id']}",
json=_payload(other["id"]),
headers=headers,
)
assert blocked_ship.status_code == 400, blocked_ship.text
assert "已有发货或回款记录" in blocked_ship.json()["message"], blocked_ship.text
# 解除发货后仍有回款:禁换(同一路径 has_docs)
client.delete(f"/api/v1/business/shipments/{ship.json()['data']['id']}", headers=headers)
receipt = client.post(
"/api/v1/business/receipts",
json={
"business_id": data["id"],
"receipt_date": str(date.today()),
"amount": "100.00",
},
headers=headers,
)
assert receipt.status_code == 200, receipt.text
blocked_receipt = client.put(
f"{_BUSINESSES}/{data['id']}",
json=_payload(other["id"]),
headers=headers,
)
assert blocked_receipt.status_code == 400, blocked_receipt.text
assert "已有发货或回款记录" in blocked_receipt.json()["message"], blocked_receipt.text
def test_有发货或回款禁删_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
data = _create(client, headers, customer["id"])
ship = client.post(
"/api/v1/business/shipments",
json={
"business_id": data["id"],
"ship_date": str(date.today()),
"items": [_item(product_name="t_货", quantity="1", sale_price="10.00")],
},
headers=headers,
)
assert ship.status_code == 200, ship.text
blocked_ship = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers)
assert blocked_ship.status_code == 400, blocked_ship.text
assert "请改为已终止而非删除" in blocked_ship.json()["message"], blocked_ship.text
# 解除发货后仍有回款:禁删(文案同一路径)
client.delete(f"/api/v1/business/shipments/{ship.json()['data']['id']}", headers=headers)
receipt = client.post(
"/api/v1/business/receipts",
json={
"business_id": data["id"],
"receipt_date": str(date.today()),
"amount": "100.00",
},
headers=headers,
)
assert receipt.status_code == 200, receipt.text
blocked_receipt = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers)
assert blocked_receipt.status_code == 400, blocked_receipt.text
assert "请改为已终止而非删除" in blocked_receipt.json()["message"], blocked_receipt.text
def test_空业务删除成功_连带清理明细与留痕(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
owner = _personnel(client, headers)
new_owner = _personnel(client, headers)
data = _create(
client,
headers,
customer["id"],
owner_personnel_id=owner["id"],
items=[_item(quantity="1", sale_price="5.00")],
)
# 先造一次归属留痕,删除时须连带清理(无外键固化表)
moved = client.put(
f"{_BUSINESSES}/{data['id']}/ownership",
json={"new_owner_personnel_id": new_owner["id"], "reason": "调动"},
headers=headers,
)
assert moved.status_code == 200, moved.text
deleted = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers)
assert deleted.status_code == 200, deleted.text
assert client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).status_code == 404
factory = get_session_factory()
with factory() as session:
assert session.get(Business, data["id"]) is None
assert (
session.execute(select(BusinessItem).where(BusinessItem.business_id == data["id"]))
.scalars()
.first()
is None
)
# 状态留痕(business_status_logs)与归属留痕(owner_type=2)均无
# 外键固化,删除时必须显式清
assert (
session.execute(
select(OwnershipChangeLog).where(
OwnershipChangeLog.business_id == data["id"],
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
)
)
.scalars()
.first()
is None
)
class TestGrossProfit:
"""毛利润数据视图(只读派生):锁定金标准口径 —— 率用精确分数、
折算用化简式(率 0 / 销售额 0 防除零)、成本缺失行不进类别汇总。"""
def test_毛利润基准值_率与折算锁定精确分数(self, client: TestClient, normal: dict) -> None:
"""销售 10000 / 成本 30 → 利润 9970、率 997/1000、市政阈值 30% →
折算 10000 ×(997/1000 − 30/100) = 6970。"""
headers = normal["headers"]
customer = _customer(client, headers)
data = _create(
client,
headers,
customer["id"],
items=[_item(quantity="1", sale_price="10000.00", cost_amount="30.00")],
)
resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers)
assert resp.status_code == 200, resp.text
view = resp.json()["data"]
assert view["as_of"] == str(date.today())
row = view["rows"][0]
assert Decimal(row["sale_amount"]) == Decimal("10000")
assert Decimal(row["cost_amount"]) == Decimal("30")
assert Decimal(row["profit_amount"]) == Decimal("9970")
assert row["profit_rate"] == "997/1000", "率须精确分数串(约分后),不得截断"
assert row["threshold"] == "30"
assert Decimal(row["converted_profit"]) == Decimal("6970")
assert row["missing_reason"] is None
assert view["missing_count"] == 0
cats = view["categories"]
assert [c["product_category"] for c in cats] == ["municipal_water"]
assert Decimal(cats[0]["sale_amount"]) == Decimal("10000")
assert Decimal(cats[0]["profit_amount"]) == Decimal("9970")
assert cats[0]["profit_rate"] == "997/1000"
assert Decimal(cats[0]["converted_profit"]) == Decimal("6970")
def test_毛利润_销售额0_全0不抛错_防除零(self, client: TestClient, normal: dict) -> None:
"""单价空 → 行销售额 0:率分母为 0 时必须返回 0 而非抛除零异常,
否则录入期(补价前)打开毛利润视图就是 500。"""
headers = normal["headers"]
customer = _customer(client, headers)
data = _create(
client,
headers,
customer["id"],
items=[_item(quantity="5", sale_price=None, cost_amount="0.00")],
)
resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers)
assert resp.status_code == 200, resp.text
view = resp.json()["data"]
row = view["rows"][0]
assert Decimal(row["sale_amount"]) == Decimal("0")
assert Decimal(row["profit_amount"]) == Decimal("0")
assert row["profit_rate"] == "0"
assert Decimal(row["converted_profit"]) == Decimal("0")
assert view["missing_count"] == 0
cats = view["categories"]
assert [c["product_category"] for c in cats] == ["municipal_water"]
assert Decimal(cats[0]["sale_amount"]) == Decimal("0")
assert Decimal(cats[0]["converted_profit"]) == Decimal("0")
def test_毛利润_成本缺失行_派生为空不计入类别汇总(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
data = _create(
client,
headers,
customer["id"],
items=[
_item(
product_name="t_产品_市政",
quantity="1",
sale_price="5000.00",
cost_amount="100.00",
),
_item(
product_name="t_产品_轨交",
product_category=PRODUCT_CATEGORY_RAIL_TRANSIT,
quantity="2",
sale_price=None,
cost_amount=None,
),
],
)
resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers)
assert resp.status_code == 200, resp.text
view = resp.json()["data"]
assert view["missing_count"] == 1
rows = view["rows"]
assert rows[1]["cost_amount"] is None
assert rows[1]["profit_amount"] is None
assert rows[1]["profit_rate"] is None
assert rows[1]["converted_profit"] is None
assert "成本缺失" in (rows[1]["missing_reason"] or ""), rows[1]
assert rows[1]["threshold"] == "20"
# 可计算行正常派生
assert Decimal(rows[0]["profit_amount"]) == Decimal("4900")
assert rows[0]["profit_rate"] == "49/50"
cats = view["categories"]
assert [c["product_category"] for c in cats] == ["municipal_water"], "缺失行不得进类别汇总"
assert Decimal(cats[0]["sale_amount"]) == Decimal("5000")
assert Decimal(cats[0]["cost_amount"]) == Decimal("100")
assert Decimal(cats[0]["profit_amount"]) == Decimal("4900")
assert cats[0]["profit_rate"] == "49/50"
assert Decimal(cats[0]["converted_profit"]) == Decimal("3400")
def test_毛利润asOf按业务日期取参数时点值_旧日期取旧值(
self, client: TestClient, normal: dict, threshold_batch
) -> None:
"""改参数后按旧业务日期取值必须回溯旧阈值 —— 落库即漂移的「假快照」
靠 asOf 回溯参数快照,M3 台账才是法定固化点。"""
headers = normal["headers"]
customer = _customer(client, headers)
data = _create(
client,
headers,
customer["id"],
items=[_item(quantity="1", sale_price="10000.00", cost_amount="30.00")],
)
threshold_batch("municipal_water", "25") # 立即生效(今日)
today = client.get(
f"{_BUSINESSES}/{data['id']}/gross-profit",
params={"as_of": str(date.today())},
headers=headers,
)
assert today.status_code == 200, today.text
row_today = today.json()["data"]["rows"][0]
assert row_today["threshold"] == "25"
# 25%:10000 ×(997/1000 − 25/100) = 7470
assert Decimal(row_today["converted_profit"]) == Decimal("7470")
past = client.get(
f"{_BUSINESSES}/{data['id']}/gross-profit",
params={"as_of": "2026-02-17"},
headers=headers,
)
assert past.status_code == 200, past.text
row_past = past.json()["data"]["rows"][0]
assert row_past["threshold"] == "30", "旧业务日期须回溯变更前的阈值"
assert Decimal(row_past["converted_profit"]) == Decimal("6970")
class TestAudit:
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
def test_新增业务成功留审计(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
_create(client, headers, customer["id"])
logs = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_BUSINESS and log.action == ACTION_CREATE
]
assert logs, "新增业务未留操作日志"
latest = logs[0]
assert latest.success == 1
assert "创建业务" in latest.summary
def test_归属人员不存在失败留审计(self, client: TestClient, normal: dict) -> None:
customer = _customer(client, normal["headers"])
resp = client.post(
_BUSINESSES,
json=_payload(customer["id"], owner_personnel_id=999999999),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
failed = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_BUSINESS and log.success == 0
]
assert failed, "失败操作未留痕"
assert "负责人员不存在或已离职" in (failed[0].error_message or ""), failed[0]
+378
View File
@@ -0,0 +1,378 @@
"""客户管理接口集成测试(需要 MySQL 与 Redis)。
守的是客户归属的一致性 —— 归属变更必须「主表更新 + 留痕新增」同事务
完成,拆开就会出现「主表已换人、留痕缺失」的静默不一致,事后追溯无据;
编辑端点若能触归属,就会出现「改了归属却无留痕」的旁路,破坏留痕的
append-only 完整性;「公司名称+负责人」组合唯一缺失会放行重复客户。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
数据卫生:公司名与归属人员姓名一律 ``t_`` 前缀,由 conftest 兜底清理。
"""
import uuid
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.core.constants import (
ACTION_CREATE,
BUSINESS_ROLE_SALESPERSON,
CUSTOMER_TYPE_ENTERPRISE,
CUSTOMER_TYPE_PERSONAL,
MODULE_CUSTOMER,
PRODUCT_CATEGORY_MUNICIPAL_WATER,
)
from app.core.db import get_session_factory
from app.models import BusinessRole, OperationLog, OwnershipChangeLog
from app.models.customer import OWNER_TYPE_CUSTOMER
pytestmark = pytest.mark.integration
_PERSONNEL = "/api/v1/base/personnel"
_CUSTOMERS = "/api/v1/base/customers"
_BUSINESSES = "/api/v1/business/businesses"
def _company() -> str:
return f"t_公司_{uuid.uuid4().hex[:8]}"
def _role_id() -> int:
"""种子岗位(营销员):归属人员的合法岗位引用。"""
factory = get_session_factory()
with factory() as session:
return int(
session.execute(
select(BusinessRole.id).where(
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
BusinessRole.status == 1,
)
).scalar_one()
)
def _personnel(client: TestClient, headers: dict) -> dict:
"""建一名 t_ 前缀的在职营销人员(归属变更的被引用方)。"""
resp = client.post(
_PERSONNEL,
json={
"personnel_name": f"t_人员_{uuid.uuid4().hex[:8]}",
"business_role_id": _role_id(),
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _cpayload(company: str, **overrides: object) -> dict:
body: dict = {
"company_name": company,
"owner_name": "张老板",
"customer_type": CUSTOMER_TYPE_PERSONAL,
"address": "本溪市平山区",
}
body.update(overrides)
return body
def _create_customer(client: TestClient, headers: dict, **overrides: object) -> dict:
resp = client.post(_CUSTOMERS, json=_cpayload(_company(), **overrides), headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _logs_of(username: str) -> list[OperationLog]:
factory = get_session_factory()
with factory() as session:
return list(
session.execute(
select(OperationLog)
.where(OperationLog.username == username)
.order_by(OperationLog.id.desc())
).scalars()
)
def _latest_log(customer_id: int) -> OwnershipChangeLog | None:
"""取该客户最新一条归属留痕(按 id 倒序)。"""
factory = get_session_factory()
with factory() as session:
return (
session.execute(
select(OwnershipChangeLog)
.where(OwnershipChangeLog.customer_id == customer_id)
.order_by(OwnershipChangeLog.id.desc())
)
.scalars()
.first()
)
class TestAuth:
"""鉴权:登录后接口,裸访问一律 401。"""
def test_未登录访问客户列表_401(self, client: TestClient) -> None:
resp = client.get(_CUSTOMERS)
assert resp.status_code == 401, resp.text
class TestCrudFlow:
"""正向链路与组合唯一 ——「公司名称+负责人」是业务唯一标识。"""
def test_新增客户带初始归属_回显归属人员姓名(self, client: TestClient, normal: dict) -> None:
owner = _personnel(client, normal["headers"])
data = _create_customer(client, normal["headers"], owner_personnel_id=owner["id"])
assert data["owner_personnel_id"] == owner["id"]
assert data["owner_personnel_name"] == owner["personnel_name"]
assert data["customer_type"] == CUSTOMER_TYPE_PERSONAL
def test_同公司名不同负责人创建成功(self, client: TestClient, normal: dict) -> None:
company = _company()
first = _create_customer(client, normal["headers"], company_name=company)
second = client.post(
_CUSTOMERS,
json=_cpayload(company, owner_name="李老板"),
headers=normal["headers"],
)
assert second.status_code == 200, second.text
assert second.json()["data"]["id"] != first["id"]
def test_重名同负责人_400(self, client: TestClient, normal: dict) -> None:
company = _company()
_create_customer(client, normal["headers"], company_name=company)
dup = client.post(
_CUSTOMERS,
json=_cpayload(company, owner_name="张老板"),
headers=normal["headers"],
)
assert dup.status_code == 400, dup.text
assert "该公司名称与负责人的客户已存在" in dup.json()["message"]
def test_删除被业务引用的客户_400引导处理单据(self, client: TestClient, normal: dict) -> None:
"""业务表就位后删除保护补齐:FK RESTRICT 不能以 500 暴露。"""
headers = normal["headers"]
customer = _create_customer(client, headers)
resp = client.post(
_BUSINESSES,
json={
"customer_id": customer["id"],
"items": [
{
"product_name": "t_被引用产品",
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
"quantity": "1",
"sale_price": "1",
}
],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
blocked = client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers)
assert blocked.status_code == 400, blocked.text
assert "已存在关联业务" in blocked.json()["message"]
def test_客户分类非法_400(self, client: TestClient, normal: dict) -> None:
resp = client.post(
_CUSTOMERS,
json=_cpayload(_company(), customer_type="not_a_type"),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "客户分类不合法" in resp.json()["message"]
def test_编辑其他字段不触归属_塞归属字段也被忽略(
self, client: TestClient, normal: dict
) -> None:
"""归属只有 /ownership 一条写路径 —— 编辑端点防「改归属不留痕」旁路。"""
headers = normal["headers"]
owner = _personnel(client, headers)
stranger = _personnel(client, headers)
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
updated = client.put(
f"{_CUSTOMERS}/{customer['id']}",
json={
"company_name": customer["company_name"],
"owner_name": customer["owner_name"],
"customer_type": CUSTOMER_TYPE_ENTERPRISE,
"address": "沈阳市沈河区",
# 试图经编辑端点改归属:schema 未声明该字段,必须被忽略
"ownerPersonnelId": stranger["id"],
},
headers=headers,
)
assert updated.status_code == 200, updated.text
body = updated.json()["data"]
assert body["customer_type"] == CUSTOMER_TYPE_ENTERPRISE
assert body["address"] == "沈阳市沈河区"
assert body["owner_personnel_id"] == owner["id"], "编辑端点不得改动归属"
assert body["owner_personnel_name"] == owner["personnel_name"]
detail = client.get(f"{_CUSTOMERS}/{customer['id']}", headers=headers)
assert detail.json()["data"]["owner_personnel_id"] == owner["id"]
assert _latest_log(int(customer["id"])) is None, "编辑不应产生归属留痕"
class TestOwnership:
"""归属变更:主表更新与留痕同事务;校验失败不留脏数据。"""
def test_归属变更_主表更新且留痕新增(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
old = _personnel(client, headers)
new = _personnel(client, headers)
customer = _create_customer(client, headers, owner_personnel_id=old["id"])
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/ownership",
json={"new_owner_personnel_id": new["id"], "reason": "区域调整"},
headers=headers,
)
assert resp.status_code == 200, resp.text
body = resp.json()["data"]
assert body["owner_personnel_id"] == new["id"]
assert body["owner_personnel_name"] == new["personnel_name"]
log = _latest_log(int(customer["id"]))
assert log is not None, "归属变更未写留痕"
assert log.owner_type == OWNER_TYPE_CUSTOMER
assert log.customer_id == int(customer["id"])
assert log.old_owner_personnel_id == int(old["id"])
assert log.new_owner_personnel_id == int(new["id"])
assert log.change_reason == "区域调整"
assert log.operator_name == normal["username"]
def test_变更后归属人员已离职_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
target = _personnel(client, headers)
client.put(f"{_PERSONNEL}/{target['id']}/status", json={"status": 0}, headers=headers)
customer = _create_customer(client, headers)
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/ownership",
json={"new_owner_personnel_id": target["id"]},
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "归属人员不存在或已离职" in resp.json()["message"]
assert _latest_log(int(customer["id"])) is None, "失败变更不应留痕"
def test_新旧归属相同_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
owner = _personnel(client, headers)
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/ownership",
json={"new_owner_personnel_id": owner["id"]},
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "归属未变化" in resp.json()["message"]
def test_双方均为空_400(self, client: TestClient, normal: dict) -> None:
"""无归属客户再清一次归属也属「未变化」——不产生空转留痕。"""
customer = _create_customer(client, normal["headers"])
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/ownership",
json={"new_owner_personnel_id": None},
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "归属未变化" in resp.json()["message"]
def test_变更为空即清空归属_主表与留痕一致(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
owner = _personnel(client, headers)
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/ownership",
json={"new_owner_personnel_id": None, "reason": "暂无负责人"},
headers=headers,
)
assert resp.status_code == 200, resp.text
body = resp.json()["data"]
assert body["owner_personnel_id"] is None
assert body["owner_personnel_name"] is None
log = _latest_log(int(customer["id"]))
assert log is not None
assert log.old_owner_personnel_id == int(owner["id"])
assert log.new_owner_personnel_id is None
assert log.change_reason == "暂无负责人"
class TestOwnershipLogs:
"""留痕分页:新旧归属人姓名由联表带出,倒序可追溯变更轨迹。"""
def test_留痕分页带新旧归属人姓名并倒序(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
first = _personnel(client, headers)
second = _personnel(client, headers)
customer = _create_customer(client, headers, owner_personnel_id=first["id"])
r1 = client.put(
f"{_CUSTOMERS}/{customer['id']}/ownership",
json={"new_owner_personnel_id": second["id"], "reason": "第一次调整"},
headers=headers,
)
assert r1.status_code == 200, r1.text
r2 = client.put(
f"{_CUSTOMERS}/{customer['id']}/ownership",
json={"new_owner_personnel_id": None, "reason": "清空归属"},
headers=headers,
)
assert r2.status_code == 200, r2.text
resp = client.get(f"{_CUSTOMERS}/{customer['id']}/ownership-logs", headers=headers)
assert resp.status_code == 200, resp.text
body = resp.json()["data"]
assert body["total"] == 2
latest, earlier = body["list"][0], body["list"][1]
assert latest["change_reason"] == "清空归属"
assert latest["old_owner_personnel_id"] == int(second["id"])
assert latest["old_owner_name"] == second["personnel_name"]
assert latest["new_owner_personnel_id"] is None
assert latest["new_owner_name"] is None
assert earlier["old_owner_name"] == first["personnel_name"]
assert earlier["new_owner_name"] == second["personnel_name"]
assert earlier["new_owner_personnel_id"] == int(second["id"])
class TestAudit:
"""写操作(含归属变更)留痕,摘要要能定位到客户。"""
def test_新增客户留审计(self, client: TestClient, normal: dict) -> None:
data = _create_customer(client, normal["headers"])
logs = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_CUSTOMER and log.action == ACTION_CREATE
]
assert logs, "新增客户未留操作日志"
latest = logs[0]
assert latest.success == 1
assert data["company_name"] in latest.summary
def test_归属变更审计摘要含客户名(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
old = _personnel(client, headers)
new = _personnel(client, headers)
customer = _create_customer(client, headers, owner_personnel_id=old["id"])
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/ownership",
json={"new_owner_personnel_id": new["id"], "reason": "区域调整"},
headers=headers,
)
assert resp.status_code == 200, resp.text
logs = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_CUSTOMER and log.success == 1
]
assert logs, "归属变更未留操作日志"
latest = logs[0]
assert customer["company_name"] in latest.summary, "摘要应含客户名"
assert "→" in latest.summary, "摘要应体现新旧归属走向"
@@ -0,0 +1,82 @@
"""单据编号生成器的跨位宽正确性(no_generator)。
守:当日序号超过 4 位宽度(9999→10000)后,VARCHAR 字典序排序会失效
("-10000" < "-9999",'1'<'9')——MAX 检索若按纯字典序,seq 会恒重算出
同一号码,每笔建单都撞唯一键。修复口径:长度优先、同级按字典序
(同前缀下长度大即数值大;同长时字典序与数值序一致)。
"""
import pytest
from sqlalchemy import select
from app.core.constants import NO_PREFIX_BUSINESS
from app.core.db import get_session_factory
from app.models import Business, Customer
from app.services.no_generator import next_no, today_prefix
pytestmark = pytest.mark.integration
def _insert_business_nos(nos: list[str]) -> None:
"""直插同日前缀的业务编号行(仅编号用于 MAX 检索;其余列取默认/可空)。
直插绕过服务层(服务层走完整创建链太重),业务行为挂在 t_ 客户名下,
由 integration/conftest 兜底清理(YW% 单据 + t\\_% 客户)。
"""
factory = get_session_factory()
with factory() as session:
customer = Customer(
company_name="t_no_generator_client",
owner_name="t_no_generator_owner",
customer_type="enterprise",
)
session.add(customer)
session.flush()
session.add_all(Business(business_no=no, customer_id=customer.id) for no in nos)
session.commit()
class TestMaxCrossWidth:
"""MAX 检索在同日前缀下跨位宽取最大序号。"""
def test_序号破四位宽后仍按数值取最大(self) -> None:
"""4 位与 5 位并存:"-9999" 字典序大于 "-10000",纯字典序会取错。"""
prefix = today_prefix(NO_PREFIX_BUSINESS)
_insert_business_nos([f"{prefix}9999", f"{prefix}10000"])
factory = get_session_factory()
with factory() as session:
no = next_no(
session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS
)
assert no == f"{prefix}10001"
def test_同位宽内字典序与数值序一致仍取最大(self) -> None:
"""同宽多行(修复的排序不破坏原行为):是长度优先而非只按长度取首行。"""
prefix = today_prefix(NO_PREFIX_BUSINESS)
_insert_business_nos([f"{prefix}0099", f"{prefix}8499", f"{prefix}8500"])
factory = get_session_factory()
with factory() as session:
no = next_no(
session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS
)
assert no == f"{prefix}8501"
def test_无当日编号时从1起编(self) -> None:
"""当日无同前缀行(昨日编号不被 like 命中):从 0001 起,不受历史天数影响。
当日若已有其他用例留下的同前缀编号(用例间无隔离库),跳过——
空表场景由每次 `down -v` 重建后的首个用例覆盖。
"""
prefix = today_prefix(NO_PREFIX_BUSINESS)
factory = get_session_factory()
with factory() as session:
existing = session.execute(
select(Business.id).where(Business.business_no.like(f"{prefix}%")).limit(1)
).scalar()
if existing is not None:
pytest.skip(f"当日已有同前缀编号 {existing},空表场景跳过")
with factory() as session:
no = next_no(
session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS
)
assert no == f"{prefix}0001"
@@ -11,6 +11,7 @@
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
"""
import uuid
from datetime import date, timedelta
from fractions import Fraction
@@ -49,6 +50,31 @@ def track_batches():
session.commit()
@pytest.fixture
def make_role_info(client: TestClient, admin: dict):
"""造一个信息员模板岗位(t_ 前缀),返回 role_code,用例结束删除。
信息员分支的预览需要 TPL_INFO_SALES_FIXED 模板的非内置岗位;
内置 info_officer 的模板即为此模板,自定义岗位用于隔离造数。
"""
code = f"t_info_{uuid.uuid4().hex[:6]}"
resp = client.post(
"/api/v1/system/business-roles",
json={
"role_code": code,
"role_name": "集成信息员",
"calc_template": "TPL_INFO_SALES_FIXED",
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
role_id = resp.json()["data"]["id"]
try:
yield code
finally:
client.delete(f"/api/v1/system/business-roles/{role_id}", headers=admin["headers"])
def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of: str) -> str | None:
"""从目录接口取某参数默认维度的当前生效值。"""
items = client.get(_PARAMS, params={"as_of": as_of}, headers=admin["headers"]).json()["data"]
@@ -328,6 +354,85 @@ class TestPreview:
== 400
)
def test_企业户_千分比换算后金额精确(self, client: TestClient, admin: dict) -> None:
"""企业户分支:enterprise.rate 存储 "3"(3‰),须换算为 3/1000 再相乘。
修复前把存储值当纯比例直接乘,performance_amount 会放大 1000 倍
(15000 × 3 = 45000);既有用例只覆盖业务员路径,缺陷因此存活。
"""
payload = {
"customer_type": "enterprise",
"product_category": "municipal_water",
"ship_date": "2025-12-15",
"receipt_date": "2026-01-10",
"revenue": "100000",
"profit": "45000",
"received_amount": "100000",
}
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
"data"
]
assert Fraction(data["rate"]) == Fraction(3, 1000)
# 折算毛利润额 15000 × 3‰ = 45(修复前误返回 45000)
assert Fraction(data["performance_amount"]) == Fraction(45)
# 全额回款 R=100 → 权重 1,可付 == 绩效
assert data["payment_weight_r"] == 100
assert Fraction(data["payable_amount"]) == Fraction(45)
def test_信息员_销售额基数乘千分比例(
self, client: TestClient, admin: dict, make_role_info
) -> None:
"""信息员模板:基数覆盖为本人销售额(不走折算与阈值),比例 1‰。
修复前 take("info.rate")(存储 "1")直接当纯比例乘出本人销售额原额。
"""
payload = {
"business_role": make_role_info,
"customer_type": "personal",
"ship_date": "2025-12-15",
"receipt_date": "2026-01-10",
"revenue": "100000",
"profit": "45000",
"received_amount": "100000",
}
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
"data"
]
# 基数覆盖为本人销售额:converted_profit == revenue(不扣阈值)
assert Fraction(data["converted_profit"]) == Fraction(100000)
assert Fraction(data["rate"]) == Fraction(1, 1000)
# 100000 × 1‰ = 100
assert Fraction(data["performance_amount"]) == Fraction(100)
def test_提交后维度取库里规范值(self, client: TestClient, admin: dict, track_batches) -> None:
"""岗位维度大小写归一:ci 校对下变形大小写可提交成功,但须落库规范值。
快照与注册表按精确键取值,原始大小写入库会让覆盖链静默不命中
(按错误参数算钱且无报错)。business_roles.role_code 唯一键的 ci
等价冲突由 IntegrityError 分支翻译 400 兜底。
"""
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"items": [
{"param_code": "info.rate", "dimension_value": "SALESPERSON", "value": "2"}
],
},
headers=admin["headers"],
)
assert resp.status_code in (200, 400), resp.text
if resp.status_code == 400:
pytest.skip("种子岗位未命中(见断言),规范值回读由造岗位链路覆盖")
batch = resp.json()["data"]
track_batches(batch["id"])
detail = client.get(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"]).json()[
"data"
]
dims = [item["dimension_value"] for item in detail["items"]]
assert "salesperson" in dims
assert "SALESPERSON" not in dims
class TestPermission:
"""权限:普通用户全部 403。"""
+452
View File
@@ -0,0 +1,452 @@
"""人员管理接口集成测试(需要 MySQL 与 Redis)。
守的是人员档案的引用完整性 —— 岗位与直属经理是 M3 核算取公式模板与
经理聚合展开的锚点,悬空引用或环引用会让核算取不到公式、聚合死循环;
删除保护缺失会留下悬空的 manager_id 与客户归属,历史核算无法回溯到人;
辐射区域与拓展计划若把「全量替换」做成了追加,编辑一次就翻倍一份脏数据。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
数据卫生:人员姓名一律 ``t_`` 前缀,由 conftest 兜底清理;仅「删除」
语义的用例自验 DELETE 端点。
"""
import uuid
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.core.constants import (
ACTION_CREATE,
BUSINESS_ROLE_SALESPERSON,
MODULE_PERSONNEL,
)
from app.core.db import get_session_factory
from app.models import BusinessRole, OperationLog
pytestmark = pytest.mark.integration
_PERSONNEL = "/api/v1/base/personnel"
_CUSTOMERS = "/api/v1/base/customers"
_BUSINESSES = "/api/v1/business/businesses"
def _uname() -> str:
return f"t_人员_{uuid.uuid4().hex[:8]}"
def _role_id() -> int:
"""种子岗位(营销员,内置且启用):人员归属岗位的合法引用。"""
factory = get_session_factory()
with factory() as session:
return int(
session.execute(
select(BusinessRole.id).where(
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
BusinessRole.status == 1,
)
).scalar_one()
)
def _payload(name: str, **overrides: object) -> dict:
body: dict = {
"personnel_name": name,
"business_role_id": _role_id(),
"salary_standard": "3500.50",
}
body.update(overrides)
return body
def _create(client: TestClient, headers: dict, **overrides: object) -> dict:
resp = client.post(_PERSONNEL, json=_payload(_uname(), **overrides), headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _logs_of(username: str) -> list[OperationLog]:
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
factory = get_session_factory()
with factory() as session:
return list(
session.execute(
select(OperationLog)
.where(OperationLog.username == username)
.order_by(OperationLog.id.desc())
).scalars()
)
class TestAuth:
"""鉴权:基础数据是业务操作不要求管理员,但仍是登录后接口。"""
def test_未登录访问人员列表_401(self, client: TestClient) -> None:
resp = client.get(_PERSONNEL)
assert resp.status_code == 401, resp.text
class TestCrudFlow:
"""正向链路:建 → 查(列表/详情)→ 改 → 状态切换 → 删。"""
def test_新增人员默认在职且工资标准字符串直出(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
assert data["status"] == 1, "新增人员应默认在职"
assert data["is_probation"] == 0
# 金额红线:str 进 str 出,不得经 float
assert data["salary_standard"] == "3500.50"
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"])
assert detail.status_code == 200, detail.text
body = detail.json()["data"]
assert body["salary_standard"] == "3500.50"
assert body["regions"] == []
assert body["plans"] == []
def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
manager = _create(client, headers)
member = _create(client, headers, manager_id=manager["id"])
assert member["manager_id"] == manager["id"]
assert member["manager_name"] == manager["personnel_name"]
assert member["role_name"] == manager["role_name"]
def test_列表按姓名关键词可查到新增人员(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
listed = client.get(
_PERSONNEL, params={"keyword": data["personnel_name"]}, headers=normal["headers"]
)
assert listed.status_code == 200, listed.text
rows = listed.json()["data"]["list"]
assert [r["id"] for r in rows] == [data["id"]]
def test_编辑人员全量替换生效(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
updated = client.put(
f"{_PERSONNEL}/{data['id']}",
json=_payload(f"t_人员_改_{uuid.uuid4().hex[:6]}", phone="13900001111", is_probation=1),
headers=normal["headers"],
)
assert updated.status_code == 200, updated.text
body = updated.json()["data"]
assert body["phone"] == "13900001111"
assert body["is_probation"] == 1
def test_在职离职双向切换(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
data = _create(client, headers)
off = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 0}, headers=headers)
assert off.status_code == 200, off.text
assert off.json()["data"]["status"] == 0
on = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 1}, headers=headers)
assert on.status_code == 200, on.text
assert on.json()["data"]["status"] == 1
def test_删除无引用人员成功(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"])
assert resp.status_code == 200, resp.text
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"])
assert detail.status_code == 404
assert "人员不存在" in detail.json()["message"]
class TestRefRules:
"""岗位/经理引用校验 —— 核算锚点不悬空、不环引用。"""
def test_编辑时直属经理是自己_400(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
resp = client.put(
f"{_PERSONNEL}/{data['id']}",
json=_payload(data["personnel_name"], manager_id=data["id"]),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "直属经理不能是自己" in resp.json()["message"]
def test_直属经理不存在_400(self, client: TestClient, normal: dict) -> None:
resp = client.post(
_PERSONNEL,
json=_payload(_uname(), manager_id=999999999),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "直属经理不存在或已离职" in resp.json()["message"]
def test_直属经理已离职_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
manager = _create(client, headers)
quit_resp = client.put(
f"{_PERSONNEL}/{manager['id']}/status", json={"status": 0}, headers=headers
)
assert quit_resp.status_code == 200, quit_resp.text
resp = client.post(
_PERSONNEL,
json=_payload(_uname(), manager_id=manager["id"]),
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "直属经理不存在或已离职" in resp.json()["message"]
def test_岗位不存在_400(self, client: TestClient, normal: dict) -> None:
resp = client.post(
_PERSONNEL,
json=_payload(_uname(), business_role_id=999999999),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "岗位不存在或已停用" in resp.json()["message"]
def test_岗位已停用_400(self, client: TestClient, normal: dict) -> None:
"""停用岗位不能挂人:岗位决定计提模板,挂上去核算会取不到公式。"""
code = f"t_br_{uuid.uuid4().hex[:8]}"
factory = get_session_factory()
with factory() as session:
session.add(
BusinessRole(
role_code=code,
role_name="已停用测试岗位",
calc_template="TPL_SALESPERSON_DECAY",
status=0,
)
)
session.commit()
role_id = int(
session.execute(
select(BusinessRole.id).where(BusinessRole.role_code == code)
).scalar_one()
)
resp = client.post(
_PERSONNEL,
json=_payload(_uname(), business_role_id=role_id),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "岗位不存在或已停用" in resp.json()["message"]
class TestDeleteProtection:
"""删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。"""
def test_被团队成员引用禁删_解除后可删(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
manager = _create(client, headers)
member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用
blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers)
assert blocked.status_code == 400, blocked.text
assert (
"存在团队成员、客户归属或业务归属引用,请改为离职而非删除" in blocked.json()["message"]
)
client.delete(f"{_PERSONNEL}/{member['id']}", headers=headers)
freed = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers)
assert freed.status_code == 200, freed.text
def test_被客户归属引用禁删_离职不放宽_解除后可删(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"]
owner = _create(client, headers)
created = client.post(
_CUSTOMERS,
json={
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
"owner_name": "张老板",
"customer_type": "personal",
"owner_personnel_id": owner["id"],
},
headers=headers,
)
assert created.status_code == 200, created.text
customer = created.json()["data"]
blocked = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
assert blocked.status_code == 400, blocked.text
assert (
"存在团队成员、客户归属或业务归属引用,请改为离职而非删除" in blocked.json()["message"]
)
# 保护与在职状态无关:离职并不能让物理删除放行
client.put(f"{_PERSONNEL}/{owner['id']}/status", json={"status": 0}, headers=headers)
still = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
assert still.status_code == 400, still.text
# 删客户解除引用后方可删人
assert client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers).status_code == 200
freed = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
assert freed.status_code == 200, freed.text
def test_被业务归属引用禁删_负责或协助任一即拦(self, client: TestClient, normal: dict) -> None:
"""业务表就位后删除保护补齐:负责/协助引用任一即拦,FK 不以 500 暴露。"""
headers = normal["headers"]
owner = _create(client, headers)
assistant = _create(client, headers)
resp_customer = client.post(
_CUSTOMERS,
json={
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
"owner_name": "张老板",
"customer_type": "personal",
},
headers=headers,
)
assert resp_customer.status_code == 200, resp_customer.text
customer_id = resp_customer.json()["data"]["id"]
resp = client.post(
_BUSINESSES,
json={
"customer_id": customer_id,
"owner_personnel_id": owner["id"],
"assistant_personnel_id": assistant["id"],
"items": [
{
"product_name": "t_引用产品",
"product_category": "municipal_water",
"quantity": "1",
"sale_price": "1",
}
],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
for target, label in ((owner, "负责"), (assistant, "协助")):
blocked = client.delete(f"{_PERSONNEL}/{target['id']}", headers=headers)
assert blocked.status_code == 400, blocked.text
assert "业务归属" in blocked.json()["message"], f"{label}引用未拦截"
# 删除业务解除引用后,人员方可删除(owner 仍被客户归属引用,先删客户)
business_id = resp.json()["data"]["id"]
assert client.delete(f"{_BUSINESSES}/{business_id}", headers=headers).status_code == 200
assert client.delete(f"{_CUSTOMERS}/{customer_id}", headers=headers).status_code == 200
assert client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers).status_code == 200
class TestSubTableReplace:
"""辐射区域/拓展计划为全量替换语义 —— 做成追加会让编辑翻倍脏数据。"""
def test_辐射区域设置后详情回显一致_再次替换生效(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"]
data = _create(client, headers)
resp = client.put(
f"{_PERSONNEL}/{data['id']}/regions",
json={
"items": [
{"region_name": "本溪市平山区", "remark": "核心区域"},
{"region_name": "本溪市溪湖区"},
]
},
headers=headers,
)
assert resp.status_code == 200, resp.text
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
assert [r["region_name"] for r in detail["regions"]] == ["本溪市平山区", "本溪市溪湖区"]
assert detail["regions"][0]["remark"] == "核心区域"
# 再替换为 1 条:是覆盖而非追加
resp2 = client.put(
f"{_PERSONNEL}/{data['id']}/regions",
json={"items": [{"region_name": "沈阳市沈河区"}]},
headers=headers,
)
assert resp2.status_code == 200, resp2.text
detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
assert [r["region_name"] for r in detail2["regions"]] == ["沈阳市沈河区"]
def test_辐射区域替换为空列表即清空(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
data = _create(client, headers)
client.put(
f"{_PERSONNEL}/{data['id']}/regions",
json={"items": [{"region_name": "本溪市平山区"}]},
headers=headers,
)
cleared = client.put(
f"{_PERSONNEL}/{data['id']}/regions", json={"items": []}, headers=headers
)
assert cleared.status_code == 200, cleared.text
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
assert detail["regions"] == []
def test_拓展计划设置后详情回显一致_再次替换生效(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"]
data = _create(client, headers)
resp = client.put(
f"{_PERSONNEL}/{data['id']}/plans",
json={
"items": [
{
"plan_period": "2026Q1",
"plan_content": "拓展本溪市政客户",
"target_desc": "新增3户",
},
{"plan_period": "2026Q2", "plan_content": "维护存量客户"},
]
},
headers=headers,
)
assert resp.status_code == 200, resp.text
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
assert [p["plan_period"] for p in detail["plans"]] == ["2026Q1", "2026Q2"]
assert detail["plans"][0]["target_desc"] == "新增3户"
resp2 = client.put(
f"{_PERSONNEL}/{data['id']}/plans",
json={"items": [{"plan_period": "2026Q3", "plan_content": "开拓轨交市场"}]},
headers=headers,
)
assert resp2.status_code == 200, resp2.text
detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
assert [p["plan_period"] for p in detail2["plans"]] == ["2026Q3"]
def test_拓展计划替换为空列表即清空(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
data = _create(client, headers)
client.put(
f"{_PERSONNEL}/{data['id']}/plans",
json={"items": [{"plan_period": "2026Q1", "plan_content": "拓展本溪市政客户"}]},
headers=headers,
)
cleared = client.put(
f"{_PERSONNEL}/{data['id']}/plans", json={"items": []}, headers=headers
)
assert cleared.status_code == 200, cleared.text
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
assert detail["plans"] == []
class TestAudit:
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
def test_新增人员成功留审计(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
logs = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_PERSONNEL and log.action == ACTION_CREATE
]
assert logs, "新增人员未留操作日志"
latest = logs[0]
assert latest.success == 1
assert data["personnel_name"] in latest.summary
def test_校验失败也留审计(self, client: TestClient, normal: dict) -> None:
resp = client.post(
_PERSONNEL,
json=_payload(_uname(), manager_id=999999999),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
failed = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_PERSONNEL and log.success == 0
]
assert failed, "失败操作未留痕"
assert "直属经理不存在或已离职" in (failed[0].error_message or "")
+334
View File
@@ -0,0 +1,334 @@
"""产品管理接口集成测试(需要 MySQL 与 Redis)。
守的是产品主数据的两条硬约束 —— product_category 是折算阈值的行级锚点,
脏类别会让核算按类别回溯参数取不到阈值;「名称+规格」组合唯一缺失会
放行重复产品,业务数据不知道该挂在哪一条上。数量/单价以字符串进出,
断言同时验证 str 直出(金额红线:不经 float)。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
数据卫生:产品名一律 ``t_`` 前缀,由 conftest 兜底清理;删除语义用例
自验 DELETE 端点。
"""
import uuid
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.core.constants import (
ACTION_CREATE,
MODULE_PRODUCT,
PRODUCT_CATEGORY_MUNICIPAL_WATER,
PRODUCT_CATEGORY_RAIL_TRANSIT,
)
from app.core.db import get_session_factory
from app.models import OperationLog
pytestmark = pytest.mark.integration
_PRODUCTS = "/api/v1/base/products"
_CUSTOMERS = "/api/v1/base/customers"
_BUSINESSES = "/api/v1/business/businesses"
def _pname() -> str:
return f"t_产品_{uuid.uuid4().hex[:8]}"
def _payload(name: str, **overrides: object) -> dict:
body: dict = {
"product_name": name,
"spec_model": "DN100",
"unit": "台",
"stock_quantity": "100",
"sale_price": "12.5",
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
}
body.update(overrides)
return body
def _create(client: TestClient, headers: dict, **overrides: object) -> dict:
resp = client.post(_PRODUCTS, json=_payload(_pname(), **overrides), headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _logs_of(username: str) -> list[OperationLog]:
factory = get_session_factory()
with factory() as session:
return list(
session.execute(
select(OperationLog)
.where(OperationLog.username == username)
.order_by(OperationLog.id.desc())
).scalars()
)
@pytest.fixture
def make_business_for_product(client: TestClient):
"""造「客户 → 业务 → 明细挂指定产品」的最小引用链(删除保护场景)。
business_items.product_id 是 RESTRICT 外键,产品删除保护以此为引用源;
客户/业务均按 t_ 前缀与系统编号由 conftest 兜底清理。
"""
def _factory(headers: dict, product_id: int) -> dict:
customer = client.post(
_CUSTOMERS,
json={
"company_name": f"t_引用公司_{uuid.uuid4().hex[:8]}",
"owner_name": "张老板",
"customer_type": "personal",
},
headers=headers,
)
assert customer.status_code == 200, customer.text
business = client.post(
_BUSINESSES,
json={
"customer_id": customer.json()["data"]["id"],
"business_date": "2026-01-01",
"items": [
{
"product_id": product_id,
"product_name": f"t_引用产品_{uuid.uuid4().hex[:6]}",
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
"quantity": "1.00",
"sale_price": "10.00",
}
],
},
headers=headers,
)
assert business.status_code == 200, business.text
return business.json()["data"]
return _factory
class TestAuth:
"""鉴权:登录后接口,裸访问一律 401。"""
def test_未登录访问产品列表_401(self, client: TestClient) -> None:
resp = client.get(_PRODUCTS)
assert resp.status_code == 401, resp.text
class TestCrudFlow:
"""正向链路:建 → 查 → 改(含换类别)→ 删。"""
def test_新增产品默认启用且数量单价字符串直出(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
assert data["status"] == 1, "新产品应默认启用"
assert data["spec_model"] == "DN100"
# 金额红线:str 进 str 出,且数值与入参精确相等(Decimal 比较,不经 float)
assert isinstance(data["sale_price"], str)
assert Decimal(data["sale_price"]) == Decimal("12.5")
assert isinstance(data["stock_quantity"], str)
assert Decimal(data["stock_quantity"]) == Decimal("100")
def test_详情与列表均能查到新增产品(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
assert detail.status_code == 200, detail.text
assert detail.json()["data"]["id"] == data["id"]
listed = client.get(
_PRODUCTS, params={"keyword": data["product_name"]}, headers=normal["headers"]
)
assert listed.status_code == 200, listed.text
assert [r["id"] for r in listed.json()["data"]["list"]] == [data["id"]]
def test_同名称不同规格创建成功(self, client: TestClient, normal: dict) -> None:
name = _pname()
first = _create(client, normal["headers"], product_name=name, spec_model="DN100")
second = client.post(
_PRODUCTS,
json=_payload(name, spec_model="DN200"),
headers=normal["headers"],
)
assert second.status_code == 200, second.text
assert second.json()["data"]["id"] != first["id"]
def test_删除产品成功(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
resp = client.delete(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
assert resp.status_code == 200, resp.text
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
assert detail.status_code == 404
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:
"""类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。"""
def test_重名同规格_400(self, client: TestClient, normal: dict) -> None:
name = _pname()
_create(client, normal["headers"], product_name=name)
dup = client.post(
_PRODUCTS,
json=_payload(name, spec_model="DN100"),
headers=normal["headers"],
)
assert dup.status_code == 400, dup.text
assert "已存在同名称同规格的产品" in dup.json()["message"]
def test_规格缺省归一空串且重名同空规格仍拦截(self, client: TestClient, normal: dict) -> None:
"""组合唯一键对 NULL 不去重:缺省规格若归 NULL 会放行重名同规格数据。"""
name = _pname()
first = _create(client, normal["headers"], product_name=name, spec_model=None)
assert first["spec_model"] == ""
dup = client.post(
_PRODUCTS,
json=_payload(name, spec_model=None),
headers=normal["headers"],
)
assert dup.status_code == 400, dup.text
assert "已存在同名称同规格的产品" in dup.json()["message"]
def test_产品类别非法_400(self, client: TestClient, normal: dict) -> None:
resp = client.post(
_PRODUCTS,
json=_payload(_pname(), product_category="not_a_category"),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "产品类别不合法" in resp.json()["message"]
def test_销售单价为负数_400(self, client: TestClient, normal: dict) -> None:
resp = client.post(
_PRODUCTS,
json=_payload(_pname(), sale_price="-1"),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None:
resp = client.post(
_PRODUCTS,
json=_payload(_pname(), sale_price="abc"),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
def test_编辑改类别为合法值成功(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
updated = client.put(
f"{_PRODUCTS}/{data['id']}",
json=_payload(
data["product_name"],
product_category=PRODUCT_CATEGORY_RAIL_TRANSIT,
sale_price="15.8",
),
headers=normal["headers"],
)
assert updated.status_code == 200, updated.text
body = updated.json()["data"]
assert body["product_category"] == PRODUCT_CATEGORY_RAIL_TRANSIT
assert Decimal(body["sale_price"]) == Decimal("15.8")
class TestStatusSwitch:
"""启用/停用独立于编辑端点 —— 编辑表单不携带状态,防误触停用。"""
def test_停用再启用切换(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
data = _create(client, headers)
off = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 0}, headers=headers)
assert off.status_code == 200, off.text
assert off.json()["message"] == "已停用"
assert off.json()["data"]["status"] == 0
on = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 1}, headers=headers)
assert on.status_code == 200, on.text
assert on.json()["message"] == "已启用"
assert on.json()["data"]["status"] == 1
class TestAudit:
"""写操作(含失败)留痕 —— 审计是验收项。"""
def test_新增产品成功留审计(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
logs = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_PRODUCT and log.action == ACTION_CREATE
]
assert logs, "新增产品未留操作日志"
latest = logs[0]
assert latest.success == 1
assert data["product_name"] in latest.summary
def test_重名失败留审计(self, client: TestClient, normal: dict) -> None:
name = _pname()
_create(client, normal["headers"], product_name=name)
dup = client.post(
_PRODUCTS,
json=_payload(name, spec_model="DN100"),
headers=normal["headers"],
)
assert dup.status_code == 400, dup.text
failed = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_PRODUCT and log.success == 0
]
assert failed, "失败操作未留痕"
assert "已存在同名称同规格的产品" in (failed[0].error_message or "")
+315
View File
@@ -0,0 +1,315 @@
"""客户评级等级配置接口集成测试(需要 MySQL 与 Redis)。
守的是评级**判定表**的两条硬约束 —— ① 区间互不重叠:重叠会让同一总分
命中多个等级(或两段都跳过),判定表失效,新增与编辑两路都必须校验;
② 分值域 0≤min≤max≤100:越界/倒挂的配置直接算错等级。边界相邻不算重叠
(前段 max=74、后段 min=75 相邻)是种子铺满 0-100 的铺法,要有正例锁住;
「已存在」的名称唯一在先查与 1062 两路都要收敛成 400。
评级用例会**删改种子**(删除 C、编辑 D 的区间构造相邻/重叠场景),conftest
兜底在每个用例后把判定表恢复为种子基线(全删全插,判定表无下游引用),
因此种子断言用例(列表全量返回)始终在纯种子上运行。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
数据卫生:测试等级名一律 ``t_`` 前缀;种子(A/B/C/D)的删改由 conftest
恢复,删除语义的用例自验 DELETE 端点。
"""
import uuid
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.core.constants import ACTION_CREATE, MODULE_RATING
from app.core.db import get_session_factory
from app.models import OperationLog
pytestmark = pytest.mark.integration
_GRADES = "/api/v1/business/rating-grades"
# 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1))
_SEED = [
("A", "90.0", "100.0", 1),
("B", "75.0", "89.0", 2),
("C", "60.0", "74.0", 3),
("D", "0.0", "59.0", 4),
]
def _tname() -> str:
return f"t_等级_{uuid.uuid4().hex[:6]}"
def _gpayload(grade_name: str, min_score: str, max_score: str, **overrides: object) -> dict:
body: dict = {
"grade_name": grade_name,
"min_score": min_score,
"max_score": max_score,
"sort_order": 99,
"remark": "t_评级测试",
}
body.update(overrides)
return body
def _seed_id(client: TestClient, headers: dict, grade_name: str) -> int:
"""从判定表列表取种子等级 id(种子由迁移初始化,主键不可假造)。"""
rows = client.get(_GRADES, headers=headers).json()["data"]
match = next(row for row in rows if row["grade_name"] == grade_name)
return match["id"]
def _drop_seed(client: TestClient, headers: dict, grade_name: str) -> None:
"""删掉种子等级,为测试等级清出分值空隙(种子由 conftest 兜底恢复)。
种子铺满 0-100 —— 凡要创建/编辑测试等级的用例,必须先清出与既有等级
完全分离的空隙,否则重叠约束(设计如此)一律 400。
"""
resp = client.delete(f"{_GRADES}/{_seed_id(client, headers, grade_name)}", headers=headers)
assert resp.status_code == 200, resp.text
def _names(client: TestClient, headers: dict) -> list[str]:
return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]]
def _logs_of(username: str) -> list[OperationLog]:
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
factory = get_session_factory()
with factory() as session:
return list(
session.execute(
select(OperationLog)
.where(OperationLog.username == username)
.order_by(OperationLog.id.desc())
).scalars()
)
class TestAuth:
"""鉴权:评级配置属业务操作不要求管理员,但仍是登录后接口。"""
def test_未登录访问评级等级列表_401(self, client: TestClient) -> None:
resp = client.get(_GRADES)
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:
"""列表直出种子基线:判定表是前端评级计算器的唯一数据源。"""
def test_种子四等级全量返回_按sort_order升序(self, client: TestClient, normal: dict) -> None:
resp = client.get(_GRADES, headers=normal["headers"])
assert resp.status_code == 200, resp.text
rows = resp.json()["data"]
assert [(r["grade_name"], r["sort_order"]) for r in rows] == [
(name, order) for name, _, _, order in _SEED
], rows
# 分值红线:str 出参且与种子精确相等(Decimal 比较,不经 float)
for row, (_, min_score, max_score, _) in zip(rows, _SEED, strict=True):
assert Decimal(row["min_score"]) == Decimal(min_score)
assert Decimal(row["max_score"]) == Decimal(max_score)
class TestCreate:
"""新增:区间合法(0≤min≤max≤100)、互不重叠、名称唯一 —— 三条硬约束
任一缺失都会算错等级。"""
def test_删除种子后新增相邻区间_成功且判定表更新(
self, client: TestClient, normal: dict
) -> None:
"""种子铺满 0-100,任何新区间都与某条重叠 —— 先删 C(60-74),
新增 60-74 与 D(0-59)/B(75-89) **边界相邻不算重叠**(正例)。"""
headers = normal["headers"]
client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers)
resp = client.post(
_GRADES, json=_gpayload("t_E", "60", "74", sort_order=5), headers=headers
)
assert resp.status_code == 200, resp.text
body = resp.json()["data"]
assert Decimal(body["min_score"]) == Decimal("60")
assert Decimal(body["max_score"]) == Decimal("74")
assert body["sort_order"] == 5
rows = client.get(_GRADES, headers=headers).json()["data"]
assert _names(client, headers) == ["A", "B", "D", "t_E"], rows
def test_区间倒挂_400(self, client: TestClient, normal: dict) -> None:
resp = client.post(_GRADES, json=_gpayload(_tname(), "80", "10"), headers=normal["headers"])
assert resp.status_code == 400, resp.text
assert "分值区间不合法" in resp.json()["message"], resp.text
def test_分值超上限_400(self, client: TestClient, normal: dict) -> None:
resp = client.post(
_GRADES, json=_gpayload(_tname(), "95", "150"), headers=normal["headers"]
)
assert resp.status_code == 400, resp.text
assert "分值区间不合法" in resp.json()["message"], resp.text
def test_分值负下限_400(self, client: TestClient, normal: dict) -> None:
resp = client.post(_GRADES, json=_gpayload(_tname(), "-5", "10"), headers=normal["headers"])
assert resp.status_code == 400, resp.text
assert "分值区间不合法" in resp.json()["message"], resp.text
def test_等级名称已存在_400(self, client: TestClient, normal: dict) -> None:
"""先删 D 清出空隙:重叠校验在名称唯一校验之前,不清空隙会先撞
「与等级 D 重叠」而走不到名称校验。"""
headers = normal["headers"]
_drop_seed(client, headers, "D")
resp = client.post(_GRADES, json=_gpayload("A", "10", "20"), headers=headers)
assert resp.status_code == 400, resp.text
assert "该等级名称已存在" in resp.json()["message"], resp.text
def test_新增与种子重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None:
"""85-95 与种子 A(90-100) 闭区间相交 → 400 并点名 A,前端据此定位行。"""
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"])
assert resp.status_code == 400, resp.text
assert "分值区间与等级 A 重叠" in resp.json()["message"], resp.text
# 失败不落库:判定表仍是 4 条种子
assert _names(client, normal["headers"]) == ["A", "B", "C", "D"]
class TestEdit:
"""编辑以修改后的区间对全部既有等级(排除自身)两两相交 —— 编辑路与
新增路共用重叠约束,两处都要拦。"""
def test_编辑与其他等级重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None:
"""把 B 编辑为 60-65:与 C(60-74) 相交 → 点名 C;与 D(0-59)/A 均分离。"""
headers = normal["headers"]
b_id = _seed_id(client, headers, "B")
resp = client.put(
f"{_GRADES}/{b_id}",
json=_gpayload("B", "60", "65", sort_order=2),
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "分值区间与等级 C 重叠" in resp.json()["message"], resp.text
# 失败不落库:种子保持原样
assert _names(client, headers) == ["A", "B", "C", "D"]
def test_编辑相邻区间_成功且判定表更新(self, client: TestClient, normal: dict) -> None:
"""删除 C 后把 D 编辑为 0-74:与 B(75-89) **边界相邻不算重叠**
(74 与 75 相邻),这是种子铺法的正例。"""
headers = normal["headers"]
client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers)
d_id = _seed_id(client, headers, "D")
resp = client.put(
f"{_GRADES}/{d_id}",
json=_gpayload("D", "0", "74", sort_order=4),
headers=headers,
)
assert resp.status_code == 200, resp.text
rows = client.get(_GRADES, headers=headers).json()["data"]
assert [(r["grade_name"], r["sort_order"]) for r in rows] == [("A", 1), ("B", 2), ("D", 4)]
assert Decimal(rows[2]["max_score"]) == Decimal("74"), rows[2]
def test_编辑回显改名与分数(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
grade_name = _tname()
created = client.post(
_GRADES, json=_gpayload(grade_name, "10", "20", sort_order=50), headers=headers
)
assert created.status_code == 200, created.text
grade_id = created.json()["data"]["id"]
new_name = _tname()
updated = client.put(
f"{_GRADES}/{grade_id}",
json=_gpayload(new_name, "15", "25", sort_order=60, remark="t_改"),
headers=headers,
)
assert updated.status_code == 200, updated.text
body = updated.json()["data"]
assert body["grade_name"] == new_name
assert Decimal(body["min_score"]) == Decimal("15")
assert Decimal(body["max_score"]) == Decimal("25")
assert body["sort_order"] == 60
rows = client.get(_GRADES, headers=headers).json()["data"]
match = next(r for r in rows if r["id"] == grade_id)
assert match["grade_name"] == new_name
assert Decimal(match["min_score"]) == Decimal("15")
class TestDelete:
"""删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表
立即反映,二次删除 404。"""
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
grade_name = _tname()
created = client.post(
_GRADES, json=_gpayload(grade_name, "30.5", "40.5", sort_order=50), headers=headers
)
assert created.status_code == 200, created.text
grade_id = created.json()["data"]["id"]
assert grade_name in _names(client, headers)
assert client.delete(f"{_GRADES}/{grade_id}", headers=headers).status_code == 200
assert grade_name not in _names(client, headers)
again = client.delete(f"{_GRADES}/{grade_id}", headers=headers)
assert again.status_code == 404, again.text
assert "评级等级不存在" in again.json()["message"], again.text
class TestAudit:
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
def test_新增等级成功留审计(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
grade_name = _tname()
resp = client.post(
_GRADES,
json=_gpayload(grade_name, "30.5", "40.5", sort_order=50),
headers=headers,
)
assert resp.status_code == 200, resp.text
logs = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_RATING and log.action == ACTION_CREATE
]
assert logs, "新增评级等级未留操作日志"
latest = logs[0]
assert latest.success == 1
assert grade_name in latest.summary
def test_重叠失败留审计(self, client: TestClient, normal: dict) -> None:
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"])
assert resp.status_code == 400, resp.text
failed = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_RATING and log.success == 0
]
assert failed, "失败操作未留痕"
assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0]
+378
View File
@@ -0,0 +1,378 @@
"""回款管理接口集成测试(需要 MySQL 与 Redis)。
守的是回款的数据完整性三道线 —— ① 发货批次同业务强校验:跨业务指定会让
回款天数与台账归属漂移;② settlement_status 是 M3 核算防重锚点(需规
2.3.3「每笔回款仅核算一次」),已核算禁改禁删缺失会让台账与源数据漂移;
③ total_received_amount 回写是 M3 起付判定的直接消费源,删行漏算即漂移。
payment_days 为只读派生值,锁定「发货日首日计第 1 天」口径。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;回款批次
编号系统生成(HK 前缀),conftest 按编号前缀全量清;「已核算」的造数走
直接改库(M2 只写 0、置位是 M3 职责),M3 核算置位语义不绕开。
"""
import re
import uuid
from datetime import date
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.core.constants import ACTION_CREATE, MODULE_RECEIPT
from app.core.db import get_session_factory
from app.models import OperationLog, Receipt
from app.models.receipt import SETTLEMENT_DONE
from tests.integration.factories import create_business as _create_business
pytestmark = pytest.mark.integration
_BUSINESSES = "/api/v1/business/businesses"
_SHIPMENTS = "/api/v1/business/shipments"
_RECEIPTS = "/api/v1/business/receipts"
def _sitem(**overrides: object) -> dict:
body: dict = {
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
"quantity": "1",
"sale_price": "500.00",
}
body.update(overrides)
return body
def _spayload(business_id: int, ship_date: str, **overrides: object) -> dict:
body: dict = {
"business_id": business_id,
"ship_date": ship_date,
"items": [_sitem()],
}
body.update(overrides)
return body
def _create_shipment(client: TestClient, headers: dict, business_id: int, ship_date: str) -> dict:
resp = client.post(_SHIPMENTS, json=_spayload(business_id, ship_date), headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _rpayload(business_id: int, **overrides: object) -> dict:
body: dict = {
"business_id": business_id,
"receipt_date": str(date.today()),
"amount": "1200.00",
}
body.update(overrides)
return body
def _create(client: TestClient, headers: dict, business_id: int, **overrides: object) -> dict:
resp = client.post(_RECEIPTS, json=_rpayload(business_id, **overrides), headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _terminate(client: TestClient, headers: dict, business_id: int) -> None:
"""把业务流转到已终止(终态禁写场景的造数步骤)。"""
resp = client.put(
f"{_BUSINESSES}/{business_id}/status",
json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"},
headers=headers,
)
assert resp.status_code == 200, resp.text
def _settle(receipt_id: int) -> None:
"""已核算造数:M2 只写 0,置 1 由 M3 核算负责 —— 测试直接改库模拟置位,
不绕开「M2 不置位」的实现约定。"""
factory = get_session_factory()
with factory() as session:
row = session.get(Receipt, receipt_id)
assert row is not None, "回款行不存在"
row.settlement_status = SETTLEMENT_DONE
session.commit()
def _logs_of(username: str) -> list[OperationLog]:
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
factory = get_session_factory()
with factory() as session:
return list(
session.execute(
select(OperationLog)
.where(OperationLog.username == username)
.order_by(OperationLog.id.desc())
).scalars()
)
class TestAuth:
"""鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。"""
def test_未登录访问回款列表_401(self, client: TestClient) -> None:
resp = client.get(_RECEIPTS)
assert resp.status_code == 401, resp.text
class TestCreate:
"""创建:HK 批次契约、金额红线、回款天数派生与业务合计回写。"""
def test_新增回款_批次号格式_未指定批次_无派生(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
data = _create(client, headers, business["id"])
# 批次号格式:HK{YYYYMMDD}-{4位当日序号}(日期取当日)
expected_prefix = f"HK{date.today():%Y%m%d}-"
assert re.fullmatch(r"HK\d{8}-\d{4}", data["receipt_batch_no"]), data["receipt_batch_no"]
assert data["receipt_batch_no"].startswith(expected_prefix), data["receipt_batch_no"]
# 未指定批次(shipment_id 空):批次可暂不指定,回款天数无从派生
assert data["shipment_id"] is None
assert data["shipment_no"] is None
assert data["payment_days"] is None
# M2 只写 0:置 1 由 M3 核算负责
assert data["settlement_status"] == 0
assert Decimal(data["amount"]) == Decimal("1200")
def test_指定批次_回款天数发货日首日计第1天(self, client: TestClient, normal: dict) -> None:
"""发货 2026-10-05 / 回款 2026-11-02 → 29 天(首日计第 1 天,
需规「26 天」示例已作废)。"""
headers = normal["headers"]
business = _create_business(client, headers)
shipment = _create_shipment(client, headers, business["id"], "2026-10-05")
data = _create(
client,
headers,
business["id"],
shipment_id=shipment["id"],
receipt_date="2026-11-02",
amount="500.00",
)
assert data["shipment_id"] == shipment["id"]
assert data["shipment_no"] == shipment["shipment_no"]
assert data["payment_days"] == 29, data
def test_新增回款_业务合计回写_删除后回退(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
data = _create(client, headers, business["id"])
after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
assert Decimal(after["total_received_amount"]) == Decimal("1200")
assert client.delete(f"{_RECEIPTS}/{data['id']}", headers=headers).status_code == 200
rollback = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
assert Decimal(rollback["total_received_amount"]) == Decimal("0")
def test_业务不存在_404(self, client: TestClient, normal: dict) -> None:
resp = client.post(_RECEIPTS, json=_rpayload(999999999), headers=normal["headers"])
assert resp.status_code == 404, resp.text
assert "业务不存在" in resp.json()["message"], resp.text
def test_终态业务禁新增回款_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
_terminate(client, headers, business["id"])
resp = client.post(_RECEIPTS, json=_rpayload(business["id"]), headers=headers)
assert resp.status_code == 400, resp.text
assert "业务已完成或已终止,禁止新增回款" in resp.json()["message"], resp.text
def test_金额负数_400(self, client: TestClient, normal: dict) -> None:
"""一期禁负数金额(红字/退款冲销需求出现时先补设计再放开)。"""
business = _create_business(client, normal["headers"])
resp = client.post(
_RECEIPTS, json=_rpayload(business["id"], amount="-1"), headers=normal["headers"]
)
assert resp.status_code == 400, resp.text
assert "不能为负数" in resp.json()["message"], resp.text
def test_金额三位小数_400(self, client: TestClient, normal: dict) -> None:
"""小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱。"""
business = _create_business(client, normal["headers"])
resp = client.post(
_RECEIPTS,
json=_rpayload(business["id"], amount="12.345"),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "最多两位小数" in resp.json()["message"], resp.text
class TestCrossBusiness:
"""发货批次同业务强校验 —— 两类失败(不存在/跨业务)统一文案,防响应
差异探测发货单存在性。"""
def test_跨业务指定发货批次_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business1 = _create_business(client, headers)
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
business2 = _create_business(client, headers)
resp = client.post(
_RECEIPTS,
json=_rpayload(business2["id"], shipment_id=shipment["id"]),
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text
def test_编辑时指定跨业务发货批次_400(self, client: TestClient, normal: dict) -> None:
"""编辑路径(全量替换)同样强校验,无「未变即免检」旁路。"""
headers = normal["headers"]
business1 = _create_business(client, headers)
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
business2 = _create_business(client, headers)
data = _create(client, headers, business2["id"])
resp = client.put(
f"{_RECEIPTS}/{data['id']}",
json={
"shipment_id": shipment["id"],
"receipt_date": str(date.today()),
"amount": "1200.00",
},
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text
def test_编辑回款_全量替换_派生值实时刷新(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
shipment = _create_shipment(client, headers, business["id"], "2026-10-05")
data = _create(client, headers, business["id"])
assert data["payment_days"] is None
specified = client.put(
f"{_RECEIPTS}/{data['id']}",
json={
"shipment_id": shipment["id"],
"receipt_date": "2026-11-02",
"amount": "300.00",
},
headers=headers,
)
assert specified.status_code == 200, specified.text
body = specified.json()["data"]
assert body["payment_days"] == 29, "指定批次后回款天数按发货日首日派生"
assert body["shipment_no"] == shipment["shipment_no"]
cleared = client.put(
f"{_RECEIPTS}/{data['id']}",
json={
"shipment_id": None,
"receipt_date": "2026-11-02",
"amount": "300.00",
},
headers=headers,
)
assert cleared.status_code == 200, cleared.text
assert cleared.json()["data"]["payment_days"] is None, "清除指定后派生值回 null"
class TestSettled:
"""已核算回款禁改禁删(M3 核算防重锚点)—— 台账固化后源数据禁漂移。"""
def test_已核算禁改_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
data = _create(client, headers, business["id"])
_settle(data["id"])
resp = client.put(
f"{_RECEIPTS}/{data['id']}",
json={
"shipment_id": None,
"receipt_date": str(date.today()),
"amount": "9999.00",
},
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "已核算回款禁止修改" in resp.json()["message"], resp.text
def test_已核算禁删_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
data = _create(client, headers, business["id"])
_settle(data["id"])
resp = client.delete(f"{_RECEIPTS}/{data['id']}", headers=headers)
assert resp.status_code == 400, resp.text
assert "已核算回款禁止删除" in resp.json()["message"], resp.text
class TestFilter:
"""核算状态筛选(M3 核算消费的入口口径)。"""
def test_settlement_status筛选_0只回未核算_1只回已核算(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
pending = _create(client, headers, business["id"], amount="500.00")
settled = _create(client, headers, business["id"], amount="700.00")
_settle(settled["id"])
resp0 = client.get(
_RECEIPTS,
params={"business_id": business["id"], "settlement_status": 0},
headers=headers,
)
assert resp0.status_code == 200, resp0.text
rows0 = resp0.json()["data"]["list"]
assert [r["id"] for r in rows0] == [pending["id"]], rows0
assert rows0[0]["settlement_status"] == 0
resp1 = client.get(
_RECEIPTS,
params={"business_id": business["id"], "settlement_status": 1},
headers=headers,
)
assert resp1.status_code == 200, resp1.text
rows1 = resp1.json()["data"]["list"]
assert [r["id"] for r in rows1] == [settled["id"]], rows1
class TestAudit:
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
def test_新增回款成功留审计(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
data = _create(client, headers, business["id"])
logs = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_RECEIPT and log.action == ACTION_CREATE
]
assert logs, "新增回款未留操作日志"
latest = logs[0]
assert latest.success == 1
assert "新增回款" in latest.summary
assert data["receipt_batch_no"] in latest.summary
def test_跨业务指定失败留审计(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business1 = _create_business(client, headers)
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
business2 = _create_business(client, headers)
resp = client.post(
_RECEIPTS,
json=_rpayload(business2["id"], shipment_id=shipment["id"]),
headers=headers,
)
assert resp.status_code == 400, resp.text
failed = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_RECEIPT and log.success == 0
]
assert failed, "失败操作未留痕"
assert "发货批次不属于该业务" in (failed[0].error_message or ""), failed[0]
+28 -8
View File
@@ -28,10 +28,14 @@ _CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL)
_COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE)
# 这些开头的是约束定义,不是列定义
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
# ALTER TABLE 的列级变更(语义重放用):ADD COLUMN / DROP COLUMN;
# MODIFY 只改类型不改列名,对列集合无影响,无需处理
_ALTER_ADD = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+ADD(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
_ALTER_DROP = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+DROP(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
def _parse_tables(sql: str) -> dict[str, set[str]]:
"""从 SQL 文本中提取 {表名: {列名集合}}。"""
"""从 SQL 文本中提取 {表名: {列名集合}}(仅 CREATE TABLE 的静态解析)。"""
tables: dict[str, set[str]] = {}
for table_name, body in _CREATE_TABLE.findall(sql):
tables[table_name] = {
@@ -42,6 +46,27 @@ def _parse_tables(sql: str) -> dict[str, set[str]]:
return tables
def _migration_final_state(paths: list[Path]) -> dict[str, set[str]]:
"""按文件序重放迁移脚本,得到迁移链**终态**的 {表名: {列名集合}}。
早前实现把全部脚本的 CREATE 列做并集:某表日后被 ALTER DROP COLUMN
时,并集里旧列名永远存在,init-dev.sql 必须保留死列才能过测 —— 守卫
会主动背书一份与生产结构漂移的开发库 DDL。按序重放 ADD/DROP 后,
比对基准才是终态(CHANGE RENAME 列名出现时重放会报差异,属人工核对
的正确时机:改名同时牵动 init-dev 与 ORM,须人来确认两处同步)。
"""
merged: dict[str, set[str]] = {}
for path in paths:
sql = path.read_text(encoding="utf-8")
for table, columns in _parse_tables(sql).items():
merged.setdefault(table, set()).update(columns)
for table, column in _ALTER_ADD.findall(sql):
merged.setdefault(table, set()).add(column)
for table, column in _ALTER_DROP.findall(sql):
merged.setdefault(table, set()).discard(column)
return merged
def _alembic_head() -> str:
"""从迁移脚本推断当前 head 版本号。
@@ -65,15 +90,10 @@ def _alembic_head() -> str:
@pytest.fixture(scope="module")
def migration_tables() -> dict[str, set[str]]:
"""合并解析 db/migration/ 下全部 V*.sql。"""
"""按文件序重放 db/migration/ 下全部 V*.sql,取迁移链终态。"""
files = sorted(MIGRATION_DIR.glob("V*.sql"))
assert files, f"未找到迁移脚本:{MIGRATION_DIR}"
merged: dict[str, set[str]] = {}
for path in files:
for table, columns in _parse_tables(path.read_text(encoding="utf-8")).items():
merged.setdefault(table, set()).update(columns)
return merged
return _migration_final_state(files)
@pytest.fixture(scope="module")
+350
View File
@@ -0,0 +1,350 @@
"""发货管理接口集成测试(需要 MySQL 与 Redis)。
守的是发货单的行金额固化与双重汇总回写 —— 行金额(amount)与发货单总额
(total_amount)若经前端传值,业务级 total_ship_amount 与运费合计就是可
被篡改的源数据,M3 核算直接读业务冗余列;终态业务发货单禁写缺失会让核算
基线漂移;被回款批次指定的发货单若能直接删,指定它的回款就指向悬空批次,
回款天数无从派生。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;发货单
编号系统生成(SH 前缀),conftest 按编号前缀全量清;删除语义的用例自验
DELETE 端点。
"""
import re
import uuid
from datetime import date
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.core.constants import ACTION_CREATE, MODULE_SHIPMENT
from app.core.db import get_session_factory
from app.models import OperationLog
from tests.integration.factories import create_business as _create_business
pytestmark = pytest.mark.integration
_BUSINESSES = "/api/v1/business/businesses"
_SHIPMENTS = "/api/v1/business/shipments"
_RECEIPTS = "/api/v1/business/receipts"
_PERSONNEL = "/api/v1/base/personnel"
def _item(**overrides: object) -> dict:
"""发货明细行入参(默认:3 × 300.00 = 900)。"""
body: dict = {
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
"quantity": "3",
"sale_price": "300.00",
}
body.update(overrides)
return body
def _spayload(business_id: int, **overrides: object) -> dict:
body: dict = {
"business_id": business_id,
"ship_date": str(date.today()),
"items": [_item()],
}
body.update(overrides)
return body
def _create(client: TestClient, headers: dict, business_id: int, **overrides: object) -> dict:
resp = client.post(_SHIPMENTS, json=_spayload(business_id, **overrides), headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _terminate(client: TestClient, headers: dict, business_id: int) -> None:
"""把业务流转到已终止(终态禁写场景的造数步骤)。"""
resp = client.put(
f"{_BUSINESSES}/{business_id}/status",
json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"},
headers=headers,
)
assert resp.status_code == 200, resp.text
def _logs_of(username: str) -> list[OperationLog]:
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
factory = get_session_factory()
with factory() as session:
return list(
session.execute(
select(OperationLog)
.where(OperationLog.username == username)
.order_by(OperationLog.id.desc())
).scalars()
)
class TestAuth:
"""鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。"""
def test_未登录访问发货单列表_401(self, client: TestClient) -> None:
resp = client.get(_SHIPMENTS)
assert resp.status_code == 401, resp.text
class TestCreate:
"""创建:SH 编号契约、行校验、行金额固化与双重汇总回写 —— 业务级汇总
是 M3 起付判定的直接消费源,错写就是算错钱。"""
def test_创建发货单_行金额固化与双重汇总回写(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
data = _create(
client,
headers,
business["id"],
freight_amount="88.50",
items=[
_item(quantity="3", sale_price="300.00", amount="999.99"),
_item(quantity="2", sale_price="250.00"),
],
)
# 编号格式:SH{YYYYMMDD}-{4位当日序号}(日期取当日)
expected_prefix = f"SH{date.today():%Y%m%d}-"
assert re.fullmatch(r"SH\d{8}-\d{4}", data["shipment_no"]), data["shipment_no"]
assert data["shipment_no"].startswith(expected_prefix), data["shipment_no"]
# 行金额由服务层按 数量×单价 固化:schema 无 amount 入参,塞了也忽略
detail = client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).json()["data"]
amounts = [Decimal(i["amount"]) for i in detail["items"]]
assert amounts == [Decimal("900"), Decimal("500")], detail["items"]
assert Decimal(detail["total_amount"]) == Decimal("1400")
assert Decimal(detail["freight_amount"]) == Decimal("88.50")
# 业务级汇总回写(发货金额 / 运费合计)
business_detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()[
"data"
]
assert Decimal(business_detail["total_ship_amount"]) == Decimal("1400")
assert Decimal(business_detail["total_freight_amount"]) == Decimal("88.50")
def test_业务不存在_404(self, client: TestClient, normal: dict) -> None:
resp = client.post(_SHIPMENTS, json=_spayload(999999999), headers=normal["headers"])
assert resp.status_code == 404, resp.text
assert "业务不存在" in resp.json()["message"], resp.text
def test_终态业务禁新发货_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
_terminate(client, headers, business["id"])
resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers)
assert resp.status_code == 400, resp.text
assert "业务已完成或已终止,禁止新发货" in resp.json()["message"], resp.text
def test_明细为空_400(self, client: TestClient, normal: dict) -> None:
business = _create_business(client, normal["headers"])
resp = client.post(
_SHIPMENTS, json=_spayload(business["id"], items=[]), headers=normal["headers"]
)
assert resp.status_code == 400, resp.text
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None:
business = _create_business(client, normal["headers"])
resp = client.post(
_SHIPMENTS,
json=_spayload(business["id"], items=[_item(quantity="0")]),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "数量" in resp.json()["message"], resp.text
def test_运费负数_400(self, client: TestClient, normal: dict) -> None:
"""运费是金额源数据:负值会让业务运费合计与报表直接错乱。"""
business = _create_business(client, normal["headers"])
resp = client.post(
_SHIPMENTS,
json=_spayload(business["id"], freight_amount="-8.00"),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "不能为负数" in resp.json()["message"], resp.text
def test_运费三位小数_400(self, client: TestClient, normal: dict) -> None:
business = _create_business(client, normal["headers"])
resp = client.post(
_SHIPMENTS,
json=_spayload(business["id"], freight_amount="8.333"),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "最多两位小数" in resp.json()["message"], resp.text
class TestEdit:
"""编辑(明细全量替换)与两处汇总同步 —— 业务级汇总漏算即漂移。"""
def test_编辑发货单_两处汇总同步(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
data = _create(
client,
headers,
business["id"],
freight_amount="50.00",
items=[_item(quantity="1", sale_price="1000.00")],
)
before = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
assert Decimal(before["total_ship_amount"]) == Decimal("1000")
updated = client.put(
f"{_SHIPMENTS}/{data['id']}",
json={
"ship_date": str(date.today()),
"freight_amount": "12.00",
"items": [_item(quantity="2", sale_price="150.00")],
},
headers=headers,
)
assert updated.status_code == 200, updated.text
assert Decimal(updated.json()["data"]["total_amount"]) == Decimal("300")
# 业务级两处汇总随编辑同步
after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
assert Decimal(after["total_ship_amount"]) == Decimal("300")
assert Decimal(after["total_freight_amount"]) == Decimal("12")
detail = client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).json()["data"]
assert len(detail["items"]) == 1, "编辑是全量替换,旧行不得残留"
assert Decimal(detail["items"][0]["amount"]) == Decimal("300")
class TestDelete:
"""删除保护与汇总回退:被回款指定禁删;删除后业务级汇总必须回算。"""
def test_被回款指定禁删_解除后可删_汇总回退(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
data = _create(
client,
headers,
business["id"],
freight_amount="20.00",
items=[_item(quantity="1", sale_price="500.00")],
)
receipt = client.post(
_RECEIPTS,
json={
"business_id": business["id"],
"shipment_id": data["id"],
"receipt_date": str(date.today()),
"amount": "500.00",
},
headers=headers,
)
assert receipt.status_code == 200, receipt.text
blocked = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers)
assert blocked.status_code == 400, blocked.text
assert "已被回款批次指定" in blocked.json()["message"], blocked.text
# 解除回款的批次指定(未核算回款可删)后发货单可删,业务级汇总回退
assert (
client.delete(
f"{_RECEIPTS}/{receipt.json()['data']['id']}", headers=headers
).status_code
== 200
)
freed = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers)
assert freed.status_code == 200, freed.text
business_detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()[
"data"
]
assert Decimal(business_detail["total_ship_amount"]) == Decimal("0")
assert Decimal(business_detail["total_freight_amount"]) == Decimal("0")
def test_删除未被指定的发货单_业务级汇总回退(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
data = _create(
client,
headers,
business["id"],
freight_amount="100.00",
items=[_item(quantity="2", sale_price="1000.00")],
)
before = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
assert Decimal(before["total_ship_amount"]) == Decimal("2000")
freed = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers)
assert freed.status_code == 200, freed.text
assert client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).status_code == 404
after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
assert Decimal(after["total_ship_amount"]) == Decimal("0")
assert Decimal(after["total_freight_amount"]) == Decimal("0")
class TestTerminal:
"""终态业务的发货单禁改禁删 —— 发货的运费/金额是业务级汇总与 M3 核算
的源数据,终态后不得变动。"""
def test_终态业务发货单禁改_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
data = _create(client, headers, business["id"])
_terminate(client, headers, business["id"])
resp = client.put(
f"{_SHIPMENTS}/{data['id']}",
json={
"ship_date": str(date.today()),
"items": [_item(quantity="1", sale_price="1.00")],
},
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "禁止编辑发货单" in resp.json()["message"], resp.text
def test_终态业务发货单禁删_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
data = _create(client, headers, business["id"])
_terminate(client, headers, business["id"])
resp = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers)
assert resp.status_code == 400, resp.text
assert "禁止删除发货单" in resp.json()["message"], resp.text
class TestAudit:
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
def test_新增发货单成功留审计(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
data = _create(client, headers, business["id"])
logs = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_SHIPMENT and log.action == ACTION_CREATE
]
assert logs, "新增发货单未留操作日志"
latest = logs[0]
assert latest.success == 1
assert "新增发货单" in latest.summary
assert data["shipment_no"] in latest.summary
def test_终态禁新发货失败留审计(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
business = _create_business(client, headers)
_terminate(client, headers, business["id"])
resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers)
assert resp.status_code == 400, resp.text
failed = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_SHIPMENT and log.success == 0
]
assert failed, "失败操作未留痕"
assert "禁止新发货" in (failed[0].error_message or ""), failed[0]
+8
View File
@@ -69,6 +69,14 @@ class TestSalespersonRate:
assert _sp_rate(360) == Fraction(6, 1000)
assert _sp_rate(360) != Fraction(6)
def test_负天数钳制为0天_按起始比例计提(self) -> None:
"""先款后货(payment_days 为负)不线性外插:钳到 0 天取起始比例。
修复前 days=-5 会算出 (10 + 5×4/360)‰ —— 高于起始值,多算绩效。
"""
assert _sp_rate(-5) == _sp_rate(0)
assert _sp_rate(-1) == Fraction(10, 1000)
class TestProfitRate:
"""毛利润率:不含运费口径(对照表 #2),毛利润额 / 销售额。"""
+19 -1
View File
@@ -23,8 +23,24 @@ EXPECTED_TABLES = {
"param_definitions",
"param_change_batches",
"param_values",
"personnel",
"personnel_regions",
"personnel_plans",
"products",
"customers",
"ownership_change_logs",
"businesses",
"business_items",
"business_status_logs",
"shipments",
"shipment_items",
"receipts",
"customer_rating_grades",
}
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束
PLURAL_EXCEPTIONS = {"personnel"}
def _ddl(table_name: str) -> str:
"""返回某张表的建表 DDL 文本。"""
@@ -43,7 +59,9 @@ class TestTableSet:
def test_所有表名均为小写蛇形复数(self) -> None:
for name in Base.metadata.tables:
assert re.fullmatch(r"[a-z][a-z0-9_]*", name), f"表名 {name} 不符合小写蛇形"
assert name.endswith("s"), f"表名 {name} 应为复数"
# personnel 等集合名词单复数同形,本身即复数语义
if name not in PLURAL_EXCEPTIONS:
assert name.endswith("s"), f"表名 {name} 应为复数"
class TestNamingConvention:
+72
View File
@@ -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"
+352 -1
View File
@@ -254,6 +254,153 @@ CREATE INDEX idx_param_values_effective_date ON param_values (effective_date);
CREATE INDEX idx_param_values_param_id_dimension_value_effective_date ON param_values (param_id, dimension_value, effective_date);
-- ---------------------------------------------------------------------------
-- 营销人员表(V4 迁移;T+ 同步不覆盖岗位与工资标准)
-- ---------------------------------------------------------------------------
CREATE TABLE personnel (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
personnel_name VARCHAR(64) NOT NULL COMMENT '姓名',
business_role_id BIGINT UNSIGNED NOT NULL COMMENT '岗位角色ID(决定计提公式模板;T+同步不覆盖)',
manager_id BIGINT UNSIGNED COMMENT '直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)',
phone VARCHAR(20) COMMENT '联系电话',
hire_date DATE COMMENT '入职日期',
is_probation TINYINT NOT NULL COMMENT '是否试用期:1是 0否(试用期绩效照算不发放)' DEFAULT '0',
salary_standard DECIMAL(12, 2) COMMENT '月工资标准(元;T+同步不覆盖)',
status TINYINT NOT NULL COMMENT '状态:1在职 0离职(离职人员不可再被业务/客户归属引用)' DEFAULT '1',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
synced_at DATETIME COMMENT '最近同步时间',
CONSTRAINT pk_personnel PRIMARY KEY (id),
CONSTRAINT fk_personnel_business_role_id FOREIGN KEY(business_role_id) REFERENCES business_roles (id) ON DELETE RESTRICT,
CONSTRAINT fk_personnel_manager_id FOREIGN KEY(manager_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='营销人员表(T+同步不覆盖岗位与工资标准)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_personnel_business_role_id ON personnel (business_role_id);
CREATE INDEX idx_personnel_manager_id ON personnel (manager_id);
CREATE INDEX idx_personnel_source_no ON personnel (source_no);
-- ---------------------------------------------------------------------------
-- 产品辐射区域表(V4 迁移)
-- ---------------------------------------------------------------------------
CREATE TABLE personnel_regions (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)',
region_name VARCHAR(128) NOT NULL COMMENT '区域名称(产品辐射地理范围)',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_personnel_regions PRIMARY KEY (id),
CONSTRAINT fk_personnel_regions_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品辐射区域表' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_personnel_regions_personnel_id ON personnel_regions (personnel_id);
-- ---------------------------------------------------------------------------
-- 市场拓展计划表(V4 迁移)
-- ---------------------------------------------------------------------------
CREATE TABLE personnel_plans (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)',
plan_period VARCHAR(32) NOT NULL COMMENT '计划期间(如 2026Q1、2026-05)',
plan_content VARCHAR(500) NOT NULL COMMENT '计划内容',
target_desc VARCHAR(255) COMMENT '指标说明(文字描述)',
source_no VARCHAR(64) COMMENT 'T+ 原始条目号(预留)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_personnel_plans PRIMARY KEY (id),
CONSTRAINT fk_personnel_plans_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='市场拓展计划表' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_personnel_plans_personnel_id ON personnel_plans (personnel_id);
-- ---------------------------------------------------------------------------
-- 产品表(V4 迁移;类别锚定折算阈值参数,T+ 同步不覆盖产品类别)
-- ---------------------------------------------------------------------------
CREATE TABLE products (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
product_name VARCHAR(128) NOT NULL COMMENT '产品名称',
spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)' DEFAULT '',
unit VARCHAR(16) COMMENT '计量单位',
stock_quantity DECIMAL(14, 2) COMMENT '数量(T+ 同步原样存储,语义待 IT 确认)',
sale_price DECIMAL(14, 4) COMMENT '销售单价(元,T+ 同步)',
product_category VARCHAR(32) NOT NULL COMMENT '产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)',
status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
synced_at DATETIME COMMENT '最近同步时间',
CONSTRAINT pk_products PRIMARY KEY (id),
CONSTRAINT uk_products_name_spec UNIQUE (product_name, spec_model)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_products_source_no ON products (source_no);
-- ---------------------------------------------------------------------------
-- 客户表(V4 迁移;公司名称+负责人为业务唯一标识,T+ 同步不覆盖客户分类)
-- ---------------------------------------------------------------------------
CREATE TABLE customers (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
company_name VARCHAR(128) NOT NULL COMMENT '公司名称',
owner_name VARCHAR(64) NOT NULL COMMENT '客户负责人姓名(客户公司负责人的人名,非系统人员)',
contact_person VARCHAR(64) COMMENT '联系人',
contact_phone VARCHAR(20) COMMENT '联系电话',
address VARCHAR(255) COMMENT '地址',
industry VARCHAR(64) COMMENT '所属行业(自由文本)',
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
synced_at DATETIME COMMENT '最近同步时间',
CONSTRAINT pk_customers PRIMARY KEY (id),
CONSTRAINT uk_customers_company_owner UNIQUE (company_name, owner_name),
CONSTRAINT fk_customers_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_customers_customer_type ON customers (customer_type);
CREATE INDEX idx_customers_owner_personnel_id ON customers (owner_personnel_id);
CREATE INDEX idx_customers_source_no ON customers (source_no);
-- ---------------------------------------------------------------------------
-- 归属变更记录表(V4 迁移,append-only;客户/业务归属变更留痕,不回溯历史)
-- ---------------------------------------------------------------------------
CREATE TABLE ownership_change_logs (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
owner_type TINYINT NOT NULL COMMENT '归属类型:1客户归属 2业务归属',
customer_id BIGINT UNSIGNED COMMENT '客户ID(固化,owner_type=1 时非空)',
business_id BIGINT UNSIGNED COMMENT '业务ID(固化,owner_type=2 时非空)',
old_owner_personnel_id BIGINT UNSIGNED COMMENT '变更前负责营销人员ID(固化)',
new_owner_personnel_id BIGINT UNSIGNED COMMENT '变更后负责营销人员ID(固化)',
old_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更前协助营销人员ID(固化,仅业务归属使用)',
new_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更后协助营销人员ID(固化,仅业务归属使用)',
change_reason VARCHAR(255) COMMENT '变更原因',
operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_ownership_change_logs PRIMARY KEY (id),
CONSTRAINT fk_ownership_change_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_ownership_change_logs_customer_id ON ownership_change_logs (customer_id);
CREATE INDEX idx_ownership_change_logs_business_id ON ownership_change_logs (business_id);
CREATE INDEX idx_ownership_change_logs_create_time ON ownership_change_logs (create_time);
-- ---------------------------------------------------------------------------
-- 字典数据:角色(与 V1 迁移中的初始化数据一致)
-- ---------------------------------------------------------------------------
@@ -312,6 +459,210 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t
(18, 15, '', '2026-01-01', '18000', 1),
(19, 16, '', '2026-01-01', '3000', 1);
-- ---------------------------------------------------------------------------
-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
-- ---------------------------------------------------------------------------
CREATE TABLE businesses (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
business_no VARCHAR(32) NOT NULL COMMENT '业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(customers.id)',
business_date DATE COMMENT '业务日期(T+ 同步口径)',
status TINYINT NOT NULL COMMENT '业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)' DEFAULT '1',
owner_personnel_id BIGINT UNSIGNED COMMENT '负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)',
assistant_personnel_id BIGINT UNSIGNED COMMENT '协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)',
is_special TINYINT NOT NULL COMMENT '特殊业务标记:1是 0否(公司参与等)' DEFAULT '0',
special_note VARCHAR(255) COMMENT '特殊业务说明',
total_sale_amount DECIMAL(14, 2) COMMENT '销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)' DEFAULT '0',
total_cost_amount DECIMAL(14, 2) COMMENT '成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0',
total_ship_amount DECIMAL(14, 2) COMMENT '发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0',
total_received_amount DECIMAL(14, 2) COMMENT '回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)' DEFAULT '0',
total_freight_amount DECIMAL(14, 2) COMMENT '运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
synced_at DATETIME COMMENT '最近同步时间',
CONSTRAINT pk_businesses PRIMARY KEY (id),
CONSTRAINT uk_businesses_business_no UNIQUE (business_no),
CONSTRAINT fk_businesses_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
CONSTRAINT fk_businesses_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
CONSTRAINT fk_businesses_assistant_personnel_id FOREIGN KEY(assistant_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务表(业务编号自动生成;T+同步不覆盖业务状态)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_businesses_business_date ON businesses (business_date);
CREATE INDEX idx_businesses_customer_id ON businesses (customer_id);
CREATE INDEX idx_businesses_owner_personnel_id ON businesses (owner_personnel_id);
CREATE INDEX idx_businesses_source_no ON businesses (source_no);
CREATE INDEX idx_businesses_status ON businesses (status);
-- ---------------------------------------------------------------------------
-- 业务产品明细表(V5 迁移;行级类别为折算锚点,指导价 T+ 同步不覆盖)
-- ---------------------------------------------------------------------------
CREATE TABLE business_items (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)',
product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入,未入产品库)',
product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)',
spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '',
unit VARCHAR(16) COMMENT '计量单位',
product_category VARCHAR(32) NOT NULL COMMENT '产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)',
quantity DECIMAL(14, 2) NOT NULL COMMENT '数量(>0)',
sale_price DECIMAL(14, 4) COMMENT '销售单价(元;T+ 同步或人工)',
sale_amount DECIMAL(14, 2) NOT NULL COMMENT '销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)',
cost_amount DECIMAL(14, 2) COMMENT '成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)',
guided_price DECIMAL(14, 4) COMMENT '指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_business_items PRIMARY KEY (id),
CONSTRAINT fk_business_items_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT,
CONSTRAINT fk_business_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_business_items_business_id ON business_items (business_id);
CREATE INDEX idx_business_items_product_category ON business_items (product_category);
CREATE INDEX idx_business_items_product_id ON business_items (product_id);
-- ---------------------------------------------------------------------------
-- 业务状态变更记录表(V5 迁移,append-only;创建时写首条 old=new=1)
-- ---------------------------------------------------------------------------
CREATE TABLE business_status_logs (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化,无外键;删除保护由服务层承担)',
old_status TINYINT NOT NULL COMMENT '变更前状态(创建首条为 1)',
new_status TINYINT NOT NULL COMMENT '变更后状态',
change_reason VARCHAR(255) COMMENT '变更原因(终止时必填)',
operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_business_status_logs PRIMARY KEY (id),
CONSTRAINT fk_business_status_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务状态变更记录表(append-only)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_business_status_logs_business_id ON business_status_logs (business_id);
CREATE INDEX idx_business_status_logs_create_time ON business_status_logs (create_time);
-- ---------------------------------------------------------------------------
-- 发货单表(V5 迁移;发货日为回款天数基准日,发货日首日计第 1 天)
-- ---------------------------------------------------------------------------
CREATE TABLE shipments (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
shipment_no VARCHAR(32) NOT NULL COMMENT '发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)',
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;终态业务禁止新发货)',
ship_date DATE NOT NULL COMMENT '发货日期(回款天数基准日,发货日首日=第 1 天)',
freight_amount DECIMAL(14, 2) NOT NULL COMMENT '运费金额(仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0',
total_amount DECIMAL(14, 2) NOT NULL COMMENT '发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
synced_at DATETIME COMMENT '最近同步时间',
CONSTRAINT pk_shipments PRIMARY KEY (id),
CONSTRAINT uk_shipments_shipment_no UNIQUE (shipment_no),
CONSTRAINT fk_shipments_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货单表(发货日为回款天数基准日,发货日首日计第 1 天)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_shipments_business_id ON shipments (business_id);
CREATE INDEX idx_shipments_ship_date ON shipments (ship_date);
CREATE INDEX idx_shipments_source_no ON shipments (source_no);
-- ---------------------------------------------------------------------------
-- 发货产品明细表(V5 迁移)
-- ---------------------------------------------------------------------------
CREATE TABLE shipment_items (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
shipment_id BIGINT UNSIGNED NOT NULL COMMENT '发货单ID(shipments.id)',
product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入)',
product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)',
spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '',
unit VARCHAR(16) COMMENT '计量单位',
quantity DECIMAL(14, 2) NOT NULL COMMENT '发货数量(>0)',
sale_price DECIMAL(14, 4) COMMENT '发货单价(元;T+ 同步或人工,口径待 IT 确认)',
amount DECIMAL(14, 2) NOT NULL COMMENT '行金额 = 数量×单价(服务层计算固化,不信任前端传值)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_shipment_items PRIMARY KEY (id),
CONSTRAINT fk_shipment_items_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT,
CONSTRAINT fk_shipment_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货产品明细表' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_shipment_items_product_id ON shipment_items (product_id);
CREATE INDEX idx_shipment_items_shipment_id ON shipment_items (shipment_id);
-- ---------------------------------------------------------------------------
-- 回款表(V5 迁移;手动指定发货批次,已核算回款禁改禁删)
-- ---------------------------------------------------------------------------
CREATE TABLE receipts (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
receipt_batch_no VARCHAR(32) NOT NULL COMMENT '回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)',
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)',
shipment_id BIGINT UNSIGNED COMMENT '发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)',
receipt_date DATE NOT NULL COMMENT '回款到账日(M3 按此日匹配参数版本,对照表 #11)',
amount DECIMAL(14, 2) NOT NULL COMMENT '回款金额(≥0;一期禁负数/红字)',
is_barter TINYINT NOT NULL COMMENT '以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)' DEFAULT '0',
settlement_status TINYINT NOT NULL COMMENT '绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)' DEFAULT '0',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
synced_at DATETIME COMMENT '最近同步时间',
CONSTRAINT pk_receipts PRIMARY KEY (id),
CONSTRAINT uk_receipts_receipt_batch_no UNIQUE (receipt_batch_no),
CONSTRAINT fk_receipts_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT,
CONSTRAINT fk_receipts_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='回款表(手动指定发货批次;已核算回款禁改禁删)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_receipts_business_id ON receipts (business_id);
CREATE INDEX idx_receipts_receipt_date ON receipts (receipt_date);
CREATE INDEX idx_receipts_settlement_status ON receipts (settlement_status);
CREATE INDEX idx_receipts_shipment_id ON receipts (shipment_id);
CREATE INDEX idx_receipts_source_no ON receipts (source_no);
-- ---------------------------------------------------------------------------
-- 客户评级等级配置表(V5 迁移;判定按 sort_order 升序首个 min_score≤总分)
-- ---------------------------------------------------------------------------
CREATE TABLE customer_rating_grades (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
grade_name VARCHAR(32) NOT NULL COMMENT '等级名称(如 A/优秀)',
min_score DECIMAL(5, 1) NOT NULL COMMENT '总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分',
max_score DECIMAL(5, 1) NOT NULL COMMENT '总分上限(展示用)',
sort_order INT NOT NULL COMMENT '排序(判定顺序,升序)' DEFAULT '0',
remark VARCHAR(255) COMMENT '说明',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_customer_rating_grades PRIMARY KEY (id),
CONSTRAINT uk_customer_rating_grades_grade_name UNIQUE (grade_name)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)' COLLATE utf8mb4_general_ci;
-- ---------------------------------------------------------------------------
-- 字典数据:评级等级(与 V5 迁移种子一致;评分项固定 5 项×20 分为前端常量,对照表 #25)
-- ---------------------------------------------------------------------------
INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES
('A', 90, 100, 1, '优秀'),
('B', 75, 89, 2, '良好'),
('C', 60, 74, 3, '合格'),
('D', 0, 59, 4, '待改进');
-- ---------------------------------------------------------------------------
-- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作
-- ---------------------------------------------------------------------------
@@ -320,4 +671,4 @@ CREATE TABLE alembic_version (
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
INSERT INTO alembic_version (version_num) VALUES ('bfbda9c3191d');
INSERT INTO alembic_version (version_num) VALUES ('8efc189b131f');
+133
View File
@@ -0,0 +1,133 @@
-- Running upgrade bfbda9c3191d -> e1133a7c670a
CREATE TABLE products (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
product_name VARCHAR(128) NOT NULL COMMENT '产品名称',
spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)' DEFAULT '',
unit VARCHAR(16) COMMENT '计量单位',
stock_quantity NUMERIC(14, 2) COMMENT '数量(T+ 同步原样存储,语义待 IT 确认)',
sale_price NUMERIC(14, 4) COMMENT '销售单价(元,T+ 同步)',
product_category VARCHAR(32) NOT NULL COMMENT '产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)',
status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
synced_at DATETIME COMMENT '最近同步时间',
CONSTRAINT pk_products PRIMARY KEY (id),
CONSTRAINT uk_products_name_spec UNIQUE (product_name, spec_model)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_products_source_no ON products (source_no);
CREATE TABLE ownership_change_logs (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
owner_type TINYINT NOT NULL COMMENT '归属类型:1客户归属 2业务归属',
customer_id BIGINT UNSIGNED COMMENT '客户ID(固化,owner_type=1 时非空)',
business_id BIGINT UNSIGNED COMMENT '业务ID(固化,owner_type=2 时非空)',
old_owner_personnel_id BIGINT UNSIGNED COMMENT '变更前负责营销人员ID(固化)',
new_owner_personnel_id BIGINT UNSIGNED COMMENT '变更后负责营销人员ID(固化)',
old_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更前协助营销人员ID(固化,仅业务归属使用)',
new_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更后协助营销人员ID(固化,仅业务归属使用)',
change_reason VARCHAR(255) COMMENT '变更原因',
operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_ownership_change_logs PRIMARY KEY (id),
CONSTRAINT fk_ownership_change_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_ownership_change_logs_customer_id ON ownership_change_logs (customer_id);
CREATE INDEX idx_ownership_change_logs_business_id ON ownership_change_logs (business_id);
CREATE INDEX idx_ownership_change_logs_create_time ON ownership_change_logs (create_time);
CREATE TABLE personnel (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
personnel_name VARCHAR(64) NOT NULL COMMENT '姓名',
business_role_id BIGINT UNSIGNED NOT NULL COMMENT '岗位角色ID(决定计提公式模板;T+同步不覆盖)',
manager_id BIGINT UNSIGNED COMMENT '直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)',
phone VARCHAR(20) COMMENT '联系电话',
hire_date DATE COMMENT '入职日期',
is_probation TINYINT NOT NULL COMMENT '是否试用期:1是 0否(试用期绩效照算不发放)' DEFAULT '0',
salary_standard NUMERIC(12, 2) COMMENT '月工资标准(元;T+同步不覆盖)',
status TINYINT NOT NULL COMMENT '状态:1在职 0离职(离职人员不可再被业务/客户归属引用)' DEFAULT '1',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
synced_at DATETIME COMMENT '最近同步时间',
CONSTRAINT pk_personnel PRIMARY KEY (id),
CONSTRAINT fk_personnel_business_role_id FOREIGN KEY(business_role_id) REFERENCES business_roles (id) ON DELETE RESTRICT,
CONSTRAINT fk_personnel_manager_id FOREIGN KEY(manager_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='营销人员表(T+同步不覆盖岗位与工资标准)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_personnel_business_role_id ON personnel (business_role_id);
CREATE INDEX idx_personnel_manager_id ON personnel (manager_id);
CREATE INDEX idx_personnel_source_no ON personnel (source_no);
CREATE TABLE customers (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
company_name VARCHAR(128) NOT NULL COMMENT '公司名称',
owner_name VARCHAR(64) NOT NULL COMMENT '客户负责人姓名(客户公司负责人的人名,非系统人员)',
contact_person VARCHAR(64) COMMENT '联系人',
contact_phone VARCHAR(20) COMMENT '联系电话',
address VARCHAR(255) COMMENT '地址',
industry VARCHAR(64) COMMENT '所属行业(自由文本)',
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
synced_at DATETIME COMMENT '最近同步时间',
CONSTRAINT pk_customers PRIMARY KEY (id),
CONSTRAINT fk_customers_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
CONSTRAINT uk_customers_company_owner UNIQUE (company_name, owner_name)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_customers_customer_type ON customers (customer_type);
CREATE INDEX idx_customers_owner_personnel_id ON customers (owner_personnel_id);
CREATE INDEX idx_customers_source_no ON customers (source_no);
CREATE TABLE personnel_plans (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)',
plan_period VARCHAR(32) NOT NULL COMMENT '计划期间(如 2026Q1、2026-05)',
plan_content VARCHAR(500) NOT NULL COMMENT '计划内容',
target_desc VARCHAR(255) COMMENT '指标说明(文字描述)',
source_no VARCHAR(64) COMMENT 'T+ 原始条目号(预留)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_personnel_plans PRIMARY KEY (id),
CONSTRAINT fk_personnel_plans_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='市场拓展计划表' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_personnel_plans_personnel_id ON personnel_plans (personnel_id);
CREATE TABLE personnel_regions (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)',
region_name VARCHAR(128) NOT NULL COMMENT '区域名称(产品辐射地理范围)',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_personnel_regions PRIMARY KEY (id),
CONSTRAINT fk_personnel_regions_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品辐射区域表' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_personnel_regions_personnel_id ON personnel_regions (personnel_id);
UPDATE alembic_version SET version_num='e1133a7c670a' WHERE alembic_version.version_num = 'bfbda9c3191d';
@@ -0,0 +1,186 @@
-- Running upgrade e1133a7c670a -> 8efc189b131f
CREATE TABLE customer_rating_grades (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
grade_name VARCHAR(32) NOT NULL COMMENT '等级名称(如 A/优秀)',
min_score NUMERIC(5, 1) NOT NULL COMMENT '总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分',
max_score NUMERIC(5, 1) NOT NULL COMMENT '总分上限(展示用)',
sort_order INTEGER NOT NULL COMMENT '排序(判定顺序,升序)' DEFAULT '0',
remark VARCHAR(255) COMMENT '说明',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_customer_rating_grades PRIMARY KEY (id),
CONSTRAINT uk_customer_rating_grades_grade_name UNIQUE (grade_name)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)' COLLATE utf8mb4_general_ci;
CREATE TABLE business_status_logs (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化,无外键;删除保护由服务层承担)',
old_status TINYINT NOT NULL COMMENT '变更前状态(创建首条为 1)',
new_status TINYINT NOT NULL COMMENT '变更后状态',
change_reason VARCHAR(255) COMMENT '变更原因(终止时必填)',
operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_business_status_logs PRIMARY KEY (id),
CONSTRAINT fk_business_status_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务状态变更记录表(append-only)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_business_status_logs_business_id ON business_status_logs (business_id);
CREATE INDEX idx_business_status_logs_create_time ON business_status_logs (create_time);
CREATE TABLE businesses (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
business_no VARCHAR(32) NOT NULL COMMENT '业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(customers.id)',
business_date DATE COMMENT '业务日期(T+ 同步口径)',
status TINYINT NOT NULL COMMENT '业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)' DEFAULT '1',
owner_personnel_id BIGINT UNSIGNED COMMENT '负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)',
assistant_personnel_id BIGINT UNSIGNED COMMENT '协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)',
is_special TINYINT NOT NULL COMMENT '特殊业务标记:1是 0否(公司参与等)' DEFAULT '0',
special_note VARCHAR(255) COMMENT '特殊业务说明',
total_sale_amount NUMERIC(14, 2) COMMENT '销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)' DEFAULT '0',
total_cost_amount NUMERIC(14, 2) COMMENT '成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0',
total_ship_amount NUMERIC(14, 2) COMMENT '发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0',
total_received_amount NUMERIC(14, 2) COMMENT '回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)' DEFAULT '0',
total_freight_amount NUMERIC(14, 2) COMMENT '运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
synced_at DATETIME COMMENT '最近同步时间',
CONSTRAINT pk_businesses PRIMARY KEY (id),
CONSTRAINT fk_businesses_assistant_personnel_id FOREIGN KEY(assistant_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
CONSTRAINT fk_businesses_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
CONSTRAINT fk_businesses_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
CONSTRAINT uk_businesses_business_no UNIQUE (business_no)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务表(业务编号自动生成;T+同步不覆盖业务状态)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_businesses_business_date ON businesses (business_date);
CREATE INDEX idx_businesses_customer_id ON businesses (customer_id);
CREATE INDEX idx_businesses_owner_personnel_id ON businesses (owner_personnel_id);
CREATE INDEX idx_businesses_source_no ON businesses (source_no);
CREATE INDEX idx_businesses_status ON businesses (status);
CREATE TABLE business_items (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)',
product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入,未入产品库)',
product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)',
spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '',
unit VARCHAR(16) COMMENT '计量单位',
product_category VARCHAR(32) NOT NULL COMMENT '产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)',
quantity NUMERIC(14, 2) NOT NULL COMMENT '数量(>0)',
sale_price NUMERIC(14, 4) COMMENT '销售单价(元;T+ 同步或人工)',
sale_amount NUMERIC(14, 2) NOT NULL COMMENT '销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)',
cost_amount NUMERIC(14, 2) COMMENT '成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)',
guided_price NUMERIC(14, 4) COMMENT '指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_business_items PRIMARY KEY (id),
CONSTRAINT fk_business_items_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT,
CONSTRAINT fk_business_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_business_items_business_id ON business_items (business_id);
CREATE INDEX idx_business_items_product_category ON business_items (product_category);
CREATE INDEX idx_business_items_product_id ON business_items (product_id);
CREATE TABLE shipments (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
shipment_no VARCHAR(32) NOT NULL COMMENT '发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)',
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;终态业务禁止新发货)',
ship_date DATE NOT NULL COMMENT '发货日期(回款天数基准日,发货日首日=第 1 天)',
freight_amount NUMERIC(14, 2) NOT NULL COMMENT '运费金额(仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0',
total_amount NUMERIC(14, 2) NOT NULL COMMENT '发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
synced_at DATETIME COMMENT '最近同步时间',
CONSTRAINT pk_shipments PRIMARY KEY (id),
CONSTRAINT fk_shipments_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT,
CONSTRAINT uk_shipments_shipment_no UNIQUE (shipment_no)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货单表(发货日为回款天数基准日,发货日首日计第 1 天)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_shipments_business_id ON shipments (business_id);
CREATE INDEX idx_shipments_ship_date ON shipments (ship_date);
CREATE INDEX idx_shipments_source_no ON shipments (source_no);
CREATE TABLE receipts (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
receipt_batch_no VARCHAR(32) NOT NULL COMMENT '回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)',
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)',
shipment_id BIGINT UNSIGNED COMMENT '发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)',
receipt_date DATE NOT NULL COMMENT '回款到账日(M3 按此日匹配参数版本,对照表 #11)',
amount NUMERIC(14, 2) NOT NULL COMMENT '回款金额(≥0;一期禁负数/红字)',
is_barter TINYINT NOT NULL COMMENT '以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)' DEFAULT '0',
settlement_status TINYINT NOT NULL COMMENT '绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)' DEFAULT '0',
remark VARCHAR(255) COMMENT '备注',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
synced_at DATETIME COMMENT '最近同步时间',
CONSTRAINT pk_receipts PRIMARY KEY (id),
CONSTRAINT fk_receipts_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT,
CONSTRAINT fk_receipts_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT,
CONSTRAINT uk_receipts_receipt_batch_no UNIQUE (receipt_batch_no)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='回款表(手动指定发货批次;已核算回款禁改禁删)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_receipts_business_id ON receipts (business_id);
CREATE INDEX idx_receipts_receipt_date ON receipts (receipt_date);
CREATE INDEX idx_receipts_settlement_status ON receipts (settlement_status);
CREATE INDEX idx_receipts_shipment_id ON receipts (shipment_id);
CREATE INDEX idx_receipts_source_no ON receipts (source_no);
CREATE TABLE shipment_items (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
shipment_id BIGINT UNSIGNED NOT NULL COMMENT '发货单ID(shipments.id)',
product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入)',
product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)',
spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '',
unit VARCHAR(16) COMMENT '计量单位',
quantity NUMERIC(14, 2) NOT NULL COMMENT '发货数量(>0)',
sale_price NUMERIC(14, 4) COMMENT '发货单价(元;T+ 同步或人工,口径待 IT 确认)',
amount NUMERIC(14, 2) NOT NULL COMMENT '行金额 = 数量×单价(服务层计算固化,不信任前端传值)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_shipment_items PRIMARY KEY (id),
CONSTRAINT fk_shipment_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT,
CONSTRAINT fk_shipment_items_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货产品明细表' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_shipment_items_product_id ON shipment_items (product_id);
CREATE INDEX idx_shipment_items_shipment_id ON shipment_items (shipment_id);
INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('A', 90, 100, 1, '优秀');
INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('B', 75, 89, 2, '良好');
INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('C', 60, 74, 3, '合格');
INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('D', 0, 59, 4, '待改进');
UPDATE alembic_version SET version_num='8efc189b131f' WHERE alembic_version.version_num = 'e1133a7c670a';
+115
View File
@@ -0,0 +1,115 @@
-- ============================================================================
-- 业务数据查询留档(M2 业务数据管理)
--
-- 设计要点:
-- - businesses 的 total_* 五列为服务层维护的汇总冗余,查询直接读冗余列
-- 而非实时 JOIN 聚合;本文件同时给出「对账用」的实时聚合口径
-- - 回款天数 = formula.payment_days(ship_date, receipt_date),即
-- DATEDIFF(receipt_date, ship_date) + 1(发货日首日计第 1 天,对照表 #1),
-- 应用层以纯函数为准,SQL 版仅供人工对账
-- - 毛利润率/折算毛利润额不落库:视图按行实时计算(阈值取 M1 参数快照)
-- ============================================================================
-- 1. 业务台账(列表页;冗余列直读)
SELECT b.id,
b.business_no,
c.company_name,
c.customer_type,
b.status,
b.business_date,
p.personnel_name AS owner_name,
pa.personnel_name AS assistant_name,
b.total_sale_amount,
b.total_cost_amount,
b.total_ship_amount,
b.total_received_amount,
b.total_freight_amount
FROM businesses b
JOIN customers c ON c.id = b.customer_id
LEFT JOIN personnel p ON p.id = b.owner_personnel_id
LEFT JOIN personnel pa ON pa.id = b.assistant_personnel_id
WHERE (:keyword IS NULL OR b.business_no LIKE CONCAT('%', :keyword, '%')
OR c.company_name LIKE CONCAT('%', :keyword, '%'))
AND (:status IS NULL OR b.status = :status)
AND (:customer_id IS NULL OR b.customer_id = :customer_id)
AND (:owner_personnel_id IS NULL OR b.owner_personnel_id = :owner_personnel_id)
ORDER BY b.id DESC
LIMIT :limit OFFSET :offset;
-- 2. 汇总冗余对账(五列与明细/发货/回款实时聚合的一致性校验,T11 集成用例同口径)
SELECT b.id,
b.total_sale_amount,
COALESCE((SELECT SUM(i.sale_amount) FROM business_items i WHERE i.business_id = b.id), 0) AS live_sale,
b.total_ship_amount,
COALESCE((SELECT SUM(si.amount) FROM shipments s JOIN shipment_items si ON si.shipment_id = s.id
WHERE s.business_id = b.id), 0) AS live_ship,
b.total_received_amount,
COALESCE((SELECT SUM(r.amount) FROM receipts r WHERE r.business_id = b.id), 0) AS live_received,
b.total_freight_amount,
COALESCE((SELECT SUM(s.freight_amount) FROM shipments s WHERE s.business_id = b.id), 0) AS live_freight
FROM businesses b
WHERE b.total_sale_amount <> COALESCE((SELECT SUM(i.sale_amount) FROM business_items i WHERE i.business_id = b.id), 0)
OR b.total_ship_amount <> COALESCE((SELECT SUM(si.amount) FROM shipments s JOIN shipment_items si ON si.shipment_id = s.id
WHERE s.business_id = b.id), 0)
OR b.total_received_amount <> COALESCE((SELECT SUM(r.amount) FROM receipts r WHERE r.business_id = b.id), 0)
OR b.total_freight_amount <> COALESCE((SELECT SUM(s.freight_amount) FROM shipments s WHERE s.business_id = b.id), 0);
-- 3. 未指定发货批次的回款(M3 核算前置校验名单;列表页筛选项)
SELECT r.id, r.receipt_batch_no, b.business_no, r.receipt_date, r.amount, r.is_barter
FROM receipts r
JOIN businesses b ON b.id = r.business_id
WHERE r.shipment_id IS NULL
AND r.settlement_status = 0
ORDER BY r.receipt_date;
-- 4. 回款天数对账视图(SQL 版仅供人工核对;应用层以 formula.payment_days 为准)
SELECT r.id,
r.receipt_batch_no,
b.business_no,
s.shipment_no,
s.ship_date,
r.receipt_date,
DATEDIFF(r.receipt_date, s.ship_date) + 1 AS payment_days
FROM receipts r
JOIN businesses b ON b.id = r.business_id
LEFT JOIN shipments s ON s.id = r.shipment_id
WHERE r.shipment_id IS NOT NULL
ORDER BY r.receipt_date DESC;
-- 5. 按产品类别的毛利润汇总(毛利润视图的类别汇总口径;阈值需按 M1 参数快照逐日匹配,
-- 此处仅聚合源数据,折算在应用层以 formula.converted_profit 计算)
SELECT i.product_category,
SUM(i.sale_amount) AS sale_total,
SUM(i.cost_amount) AS cost_total,
SUM(i.sale_amount - COALESCE(i.cost_amount, 0)) AS profit_total
FROM business_items i
WHERE i.business_id = :business_id
GROUP BY i.product_category;
-- 6. 业务状态流转时间线(详情页;创建首条 old=new=1)
SELECT l.old_status, l.new_status, l.change_reason, l.operator_name, l.create_time
FROM business_status_logs l
WHERE l.business_id = :business_id
ORDER BY l.create_time, l.id;
-- 7. 发货单与明细(列表 + 行展开)
SELECT s.id, s.shipment_no, b.business_no, s.ship_date, s.freight_amount, s.total_amount
FROM shipments s
JOIN businesses b ON b.id = s.business_id
WHERE (:business_id IS NULL OR s.business_id = :business_id)
AND (:ship_date_from IS NULL OR s.ship_date >= :ship_date_from)
AND (:ship_date_to IS NULL OR s.ship_date <= :ship_date_to)
ORDER BY s.id DESC
LIMIT :limit OFFSET :offset;
SELECT si.product_name, si.spec_model, si.unit, si.quantity, si.sale_price, si.amount
FROM shipment_items si
WHERE si.shipment_id = :shipment_id
ORDER BY si.id;
-- 8. 评级等级判定(前端按 sort_order 升序首个 min_score ≤ 总分取等级;SQL 版供后端复用)
SELECT grade_name, min_score, max_score
FROM customer_rating_grades
WHERE min_score <= :total_score
ORDER BY sort_order
LIMIT 1;
+98
View File
@@ -0,0 +1,98 @@
-- ============================================================================
-- 基础数据查询留档(M2 基础数据管理)
--
-- 设计要点:
-- - 人员团队关系用 manager_id 自引用展开(NULL=经理本人或未分配),
-- M3 经理 12.5% 聚合与 M4 部门汇总均按此关系,无独立团队表
-- - 归属变更留痕(ownership_change_logs)对客户/业务/人员 ID 为无外键的
-- 冗余固化:被引用方删除后记录仍须完整可读,join 不到时以固化值为准
-- - T+ 预留列(source_no 等)当前全部为空,同步模块(M4)上线后启用
-- ============================================================================
-- 1. 人员列表(含岗位名称与直属经理姓名;分页 + 关键字筛选)
SELECT p.id,
p.personnel_name,
p.phone,
p.hire_date,
p.is_probation,
p.salary_standard,
p.status,
br.role_name,
br.role_code,
m.personnel_name AS manager_name
FROM personnel p
JOIN business_roles br ON br.id = p.business_role_id
LEFT JOIN personnel m ON m.id = p.manager_id
WHERE (:keyword IS NULL OR p.personnel_name LIKE CONCAT('%', :keyword, '%'))
AND (:status IS NULL OR p.status = :status)
ORDER BY p.id DESC
LIMIT :limit OFFSET :offset;
-- 2. 某经理的团队成员(M3 经理 12.5% 聚合的取数口径)
SELECT p.id, p.personnel_name, p.business_role_id, p.status
FROM personnel p
WHERE p.manager_id = :manager_id
AND p.status = 1;
-- 3. 人员的辐射区域与拓展计划(编辑抽屉回显,随人员整表替换维护)
SELECT region_name, remark
FROM personnel_regions
WHERE personnel_id = :personnel_id
ORDER BY id;
SELECT plan_period, plan_content, target_desc, source_no
FROM personnel_plans
WHERE personnel_id = :personnel_id
ORDER BY id;
-- 4. 产品目录(名称/规格关键字 + 类别筛选;名称+规格为组合唯一键)
SELECT id, product_name, spec_model, unit, stock_quantity, sale_price,
product_category, status
FROM products
WHERE (:keyword IS NULL OR product_name LIKE CONCAT('%', :keyword, '%')
OR spec_model LIKE CONCAT('%', :keyword, '%'))
AND (:product_category IS NULL OR product_category = :product_category)
AND (:status IS NULL OR status = :status)
ORDER BY id DESC
LIMIT :limit OFFSET :offset;
-- 5. 客户列表(公司/负责人关键字 + 分类 + 归属人员筛选)
SELECT c.id, c.company_name, c.owner_name, c.customer_type, c.status, c.owner_personnel_id,
p.personnel_name AS owner_personnel_name
FROM customers c
LEFT JOIN personnel p ON p.id = c.owner_personnel_id
WHERE (:keyword IS NULL OR c.company_name LIKE CONCAT('%', :keyword, '%')
OR c.owner_name LIKE CONCAT('%', :keyword, '%'))
AND (:customer_type IS NULL OR c.customer_type = :customer_type)
AND (:owner_personnel_id IS NULL OR c.owner_personnel_id = :owner_personnel_id)
ORDER BY c.id DESC
LIMIT :limit OFFSET :offset;
-- 6. 归属变更留痕(客户或业务维度;join 人员出姓名,人员已删时显示固化 ID)
SELECT l.id,
l.owner_type,
l.customer_id,
l.business_id,
l.old_owner_personnel_id,
l.new_owner_personnel_id,
po.personnel_name AS old_owner_name,
pn.personnel_name AS new_owner_name,
l.change_reason,
l.operator_name,
l.create_time
FROM ownership_change_logs l
LEFT JOIN personnel po ON po.id = l.old_owner_personnel_id
LEFT JOIN personnel pn ON pn.id = l.new_owner_personnel_id
WHERE (:customer_id IS NULL OR l.customer_id = :customer_id)
AND (:business_id IS NULL OR l.business_id = :business_id)
ORDER BY l.create_time DESC, l.id DESC
LIMIT :limit OFFSET :offset;
-- 7. 归属变更留痕(幂等校验辅助:人员删除保护——被归属引用者不可物理删除)
SELECT COUNT(*) AS ref_count
FROM customers c
WHERE c.owner_personnel_id = :personnel_id
UNION ALL
SELECT COUNT(*)
FROM personnel m
WHERE m.manager_id = :personnel_id;
@@ -0,0 +1,106 @@
# M2 实施计划:基础数据管理(②)+ 业务数据管理(③)
## 背景
- 需求 28 项裁决已落定(2026-10-12),M1(系统设置与参数体系)已完成并推送 `feat/system-params`。按剩余开发计划,M2 = ②基础数据 + ③业务数据 **合并交付**,一次表结构评审定稿 T+ 预留列与报表字段清单(即本计划 A 节)。
- 目标:三类基础数据(人员/产品/客户)+ 四类业务数据(业务/发货/回款/评级)全流程可维护、可留痕,为 M3 绩效核算供数。
- 设计调研已完成并抽查核对(`models/base.py` 命名约定、`formula.py` 纯函数、`test_models.py` 白名单、迁移现状 V3 均与设计一致)。
## 开工前处置
1. 当前 `feat/system-params` 工作区有未提交内容(2 个已删除 docs 文件、若干未跟踪的测试/文档)——先提交或与用户确认处置。
2. 从 `feat/system-params` 切出 `feat/base-business-data`(M2 外键依赖 M1 的 `business_roles`/参数体系)。
## A. 表结构(V4 基础数据 6 表 + V5 业务数据 7 表)
公共约定(照 `models/base.py` 现状):表名小写蛇形复数;`NAMING_CONVENTION` 自动产出 `uk_/idx_/fk_`;`mysql_table_args("中文表注释")`;可编辑表继承 `TimestampMixin`;**append-only 留痕表不继承**(仅手写 `create_time`,`login_logs` 先例)。金额 `DECIMAL(14,2)`、单价 `(14,4)`,schema 入出参一律 `str`,服务层 `Decimal`/`Fraction`。
### T+ 预留四列(覆盖 6 张主表:personnel/products/customers/businesses/shipments/receipts)
| 列 | 类型 | 说明 |
| --- | --- | --- |
| `source_no` | VARCHAR(64) NULL | T+ 原始单据号,普通索引(幂等去重锚点;唯一键待 IT 定键后小迁移升级) |
| `t_plus_id` | VARCHAR(64) NULL | T+ 内部标识 |
| `sync_batch_no` | VARCHAR(32) NULL | 同步批次号(M4 写入) |
| `synced_at` | DATETIME NULL | 最近同步时间 |
全部可空、不设唯一键(对照表 #26 键语义未定)。`personnel_plans` 仅 `source_no` 单列;明细行表不加(随主表同步)。**「同步不覆盖人工字段」写入表注释**:`business_items.guided_price`、各归属列、`personnel.business_role_id`/`salary_standard`、`customers.customer_type`、`businesses.status`。
### V4 六表
1. **`personnel`** 人员表:`personnel_name`、`business_role_id`(FK→business_roles RESTRICT,同步不覆盖)、`manager_id`(FK→personnel 自引用,NULL=经理本人/未分配,支撑「本团队业务员」)、`phone`、`hire_date`、`is_probation`(试用期照算不发放,裁决 #17)、`salary_standard DECIMAL(12,2)`、`status`(1在职 0离职)、T+4 列、`remark`。不与 `users` 关联(一期营销人员不登录)。
2. **`personnel_regions`** 产品辐射区域:`personnel_id` FK、`region_name`、`remark`(1:N 子表,随人员维护)。
3. **`personnel_plans`** 市场拓展计划:`personnel_id` FK、`plan_period`、`plan_content`、`target_desc`、`source_no`。
4. **`products`** 产品表:`product_name`、`spec_model NOT NULL DEFAULT ''`(空串参与唯一键,对齐 MySQL 组合索引 NULL 不去重先例)、`unit`、`stock_quantity`(T+ 语义待确认,原样存储)、`sale_price`、`product_category`(`municipal_water`/`rail_transit`,**与 M1 `product.threshold` 参数维度同一值域常量 `PRODUCT_CATEGORIES`**,阈值由 M1 参数承担不建列)、`status`、T+4 列、`remark`。`uk_products_name_spec(product_name, spec_model)`。
5. **`customers`** 客户表:`company_name`、`owner_name`(客户负责人**人名字符串**,非系统人员引用)、`contact_person`、`contact_phone`、`address`、`industry`、`customer_type`(`personal` 个人户 / `manager_personal` 经理个人户 / `enterprise` 企业户,计提规则匹配锚点,同步不覆盖)、`owner_personnel_id` FK(归属,变更不回溯,裁决 #19)、T+4 列、`remark`。`uk_customers_company_owner(company_name, owner_name)`。
6. **`ownership_change_logs`** 归属变更留痕(**append-only**):`owner_type`(1客户 2业务)、`customer_id`/`business_id` 可空 FK、`old/new_owner_personnel_id`、`old/new_assistant_personnel_id`(冗余固化防人员删除后不可读)、`change_reason`、`operator_id`(FK→users SET NULL)、`operator_name`、`create_time`。独立表而非 JSON:可分页/join 人员/导出,与 M1 `param_values` 同一哲学。
### V5 七表
7. **`businesses`** 业务表:`business_no`(自动生成 `YW{YYYYMMDD}-{4位序号}`,即报表「合同编号」,uk)、`customer_id` FK、`business_date`、`status`(1执行中 2已完成 3已终止,终态不可变更)、`owner_personnel_id`(可空=待指定,M3 核算前置校验非空)、`assistant_personnel_id`(一期核算不使用,裁决 #15 剔除协助 25%,仅记录事实)、`is_special`/`special_note`、**五个汇总冗余列** `total_sale_amount`/`total_cost_amount`/`total_ship_amount`/`total_received_amount`/`total_freight_amount`(服务层事务内单点重算,禁手工直改;运费不计毛利润口径,裁决 #2)、T+4 列、`remark`。
8. **`business_items`** 业务产品明细:`business_id` FK、`product_id` 可空 FK(可空=自由录入,配合「数据提取」)、`product_name`/`spec_model`/`unit` 冗余固化(防产品改名影响历史)、`product_category`(**行级折算锚点**,混合业务按产品逐条算,裁决 #14)、`quantity`、`sale_price`、`sale_amount`(服务层按 数量×单价 固化)、`cost_amount`(可空,来源待 T+ 确认)、`guided_price`(人工录入,同步不覆盖)、`remark`。**不建** `profit_amount`/`profit_rate`/`converted_profit` 落库列——派生值落库会在成本调整/参数改版后漂移,M2 以只读毛利润视图出数,M3 台账才是固化点。
9. **`business_status_logs`** 状态流转留痕(**append-only**):`business_id` FK、`old_status`、`new_status`、`change_reason`、`operator_id`/`operator_name`、`create_time`。
10. **`shipments`** 发货单:`shipment_no`(`SH{YYYYMMDD}-{序号}`,uk)、`business_id` FK、`ship_date`(**回款天数基准日,发货日首日=第 1 天**,裁决 #1)、`freight_amount`(仅记录展示)、`total_amount`(明细汇总冗余)、T+4 列、`remark`。
11. **`shipment_items`** 发货明细:`shipment_id` FK、`product_id` 可空、`product_name`/`spec_model`/`unit` 冗余、`quantity`、`sale_price`、`amount`。
12. **`receipts`** 回款表:`receipt_batch_no`(`HK{YYYYMMDD}-{序号}`,uk)、`business_id` FK、`shipment_id` **可空** FK(**手动指定发货批次**,裁决 #18;须与 business_id 同属一个业务,服务层强校验;指定后回款天数方可计算)、`receipt_date`(M3 按此日匹配参数版本)、`amount`(≥0,一期禁负数)、`is_barter`(以物抵顶标记,M3 按 80% 折算,M2 仅标记)、`settlement_status`(0未核算 1已核算,**M3 防重锚点**,预置避免 M3 反向迁移)、T+4 列、`remark`。
13. **`customer_rating_grades`** 评级等级配置:`grade_name` uk、`min_score`/`max_score DECIMAL(5,1)`、`sort_order`(判定=按序首个 min_score ≤ 总分,避免区间边界歧义)、`remark`。种子 4 条(迁移 + init-dev 双写):A 优秀 90-100、B 良好 75-89、C 合格 60-74、D 待改进 0-59。评分项固定 5 项 × 20 分为前端常量(#25:仅打分工具,不影响绩效金额)。
### 编号生成规则(`core/constants.py` 集中前缀常量)
`{前缀}{YYYYMMDD}-{当日4位序号}`,序号按自然日重置。服务层 `SELECT MAX(no) ... FOR UPDATE` 后 +1;撞 uk(1062)兜底重试一次后仍失败返回 400(沿用 M1 code-review 修复过的并发兜底模式)。
### 客户分类 × M1 参数对应(M3 消费契约)
| customer_type | 核算规则 | M1 参数 |
| --- | --- | --- |
| `personal` | `10‰ − 天数×4/360‰`,361 天起 4‰ | `salesperson.rate.*`/`days.boundary` |
| `manager_personal` | 归经理本人,12.5%+5% 结构 | `manager.share.*` |
| `enterprise` | 固定计提(2‰/3‰ 按日期) | `enterprise.rate`(与产品类别交叉) |
## B. 接口与前端
- 新增 `app/api/v1/base/`(`/api/v1/base`,人员/产品/客户)与 `app/api/v1/business/`(`/api/v1/business`,业务/发货/回款/评级),与 `system/*` 平级。约 30 个端点。
- **权限**:全部登录即可(`get_current_user`,不套 `AdminUser`)——M2 是业务操作(需规 2.5.1「普通用户」),M1 的 `AdminUser` 仅用于 system/*。写操作全部 `audit_service.audit()` 留痕。
- 关键端点:人员 CRUD+区域/计划全量替换(**clear 后立即 flush**,`replace_roles` 先例坑);产品 CRUD+`POST /products/extract`(业务明细未入库产品按名称+规格批量建产品,幂等);客户归属变更(事务内更新+插留痕);业务 CRUD(明细行数组、汇总重算 `_recalc_totals`、状态机 1→2/3 终态 400、毛利润视图 `GET /businesses/{id}/gross-profit?asOf=`);发货/回款 CRUD(回款跨业务指定批次 400、已核算禁改禁删);评级等级 CRUD(区间不重叠校验)。
- 关键校验:删除保护三类(被引用→400 引导离职/停用/改终止);金额 ≥0;`quantity>0`;销售额 0 防除零(`formula.profit_rate` 已内置)。
- 前端 7 页(复用 `usePagedList`/`useConfirmAction`/`AppPagination`/`StatusTag`/`--ys-*` token,不新增基础组件):`/base/personnel`(编辑抽屉内子表编辑区域/计划)、`/base/products`(类别下拉展示当前阈值、提取产品按钮)、`/base/customers`(归属变更二次确认文案含「不回溯历史」)、`/business/businesses`(明细行编辑器、详情抽屉含状态时间线/毛利润卡片)、`/business/shipments`、`/business/receipts`(选业务级联加载该业务发货单,可清空)、`/business/rating`(5 项评分实时出等级+映射维护抽屉,标注「仅打分工具」)。菜单新增「基础数据」「业务数据」两组,均不设 `requiresAdmin`。api 封装 7 个文件(蛇形字段直出,同 `businessRoles.ts` 模式)。金额展示字符串直出,禁 float 格式化。
## C. 任务分解(T1–T15)
依赖链:`T1 → (T2∥T3∥T4) → T5 → T6 → T7 → T8 → (T9∥T10) → T11 → (T12∥T13∥T14) → T15`。每任务「实现 → 测试/冒烟 → commit(`feat(scope): 中文说明`)」。
| # | 任务 | 要点 |
| --- | --- | --- |
| T1 | V4 迁移 + 基础数据模型 | `models/personnel.py`/`product.py`/`customer.py`;**注册进 `models/__init__.py`(漏注册会生成删表迁移)**;`test_models.py` EXPECTED_TABLES +6;autogenerate → 人工核对 → 导出 `db/migration/V4__*.sql`;同步 `init-dev.sql`(含 alembic_version 标记);`db/queries/基础数据.sql` 留档 |
| T2 | 人员管理后端 | schemas/repositories/services/api 四层;岗位存在且启用、manager 非自己且在职、删除保护→提示改离职、区域/计划全量替换 clear 后立即 flush |
| T3 | 产品管理后端 | 类别校验复用 `PRODUCT_CATEGORIES`;1062→400;删除保护;`extract` 幂等 |
| T4 | 客户管理后端 | 公司+负责人唯一;归属变更(更新+留痕同事务);留痕分页查询 |
| T5 | 基础数据集成测试(统一编写点) | `test_personnel.py`/`test_products.py`/`test_customers.py`:唯一冲突、删除保护、留痕内容、未登录 401、审计双记录、非法枚举;更新 `backend/tests/README.md` |
| T6 | V5 迁移 + 业务数据模型 | `models/business.py`/`shipment.py`/`receipt.py`/`rating.py`;EXPECTED_TABLES +7;等级种子 4 条双写;`db/migration/V5__*.sql` + `init-dev.sql` + `db/queries/业务数据.sql` |
| T7 | 业务管理后端 | 编号生成;明细全量替换+五项汇总重算;状态机终态 400;归属留痕;毛利润视图(`resolve_snapshot` + `formula.profit_rate`/`converted_profit`,asOf 参数) |
| T8 | 发货管理后端 | 金额/运费汇总回写业务冗余列;终态业务禁新发货;被回款指定禁删 |
| T9 | 回款管理后端 | 批次号;**shipment_id 同业务强校验**;已核算禁改禁删;合计回写;DTO 出 `paymentDays`(`formula.payment_days`) |
| T10 | 评级等级配置后端 | 区间不重叠/0≤min≤max≤100 校验;名称唯一 |
| T11 | 业务数据集成测试(统一编写点) | 编号格式与序号递增、终态拦截、三处汇总冗余一致性、跨业务 400、已核算禁改、毛利润视图(27 天回款天数/防除零/取参)、审计、401;**全量 pytest 回归** |
| T12 | 前端基础数据三页 | 人员/产品/客户;归属变更确认文案含「不回溯历史」;`pnpm type-check && pnpm build` |
| T13 | 前端业务/发货/回款三页 | 明细行编辑器、详情抽屉、回款级联指定批次、金额字符串直出 |
| T14 | 前端评级计算器页 | 5 项评分→实时等级;映射维护抽屉;「仅打分工具」标注 |
| T15 | e2e 与文档收尾 | `e2e/base_data.py`/`business_data.py` 全链路(建数据→业务→发货→回款→回款天数→毛利润→评级);全量回归(后端基线 406+ 新增全绿、前端构建、e2e);三份 README 更新 |
## 验证方式
- T1/T6:`uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q` 全绿(双轨零漂移是硬关口)。
- T5/T11:模块集成用例统一编写并执行;T11 后 `uv run pytest -q` 全量回归。
- T12–T14:`pnpm type-check && pnpm build`。
- T15:后端全量 pytest + 前端构建 + 两个 e2e 脚本全过;`ai-aux/AI_CONTEXT.md` 历史任务摘要由主会话收尾时更新。
## 风险与开放问题(默认处理,不阻塞开工)
1. 毛利润成本来源待 T+:`cost_amount` 可空+人工可录先行;视图中成本缺失的行标注「无法计算」。
2. 人员条目 T+ 边界:一期手工 CRUD 完整可用,同步以 `source_no` 幂等、不覆盖人工字段。
3. 拓展计划粒度:自由条目容器(期间+内容+指标说明)。
4. 业务编号前缀:一期代码常量 YW/SH/HK,如需配置走 M1 参数体系。
5. 终态回退:一期不可变更(400);如需重开再加 2→1 规则。
6. 负数回款/红字:一期禁负金额;冲销需求出现时 M3 前补设计。
7. 回款未指定批次:允许保存,列表提供筛选,M3 核算前置校验非空。
8. T+ 幂等键:`source_no` 普通索引先行,IT 定键后小迁移升级。
Binary file not shown.
+154
View File
@@ -0,0 +1,154 @@
# 衍水集团营销人员工资管理系统项目阶段报告
- **报告日期**:2026-10-13
- **报告范围**:需求澄清、技术底座、系统管理、M1 参数体系、M2 当前进展与后续计划
- **项目分支**:`feat/base-business-data`
- **阶段结论**:项目已从“需求与底座建设”进入 **M2 基础数据管理开发期**;M1 已形成可验收闭环,M2 数据模型与迁移初稿已落地,但接口、页面与模块级测试尚未完成。
## 一、3P 阶段摘要
### Progress(进展)
已完成需求裁决、开发环境、数据库基础设计、登录鉴权、系统管理、前端框架与 M1 系统设置参数体系。M1 覆盖岗位角色、公式模板、16 项参数种子、时点化取值、版本留痕、参数预览与 Excel 导出;最近一次全量回归为后端 406 个用例与 28 项端到端检查通过。当前 M2 已完成人员、区域、计划、产品、客户、归属留痕六类表的模型与 V4 迁移初稿。
### Plans(计划)
短期优先完成 M2:在既有六表基础上补齐仓储、服务、接口、前端页面、审计与模块测试,并完成双轨迁移一致性验证。随后进入业务数据管理,为绩效核算提供业务单据与回款数据输入。报表与 T+ 同步继续后置,待业务数据模型稳定和 IT 字段映射确认后实施。
### Problems(问题)
当前无业务需求阻塞,但 M2 代码尚未提交,需防止结构设计与后续接口实现长期分叉。T+ 字段映射、幂等唯一键与失败重试策略仍待 IT 确认;经理个人户业绩进入 12.5%+5% 的衔接口径需在核算模块开工前确认。原需求文档存在已裁决作废条款,对外引用前需修订或明确以对照表 v2 为准。
## 二、阶段定位与完成度
| 维度 | 当前状态 | 说明 |
| --- | --- | --- |
| 项目阶段 | 开发期 | M1 已交付,M2 进行中 |
| 整体完成度 | 14% | 管理口径;M2 尚未达到可交付节点,仅按结构设计进展小幅上调 |
| 交付主线 | M2 基础数据管理 | 六表结构与 T+ 预留已形成初稿 |
| 质量状态 | M1 回归通过 | 后端 406 用例、e2e 登录 8 项 / 系统管理 13 项 / 参数 7 项通过 |
| 主要风险 | 外部对接与口径追溯 | T+ 映射未定;需规含作废条款 |
> 说明:整体完成度用于管理沟通,不等同于代码行占比。M2 仅完成数据结构层,接口、页面和模块级测试未完成,因此不能标记为模块交付。
## 三、已完成成果
### 1. 需求与执行口径
- 业务方已回执《问题与解决方案对照表_v2.xlsx》,28 项争议全部落定。
- 2026-10-12 后,需求以对照表 v2 与《2026.9.8修改方案.docx》的裁决口径为准。
- 关键口径已固化:回款天数自然日累计且发货日计第 1 天;毛利润率不含运费;业务员计提按 1~360 天线性递减、361 天及以上固定 4‰;企业户 3‰;信息员按销售额 1‰;参数按回款到账日时点取值。
- 支付环节的起付比例、支付权重、预付款、以物抵顶均按可配置参数预留。
### 2. 技术与运行底座
- 后端六层结构建立:`api → services → repositories → models`,`schemas` 承载请求响应,`core` 处理横切能力。
- 统一响应、全局异常、结构化日志、审计脱敏、配置安全校验已落地。
- 本地环境基于 Docker Compose 编排 MySQL 8.0 与 Redis 7,使用命名卷持久化、业务账号最小权限、utf8mb4 字符集。
- 迁移采用 Alembic 生成并导出 `db/migration/V{n}__*.sql` 的双轨方式,兼顾开发迭代与生产脚本留存。
### 3. 登录鉴权与系统管理
- 登录链路包含图形验证码、JWT、登录失败限流、Token 黑名单与登录日志。
- 系统管理包含用户、角色、操作日志与日志查询,前后端闭环。
- 审计机制记录成功与失败操作,敏感字段按键名结构化脱敏,日志列表与详情分段加载。
### 4. M1 系统设置与参数体系
- 建立岗位角色、参数定义、变更批次、参数值四表。
- 公式模板以代码内有限集合注册,参数按生效日期时点化存储,避免用当前值重算历史。
- 提供 16 个管理端点,支持参数分组、版本管理、批次撤回、两日期对比、计算预览与 xlsx 导出。
- 前端交付岗位角色配置页、计提参数配置页、Bento 仪表盘与统一视觉体系。
- Code review 发现的 15 项问题已全部修复,并完成回归。
## 四、当前进行中工作
当前分支 `feat/base-business-data` 正在推进 M2 基础数据管理。已形成的结构设计包括:
| 数据对象 | 设计要点 | 后续消费方 |
| --- | --- | --- |
| `personnel` | 人员档案、岗位角色、直属经理、试用期、工资标准与状态 | 基础数据、业务归属、绩效核算 |
| `personnel_regions` | 人员负责的产品辐射区域,1:N 子表 | 基础数据维护 |
| `personnel_plans` | 市场拓展计划与指标说明,先做自由条目容器 | T+ 字段映射确认后细化 |
| `products` | 产品名称与规格唯一,类别锚定折算阈值参数 | 业务数据与绩效核算 |
| `customers` | 公司名称加负责人唯一,客户分类作为计提规则锚点 | 业务数据与绩效核算 |
| `ownership_change_logs` | 客户与业务归属变更留痕,append-only,不回溯历史 | 审计与核算追溯 |
设计上已为 T+ 同步预留 `source_no`、`t_plus_id`、`sync_batch_no`、`synced_at`。由于 IT 尚未确认字段映射与幂等键,这些列暂为可空并仅对 `source_no` 建普通索引,待键语义确定后再以小迁移升级唯一约束。
**当前边界**:以上属于模型、Alembic 迁移、生产 SQL 与开发初始化脚本层面的初稿;M2 的 CRUD API、服务事务、前端页面、权限审计和完整测试仍待实现。相关文件当前未提交,不能视为已交付功能。
## 五、下一步计划
| 优先级 | 工作项 | 验收标准 |
| --- | --- | --- |
| P0 | 完成 M2 六表接口与服务 | 列表、详情、创建、更新、停用 / 删除保护可用;事务与审计完整 |
| P0 | 完成 M2 前端页面 | 人员、产品、客户管理页面复用现有分页与确认交互,类型检查通过 |
| P0 | 补齐 M2 测试与迁移一致性检查 | 单元测试、集成测试、schema 同步测试通过 |
| P1 | 提交并评审 M2 | 提交信息符合规范,不直推共享主干 |
| P1 | 启动业务数据管理设计 | 明确业务、发货、回款、批次匹配与核销状态模型 |
| P1 | 确认核算前置口径 | 经理个人户业绩是否计入 12.5% 团队基数 |
| P2 | T+ 对接专项 | 字段映射、唯一键、去重、同步日志与失败重试策略由 IT 确认 |
## 六、风险与应对
| 风险 | 影响 | 当前应对 |
| --- | --- | --- |
| M2 结构初稿长期未提交 | 后续接口实现与表结构评审结论容易分叉 | 尽快完成最小可测闭环后提交,评审后再扩展 |
| T+ 字段映射未定 | 同步模块无法最终定幂等键与字段清洗规则 | 目标表预留同步字段;同步模块后置;不覆盖人工维护字段 |
| 需求文档存在作废条款 | 新成员或外部方按旧需规理解会产生口径冲突 | 以对照表 v2 与 9.8 修改方案为准;后续修订需规或加显著说明 |
| 经理个人户衔接口径待确认 | 影响绩效台账归属字段与经理绩效基数 | 在核算模块开工前作为必须裁决项跟踪 |
| 绩效金额精度风险 | 浮点误差会直接造成算错钱 | 金额使用 `Decimal`,比例斜率使用 `Fraction`;关键边界已有纯函数测试 |
## 七、技术选型理由
### 1. 后端:Python 3.12 + FastAPI
| 选型 | 理由 | 放弃的替代方案 |
| --- | --- | --- |
| Python 3.12 | `Decimal` 与 `Fraction` 能精确处理金额、千分比和 `4/360` 斜率;数据处理、Excel、后续 T+ 清洗生态成熟;版本锁定 3.12 避免新版本第三方轮子覆盖不稳 | Python 3.14:生态轮子覆盖尚不完备,兼容风险高 |
| FastAPI | 自动 OpenAPI、Pydantic 参数校验、依赖注入和统一异常模型适合内部管理系统;显式路由便于把算钱逻辑限定在 service 层 | Django:Admin 对 T+ 来源的数据 CRUD 复用价值有限,隐式 ORM 行为不利于工资核算追溯 |
| 同步 SQLAlchemy 2.0 | 项目并发压力低;绩效批量核算、Excel 导出和数据清洗是同步阻塞任务,同步路由由 FastAPI 放入线程池,避免阻塞事件循环 | 异步 SQLAlchemy:增加复杂度但没有对应吞吐收益 |
### 2. 数据与缓存:MySQL 8.0 + Redis 7
| 选型 | 理由 | 放弃的替代方案 |
| --- | --- | --- |
| MySQL 8.0 + InnoDB + utf8mb4 | 事务、外键、约束与审计留痕可靠;团队与运维熟悉度高;`DECIMAL` 支撑金额精度;utf8mb4 覆盖中文与特殊字符 | Excel / 文件库:无法承载并发、事务、权限与审计;NoSQL:缺少关系约束,不利薪酬追溯 |
| Alembic 双轨迁移 | 开发期可自动生成演进,生产保留按序 SQL 脚本,符合审计与发布要求 | 仅手写 SQL:模型与库结构易漂移;仅 Alembic:不满足生产脚本留存约定 |
| Redis 7 | 验证码、登录限流、Token 黑名单都需要带 TTL 的短生命周期状态;`GETDEL` 支撑验证码一次性消费 | 仅 MySQL:一次性状态表会增加清理与原子性复杂度 |
### 3. 前端:Vue 3 + TypeScript + Vite + Element Plus + Pinia
| 选型 | 理由 | 放弃的替代方案 |
| --- | --- | --- |
| Vue 3 + TypeScript | 组件生态成熟,类型约束能降低表单和接口字段错误;项目已沉淀路由、状态、分页与确认交互 | 新换框架:无业务收益,徒增迁移成本 |
| Vite | 启动与热更新快,开发体验好;生产构建链路简单 | Webpack:配置与维护成本更高 |
| Element Plus | 表格、表单、抽屉、分页、日期选择等管理端组件齐全,可快速交付后台页面 | 自研组件库:一期交付效率低 |
| Pinia | 轻量状态管理,适合登录态、用户信息与模块状态 | 大型全局状态方案:当前规模过度设计 |
### 4. 工程与质量:uv + pnpm + Docker Compose + pytest + Playwright
| 选型 | 理由 |
| --- | --- |
| uv | 锁定 Python 与依赖版本,安装速度快,避免“本机能跑、别处失败” |
| pnpm | 依赖硬链接节省空间,lockfile 保证前端可重复安装 |
| Docker Compose | 一条命令拉起 MySQL 与 Redis,版本固定、环境一致,不依赖开发者本机散装数据库 |
| pytest + fakeredis + Playwright | 单元、集成、端到端分层验证;fakeredis 让验证码和限流测试无需容器,浏览器测试覆盖真实登录与管理链路 |
### 5. 业务计算架构:公式模板 + 参数版本,而非规则引擎
一期公式骨架固定,变化的是比例、阈值、生效日期和岗位绑定,因此使用“代码内有限公式模板 + 参数时点化版本”足够,并且可测试、可审计、排错直观。引入规则引擎会增加公式调试、权限、留痕与回归成本,却不能消除业务口径澄清本身。若未来业务要求公式可由管理员自由编写,再评估规则引擎或表达式引擎,并保持计算入口兼容。
## 八、报告结论
项目基础能力和 M1 参数地基已经稳定,当前最短路径是完成 M2 基础数据管理的可测闭环,再进入业务数据管理。技术上应继续坚持“关系数据库约束 + 精确数值类型 + 时点化参数 + 审计留痕”的路线,不在 T+ 映射未定时提前锁死同步唯一键。管理上需重点跟踪 M2 提交、核算前口径确认和 T+ 对接三项。
## 附录:信息来源
- 项目入口与运行说明:[README.md](../README.md)
- 剩余开发路线:[2026-10-12-剩余开发计划.md](superpowers/plans/2026-10-12-剩余开发计划.md)
- 需求裁决:`docs/问题与解决方案对照表_v2.xlsx`
- 修改方案:`docs/2026.9.8修改方案.docx`
- 后端测试记录:[backend/tests/README.md](../backend/tests/README.md)
- 前端约定与测试:[frontend/README.md](../frontend/README.md)
+11
View File
@@ -148,3 +148,14 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
| `e2e/param_management.py` | 计提参数 7 步:目录分组 → 修改入待提交 → 批次提交(立即生效)→ 目录反映新值 → 版本记录 → 恢复种子值 → 计算预览金标准(27 天 / 9.7‰ / 145.5 元) | ✓ 全通过(首跑) |
运行前置:容器 + 后端(8000)+ 前端 dev(5173);`E2E_ADMIN_PASSWORD` 传管理员密码。
## 端到端测试(2026-10-13 追加 · M2 基础数据与业务数据)
| 脚本 | 覆盖 | 结果 |
| --- | --- | --- |
| `e2e/base_data.py` | 基础数据 13 步:菜单两组 → 人员建(经理/业务员指派)→ 编辑抽屉加辐射区域回显 → 删除保护(400 引导改离职)→ 产品建 + 重名 400 → 客户建 → 归属变更(确认文案含「不回溯历史」)→ 留痕抽屉可见 → 控制台无 JS 错误 | ✓ 全通过(首跑) |
| `e2e/business_data.py` | 业务全链路 16 步:基础建数 → 业务建(双类别明细)→ 编辑汇总重算(65000→115000)→ 详情抽屉(时间线/毛利润卡片)→ 归属调整 → 发货(SH 编号)→ 回款级联指定批次 → 回款天数「6 天」→ 未指定批次文案 → 状态流转已完成 → 终态禁发货 → 评级计算器出 A 级 → 清理留档 | ✓ 全通过(修复单价小数位/删除保护后) |
运行前置同上;测试数据 `e2e_` 前缀,正常流程内清理(业务含发货/回款时按设计不可删,留档说明)。
**期间修复的缺陷**:后端删除保护两处漏检(客户被业务引用/人员被业务归属引用 → 500)、
单价列 `DECIMAL(14,4)` 被两位小数校验拒收(前后端同源)、`wait_toast` 超时丢失诊断信息。
+519
View File
@@ -0,0 +1,519 @@
"""基础数据端到端验证:人员 / 产品 / 客户。
覆盖的链路(只有真实浏览器才能验证的组合):
- 侧边菜单「基础数据」分组展开与三页导航
- 人员:新增(营销经理 / 挂经理的营销员)、直属经理名回显、辐射区域
编辑抽屉行内添加保存并重开回显
- 人员删除保护:被团队成员引用 → 400 引导「改为离职」,改为离职成功
- 产品:新增(轨道交通类别)、同名称同规格重复 → 400
- 客户:新增(企业户 + 初始归属业务员)、归属调整二次确认文案含
「不回溯历史」(对照表 #19 业务口径)、变更留痕展示旧 → 新归属
- 控制台无 JS 错误(4xx 请求的网络资源日志属预期,见 step_console_assertion)
**运行前置**(缺一不可)::
docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis
cd backend && uv run uvicorn app.main:app --reload --port 8000 # 后端
cd frontend && pnpm dev # 前端
管理员密码经环境变量传入(不写死在源码里)::
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/base_data.py
脚本自建的测试数据(`e2e_` 前缀)在流程内清理;被引用保护拦截的清理项
打印留档说明而非失败(删除保护本身是预期行为)。公共工具见 `e2e/common.py`。
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(__file__))
from playwright.sync_api import Page, sync_playwright
from common import (
E2EFailure,
click_query,
collect_console_errors,
confirm_message_box,
current_path,
expect_toast,
fail,
fill_filter,
login,
open_menu,
select_filter_option,
table_rows,
toast_text,
visible_dialog,
wait_toast,
)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ADMIN = "admin"
SUFFIX = str(int(time.time()))
MANAGER_NAME = f"e2e_经理_{SUFFIX}"
SALESMAN_NAME = f"e2e_业务员_{SUFFIX}"
MANAGER_PHONE = "13800000001"
MANAGER_SALARY = "5000"
SALESMAN_SALARY = "4000"
PRODUCT_NAME = f"e2e_产品_{SUFFIX}"
PRODUCT_SPEC = f"e2e_规格_{SUFFIX}"
CUSTOMER_COMPANY = f"e2e_企业_{SUFFIX}"
CUSTOMER_OWNER = "e2e_客户负责人"
REGION_NAME = "沈阳市铁西区"
# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取
console_errors: list[str] = []
# ==================== 页面操作辅助 ====================
def confirm_text(page: Page) -> str:
"""当前确认框的完整文本(业务口径文案断言用),轮询等待出现。"""
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if page.locator(".el-message-box:visible").count():
break
page.wait_for_timeout(200)
return page.locator(".el-message-box:visible").last.inner_text()
def fill_form_item(page: Page, scope, label: str, value: str) -> None:
"""在表单(dialog / drawer)范围内按 form-item 标签填写输入框。
标签用 `:text-is` 精确匹配(子串会让「姓名」命中「真实姓名」一类歧义);
只用于真实 el-input 的表单项(el-select 的表单项走 select_form_option)。
"""
item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))')
item.locator("input").first.fill(value)
def fill_date(page: Page, scope, label: str, value: str) -> None:
"""el-date-picker(type=date)按 form-item 标签填日期:点输入框 → 键入 → 回车。"""
item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))')
item.locator("input").first.click()
page.keyboard.type(value)
page.keyboard.press("Enter")
page.wait_for_timeout(300)
def select_form_option(page: Page, scope, label: str, option: str) -> None:
"""在表单范围内展开指定标签的下拉并选择选项。
下拉面板 teleport 到 body,展开与选项均按 `:visible` 过滤;选项渲染
(filterable / 页面挂载时拉取的数据源)可能滞后,轮询等待再点击。
"""
item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))')
item.locator(".el-select").first.click()
option_item = page.locator(
".el-select-dropdown:visible .el-select-dropdown__item", has_text=option
)
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if option_item.count():
break
page.wait_for_timeout(200)
if option_item.count() == 0:
fail("下拉选择", f"下拉「{label}」中未出现选项「{option}」")
option_item.first.click()
page.wait_for_timeout(200)
def refresh_page_data(page: Page) -> None:
"""刷新当前页面(保持路径):重新挂载页面并重拉挂载时的一次性数据源。"""
page.reload(wait_until="networkidle")
page.wait_for_timeout(800)
def find_row(page: Page, filter_label: str, keyword: str):
"""按筛选条件查询并返回含 keyword 的第一行(找不到时 count() 为 0)。"""
fill_filter(page, filter_label, keyword)
click_query(page)
return table_rows(page).filter(has_text=keyword).first
def delete_row(page: Page, row, success_toast: str) -> None:
"""删除行:点「删除」→ 确认「删除」→ 断言成功提示(各模块成功文案不同)。"""
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "删除")
expect_toast(page, success_toast, "数据清理")
page.wait_for_timeout(600)
# ==================== 基础数据创建(本脚本与 business_data 复用) ====================
def create_manager_via_ui(page: Page, name: str) -> None:
"""导航到人员管理并经界面新增营销经理 → 列表岗位回显。"""
open_menu(page, "人员管理", "基础数据")
if current_path(page) != "/base/personnel":
fail("菜单导航", f"未进入人员管理页,当前 {page.url}")
page.click("button:has-text('新增人员')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
fill_form_item(page, dialog, "姓名", name)
select_form_option(page, dialog, "岗位", "营销经理")
fill_form_item(page, dialog, "联系电话", MANAGER_PHONE)
fill_form_item(page, dialog, "工资标准", MANAGER_SALARY)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "人员已创建", "新增人员")
row = find_row(page, "姓名", name)
if row.count() == 0:
fail("新增人员", f"列表中未找到新建的 {name}")
if "营销经理" not in row.inner_text():
fail("新增人员", "列表岗位列未回显「营销经理」")
page.wait_for_timeout(400)
def create_salesman_via_ui(page: Page, name: str, manager_name: str) -> None:
"""在人员管理页新增营销员并挂直属经理(先刷新页面重拉经理下拉)。
经理下拉数据源只在页面挂载时拉取一次 —— 同一页面连续「新建经理再
新建业务员」时下拉不含新经理,必须先刷新页面。
"""
open_menu(page, "人员管理", "基础数据")
refresh_page_data(page)
if current_path(page) != "/base/personnel":
fail("菜单导航", f"刷新后未回到人员管理页,当前 {page.url}")
page.click("button:has-text('新增人员')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
fill_form_item(page, dialog, "姓名", name)
select_form_option(page, dialog, "岗位", "营销员")
select_form_option(page, dialog, "直属经理", manager_name)
fill_form_item(page, dialog, "工资标准", SALESMAN_SALARY)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "人员已创建", "新增业务员")
row = find_row(page, "姓名", name)
if row.count() == 0:
fail("新增业务员", f"列表中未找到新建的 {name}")
if manager_name not in row.inner_text():
fail("新增业务员", "列表「直属经理」列未回显经理姓名")
page.wait_for_timeout(400)
def create_product_via_ui(page: Page, product_name: str, spec: str) -> None:
"""导航到产品管理并经界面新增产品(类别=轨道交通)→ 列表回显。"""
open_menu(page, "产品管理", "基础数据")
if current_path(page) != "/base/products":
fail("菜单导航", f"未进入产品管理页,当前 {page.url}")
page.click("button:has-text('新增产品')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
fill_form_item(page, dialog, "产品名称", product_name)
fill_form_item(page, dialog, "规格型号", spec)
fill_form_item(page, dialog, "数量", "100")
fill_form_item(page, dialog, "单价(元)", "3000")
select_form_option(page, dialog, "产品类别", "轨道交通")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "产品已创建", "新增产品")
row = find_row(page, "关键词", product_name)
if row.count() == 0:
fail("新增产品", f"列表中未找到新建的 {product_name}")
if "轨道交通" not in row.inner_text():
fail("新增产品", "列表类别列未回显「轨道交通」")
page.wait_for_timeout(400)
def create_customer_via_ui(page: Page, company: str, owner: str, initial_owner: str) -> None:
"""导航到客户管理并经界面新增客户(企业户 + 初始归属)→ 列表回显。"""
open_menu(page, "客户管理", "基础数据")
if current_path(page) != "/base/customers":
fail("菜单导航", f"未进入客户管理页,当前 {page.url}")
page.click("button:has-text('新增客户')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
fill_form_item(page, dialog, "公司名称", company)
fill_form_item(page, dialog, "负责人", owner)
fill_form_item(page, dialog, "行业", "轨道交通")
select_form_option(page, dialog, "客户分类", "企业户")
select_form_option(page, dialog, "初始归属", initial_owner)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "客户已创建", "新增客户")
row = find_row(page, "关键词", company)
if row.count() == 0:
fail("新增客户", f"列表中未找到新建的 {company}")
if initial_owner not in row.inner_text():
fail("新增客户", "列表「归属人员」列未回显初始归属")
if "企业户" not in row.inner_text():
fail("新增客户", "列表分类列未回显「企业户」")
page.wait_for_timeout(400)
# ==================== 流程步骤 ====================
def step_open_group_and_personnel(page: Page, admin_password: str) -> None:
"""登录、「基础数据」分组出现且含三页,点开「人员管理」。"""
login(page, ADMIN, admin_password)
menu = page.locator(".app-menu")
if menu.locator(".el-sub-menu__title", has_text="基础数据").count() == 0:
fail("菜单分组", "侧边菜单未出现「基础数据」分组")
for title in ("人员管理", "产品管理", "客户管理"):
if menu.locator(".el-menu-item", has_text=title).count() == 0:
fail("菜单分组", f"「基础数据」分组内未找到「{title}」")
open_menu(page, "人员管理", "基础数据")
if current_path(page) != "/base/personnel":
fail("菜单导航", f"未进入人员管理页,当前 {page.url}")
print("✓ 1. 菜单导航 「基础数据」分组出现、三页可点开,已进入人员管理")
def step_person_create_manager(page: Page) -> None:
create_manager_via_ui(page, MANAGER_NAME)
print(f"✓ 2. 新增经理 创建 {MANAGER_NAME}(岗位=营销经理),列表可见")
def step_person_create_salesman(page: Page) -> None:
create_salesman_via_ui(page, SALESMAN_NAME, MANAGER_NAME)
print(f"✓ 3. 新增业务员 创建 {SALESMAN_NAME}(岗位=营销员),经理名回显")
def step_person_edit_regions(page: Page) -> None:
"""编辑抽屉给经理添加一条辐射区域行 → 保存 → 重开回显。"""
row = find_row(page, "姓名", MANAGER_NAME)
row.locator("button:has-text('区域与计划')").click()
page.wait_for_timeout(600)
drawer = page.locator(".el-drawer:visible").last
drawer.locator("button:has-text('添加区域')").click()
page.wait_for_timeout(300)
drawer.locator('input[placeholder="如 沈阳市铁西区"]').last.fill(REGION_NAME)
drawer.locator("button:has-text('保存区域')").click()
expect_toast(page, "辐射区域已保存", "编辑辐射区域")
# 重开抽屉验证回显(保存走全量替换接口,详情须持久化)
page.keyboard.press("Escape")
page.wait_for_timeout(600)
row = find_row(page, "姓名", MANAGER_NAME)
row.locator("button:has-text('区域与计划')").click()
page.wait_for_timeout(600)
drawer = page.locator(".el-drawer:visible").last
region_input = drawer.locator('input[placeholder="如 沈阳市铁西区"]').first
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if region_input.count() and region_input.input_value() == REGION_NAME:
break
page.wait_for_timeout(200)
if region_input.count() == 0 or region_input.input_value() != REGION_NAME:
fail("辐射区域回显", "重新打开抽屉未回显已保存的区域名称")
page.keyboard.press("Escape")
page.wait_for_timeout(500)
print("✓ 4. 辐射区域 编辑抽屉添加区域保存,重开回显正确")
def step_person_delete_protection(page: Page) -> None:
"""删除被引用的经理被 400 拦截并引导「改为离职」。
此处**不真改离职**——后续归属调整步骤要以在职经理为目标;
「改为离职」的动作移到清理步骤执行(删除保护路径已在本步断言)。
"""
row = find_row(page, "姓名", MANAGER_NAME)
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
# 确认框文案本身已含「此时应改为离职」引导;确认后走后端校验
confirm_message_box(page, "删除")
expect_toast(page, "请改为离职", "人员删除保护")
print("✓ 5. 删除保护 删除被引人员被 400 拦截,引导改为离职")
def step_products(page: Page) -> None:
"""新增产品(轨道交通)与同名称同规格重复负例。"""
create_product_via_ui(page, PRODUCT_NAME, PRODUCT_SPEC)
print(f"✓ 6. 新增产品 创建 {PRODUCT_NAME}(类别=轨道交通),列表可见")
# 同名称同规格重复 → 400(唯一键 = 名称 + 规格)
page.click("button:has-text('新增产品')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
fill_form_item(page, dialog, "产品名称", PRODUCT_NAME)
fill_form_item(page, dialog, "规格型号", PRODUCT_SPEC)
select_form_option(page, dialog, "产品类别", "轨道交通")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
if "已存在同名称同规格的产品" not in wait_toast(page, "已存在"):
fail("产品重复负例", f"重复产品未提示已存在,当前提示:{toast_text(page) or '(无)'}")
visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click()
page.wait_for_timeout(500)
print("✓ 7. 产品重复负例 同名称同规格被 400 拒绝并给出中文文案")
def step_customers(page: Page) -> None:
create_customer_via_ui(page, CUSTOMER_COMPANY, CUSTOMER_OWNER, SALESMAN_NAME)
print(f"✓ 8. 新增客户 创建 {CUSTOMER_COMPANY}(企业户,初始归属=业务员)")
def step_ownership_change(page: Page) -> None:
"""归属调整到经理:二次确认含「不回溯历史」,留痕展示旧 → 新。"""
row = find_row(page, "关键词", CUSTOMER_COMPANY)
row.locator("button:has-text('归属变更')").click()
page.wait_for_timeout(500)
dialog = visible_dialog(page)
if SALESMAN_NAME not in dialog.inner_text():
fail("归属变更", "变更弹窗未回显当前归属业务员")
select_form_option(page, dialog, "新归属人员", MANAGER_NAME)
dialog.locator("button:has-text('提交变更')").click()
page.wait_for_timeout(500)
# 业务口径(对照表 #19):二次确认文案必须含「不回溯历史」
box = confirm_text(page)
if "不回溯历史" not in box:
fail("归属变更确认", f"二次确认文案缺少「不回溯历史」:{box[:120]}")
confirm_message_box(page, "确认变更")
expect_toast(page, "归属已调整", "归属调整")
row = find_row(page, "关键词", CUSTOMER_COMPANY)
if row.count() == 0:
fail("归属调整", "按关键词查询未找到客户行")
if MANAGER_NAME not in row.inner_text():
fail("归属调整", "列表「归属人员」列未更新为经理")
print("✓ 9. 归属调整 二次确认含「不回溯历史」,归属已改到经理")
row.locator("button:has-text('变更记录')").click()
page.wait_for_timeout(800)
drawer = page.locator(".el-drawer:visible").last
transition = f"{SALESMAN_NAME} → {MANAGER_NAME}"
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if drawer.locator(".el-table__row", has_text=transition).count():
break
page.wait_for_timeout(200)
if drawer.locator(".el-table__row", has_text=transition).count() == 0:
fail("归属留痕", f"变更记录抽屉未看到「{transition}」的留痕行")
page.keyboard.press("Escape")
page.wait_for_timeout(500)
print("✓ 10. 归属留痕 留痕抽屉展示旧 → 新归属一条记录")
def step_cleanup(page: Page) -> None:
"""清理测试数据:按 客户 → 产品 → 业务员 → 经理 依赖序删除。
经理此刻仍在职(删除保护步骤刻意未改);删除被拦时先「设为离职」
再删——届时客户/业务员均已删除,经理应无引用、可正常删除。
"""
open_menu(page, "客户管理", "基础数据")
row = find_row(page, "关键词", CUSTOMER_COMPANY)
if row.count() > 0:
delete_row(page, row, "客户已删除")
open_menu(page, "产品管理", "基础数据")
row = find_row(page, "关键词", PRODUCT_NAME)
if row.count() > 0:
delete_row(page, row, "产品已删除")
open_menu(page, "人员管理", "基础数据")
row = find_row(page, "姓名", SALESMAN_NAME)
if row.count() > 0:
delete_row(page, row, "人员已删除")
row = find_row(page, "姓名", MANAGER_NAME)
if row.count() > 0:
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "删除")
text = wait_toast(page, "已删除")
if "人员已删除" in text:
pass
elif "请改为离职" in text or "存在团队成员" in text or "业务归属" in text:
# 引导路径照做:设为离职后再删(引用已随客户/业务员删除解除)
row = find_row(page, "姓名", MANAGER_NAME)
row.locator("button:has-text('设为离职')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "设为离职")
expect_toast(page, "已设为离职", "清理·经理离职")
row = find_row(page, "姓名", MANAGER_NAME)
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "删除")
expect_toast(page, "人员已删除", "清理·经理删除")
else:
fail("数据清理", f"清理经理出现未预期提示:{text or '(无)'}")
print("✓ 12. 数据清理 测试数据按依赖序清理,被保护项按留档说明处理")
def step_console_assertion() -> None:
"""断言无 JS 控制台错误。
口径:4xx 请求触发的浏览器网络层日志(Failed to load resource)在
负例步骤属预期,不算 JS 错误;只拦脚本错误(console.error / pageerror)。
网络资源日志仍收集在 console_errors 供失败诊断。
"""
real_errors = [e for e in console_errors if "Failed to load resource" not in e]
if real_errors:
fail("控制台检查", f"发现 {len(real_errors)} 条 JS 错误:{real_errors[:3]}")
print("✓ 13. 控制台检查 无 JS 控制台错误")
# ==================== 入口 ====================
def run(page: Page) -> None:
collect_console_errors(page, console_errors)
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
if not admin_password:
fail(
"环境变量",
"未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码),"
"示例:E2E_ADMIN_PASSWORD=xxx python e2e/base_data.py",
)
step_open_group_and_personnel(page, admin_password)
step_person_create_manager(page)
step_person_create_salesman(page)
step_person_edit_regions(page)
step_person_delete_protection(page)
step_products(page)
step_customers(page)
step_ownership_change(page)
step_cleanup(page)
step_console_assertion()
if console_errors:
print(f" (网络资源日志 {len(console_errors)} 条,属 4xx 请求的预期噪声)")
def main() -> int:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 860})
page.set_default_timeout(10_000)
try:
run(page)
except E2EFailure as exc:
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ {exc}")
if console_errors:
print(f" 控制台错误:{console_errors[-5:]}")
print(" 已保存截图 e2e/failure.png")
return 1
except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
if console_errors:
print(f" 控制台错误:{console_errors[-5:]}")
print(" 已保存截图 e2e/failure.png")
return 1
finally:
browser.close()
print("\n全部通过。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+623
View File
@@ -0,0 +1,623 @@
"""业务数据全链路端到端验证:业务 / 发货 / 回款 / 状态机 / 评级计算器。
覆盖的链路(只有真实浏览器才能验证的组合):
- 基础数据准备(复用 `e2e/base_data.py` 建数):经理 / 业务员(挂经理)/
产品(轨道交通)/ 客户(企业户,归属=业务员)
- 业务:创建(两行明细:市政+轨交,数量/单价/成本)→ YW 业务编号与销售额
显示;编辑明细数量 → 汇总重算(销售额变化)
- 详情抽屉:产品明细回显、状态时间线「业务创建」首条、毛利润类别汇总卡片
与「不含运费」口径注释
- 归属调整(业务员 → 经理):二次确认含「不回溯历史」(对照表 #19)、
详情留痕表可见旧 → 新
- 发货管理:挂执行中业务创建发货单(日期/运费/明细行)→ SH 单号与发货总额
- 回款管理:选业务后级联加载其发货单(断言下拉出现刚建发货单号)→ 指定
批次 → 回款天数派生显示「6 天」(发货日计第 1 天);第二笔不指定批次 →
显示「未指定批次」
- 状态机:执行中 → 已完成(详情时间线新增记录)→ 终态业务新发货被 400
拦截(「禁止新发货」)
- 客户评级计算器:五项各 20 分 → 总分 100 → 等级 A;等级映射抽屉 A/B/C/D
- 控制台无 JS 错误;清理按 回款 → 发货 → 业务 → 客户 → 产品 → 人员 依赖序
**运行前置**(缺一不可)::
docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis
cd backend && uv run uvicorn app.main:app --reload --port 8000 # 后端
cd frontend && pnpm dev # 前端
管理员密码经环境变量传入(不写死在源码里)::
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/business_data.py
脚本自建的测试数据(`e2e_` 前缀)在流程内清理;删除保护拦截属预期行为,
被拦截时打印留档说明。页面辅助与基础数据建数复用 `e2e/base_data.py`。
"""
import os
import re
import sys
import time
sys.path.insert(0, os.path.dirname(__file__))
from playwright.sync_api import Page, sync_playwright
from common import (
E2EFailure,
collect_console_errors,
current_path,
expect_toast,
fail,
login,
wait_toast,
)
import base_data as base
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ADMIN = "admin"
# 明细两行:市政 10×5000(成本 3000)+ 轨交 5×3000(成本 2000)
ITEM_NAME_MUNICIPAL = f"e2e_市政产品_{base.SUFFIX}"
BUSINESS_DATE = "2026-01-05"
SHIP_DATE = "2026-01-10"
RECEIPT_DATE = "2026-01-15"
INIT_SALE_AMOUNT = "65000" # 10×5000 + 5×3000
EDITED_SALE_AMOUNT = "115000" # 编辑后 20×5000 + 5×3000
SHIP_FREIGHT = "100"
SHIP_ITEM_QUANTITY = "6"
SHIP_ITEM_PRICE = "4800"
SHIP_TOTAL = "28800" # 6×4800
RECEIPT_AMOUNT_1 = "20000"
RECEIPT_AMOUNT_2 = "5000"
RECEIPT_DAYS = "6 天" # 回款日相对发货日 2026-01-10(发货日计第 1 天)
# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取
console_errors: list[str] = []
# 运行时提取的单据编号(新增业务/发货单后从列表文本提取,级联与清理用)
business_no = ""
shipment_no = ""
# ==================== 流程步骤 ====================
def step_setup_base(page: Page, admin_password: str) -> None:
"""登录并复用基础数据建数:经理 / 业务员(挂经理)/ 产品 / 客户(归属=业务员)。"""
login(page, ADMIN, admin_password)
base.create_manager_via_ui(page, base.MANAGER_NAME)
print(f"✓ 1. 建经理 {base.MANAGER_NAME}(岗位=营销经理)")
base.create_salesman_via_ui(page, base.SALESMAN_NAME, base.MANAGER_NAME)
print(f"✓ 2. 建业务员 {base.SALESMAN_NAME}(岗位=营销员,挂经理)")
base.create_product_via_ui(page, base.PRODUCT_NAME, base.PRODUCT_SPEC)
print(f"✓ 3. 建产品 {base.PRODUCT_NAME}(类别=轨道交通)")
base.create_customer_via_ui(
page, base.CUSTOMER_COMPANY, base.CUSTOMER_OWNER, base.SALESMAN_NAME
)
print(f"✓ 4. 建客户 {base.CUSTOMER_COMPANY}(企业户,归属=业务员)")
def fill_item_row(row, name: str, quantity: str, price: str, cost: str) -> None:
"""按 placeholder 填写业务明细行:名称 / 数量 / 销售单价 / 成本额。
「元,选填」的 placeholder 在单价/成本/指导价三列重复,按列序 nth 取
(单价在前、成本居次),指导价不填。
"""
row.locator('input[placeholder="产品名称,可手填"]').fill(name)
row.locator('input[placeholder=">0"]').fill(quantity)
row.locator('input[placeholder="元,选填"]').nth(0).fill(price)
row.locator('input[placeholder="元,选填"]').nth(1).fill(cost)
def select_row_option(page: Page, row, option: str) -> None:
"""点开表格行内的下拉(明细行「类别」列,非 form-item 结构)并选择选项。"""
row.locator(".el-select").first.click()
option_item = page.locator(
".el-select-dropdown:visible .el-select-dropdown__item", has_text=option
)
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if option_item.count():
break
page.wait_for_timeout(200)
if option_item.count() == 0:
fail("下拉选择", f"行内下拉未出现选项「{option}」")
option_item.first.click()
page.wait_for_timeout(200)
def step_business_create(page: Page) -> None:
"""创建业务:选客户、两行明细(市政+轨交)、负责人员=业务员。"""
global business_no
base.open_menu(page, "业务管理", "业务数据")
if current_path(page) != "/business/businesses":
fail("菜单导航", f"未进入业务管理页,当前 {page.url}")
page.click("button:has-text('新增业务')")
page.wait_for_timeout(500)
dialog = base.visible_dialog(page)
base.select_form_option(page, dialog, "客户", base.CUSTOMER_COMPANY)
base.fill_date(page, dialog, "业务日期", BUSINESS_DATE)
base.select_form_option(page, dialog, "负责人员", base.SALESMAN_NAME)
item_rows = dialog.locator(".items-table .el-table__row")
fill_item_row(item_rows.nth(0), ITEM_NAME_MUNICIPAL, "10", "5000", "3000")
dialog.locator("button:has-text('添加一行')").click()
page.wait_for_timeout(300)
item_rows = dialog.locator(".items-table .el-table__row")
select_row_option(page, item_rows.nth(1), "轨道交通")
fill_item_row(item_rows.nth(1), base.PRODUCT_NAME, "5", "3000", "2000")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "业务已创建", "新增业务")
row = base.find_row(page, "关键词", base.CUSTOMER_COMPANY)
if row.count() == 0:
fail("新增业务", f"列表中未找到新建业务(客户 {base.CUSTOMER_COMPANY})")
text = row.inner_text()
no_match = re.search(r"YW\d{8}-\d+", text)
if not no_match:
fail("新增业务", f"列表未显示 YW 前缀业务编号:{text[:120]}")
business_no = no_match.group(0)
if INIT_SALE_AMOUNT not in text:
fail("新增业务", f"列表销售额未显示 {INIT_SALE_AMOUNT}:{text[:160]}")
print(f"✓ 5. 新增业务 两行明细创建成功,业务编号 {business_no},销售额 {INIT_SALE_AMOUNT}")
def step_business_edit(page: Page) -> None:
"""编辑业务:第一行数量 10 → 20 → 保存 → 列表销售额汇总重算。"""
row = base.find_row(page, "关键词", business_no)
row.locator("button:has-text('编辑')").click()
# openEdit 异步拉详情后全量替换明细草稿(此时预置草稿仍显示旧值),
# 固定等待让「回显替换」先完成,避免填到将被丢弃的草稿上
page.wait_for_timeout(1500)
dialog = base.visible_dialog(page)
item_rows = dialog.locator(".items-table .el-table__row")
if item_rows.count() < 2:
fail("编辑业务", "编辑弹窗未回显两行明细")
item_rows.nth(0).locator('input[placeholder=">0"]').fill("20")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "已保存", "编辑业务")
row = base.find_row(page, "关键词", business_no)
if EDITED_SALE_AMOUNT not in row.inner_text():
fail("编辑汇总重算", f"保存后销售额未更新为 {EDITED_SALE_AMOUNT}:{row.inner_text()[:160]}")
print(f"✓ 6. 编辑重算 明细数量 10→20,销售额 {INIT_SALE_AMOUNT} → {EDITED_SALE_AMOUNT}")
def step_business_detail(page: Page) -> None:
"""详情抽屉:明细表回显、时间线「业务创建」首条、毛利润类别汇总与口径注释。"""
row = base.find_row(page, "关键词", business_no)
row.locator("button:has-text('详情')").click()
page.wait_for_timeout(1000)
drawer = page.locator(".el-drawer:visible").last
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if drawer.locator(".detail-desc").count():
break
page.wait_for_timeout(200)
detail_text = drawer.inner_text()
if ITEM_NAME_MUNICIPAL not in detail_text or base.PRODUCT_NAME not in detail_text:
fail("业务详情", "详情抽屉产品明细未回显两行产品")
timeline = drawer.locator(".status-timeline .el-timeline-item")
if timeline.count() == 0:
fail("业务详情", "状态时间线为空")
if "业务创建" not in (timeline.first.inner_text() or ""):
fail("业务详情", "时间线首条不是「业务创建」")
if (
drawer.locator(".gp-card", has_text="市政水利").count() == 0
or drawer.locator(".gp-card", has_text="轨道交通").count() == 0
):
fail("毛利润视图", "类别汇总卡片未展示市政水利/轨道交通")
note = drawer.locator(".gp-note").inner_text()
if "不含运费" not in note:
fail("毛利润注释", f"口径注释缺少「不含运费」:{note[:120]}")
page.keyboard.press("Escape")
page.wait_for_timeout(600)
print("✓ 7. 详情抽屉 明细回显、时间线「业务创建」首条、毛利润汇总与口径注释")
def step_business_ownership(page: Page) -> None:
"""归属调整(业务员 → 经理):确认含「不回溯历史」→ 详情留痕表可见。"""
row = base.find_row(page, "关键词", business_no)
row.locator("button:has-text('归属')").click()
page.wait_for_timeout(500)
dialog = base.visible_dialog(page)
if business_no not in dialog.inner_text():
fail("归属调整", "归属调整弹窗未回显业务编号")
base.select_form_option(page, dialog, "新负责人员", base.MANAGER_NAME)
dialog.locator("button:has-text('提交调整')").click()
page.wait_for_timeout(500)
box = base.confirm_text(page)
if "不回溯历史" not in box:
fail("归属调整确认", f"二次确认文案缺少「不回溯历史」:{box[:120]}")
base.confirm_message_box(page, "确认调整")
expect_toast(page, "归属已调整", "业务归属调整")
row = base.find_row(page, "关键词", business_no)
if row.count() == 0:
fail("业务归属调整", "按关键词查询未找到业务行")
if base.MANAGER_NAME not in row.inner_text():
fail("业务归属调整", "列表「负责人员」列未更新为经理")
row.locator("button:has-text('详情')").click()
page.wait_for_timeout(1000)
drawer = page.locator(".el-drawer:visible").last
transition = f"负责 {base.SALESMAN_NAME} → {base.MANAGER_NAME}"
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if drawer.locator(".el-table__row", has_text=transition).count():
break
page.wait_for_timeout(200)
if drawer.locator(".el-table__row", has_text=transition).count() == 0:
fail("归属留痕", f"详情抽屉归属变更记录未看到「{transition}」")
page.keyboard.press("Escape")
page.wait_for_timeout(600)
print("✓ 8. 归属调整 业务员→经理确认含「不回溯历史」,详情留痕表可见")
def step_shipment_create(page: Page) -> None:
"""发货管理:挂执行中业务创建发货单(日期/运费/明细行)→ SH 单号。"""
global shipment_no
base.open_menu(page, "发货管理", "业务数据")
if current_path(page) != "/business/shipments":
fail("菜单导航", f"未进入发货管理页,当前 {page.url}")
page.click("button:has-text('新增发货单')")
page.wait_for_timeout(500)
dialog = base.visible_dialog(page)
base.select_form_option(page, dialog, "业务", business_no)
base.fill_date(page, dialog, "发货日期", SHIP_DATE)
base.fill_form_item(page, dialog, "运费(元)", SHIP_FREIGHT)
item_row = dialog.locator(".items-table .el-table__row").first
item_row.locator('input[placeholder="产品名称,可手填"]').fill(base.PRODUCT_NAME)
item_row.locator('input[placeholder=">0"]').fill(SHIP_ITEM_QUANTITY)
item_row.locator('input[placeholder="可空=待人工补价"]').fill(SHIP_ITEM_PRICE)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
page.wait_for_timeout(500)
base.confirm_message_box(page, "确认创建")
expect_toast(page, "发货单已创建", "新增发货单")
base.select_filter_option(page, "业务", business_no)
base.click_query(page)
ship_row = base.table_rows(page).filter(has_text=business_no).first
if ship_row.count() == 0:
fail("新增发货单", "发货列表未显示该业务的发货单")
ship_text = ship_row.inner_text()
no_match = re.search(r"SH\d{8}-\d+", ship_text)
if not no_match:
fail("新增发货单", f"发货单号未显示 SH 前缀:{ship_text[:120]}")
shipment_no = no_match.group(0)
if SHIP_TOTAL not in ship_text:
fail("新增发货单", f"发货总额未显示 {SHIP_TOTAL}:{ship_text[:160]}")
print(f"✓ 9. 发货单 挂业务创建成功,发货单号 {shipment_no},发货总额 {SHIP_TOTAL}")
def step_receipt_with_shipment(page: Page) -> None:
"""第一笔回款:选业务 → 级联加载发货单并断言下拉出现刚建单号 → 天数派生。"""
base.open_menu(page, "回款管理", "业务数据")
if current_path(page) != "/business/receipts":
fail("菜单导航", f"未进入回款管理页,当前 {page.url}")
page.click("button:has-text('新增回款')")
page.wait_for_timeout(500)
dialog = base.visible_dialog(page)
base.select_form_option(page, dialog, "业务", business_no)
page.wait_for_timeout(600) # 等发货批次级联加载完成
# 级联断言:发货批次下拉必须出现刚建的发货单号
batch_item = dialog.locator('.el-form-item:has(.el-form-item__label:text-is("发货批次"))')
batch_item.locator(".el-select").first.click()
batch_option = page.locator(
".el-select-dropdown:visible .el-select-dropdown__item", has_text=shipment_no
)
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if batch_option.count():
break
page.wait_for_timeout(200)
if batch_option.count() == 0:
fail("回款级联", f"发货批次下拉未级联出刚建的发货单 {shipment_no}")
batch_option.first.click()
page.wait_for_timeout(200)
base.fill_date(page, dialog, "回款到账日", RECEIPT_DATE)
base.fill_form_item(page, dialog, "金额(元)", RECEIPT_AMOUNT_1)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "回款已登记", "新增回款")
base.select_filter_option(page, "业务", business_no)
base.click_query(page)
rows_text = " ".join(base.table_rows(page).all_inner_texts())
if RECEIPT_DAYS not in rows_text:
fail("回款天数派生", f"指定批次后回款天数未显示「{RECEIPT_DAYS}」:{rows_text[:200]}")
if "未指定批次" in rows_text:
fail("回款天数派生", "指定批次后不应显示「未指定批次」")
print(f"✓ 10. 回款第一笔 级联出 {shipment_no} 并指定,回款天数显示「{RECEIPT_DAYS}」")
def step_receipt_without_shipment(page: Page) -> None:
"""第二笔回款:不指定批次 → 列表显示「未指定批次」,第一笔天数不消失。"""
page.click("button:has-text('新增回款')")
page.wait_for_timeout(500)
dialog = base.visible_dialog(page)
base.select_form_option(page, dialog, "业务", business_no)
page.wait_for_timeout(600)
base.fill_date(page, dialog, "回款到账日", RECEIPT_DATE)
base.fill_form_item(page, dialog, "金额(元)", RECEIPT_AMOUNT_2)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "回款已登记", "新增第二笔回款")
base.click_query(page)
rows_text = " ".join(base.table_rows(page).all_inner_texts())
if "未指定批次" not in rows_text:
fail("未指定批次", "第二笔回款(未指定批次)未显示「未指定批次」")
if RECEIPT_DAYS not in rows_text:
fail("未指定批次", f"第一笔回款的「{RECEIPT_DAYS}」不应消失")
print("✓ 11. 回款第二笔 不指定批次显示「未指定批次」,第一笔天数保留")
def step_status_flow(page: Page) -> None:
"""状态机:执行中 → 已完成(详情时间线新增记录)。"""
base.open_menu(page, "业务管理", "业务数据")
row = base.find_row(page, "关键词", business_no)
if "执行中" not in row.inner_text():
fail("状态流转", f"业务当前状态不是执行中:{row.inner_text()[:120]}")
row.locator("button:has-text('状态')").click()
page.wait_for_timeout(500)
dialog = base.visible_dialog(page)
# 目标状态默认「已完成」(openStatus 预设 status=2),直接确认
dialog.locator("button:has-text('确认变更')").click()
page.wait_for_timeout(500)
box = base.confirm_text(page)
if "终态不可再变更" not in box:
fail("状态流转确认", f"二次确认文案缺少「终态不可再变更」:{box[:120]}")
base.confirm_message_box(page, "已完成")
expect_toast(page, "状态已变更", "状态流转")
row = base.find_row(page, "关键词", business_no)
if row.count() == 0:
fail("状态流转", "按关键词查询未找到业务行")
if "已完成" not in row.inner_text():
fail("状态流转", "列表状态未更新为已完成")
row.locator("button:has-text('详情')").click()
page.wait_for_timeout(1000)
drawer = page.locator(".el-drawer:visible").last
timeline = drawer.locator(".status-timeline .el-timeline-item")
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if timeline.count() >= 2:
break
page.wait_for_timeout(200)
if timeline.count() < 2:
fail("状态时间线", f"流转后时间线未新增记录({timeline.count()} 条)")
if "已完成" not in (timeline.last.inner_text() or ""):
fail("状态时间线", "时间线末条未显示已完成状态")
if "业务创建" not in (timeline.first.inner_text() or ""):
fail("状态时间线", "时间线首条「业务创建」不应消失")
page.keyboard.press("Escape")
page.wait_for_timeout(600)
print("✓ 12. 状态流转 执行中→已完成,详情时间线新增一条记录")
def step_terminal_shipment_blocked(page: Page) -> None:
"""终态业务(已完成)再建发货单被 400 拦截(「禁止新发货」)。"""
base.open_menu(page, "发货管理", "业务数据")
page.click("button:has-text('新增发货单')")
page.wait_for_timeout(500)
dialog = base.visible_dialog(page)
base.select_form_option(page, dialog, "业务", business_no)
base.fill_date(page, dialog, "发货日期", SHIP_DATE)
item_row = dialog.locator(".items-table .el-table__row").first
item_row.locator('input[placeholder="产品名称,可手填"]').fill(base.PRODUCT_NAME)
item_row.locator('input[placeholder=">0"]').fill("1")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
page.wait_for_timeout(500)
base.confirm_message_box(page, "确认创建")
expect_toast(page, "禁止新发货", "终态业务发货拦截")
base.visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click()
page.wait_for_timeout(500)
print("✓ 13. 终态拦截 已完成业务新发货被 400 拒绝(禁止新发货)")
def step_rating(page: Page) -> None:
"""客户评级计算器:五项各 20 → 总分 100 → 等级 A;映射抽屉 A/B/C/D。"""
base.open_menu(page, "客户评级计算器", "业务数据")
if current_path(page) != "/business/rating":
fail("菜单导航", f"未进入客户评级计算器页,当前 {page.url}")
for label in ("发货额", "回款率", "毛利率", "合同履约配合能力", "现场规范施工能力"):
score_row = page.locator(".calc-form .score-row", has_text=label).first
score_row.locator(".el-input-number input").fill("20")
score_row.locator(".el-input-number input").press("Enter")
page.wait_for_timeout(200)
total = (page.locator(".total-num").first.text_content() or "").strip()
if total != "100":
fail("评级总分", f"五项各 20 分后总分未显示 100:{total or '(无)'}")
grade = (page.locator(".grade-name").first.text_content() or "").strip()
if grade != "A":
fail("评级等级", f"总分 100 未命中等级 A:{grade or '(无)'}")
page.click("button:has-text('等级映射设置')")
page.wait_for_timeout(600)
drawer = page.locator(".el-drawer").filter(has_text="等级映射设置").last
table_text = drawer.inner_text()
for letter in ("A", "B", "C", "D"):
if letter not in table_text:
fail("等级映射抽屉", f"映射抽屉表格缺少等级 {letter}")
page.keyboard.press("Escape")
page.wait_for_timeout(500)
print("✓ 14. 评级计算器 五项满 20 → 总分 100 → 等级 A;映射抽屉 A/B/C/D")
def step_cleanup(page: Page) -> None:
"""清理:按 回款 → 发货 → 业务 → 客户 → 产品 → 人员 依赖序删除。
删除保护拦截属预期行为:被拦截时打印留档说明,不做假绿也不误伤主流程。
特别地,步骤 12 已把业务流转为「已完成」——终态业务禁删回款/发货是
设计保护(单据金额参与核算口径),此处删除被拦即留档,不视为失败。
"""
# 回款(业务此刻已是终态:删除必然被拦,留档说明)
base.open_menu(page, "回款管理", "业务数据")
base.select_filter_option(page, "业务", business_no)
base.click_query(page)
while base.table_rows(page).count() > 0:
base.table_rows(page).first.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
base.confirm_message_box(page, "删除")
text = wait_toast(page, "已删除")
if "回款已删除" not in text:
print(" 数据清理 回款删除被拦(业务已终态属预期保护),留档说明:回款保留在开发库")
break
page.wait_for_timeout(600)
base.click_query(page)
# 发货单(回款已删,无批次指定;删除连带明细并回写业务汇总)
base.open_menu(page, "发货管理", "业务数据")
base.select_filter_option(page, "业务", business_no)
base.click_query(page)
while base.table_rows(page).count() > 0:
base.table_rows(page).first.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
base.confirm_message_box(page, "删除")
text = wait_toast(page, "已删除")
if "发货单已删除" not in text:
print(" 数据清理 发货单删除被拦(属预期保护),留档说明:发货单保留在开发库")
break
page.wait_for_timeout(600)
base.click_query(page)
# 业务(发货/回款均已删则可删;仍被拦时留档)
base.open_menu(page, "业务管理", "业务数据")
row = base.find_row(page, "关键词", business_no)
if row.count() > 0:
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
base.confirm_message_box(page, "删除")
text = wait_toast(page, "已删除")
if "业务已删除" in text:
print(f" 数据清理 业务 {business_no} 已删除")
else:
print(
f" 数据清理 业务删除被拦(属预期保护),留档说明:业务 {business_no} 保留在开发库"
)
# 基础数据:客户 → 产品 → 业务员 → 经理
# 客户被留档的业务引用时删除被拦(属预期保护),宽清处理不误伤主流程
base.open_menu(page, "客户管理", "基础数据")
row = base.find_row(page, "关键词", base.CUSTOMER_COMPANY)
if row.count() > 0:
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
base.confirm_message_box(page, "删除")
text = wait_toast(page, "已删除")
if "客户已删除" in text:
pass
else:
print(" 数据清理 客户删除被拦(被留档业务引用属预期保护),留档说明:客户保留在开发库")
base.open_menu(page, "产品管理", "基础数据")
row = base.find_row(page, "关键词", base.PRODUCT_NAME)
if row.count() > 0:
base.delete_row(page, row, "产品已删除")
base.open_menu(page, "人员管理", "基础数据")
for person_name in (base.SALESMAN_NAME, base.MANAGER_NAME):
row = base.find_row(page, "姓名", person_name)
if row.count() == 0:
continue
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
base.confirm_message_box(page, "删除")
text = wait_toast(page, "已删除")
if "人员已删除" in text:
continue
if "请改为离职" in text or "存在团队成员" in text or "业务归属" in text:
print(f" 数据清理 {person_name} 仍被引用(属预期保护),留档说明:保留人员")
continue
fail("数据清理", f"清理人员出现未预期提示:{text or '(无)'}")
print("✓ 15. 数据清理 回款/发货/业务与基础数据按依赖序清理,被保护项留档")
def step_console_assertion() -> None:
"""断言无 JS 控制台错误(口径同 base_data.py:只拦脚本错误)。"""
real_errors = [e for e in console_errors if "Failed to load resource" not in e]
if real_errors:
fail("控制台检查", f"发现 {len(real_errors)} 条 JS 错误:{real_errors[:3]}")
print("✓ 16. 控制台检查 无 JS 控制台错误")
# ==================== 入口 ====================
def run(page: Page) -> None:
collect_console_errors(page, console_errors)
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
if not admin_password:
fail(
"环境变量",
"未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码),"
"示例:E2E_ADMIN_PASSWORD=xxx python e2e/business_data.py",
)
step_setup_base(page, admin_password)
step_business_create(page)
step_business_edit(page)
step_business_detail(page)
step_business_ownership(page)
step_shipment_create(page)
step_receipt_with_shipment(page)
step_receipt_without_shipment(page)
step_status_flow(page)
step_terminal_shipment_blocked(page)
step_rating(page)
step_cleanup(page)
step_console_assertion()
if console_errors:
print(f" (网络资源日志 {len(console_errors)} 条,属 4xx 请求的预期噪声)")
def main() -> int:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 860})
page.set_default_timeout(10_000)
try:
run(page)
except E2EFailure as exc:
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ {exc}")
if console_errors:
print(f" 控制台错误:{console_errors[-5:]}")
print(" 已保存截图 e2e/failure.png")
return 1
except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
if console_errors:
print(f" 控制台错误:{console_errors[-5:]}")
print(" 已保存截图 e2e/failure.png")
return 1
finally:
browser.close()
print("\n全部通过。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+71 -3
View File
@@ -85,19 +85,24 @@ def toast_text(page: Page) -> str:
def wait_toast(page: Page, expected: str, timeout_ms: int = 5000) -> str:
"""等待包含 expected 的全局提示出现,返回其文本(超时返回当前文本)。
"""等待包含 expected 的全局提示出现,返回其文本(超时返回最后见到的非空提示)。
直接读 `toast_text` 会在「上一条提示尚未消失、新提示尚未出现」的窗口里
读到旧文本,产生误判;轮询等待把这类时序问题挡在断言之外。
返回值语义:匹配成功返回该条;超时返回**见过的最后一条非空提示** ——
toast 只有 3 秒生命周期,等到超时才回来断言的场景(如「请求已 400、
错误 toast 一闪而过」)若返回最后一次的空串,诊断信息会全部丢失。
"""
deadline = time.monotonic() + timeout_ms / 1000
text = ""
last_seen = ""
while time.monotonic() < deadline:
text = toast_text(page)
if text:
last_seen = text
if expected in text:
return text
page.wait_for_timeout(200)
return text
return last_seen
def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) -> None:
@@ -151,3 +156,66 @@ def login(page: Page, username: str, password: str) -> None:
page.wait_for_load_state("networkidle")
finally:
page.remove_listener("response", on_response)
# ==================== 列表页 UI 操作(base_data/system_management 共用) ====================
def open_menu(page: Page, title: str, group: str) -> None:
"""点击侧边菜单项,必要时先展开指定分组。
分组(el-sub-menu)已展开时再次点击会收拢,因此先判断菜单项是否可见。
"""
menu = page.locator(".app-menu")
item = menu.locator(".el-menu-item", has_text=title).first
if not item.is_visible():
menu.locator(".el-sub-menu__title", has_text=group).click()
page.wait_for_timeout(400)
item.click()
page.wait_for_timeout(800)
def fill_filter(page: Page, label: str, value: str) -> None:
"""在**当前可见**的筛选表单中按标签填写输入框。
标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」;
选择器带 `:visible` —— 同页存在多个表单实例(v-show 隐藏未销毁)时
会命中隐藏元素。
"""
form_item = page.locator(
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
).first
form_item.locator("input").first.fill(value)
def select_filter_option(page: Page, label: str, option: str) -> None:
"""展开可见筛选表单中指定标签的下拉并选择选项。"""
item = page.locator(
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
)
item.locator(".el-select").click()
page.wait_for_timeout(300)
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
page.wait_for_timeout(200)
def click_query(page: Page) -> None:
"""点击可见筛选表单中的查询按钮。"""
page.locator(".filter-form:visible button:has-text('查询')").first.click()
page.wait_for_timeout(800)
def table_rows(page: Page):
"""当前可见的表格行(选择器带 :visible,避开隐藏实例)。"""
return page.locator(".el-table__body .el-table__row:visible")
def visible_dialog(page: Page):
"""当前可见的对话框(同页可能存在多个 dialog 实例,按可见性取最后一个)。"""
return page.locator(".el-dialog:visible").last
def confirm_message_box(page: Page, button_text: str) -> None:
"""点击确认框(ElMessageBox)中的指定按钮。"""
page.locator(".el-message-box:visible").locator(f"button:has-text('{button_text}')").click()
page.wait_for_timeout(800)
+5 -1
View File
@@ -23,6 +23,8 @@
公共工具见 `e2e/common.py`。
"""
import os
from playwright.sync_api import Page, sync_playwright
from common import (
@@ -36,7 +38,9 @@ from common import (
)
USERNAME = "admin"
PASSWORD = "Admin@2026"
# 与 system_management 同一约定:开发库 admin 密码从环境变量传入(各库不同,
# 硬编码会在他机上假绿为「凭据错误」),缺省保留历史默认值兜底本机老库
PASSWORD = os.environ.get("E2E_ADMIN_PASSWORD", "Admin@2026")
def run(page: Page) -> None:
+13 -66
View File
@@ -29,6 +29,13 @@ import time
from playwright.sync_api import Page, sync_playwright
from common import (
click_query,
confirm_message_box,
fill_filter,
open_menu,
select_filter_option,
table_rows,
visible_dialog,
BASE_URL,
E2EFailure,
collect_console_errors,
@@ -56,66 +63,6 @@ console_errors: list[str] = []
# ==================== 页面操作辅助 ====================
def open_menu(page: Page, title: str) -> None:
"""点击侧边菜单项,必要时先展开「系统管理」分组。
分组已展开时再次点击会收拢,因此先判断菜单项是否已经可见。
"""
menu = page.locator(".app-menu")
item = menu.get_by_text(title, exact=True)
if not item.is_visible():
menu.get_by_text("系统管理", exact=True).click()
page.wait_for_timeout(400)
item.click()
page.wait_for_timeout(800)
def fill_filter(page: Page, label: str, value: str) -> None:
"""在**当前可见**的筛选表单中按标签填写输入框。
选择器一律带 `:visible` —— 日志页两个 tab 各有一个表单(非激活 tab
用 v-show 隐藏),不带可见性过滤会命中隐藏表单的元素。
"""
form_item = page.locator(".filter-form:visible .el-form-item", has_text=label).first
form_item.locator("input").first.fill(value)
def select_filter_option(page: Page, label: str, option: str) -> None:
"""展开可见筛选表单中指定标签的下拉并选择选项。
标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」。
"""
item = page.locator(
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
)
item.locator(".el-select").click()
page.wait_for_timeout(300)
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
page.wait_for_timeout(200)
def click_query(page: Page) -> None:
"""点击可见筛选表单中的查询按钮。"""
page.locator(".filter-form:visible button:has-text('查询')").first.click()
page.wait_for_timeout(800)
def table_rows(page: Page):
"""当前可见的表格行(日志页非激活 tab 的表格隐藏,需过滤)。"""
return page.locator(".el-table__body .el-table__row:visible")
def confirm_message_box(page: Page, button_text: str) -> None:
"""点击确认框(ElMessageBox)中的指定按钮。"""
page.locator(".el-message-box").locator(f"button:has-text('{button_text}')").click()
page.wait_for_timeout(800)
def visible_dialog(page: Page):
"""当前可见的对话框(同页存在多个 dialog 实例,按可见性取)。"""
return page.locator(".el-dialog:visible").last
def create_user_via_ui(
page: Page, username: str, real_name: str, expect_success: bool = True
) -> None:
@@ -154,7 +101,7 @@ def find_user_row(page: Page, username: str):
def step_user_create(page: Page, admin_password: str) -> None:
"""登录、菜单导航、新增用户与重名负例。"""
login(page, ADMIN, admin_password)
open_menu(page, "用户管理")
open_menu(page, "用户管理", "系统管理")
if current_path(page) != "/system/users":
fail("菜单导航", f"未进入用户管理页,当前 {page.url}")
print("✓ 1. 菜单导航 经侧边菜单进入用户管理")
@@ -217,7 +164,7 @@ def step_user_edit_and_status(page: Page) -> None:
def step_role_management(page: Page) -> None:
"""角色保护与角色 CRUD。"""
open_menu(page, "角色管理")
open_menu(page, "角色管理", "系统管理")
if current_path(page) != "/system/roles":
fail("菜单导航", f"未进入角色管理页,当前 {page.url}")
@@ -259,7 +206,7 @@ def step_role_management(page: Page) -> None:
def step_logs_query(page: Page) -> None:
"""操作日志(含详情懒加载与脱敏断言)与登录日志。"""
open_menu(page, "日志查询")
open_menu(page, "日志查询", "系统管理")
if current_path(page) != "/system/logs":
fail("菜单导航", f"未进入日志查询页,当前 {page.url}")
@@ -297,7 +244,7 @@ def step_logs_query(page: Page) -> None:
def step_permission_check(page: Page, admin_password: str) -> None:
"""普通用户菜单隐藏与路由守卫。"""
open_menu(page, "用户管理")
open_menu(page, "用户管理", "系统管理")
create_user_via_ui(page, VIEWER_USERNAME, "E2E访客")
page.click("text=退出登录")
page.wait_for_url("**/login**", timeout=10_000)
@@ -322,7 +269,7 @@ def step_permission_check(page: Page, admin_password: str) -> None:
def step_cleanup(page: Page) -> None:
"""删除测试用户与角色(用 expect_toast 校验,失败不假绿)。"""
open_menu(page, "用户管理")
open_menu(page, "用户管理", "系统管理")
for username in (NEW_USERNAME, VIEWER_USERNAME):
row = find_user_row(page, username)
if row.count() == 0:
@@ -332,7 +279,7 @@ def step_cleanup(page: Page) -> None:
confirm_message_box(page, "删除")
expect_toast(page, "已删除", "数据清理")
open_menu(page, "角色管理")
open_menu(page, "角色管理", "系统管理")
fill_filter(page, "关键词", NEW_ROLE_CODE)
click_query(page)
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
+294
View File
@@ -0,0 +1,294 @@
import { request } from './request'
import type { PageData } from './types'
/**
* 业务产品明细行(对应后端 `BusinessItemOut`)。
*
* 数量与金额一律字符串直出 —— 金额红线:不经 float、不 toFixed,
* Decimal 运算收敛在后端 service 层。
*/
export interface BusinessItemRow {
id: number
product_id: number | null
product_name: string
spec_model: string | null
unit: string | null
/** municipal_water 市政水利 / rail_transit 轨道交通 —— 行级折算锚点 */
product_category: string
/** 数字字符串,>0 */
quantity: string
sale_price: string | null
/** 行销售额 = 数量 × 销售单价,后端计算固化 */
sale_amount: string
cost_amount: string | null
guided_price: string | null
remark: string | null
}
/** 业务列表项(对应后端 `BusinessOut`;四个汇总金额均为字符串直出) */
export interface BusinessItem {
id: number
business_no: string
customer_id: number
customer_name: string | null
/** ISO 串(YYYY-MM-DD) */
business_date: string | null
/** 1执行中 2已完成 3已终止 */
status: number
owner_personnel_id: number | null
owner_personnel_name: string | null
assistant_personnel_id: number | null
assistant_personnel_name: string | null
/** 特殊业务标记:1是 0否 */
is_special: number
special_note: string | null
total_sale_amount: string | null
total_cost_amount: string | null
total_ship_amount: string | null
total_received_amount: string | null
total_freight_amount: string | null
remark: string | null
}
/** 业务详情:附产品明细行(编辑弹窗一次回显) */
export interface BusinessDetail extends BusinessItem {
items: BusinessItemRow[]
}
export interface BusinessQueryParams {
pageNum?: number
pageSize?: number
/** 业务编号/公司名称,模糊匹配 */
keyword?: string
status?: number
customer_id?: number
owner_personnel_id?: number
/** 业务日期起(含),ISO 串 */
date_from?: string
/** 业务日期止(含),ISO 串 */
date_to?: string
}
/** 明细行入参(product_id 空 = 自由录入;金额/数量为数字字符串) */
export interface BusinessItemParams {
product_id?: number | null
product_name: string
spec_model?: string | null
unit?: string | null
product_category: string
quantity: string
sale_price?: string | null
cost_amount?: string | null
guided_price?: string | null
remark?: string | null
}
/**
* 创建业务:主表信息 + 明细行(≥1)。
*
* is_special=1 时 special_note 必填(后端校验,前端同步拦截)。
*/
export interface BusinessCreateParams {
customer_id: number
business_date?: string | null
owner_personnel_id?: number | null
assistant_personnel_id?: number | null
is_special?: number
special_note?: string | null
items: BusinessItemParams[]
remark?: string | null
}
/**
* 编辑业务:明细全量替换并重算汇总。
*
* 不含归属字段 —— 归属调整统一走 `PUT /{id}/ownership`(唯一写归属
* 留痕的入口),编辑表单即使提交归属也会被后端忽略。
*/
export interface BusinessUpdateParams {
customer_id: number
business_date?: string | null
is_special?: number
special_note?: string | null
items: BusinessItemParams[]
remark?: string | null
}
/** 状态流转入参:执行中 → 2已完成 / 3已终止;终止时 reason 必填 */
export interface BusinessStatusParams {
status: number
reason?: string | null
}
/** 归属变更入参(新负责/新协助;前后值写入留痕,不回溯历史) */
export interface BusinessOwnershipParams {
new_owner_personnel_id?: number | null
new_assistant_personnel_id?: number | null
reason?: string | null
}
/** 业务归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */
export interface OwnershipLogItem {
id: number
business_id: number
old_owner_personnel_id: number | null
new_owner_personnel_id: number | null
old_assistant_personnel_id: number | null
new_assistant_personnel_id: number | null
old_owner_name: string | null
new_owner_name: string | null
change_reason: string | null
operator_id: number | null
operator_name: string
create_time: string
}
/** 业务状态流转记录(对应后端 `BusinessStatusLogOut`;创建首条 old=new=1) */
export interface StatusLogItem {
id: number
business_id: number
old_status: number
new_status: number
change_reason: string | null
operator_id: number | null
operator_name: string
create_time: string
}
/** 毛利润视图行(对应后端 `GrossProfitRowOut`);成本缺失行派生列为 null */
export interface GrossProfitRowItem {
item_id: number
product_category: string
product_name: string
spec_model: string | null
quantity: string
sale_amount: string
cost_amount: string | null
profit_amount: string | null
profit_rate: string | null
threshold: string
converted_profit: string | null
missing_reason: string | null
}
/** 按产品类别的毛利润汇总(成本缺失行不计入聚合) */
export interface GrossProfitCategory {
product_category: string
sale_amount: string
cost_amount: string
profit_amount: string
/** 分数字符串原样(如 "997/1000"),不做数值化展示 */
profit_rate: string
threshold: string
converted_profit: string
}
/** 毛利润数据视图(对应后端 `GrossProfitOut`) */
export interface GrossProfitView {
business_id: number
/** 参数取值时点(默认今日),ISO 串 */
as_of: string
rows: GrossProfitRowItem[]
categories: GrossProfitCategory[]
missing_count: number
note: string
}
/** 业务状态下拉项(与后端值域一致;执行中=1 已完成=2 已终止=3) */
export const BUSINESS_STATUS_OPTIONS = [
{ label: '执行中', value: 1 },
{ label: '已完成', value: 2 },
{ label: '已终止', value: 3 },
] as const
/** 状态编码 → 展示名 */
export function businessStatusLabel(status: number): string {
return BUSINESS_STATUS_OPTIONS.find((o) => o.value === status)?.label ?? `状态${status}`
}
/** 状态编码 → 标签类型(三色:执行中 primary / 已完成 success / 已终止 danger) */
export function businessStatusTagType(status: number): 'primary' | 'success' | 'danger' | 'info' {
if (status === 2) return 'success'
if (status === 3) return 'danger'
return 'primary'
}
/** 分页查询业务(编号/公司名称模糊,可按状态/客户/归属/日期范围筛选) */
export function listBusinesses(params: BusinessQueryParams): Promise<PageData<BusinessItem>> {
return request({ url: '/v1/business/businesses', method: 'get', params })
}
/** 创建业务(编号自动生成,写首条状态留痕) */
export function createBusiness(data: BusinessCreateParams): Promise<BusinessItem> {
return request({ url: '/v1/business/businesses', method: 'post', data })
}
/** 业务详情(附产品明细行) */
export function getBusinessDetail(businessId: number): Promise<BusinessDetail> {
return request({ url: `/v1/business/businesses/${businessId}`, method: 'get' })
}
/** 编辑业务(明细全量替换;归属不在此端点调整) */
export function updateBusiness(
businessId: number,
data: BusinessUpdateParams,
): Promise<BusinessItem> {
return request({ url: `/v1/business/businesses/${businessId}`, method: 'put', data })
}
/** 删除业务(有发货或回款记录时后端拒绝,应改为已终止) */
export function deleteBusiness(businessId: number): Promise<null> {
return request({ url: `/v1/business/businesses/${businessId}`, method: 'delete' })
}
/** 状态流转(终态不可再变更;终止必填原因) */
export function changeBusinessStatus(
businessId: number,
data: BusinessStatusParams,
): Promise<BusinessItem> {
return request({ url: `/v1/business/businesses/${businessId}/status`, method: 'put', data })
}
/** 调整负责/协助营销人员(同事务写归属变更留痕) */
export function changeBusinessOwnership(
businessId: number,
data: BusinessOwnershipParams,
): Promise<BusinessItem> {
return request({ url: `/v1/business/businesses/${businessId}/ownership`, method: 'put', data })
}
/** 某业务的归属变更留痕(分页,按变更时间倒序) */
export function listOwnershipLogs(
businessId: number,
params: { pageNum?: number; pageSize?: number },
): Promise<PageData<OwnershipLogItem>> {
return request({
url: `/v1/business/businesses/${businessId}/ownership-logs`,
method: 'get',
params,
})
}
/** 某业务的状态流转时间线(分页,按时间正序,从创建起展示) */
export function listStatusLogs(
businessId: number,
params: { pageNum?: number; pageSize?: number },
): Promise<PageData<StatusLogItem>> {
return request({
url: `/v1/business/businesses/${businessId}/status-logs`,
method: 'get',
params,
})
}
/** 毛利润数据视图(as_of 缺省=今日;阈值按该时点参数快照) */
export function getGrossProfit(
businessId: number,
asOf?: string,
): Promise<GrossProfitView> {
return request({
url: `/v1/business/businesses/${businessId}/gross-profit`,
method: 'get',
params: asOf ? { as_of: asOf } : undefined,
})
}
+127
View File
@@ -0,0 +1,127 @@
import { request } from './request'
import type { PageData } from './types'
/**
* 客户列表项(对应后端 `CustomerOut`)。
*
* owner_personnel_name 由联表带出:归属人员不可考(被物理删除)时为 null,
* owner_personnel_id 仍保留原值供追溯。
*/
export interface CustomerItem {
id: number
company_name: string
/** 客户公司的负责人姓名(非系统内营销人员) */
owner_name: string
contact_person: string | null
contact_phone: string | null
address: string | null
industry: string | null
/** personal 个人户 / manager_personal 经理个人户 / enterprise 企业户(计提规则锚点) */
customer_type: string
owner_personnel_id: number | null
owner_personnel_name: string | null
remark: string | null
}
export interface CustomerQueryParams {
pageNum?: number
pageSize?: number
/** 公司名称或客户负责人,模糊匹配 */
keyword?: string
customer_type?: string
owner_personnel_id?: number
}
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */
export interface CustomerCreateParams {
company_name: string
owner_name: string
contact_person?: string | null
contact_phone?: string | null
address?: string | null
industry?: string | null
customer_type: string
owner_personnel_id?: number | null
remark?: string | null
}
/**
* 编辑客户:不含归属字段 —— 归属调整统一走 `PUT /{id}/ownership`
* (唯一写归属留痕的入口),编辑表单即使提交归属也会被后端忽略。
*/
export type CustomerUpdateParams = Omit<CustomerCreateParams, 'owner_personnel_id'>
/** 归属变更入参(new_owner_personnel_id 空 = 清空归属) */
export interface CustomerOwnershipParams {
new_owner_personnel_id?: number | null
reason?: string | null
}
/** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */
export interface OwnershipLogItem {
id: number
customer_id: number
old_owner_personnel_id: number | null
new_owner_personnel_id: number | null
old_owner_name: string | null
new_owner_name: string | null
change_reason: string | null
operator_id: number | null
operator_name: string
create_time: string
}
/** 客户分类下拉项(与后端 `core/constants.py` 的值域一致) */
export const CUSTOMER_TYPE_OPTIONS = [
{ label: '个人户', value: 'personal' },
{ label: '经理个人户', value: 'manager_personal' },
{ label: '企业户', value: 'enterprise' },
] as const
/** 分类编码 → 展示名(表格列展示用,未知编码原样回显) */
export function customerTypeLabel(code: string): string {
return CUSTOMER_TYPE_OPTIONS.find((o) => o.value === code)?.label ?? code
}
/** 分页查询客户(keyword 对公司名称/客户负责人模糊匹配,附归属人员姓名) */
export function listCustomers(params: CustomerQueryParams): Promise<PageData<CustomerItem>> {
return request({ url: '/v1/base/customers', method: 'get', params })
}
/** 新增客户(公司名称+负责人组合唯一,冲突由后端拦截) */
export function createCustomer(data: CustomerCreateParams): Promise<CustomerItem> {
return request({ url: '/v1/base/customers', method: 'post', data })
}
/** 编辑客户档案(不含归属;全量提交) */
export function updateCustomer(
customerId: number,
data: CustomerUpdateParams,
): Promise<CustomerItem> {
return request({ url: `/v1/base/customers/${customerId}`, method: 'put', data })
}
/** 删除客户(物理删除) */
export function deleteCustomer(customerId: number): Promise<null> {
return request({ url: `/v1/base/customers/${customerId}`, method: 'delete' })
}
/** 归属变更(唯一写归属留痕的入口;新旧相同后端报「归属未变化」) */
export function changeOwnership(
customerId: number,
data: CustomerOwnershipParams,
): Promise<CustomerItem> {
return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data })
}
/** 查某客户的归属变更留痕(分页,按变更时间倒序) */
export function listOwnershipLogs(
customerId: number,
params: { pageNum?: number; pageSize?: number },
): Promise<PageData<OwnershipLogItem>> {
return request({
url: `/v1/base/customers/${customerId}/ownership-logs`,
method: 'get',
params,
})
}
+150
View File
@@ -0,0 +1,150 @@
import { request } from './request'
import type { PageData } from './types'
/** 辐射区域条目(对应后端 `PersonnelRegionOut`) */
export interface PersonnelRegionItem {
id: number
region_name: string
remark: string | null
}
/** 拓展计划条目(对应后端 `PersonnelPlanOut`) */
export interface PersonnelPlanItem {
id: number
plan_period: string
plan_content: string
target_desc: string | null
}
/** 人员列表项(对应后端 `PersonnelOut`) */
export interface PersonnelItem {
id: number
personnel_name: string
business_role_id: number
/** 岗位名称由联表带出 */
role_name: string | null
manager_id: number | null
manager_name: string | null
phone: string | null
/** 入职日期,ISO 串(YYYY-MM-DD) */
hire_date: string | null
/** 1试用期 0正式 */
is_probation: number
/** 月工资标准(元),字符串直出 —— 金额红线:不经 float */
salary_standard: string | null
/** 1在职 0离职 */
status: number
remark: string | null
}
/** 人员详情:附辐射区域与拓展计划,供编辑抽屉一次回显 */
export interface PersonnelDetail extends PersonnelItem {
regions: PersonnelRegionItem[]
plans: PersonnelPlanItem[]
}
export interface PersonnelQueryParams {
pageNum?: number
pageSize?: number
/** 姓名,模糊匹配 */
keyword?: string
/** 1在职 0离职 */
status?: number
/** 岗位角色ID */
business_role_id?: number
}
/**
* 新增/编辑人员(全量提交)。
*
* 不含状态 —— 在职/离职是独立操作(PUT /{id}/status),避免编辑表单
* 误触离职。可空字段传 `null` 表示清空,与后端 `PersonnelCreateIn` 对齐。
*/
export interface PersonnelSaveParams {
personnel_name: string
business_role_id: number
/** 直属经理ID,空=无;后端校验不能是自己且须在职 */
manager_id?: number | null
phone?: string | null
/** ISO 串(YYYY-MM-DD) */
hire_date?: string | null
/** 1试用期 0正式 */
is_probation?: number
/** 月工资标准(元),字符串形式的数字,最多两位小数 */
salary_standard?: string | null
remark?: string | null
}
/** 辐射区域条目入参(全量替换用) */
export interface RegionItemParams {
region_name: string
remark?: string | null
}
/** 拓展计划条目入参(全量替换用) */
export interface PlanItemParams {
plan_period: string
plan_content: string
target_desc?: string | null
}
/** 分页查询人员(含离职),姓名模糊匹配 */
export function listPersonnel(params: PersonnelQueryParams): Promise<PageData<PersonnelItem>> {
return request({ url: '/v1/base/personnel', method: 'get', params })
}
/** 新增人员(默认在职;岗位须存在且启用) */
export function createPersonnel(data: PersonnelSaveParams): Promise<PersonnelItem> {
return request({ url: '/v1/base/personnel', method: 'post', data })
}
/** 人员详情(附辐射区域与拓展计划) */
export function getPersonnelDetail(personnelId: number): Promise<PersonnelDetail> {
return request({ url: `/v1/base/personnel/${personnelId}`, method: 'get' })
}
/** 编辑人员(全量提交,不含状态) */
export function updatePersonnel(
personnelId: number,
data: PersonnelSaveParams,
): Promise<PersonnelItem> {
return request({ url: `/v1/base/personnel/${personnelId}`, method: 'put', data })
}
/** 删除人员(被团队成员或客户归属引用时后端拒绝,应改为离职) */
export function deletePersonnel(personnelId: number): Promise<null> {
return request({ url: `/v1/base/personnel/${personnelId}`, method: 'delete' })
}
/** 设为在职 / 离职(离职人员不可再被业务与客户归属引用) */
export function setPersonnelStatus(personnelId: number, status: number): Promise<PersonnelItem> {
return request({
url: `/v1/base/personnel/${personnelId}/status`,
method: 'put',
data: { status },
})
}
/** 全量替换辐射区域(空列表即清空该人员的全部区域) */
export function replacePersonnelRegions(
personnelId: number,
items: RegionItemParams[],
): Promise<null> {
return request({
url: `/v1/base/personnel/${personnelId}/regions`,
method: 'put',
data: { items },
})
}
/** 全量替换拓展计划(空列表即清空该人员的全部计划) */
export function replacePersonnelPlans(
personnelId: number,
items: PlanItemParams[],
): Promise<null> {
return request({
url: `/v1/base/personnel/${personnelId}/plans`,
method: 'put',
data: { items },
})
}
+91
View File
@@ -0,0 +1,91 @@
import { request } from './request'
import type { PageData } from './types'
/**
* 产品列表项(对应后端 `ProductOut`)。
*
* 数量/单价为字符串直出 —— 金额红线:前端禁用 float 解析与 toFixed,
* 展示原串、提交原串,Decimal 转换收敛在后端 service 层。
*/
export interface ProductItem {
id: number
product_name: string
/** 规格型号,可空串(与名称构成组合唯一键) */
spec_model: string
unit: string | null
stock_quantity: string | null
sale_price: string | null
/** municipal_water 市政水利 / rail_transit 轨道交通 —— 折算阈值的行级锚点 */
product_category: string
/** 1启用 0停用 */
status: number
remark: string | null
create_time: string
update_time: string
}
export interface ProductQueryParams {
pageNum?: number
pageSize?: number
/** 名称或规格,模糊匹配 */
keyword?: string
product_category?: string
status?: number
}
/** 新增/编辑产品(全量提交;不含状态,启停走独立端点) */
export interface ProductSaveParams {
product_name: string
/** 空串与 None 同义(后端归一为空串以保组合唯一键生效) */
spec_model?: string | null
unit?: string | null
/** 非负数字字符串 */
stock_quantity?: string | null
/** 非负数字字符串 */
sale_price?: string | null
product_category: string
remark?: string | null
}
/** 产品类别下拉项(与后端 `core/constants.py` 的值域一致) */
export const PRODUCT_CATEGORY_OPTIONS = [
{ label: '市政水利', value: 'municipal_water' },
{ label: '轨道交通', value: 'rail_transit' },
] as const
/** 类别编码 → 展示名(表格列展示用,未知编码原样回显) */
export function productCategoryLabel(code: string): string {
return PRODUCT_CATEGORY_OPTIONS.find((o) => o.value === code)?.label ?? code
}
/** 分页查询产品(含停用) */
export function listProducts(params: ProductQueryParams): Promise<PageData<ProductItem>> {
return request({ url: '/v1/base/products', method: 'get', params })
}
/** 新增产品(默认启用;类别须在合法值域内) */
export function createProduct(data: ProductSaveParams): Promise<ProductItem> {
return request({ url: '/v1/base/products', method: 'post', data })
}
/** 编辑产品(全量提交,不含状态;名称+规格组合唯一,冲突由后端拦截) */
export function updateProduct(
productId: number,
data: ProductSaveParams,
): Promise<ProductItem> {
return request({ url: `/v1/base/products/${productId}`, method: 'put', data })
}
/** 删除产品(物理删除,仅限误录数据;常规下架用停用) */
export function deleteProduct(productId: number): Promise<null> {
return request({ url: `/v1/base/products/${productId}`, method: 'delete' })
}
/** 启用 / 停用产品 */
export function setProductStatus(productId: number, status: number): Promise<ProductItem> {
return request({
url: `/v1/base/products/${productId}/status`,
method: 'put',
data: { status },
})
}
+60
View File
@@ -0,0 +1,60 @@
import { request } from './request'
/**
* 评级等级配置项(对应后端 `RatingGradeOut`)。
*
* min_score/max_score 按分值红线以**字符串**出参(后端 str 直出),
* 前端参与数值比较前必须显式 parseFloat 转数值,禁止直接做字符串比大小。
*/
export interface RatingGradeItem {
id: number
grade_name: string
min_score: string
max_score: string
/** 判定顺序,升序;判定取首个 min_score ≤ 总分的等级 */
sort_order: number
remark: string | null
create_time: string
update_time: string
}
/**
* 新增 / 编辑等级入参(编辑为全量替换语义)。
*
* 分值以字符串承载(如 "90.0"):number 无法精确表示全部十进制小数,
* 字符串是分值红线的入口闸门,Decimal 转换统一收敛在后端 service 层。
*/
export interface RatingGradeParams {
grade_name: string
min_score: string
max_score: string
sort_order?: number
remark?: string
}
/**
* 全量等级列表(按 sort_order 升序返回,不分页)。
*
* 等级映射是评级计算器的判定表,数量个位数,分页无意义。
*/
export function listRatingGrades(): Promise<RatingGradeItem[]> {
return request({ url: '/v1/business/rating-grades', method: 'get' })
}
/** 新增评级等级(区间合法与不重叠由后端校验兜底,前端校验仅是展示约束) */
export function createRatingGrade(data: RatingGradeParams): Promise<RatingGradeItem> {
return request({ url: '/v1/business/rating-grades', method: 'post', data })
}
/** 编辑评级等级(全量替换,字段与新增一致) */
export function updateRatingGrade(
gradeId: number,
data: RatingGradeParams,
): Promise<RatingGradeItem> {
return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'put', data })
}
/** 删除评级等级(物理删除,等级表无下游引用,允许清空后由列表空态提示) */
export function deleteRatingGrade(gradeId: number): Promise<null> {
return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'delete' })
}
+104
View File
@@ -0,0 +1,104 @@
import { request } from './request'
import type { PageData } from './types'
/**
* 回款列表项(对应后端 `ReceiptOut`;字段契约纯蛇形直出)。
*
* amount 字符串直出(金额红线);payment_days 为派生值:未指定发货
* 批次时为 null,指定后 = 发货日到回款日的天数(发货日计第 1 天)。
*/
export interface ReceiptItem {
id: number
receipt_batch_no: string
business_id: number
business_no: string
shipment_id: number | null
shipment_no: string | null
/** 回款到账日,ISO 串(YYYY-MM-DD);M3 核算按此日匹配参数版本 */
receipt_date: string
/** 回款金额(元),字符串直出 */
amount: string
/** 以物抵顶标记:1是 0否(绩效按比例折算) */
is_barter: number
/** 核算状态:0未核算 1已核算(已核算禁改禁删) */
settlement_status: number
payment_days: number | null
remark: string | null
}
export interface ReceiptQueryParams {
pageNum?: number
pageSize?: number
business_id?: number
/** 回款到账日起(含),ISO 串 */
date_from?: string
/** 回款到账日止(含),ISO 串 */
date_to?: string
is_barter?: number
settlement_status?: number
}
/**
* 新增回款:业务必选,发货批次可暂不指定(手动指定)。
*
* shipment_id 须与本回款同属一个业务(后端强校验,数据完整性红线)。
*/
export interface ReceiptCreateParams {
business_id: number
shipment_id?: number | null
receipt_date: string
/** 数字字符串,非负,最多两位小数 */
amount: string
is_barter?: number
remark?: string | null
}
/**
* 编辑回款:全量替换语义;不含 business_id(挂靠业务创建时定死)。
*
* shipment_id 传 null = 清除指定(回到「暂未指定」)。
*/
export interface ReceiptUpdateParams {
shipment_id?: number | null
receipt_date: string
amount: string
is_barter?: number
remark?: string | null
}
/** 以物抵顶筛选项 */
export const BARTER_OPTIONS = [
{ label: '以物抵顶', value: 1 },
{ label: '货币回款', value: 0 },
] as const
/** 核算状态筛选项(与列表标签文案一致) */
export const SETTLEMENT_OPTIONS = [
{ label: '已核算', value: 1 },
{ label: '未核算', value: 0 },
] as const
/** 分页查询回款(附业务号/发货单号与回款天数派生值) */
export function listReceipts(params: ReceiptQueryParams): Promise<PageData<ReceiptItem>> {
return request({ url: '/v1/business/receipts', method: 'get', params })
}
/** 新增回款(批次号自动生成 HK 前缀;业务须执行中) */
export function createReceipt(data: ReceiptCreateParams): Promise<ReceiptItem> {
return request({ url: '/v1/business/receipts', method: 'post', data })
}
/** 回款详情(含回款天数派生值) */
export function getReceiptDetail(receiptId: number): Promise<ReceiptItem> {
return request({ url: `/v1/business/receipts/${receiptId}`, method: 'get' })
}
/** 编辑回款(全量替换;已核算回款后端拒绝) */
export function updateReceipt(receiptId: number, data: ReceiptUpdateParams): Promise<ReceiptItem> {
return request({ url: `/v1/business/receipts/${receiptId}`, method: 'put', data })
}
/** 删除回款(已核算/业务非执行中时后端拒绝;业务回款合计自动回退) */
export function deleteReceipt(receiptId: number): Promise<null> {
return request({ url: `/v1/business/receipts/${receiptId}`, method: 'delete' })
}
+13 -12
View File
@@ -63,20 +63,21 @@ service.interceptors.response.use(
}
return response
},
(error: AxiosError<ApiResult>) => {
async (error: AxiosError<ApiResult>) => {
const status = error.response?.status
let message = error.response?.data?.message ?? '网络异常,请稍后重试'
// blob 请求失败时 data 是 Blob(如导出接口返回的 JSON 信封错误),需读出真实 message
if (error.response?.data instanceof Blob) {
const raw = error.response?.data
let message = (raw as ApiResult | undefined)?.message ?? '网络异常,请稍后重试'
// blob 请求失败时 data 是 Blob(如导出接口返回的 JSON 信封错误):
// 先解析出真实文案再统一提示 —— 先弹兜底再异步补弹会造成双提示,
// 401 时跳转提示也会与实际原因错位
if (raw instanceof Blob) {
message = '导出失败,请稍后重试'
void error.response.data.text().then((text) => {
try {
const body = JSON.parse(text) as { message?: string }
if (body.message) ElMessage.error(body.message)
} catch {
/* 非 JSON 响应,保持兜底提示 */
}
})
try {
const body = JSON.parse(await raw.text()) as { message?: string }
if (body.message) message = body.message
} catch {
/* 非 JSON 响应,保持兜底提示 */
}
}
if (status === 401) {
+117
View File
@@ -0,0 +1,117 @@
import { request } from './request'
import type { PageData } from './types'
/**
* 发货产品明细行(对应后端 `ShipmentItemOut`)。
*
* 数量/单价/行金额一律字符串直出 —— 金额红线:不经 float、不 toFixed;
* 行金额 = 数量 × 单价由后端计算固化,不信任前端传值。
*/
export interface ShipmentItemRow {
id: number
product_id: number | null
product_name: string
spec_model: string | null
unit: string | null
/** 数字字符串,>0 */
quantity: string
/** 发货单价,可空 = 待人工补价(补价前行金额记 0) */
sale_price: string | null
/** 行金额,后端按数量×单价计算固化 */
amount: string
}
/** 发货单列表项(对应后端 `ShipmentOut`;business_no 为联表附带列) */
export interface ShipmentItem {
id: number
shipment_no: string
business_id: number
business_no: string | null
/** ISO 串(YYYY-MM-DD);发货日是回款天数计算基准日(发货日计第 1 天) */
ship_date: string
/** 运费(元),仅记录展示,不计入毛利润口径 */
freight_amount: string
/** 发货总金额,后端按明细固化 */
total_amount: string
remark: string | null
}
/** 发货单详情:附产品明细行(编辑弹窗一次回显) */
export interface ShipmentDetail extends ShipmentItem {
items: ShipmentItemRow[]
}
export interface ShipmentQueryParams {
pageNum?: number
pageSize?: number
/** 业务筛选,空 = 全部 */
business_id?: number
/** 发货日期起(含),ISO 串 */
ship_date_from?: string
/** 发货日期止(含),ISO 串 */
ship_date_to?: string
}
/** 发货明细行入参(product_id 空 = 自由录入;单价可空=待人工补价) */
export interface ShipmentItemParams {
product_id?: number | null
product_name: string
spec_model?: string | null
unit?: string | null
quantity: string
sale_price?: string | null
}
/**
* 创建发货单:业务维度 + 明细行(≥1)。
*
* 业务须处于执行中(后端校验);ship_date 为回款天数计算基准日。
*/
export interface ShipmentCreateParams {
business_id: number
ship_date: string
freight_amount?: string | null
items: ShipmentItemParams[]
remark?: string | null
}
/**
* 编辑发货单:明细全量替换。
*
* 不含 business_id —— 发货单挂靠的业务创建时定死,改挂会让已指定
* 本发货单的回款语义断裂。
*/
export interface ShipmentUpdateParams {
ship_date: string
freight_amount?: string | null
items: ShipmentItemParams[]
remark?: string | null
}
/** 分页查询发货单(附业务编号,可按业务/发货日期范围筛选) */
export function listShipments(params: ShipmentQueryParams): Promise<PageData<ShipmentItem>> {
return request({ url: '/v1/business/shipments', method: 'get', params })
}
/** 创建发货单(明细 ≥1;业务须执行中) */
export function createShipment(data: ShipmentCreateParams): Promise<ShipmentItem> {
return request({ url: '/v1/business/shipments', method: 'post', data })
}
/** 发货单详情(附产品明细行) */
export function getShipmentDetail(shipmentId: number): Promise<ShipmentDetail> {
return request({ url: `/v1/business/shipments/${shipmentId}`, method: 'get' })
}
/** 编辑发货单(明细全量替换;终态业务的发货单后端拒绝) */
export function updateShipment(
shipmentId: number,
data: ShipmentUpdateParams,
): Promise<ShipmentItem> {
return request({ url: `/v1/business/shipments/${shipmentId}`, method: 'put', data })
}
/** 删除发货单(连带明细并回写业务汇总;被回款批次指定时后端拒绝) */
export function deleteShipment(shipmentId: number): Promise<null> {
return request({ url: `/v1/business/shipments/${shipmentId}`, method: 'delete' })
}
+3 -1
View File
@@ -39,8 +39,10 @@ export function useConfirmAction() {
try {
await options.run()
ElMessage.success(options.successText)
await options.after?.()
// 收尾(列表刷新)成功后才提示:先提示后收尾会让用户看到「已删除」
// 而列表仍留旧行(after 抛错被吞),成功/失败提示同屏矛盾
ElMessage.success(options.successText)
return true
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
@@ -0,0 +1,24 @@
import { ref, type Ref } from 'vue'
import { handledError } from '@/utils/error'
/**
* 下拉选项的加载状态:取数失败不阻断列表使用(下拉为空 + 拦截器提示)。
*
* 业务/客户/人员等参照下拉在多个页面重复同一「try 取数 → map 成选项 →
* 失败吞掉」骨架,映射函数由调用方给出(各页字段不同),骨架收口单点。
*/
export function useDropdownOptions<T>(fetch: () => Promise<T[]>): {
options: Ref<T[]>
load: () => Promise<void>
} {
const options = ref([]) as Ref<T[]>
async function load(): Promise<void> {
try {
options.value = await fetch()
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
return { options, load }
}
+16
View File
@@ -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),
)
}
+46
View File
@@ -70,6 +70,52 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/system/logs/index.vue'),
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 7 },
},
{
// 基础数据属业务操作:登录即可访问,不设 requiresAdmin(对照后端 api/v1/base)
path: '/base/personnel',
name: 'base-personnel',
component: () => import('@/views/base/personnel/index.vue'),
meta: { title: '人员管理', group: '基础数据', order: 8 },
},
{
path: '/base/products',
name: 'base-products',
component: () => import('@/views/base/products/index.vue'),
meta: { title: '产品管理', group: '基础数据', order: 9 },
},
{
path: '/base/customers',
name: 'base-customers',
component: () => import('@/views/base/customers/index.vue'),
meta: { title: '客户管理', group: '基础数据', order: 10 },
},
{
// 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin
path: '/business/businesses',
name: 'business-businesses',
component: () => import('@/views/business/businesses/index.vue'),
meta: { title: '业务管理', group: '业务数据', order: 11 },
},
{
path: '/business/shipments',
name: 'business-shipments',
component: () => import('@/views/business/shipments/index.vue'),
meta: { title: '发货管理', group: '业务数据', order: 12 },
},
{
path: '/business/receipts',
name: 'business-receipts',
component: () => import('@/views/business/receipts/index.vue'),
meta: { title: '回款管理', group: '业务数据', order: 13 },
},
{
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
// 登录用户即可用,不设 requiresAdmin(区别于系统管理组)
path: '/business/rating',
name: 'business-rating',
component: () => import('@/views/business/rating/index.vue'),
meta: { title: '客户评级计算器', group: '业务数据', order: 14 },
},
],
},
// 未匹配路由回首页;若未登录会被守卫拦到登录页
+13 -1
View File
@@ -7,7 +7,19 @@ export const ROLE_ADMIN = 'admin'
* 操作日志的模块 / 操作词表 —— 与后端 `core/constants.py` 的
* `MODULE_*` / `ACTION_*` 保持一致;新增模块时两处同步(后端是权威来源)。
*/
export const MODULE_OPTIONS = ['用户管理', '角色管理', '岗位角色', '计提参数'] as const
export const MODULE_OPTIONS = [
'用户管理',
'角色管理',
'岗位角色',
'计提参数',
'人员管理',
'产品管理',
'客户管理',
'业务管理',
'发货管理',
'回款管理',
'客户评级',
] as const
export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const
/** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */
+15
View File
@@ -12,6 +12,21 @@ export const PASSWORD_MAX_BYTES = 72
const BYTE_HINT = '(中文一个字符约 3 字节)'
/**
* 金额形态正则(与后端 `schemas/business._check_amount` 同口径单点维护)。
*
* 「DECIMAL 列 ↔ 前端放行位数」规则此前在业务/发货/回款/人员四页各写一份
* (businesses 与 shipments 逐字重复,receipts/personnel 又各自内联两位小数
* 正则),后端精度调整时漏改任何一页,就会出现「A 页面拦得住、B 页面放行
* 后端 400」或「带精度历史行永远无法编辑」(fba8cfc 的教训)。
*/
/** 非负数字、最多两位小数(对齐 DECIMAL(14,2):金额/数量/成本/运费/工资) */
export const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
/** 非负数字、最多四位小数(对齐 DECIMAL(14,4):单价/指导价) */
export const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
/** 全零形态(数量「0 或 0.00」须拦:数量 >0 是明细行的硬约束) */
export const ZERO_PATTERN = /^0+(\.0+)?$/
/** 计算 UTF-8 字节数 */
export function passwordByteLength(value: string): number {
return new TextEncoder().encode(value).length
+562
View File
@@ -0,0 +1,562 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as customerApi from '@/api/customers'
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
import * as personnelApi from '@/api/personnel'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format'
import { handledError } from '@/utils/error'
/**
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
*
* 与后端约定对齐:
* - 编辑弹窗不含归属字段 —— 归属调整是独立操作(「归属变更」按钮),
* 走 PUT /{id}/ownership,是唯一写归属留痕的入口
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
* (对照表 #19 的业务口径)
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
*/
const { confirmAndRun } = useConfirmAction()
// ==================== 列表 ====================
const filters = reactive({
keyword: '',
customer_type: undefined as string | undefined,
owner_personnel_id: undefined as number | undefined,
})
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<CustomerItem, customerApi.CustomerQueryParams>(
customerApi.listCustomers,
() => ({
keyword: filters.keyword,
customer_type: filters.customer_type,
owner_personnel_id: filters.owner_personnel_id,
}),
)
function resetFilters(): void {
filters.keyword = ''
filters.customer_type = undefined
filters.owner_personnel_id = undefined
search()
}
// ==================== 归属人员下拉数据源 ====================
interface PersonnelOption {
id: number
name: string
status: number
}
const ownerOptions = ref<PersonnelOption[]>([])
async function loadOwnerOptions(): Promise<void> {
try {
// 归属下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
// 极端场景由后端对归属合法性的校验兜底
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
ownerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
/** 下拉项展示名:离职人员加后缀标注(仍可被选作归属吗?由后端校验兜底) */
function ownerLabel(option: PersonnelOption): string {
return option.status === 1 ? option.name : `${option.name}(离职)`
}
function personnelName(id: number): string {
return ownerOptions.value.find((o) => o.id === id)?.name ?? `人员#${id}`
}
// ==================== 新增 / 编辑 ====================
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
const isEdit = computed(() => editingId.value !== null)
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
company_name: '',
owner_name: '',
contact_person: '',
contact_phone: '',
address: '',
industry: '',
customer_type: '',
owner_personnel_id: undefined as number | undefined,
remark: '',
})
const rules = computed<FormRules>(() => ({
company_name: [
{ required: true, message: '请输入公司名称', trigger: 'blur' },
{ max: 128, message: '公司名称最长 128 个字符', trigger: 'blur' },
],
owner_name: [
{ required: true, message: '请输入客户负责人', trigger: 'blur' },
{ max: 64, message: '负责人最长 64 个字符', trigger: 'blur' },
],
contact_person: [{ max: 64, message: '联系人最长 64 个字符', trigger: 'blur' }],
contact_phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }],
address: [{ max: 255, message: '地址最长 255 个字符', trigger: 'blur' }],
industry: [{ max: 64, message: '行业最长 64 个字符', trigger: 'blur' }],
customer_type: [{ required: true, message: '请选择客户分类', trigger: 'change' }],
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
}))
function openCreate(): void {
editingId.value = null
Object.assign(form, {
company_name: '',
owner_name: '',
contact_person: '',
contact_phone: '',
address: '',
industry: '',
customer_type: '',
owner_personnel_id: undefined,
remark: '',
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
function openEdit(row: CustomerItem): void {
editingId.value = row.id
Object.assign(form, {
company_name: row.company_name,
owner_name: row.owner_name,
contact_person: row.contact_person ?? '',
contact_phone: row.contact_phone ?? '',
address: row.address ?? '',
industry: row.industry ?? '',
customer_type: row.customer_type,
owner_personnel_id: undefined, // 编辑弹窗不含归属,归属走「归属变更」按钮
remark: row.remark ?? '',
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (editingId.value !== null) {
// 编辑端点不含归属:归属调整统一走「归属变更」,保证留痕完整
const { owner_personnel_id: _ignored, ...rest } = form
await customerApi.updateCustomer(editingId.value, {
...rest,
company_name: rest.company_name.trim(),
owner_name: rest.owner_name.trim(),
contact_person: rest.contact_person.trim() || null,
contact_phone: rest.contact_phone.trim() || null,
address: rest.address.trim() || null,
industry: rest.industry.trim() || null,
remark: rest.remark.trim() || null,
})
ElMessage.success('已保存')
} else {
await customerApi.createCustomer({
company_name: form.company_name.trim(),
owner_name: form.owner_name.trim(),
contact_person: form.contact_person.trim() || null,
contact_phone: form.contact_phone.trim() || null,
address: form.address.trim() || null,
industry: form.industry.trim() || null,
customer_type: form.customer_type,
owner_personnel_id: form.owner_personnel_id ?? null,
remark: form.remark.trim() || null,
})
ElMessage.success('客户已创建')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
// ==================== 归属变更 ====================
const ownershipVisible = ref(false)
const ownershipTarget = ref<CustomerItem | null>(null)
const ownershipSubmitting = ref(false)
const ownershipForm = reactive({
new_owner_personnel_id: undefined as number | undefined,
reason: '',
})
const ownershipCurrentLabel = computed(() => {
const target = ownershipTarget.value
if (!target) return '—'
return target.owner_personnel_name ?? '(无归属)'
})
function openOwnership(row: CustomerItem): void {
ownershipTarget.value = row
ownershipForm.new_owner_personnel_id = row.owner_personnel_id ?? undefined
ownershipForm.reason = ''
ownershipVisible.value = true
}
async function handleOwnershipSubmit(): Promise<void> {
const target = ownershipTarget.value
if (!target) return
const nextId = ownershipForm.new_owner_personnel_id ?? null
const currentId = target.owner_personnel_id ?? null
if (nextId === currentId) {
ElMessage.warning('归属未变化,请选择新的归属人员,或清空下拉后提交以清空归属')
return
}
const nextName = nextId === null ? '(清空归属)' : personnelName(nextId)
await confirmAndRun({
// 业务口径(对照表 #19):仅影响变更后核算,不回溯历史 —— 文案必须保留
confirmText: `确定调整客户「${target.company_name}」的归属为「${nextName}」?归属变更仅影响变更后核算,不回溯历史。`,
title: '归属变更确认',
confirmButtonText: '确认变更',
successText: '归属已调整',
run: async () => {
ownershipSubmitting.value = true
try {
await customerApi.changeOwnership(target.id, {
new_owner_personnel_id: nextId,
reason: ownershipForm.reason.trim() || null,
})
} finally {
ownershipSubmitting.value = false
}
},
after: async () => {
ownershipVisible.value = false
await load()
// 归属记录抽屉开着且正是该客户时同步刷新
if (logsVisible.value && logsCustomerId.value === target.id) await loadLogs()
},
})
}
// ==================== 归属变更记录 ====================
const logsVisible = ref(false)
const logsCustomerId = ref<number | null>(null)
const logsCustomerName = ref('')
const logsTitle = computed(() =>
logsCustomerId.value === null ? '归属变更记录' : `「${logsCustomerName.value}」· 归属变更记录`,
)
const {
records: logRecords,
total: logTotal,
pageNum: logPageNum,
pageSize: logPageSize,
loading: logsLoading,
load: loadLogs,
onPageChange: onLogsPageChange,
onSizeChange: onLogsSizeChange,
} = usePagedList<OwnershipLogItem, object>(
(params) => {
const id = logsCustomerId.value
// 抽屉未指定客户时兜底空数据,避免发出无意义请求
return id === null
? Promise.resolve({ list: [], total: 0, pageNum: 1, pageSize: 10 })
: customerApi.listOwnershipLogs(id, params)
},
() => ({}),
)
function openLogs(row: CustomerItem): void {
logsCustomerId.value = row.id
logsCustomerName.value = row.company_name
logPageNum.value = 1
logsVisible.value = true
void loadLogs()
}
/** 留痕展示:新旧归属任一为 null 表示该时点无归属(ID 固化、姓名随联表) */
function ownerTransition(row: OwnershipLogItem): string {
const oldName = row.old_owner_name ?? '(无)'
const newName = row.new_owner_name ?? '(无)'
return `${oldName} → ${newName}`
}
// ==================== 删除 ====================
async function handleDelete(row: CustomerItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除客户「${row.company_name}」?删除为物理删除、不可恢复;归属变更留痕将一并失去展示主体。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '客户已删除',
run: () => customerApi.deleteCustomer(row.id),
after: reloadAfterDelete,
})
}
// ==================== 初始化 ====================
onMounted(() => {
// 两个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadOwnerOptions()
})
</script>
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="公司名称或客户负责人"
clearable
style="width: 190px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="分类">
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="归属人员">
<el-select
v-model="filters.owner_personnel_id"
placeholder="全部"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="item in ownerOptions"
:key="item.id"
:label="ownerLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增客户</el-button>
</div>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="company_name" label="公司名称" min-width="180" />
<el-table-column prop="owner_name" label="负责人" min-width="90" />
<el-table-column prop="contact_person" label="联系人" min-width="90">
<template #default="{ row }">{{ row.contact_person ?? '—' }}</template>
</el-table-column>
<el-table-column prop="contact_phone" label="电话" min-width="120">
<template #default="{ row }">{{ row.contact_phone ?? '—' }}</template>
</el-table-column>
<el-table-column label="分类" width="110" align="center">
<template #default="{ row }">
<el-tag
:type="
row.customer_type === 'personal'
? 'info'
: row.customer_type === 'manager_personal'
? 'warning'
: 'primary'
"
size="small"
disable-transitions
>
{{ customerApi.customerTypeLabel(row.customer_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="归属人员" min-width="110">
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openOwnership(row)">归属变更</el-button>
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑(编辑不含归属,归属走独立按钮保证留痕) -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑客户' : '新增客户'"
width="560px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="公司名称" prop="company_name">
<el-input v-model="form.company_name" maxlength="128" placeholder="与负责人构成组合唯一键" />
</el-form-item>
<el-form-item label="负责人" prop="owner_name">
<el-input v-model="form.owner_name" maxlength="64" placeholder="客户公司的负责人姓名" />
</el-form-item>
<el-form-item label="联系人" prop="contact_person">
<el-input v-model="form.contact_person" maxlength="64" placeholder="选填" />
</el-form-item>
<el-form-item label="电话" prop="contact_phone">
<el-input v-model="form.contact_phone" maxlength="20" placeholder="选填" />
</el-form-item>
<el-form-item label="地址" prop="address">
<el-input v-model="form.address" maxlength="255" placeholder="选填" />
</el-form-item>
<el-form-item label="行业" prop="industry">
<el-input v-model="form.industry" maxlength="64" placeholder="选填" />
</el-form-item>
<el-form-item label="客户分类" prop="customer_type">
<el-select v-model="form.customer_type" placeholder="决定计提规则匹配" style="width: 100%">
<el-option
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="!isEdit" label="初始归属" prop="owner_personnel_id">
<el-select
v-model="form.owner_personnel_id"
placeholder="不选择=暂无归属(可先建档后派归属)"
clearable
filterable
style="width: 100%"
>
<el-option
v-for="item in ownerOptions"
:key="item.id"
:label="ownerLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
<!-- 归属变更(唯一写归属留痕的入口) -->
<el-dialog v-model="ownershipVisible" title="归属变更" width="480px" destroy-on-close>
<el-form label-width="110px">
<el-form-item label="客户">
<span>{{ ownershipTarget?.company_name }}</span>
</el-form-item>
<el-form-item label="当前归属">
<span>{{ ownershipCurrentLabel }}</span>
</el-form-item>
<el-form-item label="新归属人员">
<el-select
v-model="ownershipForm.new_owner_personnel_id"
placeholder="不选择并提交=清空归属"
clearable
filterable
style="width: 100%"
>
<el-option
v-for="item in ownerOptions"
:key="item.id"
:label="ownerLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="变更原因">
<el-input
v-model="ownershipForm.reason"
type="textarea"
:rows="3"
maxlength="255"
placeholder="选填,将写入归属变更留痕"
/>
</el-form-item>
</el-form>
<div class="ownership-warn">
归属变更仅影响变更后核算,不回溯历史。
</div>
<template #footer>
<el-button @click="ownershipVisible = false">取消</el-button>
<el-button type="primary" :loading="ownershipSubmitting" @click="handleOwnershipSubmit">
提交变更
</el-button>
</template>
</el-dialog>
<!-- 归属变更记录(分页留痕,按变更时间倒序) -->
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
<el-table v-loading="logsLoading" :data="logRecords" stripe size="small">
<el-table-column label="变更时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="原归属 → 新归属" min-width="220">
<template #default="{ row }">{{ ownerTransition(row) }}</template>
</el-table-column>
<el-table-column label="变更原因" min-width="180">
<template #default="{ row }">{{ row.change_reason ?? '—' }}</template>
</el-table-column>
<el-table-column prop="operator_name" label="操作人" width="100" />
</el-table>
<AppPagination
:total="logTotal"
:page-num="logPageNum"
:page-size="logPageSize"
@page-change="onLogsPageChange"
@size-change="onLogsSizeChange"
/>
</el-drawer>
</div>
</template>
<style scoped>
.ownership-warn {
padding: 8px 12px;
border-radius: 6px;
background: var(--ys-indigo-wash);
color: var(--ys-ink-2);
font-size: 12px;
line-height: 1.7;
}
</style>
+659
View File
@@ -0,0 +1,659 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as businessRoleApi from '@/api/businessRoles'
import * as personnelApi from '@/api/personnel'
import type { PersonnelItem } from '@/api/personnel'
import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { useEditingRow } from '@/composables/useEditingRow'
import { AMOUNT_PATTERN } from '@/utils/validate'
import { handledError } from '@/utils/error'
/**
* 人员管理(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。
*
* 与后端约定对齐:
* - 基本信息新增/编辑走弹窗(全量提交、不含状态);在职/离职切换、
* 删除均为独立操作,删除被引用时后端拒绝,此时应改为离职
* - 编辑抽屉承载子表:辐射区域与拓展计划行内增删,保存走
* PUT /{id}/regions、PUT /{id}/plans 全量替换(空列表即清空)
* - 工资标准为字符串直出(金额红线:不经 float、不 toFixed)
*/
const { confirmAndRun } = useConfirmAction()
// ==================== 列表 ====================
/** 在职/离职筛选项(与产品页「启用/禁用」语义不同,页面内定义) */
const EMPLOYMENT_OPTIONS = [
{ label: '在职', value: 1 },
{ label: '离职', value: 0 },
] as const
const filters = reactive({
keyword: '',
status: undefined as number | undefined,
business_role_id: undefined as number | undefined,
})
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<PersonnelItem, personnelApi.PersonnelQueryParams>(
personnelApi.listPersonnel,
() => ({
keyword: filters.keyword,
status: filters.status,
business_role_id: filters.business_role_id,
}),
)
function resetFilters(): void {
filters.keyword = ''
filters.status = undefined
filters.business_role_id = undefined
search()
}
// ==================== 岗位 / 经理下拉数据源 ====================
interface SelectOption {
id: number
name: string
status: number
}
const roleOptions = ref<SelectOption[]>([])
const managerOptions = ref<SelectOption[]>([])
async function loadRoleOptions(): Promise<void> {
try {
// 岗位规模有限(内置 3 个 + 自定义),一页足够
const data = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 100 })
roleOptions.value = data.list.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
async function loadManagerOptions(): Promise<void> {
try {
// 经理下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
// 超出规模的极端场景由后端对经理合法性的校验兜底
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
managerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
} catch (error) {
handledError(error)
}
}
// ==================== 新增 / 编辑(基本信息) ====================
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
const isEdit = computed(() => editingId.value !== null)
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
personnel_name: '',
business_role_id: undefined as number | undefined,
manager_id: undefined as number | undefined,
phone: '',
hire_date: '',
is_probation: 0,
salary_standard: '',
remark: '',
})
/** 编辑中的行(用于停用岗位/离职经理的回显兜底) */
const editingRow = useEditingRow(records, editingId)
/** 搜索下拉显示全部岗位(含停用,便于筛出仍绑定停用岗位的人员) */
const searchRoleOptions = computed(() =>
roleOptions.value.map((r) => ({ ...r, name: r.status === 1 ? r.name : `${r.name}(已停用)` })),
)
/** 表单下拉只列启用岗位;编辑时若原岗位已停用则保留一项供回显 */
const formRoleOptions = computed(() => {
const enabled = roleOptions.value.filter((r) => r.status === 1)
const row = editingRow.value
if (row && !enabled.some((r) => r.id === row.business_role_id)) {
enabled.unshift({ id: row.business_role_id, name: `${row.role_name ?? ''}(已停用)`, status: 0 })
}
return enabled
})
/** 经理下拉:排除编辑中的人员自己(后端校验「经理不能是自己」) */
const formManagerOptions = computed(() =>
managerOptions.value.filter((m) => m.id !== editingId.value),
)
const rules = computed<FormRules>(() => ({
personnel_name: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ max: 64, message: '姓名最长 64 个字符', trigger: 'blur' },
],
business_role_id: [{ required: true, message: '请选择岗位', trigger: 'change' }],
phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }],
salary_standard: [
{ pattern: AMOUNT_PATTERN, message: '工资标准须为非负数字,最多两位小数', trigger: 'blur' },
],
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
}))
function openCreate(): void {
editingId.value = null
Object.assign(form, {
personnel_name: '',
business_role_id: undefined,
manager_id: undefined,
phone: '',
hire_date: '',
is_probation: 0,
salary_standard: '',
remark: '',
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
function openEdit(row: PersonnelItem): void {
editingId.value = row.id
Object.assign(form, {
personnel_name: row.personnel_name,
business_role_id: row.business_role_id,
manager_id: row.manager_id ?? undefined,
phone: row.phone ?? '',
hire_date: row.hire_date ?? '',
is_probation: row.is_probation,
salary_standard: row.salary_standard ?? '',
remark: row.remark ?? '',
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
// 空串归一为 null(与后端「空串归一 NULL」口径一致);金额原串提交
const payload: personnelApi.PersonnelSaveParams = {
personnel_name: form.personnel_name.trim(),
business_role_id: form.business_role_id as number,
manager_id: form.manager_id ?? null,
phone: form.phone.trim() || null,
hire_date: form.hire_date || null,
is_probation: form.is_probation,
salary_standard: form.salary_standard.trim() || null,
remark: form.remark.trim() || null,
}
submitting.value = true
try {
if (editingId.value !== null) {
await personnelApi.updatePersonnel(editingId.value, payload)
ElMessage.success('已保存')
} else {
await personnelApi.createPersonnel(payload)
ElMessage.success('人员已创建')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
// ==================== 编辑抽屉:辐射区域 / 拓展计划 ====================
interface RegionDraft {
region_name: string
remark: string
}
interface PlanDraft {
plan_period: string
plan_content: string
target_desc: string
}
const drawerVisible = ref(false)
const drawerRow = ref<PersonnelItem | null>(null)
const drawerTitle = computed(() =>
drawerRow.value ? `「${drawerRow.value.personnel_name}」· 区域与拓展计划` : '区域与拓展计划',
)
const detailLoading = ref(false)
const regionSaving = ref(false)
const planSaving = ref(false)
/** 本地草稿行(全量替换:保存即以后端返回为准,无需保留行 id) */
const regionRows = ref<RegionDraft[]>([])
const planRows = ref<PlanDraft[]>([])
async function openSubTables(row: PersonnelItem): Promise<void> {
drawerRow.value = row
drawerVisible.value = true
detailLoading.value = true
regionRows.value = []
planRows.value = []
try {
const detail = await personnelApi.getPersonnelDetail(row.id)
regionRows.value = detail.regions.map((r) => ({
region_name: r.region_name,
remark: r.remark ?? '',
}))
planRows.value = detail.plans.map((p) => ({
plan_period: p.plan_period,
plan_content: p.plan_content,
target_desc: p.target_desc ?? '',
}))
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成,抽屉显示空表
} finally {
detailLoading.value = false
}
}
function addRegionRow(): void {
regionRows.value.push({ region_name: '', remark: '' })
}
function removeRegionRow(index: number): void {
regionRows.value.splice(index, 1)
}
function addPlanRow(): void {
planRows.value.push({ plan_period: '', plan_content: '', target_desc: '' })
}
function removePlanRow(index: number): void {
planRows.value.splice(index, 1)
}
/** 保存辐射区域:全量替换,未填名称的行不能混入 */
async function saveRegions(): Promise<void> {
const row = drawerRow.value
if (!row) return
const blank = regionRows.value.findIndex((r) => !r.region_name.trim())
if (blank >= 0) {
ElMessage.warning(`第 ${blank + 1} 行的区域名称未填写`)
return
}
regionSaving.value = true
try {
await personnelApi.replacePersonnelRegions(
row.id,
regionRows.value.map((r) => ({
region_name: r.region_name.trim(),
remark: r.remark.trim() || null,
})),
)
ElMessage.success('辐射区域已保存')
} catch (error) {
handledError(error)
} finally {
regionSaving.value = false
}
}
/** 保存拓展计划:期间与内容必填,指标说明可空 */
async function savePlans(): Promise<void> {
const row = drawerRow.value
if (!row) return
const blank = planRows.value.findIndex((r) => !r.plan_period.trim() || !r.plan_content.trim())
if (blank >= 0) {
ElMessage.warning(`第 ${blank + 1} 行的计划期间或内容未填写`)
return
}
planSaving.value = true
try {
await personnelApi.replacePersonnelPlans(
row.id,
planRows.value.map((r) => ({
plan_period: r.plan_period.trim(),
plan_content: r.plan_content.trim(),
target_desc: r.target_desc.trim() || null,
})),
)
ElMessage.success('拓展计划已保存')
} catch (error) {
handledError(error)
} finally {
planSaving.value = false
}
}
// ==================== 在职切换 / 删除 ====================
async function handleToggleStatus(row: PersonnelItem): Promise<void> {
const next = row.status === 1 ? 0 : 1
const action = next === 1 ? '设为在职' : '设为离职'
await confirmAndRun({
confirmText:
next === 0
? `确定将「${row.personnel_name}」设为离职?离职人员不可再被业务与客户归属引用。`
: `确定将「${row.personnel_name}」设为在职?`,
title: '状态确认',
confirmButtonText: action,
successText: `已${action}`,
run: () => personnelApi.setPersonnelStatus(row.id, next),
after: load,
})
}
async function handleDelete(row: PersonnelItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除人员「${row.personnel_name}」?删除为物理删除、不可恢复;若该人员已被团队成员或客户归属引用将无法删除,此时应改为离职。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '人员已删除',
run: () => personnelApi.deletePersonnel(row.id),
after: reloadAfterDelete,
})
}
// ==================== 初始化 ====================
onMounted(() => {
// 三个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadRoleOptions()
void loadManagerOptions()
})
</script>
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="姓名">
<el-input
v-model="filters.keyword"
placeholder="姓名,模糊匹配"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in EMPLOYMENT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="岗位">
<el-select
v-model="filters.business_role_id"
placeholder="全部"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="item in searchRoleOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增人员</el-button>
</div>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="personnel_name" label="姓名" min-width="100" />
<el-table-column prop="role_name" label="岗位" min-width="100">
<template #default="{ row }">{{ row.role_name ?? '—' }}</template>
</el-table-column>
<el-table-column prop="manager_name" label="直属经理" min-width="100">
<template #default="{ row }">{{ row.manager_name ?? '—' }}</template>
</el-table-column>
<el-table-column prop="phone" label="联系电话" min-width="120">
<template #default="{ row }">{{ row.phone ?? '—' }}</template>
</el-table-column>
<el-table-column prop="hire_date" label="入职日期" min-width="110">
<template #default="{ row }">{{ row.hire_date ?? '—' }}</template>
</el-table-column>
<el-table-column label="试用期" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_probation === 1" type="warning" size="small" disable-transitions>
试用期
</el-tag>
<span v-else class="cell-muted">正式</span>
</template>
</el-table-column>
<el-table-column label="工资标准(元)" min-width="120" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.salary_standard ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openSubTables(row)">区域与计划</el-button>
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '设为离职' : '设为在职' }}
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑(基本信息;不含状态与子表) -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑人员' : '新增人员'"
width="560px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="姓名" prop="personnel_name">
<el-input v-model="form.personnel_name" placeholder="营销人员姓名" />
</el-form-item>
<el-form-item label="岗位" prop="business_role_id">
<el-select
v-model="form.business_role_id"
placeholder="选择岗位(计提公式随岗位绑定)"
filterable
style="width: 100%"
>
<el-option
v-for="item in formRoleOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="直属经理" prop="manager_id">
<el-select
v-model="form.manager_id"
placeholder="不选择=无直属经理"
clearable
filterable
style="width: 100%"
>
<el-option
v-for="item in formManagerOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" maxlength="20" placeholder="选填" />
</el-form-item>
<el-form-item label="入职日期" prop="hire_date">
<el-date-picker
v-model="form.hire_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="试用期" prop="is_probation">
<el-switch v-model="form.is_probation" :active-value="1" :inactive-value="0" />
<span class="switch-hint">试用期绩效照算但不发放,年累计超 18000 元部分年底一次性结算</span>
</el-form-item>
<el-form-item label="工资标准" prop="salary_standard">
<el-input v-model="form.salary_standard" placeholder="如 5000.50(月,元),选填" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
<!-- 编辑抽屉:辐射区域 / 拓展计划(行内增删,全量替换保存) -->
<el-drawer v-model="drawerVisible" :title="drawerTitle" size="720px" destroy-on-close>
<div v-loading="detailLoading">
<div class="sub-section">
<div class="sub-section-head">
<span class="sub-section-title">辐射区域</span>
<span>
<el-button size="small" @click="addRegionRow">添加区域</el-button>
<el-button
size="small"
type="primary"
:loading="regionSaving"
@click="saveRegions"
>
保存区域
</el-button>
</span>
</div>
<el-table :data="regionRows" stripe size="small">
<el-table-column label="区域名称" min-width="200">
<template #default="{ row }">
<el-input v-model="row.region_name" maxlength="128" placeholder="如 沈阳市铁西区" />
</template>
</el-table-column>
<el-table-column label="备注" min-width="220">
<template #default="{ row }">
<el-input v-model="row.remark" maxlength="255" placeholder="选填" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removeRegionRow($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="sub-section">
<div class="sub-section-head">
<span class="sub-section-title">拓展计划</span>
<span>
<el-button size="small" @click="addPlanRow">添加计划</el-button>
<el-button size="small" type="primary" :loading="planSaving" @click="savePlans">
保存计划
</el-button>
</span>
</div>
<el-table :data="planRows" stripe size="small">
<el-table-column label="期间" min-width="110">
<template #default="{ row }">
<el-input v-model="row.plan_period" maxlength="32" placeholder="如 2026Q1" />
</template>
</el-table-column>
<el-table-column label="计划内容" min-width="240">
<template #default="{ row }">
<el-input v-model="row.plan_content" maxlength="500" placeholder="计划内容" />
</template>
</el-table-column>
<el-table-column label="指标说明" min-width="160">
<template #default="{ row }">
<el-input v-model="row.target_desc" maxlength="255" placeholder="选填" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removePlanRow($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.cell-muted {
color: var(--ys-ink-3);
font-size: 12px;
}
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
.switch-hint {
margin-left: 10px;
color: var(--ys-ink-3);
font-size: 12px;
}
.sub-section {
margin-bottom: 20px;
}
.sub-section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.sub-section-title {
font-weight: 600;
color: var(--ys-ink);
}
</style>
+351
View File
@@ -0,0 +1,351 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as productApi from '@/api/products'
import type { ProductItem } from '@/api/products'
import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { STATUS_OPTIONS } from '@/utils/constants'
import { handledError } from '@/utils/error'
/**
* 产品管理(需求 2.1.2:产品主数据维护)。
*
* 与后端约定对齐:
* - product_category 是折算阈值的行级锚点(市政水利 30% / 轨道交通 20%,
* 阈值本身在「系统设置-计提参数」维护),类别下拉为受控枚举
* - 数量/单价以字符串进出(金额红线:禁 float、禁 toFixed),
* 前端只做格式提示,有效性与精度由后端校验兜底
* - 编辑全量提交、不含状态;启用/停用与删除为独立操作
*/
const { confirmAndRun } = useConfirmAction()
// ==================== 列表 ====================
const filters = reactive({
keyword: '',
product_category: undefined as string | undefined,
status: undefined as number | undefined,
})
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<ProductItem, productApi.ProductQueryParams>(
productApi.listProducts,
() => ({
keyword: filters.keyword,
product_category: filters.product_category,
status: filters.status,
}),
)
function resetFilters(): void {
filters.keyword = ''
filters.product_category = undefined
filters.status = undefined
search()
}
// ==================== 新增 / 编辑 ====================
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
const isEdit = computed(() => editingId.value !== null)
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
product_name: '',
spec_model: '',
unit: '',
stock_quantity: '',
sale_price: '',
product_category: '',
remark: '',
})
const rules = computed<FormRules>(() => ({
product_name: [
{ required: true, message: '请输入产品名称', trigger: 'blur' },
{ max: 128, message: '产品名称最长 128 个字符', trigger: 'blur' },
],
spec_model: [{ max: 128, message: '规格型号最长 128 个字符', trigger: 'blur' }],
unit: [{ max: 16, message: '计量单位最长 16 个字符', trigger: 'blur' }],
stock_quantity: [
{ pattern: /^(\d+)(\.\d+)?$/, message: '数量须为非负数字', trigger: 'blur' },
],
sale_price: [
{ pattern: /^(\d+)(\.\d+)?$/, message: '单价须为非负数字', trigger: 'blur' },
],
product_category: [{ required: true, message: '请选择产品类别', trigger: 'change' }],
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
}))
function openCreate(): void {
editingId.value = null
Object.assign(form, {
product_name: '',
spec_model: '',
unit: '',
stock_quantity: '',
sale_price: '',
product_category: '',
remark: '',
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
function openEdit(row: ProductItem): void {
editingId.value = row.id
Object.assign(form, {
product_name: row.product_name,
spec_model: row.spec_model ?? '',
unit: row.unit ?? '',
stock_quantity: row.stock_quantity ?? '',
sale_price: row.sale_price ?? '',
product_category: row.product_category,
remark: row.remark ?? '',
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
// 可空字段空串归一为 null;数量/单价保持原串提交(金额红线)
const payload: productApi.ProductSaveParams = {
product_name: form.product_name.trim(),
spec_model: form.spec_model.trim(),
unit: form.unit.trim() || null,
stock_quantity: form.stock_quantity.trim() || null,
sale_price: form.sale_price.trim() || null,
product_category: form.product_category,
remark: form.remark.trim() || null,
}
submitting.value = true
try {
if (editingId.value !== null) {
await productApi.updateProduct(editingId.value, payload)
ElMessage.success('已保存')
} else {
await productApi.createProduct(payload)
ElMessage.success('产品已创建')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
// ==================== 启停 / 删除 ====================
async function handleToggleStatus(row: ProductItem): Promise<void> {
const next = row.status === 1 ? 0 : 1
const action = next === 1 ? '启用' : '停用'
await confirmAndRun({
confirmText:
next === 0
? `确定停用产品「${row.product_name}」?停用后新业务不再选用该产品。`
: `确定启用产品「${row.product_name}」?`,
title: `${action}确认`,
confirmButtonText: action,
successText: `已${action}`,
run: () => productApi.setProductStatus(row.id, next),
after: load,
})
}
async function handleDelete(row: ProductItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除产品「${row.product_name}」?删除为物理删除、不可恢复;常规下架请改用停用。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '产品已删除',
run: () => productApi.deleteProduct(row.id),
after: reloadAfterDelete,
})
}
// ==================== 初始化 ====================
onMounted(() => {
void load()
})
</script>
<template>
<div>
<el-alert
class="page-alert"
type="info"
show-icon
:closable="false"
title="产品类别决定折算阈值:市政水利 30% / 轨道交通 20%;阈值在「系统设置-计提参数」维护。"
/>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="名称或规格"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="类别">
<el-select v-model="filters.product_category" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in productApi.PRODUCT_CATEGORY_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增产品</el-button>
</div>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="product_name" label="产品名称" min-width="150" />
<el-table-column prop="spec_model" label="规格型号" min-width="140">
<template #default="{ row }">{{ row.spec_model || '—' }}</template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="80" align="center">
<template #default="{ row }">{{ row.unit ?? '—' }}</template>
</el-table-column>
<el-table-column label="数量" min-width="100" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.stock_quantity ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="单价(元)" min-width="110" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.sale_price ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="类别" width="110" align="center">
<template #default="{ row }">
<el-tag
:type="row.product_category === 'municipal_water' ? 'primary' : 'success'"
size="small"
disable-transitions
>
{{ productApi.productCategoryLabel(row.product_category) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '停用' : '启用' }}
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑产品' : '新增产品'"
width="540px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="产品名称" prop="product_name">
<el-input v-model="form.product_name" maxlength="128" placeholder="与规格构成组合唯一键" />
</el-form-item>
<el-form-item label="规格型号" prop="spec_model">
<el-input v-model="form.spec_model" maxlength="128" placeholder="选填" />
</el-form-item>
<el-form-item label="计量单位" prop="unit">
<el-input v-model="form.unit" maxlength="16" placeholder="如 台 / 套 / 米" />
</el-form-item>
<el-form-item label="数量" prop="stock_quantity">
<el-input v-model="form.stock_quantity" placeholder="非负数字,选填" />
</el-form-item>
<el-form-item label="单价(元)" prop="sale_price">
<el-input v-model="form.sale_price" placeholder="非负数字,选填" />
</el-form-item>
<el-form-item label="产品类别" prop="product_category">
<el-select
v-model="form.product_category"
placeholder="决定折算阈值(市政 30% / 轨交 20%)"
style="width: 100%"
>
<el-option
v-for="item in productApi.PRODUCT_CATEGORY_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-alert {
margin-bottom: 12px;
}
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
</style>
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more