Compare commits

..

2 Commits

Author SHA1 Message Date
郑博文
ac4ba7514b merge: 集成客户评级弹窗、公司信息精简与参数中文化
将 feat/settings-rating-brand(基于 927592b)合并到最新 main(d03ddad):
- 冲突收口:系统设置服务/契约、集成测试夹具、布局/路由/登录页、客户列表
- 迁移重编号:本分支未发布的三个迁移压成 V11(客户评级 + 站点信息 + 版本号),
  down_revision 接到 main 的 90653814a421,db/migration 相应改为 V11
- 站点图标上传与 logo 上传统一为同步 def,超限先按 file.size 拦截
2026-10-20 11:42:49 +08:00
郑博文
27533eb465 wip: 客户评级弹窗化/公司信息精简/参数中文化/品牌站点同步(基于 927592b) 2026-10-20 11:06:01 +08:00
155 changed files with 6704 additions and 10548 deletions

8
.gitignore vendored
View File

@ -45,9 +45,8 @@ htmlcov/
.mypy_cache/ .mypy_cache/
.ruff_cache/ .ruff_cache/
.tox/ .tox/
# 端到端测试产出的截图(脚本以相对路径 e2e/failure.png 落盘,在不同 CWD # 端到端测试产出的截图
# 下会落到 frontend/e2e/ 或 frontend/e2e/e2e/,用 ** 一并覆盖) frontend/e2e/*.png
frontend/e2e/**/*.png
# 临时文件 # 临时文件
*.tmp *.tmp
@ -71,6 +70,3 @@ docs/**/*.rar
docs/T_12.3数据字典/ docs/T_12.3数据字典/
# macOS 压缩包残留元数据 # macOS 压缩包残留元数据
__MACOSX/ __MACOSX/
# AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库
.tmpdocx/

View File

@ -83,9 +83,8 @@ app/
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) | | GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) | | GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 | | PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选;`order_by=rating_score` 按评分排序,未评分垫底)/ 新增(分类值域;公司名+负责人唯一) | | GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 | | GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
| PUT | `/api/v1/base/customers/{id}/rating` | 保存评级(评级计算器「保存评分」;只写 `rating_score`/`rating_time`,五项明细不落库 #25) |
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) | | PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) | | GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
@ -222,22 +221,6 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。 - **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。 - **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
### 审批快照与方案版本化(2026-10-20 定稿,改动前先读)
- **审批单带版本号与内容快照**:`approval_orders.version_no` 按 `(order_type, ref_id)` 从 1 递增,重提先撤回旧在途单、但**旧单快照不覆盖**;`snapshot_json` 统一信封 `{schema_version, order_type, captured_at, data}`,`data` 由 `approval_service.{allocation,settlement,adjustment}_content` 组装(Decimal 转 str、日期转 ISO),开关关闭的自动通过路径同样写快照。审批期间业务再改,复核者看到的仍是送审版本。
- **两个 `version_no` 语义不同,别混用**:`business_secondary_allocations.version_no` = 方案内容第几版,**仅方案内容(参与人+比例)变化时 +1**;`approval_orders.version_no` = 同一对象第几次送审,**仅送审内容变化时 +1**。改备注不动金额 → 两者都不动;改金额 → 只有审批单版本号动;改方案比例 → 两者都动。
- **送审内容未变不重提**:编辑业务时若**存在在途审批单**且其快照 `data` 与本次送审内容逐键相等(`approval_service.pending_matches_snapshot`,只比 `data` 不比 `captured_at`),则保留原单不撤回不重建;否则撤旧提新。方案内容未变时**不新增方案版本行**(`business_service._plan_changed` 按「人员+比例」集合比较,与行序无关)。
- **快照的覆盖范围是「方案 + 钱 + 归属」**(业务编号/日期、客户、负责与协助人、销售额、成本额、方案参与人与比例、合计与余额),**不含产品明细行**。因此只改产品构成而金额不变时不会重提——分摊依据(谁分、分多少、基于多少钱)未变;产品明细由复核者按业务编号回业务页查看。
- **列表不加载快照列**:快照可达上百 KB,审批单列表、工作台待办、工作台审批超时预警三处查询一律 `defer(ApprovalOrder.snapshot_json)`(与操作日志列表排除 `request_data` 同一理由,参考 `operation_log_repository.list_logs`);详情走 `get_order` 按需读取。
- **详情内容来源三态**:`content_source=snapshot`(送审快照)/ `live`(V11 前的历史单体无快照,只读回退当前内容并显式告警)/ `missing`(引用对象不存在且无快照)。**禁止回填历史单快照**——回填等于伪造提交时内容。
- **二次分配方案 append-only**:`business_secondary_allocations` 行不物理删除,编辑全量替换与驳回清空只把旧行 `is_active=0`;唯一键为 `(business_id, version_no, personnel_id)`,同一版本内参与人唯一、跨版本可重复。`business_repository.list_allocations(active_only=True)` 是核算与详情回显的唯一取数口径;业务详情 `allocation_history` 返回全部版本供回溯。
- **月结提交要锁行**:`settlement_service.submit` 取月结行必须 `for_update=True`——并发提交会让两次 `submit_order` 算出同一个审批单 `version_no`,送审次序失真。
- **审批单 `version_no` 有唯一键兜底(V12)**:`uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`——同一对象的第 N 次送审在库里有唯一一行。ORM 侧约束名必须与迁移 DDL 严格一致,否则 autogenerate 误判。
- **取版本号必须是「当前读」(`approval_service._next_version`)**,两个禁忌:(a) 不能用普通 `MAX` 查询——请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻,父行锁只串行化写入时刻、**不刷新快照**,MAX 会读到旧值;(b) 不能写成 `select(func.max(...)).with_for_update()`——MySQL 官方明确「Locking clauses are not permitted in queries with aggregate functions or GROUP BY」,聚合上的 FOR UPDATE 语义无保证。正解:锁读该 ref 的现有版本行(`select(version_no)...with_for_update()`),最大值 Python 侧算(同 `no_generator` 套路)。
- **版本号冲突的收敛口径**:`submit_order` 内不 catch、不 rollback(红线)。create 路径由外层编号重试循环整体重来(`business_service._NO_RETRY`);编辑/月结提交路径由审计上下文统一回滚,再把 1062 翻译为 400「提交冲突(版本号生成重复),请重试」(同 `param_service` 版本号冲突模板)。唯一键是最后一道防线,不是取号正确性的前提。
- **迁移前先修存量**:V11 把存量行 `version_no` 一律置 1,而 V11 前每次重提都新建行——同一 `(order_type, ref_id)` 存在多行 version_no=1,直接加唯一键必报 1062。V12 先重排「组内有重复」的分组(按 id 升序 1..n),其余分组不动(全量重排会改掉前端已展示过的历史版本号)。
- **新增审批对象类型**须同时提供快照组装函数并接入 `_content_of` 的 live 回退分支,不要在各业务服务内联 JSON 结构。
### 工作台口径(M4 ⑧,改动前先读) ### 工作台口径(M4 ⑧,改动前先读)
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。 - **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
@ -264,17 +247,6 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。 - **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。 - **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
### 面向业务人员的文案(2026-10-23 定稿,改文案前先读)
系统由财务与营销人员日常使用,**不是给开发者看的**。凡是会渲染到页面的文本(参数说明、错误提示、口径注释)一律按业务语言写:
- **`param_registry.py` 的 `description` 是唯一显示源**,会直出到「计提参数」页参数名下方。写清「这个参数管什么、钱怎么变、什么时候该调它」,不写英文枚举名(`FULL`/`BALANCE`)、需求编号(`澄清 #10`)、内部阶段号(`M3`/`M4 ⑧`)、实现层精度约束(「禁止按 0.011111 截断存储」留在代码注释与测试里)。
- **枚举值在页面上必须翻译**:前端 `params/index.vue` 的 `ENUM_VALUE_LABELS`(`FULL` 当月全额 / `BALANCE` 余额 / `INCLUDE` 含基本工资 / `DEDUCT` 扣除基本工资 / `EXCLUDE` 不计基本工资 / `1` 开 / `0` 关)与 `DIMENSION_LABELS`(`municipal_water` 市政水利 / `rail_transit` 轨道交通)。后端报错也不许把原始枚举值甩给用户 —— `param_service._preview_rate` 的产品类别校验用 `「市政水利、轨道交通」` 而不是 `municipal_water / rail_transit`。
- **公式模板报错用中文模板名**:`calc_templates.TEMPLATES[code].name`,取不到才回退 code;文案要给出下一步(「请在岗位角色里改用业务员递减或信息员固定计提」),而不是只说「不支持」。
- **`db/init/init-dev.sql` 的种子 description 必须与 registry 同源**:它只在数据卷为空时首次启动执行,已有库要跑 `ai-aux/preview/sync_param_desc.py`(读 `PARAM_REGISTRY` 逐条 UPDATE)才看得到新文案。`db/migration/V*.sql` 里的旧描述是历史记录,**不要回头改** —— 改了会让已部署库与脚本不一致。
- **前端模板层禁止出现开发者语汇**:`澄清 / 待业务 / M3 / M4 / 一期 / 二阶段 / 暂口径 / 待确认 / 待接 / 暂不 / 待补` 只允许出现在代码注释里。改完用 grep 扫一遍 `frontend/src/views`。
- **台账与预览里落库的说明文字(`rate_text` / `rate_note` / 聚合行 `snapshot_json.note`)同样受管**:这些会出现在绩效台账、个人明细、打印稿与计算预览上。写成「这笔为什么是这个比例、什么时候不再降」(如「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」),不写 `基数开关=1`、`#12 参数化`、`9.8 方案公式` 这类需求编号。**分数原文(`11/1125`)与百分比拼装(`聚合:20%+5%`)不算说明文字**,那是金额口径的可复核原文,不要为了「口语化」把它改成四舍五入的小数。
## 常见问题 ## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。 **起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。

View File

@ -1,71 +0,0 @@
"""v12 审批单版本号唯一键
Revision ID: 7f4f7b918bc4
Revises: f3a8c2d61b47
Create Date: 2026-10-21 10:15:00.000000
给 ``approval_orders`` 加 ``(order_type, ref_id, version_no)`` 唯一键,兜住
「同一引用对象的第几次送审」在并发下算重。
**加键前必须先重排存量**:V11 迁移用 ``ADD COLUMN version_no INT NOT NULL
DEFAULT 1`` 把存量行一律置 1,而 V11 之前的每次重提都是**新建行**(撤回
只改旧单 status,行不删)。于是任何有 V11 前审批历史的库里,同一
``(order_type, ref_id)`` 存在多行且 ``version_no`` 全为 1——直接
``ADD CONSTRAINT`` 必报 1062,迁移会停在半途。
重排只针对「组内有重复」的分组(``COUNT(*) > COUNT(DISTINCT version_no)``),
按 id 升序重排为 1..n;其余分组一个字节都不动——全量重排会把前端已展示
过的历史版本号(``V{{ version_no }}``)改掉,属于用户可见的历史数据篡改。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "7f4f7b918bc4"
down_revision: str | Sequence[str] | None = "f3a8c2d61b47"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_UK = "uk_approval_orders_order_type_ref_version"
def upgrade() -> None:
"""先重排存量重复的版本号,再加唯一键。"""
# 重排:只动「组内有重复版本号」的分组,按 id 升序编 1..n。
# 用派生表内联而非临时表——同一张临时表被 JOIN 两侧引用会报 1137
# "Can't reopen table"。
op.execute(
"UPDATE approval_orders ao "
"JOIN (SELECT id, ROW_NUMBER() OVER ("
" PARTITION BY order_type, ref_id ORDER BY id) AS rn "
" FROM approval_orders "
" WHERE (order_type, ref_id) IN ("
" SELECT order_type, ref_id FROM approval_orders "
" GROUP BY order_type, ref_id "
" HAVING COUNT(*) > COUNT(DISTINCT version_no))) t "
"ON ao.id = t.id "
"SET ao.version_no = t.rn"
)
# 幂等 guard:MySQL DDL 隐式提交,若加键成功而版本号未落盘,
# 重跑会报 1091(键已存在)。写法沿用 V9 先例。
op.execute(
"SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
"WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'approval_orders' "
f"AND CONSTRAINT_NAME = '{_UK}')"
)
op.execute(
"SET @ddl := IF(@uk_exists = 0, "
f"'ALTER TABLE approval_orders ADD CONSTRAINT {_UK} "
"UNIQUE (order_type, ref_id, version_no)', 'DO 0')"
)
op.execute("PREPARE stmt FROM @ddl")
op.execute("EXECUTE stmt")
op.execute("DEALLOCATE PREPARE stmt")
def downgrade() -> None:
"""删唯一键。唯一键本质是索引,DROP INDEX 不校验数据,存量是否已有
重复 version_no 与删索引无关;重排结果也不回滚(版本号保持 1..n 连续)。"""
op.execute(f"ALTER TABLE approval_orders DROP INDEX {_UK}")

View File

@ -11,7 +11,6 @@ Create Date: 2026-10-19 17:55:48.892998
「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。 「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。
""" """
from collections.abc import Sequence from collections.abc import Sequence
from datetime import date from datetime import date
@ -20,8 +19,8 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "90653814a421" revision: str = '90653814a421'
down_revision: str | Sequence[str] | None = "ba0217f5091b" down_revision: str | Sequence[str] | None = 'ba0217f5091b'
branch_labels: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None

View File

@ -1,51 +0,0 @@
"""v13 客户评级落库
Revision ID: c3a9e1d7b2f4
Revises: 7f4f7b918bc4
Create Date: 2026-10-23 10:30:00.000000
评级计算器从独立工具页并入客户管理(行内弹窗),评分由「不落库」改为
保存为客户档案属性:``rating_score`` 总分 + ``rating_time`` 评级时间。
两列均可空(null = 未评分,历史客户与新客户无需回填),人工维护字段,
T+ 同步守则不覆盖(见 TPlusSyncMixin 的同步守则说明)。
五项评分明细与命中等级都不落库:前者是打分过程的中间量,没有下游消费方;
后者由分数按当前等级映射实时推导(等级映射是可变判定表,改了配置全部客户
的等级随之更新),因此本迁移只落总分与评级时间两列。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c3a9e1d7b2f4"
down_revision: str | Sequence[str] | None = "7f4f7b918bc4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""customers 加评级两列,紧跟 remark 之后保持与 ORM 声明同序。
用 execute 手写而非 op.add_column:后者不带 AFTER 子句,两列会落到
表尾(T+ 预留列之后),与 ORM/init-dev 的声明顺序不一致。
"""
op.execute(
"ALTER TABLE customers "
"ADD COLUMN rating_score TINYINT COMMENT "
"'评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' "
"AFTER remark"
)
op.execute(
"ALTER TABLE customers "
"ADD COLUMN rating_time DATETIME COMMENT "
"'最近评级时间(评级计算器保存评分时刷新;人工维护字段)' "
"AFTER rating_score"
)
def downgrade() -> None:
"""删除评级两列。评分数据随列删除,评级无下游引用、无悬空风险。"""
op.drop_column("customers", "rating_time")
op.drop_column("customers", "rating_score")

View File

@ -0,0 +1,212 @@
"""V11 客户评级落库与站点信息
Revision ID: c4e2a86f9b13
Revises: 90653814a421
Create Date: 2026-10-19 10:00:00.000000
与上游 V7–V10(台账/月结/调整单/口径/唯一键/预警参数)合并时重编号为 V11:
本分支三个迁移(原 V7 客户评级 + V8 公司档案精简 + V9 图片版本号)从未发布,
压成单条,最终结构 = 评级表 + 最终版站点/公司信息字段(不含中途增删的
副标题/信用代码/地址/电话/邮箱)。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "c4e2a86f9b13"
down_revision: str | Sequence[str] | None = "90653814a421"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移(双主题一次交付)。
- 客户评级弹窗化:评分每客户落库(customer_ratings,一行一客户快照)
- 公司信息补充与精简:标语/页脚/备案号/站点标题/站点图标 + 图片版本号
"""
op.create_table(
"customer_ratings",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"customer_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="客户ID(唯一:一客户一份当前评级)",
),
sa.Column(
"score_ship_amount",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="发货额评分(0-20)",
),
sa.Column(
"score_receipt_rate",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="回款率评分(0-20)",
),
sa.Column(
"score_profit_rate",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="毛利率评分(0-20)",
),
sa.Column(
"score_contract_cooperation",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="合同履约配合能力评分(0-20)",
),
sa.Column(
"score_site_construction",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="现场规范施工能力评分(0-20)",
),
sa.Column(
"total_score",
sa.Numeric(precision=5, scale=1),
nullable=False,
comment="总分(服务端重算,五项之和 0-100)",
),
sa.Column(
"grade_name",
sa.String(length=32),
nullable=True,
comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"scorer_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="评级操作人ID(账号删除后置空)",
),
sa.Column(
"scorer_name",
sa.String(length=64),
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(
["customer_id"],
["customers.id"],
name=op.f("fk_customer_ratings_customer_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["scorer_id"],
["users.id"],
name=op.f("fk_customer_ratings_scorer_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_ratings")),
sa.UniqueConstraint("customer_id", name=op.f("uk_customer_ratings_customer_id")),
comment="客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.add_column(
"system_settings",
sa.Column(
"site_title",
sa.String(length=64),
nullable=True,
comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)",
),
)
op.add_column(
"system_settings",
sa.Column(
"brand_slogan",
sa.String(length=64),
nullable=True,
comment="品牌标语(登录页与品牌预览;null=内置默认)",
),
)
op.add_column(
"system_settings",
sa.Column(
"footer_text",
sa.String(length=64),
nullable=True,
comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)",
),
)
op.add_column(
"system_settings",
sa.Column(
"icp_no",
sa.String(length=32),
nullable=True,
comment="ICP 备案号(登录页页脚,空=不显示)",
),
)
op.add_column(
"system_settings",
sa.Column(
"favicon_path",
sa.String(length=255),
nullable=True,
comment="站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)",
),
)
op.add_column(
"system_settings",
sa.Column(
"logo_version",
sa.Integer(),
server_default="0",
nullable=False,
comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)",
),
)
op.add_column(
"system_settings",
sa.Column(
"favicon_version",
sa.Integer(),
server_default="0",
nullable=False,
comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)",
),
)
def downgrade() -> None:
"""回滚迁移:先删评级表(引用 customers/users),再逆序移除站点信息列。"""
op.drop_table("customer_ratings")
op.drop_column("system_settings", "favicon_version")
op.drop_column("system_settings", "logo_version")
op.drop_column("system_settings", "favicon_path")
op.drop_column("system_settings", "icp_no")
op.drop_column("system_settings", "footer_text")
op.drop_column("system_settings", "brand_slogan")
op.drop_column("system_settings", "site_title")

View File

@ -34,7 +34,10 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。""" """种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭) # 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
op.execute("UPDATE param_definitions SET default_value = '1' WHERE id IN (17, 18)") op.execute(
"UPDATE param_definitions SET default_value = '1' "
"WHERE id IN (17, 18)"
)
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析) # 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
op.bulk_insert( op.bulk_insert(
sa.table( sa.table(
@ -185,4 +188,6 @@ def downgrade() -> None:
) )
op.execute("DELETE FROM param_values WHERE id IN (23, 24)") op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
op.execute("UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)") op.execute(
"UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)"
)

View File

@ -1,93 +0,0 @@
"""v11 审批快照与方案版本化
Revision ID: f3a8c2d61b47
Revises: 90653814a421
Create Date: 2026-10-20 10:30:00.000000
补齐 code-review 遗留 2 项:
- 审批单加 ``version_no`` 与 ``snapshot_json``:重提产生新版本,送审内容
在提交时固化,审批期间业务再改不影响复核依据;
- 二次分配方案行改 append-only 版本化:加 ``version_no`` / ``is_active``,
唯一键由 (business_id, personnel_id) 扩为
(business_id, version_no, personnel_id),编辑替换与驳回清空只置历史、
不再物理删除。
存量数据均落 ``version_no=1`` / ``is_active=1``:升级前的行就是当前生效
版本,语义不变。审批单存量快照为空,详情接口按「当前内容回退 + 显式
标注」处理,不做历史回填(回填会伪造提交时内容)。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f3a8c2d61b47"
down_revision: str | Sequence[str] | None = "90653814a421"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""加审批快照列/索引与方案版本列,替换唯一键。"""
op.execute(
"ALTER TABLE approval_orders "
"ADD COLUMN version_no INT NOT NULL DEFAULT 1 "
"COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' "
"AFTER refer_no"
)
op.execute(
"ALTER TABLE approval_orders "
"ADD COLUMN snapshot_json JSON NULL "
"COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' "
"AFTER version_no"
)
op.execute("CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)")
op.execute(
"ALTER TABLE business_secondary_allocations "
"ADD COLUMN version_no INT NOT NULL DEFAULT 1 "
"COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' "
"AFTER business_id"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"ADD COLUMN is_active TINYINT NOT NULL DEFAULT 1 "
"COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' "
"AFTER ratio"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"DROP INDEX uk_business_secondary_allocations_business_personnel, "
"ADD CONSTRAINT uk_business_secondary_allocations_business_version_personnel "
"UNIQUE (business_id, version_no, personnel_id)"
)
op.execute(
"CREATE INDEX idx_business_secondary_allocations_business_active "
"ON business_secondary_allocations (business_id, is_active)"
)
op.execute(
"ALTER TABLE business_secondary_allocations COMMENT = "
"'业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)'"
)
def downgrade() -> None:
"""回退结构(降级前须确认同业务同一参与人只存在于一个版本)。"""
op.execute(
"DROP INDEX idx_business_secondary_allocations_business_active "
"ON business_secondary_allocations"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"DROP INDEX uk_business_secondary_allocations_business_version_personnel, "
"ADD CONSTRAINT uk_business_secondary_allocations_business_personnel "
"UNIQUE (business_id, personnel_id)"
)
op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN is_active")
op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN version_no")
op.execute("DROP INDEX idx_approval_orders_type_ref ON approval_orders")
op.execute("ALTER TABLE approval_orders DROP COLUMN snapshot_json")
op.execute("ALTER TABLE approval_orders DROP COLUMN version_no")

View File

@ -12,8 +12,7 @@ from fastapi import APIRouter, Depends
from app.api.deps import DbSession, get_current_user from app.api.deps import DbSession, get_current_user
from app.core.response import ApiResponse, ok from app.core.response import ApiResponse, ok
from app.models import User from app.models import User
from app.schemas.business_role import BusinessRoleOptionOut from app.repositories import business_role_repository
from app.services import business_role_service
router = APIRouter(prefix="/business-roles", tags=["人员管理"]) router = APIRouter(prefix="/business-roles", tags=["人员管理"])
@ -21,14 +20,21 @@ router = APIRouter(prefix="/business-roles", tags=["人员管理"])
CurrentUser = Annotated[User, Depends(get_current_user)] CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get( @router.get("/options", response_model=ApiResponse[list[dict]], summary="岗位下拉选项")
"/options", def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[dict]]:
response_model=ApiResponse[list[BusinessRoleOptionOut]],
summary="岗位下拉选项",
)
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[BusinessRoleOptionOut]]:
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。 """岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
岗位规模有限(内置 + 自定义),一次取全量,不做分页。 岗位规模有限(内置 + 自定义),一次取足够上限,不做分页。
""" """
return ok(business_role_service.list_options(session)) roles = business_role_repository.list_roles(session, offset=0, limit=1000)
return ok(
[
{
"id": role.id,
"role_code": role.role_code,
"role_name": role.role_name,
"status": role.status,
}
for role in roles
]
)

View File

@ -21,11 +21,11 @@ from app.schemas.customer import (
CustomerOut, CustomerOut,
CustomerOwnershipIn, CustomerOwnershipIn,
CustomerQuery, CustomerQuery,
CustomerRatingIn,
CustomerUpdateIn, CustomerUpdateIn,
OwnershipChangeLogOut, OwnershipChangeLogOut,
) )
from app.services import customer_service from app.schemas.rating import CustomerRatingOut, CustomerRatingSaveIn
from app.services import customer_service, rating_service
router = APIRouter(prefix="/customers", tags=["客户管理"]) router = APIRouter(prefix="/customers", tags=["客户管理"])
@ -103,31 +103,6 @@ def delete_customer(
return ok(message="客户已删除") return ok(message="客户已删除")
@router.put(
"/{customer_id}/rating",
response_model=ApiResponse[CustomerOut],
summary="保存客户评级",
)
def rate_customer(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerRatingIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""保存评级总分(评级计算器「保存评分」)。
评分是客户档案属性:存总分与评级时间,五项明细不落库(对照表 #25);
编辑端点不含评分 —— 评分只走本端点,由计算器统一入口写入。
"""
return ok(
customer_service.rate_customer(
session, customer_id, payload, operator=current_user, meta=meta
),
message="评级已保存",
)
@router.put( @router.put(
"/{customer_id}/ownership", "/{customer_id}/ownership",
response_model=ApiResponse[CustomerOut], response_model=ApiResponse[CustomerOut],
@ -166,3 +141,45 @@ def list_ownership_logs(
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]: ) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
"""查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。""" """查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。"""
return ok(customer_service.list_ownership_logs(session, customer_id, query)) return ok(customer_service.list_ownership_logs(session, customer_id, query))
@router.get(
"/{customer_id}/rating",
response_model=ApiResponse[CustomerRatingOut],
summary="客户当前评级",
)
def get_customer_rating(
session: DbSession,
_: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[CustomerRatingOut]:
"""查某客户当前评级。
未评级返回 ``data: null``(这是正常态而非错误);客户不存在才 404。
"""
return ok(rating_service.get_customer_rating(session, customer_id))
@router.put(
"/{customer_id}/rating",
response_model=ApiResponse[CustomerRatingOut],
summary="保存客户评级",
)
def save_customer_rating(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerRatingSaveIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerRatingOut]:
"""保存客户评级(首次创建、再次保存整体覆盖)。
五项分值 0-20 由服务层在审计上下文内校验,总分与等级由服务端重算;
等级按保存时点的映射快照固化,等级表后续增删改不影响该客户结论。
"""
return ok(
rating_service.save_customer_rating(
session, customer_id, payload, operator=current_user, meta=meta
),
message="评级已保存",
)

View File

@ -1,4 +1,4 @@
"""工作台接口(M4 ⑧):三区块聚合 + 最近动态分页。 """工作台接口(M4 ⑧):四区块一次性聚合。
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」 准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。 待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。
@ -12,18 +12,22 @@ from app.api.deps import DbSession, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.response import ApiResponse, ok from app.core.response import ApiResponse, ok
from app.models import User from app.models import User
from app.schemas.dashboard import ActivityPageOut, ActivityQuery, DashboardOut from app.schemas.dashboard import DashboardOut
from app.services import dashboard_service from app.services import dashboard_service
router = APIRouter(prefix="/dashboard", tags=["工作台"]) router = APIRouter(prefix="/dashboard", tags=["工作台"])
_DashboardUser = Annotated[ _DashboardUser = Annotated[
User, User,
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台")), Depends(
require_roles(
ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台"
)
),
] ]
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台三区块") @router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块")
def get_dashboard( def get_dashboard(
session: DbSession, session: DbSession,
current_user: _DashboardUser, current_user: _DashboardUser,
@ -31,22 +35,10 @@ def get_dashboard(
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月") str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
] = None, ] = None,
) -> ApiResponse[DashboardOut]: ) -> ApiResponse[DashboardOut]:
"""一次返回经营总览/待办/风险预警(待办与预警实时派生)。 """一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。 待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
最近动态是分页列表,走 `GET /dashboard/activities`。
""" """
return ok(dashboard_service.get_dashboard(session, month, current_user=current_user)) return ok(
dashboard_service.get_dashboard(session, month, current_user=current_user)
)
@router.get("/activities", response_model=ApiResponse[ActivityPageOut], summary="最近动态(分页)")
def list_dashboard_activities(
session: DbSession,
current_user: _DashboardUser,
query: Annotated[ActivityQuery, Query()],
) -> ApiResponse[ActivityPageOut]:
"""分页查询最近动态,可按模块筛选。
与聚合端点拆开:翻页只重取这张列表,不必重算总览/待办/预警。
"""
return ok(dashboard_service.list_activities(session, query))

View File

@ -1,11 +1,11 @@
"""系统设置接口(公司信息)。 """系统设置接口(公司信息 + 站点信息)。
`GET /public` 与 `GET /logo/public` **无鉴权**:登录页渲染公司名/logo 时 `GET /public`、`GET /logo/public`、`GET /favicon/public` **无鉴权**:登录页
尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py), 渲染公司名/logo、浏览器加载标签页图标时尚无令牌。响应经
仅暴露两个非敏感展示字段。 `SettingPublicOut` 白名单收敛(见 schemas/setting.py),仅暴露展示必需字段。
`/logo/public` 返回文件流而非统一信封——与参数导出同一例外口径 `/logo/public` 与 `/favicon/public` 返回文件流而非统一信封——与参数导出
(二进制响应套 JSON 信封无意义)。 同一例外口径(二进制响应套 JSON 信封无意义)。
""" """
from fastapi import APIRouter, Response, UploadFile from fastapi import APIRouter, Response, UploadFile
@ -20,6 +20,7 @@ from app.services import system_setting_service as service
router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"]) router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"])
_LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"} _LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"}
_FAVICON_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg", "ico": "image/x-icon"}
@router.get("", response_model=ApiResponse[SettingOut], summary="公司信息") @router.get("", response_model=ApiResponse[SettingOut], summary="公司信息")
@ -28,14 +29,14 @@ def get_settings(session: DbSession, _: AdminUser) -> ApiResponse[SettingOut]:
return ok(service.to_out(service.get_setting(session))) return ok(service.to_out(service.get_setting(session)))
@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司名称") @router.put("", response_model=ApiResponse[SettingOut], summary="更新公司信息")
def update_settings( def update_settings(
payload: SettingUpdateIn, payload: SettingUpdateIn,
session: DbSession, session: DbSession,
current_user: AdminUser, current_user: AdminUser,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[SettingOut]: ) -> ApiResponse[SettingOut]:
"""更新公司名称(审计留痕)。""" """更新公司信息(部分更新;审计留痕)。"""
return ok( return ok(
service.update_company(session, payload, operator=current_user, meta=meta), service.update_company(session, payload, operator=current_user, meta=meta),
message="已保存", message="已保存",
@ -55,7 +56,7 @@ def upload_logo(
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。 它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
大小先用 `file.size` 拦截,避免超限文件整体读进内存。 大小先用 `file.size` 拦截,避免超限文件整体读进内存。
""" """
if file.size is not None and file.size > service.LOGO_MAX_BYTES: if file.size is not None and file.size > service.ASSET_MAX_BYTES:
raise BadRequestError("logo 文件不能超过 2MB") raise BadRequestError("logo 文件不能超过 2MB")
content = file.file.read() content = file.file.read()
ext = (file.filename or "").rsplit(".", 1)[-1] ext = (file.filename or "").rsplit(".", 1)[-1]
@ -65,6 +66,27 @@ def upload_logo(
) )
@router.post("/favicon", response_model=ApiResponse[SettingOut], summary="上传站点图标")
def upload_favicon(
file: UploadFile,
session: DbSession,
current_user: AdminUser,
meta: RequestMetaDep,
) -> ApiResponse[SettingOut]:
"""上传站点图标(png/jpg/ico ≤2MB;固定名覆盖,旧扩展名文件清理)。
同步 `def` 与 logo 上传同口径:磁盘写与审计提交均为阻塞操作。
"""
if file.size is not None and file.size > service.ASSET_MAX_BYTES:
raise BadRequestError("站点图标文件不能超过 2MB")
content = file.file.read()
ext = (file.filename or "").rsplit(".", 1)[-1]
return ok(
service.save_favicon(session, content, ext, operator=current_user, meta=meta),
message="站点图标已更新",
)
@router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)") @router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)")
def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]: def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]:
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。""" """公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
@ -81,3 +103,14 @@ def get_logo_public(session: DbSession) -> FileResponse:
media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"), media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"),
headers={"Cache-Control": "no-cache"}, headers={"Cache-Control": "no-cache"},
) )
@router.get("/favicon/public", summary="站点图标(匿名)")
def get_favicon_public(session: DbSession) -> FileResponse:
"""站点图标文件流(无鉴权)。no-cache:图标替换后由版本参数破缓存兜底。"""
path, ext = service.read_favicon(session)
return FileResponse(
path,
media_type=_FAVICON_MEDIA_TYPE.get(ext, "application/octet-stream"),
headers={"Cache-Control": "no-cache"},
)

View File

@ -14,6 +14,7 @@ from app.models.base import Base, TimestampMixin
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
from app.models.business_role import BusinessRole from app.models.business_role import BusinessRole
from app.models.customer import Customer, OwnershipChangeLog from app.models.customer import Customer, OwnershipChangeLog
from app.models.customer_rating import CustomerRating
from app.models.login_log import LoginLog from app.models.login_log import LoginLog
from app.models.operation_log import OperationLog from app.models.operation_log import OperationLog
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
@ -38,6 +39,7 @@ __all__ = [
"BusinessRole", "BusinessRole",
"BusinessStatusLog", "BusinessStatusLog",
"Customer", "Customer",
"CustomerRating",
"CustomerRatingGrade", "CustomerRatingGrade",
"LoginLog", "LoginLog",
"MonthlySettlement", "MonthlySettlement",

View File

@ -15,16 +15,7 @@ ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=
from datetime import datetime from datetime import datetime
from sqlalchemy import ( from sqlalchemy import DateTime, ForeignKey, Index, String, text
JSON,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
@ -63,17 +54,7 @@ class ApprovalOrder(Base, TimestampMixin):
__tablename__ = "approval_orders" __tablename__ = "approval_orders"
__table_args__ = mysql_table_args( __table_args__ = mysql_table_args(
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)", "审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
# 同一引用对象的第 N 次送审在库里有唯一一行:并发提单算重版本号时
# 由 DB 兜住(应用层取号走锁读,见 approval_service._next_version)
UniqueConstraint(
"order_type",
"ref_id",
"version_no",
name="uk_approval_orders_order_type_ref_version",
),
Index("idx_approval_orders_type_status", "order_type", "status"), Index("idx_approval_orders_type_status", "order_type", "status"),
# 版本号按 (order_type, ref_id) 递增,须有对应索引避免全表 max 扫描
Index("idx_approval_orders_type_ref", "order_type", "ref_id"),
) )
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(
@ -95,17 +76,6 @@ class ApprovalOrder(Base, TimestampMixin):
refer_no: Mapped[str] = mapped_column( refer_no: Mapped[str] = mapped_column(
String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)" String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)"
) )
version_no: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="1",
comment="提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖;"
"同一对象内唯一,由uk_approval_orders_order_type_ref_version强制)",
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON,
comment="提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)",
)
status: Mapped[int] = mapped_column( status: Mapped[int] = mapped_column(
TINYINT_TYPE, TINYINT_TYPE,
nullable=False, nullable=False,

View File

@ -15,17 +15,7 @@
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from sqlalchemy import ( from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
Date,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import ( from app.models.base import (
@ -52,34 +42,27 @@ class SecondaryAllocation(Base, TimestampMixin):
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人 - 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
与比例,响应以 hint 提示合计/余额,不做强制校验 与比例,响应以 hint 提示合计/余额,不做强制校验
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护 - T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
- **版本行 append-only**:编辑全量替换与驳回清空都把旧行置 - **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按
``is_active=0`` 而非物理删除,历史方案可回溯;当前生效行由 核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表
``is_active=1`` 唯一标识。核算仍按「当前生效行」取快照
(``performance_ledgers.snapshot_json.alloc_plan`` 固化),版本化只
补齐「方案事实留存」,不改变核算口径
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录 personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name 不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
冗余固化,人离职后方案回显仍有名。uk 锁 冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id),
(business_id, version_no, personnel_id),同一版本内同一参与人仅一行, 同一合同的同一参与人仅一行,重复提交在服务层聚合。
重复提交在服务层聚合;跨版本允许同一人再次出现。
""" """
__tablename__ = "business_secondary_allocations" __tablename__ = "business_secondary_allocations"
__table_args__ = mysql_table_args( __table_args__ = mysql_table_args(
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」, # 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例) # 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
"业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)", "业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
UniqueConstraint( UniqueConstraint(
"business_id", "business_id",
"version_no",
"personnel_id", "personnel_id",
name="uk_business_secondary_allocations_business_version_personnel", name="uk_business_secondary_allocations_business_personnel",
), ),
Index("idx_business_secondary_allocations_business_id", "business_id"), Index("idx_business_secondary_allocations_business_id", "business_id"),
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"), Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
# 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1
Index("idx_business_secondary_allocations_business_active", "business_id", "is_active"),
) )
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(
@ -91,12 +74,6 @@ class SecondaryAllocation(Base, TimestampMixin):
nullable=False, nullable=False,
comment="业务ID(businesses.id;业务删除随删)", comment="业务ID(businesses.id;业务删除随删)",
) )
version_no: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="1",
comment="方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)",
)
personnel_id: Mapped[int] = mapped_column( personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"), ForeignKey("personnel.id", ondelete="RESTRICT"),
@ -111,12 +88,6 @@ class SecondaryAllocation(Base, TimestampMixin):
nullable=False, nullable=False,
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)", comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
) )
is_active: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)",
)
class Business(Base, TimestampMixin, TPlusSyncMixin): class Business(Base, TimestampMixin, TPlusSyncMixin):

View File

@ -63,14 +63,6 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)", comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
) )
remark: Mapped[str | None] = mapped_column(String(255), comment="备注") remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
rating_score: Mapped[int | None] = mapped_column(
TINYINT_TYPE,
comment="评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)",
)
rating_time: Mapped[datetime | None] = mapped_column(
DateTime,
comment="最近评级时间(评级计算器保存评分时刷新;人工维护字段)",
)
class OwnershipChangeLog(Base): class OwnershipChangeLog(Base):

View File

@ -0,0 +1,77 @@
"""客户评级模型(需求 2.2.5)。
评级从「纯计算器不落库」升级为**每客户一行落库**(2026-10-19 确认):
客户管理页要罗列评级列并按其排序,评分必须可回溯。设计要点:
- ``customer_id`` 唯一键 + RESTRICT 外键:一客户一份当前评级(快照语义,
非流水),删除客户由服务层先删评级行再删主行(RESTRICT 兜底并发窗口)
- ``grade_name`` 为**快照固化、无外键**:等级映射表仍允许自由增删清空
(rating_service 的既有前提),删等级不能伤及已评级客户的历史结论 ——
与归属留痕「ID/人名固化、不回溯」同一论证口径;等级未命中时为 NULL
- ``scorer_id`` 外键 SET NULL + ``scorer_name`` 冗余固化:账号删除后
记录仍可读(同 ownership_change_logs 的操作人口径)
- 分值列 DECIMAL(5,1):UI 整数 0-20,DECIMAL 留十进制余地;总分由
**服务端重算**,不采信前端回传
"""
from decimal import Decimal
from sqlalchemy import ForeignKey, Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class CustomerRating(Base, TimestampMixin):
"""客户评级表:每客户一行(uk_customer_ratings_customer_id)。
五项各 0-20 分、总分 = 五项之和(0-100),判定规则同等级映射表
(按 sort_order 升序首个 min_score ≤ 总分,见 rating_service.resolve_grade)。
"""
__tablename__ = "customer_ratings"
__table_args__ = mysql_table_args(
"客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)",
UniqueConstraint("customer_id", name="uk_customer_ratings_customer_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
customer_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("customers.id", ondelete="RESTRICT"),
nullable=False,
comment="客户ID(唯一:一客户一份当前评级)",
)
score_ship_amount: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="发货额评分(0-20)"
)
score_receipt_rate: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="回款率评分(0-20)"
)
score_profit_rate: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="毛利率评分(0-20)"
)
score_contract_cooperation: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="合同履约配合能力评分(0-20)"
)
score_site_construction: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="现场规范施工能力评分(0-20)"
)
total_score: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, comment="总分(服务端重算,五项之和 0-100)"
)
grade_name: Mapped[str | None] = mapped_column(
String(32),
comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
scorer_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="评级操作人ID(账号删除后置空)",
)
scorer_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="评级操作人用户名(冗余固化)"
)

View File

@ -1,9 +1,10 @@
"""客户评级等级配置模型(需求 2.2.5)。 """客户评级等级配置模型(需求 2.2.5)。
评级计算器定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分记录 评级定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分自 2026-10-19
不落库,等级**映射关系**可配置由本表承载。判定规则:按 sort_order 升序取 起每客户落库于 customer_ratings(快照固化等级名),本表承载可配置的等级
第一个 min_score ≤ 总分的等级 —— 单边判定避免区间边界(如 75 分)在 **映射关系**。判定规则:按 sort_order 升序取第一个 min_score ≤ 总分的
「≥75 且 ≤89」与「>74 且 <75」两套写法间的重叠/遗漏歧义;max_score 仅展示。 等级 —— 单边判定避免区间边界(如 75 分)在「≥75 且 ≤89」与「>74 且 <75」
两套写法间的重叠/遗漏歧义;max_score 仅展示。
""" """
from decimal import Decimal from decimal import Decimal

View File

@ -1,31 +1,67 @@
"""系统设置模型:公司信息单例行。 """系统设置模型:公司信息 + 站点信息单例行。
与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期 与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期
时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列 时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列
value_text 为 String(64) 装不下;故独立单行表。logo 只存相对路径——文件在 value_text 为 String(64) 装不下;故独立单行表。logo/favicon 只存相对路径——
`backend/uploads/logo/`,独立于数据库备份体积且可单独替换(见 文件在 `backend/uploads/{logo,favicon}/`,独立于数据库备份体积且可单独替换
system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底,upsert 维护)。 (见 system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底)。
2026-10-20 精简(用户确认):企业档案字段(信用代码/地址/电话/邮箱)与
系统副标题删除,新增**站点标题**(浏览器标签页/侧边栏副标题)与**站点图标**
(浏览器标签页 favicon,独立于登录页 logo)。
""" """
from sqlalchemy import String from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class SystemSetting(Base, TimestampMixin): class SystemSetting(Base, TimestampMixin):
"""系统设置表:本期仅公司信息(公司名称 / logo 路径),全系统单行 id=1。""" """系统设置表:公司信息 + 站点信息,全系统单行 id=1。
brand_slogan/footer_text/icp_no/site_title 均可空:null 表示前端回退
内置默认值,存量行为零变化。
"""
__tablename__ = "system_settings" __tablename__ = "system_settings"
__table_args__ = mysql_table_args("系统设置表(公司信息,全系统单行)") __table_args__ = mysql_table_args("系统设置表(公司信息与站点信息,全系统单行)")
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
) )
company_name: Mapped[str] = mapped_column( company_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="公司名称(登录页与导出页眉展示)" String(64), nullable=False, comment="公司名称(登录页/侧边栏/浏览器标题展示)"
) )
logo_path: Mapped[str | None] = mapped_column( logo_path: Mapped[str | None] = mapped_column(
String(255), String(255),
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)", comment="公司 logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
)
logo_version: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="0",
comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)",
)
site_title: Mapped[str | None] = mapped_column(
String(64), comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)"
)
brand_slogan: Mapped[str | None] = mapped_column(
String(64), comment="品牌标语(登录页与品牌预览;null=内置默认)"
)
footer_text: Mapped[str | None] = mapped_column(
String(64), comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)"
)
icp_no: Mapped[str | None] = mapped_column(
String(32), comment="ICP 备案号(登录页页脚,空=不显示)"
)
favicon_path: Mapped[str | None] = mapped_column(
String(255),
comment="站点图标相对路径(文件存 uploads/favicon/,null=跟随公司 logo / 内置 favicon.svg)",
)
favicon_version: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="0",
comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)",
) )

View File

@ -5,7 +5,9 @@
models/customer.py 定义)。 models/customer.py 定义)。
""" """
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update from datetime import date
from sqlalchemy import Row, Select, delete, exists, func, or_, select
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app.models import ( from app.models import (
@ -21,8 +23,6 @@ from app.models import (
) )
from app.models.customer import OWNER_TYPE_BUSINESS from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import ownership_log_repository from app.repositories import ownership_log_repository
from app.repositories.ownership_log_repository import OwnershipLogRef
from app.schemas.business import BusinessQuery
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None: def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
@ -77,32 +77,50 @@ def get_with_names(
return session.execute(stmt).first() return session.execute(stmt).first()
def _apply_filters(stmt: Select, query: BusinessQuery) -> Select: 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)。""" """业务列表的筛选条件(调用方须已 join Customer)。"""
if query.keyword: if keyword:
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy # 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合) # 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
stmt = stmt.where( stmt = stmt.where(
or_( or_(
Business.business_no.contains(query.keyword, autoescape=True), Business.business_no.contains(keyword, autoescape=True),
Customer.company_name.contains(query.keyword, autoescape=True), Customer.company_name.contains(keyword, autoescape=True),
) )
) )
if query.status is not None: if status is not None:
stmt = stmt.where(Business.status == query.status) stmt = stmt.where(Business.status == status)
if query.customer_id is not None: if customer_id is not None:
stmt = stmt.where(Business.customer_id == query.customer_id) stmt = stmt.where(Business.customer_id == customer_id)
if query.owner_personnel_id is not None: if owner_personnel_id is not None:
stmt = stmt.where(Business.owner_personnel_id == query.owner_personnel_id) stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id)
if query.date_from is not None: if date_from is not None:
stmt = stmt.where(Business.business_date >= query.date_from) stmt = stmt.where(Business.business_date >= date_from)
if query.date_to is not None: if date_to is not None:
stmt = stmt.where(Business.business_date <= query.date_to) stmt = stmt.where(Business.business_date <= date_to)
return stmt return stmt
def list_businesses( def list_businesses(
session: Session, query: BusinessQuery 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]]: ) -> list[tuple[Business, str, str | None, str | None]]:
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。""" """分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
owner = aliased(Personnel) owner = aliased(Personnel)
@ -110,24 +128,49 @@ def list_businesses(
stmt = select( stmt = select(
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
).join(Customer, Customer.id == Business.customer_id) ).join(Customer, Customer.id == Business.customer_id)
stmt = _apply_filters(stmt, query) 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 = (
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id) stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id) .outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
.order_by(Business.id.desc()) .order_by(Business.id.desc())
.offset(query.offset) .offset(offset)
.limit(query.page_size) .limit(limit)
) )
return list(session.execute(stmt).all()) return list(session.execute(stmt).all())
def count_businesses(session: Session, query: BusinessQuery) -> int: 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 = ( stmt = (
select(func.count()) select(func.count())
.select_from(Business) .select_from(Business)
.join(Customer, Customer.id == Business.customer_id) .join(Customer, Customer.id == Business.customer_id)
) )
stmt = _apply_filters(stmt, query) 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()) return int(session.execute(stmt).scalar_one())
@ -156,75 +199,43 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem])
session.flush() session.flush()
def list_allocations( def list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]:
session: Session, business_id: int, *, active_only: bool = True """二次分配方案(详情回显;空列表=未设置方案)。"""
) -> list[SecondaryAllocation]:
"""二次分配方案(详情回显与核算取数;默认仅当前生效版本)。"""
stmt = select(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
if active_only:
stmt = stmt.where(SecondaryAllocation.is_active == 1)
return list( return list(
session.execute( session.execute(
stmt.order_by(SecondaryAllocation.version_no, SecondaryAllocation.id) select(SecondaryAllocation)
.where(SecondaryAllocation.business_id == business_id)
.order_by(SecondaryAllocation.id)
).scalars() ).scalars()
) )
def list_allocation_versions(session: Session, business_id: int) -> list[SecondaryAllocation]:
"""方案全部版本行(含历史;业务详情「方案历史」用,按版本正序)。"""
return list_allocations(session, business_id, active_only=False)
def replace_allocations( def replace_allocations(
session: Session, business_id: int, rows: list[SecondaryAllocation] session: Session, business_id: int, rows: list[SecondaryAllocation]
) -> None: ) -> None:
"""全量替换二次分配方案:旧生效行置历史,新行按下一版本号插入。 """全量替换二次分配方案(不 commit,事务边界在服务层)。
不 commit(事务边界在服务层)。版本行 append-only——编辑与清空都不 本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的
物理删除,驳回后仍可回溯被否决方案的参与人与比例。空 ``rows`` 表示 防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。
只清空生效态(不产生新版本)。
""" """
deactivate_allocations(session, business_id)
if not rows:
return
next_version = (
session.execute( session.execute(
select(func.coalesce(func.max(SecondaryAllocation.version_no), 0)).where( delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
SecondaryAllocation.business_id == business_id
)
).scalar_one()
+ 1
) )
session.flush()
for row in rows: for row in rows:
row.business_id = business_id row.business_id = business_id
row.version_no = next_version
row.is_active = 1
session.add(row) session.add(row)
session.flush() session.flush()
def deactivate_allocations(session: Session, business_id: int) -> None: def delete_allocations(session: Session, business_id: int) -> None:
"""把业务的当前生效方案置为历史(审批驳回/编辑替换时调用;行保留)。 """清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。
被否决的方案不得以「当前生效行」身份继续被核算取到;``is_active=0`` 被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到);
后核算侧自然读不到,而历史版本仍可在业务详情回溯。不 commit。 方案事实由审批单留痕与操作日志 request_data 承载。
显式声明 ``synchronize_session="fetch"``:Core UPDATE 默认走 auto 策略
(优先 evaluate,本例的条件恰好能在 Python 侧求值,故实测也会刷新在途
对象)。显式写 fetch 是为了不把「在途对象被刷新」这件事押在 evaluate
能否解析条件上——条件一旦复杂化,auto 会退化为不刷新,同一会话里已
加载的方案行就会停留在 ``is_active=1`` 的过期内存值(查询虽有
is_active 过滤,但行对象本身仍是「生效态」,被序列化或参与判断就是
错的)。
""" """
session.execute( session.execute(
update(SecondaryAllocation) delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
.where(
SecondaryAllocation.business_id == business_id,
SecondaryAllocation.is_active == 1,
)
.values(is_active=0)
.execution_options(synchronize_session="fetch")
) )
session.flush() session.flush()
@ -270,17 +281,18 @@ def list_ownership_logs(
) -> list[Row]: ) -> list[Row]:
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。""" """某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
return ownership_log_repository.list_by_ref( return ownership_log_repository.list_by_ref(
session, _business_log_ref(business_id), offset=offset, limit=limit 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: def count_ownership_logs(session: Session, business_id: int) -> int:
return ownership_log_repository.count_by_ref(session, _business_log_ref(business_id)) return ownership_log_repository.count_by_ref(
session,
def _business_log_ref(business_id: int) -> OwnershipLogRef:
"""业务维度的归属留痕锚点。"""
return OwnershipLogRef(
owner_type=OWNER_TYPE_BUSINESS, owner_type=OWNER_TYPE_BUSINESS,
ref_column=OwnershipChangeLog.business_id, ref_column=OwnershipChangeLog.business_id,
ref_id=business_id, ref_id=business_id,

View File

@ -7,7 +7,6 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import BusinessRole from app.models import BusinessRole
from app.schemas.business_role import BusinessRoleQuery
# ==================== 读取 ==================== # ==================== 读取 ====================
@ -40,48 +39,41 @@ def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, Business
return {role.role_code: role for role in rows} return {role.role_code: role for role in rows}
def list_roles(session: Session, query: BusinessRoleQuery) -> list[BusinessRole]: def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[BusinessRole]:
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。""" """分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
stmt = _apply_filters(select(BusinessRole), query) stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
stmt = ( stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit)
stmt.order_by(BusinessRole.sort_order, BusinessRole.id)
.offset(query.offset)
.limit(query.page_size)
)
return list(session.execute(stmt).scalars()) return list(session.execute(stmt).scalars())
def count_roles(session: Session, query: BusinessRoleQuery) -> int: def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
"""统计符合条件的岗位总数,供分页使用。""" """统计符合条件的岗位总数,供分页使用。"""
stmt = _apply_filters(select(func.count(BusinessRole.id)), query) stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
def list_all(session: Session) -> list[BusinessRole]: def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
"""全部岗位(含停用,不分页)——岗位下拉选项的取数口径。
岗位规模有限(内置 + 自定义),一次取全量,不受分页上限约束;
排序与 list_roles 一致(sort_order, id),下拉与列表相对次序相同。
"""
stmt = select(BusinessRole).order_by(BusinessRole.sort_order, BusinessRole.id)
return list(session.execute(stmt).scalars())
def _apply_filters(stmt: Select, query: BusinessRoleQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。 """应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线, 关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
不转义时 ``_`` 会当单字符通配执行。 不转义时 ``_`` 会当单字符通配执行。
""" """
if query.keyword: if keyword:
stmt = stmt.where( stmt = stmt.where(
or_( or_(
BusinessRole.role_code.contains(query.keyword, autoescape=True), BusinessRole.role_code.contains(keyword, autoescape=True),
BusinessRole.role_name.contains(query.keyword, autoescape=True), BusinessRole.role_name.contains(keyword, autoescape=True),
) )
) )
if query.status is not None: if status is not None:
stmt = stmt.where(BusinessRole.status == query.status) stmt = stmt.where(BusinessRole.status == status)
return stmt return stmt

View File

@ -0,0 +1,33 @@
"""客户评级数据访问(每客户一行)。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import CustomerRating
# ==================== 读取 ====================
def get_by_customer_id(session: Session, customer_id: int) -> CustomerRating | None:
"""查某客户的当前评级(uk_customer_ratings_customer_id 保证至多一行)。"""
stmt = select(CustomerRating).where(CustomerRating.customer_id == customer_id)
return session.execute(stmt).scalar_one_or_none()
# ==================== 写入 ====================
def create(session: Session, rating: CustomerRating) -> CustomerRating:
"""新增评级行。只 flush 以取得自增 id。"""
session.add(rating)
session.flush()
return rating
def delete(session: Session, rating: CustomerRating) -> None:
"""删除评级行(客户物理删除前的联动清理,调用方先校验业务引用)。"""
session.delete(rating)
session.flush()

View File

@ -6,12 +6,10 @@
from sqlalchemy import ColumnElement, Row, Select, func, or_, select from sqlalchemy import ColumnElement, Row, Select, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import Customer, OwnershipChangeLog, Personnel from app.models import Customer, CustomerRating, OwnershipChangeLog, Personnel
from app.models.customer import OWNER_TYPE_CUSTOMER from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import ownership_log_repository from app.repositories import ownership_log_repository
from app.repositories.ownership_log_repository import OwnershipLogRef
from app.repositories.query_utils import fuzzy from app.repositories.query_utils import fuzzy
from app.schemas.customer import CustomerQuery
# ==================== 读取 ==================== # ==================== 读取 ====================
@ -33,7 +31,7 @@ def count_business_refs(session: Session, customer_id: int) -> int:
def get_with_owner_name(session: Session, customer_id: int) -> Row | None: def get_with_owner_name(session: Session, customer_id: int) -> Row | None:
"""查单个客户并带出归属人员姓名(详情、编辑回显与归属变更共用)。""" """查单个客户并带出归属人员姓名与评级快照(详情/编辑回显/归属变更共用)。"""
stmt = _base_select().where(Customer.id == customer_id) stmt = _base_select().where(Customer.id == customer_id)
return session.execute(stmt).first() return session.execute(stmt).first()
@ -47,27 +45,52 @@ def get_by_company_owner(session: Session, company_name: str, owner_name: str) -
return session.execute(stmt).scalar_one_or_none() return session.execute(stmt).scalar_one_or_none()
def list_customers(session: Session, query: CustomerQuery) -> list[Row]: def list_customers(
"""分页查询客户,行内容为 (客户, 归属人员姓名)。 session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
order_field: str | None = None,
order_type: str | None = None,
) -> list[Row]:
"""分页查询客户,行内容为 (客户, 归属人员姓名, 评级总分, 评级等级)。
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据) 归属人员/评级行均用 outerjoin:关联行不可考(人员被物理删除、客户
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。 未评级)不应让客户行整个查不出来(同 personnel_repository 韧性口径)。
排序键由查询白名单决定(见 ``CustomerQuery.order_by``): 默认排序按 id 倒序:新增的靠前符合列表页习惯;评级排序见 ``_apply_order``。
id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。
""" """
stmt = _apply_filters(_base_select(), query) stmt = _apply_filters(
stmt = _apply_order(stmt, query) _base_select(),
stmt = stmt.offset(query.offset).limit(query.page_size) keyword=keyword,
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
stmt = _apply_order(stmt, order_field=order_field, order_type=order_type)
stmt = stmt.offset(offset).limit(limit)
return list(session.execute(stmt).all()) return list(session.execute(stmt).all())
def count_customers(session: Session, query: CustomerQuery) -> int: def count_customers(
session: Session,
*,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> int:
"""统计符合条件的客户总数,供分页使用。 """统计符合条件的客户总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由 筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。 ``_apply_filters`` 单点保证)。
""" """
stmt = _apply_filters(select(func.count(Customer.id)), query) 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()) return int(session.execute(stmt).scalar_one())
@ -81,7 +104,9 @@ def list_ownership_logs(
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。""" """查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
return ownership_log_repository.list_by_ref( return ownership_log_repository.list_by_ref(
session, session,
_customer_log_ref(customer_id), owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
offset=offset, offset=offset,
limit=limit, limit=limit,
) )
@ -89,12 +114,8 @@ def list_ownership_logs(
def count_ownership_logs(session: Session, *, customer_id: int) -> int: def count_ownership_logs(session: Session, *, customer_id: int) -> int:
"""统计某客户的归属留痕总数,供分页使用。""" """统计某客户的归属留痕总数,供分页使用。"""
return ownership_log_repository.count_by_ref(session, _customer_log_ref(customer_id)) return ownership_log_repository.count_by_ref(
session,
def _customer_log_ref(customer_id: int) -> OwnershipLogRef:
"""客户维度的归属留痕锚点。"""
return OwnershipLogRef(
owner_type=OWNER_TYPE_CUSTOMER, owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id, ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id, ref_id=customer_id,
@ -102,20 +123,52 @@ def _customer_log_ref(customer_id: int) -> OwnershipLogRef:
def _base_select() -> Select: def _base_select() -> Select:
"""客户主表外联归属人员姓名。""" """客户主表外联归属人员姓名与评级快照(等级名/总分,列表评级列免 N+1)。"""
return select(Customer, Personnel.personnel_name).outerjoin( return (
Personnel, Customer.owner_personnel_id == Personnel.id select(
Customer,
Personnel.personnel_name,
CustomerRating.total_score,
CustomerRating.grade_name,
)
.outerjoin(Personnel, Customer.owner_personnel_id == Personnel.id)
.outerjoin(CustomerRating, CustomerRating.customer_id == Customer.id)
) )
def _apply_filters(stmt: Select, query: CustomerQuery) -> Select: def _apply_order(stmt: Select, *, order_field: str | None, order_type: str | None) -> Select:
"""应用列表排序。默认 id 倒序;order_field=rating 按评级总分排序。
等级由总分判定、与总分同向,故按总分即等价按等级排序。MySQL 没有
``NULLS LAST`` 语法,用布尔键 ``total_score IS NULL`` 升序模拟 ——
未评级行恒在末尾,正序/倒序一致;同分以 id 倒序兜底保证次序稳定。
非法字段/方向不落到 SQL(schema Literal 已在入口拦 400),此处静默
回退默认排序作为纵深防御。
"""
if order_field == "rating" and order_type in ("asc", "desc"):
score_order = (
CustomerRating.total_score.asc()
if order_type == "asc"
else CustomerRating.total_score.desc()
)
return stmt.order_by(CustomerRating.total_score.is_(None), score_order, Customer.id.desc())
return stmt.order_by(Customer.id.desc())
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
customer_type: str | None,
owner_personnel_id: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。""" """应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if query.keyword: if keyword:
stmt = stmt.where(_keyword_clause(query.keyword)) stmt = stmt.where(_keyword_clause(keyword))
if query.customer_type: if customer_type:
stmt = stmt.where(Customer.customer_type == query.customer_type) stmt = stmt.where(Customer.customer_type == customer_type)
if query.owner_personnel_id is not None: if owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id) stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id)
return stmt return stmt
@ -133,23 +186,6 @@ def _keyword_clause(keyword: str) -> ColumnElement[bool]:
) )
def _apply_order(stmt: Select, query: CustomerQuery) -> Select:
"""应用排序。排序键已由 schema 白名单收口,此处只做键 → 列的映射。
MySQL 把 NULL 视为最小值:ASC 时未评分(null)会排最前,但「没打分」
不该压在「打了低分」之上 —— ASC 分支用 ``is_(None)`` 布尔键把 null
挪到末尾;DESC 分支 null 天然在最后,无需处理。尾部再按 id 倒序兜底,
保证同分行的次序稳定(分页不跳行)。
"""
if query.order_by == "rating_score":
score = Customer.rating_score
if query.order == "asc":
stmt = stmt.order_by(score.is_(None), score.asc())
else:
stmt = stmt.order_by(score.desc())
return stmt.order_by(Customer.id.desc())
# ==================== 写入 ==================== # ==================== 写入 ====================

View File

@ -8,7 +8,7 @@
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy import exists, func, or_, select from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session, defer from sqlalchemy.orm import Session
from app.models import ( from app.models import (
ApprovalOrder, ApprovalOrder,
@ -34,11 +34,9 @@ _PENDING_STATUSES = (
# ==== 经营绩效总览 ==== # ==== 经营绩效总览 ====
def sum_month_performance(session: Session, month: str) -> dict: def sum_month_performance(session: Session, month: str) -> dict:
"""当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。""" """当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。"""
return ( return session.execute(
session.execute(
select( select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label( func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total" "performance_total"
@ -49,10 +47,7 @@ def sum_month_performance(session: Session, month: str) -> dict:
PerformanceLedger.settlement_month == month, PerformanceLedger.settlement_month == month,
*_ACTIVE_SINGLE, *_ACTIVE_SINGLE,
) )
) ).mappings().one()
.mappings()
.one()
)
def performance_trend(session: Session, months: list[str]) -> dict[str, object]: def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
@ -71,7 +66,9 @@ def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
return dict(rows) return dict(rows)
def receipt_trend(session: Session, *, start_date: date, end_date: date) -> dict[str, object]: def receipt_trend(
session: Session, *, start_date: date, end_date: date
) -> dict[str, object]:
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。 """近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于 过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
@ -110,22 +107,18 @@ def get_month_settlement(session: Session, month: str) -> MonthlySettlement | No
# ==== 待办事项 ==== # ==== 待办事项 ====
def list_pending_approvals( def list_pending_approvals(
session: Session, *, statuses: tuple[int, ...], limit: int session: Session, *, statuses: tuple[int, ...], limit: int
) -> list[ApprovalOrder]: ) -> list[ApprovalOrder]:
"""待处理审批单(按提交时间升序——最久的排前面)。 """待处理审批单(按提交时间升序——最久的排前面)。
``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。 ``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。
排除 snapshot_json 列(快照可达上百 KB,待办只取单号/类型/提交人/时间)。
""" """
if not statuses: if not statuses:
return [] return []
return list( return list(
session.execute( session.execute(
select(ApprovalOrder) select(ApprovalOrder)
.options(defer(ApprovalOrder.snapshot_json))
.where(ApprovalOrder.status.in_(statuses)) .where(ApprovalOrder.status.in_(statuses))
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc()) .order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
.limit(limit) .limit(limit)
@ -164,14 +157,15 @@ def list_unassigned_receipts(session: Session, *, limit: int) -> list:
def count_unassigned_receipts(session: Session) -> int: def count_unassigned_receipts(session: Session) -> int:
return int( return int(
session.execute( session.execute(
select(func.count()).select_from(Receipt).where(Receipt.shipment_id.is_(None)) select(func.count())
.select_from(Receipt)
.where(Receipt.shipment_id.is_(None))
).scalar_one() ).scalar_one()
) )
# ==== 风险预警 ==== # ==== 风险预警 ====
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list: def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。 """发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
@ -216,14 +210,10 @@ def count_overdue_shipments(session: Session, *, cutoff: date) -> int:
def list_overdue_approvals( def list_overdue_approvals(
session: Session, *, cutoff: datetime, limit: int session: Session, *, cutoff: datetime, limit: int
) -> list[ApprovalOrder]: ) -> list[ApprovalOrder]:
"""提交后超过阈值仍未处理的审批单(审批超时规则)。 """提交后超过阈值仍未处理的审批单(审批超时规则)。"""
排除 snapshot_json 列(同待办:预警只用单号/类型/提交人/时间)。
"""
return list( return list(
session.execute( session.execute(
select(ApprovalOrder) select(ApprovalOrder)
.options(defer(ApprovalOrder.snapshot_json))
.where( .where(
ApprovalOrder.status.in_(_PENDING_STATUSES), ApprovalOrder.status.in_(_PENDING_STATUSES),
ApprovalOrder.create_time < cutoff, ApprovalOrder.create_time < cutoff,

View File

@ -1,7 +1,5 @@
"""登录日志数据访问。""" """登录日志数据访问。"""
from dataclasses import dataclass
from sqlalchemy import Select, func, select from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -12,73 +10,68 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
_USER_AGENT_MAX_LEN = 255 _USER_AGENT_MAX_LEN = 255
@dataclass(frozen=True, slots=True) def add(
class LoginLogEntry: session: Session,
"""一条待写入的登录日志(全字段 DTO)。 *,
user_id: int | None,
与 ``OperationLogEntry`` 同款做法:写入字段平铺传递时,任何一处漏传 username: str,
都只表现为日志字段缺失、不报错(success 默认 0 的语义尤其隐蔽)。 ip: str | None,
""" user_agent: str | None,
success: bool,
user_id: int | None fail_reason: str | None = None,
username: str ) -> None:
success: bool
ip: str | None = None
user_agent: str | None = None
fail_reason: str | None = None
@dataclass(frozen=True, slots=True)
class LoginLogListQuery:
"""登录日志列表的查询条件(筛选 + 分页)。"""
filters: LogFilters
offset: int = 0
limit: int = 20
ip: str | None = None
def add(session: Session, entry: LoginLogEntry) -> None:
"""写入一条登录日志。只 flush 不 commit。 """写入一条登录日志。只 flush 不 commit。
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。 **绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
:param entry.user_id: 账号不存在时为 None(列本身可空) :param user_id: 账号不存在时传 None(列本身可空)
:param entry.username: 用户名原样记录,便于账号不存在时也能追溯 :param username: 用户名原样记录,便于账号不存在时也能追溯
""" """
session.add( session.add(
LoginLog( LoginLog(
user_id=entry.user_id, user_id=user_id,
username=entry.username, username=username,
ip=entry.ip, ip=ip,
user_agent=entry.user_agent[:_USER_AGENT_MAX_LEN] if entry.user_agent else None, user_agent=user_agent[:_USER_AGENT_MAX_LEN] if user_agent else None,
success=1 if entry.success else 0, success=1 if success else 0,
fail_reason=entry.fail_reason, fail_reason=fail_reason,
) )
) )
session.flush() session.flush()
def list_logs(session: Session, query: LoginLogListQuery) -> list[LoginLog]: def list_logs(
session: Session,
*,
offset: int,
limit: int,
filters: LogFilters,
ip: str | None = None,
) -> list[LoginLog]:
"""分页查询登录日志。 """分页查询登录日志。
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序 **按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
不确定,翻页会重复或遗漏(与用户列表同一理由)。 不确定,翻页会重复或遗漏(与用户列表同一理由)。
""" """
stmt = _apply_filters(select(LoginLog), query) stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
stmt = stmt.order_by(LoginLog.id.desc()).offset(query.offset).limit(query.limit) stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).scalars()) return list(session.execute(stmt).scalars())
def count_logs(session: Session, query: LoginLogListQuery) -> int: def count_logs(
session: Session,
*,
filters: LogFilters,
ip: str | None = None,
) -> int:
"""统计符合条件的登录日志总数,供分页使用。""" """统计符合条件的登录日志总数,供分页使用。"""
stmt = _apply_filters(select(func.count(LoginLog.id)), query) stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, query: LoginLogListQuery) -> Select: def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。""" """应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
stmt = apply_log_filters(stmt, LoginLog, query.filters) stmt = apply_log_filters(stmt, LoginLog, filters)
if query.ip: if ip:
stmt = stmt.where(LoginLog.ip == query.ip) stmt = stmt.where(LoginLog.ip == ip)
return stmt return stmt

View File

@ -107,18 +107,15 @@ def get_by_id(session: Session, log_id: int) -> OperationLog | None:
return session.get(OperationLog, log_id) return session.get(OperationLog, log_id)
@dataclass(frozen=True, slots=True) def list_logs(
class OperationLogListQuery: session: Session,
"""操作日志列表的查询条件(筛选 + 分页)。""" *,
offset: int,
filters: LogFilters limit: int,
offset: int = 0 filters: LogFilters,
limit: int = 20 module: str | None = None,
module: str | None = None action: str | None = None,
action: str | None = None ) -> list[OperationLog]:
def list_logs(session: Session, query: OperationLogListQuery) -> list[OperationLog]:
"""分页查询操作日志。 """分页查询操作日志。
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序 **按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
@ -130,34 +127,41 @@ def list_logs(session: Session, query: OperationLogListQuery) -> list[OperationL
""" """
stmt = _apply_filters( stmt = _apply_filters(
select(OperationLog).options(defer(OperationLog.request_data)), select(OperationLog).options(defer(OperationLog.request_data)),
query, filters=filters,
module=module,
action=action,
) )
stmt = stmt.order_by(OperationLog.id.desc()).offset(query.offset).limit(query.limit) stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).scalars()) return list(session.execute(stmt).scalars())
def count_logs(session: Session, query: OperationLogListQuery) -> int: def count_logs(
session: Session,
*,
filters: LogFilters,
module: str | None = None,
action: str | None = None,
) -> int:
"""统计符合条件的日志总数,供分页使用。""" """统计符合条件的日志总数,供分页使用。"""
stmt = _apply_filters(select(func.count(OperationLog.id)), query) stmt = _apply_filters(
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
def distinct_modules(session: Session) -> list[str]: def _apply_filters(
"""全部模块名(去重、按名称升序):工作台「最近动态」的模块筛选项。 stmt: Select,
*,
交给 SQL 的 DISTINCT,不在应用层聚合 —— 应用层去重要把整列拉进内存。 filters: LogFilters,
""" module: str | None,
stmt = select(OperationLog.module).distinct().order_by(OperationLog.module) action: str | None,
return [module for module in session.execute(stmt).scalars() if module] ) -> Select:
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。""" """应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
stmt = apply_log_filters(stmt, OperationLog, query.filters) stmt = apply_log_filters(stmt, OperationLog, filters)
if query.module: if module:
stmt = stmt.where(OperationLog.module == query.module) stmt = stmt.where(OperationLog.module == module)
if query.action: if action:
stmt = stmt.where(OperationLog.action == query.action) stmt = stmt.where(OperationLog.action == action)
return stmt return stmt

View File

@ -5,32 +5,18 @@ owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。 「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
""" """
from dataclasses import dataclass
from typing import Any
from sqlalchemy import Row, func, select from sqlalchemy import Row, func, select
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app.models import OwnershipChangeLog, Personnel from app.models import OwnershipChangeLog, Personnel
@dataclass(frozen=True, slots=True)
class OwnershipLogRef:
"""归属留痕的引用锚点(维度类型 + 引用列 + 引用 ID)。
引用列是 SQLAlchemy 列表达式(如 ``OwnershipChangeLog.customer_id``),
不是可序列化值 —— 故用冻结 dataclass 而非 Pydantic 模型。
"""
owner_type: int
ref_column: Any
ref_id: int
def list_by_ref( def list_by_ref(
session: Session, session: Session,
ref: OwnershipLogRef,
*, *,
owner_type: int,
ref_column,
ref_id: int,
offset: int, offset: int,
limit: int, limit: int,
) -> list[Row]: ) -> list[Row]:
@ -46,7 +32,7 @@ def list_by_ref(
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name) select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id) .outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id) .outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
.where(OwnershipChangeLog.owner_type == ref.owner_type, ref.ref_column == ref.ref_id) .where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc()) .order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
@ -54,11 +40,11 @@ def list_by_ref(
return list(session.execute(stmt).all()) return list(session.execute(stmt).all())
def count_by_ref(session: Session, ref: OwnershipLogRef) -> int: def count_by_ref(session: Session, *, owner_type: int, ref_column, ref_id: int) -> int:
"""统计某引用对象的归属留痕总数,供分页使用。""" """统计某引用对象的归属留痕总数,供分页使用。"""
stmt = select(func.count(OwnershipChangeLog.id)).where( stmt = select(func.count(OwnershipChangeLog.id)).where(
OwnershipChangeLog.owner_type == ref.owner_type, OwnershipChangeLog.owner_type == owner_type,
ref.ref_column == ref.ref_id, ref_column == ref_id,
) )
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())

View File

@ -10,7 +10,6 @@ from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import ParamChangeBatch, ParamDefinition, ParamValue from app.models import ParamChangeBatch, ParamDefinition, ParamValue
from app.schemas.param import ParamBatchQuery, ParamHistoryQuery
# ==================== 读取 ==================== # ==================== 读取 ====================
@ -98,27 +97,27 @@ def list_definitions_by_codes(
def list_history( def list_history(
session: Session, param_id: int, query: ParamHistoryQuery session: Session,
param_id: int,
*,
offset: int,
limit: int,
dimension_value: str | None = None,
) -> list[tuple[ParamValue, ParamChangeBatch]]: ) -> list[tuple[ParamValue, ParamChangeBatch]]:
"""单参数的版本历史(含批次信息),最新在前。 """单参数的版本历史(含批次信息),最新在前。"""
stmt = _history_stmt(session, param_id, dimension_value=dimension_value)
``param_id`` 是「查哪个参数」的主锚点(由路径参数 param_code 解析而来), return [(value, batch) for value, batch in session.execute(stmt.offset(offset).limit(limit))]
不属于列表筛选项,故仍以位置参数传入;筛选与分页走 ``query``。
"""
stmt = _history_stmt(param_id, query)
return [
(value, batch)
for value, batch in session.execute(stmt.offset(query.offset).limit(query.page_size))
]
def count_history(session: Session, param_id: int, query: ParamHistoryQuery) -> int: def count_history(session: Session, param_id: int, *, dimension_value: str | None = None) -> int:
"""统计单参数的历史条目数。""" """统计单参数的历史条目数。"""
stmt = select(func.count()).select_from(_history_stmt(param_id, query).subquery()) stmt = select(func.count()).select_from(
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
def _history_stmt(param_id: int, query: ParamHistoryQuery) -> Select: def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select:
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。""" """历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
stmt = ( stmt = (
select(ParamValue, ParamChangeBatch) select(ParamValue, ParamChangeBatch)
@ -126,8 +125,8 @@ def _history_stmt(param_id: int, query: ParamHistoryQuery) -> Select:
.where(ParamValue.param_id == param_id) .where(ParamValue.param_id == param_id)
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc()) .order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
) )
if query.dimension_value is not None: if dimension_value is not None:
stmt = stmt.where(ParamValue.dimension_value == query.dimension_value) stmt = stmt.where(ParamValue.dimension_value == dimension_value)
return stmt return stmt
@ -151,30 +150,41 @@ def get_batch_by_id(
return session.get(ParamChangeBatch, batch_id) return session.get(ParamChangeBatch, batch_id)
def list_batches(session: Session, query: ParamBatchQuery) -> list[ParamChangeBatch]: def list_batches(
session: Session,
*,
offset: int,
limit: int,
start_date: date | None = None,
end_date: date | None = None,
) -> list[ParamChangeBatch]:
"""分页查询变更批次(按创建时间倒序)。""" """分页查询变更批次(按创建时间倒序)。"""
stmt = _batch_filters(select(ParamChangeBatch), query) stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date)
return list( return list(
session.execute( session.execute(
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc()) stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
.offset(query.offset) .offset(offset)
.limit(query.page_size) .limit(limit)
).scalars() ).scalars()
) )
def count_batches(session: Session, query: ParamBatchQuery) -> int: def count_batches(
session: Session, *, start_date: date | None = None, end_date: date | None = None
) -> int:
"""统计符合条件的批次总数。""" """统计符合条件的批次总数。"""
stmt = _batch_filters(select(func.count(ParamChangeBatch.id)), query) stmt = _batch_filters(
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
def _batch_filters(stmt: Select, query: ParamBatchQuery) -> Select: def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select:
"""批次筛选(生效日期区间,含边界)。列表与计数共用。""" """批次筛选(生效日期区间,含边界)。列表与计数共用。"""
if query.start_date is not None: if start_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date >= query.start_date) stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
if query.end_date is not None: if end_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date <= query.end_date) stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
return stmt return stmt

View File

@ -9,7 +9,6 @@ from sqlalchemy.orm import Session, aliased
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
from app.repositories.query_utils import fuzzy from app.repositories.query_utils import fuzzy
from app.schemas.personnel import PersonnelQuery
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」 # 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
_manager = aliased(Personnel) _manager = aliased(Personnel)
@ -31,23 +30,44 @@ def get_with_names(session: Session, personnel_id: int) -> Row | None:
return session.execute(stmt).first() return session.execute(stmt).first()
def list_personnel(session: Session, query: PersonnelQuery) -> list[Row]: 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 倒序:人员无排序字段,新增的靠前符合列表页习惯。 排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
""" """
stmt = _apply_filters(_base_select(), query) stmt = _apply_filters(
stmt = stmt.order_by(Personnel.id.desc()).offset(query.offset).limit(query.page_size) _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()) return list(session.execute(stmt).all())
def count_personnel(session: Session, query: PersonnelQuery) -> int: def count_personnel(
session: Session,
*,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> int:
"""统计符合条件的人员总数,供分页使用。 """统计符合条件的人员总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由 筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。 ``_apply_filters`` 单点保证)。
""" """
stmt = _apply_filters(select(func.count(Personnel.id)), query) 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()) return int(session.execute(stmt).scalar_one())
@ -134,14 +154,20 @@ def _base_select() -> Select:
) )
def _apply_filters(stmt: Select, query: PersonnelQuery) -> Select: def _apply_filters(
stmt: Select,
*,
keyword: str | None,
status: int | None,
business_role_id: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。""" """应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if query.keyword: if keyword:
stmt = fuzzy(stmt, Personnel.personnel_name, query.keyword) stmt = fuzzy(stmt, Personnel.personnel_name, keyword)
if query.status is not None: if status is not None:
stmt = stmt.where(Personnel.status == query.status) stmt = stmt.where(Personnel.status == status)
if query.business_role_id is not None: if business_role_id is not None:
stmt = stmt.where(Personnel.business_role_id == query.business_role_id) stmt = stmt.where(Personnel.business_role_id == business_role_id)
return stmt return stmt

View File

@ -7,7 +7,6 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import BusinessItem, Product, ShipmentItem from app.models import BusinessItem, Product, ShipmentItem
from app.schemas.product import ProductQuery
# ==================== 读取 ==================== # ==================== 读取 ====================
@ -35,20 +34,50 @@ def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Pr
return session.execute(stmt).scalar_one_or_none() return session.execute(stmt).scalar_one_or_none()
def list_products(session: Session, query: ProductQuery) -> list[Product]: 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 岗位列表的次序口径一致。""" """分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
stmt = _apply_filters(select(Product), query) stmt = _apply_filters(
stmt = stmt.order_by(Product.id).offset(query.offset).limit(query.page_size) 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()) return list(session.execute(stmt).scalars())
def count_products(session: Session, query: ProductQuery) -> int: 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)), query) stmt = _apply_filters(
select(func.count(Product.id)),
keyword=keyword,
product_category=product_category,
status=status,
)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, query: ProductQuery) -> Select: def _apply_filters(
stmt: Select,
*,
keyword: str | None,
product_category: str | None,
status: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。 """应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制 关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
@ -57,17 +86,17 @@ def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
写 contains —— M1 business_role_repository 的双列关键词同款写法。 写 contains —— M1 business_role_repository 的双列关键词同款写法。
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。 规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
""" """
if query.keyword: if keyword:
stmt = stmt.where( stmt = stmt.where(
or_( or_(
Product.product_name.contains(query.keyword, autoescape=True), Product.product_name.contains(keyword, autoescape=True),
Product.spec_model.contains(query.keyword, autoescape=True), Product.spec_model.contains(keyword, autoescape=True),
) )
) )
if query.product_category: if product_category:
stmt = stmt.where(Product.product_category == query.product_category) stmt = stmt.where(Product.product_category == product_category)
if query.status is not None: if status is not None:
stmt = stmt.where(Product.status == query.status) stmt = stmt.where(Product.status == status)
return stmt return stmt

View File

@ -32,9 +32,9 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None: def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
"""按主键查询。 """按主键查询。
等级表无外键引用(客户档案只存总分,等级由分数按当前映射实时推导, 等级表对客户评级无**外键**引用(评级行快照固化等级名),无需
不存等级 id),无需 ``for_update`` 锁行 —— product_repository 锁行防的 ``for_update`` 锁行 —— product_repository 锁行防的是并发引用悬空,
是并发引用悬空,此处不存在该风险:删掉一个等级,只是以后不再判到它。 此处不存在该风险。
""" """
return session.get(CustomerRatingGrade, grade_id) return session.get(CustomerRatingGrade, grade_id)
@ -61,10 +61,6 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
def delete(session: Session, grade: CustomerRatingGrade) -> None: def delete(session: Session, grade: CustomerRatingGrade) -> None:
"""物理删除等级。 """物理删除等级。等级表对客户评级无外键引用,调用方无需引用保护校验。"""
等级表无外键引用(客户档案只存总分,不存等级 id),调用方无需引用保护
校验:删掉等级只是「以后不再判到它」,已存的总分不受影响。
"""
session.delete(grade) session.delete(grade)
session.flush() session.flush()

View File

@ -14,7 +14,6 @@ from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import Business, Receipt, Shipment from app.models import Business, Receipt, Shipment
from app.schemas.receipt import ReceiptQuery
def get_by_id(session: Session, receipt_id: int) -> Receipt | None: def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
@ -44,42 +43,72 @@ def get_shipment(session: Session, shipment_id: int) -> Shipment | None:
return session.get(Shipment, shipment_id) return session.get(Shipment, shipment_id)
def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select: 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 自身列上)。""" """回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
if query.business_id is not None: if business_id is not None:
stmt = stmt.where(Receipt.business_id == query.business_id) stmt = stmt.where(Receipt.business_id == business_id)
if query.date_from is not None: if date_from is not None:
stmt = stmt.where(Receipt.receipt_date >= query.date_from) stmt = stmt.where(Receipt.receipt_date >= date_from)
if query.date_to is not None: if date_to is not None:
stmt = stmt.where(Receipt.receipt_date <= query.date_to) stmt = stmt.where(Receipt.receipt_date <= date_to)
if query.is_barter is not None: if is_barter is not None:
stmt = stmt.where(Receipt.is_barter == query.is_barter) stmt = stmt.where(Receipt.is_barter == is_barter)
# settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段 if settlement_status is not None:
if query.settlement_status is not None: stmt = stmt.where(Receipt.settlement_status == settlement_status)
stmt = stmt.where(Receipt.settlement_status == query.settlement_status)
return stmt return stmt
def list_receipts( def list_receipts(
session: Session, query: ReceiptQuery 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]]: ) -> list[tuple[Receipt, str, str | None, date | None]]:
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。""" """分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
stmt = _apply_filters( stmt = _apply_filters(
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date) select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
.join(Business, Business.id == Receipt.business_id) .join(Business, Business.id == Receipt.business_id)
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id), .outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
query, business_id=business_id,
) date_from=date_from,
return list( date_to=date_to,
session.execute( is_barter=is_barter,
stmt.order_by(Receipt.id.desc()).offset(query.offset).limit(query.page_size) settlement_status=settlement_status,
).all()
) )
return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all())
def count_receipts(session: Session, query: ReceiptQuery) -> int: 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,防两侧口径漂移)。""" """回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
stmt = _apply_filters(select(func.count()).select_from(Receipt), query) 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()) return int(session.execute(stmt).scalar_one())

View File

@ -389,7 +389,9 @@ def _bucket_condition(bucket: DayBucket) -> tuple:
return tuple(conds) return tuple(conds)
def list_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> list: def list_payment_detail(
session: Session, query: PaymentDetailQuery, bucket: DayBucket, *, offset: int, limit: int
) -> list:
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。 """区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。 bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
@ -406,7 +408,7 @@ def list_payment_detail(session: Session, query: PaymentDetailQuery, bucket: Day
.where(*_payment_filters(query), *_bucket_condition(bucket)) .where(*_payment_filters(query), *_bucket_condition(bucket))
) )
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc()) stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
stmt = stmt.offset(query.offset).limit(query.page_size) stmt = stmt.offset(offset).limit(limit)
return list(session.execute(stmt).all()) return list(session.execute(stmt).all())

View File

@ -10,7 +10,6 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import Role, UserRole from app.models import Role, UserRole
from app.schemas.role import RoleQuery
# ==================== 读取 ==================== # ==================== 读取 ====================
@ -48,37 +47,44 @@ def get_all_roles(session: Session) -> list[Role]:
return list(session.execute(stmt).scalars()) return list(session.execute(stmt).scalars())
def list_roles(session: Session, query: RoleQuery) -> list[Role]: def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[Role]:
"""分页查询角色(管理列表:含停用角色)。 """分页查询角色(管理列表:含停用角色)。
排序与下拉口径一致(sort_order, id),保持同一套相对次序。 排序与下拉口径一致(sort_order, id),保持同一套相对次序。
""" """
stmt = _apply_filters(select(Role), query) stmt = _apply_filters(select(Role), keyword=keyword, status=status)
stmt = stmt.order_by(Role.sort_order, Role.id).offset(query.offset).limit(query.page_size) stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
return list(session.execute(stmt).scalars()) return list(session.execute(stmt).scalars())
def count_roles(session: Session, query: RoleQuery) -> int: def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
"""统计符合条件的角色总数,供分页使用。""" """统计符合条件的角色总数,供分页使用。"""
stmt = _apply_filters(select(func.count(Role.id)), query) stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, query: RoleQuery) -> Select: def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。 """应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)—— 关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。 角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
""" """
if query.keyword: if keyword:
stmt = stmt.where( stmt = stmt.where(
or_( or_(
Role.role_code.contains(query.keyword, autoescape=True), Role.role_code.contains(keyword, autoescape=True),
Role.role_name.contains(query.keyword, autoescape=True), Role.role_name.contains(keyword, autoescape=True),
) )
) )
if query.status is not None: if status is not None:
stmt = stmt.where(Role.status == query.status) stmt = stmt.where(Role.status == status)
return stmt return stmt

View File

@ -7,16 +7,7 @@ from app.models import MonthlySettlement, SettlementItem
from app.schemas.settlement import SettlementQuery from app.schemas.settlement import SettlementQuery
def get_by_id( def get_by_id(session: Session, settlement_id: int) -> MonthlySettlement | None:
session: Session, settlement_id: int, *, for_update: bool = False
) -> MonthlySettlement | None:
"""月结行;``for_update`` 供提交审批路径加行锁用。
提交要算审批单版本号(同一月结的第几次送审),并发提交会让两次
算出同一个版本号;锁住月结行使并发提交串行化。
"""
if for_update:
return session.get(MonthlySettlement, settlement_id, with_for_update=True)
return session.get(MonthlySettlement, settlement_id) return session.get(MonthlySettlement, settlement_id)

View File

@ -8,13 +8,13 @@
业务级汇总仍收口 business_service._recalc_totals 业务级汇总仍收口 business_service._recalc_totals
""" """
from datetime import date
from decimal import Decimal from decimal import Decimal
from sqlalchemy import Select, delete, func, select from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import Business, Receipt, Shipment, ShipmentItem from app.models import Business, Receipt, Shipment, ShipmentItem
from app.schemas.shipment import ShipmentQuery
def get_by_id(session: Session, shipment_id: int) -> Shipment | None: def get_by_id(session: Session, shipment_id: int) -> Shipment | None:
@ -31,37 +31,59 @@ def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment,
).first() ).first()
def _apply_filters(stmt: Select, query: ShipmentQuery) -> Select: def _apply_filters(
stmt: Select,
*,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> Select:
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。""" """发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
if query.business_id is not None: if business_id is not None:
stmt = stmt.where(Shipment.business_id == query.business_id) stmt = stmt.where(Shipment.business_id == business_id)
if query.ship_date_from is not None: if ship_date_from is not None:
stmt = stmt.where(Shipment.ship_date >= query.ship_date_from) stmt = stmt.where(Shipment.ship_date >= ship_date_from)
if query.ship_date_to is not None: if ship_date_to is not None:
stmt = stmt.where(Shipment.ship_date <= query.ship_date_to) stmt = stmt.where(Shipment.ship_date <= ship_date_to)
return stmt return stmt
def list_shipments(session: Session, query: ShipmentQuery) -> list[tuple[Shipment, str]]: 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 倒序。""" """分页查询发货单(附业务号),按 id 倒序。"""
stmt = _apply_filters( stmt = _apply_filters(
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id), select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
query, business_id=business_id,
ship_date_from=ship_date_from,
ship_date_to=ship_date_to,
) )
return list( return list(
session.execute( session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all()
stmt.order_by(Shipment.id.desc()).offset(query.offset).limit(query.page_size)
).all()
) )
def count_shipments(session: Session, query: ShipmentQuery) -> int: def count_shipments(
session: Session,
*,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> int:
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。""" """发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
stmt = _apply_filters( stmt = _apply_filters(
select(func.count()) select(func.count())
.select_from(Shipment) .select_from(Shipment)
.join(Business, Business.id == Shipment.business_id), .join(Business, Business.id == Shipment.business_id),
query, business_id=business_id,
ship_date_from=ship_date_from,
ship_date_to=ship_date_to,
) )
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())

View File

@ -10,7 +10,6 @@ from sqlalchemy.orm import Session, selectinload
from app.models import Role, User, UserRole from app.models import Role, User, UserRole
from app.repositories.query_utils import fuzzy from app.repositories.query_utils import fuzzy
from app.schemas.user import UserQuery
def _base_query() -> Select[tuple[User]]: def _base_query() -> Select[tuple[User]]:
@ -49,7 +48,16 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None:
# ==================== 列表查询 ==================== # ==================== 列表查询 ====================
def list_users(session: Session, query: UserQuery) -> list[User]: def list_users(
session: Session,
*,
offset: int,
limit: int,
username: str | None = None,
real_name: str | None = None,
status: int | None = None,
role_code: str | None = None,
) -> list[User]:
"""分页查询用户列表。 """分页查询用户列表。
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户 **按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
@ -57,35 +65,48 @@ def list_users(session: Session, query: UserQuery) -> list[User]:
id 是自增主键,天然唯一且与创建顺序一致。 id 是自增主键,天然唯一且与创建顺序一致。
""" """
stmt = _base_query() stmt = _base_query()
stmt = _apply_filters(stmt, query) stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if query.role_code: if role_code:
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略 # 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code) stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
stmt = stmt.order_by(User.id.desc()).offset(query.offset).limit(query.page_size) stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).scalars().unique()) return list(session.execute(stmt).scalars().unique())
def count_users(session: Session, query: UserQuery) -> int: def count_users(
session: Session,
*,
username: str | None = None,
real_name: str | None = None,
status: int | None = None,
role_code: str | None = None,
) -> int:
"""统计符合条件的用户总数,供分页使用。""" """统计符合条件的用户总数,供分页使用。"""
stmt = select(func.count(User.id)) stmt = select(func.count(User.id))
stmt = _apply_filters(stmt, query) stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if query.role_code: if role_code:
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code) stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, query: UserQuery) -> Select: def _apply_filters(
stmt: Select,
*,
username: str | None,
real_name: str | None,
status: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。 """应用通用筛选条件。列表与计数共用,避免两处口径不一致。
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍 模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。 含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
""" """
if query.username: if username:
stmt = fuzzy(stmt, User.username, query.username) stmt = fuzzy(stmt, User.username, username)
if query.real_name: if real_name:
stmt = fuzzy(stmt, User.real_name, query.real_name) stmt = fuzzy(stmt, User.real_name, real_name)
if query.status is not None: if status is not None:
stmt = stmt.where(User.status == query.status) stmt = stmt.where(User.status == status)
return stmt return stmt

View File

@ -1,7 +1,6 @@
"""审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。""" """审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。"""
from datetime import datetime from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
@ -32,7 +31,6 @@ class ApprovalOrderOut(BaseModel):
order_type: int order_type: int
ref_id: int ref_id: int
refer_no: str refer_no: str
version_no: int
status: int status: int
submitter_id: int | None = None submitter_id: int | None = None
submitter_name: str submitter_name: str
@ -40,21 +38,9 @@ class ApprovalOrderOut(BaseModel):
class ApprovalOrderDetailOut(ApprovalOrderOut): class ApprovalOrderDetailOut(ApprovalOrderOut):
"""审批单详情:附节点时间线与审批内容。 """审批单详情:附节点时间线。"""
``content_source`` 语义:``snapshot``=提交时快照(可信);
``live``=历史单无快照、回退读当前业务内容(仅供参考);
``missing``=引用对象已不存在且无快照。
"""
steps: list[ApprovalStepOut] = Field(default_factory=list) steps: list[ApprovalStepOut] = Field(default_factory=list)
snapshot: dict[str, Any] | None = Field(
default=None,
description="提交时内容快照(信封:schema_version/order_type/captured_at/data)",
)
content_source: Literal["snapshot", "live", "missing"] = Field(
default="missing", description="审批内容来源:提交时快照/当前内容回退/缺失"
)
class ApprovalQuery(PageQuery): class ApprovalQuery(PageQuery):
@ -62,7 +48,7 @@ class ApprovalQuery(PageQuery):
keyword: str | None = Field(default=None, max_length=32, description="审批单号/引用单号,模糊") keyword: str | None = Field(default=None, max_length=32, description="审批单号/引用单号,模糊")
status: int | None = Field(default=None, ge=1, le=5, description="状态筛选") status: int | None = Field(default=None, ge=1, le=5, description="状态筛选")
order_type: int | None = Field(default=None, ge=1, le=3, description="对象类型筛选") order_type: int | None = Field(default=None, ge=1, le=2, description="对象类型筛选")
class ApprovalActionIn(BaseModel): class ApprovalActionIn(BaseModel):

View File

@ -180,14 +180,6 @@ class SecondaryAllocationOut(BaseModel):
ratio: str ratio: str
class AllocationVersionOut(SecondaryAllocationOut):
"""方案版本行(含历史;业务详情抽屉回溯用)。"""
version_no: int
is_active: int = Field(description="1当前生效 0历史版本")
create_time: datetime
def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None: def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
"""同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示 """同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示
(让 400 文案说「重复参与人」而不是裸唯一键冲突)。""" (让 400 文案说「重复参与人」而不是裸唯一键冲突)。"""
@ -199,16 +191,13 @@ def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
class BusinessDetailOut(BusinessOut): class BusinessDetailOut(BusinessOut):
"""业务详情:附产品明细行、当前方案与方案版本历史。""" """业务详情:附产品明细行与二次分配方案。"""
items: list[BusinessItemOut] = Field(default_factory=list) items: list[BusinessItemOut] = Field(default_factory=list)
secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list) secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list)
allocation_hint: str | None = Field( allocation_hint: str | None = Field(
default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)" default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)"
) )
allocation_history: list[AllocationVersionOut] = Field(
default_factory=list, description="方案全部版本(含历史;被驳回方案亦保留)"
)
class BusinessCreateIn(BaseModel): class BusinessCreateIn(BaseModel):

View File

@ -27,19 +27,6 @@ class BusinessRoleOut(BaseModel):
is_builtin: int = 0 is_builtin: int = 0
class BusinessRoleOptionOut(BaseModel):
"""岗位下拉选项(人员档案与报表筛选共用)。
与 ``BusinessRoleOut`` 分开:下拉只需渲染用的最小字段集,不必顺带
暴露计提模板等管理字段。字段名直出蛇形(前端按同名取值)。
"""
id: int = Field(description="岗位ID")
role_code: str = Field(description="岗位编码")
role_name: str = Field(description="岗位名称")
status: int = Field(description="状态:1启用 0禁用")
class TemplateParamOut(BaseModel): class TemplateParamOut(BaseModel):
"""模板声明的参数(供岗位表单展示,含当前口径说明)。""" """模板声明的参数(供岗位表单展示,含当前口径说明)。"""

View File

@ -14,6 +14,10 @@ class CustomerOut(BaseModel):
owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的 owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的
脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕 脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕
「ID 固化、姓名随联表」同一口径。 「ID 固化、姓名随联表」同一口径。
rating_total / rating_grade_name 由 LEFT JOIN customer_ratings 带出
(列表评级列免 N+1):未评级时均为 null;总分按分值红线以字符串出参,
等级为保存时点的快照固化值(见 models/customer_rating.py)。
""" """
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -29,8 +33,8 @@ class CustomerOut(BaseModel):
owner_personnel_id: int | None = None owner_personnel_id: int | None = None
owner_personnel_name: str | None = None owner_personnel_name: str | None = None
remark: str | None = None remark: str | None = None
rating_score: int | None = None rating_total: str | None = None
rating_time: datetime | None = None rating_grade_name: str | None = None
class CustomerCreateIn(BaseModel): class CustomerCreateIn(BaseModel):
@ -93,32 +97,21 @@ class CustomerUpdateIn(BaseModel):
class CustomerQuery(PageQuery): class CustomerQuery(PageQuery):
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。 """客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。"""
排序用 ``Literal`` 白名单而不是自由字符串:``order_by`` 直接拼进
``ORDER BY``,自由值等于把注入面开到排序键上;客户列表只有两个
有意义的排序键,白名单足够。
"""
keyword: str | None = Field( keyword: str | None = Field(
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配" default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
) )
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选") customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选") owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
order_by: Literal["id", "rating_score"] = Field( # 排序白名单(全项目首个列表排序参数):非法值由 Literal 拦下、经全局
default="id", description="排序键:id 建档序 / rating_score 评级总分" # 异常处理器转 400 —— 不把排序字段透传给 SQL,避免注入面
order_field: Literal["rating"] | None = Field(
default=None, description="排序字段白名单:rating=客户评级总分(等级由总分判定、同向)"
)
order_type: Literal["asc", "desc"] | None = Field(
default=None, description="排序方向:asc 正序 / desc 倒序(未评级恒在末尾)"
) )
order: Literal["asc", "desc"] = Field(default="desc", description="排序方向")
class CustomerRatingIn(BaseModel):
"""保存客户评级入参(评级计算器「保存评分」)。
score 是五项之和的整数(0-100);五项明细不落库(评级记录只存总分,
见对照表 #25 的定位:评分是档案属性而非审计事件)。
"""
score: int = Field(ge=0, le=100, description="评级总分(0-100 整数)")
class CustomerOwnershipIn(BaseModel): class CustomerOwnershipIn(BaseModel):

View File

@ -1,13 +1,9 @@
"""工作台的请求/响应模型(M4 ⑧)。 """工作台的请求/响应模型(M4 ⑧)。
聚合端点一次返回三区块(前端一次请求渲染整页,避免多次往返): 四区块一次返回(前端一次请求渲染整页,避免 6-10 次往返):
经营绩效总览 / 待办事项 / 风险预警。金额一律 Decimal 经营绩效总览 / 待办事项 / 风险预警 / 最近动态。金额一律 Decimal
(序列化为字符串),前端不做数值运算。 (序列化为字符串),前端不做数值运算。
**最近动态独立成分页端点**:它可能有很长(操作日志全量),塞进聚合
响应既撑爆页面又只能截断前 20 条;拆开后翻页只重取这张列表,不必
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策); 待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级, ⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
不返回伪造的 0 条。 不返回伪造的 0 条。
@ -16,9 +12,7 @@
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, Field
from app.core.response import PageQuery
class TrendPoint(BaseModel): class TrendPoint(BaseModel):
@ -122,42 +116,11 @@ class ActivityItem(BaseModel):
create_time: datetime create_time: datetime
class ActivityQuery(PageQuery):
"""最近动态查询条件:分页 + 模块筛选。
只支持模块筛选(工作台只需要这一个维度);其余筛选项见日志查询页。
"""
module: str | None = Field(default=None, max_length=64, description="模块,精确匹配")
class ActivityPageOut(BaseModel):
"""最近动态分页结果:当前页 + 总数 + 模块筛选项。
分页字段名沿用全站规范(list/total/pageNum/pageSize),与
`PageResult` 同形;额外带 `modules`(全量模块名),
供前端模块下拉使用 —— 不随当前页变化,避免翻页后选项增减。
"""
model_config = ConfigDict(populate_by_name=True)
records: list[ActivityItem] = Field(
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
)
total: int = Field(default=0, description="总记录数")
page_num: int = Field(
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
)
page_size: int = Field(
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
)
modules: list[str] = Field(default_factory=list, description="全部模块名(筛选项)")
class DashboardOut(BaseModel): class DashboardOut(BaseModel):
"""工作台三区块一次性响应(最近动态走独立分页端点)。""" """工作台四区块一次性响应。"""
month: str month: str
overview: OverviewBlock overview: OverviewBlock
todos: TodoBlock todos: TodoBlock
warnings: WarningBlock warnings: WarningBlock
activities: list[ActivityItem] = Field(default_factory=list)

View File

@ -77,3 +77,55 @@ class RatingGradeUpdateIn(RatingGradeCreateIn):
前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。 前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。
""" """
class CustomerRatingOut(BaseModel):
"""客户评级响应(每客户一行;总分与分值按红线以字符串出参)。
grade_name 是保存时点按等级映射**快照固化**的值(无外键):等级表后续
增删改不影响已保存的评级结论;为 NULL 表示保存时未命中任何等级
(总分低于全部下限或等级表被清空)。
"""
customer_id: int
score_ship_amount: str
score_receipt_rate: str
score_profit_rate: str
score_contract_cooperation: str
score_site_construction: str
total_score: str
grade_name: str | None = None
remark: str | None = None
scorer_name: str
create_time: datetime
update_time: datetime
class CustomerRatingSaveIn(BaseModel):
"""保存客户评级(每客户一行,整体替换语义)。
五项分值为 0-20 的数字字符串(schema 只拦格式;0≤分≤20 与总分等级
判定在 service 层审计上下文内完成 —— 失败的 400 同样留痕)。总分由
服务端按五项重算,**不采信前端回传的总分**。
"""
model_config = ConfigDict(str_strip_whitespace=True)
score_ship_amount: str = Field(description="发货额评分(0-20 数字字符串)")
score_receipt_rate: str = Field(description="回款率评分(0-20 数字字符串)")
score_profit_rate: str = Field(description="毛利率评分(0-20 数字字符串)")
score_contract_cooperation: str = Field(description="合同履约配合能力评分(0-20 数字字符串)")
score_site_construction: str = Field(description="现场规范施工能力评分(0-20 数字字符串)")
remark: str | None = Field(default=None, max_length=255, description="备注")
@field_validator(
"score_ship_amount",
"score_receipt_rate",
"score_profit_rate",
"score_contract_cooperation",
"score_site_construction",
)
@classmethod
def _check_score(cls, value: str) -> str:
"""单项分值须为有限数字(值域 0-20 由 service 层判定)。"""
return _parse_score(value, label="评分")

View File

@ -264,9 +264,7 @@ class DepartmentReconciliation(BaseModel):
description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)", description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)",
) )
adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)") adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)")
payable_total: Decimal = Field( payable_total: Decimal = Field(description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额")
description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额"
)
team_base_total: Decimal = Field(description="展示团队基数合计") team_base_total: Decimal = Field(description="展示团队基数合计")
unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计") unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计")
other_team_base: Decimal = Field( other_team_base: Decimal = Field(

View File

@ -1,34 +1,59 @@
"""系统设置(公司信息)请求/响应模型。 """系统设置(公司信息 + 站点信息)请求/响应模型。
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权, `SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会 响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
顺着复用链泄漏到匿名端点。 顺着复用链泄漏到匿名端点。
2026-10-20 精简(用户确认):删除系统副标题与企业档案字段(信用代码/
地址/电话/邮箱);新增站点标题(浏览器标签页/侧边栏副标题)与站点图标
(浏览器标签页 favicon)。public 端点只需登录页/标签页展示字段,两者
恰好同集合,但仍保留独立 schema 作为白名单防线。
""" """
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
class SettingOut(BaseModel): class SettingOut(BaseModel):
"""公司信息(管理端)。""" """公司信息与站点信息(管理端)。"""
company_name: str company_name: str
logo_url: str | None = None logo_url: str | None = None
site_title: str | None = None
brand_slogan: str | None = None
footer_text: str | None = None
icp_no: str | None = None
favicon_url: str | None = None
class SettingPublicOut(BaseModel): class SettingPublicOut(BaseModel):
"""公司信息(匿名端点白名单):登录页展示所需的两个字段。""" """公司信息与站点信息(匿名端点白名单):登录页/浏览器标签页展示所需字段。"""
company_name: str company_name: str
logo_url: str | None = None logo_url: str | None = None
site_title: str | None = None
brand_slogan: str | None = None
footer_text: str | None = None
icp_no: str | None = None
favicon_url: str | None = None
class SettingUpdateIn(BaseModel): class SettingUpdateIn(BaseModel):
"""公司信息更新。 """公司信息与站点信息更新(**部分更新**语义)。
str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1 str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1
后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。 后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。
仅提交的字段生效(``exclude_unset``),未提交或显式 null 的字段保持
不变;可选文本传空串等价清空(落库为 null、前端回退内置默认)。
company_name 若提交必须非空(DB 非空约束)。
""" """
model_config = ConfigDict(str_strip_whitespace=True) model_config = ConfigDict(str_strip_whitespace=True)
company_name: str = Field(min_length=1, max_length=64, description="公司名称") company_name: str | None = Field(
default=None, min_length=1, max_length=64, description="公司名称(提交则须非空)"
)
site_title: str | None = Field(default=None, max_length=64, description="站点标题")
brand_slogan: str | None = Field(default=None, max_length=64, description="品牌标语")
footer_text: str | None = Field(default=None, max_length=64, description="页脚版权文本")
icp_no: str | None = Field(default=None, max_length=32, description="ICP 备案号")

View File

@ -20,11 +20,10 @@
import logging import logging
from collections.abc import Iterable from collections.abc import Iterable
from datetime import date, datetime from datetime import date
from decimal import Decimal
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session, defer from sqlalchemy.orm import Session
from app.core.constants import ( from app.core.constants import (
ACTION_APPROVE, ACTION_APPROVE,
@ -37,7 +36,7 @@ from app.core.constants import (
) )
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult from app.core.response import PageResult
from app.models import ApprovalOrder, ApprovalStep, Business, User from app.models import ApprovalOrder, ApprovalStep, User
from app.models.approval import ( from app.models.approval import (
APPROVAL_ACTION_APPROVE, APPROVAL_ACTION_APPROVE,
APPROVAL_ACTION_AUTO_APPROVE, APPROVAL_ACTION_AUTO_APPROVE,
@ -114,7 +113,6 @@ def submit_order(
ref_id: int, ref_id: int,
refer_no: str, refer_no: str,
submitter: User, submitter: User,
snapshot: dict | None = None,
) -> ApprovalOrder: ) -> ApprovalOrder:
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。 """提单(业务写路径内部调用;不 commit、不嵌套 audit)。
@ -124,10 +122,6 @@ def submit_order(
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400—— 一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务 **任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。 的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
``snapshot`` 是「提交时」的业务内容(由调用方组装,Decimal 已转 str);
落库时包一层信封并固化采集时间——审批期间业务再改,复核者看到的仍是
送审内容。开关关闭的自动通过路径同样写快照,留痕不走样。
""" """
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter) _withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
@ -141,17 +135,6 @@ def submit_order(
order_type=order_type, order_type=order_type,
ref_id=ref_id, ref_id=ref_id,
refer_no=refer_no, refer_no=refer_no,
version_no=_next_version(session, order_type, ref_id),
snapshot_json=(
{
"schema_version": 1,
"order_type": order_type,
"captured_at": datetime.now().isoformat(timespec="seconds"),
"data": snapshot,
}
if snapshot is not None
else None
),
submitter_id=submitter.id, submitter_id=submitter.id,
submitter_name=submitter.username, submitter_name=submitter.username,
) )
@ -182,35 +165,6 @@ def submit_order(
return order return order
def pending_matches_snapshot(
session: Session, *, order_type: int, ref_id: int, snapshot: dict | None
) -> bool:
"""在途审批单的送审内容是否与给定快照一致(用于「内容未变不重提」)。
只比较快照信封里的 ``data`` 部分——``captured_at`` 每次采集都不同,
参与比较会让判断恒为不一致。V11 之前的历史单没有快照,一律视为不一致
(保守重提:宁可多提一次,不能漏审)。
"""
if snapshot is None:
return False
pending = (
session.execute(
select(ApprovalOrder).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
ApprovalOrder.status.in_(_PENDING_STATUSES),
)
)
.scalars()
.all()
)
for order in pending:
stored = order.snapshot_json or {}
if stored.get("data") == snapshot:
return True
return False
def withdraw_pending_orders( def withdraw_pending_orders(
session: Session, *, order_type: int, ref_id: int, operator: User session: Session, *, order_type: int, ref_id: int, operator: User
) -> int: ) -> int:
@ -296,15 +250,15 @@ def act(
operator_name=operator.username, operator_name=operator.username,
) )
) )
# 驳回=方案未获批准:把该业务的生效方案行置历史(is_active=0, # 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以
# **不删行**——V11 起 append-only 版本化),防止被否决的方案以 # 「当前生效行」身份留在表里、被 M3 核算快照算进工资;方案事实由
# 「当前生效行」身份被 M3 核算快照算进工资;历史方案仍可在业务 # 审批单留痕与操作日志 request_data 承载(分摊语义落定后如需保留
# 详情 allocation_history 回溯。委托 business_repository 完成 # 历史版本,在 ④ 设计时演进)。审批单的清理由调用方(act 内联)
# —— service 层互调、不触 commit。 # 委托 business_repository 完成 —— service 层互调、不触 commit。
if rejected and order.order_type == 1: if rejected and order.order_type == 1:
from app.repositories import business_repository from app.repositories import business_repository
business_repository.deactivate_allocations(session, order.ref_id) business_repository.delete_allocations(session, order.ref_id)
# 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿; # 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿;
# 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。 # 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。
@ -345,18 +299,11 @@ def act(
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]: def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
"""分页查询审批单(准入由 api 层依赖判定)。 """分页查询审批单(准入由 api 层依赖判定)。"""
排除 snapshot_json 列(月结快照含按人明细,可达数十~上百 KB):列表出参
本就不含快照,整列加载会让翻页负担随快照体积线性放大;详情走
`get_order` 按需读取(与操作日志列表排除 request_data 同一理由,
见 operation_log_repository.list_logs)。
"""
filters = _order_filters(query) filters = _order_filters(query)
rows = ( rows = (
session.execute( session.execute(
select(ApprovalOrder) select(ApprovalOrder)
.options(defer(ApprovalOrder.snapshot_json))
.where(*filters) .where(*filters)
.order_by(ApprovalOrder.id.desc()) .order_by(ApprovalOrder.id.desc())
.offset(query.offset) .offset(query.offset)
@ -413,152 +360,8 @@ def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operato
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id) logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id)
def _next_version(session: Session, order_type: int, ref_id: int) -> int:
"""同一对象类型+引用对象的下一个提交版本号(从 1 递增)。
**必须用锁读(当前读),不能用普通 MAX 查询**:普通 SELECT 在
REPEATABLE READ 下是快照读,读到的是本事务首次一致读的快照;请求级
鉴权(deps.get_current_user 与业务共用同一 DbSession)那次 SELECT 已把
快照锚定在请求开始时刻,此后即使父行锁等到并发事务提交,MAX 仍停在
旧快照上——两个请求会算出同一个 version_no。锁读是当前读,永远读最新
已提交数据。
**不能写成 select(func.max(...)).with_for_update()**:MySQL 官方明确
「Locking clauses are not permitted in queries with aggregate functions
or GROUP BY」,聚合结果上的 FOR UPDATE 语义无保证。改锁「该 ref 的现有
版本行」,最大值在 Python 侧算——同 no_generator 的既有套路。只取
version_no 单列,不把 snapshot_json 拉进内存。
"""
versions = (
session.execute(
select(ApprovalOrder.version_no)
.where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
)
.with_for_update()
)
.scalars()
.all()
)
return (max(versions) if versions else 0) + 1
def allocation_content(session: Session, business: Business) -> dict:
"""二次分配方案的审批内容(提单快照与历史单回退共用同一结构)。
业务主行已存在但客户联查落空(客户被删等异常)时返回空字典——调用方
以 ``or None`` 归一,避免生成「有快照但内容为空」的信封。
"""
from app.repositories import business_repository
row = business_repository.get_with_names(session, business.id)
if row is None:
return {}
_, customer_name, owner_name, assistant_name = row
allocations = business_repository.list_allocations(session, business.id)
total = sum((a.ratio for a in allocations), Decimal("0"))
return {
"business_no": business.business_no,
"business_date": business.business_date.isoformat() if business.business_date else None,
"customer_name": customer_name,
"owner_personnel_name": owner_name,
"assistant_personnel_name": assistant_name,
"sale_amount": _amount_text(business.total_sale_amount),
"cost_amount": _amount_text(business.total_cost_amount),
"allocations": [
{
"personnel_id": a.personnel_id,
"personnel_name": a.personnel_name,
"ratio": str(a.ratio),
}
for a in allocations
],
"allocation_total": str(total.quantize(Decimal("0.01"))),
"allocation_balance": str((Decimal("100") - total).quantize(Decimal("0.01"))),
}
def _amount_text(value: Decimal | None) -> str | None:
"""金额文本化(Decimal 全程不转 float;None 保留为空,不伪造 0.00)。"""
return str(value) if value is not None else None
def settlement_content(session: Session, settlement_id: int) -> dict | None:
"""月结审批内容(汇总 + 按人明细;提单快照与历史单回退共用)。"""
from app.models import MonthlySettlement
from app.repositories import settlement_repository
settlement = session.get(MonthlySettlement, settlement_id)
if settlement is None:
return None
items = settlement_repository.list_items(session, settlement_id)
return {
"settlement_month": settlement.settlement_month,
"total_performance": _amount_text(settlement.total_performance),
"total_payable": _amount_text(settlement.total_payable),
"person_count": settlement.person_count,
"items": [
{
"personnel_name": item.personnel_name,
"role_code": item.role_code,
"calc_type": item.calc_type,
"performance_sum": str(item.performance_sum),
"payable_sum": str(item.payable_sum),
"basic_amount": _amount_text(item.basic_amount),
"salary_mode": item.salary_mode,
"net_amount": _amount_text(item.net_amount),
"is_probation": item.is_probation,
}
for item in items
],
}
def adjustment_content(session: Session, adjustment_id: int) -> dict | None:
"""调整单审批内容(提单快照与历史单回退共用)。"""
from app.models import AdjustmentOrder
adjustment = session.get(AdjustmentOrder, adjustment_id)
if adjustment is None:
return None
return {
"order_no": adjustment.order_no,
"settlement_month": adjustment.settlement_month,
"personnel_name": adjustment.personnel_name,
"direction": adjustment.direction,
"amount": str(adjustment.amount),
"reason": adjustment.reason,
}
def _content_of(session: Session, order: ApprovalOrder) -> tuple[dict | None, str]:
"""审批内容出参:优先提交时快照,历史单回退当前内容并显式标注来源。"""
if order.snapshot_json:
return order.snapshot_json, "snapshot"
if order.order_type == 1:
business = session.get(Business, order.ref_id)
data = allocation_content(session, business) if business is not None else None
elif order.order_type == 2:
data = settlement_content(session, order.ref_id)
elif order.order_type == 3:
data = adjustment_content(session, order.ref_id)
else:
data = None
if not data:
return None, "missing"
return {
"schema_version": 1,
"order_type": order.order_type,
"captured_at": None,
"data": data,
}, "live"
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut: def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
"""出参组装(含 steps 时间线与审批内容)。调用方须保证处于 commit 之前。""" """出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。"""
steps = ( steps = (
session.execute( session.execute(
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id) select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
@ -568,7 +371,6 @@ def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut
) )
detail = ApprovalOrderDetailOut.model_validate(order) detail = ApprovalOrderDetailOut.model_validate(order)
detail.steps = [ApprovalStepOut.model_validate(s) for s in steps] detail.steps = [ApprovalStepOut.model_validate(s) for s in steps]
detail.snapshot, detail.content_source = _content_of(session, order)
return detail return detail

View File

@ -37,14 +37,12 @@ def _record(
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。""" """写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
login_log_repository.add( login_log_repository.add(
session, session,
login_log_repository.LoginLogEntry(
user_id=user_id, user_id=user_id,
username=username, username=username,
ip=ip, ip=ip,
user_agent=user_agent, user_agent=user_agent,
success=success, success=success,
fail_reason=fail_reason, fail_reason=fail_reason,
),
) )
session.commit() session.commit()

View File

@ -31,7 +31,6 @@ from app.models import BusinessRole, User
from app.repositories import business_role_repository from app.repositories import business_role_repository
from app.schemas.business_role import ( from app.schemas.business_role import (
BusinessRoleCreateIn, BusinessRoleCreateIn,
BusinessRoleOptionOut,
BusinessRoleOut, BusinessRoleOut,
BusinessRoleQuery, BusinessRoleQuery,
BusinessRoleUpdateIn, BusinessRoleUpdateIn,
@ -68,8 +67,16 @@ def _ensure_template(calc_template: str) -> None:
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]: def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
"""分页查询岗位(含停用)。""" """分页查询岗位(含停用)。"""
roles = business_role_repository.list_roles(session, query) roles = business_role_repository.list_roles(
total = business_role_repository.count_roles(session, query) session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
)
total = business_role_repository.count_roles(
session, keyword=query.keyword, status=query.status
)
return PageResult[BusinessRoleOut]( return PageResult[BusinessRoleOut](
records=[_to_out(role) for role in roles], records=[_to_out(role) for role in roles],
total=total, total=total,
@ -78,23 +85,6 @@ def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[Busines
) )
def list_options(session: Session) -> list[BusinessRoleOptionOut]:
"""岗位下拉选项(含停用,不分页)。
含停用是因为人员档案可能仍引用停用岗位(停用只是禁止新增引用,
不改历史归属);岗位规模有限(内置 + 自定义),一次取全量。
"""
return [
BusinessRoleOptionOut(
id=role.id,
role_code=role.role_code,
role_name=role.role_name,
status=role.status,
)
for role in business_role_repository.list_all(session)
]
def get_role(session: Session, role_id: int) -> BusinessRoleOut: def get_role(session: Session, role_id: int) -> BusinessRoleOut:
"""岗位详情。 """岗位详情。

View File

@ -15,8 +15,6 @@
""" """
import logging import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date from datetime import date
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction from fractions import Fraction
@ -56,7 +54,6 @@ from app.models.business import (
from app.models.customer import OWNER_TYPE_BUSINESS from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import business_repository, customer_repository, personnel_repository from app.repositories import business_repository, customer_repository, personnel_repository
from app.schemas.business import ( from app.schemas.business import (
AllocationVersionOut,
BusinessCreateIn, BusinessCreateIn,
BusinessDetailOut, BusinessDetailOut,
BusinessItemIn, BusinessItemIn,
@ -236,8 +233,26 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]: def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
"""分页查询业务(附客户名与归属人名)。""" """分页查询业务(附客户名与归属人名)。"""
rows = business_repository.list_businesses(session, query) rows = business_repository.list_businesses(
total = business_repository.count_businesses(session, query) 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]( return PageResult[BusinessOut](
records=[_to_out(row) for row in rows], records=[_to_out(row) for row in rows],
total=total, total=total,
@ -266,17 +281,6 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
for a in allocations for a in allocations
] ]
out.allocation_hint = _allocation_hint(allocations) out.allocation_hint = _allocation_hint(allocations)
out.allocation_history = [
AllocationVersionOut(
personnel_id=a.personnel_id,
personnel_name=a.personnel_name,
ratio=str(a.ratio),
version_no=a.version_no,
is_active=a.is_active,
create_time=a.create_time,
)
for a in business_repository.list_allocation_versions(session, business_id)
]
return out return out
@ -331,20 +335,6 @@ def _build_allocation_rows(
return rows return rows
def _plan_changed(old_rows: list[SecondaryAllocation], new_rows: list[SecondaryAllocation]) -> bool:
"""方案内容(参与人 + 比例)是否真的变了。
比较口径是**集合**(人员 ID 与其比例的组合),与提交顺序无关——送审
快照同样按库内生效行的顺序生成,若按顺序比较,用户只是把行拖了个
序就会被判成「变了」,进而无谓地新增版本与重提审批单。
"""
def _key(row: SecondaryAllocation) -> tuple[int, Decimal]:
return (row.personnel_id, Decimal(row.ratio).quantize(Decimal("0.01")))
return {_key(r) for r in old_rows} != {_key(r) for r in new_rows}
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None: def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。 """方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
@ -443,7 +433,6 @@ def create_business(
ref_id=business.id, ref_id=business.id,
refer_no=business.business_no, refer_no=business.business_no,
submitter=operator, submitter=operator,
snapshot=approval_service.allocation_content(session, business) or None,
) )
customer = customer_repository.get_by_id(session, business.customer_id) customer = customer_repository.get_by_id(session, business.customer_id)
out = _to_out((business, customer.company_name if customer else None, None, None)) out = _to_out((business, customer.company_name if customer else None, None, None))
@ -518,51 +507,28 @@ def update_business(
business_repository.replace_items( business_repository.replace_items(
session, business_id, _build_item_rows(business_id, payload.items) session, business_id, _build_item_rows(business_id, payload.items)
) )
# 方案:内容真的变了才动版本行(内容未变不新增版本,避免「只改备注」 # 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
# 也堆出一个方案版本);空数组=清空=把生效行置历史 # 前置场景(月结在 ④,快照固化后按同一哲学隔离)
old_allocations = business_repository.list_allocations(session, business_id) old_ids = {
new_rows = _build_allocation_rows( a.personnel_id for a in business_repository.list_allocations(session, business_id)
}
business_repository.replace_allocations(
session, session,
business_id, business_id,
payload.secondary_allocations, _build_allocation_rows(
old_ids={a.personnel_id for a in old_allocations}, session, business_id, payload.secondary_allocations, old_ids=old_ids
),
) )
if not payload.secondary_allocations: # 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回
business_repository.replace_allocations(session, business_id, []) # 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批
elif _plan_changed(old_allocations, new_rows):
business_repository.replace_allocations(session, business_id, new_rows)
# 汇总先重算:送审快照里的销售额/成本额必须反映本次编辑后的值,
# 放到提单之后会固化成上一版本的金额
_recalc_totals(session, business)
# 提单/撤单与方案同步生命周期:
# - 方案为空 → 仅撤回在途单(旧在途单挂着已不存在的方案继续走审批)
# - 送审内容与在途单一致 → 保留原单,不撤回不重建(避免反复保存
# 刷出「撤回+新建」的审批留痕噪音)
# - 其余 → 提新单(内含撤旧建新)
if payload.secondary_allocations: if payload.secondary_allocations:
snapshot = approval_service.allocation_content(session, business) or None
if not approval_service.pending_matches_snapshot(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
snapshot=snapshot,
):
try:
approval_service.submit_order( approval_service.submit_order(
session, session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION, order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id, ref_id=business_id,
refer_no=business.business_no, refer_no=business.business_no,
submitter=operator, submitter=operator,
snapshot=snapshot,
) )
except IntegrityError as exc:
# version_no 唯一键的并发冲突(取号是锁读后此项理论不可达,
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062
# 翻译成可重试的业务提示,其余完整性错误原样上抛
if _is_duplicate_entry(exc):
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
raise
else: else:
approval_service.withdraw_pending_orders( approval_service.withdraw_pending_orders(
session, session,
@ -570,6 +536,7 @@ def update_business(
ref_id=business_id, ref_id=business_id,
operator=operator, operator=operator,
) )
_recalc_totals(session, business)
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段 # 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名) # 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
out = _to_out((business, customer.company_name, owner_name, assistant_name)) out = _to_out((business, customer.company_name, owner_name, assistant_name))
@ -732,67 +699,6 @@ def change_status(
return out return out
@dataclass(frozen=True, slots=True)
class _BusinessOwnershipResolution:
"""业务归属变更的解析结果(新归属人行 + 变更前固化 ID)。"""
owner: Personnel | None
assistant: Personnel | None
old_owner_id: int | None
old_assistant_id: int | None
def _resolve_business_ownership(
session: Session, business: Business, payload: BusinessOwnershipIn
) -> _BusinessOwnershipResolution:
"""校验新归属人(须在职)并判定归属是否真的变化。
校验在审计上下文内执行 —— 失败也留痕。两者均未变化 → 400「归属未变化」。
"""
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
# 固化标量,新归属人名必须按新 ID 重取
owner = (
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
if payload.new_owner_personnel_id is not None
else None
)
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("归属未变化")
return _BusinessOwnershipResolution(
owner=owner,
assistant=assistant,
old_owner_id=business.owner_personnel_id,
old_assistant_id=business.assistant_personnel_id,
)
def _business_ownership_log(
business: Business,
payload: BusinessOwnershipIn,
resolution: _BusinessOwnershipResolution,
operator: User,
) -> OwnershipChangeLog:
"""归属变更留痕行(负责/协助两个字段的前后值都记)。"""
return OwnershipChangeLog(
owner_type=OWNER_TYPE_BUSINESS,
business_id=business.id,
old_owner_personnel_id=resolution.old_owner_id,
new_owner_personnel_id=payload.new_owner_personnel_id,
old_assistant_personnel_id=resolution.old_assistant_id,
new_assistant_personnel_id=payload.new_assistant_personnel_id,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
)
def change_ownership( def change_ownership(
session: Session, session: Session,
business_id: int, business_id: int,
@ -824,12 +730,41 @@ def change_ownership(
if row is None: if row is None:
raise NotFoundError("业务不存在") raise NotFoundError("业务不存在")
business, _, old_owner_name, old_assistant_name = row business, _, old_owner_name, old_assistant_name = row
resolution = _resolve_business_ownership(session, business, payload) # 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
# 固化标量,新归属人名必须按新 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.owner_personnel_id = payload.new_owner_personnel_id
business.assistant_personnel_id = payload.new_assistant_personnel_id business.assistant_personnel_id = payload.new_assistant_personnel_id
business_repository.add_ownership_log( business_repository.add_ownership_log(
session, _business_ownership_log(business, payload, resolution, operator) 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 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照 # 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
@ -837,19 +772,17 @@ def change_ownership(
( (
business, business,
row[1], row[1],
resolution.owner.personnel_name if resolution.owner is not None else old_owner_name, new_owner.personnel_name if new_owner is not None else old_owner_name,
resolution.assistant.personnel_name new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
if resolution.assistant is not None
else old_assistant_name,
) )
) )
session.commit() session.commit()
logger.info( logger.info(
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s", "调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
business_id, business_id,
resolution.old_owner_id, old_owner_id,
payload.new_owner_personnel_id, payload.new_owner_personnel_id,
resolution.old_assistant_id, old_assistant_id,
payload.new_assistant_personnel_id, payload.new_assistant_personnel_id,
) )
return out return out
@ -915,94 +848,6 @@ def list_status_logs(
) )
@dataclass(frozen=True, slots=True)
class _GrossProfitScan:
"""毛利润逐行扫描结果(行 DTO、类别聚合桶、成本缺失计数)。"""
rows: list[GrossProfitRowOut]
category_agg: dict[str, list[Decimal]]
missing_count: int
def _gross_profit_row(
item: BusinessItem, threshold: Fraction
) -> tuple[GrossProfitRowOut, tuple[Decimal, Decimal] | None]:
"""单行毛利润派生值(行 DTO + 类别聚合桶)。
聚合桶为 None 表示成本缺失行 —— 这类行不计入类别汇总,但仍要输出
派生列为 None 的行与缺失原因。阈值对两类行都要取(成本缺失也展示阈值)。
"""
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:
row_kwargs.update(
cost_amount=None,
profit_amount=None,
profit_rate=None,
converted_profit=None,
missing_reason="成本缺失,无法计算毛利润",
)
return GrossProfitRowOut(**row_kwargs), None
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,
)
return GrossProfitRowOut(**row_kwargs), (item.sale_amount, item.cost_amount)
def _scan_gross_profit_rows(
session: Session, business_id: int, threshold_of: Callable[[str], Fraction]
) -> _GrossProfitScan:
"""逐行扫描业务明细:出行 DTO、类别聚合桶与成本缺失计数。
类别顺序沿用明细行的首次出现顺序(不排序)—— 前端汇总展示依赖该顺序。
"""
rows: list[GrossProfitRowOut] = []
agg: dict[str, list[Decimal]] = {}
missing_count = 0
for item in business_repository.list_items(session, business_id):
row, bucket = _gross_profit_row(item, threshold_of(item.product_category))
rows.append(row)
if bucket is None:
missing_count += 1
continue
current = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
current[0] += bucket[0]
current[1] += bucket[1]
return _GrossProfitScan(rows=rows, category_agg=agg, missing_count=missing_count)
def _gross_profit_category(
category: str, sale: Decimal, cost: Decimal, threshold: Fraction
) -> GrossProfitCategoryOut:
"""类别汇总行(派生口径与行级一致:利润率/折算复用 formula 纯函数)。"""
profit = sale - cost
rate = profit_rate(profit, sale)
return GrossProfitCategoryOut(
product_category=category,
sale_amount=str(sale),
cost_amount=str(cost),
profit_amount=str(profit),
profit_rate=str(rate),
threshold=str(threshold),
converted_profit=str(converted_profit(sale, rate, threshold)),
)
def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut: def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut:
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。 """毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
@ -1021,15 +866,64 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
def threshold_of(category: str) -> Fraction: def threshold_of(category: str) -> Fraction:
return snapshot.fraction("product.threshold", product_category=category) return snapshot.fraction("product.threshold", product_category=category)
scan = _scan_gross_profit_rows(session, business_id, threshold_of) 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 = [ categories = [
_gross_profit_category(category, sale, cost, threshold_of(category)) GrossProfitCategoryOut(
for category, [sale, cost] in scan.category_agg.items() 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( return GrossProfitOut(
business_id=business_id, business_id=business_id,
as_of=as_of, as_of=as_of,
rows=scan.rows, rows=rows_out,
categories=categories, categories=categories,
missing_count=scan.missing_count, missing_count=missing_count,
) )

View File

@ -60,7 +60,7 @@ from app.services.param_service import parse_fraction, resolve_snapshot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记) # snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(未计入分成基数):绩效在月结时按团队与全公司分成计算" _NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -544,7 +544,7 @@ def _personal_rate(
if customer_type == "enterprise": if customer_type == "enterprise":
# 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线) # 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线)
rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000 rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000
return _RateDecision(rate, "企业户:不按回款天数,统一按固定比例计提", None, False) return _RateDecision(rate, "企业户固定计提比例", None, False)
template = role.calc_template if role else "TPL_SALESPERSON_DECAY" template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
if template == "TPL_MANAGER_SHARE": if template == "TPL_MANAGER_SHARE":
@ -554,7 +554,7 @@ def _personal_rate(
if snapshot.get("manager.personal.included") != "1": if snapshot.get("manager.personal.included") != "1":
return _RateDecision( return _RateDecision(
Decimal("0"), Decimal("0"),
"经理个人户:绩效按团队与全公司分成在月结时计算,单笔不计提", "经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
None, None,
True, True,
) )
@ -563,12 +563,12 @@ def _personal_rate(
floor = parse_fraction(snapshot.get("salesperson.rate.floor")) floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary"))) boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary) rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"经理个人户(已开启计入分成基数):按业务员递减,回款 {days} 天,起始 {start}‰" note = f"经理个人户(基数开关=1):按业务员递减 {start}‰ 起,{days} 天"
return _RateDecision(rate, note, None, False) return _RateDecision(rate, note, None, False)
if template == "TPL_INFO_SALES_FIXED": if template == "TPL_INFO_SALES_FIXED":
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线 # 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
rate = parse_fraction(snapshot.get("info.rate")) / 1000 rate = parse_fraction(snapshot.get("info.rate")) / 1000
return _RateDecision(rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", None, False) return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False)
if template == "TPL_SALESPERSON_DECAY": if template == "TPL_SALESPERSON_DECAY":
start = parse_fraction(snapshot.get("salesperson.rate.start")) start = parse_fraction(snapshot.get("salesperson.rate.start"))
slope = parse_fraction(snapshot.get("salesperson.rate.slope")) slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
@ -577,16 +577,9 @@ def _personal_rate(
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损) # 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary"))) boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary) rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = ( note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)"
f"回款 {days} 天,从起始 {start}‰ 按日递减"
f"(满 {boundary} 天后固定按 {floor}‰ 计算)"
)
return _RateDecision(rate, note, None, False) return _RateDecision(rate, note, None, False)
# 提示给模板中文名而非编码:业务人员在岗位角色页看到的是「营销经理·团队与全公司分成」 raise BadRequestError(f"公式模板 {template} 暂不支持核算")
from app.services.calc_templates import TEMPLATES
tpl_name = TEMPLATES[template].name if template in TEMPLATES else template
raise BadRequestError(f"「{tpl_name}」不支持单笔核算,请检查该人员的岗位设置")
def _role_of(session: Session, person): def _role_of(session: Session, person):

View File

@ -14,8 +14,6 @@
""" """
import logging import logging
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import Row from sqlalchemy import Row
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@ -33,13 +31,12 @@ from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageQuery, PageResult from app.core.response import PageQuery, PageResult
from app.models import Customer, OwnershipChangeLog, Personnel, User from app.models import Customer, OwnershipChangeLog, Personnel, User
from app.models.customer import OWNER_TYPE_CUSTOMER from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import customer_repository, personnel_repository from app.repositories import customer_rating_repository, customer_repository, personnel_repository
from app.schemas.customer import ( from app.schemas.customer import (
CustomerCreateIn, CustomerCreateIn,
CustomerOut, CustomerOut,
CustomerOwnershipIn, CustomerOwnershipIn,
CustomerQuery, CustomerQuery,
CustomerRatingIn,
CustomerUpdateIn, CustomerUpdateIn,
OwnershipChangeLogOut, OwnershipChangeLogOut,
) )
@ -49,8 +46,17 @@ from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut: def _to_out(
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" customer: Customer,
owner_name: str | None,
rating_total: object | None = None,
rating_grade: str | None = None,
) -> CustomerOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。
rating_total 由 LEFT JOIN 带出(Decimal 或 None),按分值红线转字符串
出参;新建客户/未评级客户不传,保持 null。
"""
return CustomerOut( return CustomerOut(
id=customer.id, id=customer.id,
company_name=customer.company_name, company_name=customer.company_name,
@ -63,8 +69,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
owner_personnel_id=customer.owner_personnel_id, owner_personnel_id=customer.owner_personnel_id,
owner_personnel_name=owner_name, owner_personnel_name=owner_name,
remark=customer.remark, remark=customer.remark,
rating_score=customer.rating_score, rating_total=str(rating_total) if rating_total is not None else None,
rating_time=customer.rating_time, rating_grade_name=rating_grade,
) )
@ -111,11 +117,29 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]: def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
"""分页查询客户(附归属人员姓名)。""" """分页查询客户(附归属人员姓名与评级快照;支持评级列排序)。"""
rows = customer_repository.list_customers(session, query) rows = customer_repository.list_customers(
total = customer_repository.count_customers(session, query) session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id,
order_field=query.order_field,
order_type=query.order_type,
)
total = customer_repository.count_customers(
session,
keyword=query.keyword,
customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id,
)
records = [
_to_out(customer, owner_name, rating_total, rating_grade)
for customer, owner_name, rating_total, rating_grade in rows
]
return PageResult[CustomerOut]( return PageResult[CustomerOut](
records=[_to_out(customer, owner_name) for customer, owner_name in rows], records=records,
total=total, total=total,
page_num=query.page_num, page_num=query.page_num,
page_size=query.page_size, page_size=query.page_size,
@ -130,8 +154,8 @@ def get_customer(session: Session, customer_id: int) -> CustomerOut:
row = customer_repository.get_with_owner_name(session, customer_id) row = customer_repository.get_with_owner_name(session, customer_id)
if row is None: if row is None:
raise NotFoundError("客户不存在") raise NotFoundError("客户不存在")
customer, owner_name = row customer, owner_name, rating_total, rating_grade = row
return _to_out(customer, owner_name) return _to_out(customer, owner_name, rating_total, rating_grade)
def create_customer( def create_customer(
@ -234,7 +258,7 @@ def update_customer(
_ensure_customer_type(payload.customer_type) _ensure_customer_type(payload.customer_type)
if row is None: if row is None:
raise NotFoundError("客户不存在") raise NotFoundError("客户不存在")
customer, owner_name = row customer, owner_name, rating_total, rating_grade = row
duplicate = customer_repository.get_by_company_owner( duplicate = customer_repository.get_by_company_owner(
session, payload.company_name, payload.owner_name session, payload.company_name, payload.owner_name
@ -253,7 +277,7 @@ def update_customer(
try: try:
# 响应在 commit 之前构造(同 user_service 顶部说明) # 响应在 commit 之前构造(同 user_service 顶部说明)
out = _to_out(customer, owner_name) out = _to_out(customer, owner_name, rating_total, rating_grade)
session.commit() session.commit()
except IntegrityError as exc: except IntegrityError as exc:
# 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写 # 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写
@ -267,53 +291,6 @@ def update_customer(
return out return out
def rate_customer(
session: Session,
customer_id: int,
payload: CustomerRatingIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CustomerOut:
"""保存客户评级(评级计算器「保存评分」)。
评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用
等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯
「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够
回答「这个分是什么时候打的」。等级由前端按当前映射实时算并展示。
:raises NotFoundError: 客户不存在
"""
# 客户名进审计摘要,预取放在审计上下文之前(同 change_ownership 口径)
row = customer_repository.get_with_owner_name(session, customer_id)
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}:{payload.score} 分",
request_data=payload.model_dump(),
),
meta=meta,
):
if row is None:
raise NotFoundError("客户不存在")
customer, owner_name = row
customer.rating_score = payload.score
# 评级时间取应用层本地时间(库列 TZ=Asia/Shanghai,与 TimestampMixin
# 的服务器默认值同一时区口径),不用数据库 NOW():行已在本会话
customer.rating_time = datetime.now()
# 响应在 commit 之前构造(同 user_service 顶部说明)
out = _to_out(customer, owner_name)
session.commit()
logger.info("保存客户评级 | customer_id=%s | score=%s", customer_id, payload.score)
return out
def delete_customer( def delete_customer(
session: Session, session: Session,
customer_id: int, customer_id: int,
@ -348,6 +325,11 @@ def delete_customer(
company_name = customer.company_name # 固化:对象随删除失效 company_name = customer.company_name # 固化:对象随删除失效
try: try:
# 评级行 FK RESTRICT:先删评级行再删客户主行(同事务一次 commit;
# 评级是附属快照,不构成「有关联业务」的删除阻碍)
rating = customer_rating_repository.get_by_customer_id(session, customer_id)
if rating is not None:
customer_rating_repository.delete(session, rating)
customer_repository.delete(session, customer) customer_repository.delete(session, customer)
session.commit() session.commit()
except IntegrityError as exc: except IntegrityError as exc:
@ -357,71 +339,6 @@ def delete_customer(
logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name) logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name)
@dataclass(frozen=True, slots=True)
class _CustomerOwnershipPrefetch:
"""客户归属变更的审计前只读预取(摘要要客户名与新旧归属人名)。"""
row: Row | None
new_person: Personnel | None
old_label: str
new_label: str
customer_name: str
def _prefetch_customer_ownership(
session: Session, customer_id: int, payload: CustomerOwnershipIn
) -> _CustomerOwnershipPrefetch:
"""审计上下文之前的只读预取(预取不写库,失败路径也要有可读摘要)。"""
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
)
return _CustomerOwnershipPrefetch(
row=row,
new_person=new_person,
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}",
)
def _validate_customer_ownership(
session: Session, customer: Customer, payload: CustomerOwnershipIn
) -> int | None:
"""校验新归属人(须在职)并判定归属是否变化,返回变更前归属人 ID。"""
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("归属未变化")
return old_owner_id
def _customer_ownership_log(
customer: Customer,
payload: CustomerOwnershipIn,
old_owner_id: int | None,
operator: User,
) -> OwnershipChangeLog:
"""客户归属变更留痕行。"""
return 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,
)
def change_ownership( def change_ownership(
session: Session, session: Session,
customer_id: int, customer_id: int,
@ -440,34 +357,62 @@ def change_ownership(
""" """
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在 # 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
# 上下文内进行,失败同样要留下可追溯的记录 # 上下文内进行,失败同样要留下可追溯的记录
prefetch = _prefetch_customer_ownership(session, customer_id, payload) 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( with audit_service.audit(
session, session,
operator=operator, operator=operator,
record=AuditRecord( record=AuditRecord(
module=MODULE_CUSTOMER, module=MODULE_CUSTOMER,
action=ACTION_UPDATE, action=ACTION_UPDATE,
summary=( summary=f"调整客户归属 {customer_name}:{old_label} → {new_label}",
f"调整客户归属 {prefetch.customer_name}:"
f"{prefetch.old_label} → {prefetch.new_label}"
),
request_data=payload.model_dump(exclude_none=True), request_data=payload.model_dump(exclude_none=True),
), ),
meta=meta, meta=meta,
): ):
if prefetch.row is None: if row is None:
raise NotFoundError("客户不存在") raise NotFoundError("客户不存在")
customer, _ = prefetch.row customer, _, rating_total, rating_grade = row
old_owner_id = _validate_customer_ownership(session, customer, payload) 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) # 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
customer.owner_personnel_id = payload.new_owner_personnel_id customer.owner_personnel_id = payload.new_owner_personnel_id
customer_repository.add_ownership_log( customer_repository.add_ownership_log(
session, _customer_ownership_log(customer, payload, old_owner_id, operator) 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 顶部说明);归属人名以新值为准 # 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
out = _to_out(customer, prefetch.new_person.personnel_name if prefetch.new_person else None) out = _to_out(
customer,
new_person.personnel_name if new_person else None,
rating_total,
rating_grade,
)
session.commit() session.commit()
logger.info( logger.info(
"调整客户归属 | customer_id=%s | old=%s | new=%s", "调整客户归属 | customer_id=%s | old=%s | new=%s",

View File

@ -1,4 +1,4 @@
"""工作台服务(M4 ⑧):区块实时聚合编排 + 最近动态分页。 """工作台服务(M4 ⑧):四区块实时聚合编排。
设计决策(2026-10-19): 设计决策(2026-10-19):
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合, - **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
@ -9,8 +9,6 @@
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以 - **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
``sync_available=false`` 显式降级,不返回伪造的 0 条 ``sync_available=false`` 显式降级,不返回伪造的 0 条
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照 - 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
""" """
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
@ -22,23 +20,22 @@ from app.core.exceptions import BadRequestError
from app.core.month import validate_month from app.core.month import validate_month
from app.repositories import dashboard_repository, operation_log_repository, user_repository from app.repositories import dashboard_repository, operation_log_repository, user_repository
from app.repositories.query_utils import LogFilters from app.repositories.query_utils import LogFilters
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
from app.services import approval_service from app.services import approval_service
# 工作台只展示摘要,完整列表跳转对应管理页 # 工作台只展示摘要,完整列表跳转对应管理页
_TODO_LIMIT = 5 _TODO_LIMIT = 5
_WARNING_LIMIT = 20 _WARNING_LIMIT = 20
_ACTIVITY_LIMIT = 20
_TREND_MONTHS = 6 _TREND_MONTHS = 6
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"} _ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict: def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
"""三区块一次性出数(前端一次请求渲染整页)。 """四区块一次性出数(前端一次请求渲染整页)。
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单); 待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
风险预警仍是全局经营视角,不按人裁剪。 风险预警仍是全局经营视角,不按人裁剪。
最近动态不在此返回 —— 它是分页列表,走 ``list_activities``。
""" """
resolved = _normalize_month(month) resolved = _normalize_month(month)
warning_params = _warning_params(session) warning_params = _warning_params(session)
@ -50,6 +47,7 @@ def get_dashboard(session: Session, month: str | None = None, *, current_user) -
"overview": _overview(session, resolved), "overview": _overview(session, resolved),
"todos": _todos(session, pending_statuses), "todos": _todos(session, pending_statuses),
"warnings": _warnings(session, warning_params), "warnings": _warnings(session, warning_params),
"activities": _activities(session),
} }
@ -114,8 +112,8 @@ def _overview(session: Session, month: str) -> dict:
for item in months for item in months
], ],
"note": ( "note": (
"绩效按台账归属月统计(二次分配按参与人拆分后求和,不重复不计漏);" "绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月"
"回款按到账月统计(含尚未核算的回款);不含经理团队分成与调整单" "(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单"
), ),
} }
@ -170,7 +168,9 @@ def _warning_params(session: Session) -> dict:
return { return {
"enabled": snapshot.get("warning.enabled") == "1", "enabled": snapshot.get("warning.enabled") == "1",
"receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))), "receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))),
"approval_timeout_days": int(parse_fraction(snapshot.get("warning.approval.timeout.days"))), "approval_timeout_days": int(
parse_fraction(snapshot.get("warning.approval.timeout.days"))
),
} }
@ -249,38 +249,26 @@ def _warnings(session: Session, params: dict) -> dict:
} }
def list_activities(session: Session, query: ActivityQuery) -> ActivityPageOut: def _activities(session: Session) -> list[dict]:
"""最近动态分页(FR-036):以操作日志为唯一来源(写操作即留痕)。 """最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。"""
logs = operation_log_repository.list_logs(
模块筛选项与列表同源返回(全量模块名,不随当前页变化), session,
前端模块下拉不必另取一次全量。 offset=0,
""" limit=_ACTIVITY_LIMIT,
repo_query = operation_log_repository.OperationLogListQuery(
filters=LogFilters(), filters=LogFilters(),
offset=query.offset,
limit=query.page_size,
module=query.module,
)
logs = operation_log_repository.list_logs(session, repo_query)
total = operation_log_repository.count_logs(session, repo_query)
return ActivityPageOut(
records=[
ActivityItem(
id=log.id,
module=log.module,
action=log.action,
summary=log.summary,
username=log.username,
success=log.success,
create_time=log.create_time,
) )
return [
{
"id": log.id,
"module": log.module,
"action": log.action,
"summary": log.summary,
"username": log.username,
"success": log.success,
"create_time": log.create_time,
}
for log in logs for log in logs
], ]
total=total,
page_num=query.page_num,
page_size=query.page_size,
modules=operation_log_repository.distinct_modules(session),
)
def _dec(value) -> Decimal | None: def _dec(value) -> Decimal | None:

View File

@ -22,15 +22,18 @@ from app.schemas.log import (
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]: def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。""" """分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
repo_query = operation_log_repository.OperationLogListQuery( filters = _filters_from(query)
filters=_filters_from(query), logs = operation_log_repository.list_logs(
session,
offset=query.offset, offset=query.offset,
limit=query.page_size, limit=query.page_size,
filters=filters,
module=query.module, module=query.module,
action=query.action, action=query.action,
) )
logs = operation_log_repository.list_logs(session, repo_query) total = operation_log_repository.count_logs(
total = operation_log_repository.count_logs(session, repo_query) session, filters=filters, module=query.module, action=query.action
)
return PageResult[OperationLogOut]( return PageResult[OperationLogOut](
records=[OperationLogOut.model_validate(log) for log in logs], records=[OperationLogOut.model_validate(log) for log in logs],
total=total, total=total,
@ -52,14 +55,15 @@ def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut:
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]: def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
"""分页查询登录日志。""" """分页查询登录日志。"""
repo_query = login_log_repository.LoginLogListQuery( filters = _filters_from(query)
filters=_filters_from(query), logs = login_log_repository.list_logs(
session,
offset=query.offset, offset=query.offset,
limit=query.page_size, limit=query.page_size,
filters=filters,
ip=query.ip, ip=query.ip,
) )
logs = login_log_repository.list_logs(session, repo_query) total = login_log_repository.count_logs(session, filters=filters, ip=query.ip)
total = login_log_repository.count_logs(session, repo_query)
return PageResult[LoginLogOut]( return PageResult[LoginLogOut](
records=[LoginLogOut.model_validate(log) for log in logs], records=[LoginLogOut.model_validate(log) for log in logs],
total=total, total=total,

View File

@ -3,10 +3,6 @@
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义 注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。 一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。 值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
`description` 会直接显示在「计提参数」页的参数名下方,**面向业务人员撰写**:
说清这个参数管什么、钱怎么变、什么时候该调它。不写英文枚举名、需求编号、
内部阶段号,也不写实现层面的精度约束(那些留在代码注释与测试里)。
""" """
from dataclasses import dataclass from dataclasses import dataclass
@ -47,7 +43,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="10", default_value="10",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。", description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
sort_order=1, sort_order=1,
), ),
ParamSpec( ParamSpec(
@ -61,7 +57,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="4/360", default_value="4/360",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。", description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
sort_order=2, sort_order=2,
), ),
ParamSpec( ParamSpec(
@ -75,7 +71,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="4", default_value="4",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。", description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
sort_order=3, sort_order=3,
), ),
ParamSpec( ParamSpec(
@ -89,7 +85,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="360", default_value="360",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。", description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
sort_order=4, sort_order=4,
), ),
ParamSpec( ParamSpec(
@ -103,7 +99,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="12.5", default_value="12.5",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。", description="∑本人带领业务员绩效 × 此比例(限本团队)",
sort_order=5, sort_order=5,
), ),
ParamSpec( ParamSpec(
@ -117,7 +113,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="5", default_value="5",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。", description="∑全部业务员绩效 × 此比例(全公司)",
sort_order=6, sort_order=6,
), ),
ParamSpec( ParamSpec(
@ -131,7 +127,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="3", default_value="3",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。", description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
sort_order=7, sort_order=7,
), ),
ParamSpec( ParamSpec(
@ -145,7 +141,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1", default_value="1",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。", description="信息员绩效 = 本人销售额 × 此比例",
sort_order=8, sort_order=8,
), ),
ParamSpec( ParamSpec(
@ -159,7 +155,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="10", default_value="10",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="预付款到账即按这个比例计提绩效,不必等到全额回款。", description="预付款统一按此比例计提",
sort_order=9, sort_order=9,
), ),
ParamSpec( ParamSpec(
@ -173,7 +169,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="80", default_value="80",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。", description="以物抵顶回款绩效按应发绩效的此比例折算",
sort_order=10, sort_order=10,
), ),
ParamSpec( ParamSpec(
@ -187,7 +183,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="60", default_value="60",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。", description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
sort_order=11, sort_order=11,
), ),
ParamSpec( ParamSpec(
@ -201,7 +197,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="FULL", default_value="FULL",
value_min=None, value_min=None,
value_max=None, value_max=None,
description="选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。", description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
sort_order=12, sort_order=12,
), ),
ParamSpec( ParamSpec(
@ -215,7 +211,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="30", default_value="30",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。", description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
sort_order=13, sort_order=13,
), ),
ParamSpec( ParamSpec(
@ -229,7 +225,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="INCLUDE", default_value="INCLUDE",
value_min=None, value_min=None,
value_max=None, value_max=None,
description="算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。", description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
sort_order=14, sort_order=14,
), ),
ParamSpec( ParamSpec(
@ -243,7 +239,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="18000", default_value="18000",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。", description="试用期年累计绩效超过此值部分年底一次性结算",
sort_order=15, sort_order=15,
), ),
ParamSpec( ParamSpec(
@ -257,7 +253,11 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1", default_value="1",
value_min=None, value_min=None,
value_max=None, value_max=None,
description="二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。", description=(
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
"1=提交后进入财务复核→领导审核审批流。"
"默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭"
),
sort_order=17, sort_order=17,
), ),
ParamSpec( ParamSpec(
@ -271,7 +271,11 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1", default_value="1",
value_min=None, value_min=None,
value_max=None, value_max=None,
description="月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。", description=(
"月度结算审批链开关(④ 月结提交时消费):"
"0=结算生成即生效,1=走财务复核→领导审核审批流。"
"默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭"
),
sort_order=18, sort_order=18,
), ),
ParamSpec( ParamSpec(
@ -285,7 +289,11 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="0", default_value="0",
value_min=None, value_min=None,
value_max=None, value_max=None,
description="调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。", description=(
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
"调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,"
"默认值业务未明确,暂按直接生效,业务可开启)"
),
sort_order=19, sort_order=19,
), ),
ParamSpec( ParamSpec(
@ -299,7 +307,11 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="0", default_value="0",
value_min=None, value_min=None,
value_max=None, value_max=None,
description="经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。", description=(
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
"0=不计入(默认,暂口径) 1=计入"
),
sort_order=20, sort_order=20,
), ),
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)===== # ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
@ -315,7 +327,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1", default_value="1",
value_min=None, value_min=None,
value_max=None, value_max=None,
description="工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。", description="工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
sort_order=21, sort_order=21,
), ),
ParamSpec( ParamSpec(
@ -329,7 +341,9 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="90", default_value="90",
value_min="1", value_min="1",
value_max=None, value_max=None,
description="发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。", description=(
"发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"
),
sort_order=22, sort_order=22,
), ),
ParamSpec( ParamSpec(
@ -343,7 +357,9 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="3", default_value="3",
value_min="1", value_min="1",
value_max=None, value_max=None,
description="审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。", description=(
"待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"
),
sort_order=23, sort_order=23,
), ),
] ]

View File

@ -6,7 +6,7 @@ ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日
""" """
import logging import logging
from collections.abc import Callable, Iterable from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
@ -44,7 +44,7 @@ from app.schemas.param import (
ParamPreviewOut, ParamPreviewOut,
ParamUsedOut, ParamUsedOut,
) )
from app.services import audit_service, calc_templates, formula from app.services import audit_service, formula
from app.services.audit_service import AuditRecord, RequestMeta from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import to_decimal from app.services.formula import to_decimal
from app.services.no_generator import next_no from app.services.no_generator import next_no
@ -379,8 +379,16 @@ def get_history(
definition = param_repository.get_definition_by_code(session, param_code) definition = param_repository.get_definition_by_code(session, param_code)
if definition is None: if definition is None:
raise NotFoundError("参数不存在") raise NotFoundError("参数不存在")
rows = param_repository.list_history(session, definition.id, query) rows = param_repository.list_history(
total = param_repository.count_history(session, definition.id, query) session,
definition.id,
offset=query.offset,
limit=query.page_size,
dimension_value=query.dimension_value,
)
total = param_repository.count_history(
session, definition.id, dimension_value=query.dimension_value
)
return PageResult[ParamHistoryOut]( return PageResult[ParamHistoryOut](
records=[ records=[
ParamHistoryOut( ParamHistoryOut(
@ -402,8 +410,16 @@ def get_history(
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]: def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
"""变更批次分页(按创建时间倒序)。""" """变更批次分页(按创建时间倒序)。"""
batches = param_repository.list_batches(session, query) batches = param_repository.list_batches(
total = param_repository.count_batches(session, query) session,
offset=query.offset,
limit=query.page_size,
start_date=query.start_date,
end_date=query.end_date,
)
total = param_repository.count_batches(
session, start_date=query.start_date, end_date=query.end_date
)
return PageResult[ParamBatchOut]( return PageResult[ParamBatchOut](
records=[ records=[
ParamBatchOut( ParamBatchOut(
@ -493,89 +509,42 @@ def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDi
return out return out
@dataclass(frozen=True, slots=True) def _preview_rate(
class _PreviewContext: session: Session,
"""预览上下文(会话、入参、参数取值闭包)。 payload: ParamPreviewIn,
snapshot: ParamSnapshot,
``take`` 闭包同时负责取数与记录「用了哪些参数」——**调用顺序即 days: int,
``params_used`` 的展示顺序**,拆分时不得调换各段的先后。 take,
""" ) -> tuple[Fraction, str, Decimal | None]:
session: Session
payload: ParamPreviewIn
take: Callable[[str], Fraction]
@dataclass(frozen=True, slots=True)
class _PreviewRateDecision:
"""计提比例判定(回款天数、纯比例、人读说明、绩效基数覆盖值)。"""
days: int
rate: Fraction
rate_text: str
base_override: Decimal | None
@dataclass(frozen=True, slots=True)
class _PreviewEarning:
"""折算与绩效(毛利润率、折算额、绩效额)。"""
profit_rate: Fraction
converted: Decimal
performance: Decimal
@dataclass(frozen=True, slots=True)
class _PreviewBarter:
"""以物抵顶(是否适用、折算后绩效、计发基数)。"""
applied: bool
after_barter: Decimal | None
base_amount: Decimal
@dataclass(frozen=True, slots=True)
class _PreviewPayment:
"""支付环节(起付判定、R 权重、权重、应付金额)。"""
start_ok: bool
weight_r: int
weight: Fraction
payable: Decimal
def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decimal | None]:
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。 """按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按 基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
「本人销售额」计提),预览层直接采用并跳过折算与阈值。 「本人销售额」计提),预览层直接采用并跳过折算与阈值。
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。 聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
""" """
payload = ctx.payload
take = ctx.take
if payload.customer_type == "enterprise": if payload.customer_type == "enterprise":
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount # 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线) # 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
rate = take("enterprise.rate") / 1000 rate = take("enterprise.rate") / 1000
return rate, "企业户:不按回款天数,统一按固定比例计提", None return rate, "企业户固定计提比例", None
role_code = payload.business_role role_code = payload.business_role
template = "TPL_SALESPERSON_DECAY" template = "TPL_SALESPERSON_DECAY"
if role_code: if role_code:
role = business_role_repository.get_by_code(ctx.session, role_code) role = business_role_repository.get_by_code(session, role_code)
if role is None: if role is None:
raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认") raise BadRequestError(f"岗位 {role_code!r} 不存在")
template = role.calc_template template = role.calc_template
if template == "TPL_MANAGER_SHARE": if template == "TPL_MANAGER_SHARE":
raise BadRequestError( raise BadRequestError(
"营销经理的绩效按团队与全公司分成计算,不单笔试算;请到月度结算里查看" "营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
) )
if template == "TPL_INFO_SALES_FIXED": if template == "TPL_INFO_SALES_FIXED":
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍) # ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
rate = take("info.rate") / 1000 rate = take("info.rate") / 1000
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数 # 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
return rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", Decimal(payload.revenue) return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
if template == "TPL_SALESPERSON_DECAY": if template == "TPL_SALESPERSON_DECAY":
start = take("salesperson.rate.start") start = take("salesperson.rate.start")
slope = take("salesperson.rate.slope") slope = take("salesperson.rate.slope")
@ -584,84 +553,12 @@ def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decim
rate = formula.salesperson_rate( rate = formula.salesperson_rate(
days, start=start, slope=slope, floor=floor, boundary_days=boundary days, start=start, slope=slope, floor=floor, boundary_days=boundary
) )
# 说明写成业务话:说清这笔为什么是这个比例、什么时候不再降
return ( return (
rate, rate,
f"回款 {days} 天,从起始 {to_decimal(start)}‰ 按日递减" f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
f"(满 {boundary} 天后固定按 {to_decimal(floor)}‰ 计算)",
None, None,
) )
# 提示里给模板中文名而不是编码:编码是内部标识,业务人员在岗位角色页看不到它 raise BadRequestError(f"公式模板 {template} 暂不支持预览")
tpl_name = calc_templates.TEMPLATES[template].name if template in calc_templates.TEMPLATES else template
raise BadRequestError(
f"「{tpl_name}」不支持单笔试算,请在岗位角色里改用业务员递减或信息员固定计提"
)
def _preview_rate_decision(ctx: _PreviewContext) -> _PreviewRateDecision:
"""产品类别校验 + 回款天数 + 计提比例判定。"""
payload = ctx.payload
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
# 提示里用中文类别名:前端下拉显示的是「市政水利」,报错却回英文编码会让人对不上
raise BadRequestError(
f"产品类别「{payload.product_category}」不存在,可选:市政水利、轨道交通"
)
days = formula.payment_days(payload.ship_date, payload.receipt_date)
rate, rate_text, base_override = _preview_rate(ctx, days)
return _PreviewRateDecision(
days=days, rate=rate, rate_text=rate_text, base_override=base_override
)
def _preview_earning(ctx: _PreviewContext, decision: _PreviewRateDecision) -> _PreviewEarning:
"""毛利润率 → 折算额(阈值)→ 绩效额(比例)。"""
payload = ctx.payload
revenue = Decimal(payload.revenue)
profit = Decimal(payload.profit)
profit_rate = formula.profit_rate(profit, revenue)
if decision.base_override is not None:
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
converted = decision.base_override
else:
threshold = ctx.take("product.threshold")
converted = formula.converted_profit(revenue, profit_rate, threshold)
performance = formula.performance_amount(converted, decision.rate)
return _PreviewEarning(profit_rate=profit_rate, converted=converted, performance=performance)
def _preview_barter(ctx: _PreviewContext, performance: Decimal) -> _PreviewBarter:
"""以物抵顶折算(按应发绩效的固定比例折算,比例来自参数)。"""
applied = ctx.payload.barter
after_barter: Decimal | None = None
if applied:
discount = ctx.take("barter.discount")
after_barter = formula.apply_barter(performance, discount)
base_amount = after_barter if applied else performance
return _PreviewBarter(applied=applied, after_barter=after_barter, base_amount=base_amount)
def _preview_payment(
ctx: _PreviewContext, received: Decimal, revenue: Decimal, base_amount: Decimal
) -> _PreviewPayment:
"""起付比例判定 + R 权重 + 应付金额。"""
start_ratio = ctx.take("payment.start.ratio")
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
weight_r = formula.clamp_weight_r(received, revenue)
weight = formula.payment_weight(weight_r)
payable = to_decimal(Fraction(base_amount) * weight)
return _PreviewPayment(start_ok=start_ok, weight_r=weight_r, weight=weight, payable=payable)
def _preview_probation_note(ctx: _PreviewContext) -> str | None:
"""试用期说明(绩效照算不发放,年累计超阈值的部分年底一次性结算)。"""
if not ctx.payload.probation:
return None
annual = ctx.take("probation.annual.threshold")
return (
f"试用期人员:绩效照常计算但不发放;年内累计超过 {to_decimal(annual)} 元的部分,"
"年底一次性结清。"
)
def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut: def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
@ -669,6 +566,7 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
# 参数按回款到账日取值(对照表 #11) # 参数按回款到账日取值(对照表 #11)
snapshot = resolve_snapshot(session, payload.receipt_date) snapshot = resolve_snapshot(session, payload.receipt_date)
revenue = Decimal(payload.revenue) revenue = Decimal(payload.revenue)
profit = Decimal(payload.profit)
received = Decimal(payload.received_amount) received = Decimal(payload.received_amount)
used: list[ParamUsedOut] = [] used: list[ParamUsedOut] = []
@ -691,29 +589,55 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
) )
return parse_fraction(raw) return parse_fraction(raw)
ctx = _PreviewContext(session=session, payload=payload, take=take) if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
# 各段顺序固定 —— take 的调用顺序即 params_used 的展示顺序 raise BadRequestError(
decision = _preview_rate_decision(ctx) f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit"
earning = _preview_earning(ctx, decision) )
barter = _preview_barter(ctx, earning.performance)
payment = _preview_payment(ctx, received, revenue, barter.base_amount) days = formula.payment_days(payload.ship_date, payload.receipt_date)
probation_note = _preview_probation_note(ctx) rate, rate_text, base_override = _preview_rate(session, payload, snapshot, days, take)
profit_rate = formula.profit_rate(profit, revenue)
if base_override is not None:
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
converted = base_override
else:
threshold = take("product.threshold")
converted = formula.converted_profit(revenue, profit_rate, threshold)
performance = formula.performance_amount(converted, rate)
barter_applied = payload.barter
after_barter: Decimal | None = None
if barter_applied:
discount = take("barter.discount")
after_barter = formula.apply_barter(performance, discount)
start_ratio = take("payment.start.ratio")
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
weight_r = formula.clamp_weight_r(received, revenue)
weight = formula.payment_weight(weight_r)
base_amount = after_barter if barter_applied else performance
payable = to_decimal(Fraction(base_amount) * weight)
probation_note: str | None = None
if payload.probation:
annual = take("probation.annual.threshold")
probation_note = f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算"
return ParamPreviewOut( return ParamPreviewOut(
payment_days=decision.days, payment_days=days,
rate=str(decision.rate), rate=str(rate),
rate_text=decision.rate_text, rate_text=rate_text,
profit_rate=str(earning.profit_rate), profit_rate=str(profit_rate),
converted_profit=str(earning.converted), converted_profit=str(converted),
performance_amount=str(earning.performance), performance_amount=str(performance),
barter_applied=barter.applied, barter_applied=barter_applied,
performance_after_barter=( performance_after_barter=str(after_barter) if after_barter is not None else None,
str(barter.after_barter) if barter.after_barter is not None else None start_payment_ok=start_ok,
), payment_weight_r=weight_r,
start_payment_ok=payment.start_ok, payment_weight=str(weight),
payment_weight_r=payment.weight_r, payable_amount=str(payable),
payment_weight=str(payment.weight),
payable_amount=str(payment.payable),
probation_note=probation_note, probation_note=probation_note,
params_used=used, params_used=used,
) )

View File

@ -70,8 +70,20 @@ def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personn
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]: def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
"""分页查询人员(含离职)。""" """分页查询人员(含离职)。"""
rows = personnel_repository.list_personnel(session, query) rows = personnel_repository.list_personnel(
total = personnel_repository.count_personnel(session, query) 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]( return PageResult[PersonnelOut](
records=[ records=[
_to_out(personnel, role_name, manager_name) _to_out(personnel, role_name, manager_name)

View File

@ -99,8 +99,20 @@ def _raise_if_duplicate(exc: IntegrityError) -> None:
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]: def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
"""分页查询产品(含停用)。""" """分页查询产品(含停用)。"""
products = product_repository.list_products(session, query) products = product_repository.list_products(
total = product_repository.count_products(session, query) 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]( return PageResult[ProductOut](
records=[_to_out(product) for product in products], records=[_to_out(product) for product in products],
total=total, total=total,

View File

@ -1,12 +1,14 @@
"""客户评级等级配置业务编排。 """客户评级业务编排(等级映射配置 + 每客户评级落库)。
评级计算器定位(对照表 #25):仅打分工具、不影响绩效金额,评分记录不落库 评级定位(对照表 #25):**不影响绩效金额**。自 2026-10-19 起评分每客户
——本表承载的等级映射是**判定表**,判定规则为按 sort_order 升序取首个 落库(customer_ratings,一行一客户快照),等级映射仍是本服务承载的
min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反会算错等级): **判定表**,判定规则为按 sort_order 升序取首个 min_score ≤ 总分的等级,
max_score 仅展示。两条硬约束(违反会算错等级):
- 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串 - 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串
到 Decimal 的转换与落库;0≤min≤max≤100 的区间校验在**审计上下文内** 到 Decimal 的转换与落库;值域校验(等级区间 0≤min≤max≤100、单项评分
完成,失败的 400 同样留痕(2026-10-13 定稿口径) 0≤分≤20)在**审计上下文内**完成,失败的 400 同样留痕(2026-10-13 定稿
口径)
- 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表 - 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表
失效。判定为**闭区间两两相交**:任一其他等级满足 失效。判定为**闭区间两两相交**:任一其他等级满足
``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。 ``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。
@ -14,9 +16,9 @@ min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反
相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order 相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order
靠前的等级,相邻无歧义 靠前的等级,相邻无歧义
删除允许清空:等级表无下游引用(评分记录不落库,判定只读本表现值、不 删除允许清空:等级表对客户评级**无外键引用**(评级快照固化等级名、不
回溯历史快照),物理删除无悬空风险;全部删空后计算器只显示总分、不出 回指本表),物理删除无悬空风险,也不伤及已评级客户的历史结论;全部删空
等级(前端已对应提示),故不强制保留任何一条。 后新评级只记总分、等级为 NULL(前端已对应提示),故不强制保留任何一条。
""" """
import logging import logging
@ -33,9 +35,15 @@ from app.core.constants import (
) )
from app.core.db_errors import is_duplicate_entry from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.models import CustomerRatingGrade, User from app.models import CustomerRating, CustomerRatingGrade, User
from app.repositories import rating_repository from app.repositories import customer_rating_repository, customer_repository, rating_repository
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn from app.schemas.rating import (
CustomerRatingOut,
CustomerRatingSaveIn,
RatingGradeCreateIn,
RatingGradeOut,
RatingGradeUpdateIn,
)
from app.services import audit_service from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta from app.services.audit_service import AuditRecord, RequestMeta
@ -44,6 +52,8 @@ logger = logging.getLogger(__name__)
# 分值域上下界(Decimal 常量,禁用 float 参与比较) # 分值域上下界(Decimal 常量,禁用 float 参与比较)
_SCORE_FLOOR = Decimal("0") _SCORE_FLOOR = Decimal("0")
_SCORE_CEIL = Decimal("100") _SCORE_CEIL = Decimal("100")
# 单项评分上界(五项各 0-20,总分 0-100)
_ITEM_SCORE_CEIL = Decimal("20")
def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut: def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut:
@ -252,10 +262,11 @@ def delete_grade(
) -> None: ) -> None:
"""物理删除评级等级。 """物理删除评级等级。
等级表无下游引用 —— 评级是纯计算器不落评分记录(对照表 #25),判定 等级表对客户评级**无外键引用** —— 评级行快照固化等级名(见
只读本表现值、不回溯历史快照,故可物理删除且无需锁行防并发引用。 ``models/customer_rating.py`` 的论证),判定只读本表现值,故可物理删除
且无需锁行防并发引用;删除等级不改变已评级客户的历史结论。
**允许清空**:全部删空后计算器只显示总分、不出等级(前端已对应提示), **允许清空**:全部删空后新评级只记总分、等级为 NULL(前端已对应提示),
不强制保留任何一条。 不强制保留任何一条。
:raises NotFoundError: 评级等级不存在 :raises NotFoundError: 评级等级不存在
@ -278,3 +289,164 @@ def delete_grade(
rating_repository.delete(session, grade) rating_repository.delete(session, grade)
session.commit() session.commit()
logger.info("删除评级等级 | grade_name=%s", grade_name) logger.info("删除评级等级 | grade_name=%s", grade_name)
# ==================== 客户评级(每客户一行,落库) ====================
def resolve_grade(
grades: list[CustomerRatingGrade], total_score: Decimal
) -> CustomerRatingGrade | None:
"""等级判定纯函数:按 sort_order 升序取首个 min_score ≤ 总分的等级。
与前端打分卡的实时判定同规则同序(前端按数值比较,此处 Decimal 比较
——同一规则的两端实现,测试锁定边界行为一致)。无命中(总分低于全部
下限或等级表为空)返回 None,由调用方决定展示与落库(快照记 NULL)。
"""
# id 兜底让同序等级次序稳定(纯函数也接受未持久化对象:id 可能为 None)
for grade in sorted(grades, key=lambda g: (g.sort_order, g.id or 0)):
if grade.min_score <= total_score:
return grade
return None
def _to_rating_out(rating: CustomerRating) -> CustomerRatingOut:
"""客户评级行转响应模型。分值 Decimal → str(分值红线出参口径)。"""
return CustomerRatingOut(
customer_id=rating.customer_id,
score_ship_amount=str(rating.score_ship_amount),
score_receipt_rate=str(rating.score_receipt_rate),
score_profit_rate=str(rating.score_profit_rate),
score_contract_cooperation=str(rating.score_contract_cooperation),
score_site_construction=str(rating.score_site_construction),
total_score=str(rating.total_score),
grade_name=rating.grade_name,
remark=rating.remark,
scorer_name=rating.scorer_name,
create_time=rating.create_time,
update_time=rating.update_time,
)
def _ensure_item_scores(scores: list[Decimal]) -> None:
"""单项评分值域:0 ≤ 分 ≤ 20(总分由五项求和自然落在 0-100)。
须在审计上下文内调用,失败也要留痕(同 _ensure_score_range 口径)。
"""
for score in scores:
if not (_SCORE_FLOOR <= score <= _ITEM_SCORE_CEIL):
raise BadRequestError("单项评分须在 0~20 之间")
def get_customer_rating(session: Session, customer_id: int) -> CustomerRatingOut | None:
"""查某客户当前评级;未评级返回 None(接口信封 data 为 null)。
「未评级」是正常态而非错误,不用 404 表达;客户不存在才是 NotFound。
:raises NotFoundError: 客户不存在
"""
customer = customer_repository.get_by_id(session, customer_id)
if customer is None:
raise NotFoundError("客户不存在")
rating = customer_rating_repository.get_by_customer_id(session, customer_id)
return _to_rating_out(rating) if rating is not None else None
def save_customer_rating(
session: Session,
customer_id: int,
payload: CustomerRatingSaveIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CustomerRatingOut:
"""保存客户评级(upsert:首次创建,再次保存整体覆盖)。
总分与等级**服务端重算**:总分按五项求和(不采信前端回传),等级按
保存时点的等级映射判定后**快照固化**进评级行 —— 等级表后续增删改
不影响该行(见 ``models/customer_rating.py``)。
:raises NotFoundError: 客户不存在
:raises BadRequestError: 单项评分须在 0~20 之间(审计上下文内校验,留痕)
"""
# 只读预取在审计上下文之前:客户不存在(NotFound 失败)也能留下可读记录,
# 摘要需要客户名
customer = customer_repository.get_by_id(session, customer_id)
existing = customer_rating_repository.get_by_customer_id(session, customer_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_RATING,
action=ACTION_CREATE if existing is None else ACTION_UPDATE,
summary=f"评定客户「{customer.company_name if customer else f'#{customer_id}'}」评级",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 值域校验在审计上下文内:分值非法的 400 也要留痕(同等级配置口径)
item_scores = [
_to_decimal(payload.score_ship_amount),
_to_decimal(payload.score_receipt_rate),
_to_decimal(payload.score_profit_rate),
_to_decimal(payload.score_contract_cooperation),
_to_decimal(payload.score_site_construction),
]
_ensure_item_scores(item_scores)
if customer is None:
raise NotFoundError("客户不存在")
total_score = sum(item_scores, _SCORE_FLOOR)
grade = resolve_grade(rating_repository.list_all(session), total_score)
def _apply(row: CustomerRating) -> None:
"""把五项分值/总分/等级快照写入评级行(创建与并发兜底共用)。"""
(
row.score_ship_amount,
row.score_receipt_rate,
row.score_profit_rate,
row.score_contract_cooperation,
row.score_site_construction,
) = item_scores
row.total_score = total_score
row.grade_name = grade.grade_name if grade is not None else None
row.remark = payload.remark
row.scorer_id = operator.id
row.scorer_name = operator.username
try:
if existing is None:
rating = CustomerRating(
customer_id=customer_id,
scorer_id=operator.id,
scorer_name=operator.username,
)
_apply(rating)
customer_rating_repository.create(session, rating)
else:
_apply(existing)
rating = existing
out = _to_rating_out(rating)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发为同一客户首次评级会撞
# uk_customer_ratings_customer_id,回滚后转为更新兜底
session.rollback()
if not is_duplicate_entry(exc):
raise
raced = customer_rating_repository.get_by_customer_id(session, customer_id)
if raced is None:
# 1062 但行不在:数据态异常,交全局 IntegrityError 兜底
raise
_apply(raced)
rating = raced
out = _to_rating_out(rating)
session.commit()
logger.info(
"保存客户评级 | customer_id=%s | total=%s | grade=%s",
customer_id,
out.total_score,
out.grade_name,
)
return out

View File

@ -23,7 +23,6 @@
""" """
import logging import logging
from dataclasses import dataclass
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
@ -40,7 +39,7 @@ from app.core.constants import (
from app.core.db_errors import is_duplicate_entry from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult from app.core.response import PageResult
from app.models import Business, Receipt from app.models import Receipt
from app.models.business import BUSINESS_STATUS_ACTIVE from app.models.business import BUSINESS_STATUS_ACTIVE
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
from app.repositories import business_repository, receipt_repository from app.repositories import business_repository, receipt_repository
@ -130,8 +129,24 @@ def _ensure_business_writable(session: Session, business_id: int, action: str):
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]: def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。""" """分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
rows = receipt_repository.list_receipts(session, query) rows = receipt_repository.list_receipts(
total = receipt_repository.count_receipts(session, query) 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]( return PageResult[ReceiptOut](
records=[_to_out(row) for row in rows], records=[_to_out(row) for row in rows],
total=total, total=total,
@ -151,57 +166,6 @@ def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
return _to_out(row) return _to_out(row)
@dataclass(frozen=True, slots=True)
class _CreatedReceipt:
"""建行结果(出参 + 固化的 ID/批次号,commit 后不再触碰 ORM 属性)。"""
out: ReceiptOut
receipt_id: int
batch_no: str
def _create_receipt_once(
session: Session,
payload: ReceiptCreateIn,
business: Business | None,
batch_no: str,
) -> _CreatedReceipt:
"""一次建行尝试:准入校验 → 建行 → 重算合计 → 出参。
**不 commit**:提交与回滚由调用方(重试循环)统一控制,保证「一次尝试
= 一个事务边界」。
"""
if business is None:
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新增回款")
# 月结锁定拦截(2026-10-15 补充需求):比需求原文「提示」取硬,
# 一期安全侧(策略点注释);未指定日期时以当天月近似
from app.services import settlement_service
_lock_month = (payload.receipt_date or date.today()).strftime("%Y-%m")
settlement_service.ensure_month_not_locked(session, _lock_month)
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))
return _CreatedReceipt(out=out, receipt_id=receipt.id, batch_no=batch_no)
def create_receipt( def create_receipt(
session: Session, session: Session,
payload: ReceiptCreateIn, payload: ReceiptCreateIn,
@ -242,7 +206,35 @@ def create_receipt(
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归 # 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
for attempt in range(_NO_RETRY + 1): for attempt in range(_NO_RETRY + 1):
try: try:
created = _create_receipt_once(session, payload, business, batch_no) if business is None:
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新增回款")
# 月结锁定拦截(2026-10-15 补充需求):比需求原文「提示」取硬,
# 一期安全侧(策略点注释);未指定日期时以当天月近似
from app.services import settlement_service
_lock_month = (payload.receipt_date or date.today()).strftime("%Y-%m")
settlement_service.ensure_month_not_locked(session, _lock_month)
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() session.commit()
break break
except IntegrityError as exc: except IntegrityError as exc:
@ -257,11 +249,11 @@ def create_receipt(
logger.info( logger.info(
"新增回款 | receipt_id=%s | no=%s | business_id=%s", "新增回款 | receipt_id=%s | no=%s | business_id=%s",
created.receipt_id, new_id,
created.batch_no, new_no,
payload.business_id, payload.business_id,
) )
return created.out return out
def update_receipt( def update_receipt(

View File

@ -36,6 +36,7 @@ _DATE_FORMAT = "YYYY-MM-DD"
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t") _FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class _Column: class _Column:
"""列定义:key 取行 dict 字段,kind 决定单元格类型与格式。""" """列定义:key 取行 dict 字段,kind 决定单元格类型与格式。"""
@ -402,13 +403,7 @@ def _write_department_document(
company_share_count = int(company.get("company_share_count") or 0) company_share_count = int(company.get("company_share_count") or 0)
_write_document_header( _write_document_header(
sheet, sheet, len(_DEPARTMENT_WIDTHS), company_name, title, filters, template_version, operator_name
len(_DEPARTMENT_WIDTHS),
company_name,
title,
filters,
template_version,
operator_name,
) )
_write_block( _write_block(
@ -444,12 +439,16 @@ def _write_department_document(
"manager_name": "合计", "manager_name": "合计",
"member_count": sum(team["member_count"] for team in teams), "member_count": sum(team["member_count"] for team in teams),
"team_base": _sum_amount(team["team_base"] for team in teams), "team_base": _sum_amount(team["team_base"] for team in teams),
"team_share_amount": _sum_amount(team["team_share_amount"] for team in teams), "team_share_amount": _sum_amount(
team["team_share_amount"] for team in teams
),
"team_subtotal": _sum_amount(team["team_subtotal"] for team in teams), "team_subtotal": _sum_amount(team["team_subtotal"] for team in teams),
# 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导) # 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导)
"frozen_amount": ( "frozen_amount": (
_sum_amount( _sum_amount(
team["frozen_amount"] for team in teams if team["frozen_amount"] is not None team["frozen_amount"]
for team in teams
if team["frozen_amount"] is not None
) )
if any(team["frozen_amount"] is not None for team in teams) if any(team["frozen_amount"] is not None for team in teams)
else None else None

View File

@ -103,7 +103,10 @@ def _person_row(row: PerformanceLedger, role_names: dict[str, str]) -> dict:
def _role_names(session) -> dict[str, str]: def _role_names(session) -> dict[str, str]:
"""岗位编码 → 名称一次查询(明细与部门行共用,避免 N+1)。""" """岗位编码 → 名称一次查询(明细与部门行共用,避免 N+1)。"""
return {r.role_code: r.role_name for r in session.execute(select(BusinessRole)).scalars()} return {
r.role_code: r.role_name
for r in session.execute(select(BusinessRole)).scalars()
}
def _dec(value) -> Decimal | None: def _dec(value) -> Decimal | None:
@ -175,7 +178,11 @@ def department_summary(session, query) -> dict:
extra_ids = { extra_ids = {
target target
for pid in adjustments for pid in adjustments
if (target := _adjustment_target(pid, member_map, manager_of, manager_role, include_mp)) if (
target := _adjustment_target(
pid, member_map, manager_of, manager_role, include_mp
)
)
is not None is not None
} - set(manager_ids) } - set(manager_ids)
if extra_ids: if extra_ids:
@ -186,7 +193,9 @@ def department_summary(session, query) -> dict:
for team in teams_all for team in teams_all
if query.manager_id is None or team["manager_id"] == query.manager_id if query.manager_id is None or team["manager_id"] == query.manager_id
] ]
unassigned_all = _unassigned_rows(member_map, manager_of, manager_role, adjustments, include_mp) unassigned_all = _unassigned_rows(
member_map, manager_of, manager_role, adjustments, include_mp
)
# 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关 # 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关
unassigned = [] if query.manager_id is not None else unassigned_all unassigned = [] if query.manager_id is not None else unassigned_all
company_share_amount = _company_share(company_base, share.company_pct) company_share_amount = _company_share(company_base, share.company_pct)
@ -326,7 +335,9 @@ def _reconciliation(
(m["adjust_net"] for team in teams for m in team["members"]), Decimal("0") (m["adjust_net"] for team in teams for m in team["members"]), Decimal("0")
) + sum((row["adjust_net"] for row in unassigned), Decimal("0")) ) + sum((row["adjust_net"] for row in unassigned), Decimal("0"))
team_base_total = sum((team["team_base"] for team in teams), Decimal("0")) team_base_total = sum((team["team_base"] for team in teams), Decimal("0"))
unassigned_performance = sum((row["performance_sum"] for row in unassigned), Decimal("0")) unassigned_performance = sum(
(row["performance_sum"] for row in unassigned), Decimal("0")
)
payable_total = ( payable_total = (
team_member_payable + unassigned_payable + aggregate_payable_total + adjust_net_total team_member_payable + unassigned_payable + aggregate_payable_total + adjust_net_total
) )
@ -367,7 +378,9 @@ def _reconciliation(
def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal: def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal:
"""12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。""" """12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。"""
return manager_team_share(team_base, Decimal("0"), team_pct=team_pct, company_pct=Fraction(0)) return manager_team_share(
team_base, Decimal("0"), team_pct=team_pct, company_pct=Fraction(0)
)
def _company_share(company_base: Decimal, company_pct: Fraction) -> Decimal: def _company_share(company_base: Decimal, company_pct: Fraction) -> Decimal:
@ -470,8 +483,12 @@ def _team_relations(
referenced = {p.manager_id for p in people if p.manager_id is not None} referenced = {p.manager_id for p in people if p.manager_id is not None}
missing = referenced - seed missing = referenced - seed
if missing: if missing:
people.extend(session.execute(select(Personnel).where(Personnel.id.in_(missing))).scalars()) people.extend(
role_by_id = {r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()} session.execute(select(Personnel).where(Personnel.id.in_(missing))).scalars()
)
role_by_id = {
r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()
}
manager_of = {p.id: p.manager_id for p in people} manager_of = {p.id: p.manager_id for p in people}
manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people} manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people}
person_info = { person_info = {
@ -602,7 +619,9 @@ def _member_rows(
) )
) )
personal_in_team = ( personal_in_team = (
pid == manager_id and include_manager_personal and member["has_manager_personal"] pid == manager_id
and include_manager_personal
and member["has_manager_personal"]
) )
if personal_in_team: if personal_in_team:
rows.append( rows.append(
@ -874,7 +893,9 @@ def _group_rows(rows) -> list[dict]:
def payment_detail(session, query) -> dict: def payment_detail(session, query) -> dict:
"""区间下钻分页(bucket 未注册 → 400)。""" """区间下钻分页(bucket 未注册 → 400)。"""
bucket = _bucket_by_code(query.bucket) bucket = _bucket_by_code(query.bucket)
rows = report_repository.list_payment_detail(session, query, bucket) rows = report_repository.list_payment_detail(
session, query, bucket, offset=query.offset, limit=query.page_size
)
return { return {
"list": [_payment_row(r) for r in rows], "list": [_payment_row(r) for r in rows],
"total": report_repository.count_payment_detail(session, query, bucket), "total": report_repository.count_payment_detail(session, query, bucket),

View File

@ -68,8 +68,14 @@ def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**, 与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
下拉只给启用项。 下拉只给启用项。
""" """
roles = role_repository.list_roles(session, query) roles = role_repository.list_roles(
total = role_repository.count_roles(session, query) session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
)
total = role_repository.count_roles(session, keyword=query.keyword, status=query.status)
counts = role_repository.count_users_by_role(session, [role.id for role in roles]) counts = role_repository.count_users_by_role(session, [role.id for role in roles])
return PageResult[RoleManageOut]( return PageResult[RoleManageOut](
records=[_to_out(role, counts.get(role.id, 0)) for role in roles], records=[_to_out(role, counts.get(role.id, 0)) for role in roles],

View File

@ -13,13 +13,11 @@ from datetime import date
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.constants import ( from app.core.constants import (
MODULE_SETTLEMENT, MODULE_SETTLEMENT,
) )
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.models import ( from app.models import (
MonthlySettlement, MonthlySettlement,
@ -250,30 +248,19 @@ def submit(
), ),
meta=meta, meta=meta,
): ):
settlement = settlement_repository.get_by_id(session, settlement_id, for_update=True) settlement = settlement_repository.get_by_id(session, settlement_id)
if settlement is None: if settlement is None:
raise NotFoundError("月结不存在") raise NotFoundError("月结不存在")
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED): if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
raise BadRequestError("仅草稿或已驳回状态可提交") raise BadRequestError("仅草稿或已驳回状态可提交")
try:
order = approval_service.submit_order( order = approval_service.submit_order(
session, session,
order_type=_SETTLEMENT_ORDER_TYPE, order_type=_SETTLEMENT_ORDER_TYPE,
ref_id=settlement.id, ref_id=settlement.id,
refer_no=f"JS{settlement.settlement_month.replace('-', '')}", refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
submitter=operator, submitter=operator,
# 月结明细在生成时已固化;提交时快照让复核者看到的是送审版本,
# 而不是审批期间若发生重生成后的内容
snapshot=approval_service.settlement_content(session, settlement.id),
) )
except IntegrityError as exc:
# version_no 唯一键的并发冲突(提交前已锁月结行,此项理论不可达,
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062 翻译成
# 可重试的业务提示,其余完整性错误原样上抛
if is_duplicate_entry(exc):
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
raise
settlement.approval_order_no = order.order_no settlement.approval_order_no = order.order_no
if order.status == SETTLEMENT_STATUS_APPROVED: if order.status == SETTLEMENT_STATUS_APPROVED:
mark_locked(session, settlement, operator=operator) mark_locked(session, settlement, operator=operator)
@ -423,10 +410,9 @@ def _aggregate_row(
"members": member_names, "members": member_names,
"params_as_of": str(share.param_date), "params_as_of": str(share.param_date),
"manager_personal_included": 1 if share.include_manager_personal else 0, "manager_personal_included": 1 if share.include_manager_personal else 0,
# 说明写给业务人员看:说清这一笔的基数是怎么凑出来的
"note": ( "note": (
f"团队分成:基数{'含' if share.include_manager_personal else '不含'}" f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}"
"经理本人名下业绩,按本团队与全公司两级比例合计计提" "经理个人户业绩;9.8 方案公式"
), ),
}, },
), ),
@ -587,7 +573,6 @@ def _wire_adjustment_approval(session: Session, order, operator: User) -> None:
ref_id=order.id, ref_id=order.id,
refer_no=order.order_no, refer_no=order.order_no,
submitter=operator, submitter=operator,
snapshot=approval_service.adjustment_content(session, order.id),
) )
order.status = ( order.status = (
ADJUSTMENT_STATUS_EFFECTIVE ADJUSTMENT_STATUS_EFFECTIVE

View File

@ -157,8 +157,20 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]: def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。""" """分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
rows = shipment_repository.list_shipments(session, query) rows = shipment_repository.list_shipments(
total = shipment_repository.count_shipments(session, query) 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]( return PageResult[ShipmentOut](
records=[_to_out(row) for row in rows], records=[_to_out(row) for row in rows],
total=total, total=total,

View File

@ -1,12 +1,17 @@
"""系统设置(公司信息)业务编排。 """系统设置(公司信息 + 站点信息)业务编排。
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo 只存**相对路径** 单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo/favicon 只存**相对
(uploads/logo/ 下固定名覆盖),文件不入库的理由:图片进数据库会让每次 路径**(uploads/logo、uploads/favicon 下固定名覆盖),文件不入库的理由:
读取与备份放大传输,且无法独立替换;DB 只持有一行指针。 图片进数据库会让每次读取与备份放大传输,且无法独立替换;DB 只持有指针。
logo 安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名 图像安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与 后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与
覆盖任意文件。 覆盖任意文件。
logo 与 favicon 相互独立(2026-10-20):
- logo 用于登录页品牌位;
- favicon 用于浏览器标签页图标,未上传时前端使用内置 favicon.svg
(不再跟随 logo——公司 logo 是横版,作标签页图标常被裁切)。
""" """
import logging import logging
@ -24,15 +29,26 @@ from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# magic bytes:PNG 固定 8 字节签名;JPEG 以 SOI + APP0/JFIF 起始 # magic bytes:PNG 8 字节签名;JPEG 以 SOI 起始;ICO 以 00 00 01 00 起始
_MAGIC = { _MAGIC = {
"png": b"\x89PNG\r\n\x1a\n", "png": b"\x89PNG\r\n\x1a\n",
"jpg": b"\xff\xd8\xff", "jpg": b"\xff\xd8\xff",
"ico": b"\x00\x00\x01\x00",
} }
LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2MB:页眉/登录页展示尺寸上限,防上传滥用 # 允许的后缀集合:logo 沿用 png/jpg(页眉/登录页展示);favicon 额外接受 ico
LOGO_EXTS = ("png", "jpg")
FAVICON_EXTS = ("png", "jpg", "ico")
ASSET_MAX_BYTES = 2 * 1024 * 1024 # 2MB:展示尺寸上限,防上传滥用
SETTINGS_ID = 1 # 全系统单行主键 SETTINGS_ID = 1 # 全系统单行主键
# 公司档案中的可选文本字段:提交空串等价清空(落库 null,前端回退内置默认)
_OPTIONAL_TEXT_FIELDS = ("site_title", "brand_slogan", "footer_text", "icp_no")
# 资源种类 → 中文名(错误提示与审计摘要用)
_ASSET_LABELS = {"logo": "logo", "favicon": "站点图标"}
_ASSET_EXTS = {"logo": LOGO_EXTS, "favicon": FAVICON_EXTS}
def get_setting(session: Session) -> SystemSetting: def get_setting(session: Session) -> SystemSetting:
"""取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。""" """取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。"""
@ -45,10 +61,15 @@ def get_setting(session: Session) -> SystemSetting:
def to_out(setting: SystemSetting) -> SettingOut: def to_out(setting: SystemSetting) -> SettingOut:
"""管理端出参(logo_url 带更新时间版本参数,替换图片后浏览器不再用旧缓存)。""" """管理端出参(图片地址带更新时间版本参数,替换后浏览器不再用旧缓存)。"""
return SettingOut( return SettingOut(
company_name=setting.company_name, company_name=setting.company_name,
logo_url=_logo_url(setting), logo_url=_asset_url(setting, "logo"),
site_title=setting.site_title,
brand_slogan=setting.brand_slogan,
footer_text=setting.footer_text,
icp_no=setting.icp_no,
favicon_url=_asset_url(setting, "favicon"),
) )
@ -56,15 +77,26 @@ def to_public_out(setting: SystemSetting) -> SettingPublicOut:
"""匿名端点出参(白名单字段,独立 schema 见模块说明)。""" """匿名端点出参(白名单字段,独立 schema 见模块说明)。"""
return SettingPublicOut( return SettingPublicOut(
company_name=setting.company_name, company_name=setting.company_name,
logo_url=_logo_url(setting), logo_url=_asset_url(setting, "logo"),
site_title=setting.site_title,
brand_slogan=setting.brand_slogan,
footer_text=setting.footer_text,
icp_no=setting.icp_no,
favicon_url=_asset_url(setting, "favicon"),
) )
def _logo_url(setting: SystemSetting) -> str | None: def _asset_url(setting: SystemSetting, kind: str) -> str | None:
if not setting.logo_path: """资源 URL(kind=logo/favicon);未上传返回 None。
版本号取**上传计数器**而非 update_time:同扩展名替换时固定文件名不变
(logo_path 值不变),MySQL 的 ON UPDATE 不会刷新 update_time,用它做
?v= 会导致浏览器一直用旧图(2026-10-20 用户反馈的换图不刷新问题)。
"""
if not getattr(setting, f"{kind}_path"):
return None return None
version = int(setting.update_time.timestamp()) if setting.update_time else 0 version = getattr(setting, f"{kind}_version") or 0
return f"/api/v1/system/settings/logo/public?v={version}" return f"/api/v1/system/settings/{kind}/public?v={version}"
def update_company( def update_company(
@ -74,20 +106,34 @@ def update_company(
operator: User, operator: User,
meta: RequestMeta | None = None, meta: RequestMeta | None = None,
) -> SettingOut: ) -> SettingOut:
"""更新公司名称(审计留痕)。""" """更新公司信息与站点信息(部分更新 + 审计留痕)。
仅提交的字段生效(``exclude_unset``:未提交/显式 null 保持原值),
便于「只改名称」「只改站点标题」等场景不覆盖其他字段;可选文本传
空串等价清空(落库 null)。
"""
changes = payload.model_dump(exclude_unset=True, exclude_none=True)
summary = "修改公司信息"
if "company_name" in changes:
summary = f"修改公司信息(公司名称「{changes['company_name']}」)"
with audit_service.audit( with audit_service.audit(
session, session,
operator=operator, operator=operator,
record=AuditRecord( record=AuditRecord(
module=MODULE_SYSTEM_SETTINGS, module=MODULE_SYSTEM_SETTINGS,
action=ACTION_UPDATE, action=ACTION_UPDATE,
summary=f"修改公司名称为「{payload.company_name}」", summary=summary,
request_data=payload.model_dump(), request_data=changes or None,
), ),
meta=meta, meta=meta,
): ):
setting = get_setting(session) setting = get_setting(session)
setting.company_name = payload.company_name.strip() if "company_name" in changes:
# schema 已保证提交值非空(min_length=1)
setting.company_name = changes["company_name"]
for field in _OPTIONAL_TEXT_FIELDS:
if field in changes:
setattr(setting, field, changes[field].strip() or None)
session.commit() session.commit()
return to_out(setting) return to_out(setting)
@ -100,17 +146,39 @@ def save_logo(
operator: User, operator: User,
meta: RequestMeta | None = None, meta: RequestMeta | None = None,
) -> SettingOut: ) -> SettingOut:
"""上传 logo:校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。 """上传公司 logo(登录页品牌位;png/jpg ≤2MB)。"""
return _save_asset(session, content, ext, kind="logo", operator=operator, meta=meta)
def save_favicon(
session: Session,
content: bytes,
ext: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettingOut:
"""上传站点图标(浏览器标签页;png/jpg/ico ≤2MB)。"""
return _save_asset(session, content, ext, kind="favicon", operator=operator, meta=meta)
def _save_asset(
session: Session,
content: bytes,
ext: str,
*,
kind: str,
operator: User,
meta: RequestMeta | None,
) -> SettingOut:
"""校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。 旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。
""" """
label = _ASSET_LABELS[kind]
allowed = _ASSET_EXTS[kind]
ext = ext.lower() ext = ext.lower()
if ext not in _MAGIC: _validate_upload(content, ext, allowed=allowed, label=label)
raise BadRequestError("logo 仅支持 png/jpg 格式")
if len(content) > LOGO_MAX_BYTES:
raise BadRequestError("logo 文件不能超过 2MB")
if not content.startswith(_MAGIC[ext]):
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
with audit_service.audit( with audit_service.audit(
session, session,
@ -118,54 +186,79 @@ def save_logo(
record=AuditRecord( record=AuditRecord(
module=MODULE_SYSTEM_SETTINGS, module=MODULE_SYSTEM_SETTINGS,
action=ACTION_UPDATE, action=ACTION_UPDATE,
summary="更新公司 logo", summary=f"更新{label}",
request_data={"ext": ext, "size": len(content)}, request_data={"ext": ext, "size": len(content)},
), ),
meta=meta, meta=meta,
): ):
setting = get_setting(session) setting = get_setting(session)
old_path = setting.logo_path old_path = getattr(setting, f"{kind}_path")
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前, # 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名 # commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
# 由写入覆盖,无需也不会误删新文件 # 由写入覆盖,无需也不会误删新文件
target = _logo_dir() / f"logo.{ext}" target = _asset_dir(kind) / f"{kind}.{ext}"
target.write_bytes(content) target.write_bytes(content)
setting.logo_path = f"logo/logo.{ext}" setattr(setting, f"{kind}_path", f"{kind}/{kind}.{ext}")
# 版本号每次上传必增:即使同扩展名覆盖(路径不变)也能破浏览器缓存
setattr(setting, f"{kind}_version", (getattr(setting, f"{kind}_version") or 0) + 1)
session.commit() session.commit()
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
if old_ext and old_ext != ext: if old_ext and old_ext != ext and old_ext in allowed:
try: try:
_cleanup_old_logo(old_ext) _cleanup_old_asset(kind, old_ext)
except OSError: except OSError:
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛, # 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
# 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反 # 成功的更新会被记成失败审计并 500 —— 审计与事实相反
logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True) logger.warning(
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext) "旧%s清理失败(不影响本次更新)| ext=%s", label, old_ext, exc_info=True
)
logger.info("更新%s | size=%s ext=%s", label, len(content), ext)
return to_out(setting) return to_out(setting)
def read_logo(session: Session) -> tuple[Path, str]: def read_logo(session: Session) -> tuple[Path, str]:
"""按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。""" """按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。"""
return _read_asset(session, "logo")
def read_favicon(session: Session) -> tuple[Path, str]:
"""按库中记录定位站点图标文件,返回 (路径, 扩展名)。未上传抛 404。"""
return _read_asset(session, "favicon")
def _read_asset(session: Session, kind: str) -> tuple[Path, str]:
label = _ASSET_LABELS[kind]
setting = get_setting(session) setting = get_setting(session)
if not setting.logo_path: path_str = getattr(setting, f"{kind}_path")
raise NotFoundError("尚未上传 logo") if not path_str:
path = get_settings().upload_dir / setting.logo_path raise NotFoundError(f"尚未上传{label}")
path = get_settings().upload_dir / path_str
if not path.is_file(): if not path.is_file():
raise NotFoundError("logo 文件不存在,请重新上传") raise NotFoundError(f"{label}文件不存在,请重新上传")
return path, path.suffix.lstrip(".") return path, path.suffix.lstrip(".")
def _logo_dir() -> Path: def _validate_upload(content: bytes, ext: str, *, allowed: tuple[str, ...], label: str) -> None:
directory = get_settings().upload_dir / "logo" """后缀白名单 + magic bytes + 大小三重校验(同口径供 logo/favicon 复用)。"""
if ext not in allowed:
raise BadRequestError(f"{label}仅支持 {'/'.join(allowed)} 格式")
if len(content) > ASSET_MAX_BYTES:
raise BadRequestError(f"{label}文件不能超过 2MB")
if not content.startswith(_MAGIC[ext]):
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
def _asset_dir(kind: str) -> Path:
directory = get_settings().upload_dir / kind
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
return directory return directory
def _cleanup_old_logo(old_ext: str) -> None: def _cleanup_old_asset(kind: str, old_ext: str) -> None:
if old_ext in _MAGIC: if old_ext in _ASSET_EXTS[kind]:
old_file = _logo_dir() / f"logo.{old_ext}" old_file = _asset_dir(kind) / f"{kind}.{old_ext}"
if old_file.exists(): if old_file.exists():
old_file.unlink(missing_ok=True) old_file.unlink(missing_ok=True)

View File

@ -68,8 +68,22 @@ def _to_out(user: User) -> UserOut:
def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]: def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]:
"""分页查询用户列表。""" """分页查询用户列表。"""
users = user_repository.list_users(session, query) users = user_repository.list_users(
total = user_repository.count_users(session, query) session,
offset=query.offset,
limit=query.page_size,
username=query.username,
real_name=query.real_name,
status=query.status,
role_code=query.role_code,
)
total = user_repository.count_users(
session,
username=query.username,
real_name=query.real_name,
status=query.status,
role_code=query.role_code,
)
return PageResult[UserOut]( return PageResult[UserOut](
records=[_to_out(user) for user in users], records=[_to_out(user) for user in users],
total=total, total=total,

View File

@ -98,9 +98,9 @@ uv run pytest tests/integration -q # 仅集成
| --- | --- | --- | | --- | --- | --- |
| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 | | `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 | | `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 |
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕、评级保存(回显总分/时间、覆盖式保存)、评分 101→400、按评分排序未评分垫底(asc/desc) | 18 | | `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-23 评级功能后客户单文件 **18 passed**(18.5s)。 运行:`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 完成) ### 业务数据管理(2026-10-13 完成)
@ -159,25 +159,6 @@ uv run pytest tests/integration -q # 仅集成
**实施中发现并修复**:`alembic upgrade --sql` 在 Windows 控制台按 GBK 输出,重定向生成的 `V10__*.sql` 被 `test_schema_sync` 按 UTF-8 读取时抛 `UnicodeDecodeError`——导出命令须加 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1`(已写入 backend/README)。 **实施中发现并修复**:`alembic upgrade --sql` 在 Windows 控制台按 GBK 输出,重定向生成的 `V10__*.sql` 被 `test_schema_sync` 按 UTF-8 读取时抛 `UnicodeDecodeError`——导出命令须加 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1`(已写入 backend/README)。
### code-review 遗留 2 项(2026-10-20 完成:方案版本化 / 审批内容快照)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_approval_flow.py`(扩) | **提交时快照固化**(version_no=1、content_source=snapshot、方案比例/合计/余额与业务编号);编辑方案为 80% 后新单 V2、旧单快照仍 50.00 且旧在途单撤回;**历史单无快照回退当前内容并标 live**(captured_at=null);引用对象缺失且无快照 → missing;**方案版本化**:编辑后 history=[(1,0,50.00),(2,1,80.00)]、生效行仅 80.00;驳回后生效行为空但历史行保留(is_active=0) | +5 |
| `tests/unit/test_approval_schema.py` | 对象类型筛选 1/2/3 均可、0/4 拒绝(修复原上界 `le=2` 导致调整单审批单无法筛选);不传类型表示全部;驳回必须填意见、通过可不填 | 6 |
| `tests/unit/test_approval_helpers.py` | **方案内容比较**(`business_service._plan_changed`):完全相同/行序不同判未变、比例变/人员增减/同比例换人判已变、比例写法 `50` 与 `50.00` 等价、空方案对照 | 10 |
| `tests/integration/test_secondary_allocation.py`(扩) | **同一会话内旧方案行随置历史同步刷新**(Core UPDATE 的会话同步策略退化为不刷新时,行对象会停留在 is_active=1 的过期内存值) | +1 |
| `tests/integration/test_approval_flow.py`(扩) | **送审内容未变不重提**:方案与金额都没动 → 方案不新增版本、审批单不重提(旧单仍待财务复核);**只改金额** → 方案版本不变但审批单 version_no+1 且新快照金额为改后值;**改方案比例** → 方案版本+1、审批单 version_no+1 且旧单撤回;**无在途单时保存仍提新单**(保守规则);`pending_matches_snapshot` 四态(一致/不一致/无在途单/快照为 None) | +5 |
### 审批单版本号并发防线(2026-10-21 完成:V12 唯一键 + 锁读取号)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_approval_version.py`(新) | **取号读最新已提交值**(两会话法:s1 锚定快照 + s2 提交 version_no=2 → s1 必须取到 3,守住「快照读 vs 当前读」根因);**取号查询带 FOR UPDATE 且不含聚合**(事件钩子抓真实 SQL——MySQL 官方不允许聚合函数携带锁子句);**唯一键生效**(重复 (order_type, ref_id, version_no) 直插撞 1062);**编辑路径冲突收敛为 400** 且业务数据不写坏(monkeypatch `_next_version` 恒返回 1);**正常路径版本号仍递增**(1→2→3) | +5 |
| `tests/unit/test_models.py`(扩) | 审批单唯一键覆盖 (order_type, ref_id, version_no) 三列且约束名与迁移 DDL 一致 | +1 |
运行(2026-10-22,容器恢复后):`tests/integration/test_approval_version.py` + `test_dashboard.py` → **16 passed**;全量 `uv run pytest -q` → **897 passed(12:02)**;`uv run ruff check .` 绿、前端 `pnpm type-check` 绿。首次全量跑出两处**测试代码**适配(非产品缺陷):① 工作台 `_insert_approval` 用同一 `(order_type=2, ref_id=0)` 连插两张单,撞 V12 新唯一键 → 每张单改给不同 `ref_id`;② 新用例跨模块引用 `switched_on` 夹具(pytest 夹具只在定义模块与 conftest 可见,报 fixture not found)→ 用 `_set_switch` 本地重建。
## 三、运行记录 ## 三、运行记录
| 日期 | 范围 | 用例数 | 结果 | 备注 | | 日期 | 范围 | 用例数 | 结果 | 备注 |
@ -203,15 +184,9 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-19 | **code-review 修复回归** | 全量 862 | ✅ 862 passed(9:36) | **M4 工作台 + 报表 3 必须 + 8 建议修复**:普通用户落地页/守卫(报表集成 27、工作台集成 11、报表单元 17,新增 10 例);部门汇总 5% 按计入次数累计并与月结 `total_payable` 对拍、聚合行舍入差;工作台待办按角色过滤、逾期命中与阈值生效、终止业务排除、`warnings.total` 截断、趋势跨月归属。ruff 绿;type-check + build 绿 | | 2026-10-19 | **code-review 修复回归** | 全量 862 | ✅ 862 passed(9:36) | **M4 工作台 + 报表 3 必须 + 8 建议修复**:普通用户落地页/守卫(报表集成 27、工作台集成 11、报表单元 17,新增 10 例);部门汇总 5% 按计入次数累计并与月结 `total_payable` 对拍、聚合行舍入差;工作台待办按角色过滤、逾期命中与阈值生效、终止业务排除、`warnings.total` 截断、趋势跨月归属。ruff 绿;type-check + build 绿 |
| 2026-10-20 | e2e 全链路 | — | ✅ login 8 / system 13 / role_home 4 / report 12 / calc_settlement 8 | 重启后端加载新代码后实跑:普通用户落地页与受限页回退、报表四页/导出/打印、核算与月结链路全过。`report_flow` 回款统计改为展开目标桶后按本次业务编号定位明细,不依赖桶累计值,可重复运行 | | 2026-10-20 | e2e 全链路 | — | ✅ login 8 / system 13 / role_home 4 / report 12 / calc_settlement 8 | 重启后端加载新代码后实跑:普通用户落地页与受限页回退、报表四页/导出/打印、核算与月结链路全过。`report_flow` 回款统计改为展开目标桶后按本次业务编号定位明细,不依赖桶累计值,可重复运行 |
| 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 | | 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 |
| 2026-10-20 | code-review 遗留 2 项 | 全量 875 | ✅ 875 passed(12:13) | **方案版本化 + 审批内容快照**(V11 迁移):审批单 `version_no`/`snapshot_json`,方案行 `is_active` 版本化;编辑/驳回不再物理删方案;历史单无快照时 live 回退并显式标注。新增集成 7 + 单元 6;定点审批/月结/核算/业务/报表全绿;ruff 与 type-check/build 绿 |
| 2026-10-20 | 遗留项自评审收口 | 全量 891 | ✅ 891 passed(10:23) | **列表 defer 快照列**(审批列表/工作台待办/超时预警)+ **送审内容未变不重提、方案未变不新增版本**(两个 version_no 语义解耦)+ **月结提交加行锁**。新增单元 10 + 集成 5;单元 525、集成定点 96 全绿 |
| 2026-10-22 | 清理批次(散参数 DTO 化) | 全量 900 | ✅ 900 passed(11:08) | **仓储层散参数收敛为查询 DTO**(`list_*(session, query)` 取代 keyword/status/offset/limit 四参数),同步调整服务层与测试调用;单元 526 绿、ruff 绿、前端 type-check 与 build 绿 |
| 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 | | 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 |
| 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 | | 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
| 2026-10-23 | 参数文案中文化 | 单元 526 + 参数集成 24 | ✅ 526 passed(45.15s)/ 24 passed(10.60s) | **计提参数页全面中文化**:`param_registry` 22 条 `description` 改写为业务语言(去掉「澄清 #10」「M3」「禁止按 0.011111 截断」等开发者语汇),`db/init/init-dev.sql` 种子同源同步;`param_service` 模板报错改中文模板名、产品类别报错去掉英文枚举;`dashboard_service` 概览口径说明重写。**这些文案无测试断言**(`rate_text` 断言 `11/1125`、`聚合:20%+5%` 未动),故用例数不变、全绿即口径未被破坏。ruff 绿;前端 `pnpm type-check` 绿 |
| 2026-10-23 | 台账/预览的计提说明口语化 | 单元 526 + 集成 87(calc/settlements/param/reports) | ✅ 526 passed(47.29s)/ 87 passed(7:41) | **`rate_text`/`rate_note`/聚合行 `note` 改业务话**:`企业户固定计提比例`→「企业户:不按回款天数,统一按固定比例计提」、`业务员递减:10‰ 起,20 天(分界 360 天…)`→「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」、`信息员:本人销售额 × 计提比例`→「信息员:按本人销售额乘计提比例,不随回款天数递减」、聚合行 `#12 参数化:基数不含经理个人户业绩;9.8 方案公式`→「团队分成:基数不含经理本人名下业绩,按本团队与全公司两级比例合计计提」。**用例同步**:`test_经理聚合口径_预览拒绝` 原断言消息含「聚合」,改为断言含「营销经理」+「月度结算」(守「拒绝且给下一步」,不再绑定措辞);`rate_text` 的 `11/1125`、`聚合:20%+5%` 断言未动(那是分数原文与百分比拼装,非说明文案) |
## 四、测试发现并修复的缺陷 ## 四、测试发现并修复的缺陷
| 日期 | 缺陷 | 影响 | 处理 | | 日期 | 缺陷 | 影响 | 处理 |
@ -222,15 +197,6 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-19 | 经理个人户成员行取该人全部绩效 | 团队基数只含个人户行,成员行却含普通行——**基数≠成员合计,同一笔业绩在成员行与未分配重复** | 普通行与个人户分行记账;经理成员行只取个人户金额,普通行留在上级团队/未分配 | | 2026-10-19 | 经理个人户成员行取该人全部绩效 | 团队基数只含个人户行,成员行却含普通行——**基数≠成员合计,同一笔业绩在成员行与未分配重复** | 普通行与个人户分行记账;经理成员行只取个人户金额,普通行留在上级团队/未分配 |
| 2026-10-19 | 导出取数与超限校验在审计上下文之外 | 超限 400 与取数异常**不留失败日志**,与 M4 计划 T12「导出 400/失败审计」不符 | `_export_payload` 移入 `with audit`;静态标题映射(AuditRecord frozen) | | 2026-10-19 | 导出取数与超限校验在审计上下文之外 | 超限 400 与取数异常**不留失败日志**,与 M4 计划 T12「导出 400/失败审计」不符 | `_export_payload` 移入 `with audit`;静态标题映射(AuditRecord frozen) |
| 2026-10-19 | 月份只校验长度 | `2026-13` 传到 `calendar.monthrange` 抛 ValueError → **500**;导出手工构造模型抛裸 ValidationError 同样 500 | 新增 `core/month.py`(格式+01-12+年份);四类 query、月结、调整单接入;导出 ValidationError 转 400 | | 2026-10-19 | 月份只校验长度 | `2026-13` 传到 `calendar.monthrange` 抛 ValueError → **500**;导出手工构造模型抛裸 ValidationError 同样 500 | 新增 `core/month.py`(格式+01-12+年份);四类 query、月结、调整单接入;导出 ValidationError 转 400 |
| 2026-10-20 | 审批类型筛选上界写死 2 | `ApprovalQuery.order_type` 为 `le=2`:调整单(order_type=3)审批单在列表无法筛选,只能翻全量 | 上界改 3;单元用例锁定 1/2/3 与越界拒绝 |
| 2026-10-20 | 编辑业务时送审快照取旧汇总 | `update_business` 中 `_recalc_totals` 在提单之后,快照里的销售额/成本额会固化上一版本金额 | 汇总重算移到提单之前;快照用例断言送审内容与本次编辑后方案一致 |
| 2026-10-20 | 快照金额把「空」写成 0.00 | 业务尚无成本时 `total_cost_amount=None` 被文本化成 `0.00`,复核者会把「无成本」误读成「成本为零」 | `_amount_text(None)` 保留为空,前端显示「—」 |
| 2026-10-20 | 每次保存带方案的业务都刷出版本与审批留痕 | 只改备注、方案一字未动,也会「置历史+新增版本+撤回旧单+提新单」,方案历史与审批留痕被噪音淹没,两个 `version_no` 语义混同 | 判据统一为**送审内容**:方案未变不新增版本行;在途单送审内容一致则不重提(`pending_matches_snapshot`,只比 `data`) |
| 2026-10-20 | 三处列表查询整列拉快照 | 审批单列表、工作台待办、审批超时预警都用 `select(ApprovalOrder)` 取全列,快照 JSON 可达上百 KB,翻页与工作台负担随快照体积线性放大 | 三处加 `defer(ApprovalOrder.snapshot_json)`(沿用操作日志列表排除 `request_data` 的既有模式) |
| 2026-10-20 | 月结提交未锁行 | 并发/双击提交同一月结时两次 `_next_version` 可能算出同一 `version_no`,送审次序失真 | `settlement_repository.get_by_id` 加 `for_update` 参数,提交路径锁行 |
| 2026-10-21 | 审批单 `version_no` 无唯一约束 | 应用层取号即使正确,并发下 DB 层无兜底;且唯一键是「一个对象至多一张在途单」红线之外版本语义的最后防线 | V12 迁移加 `uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`;加键前先重排存量重复分组(V11 把存量一律置 1 而 V11 前重提是新建行,直接加键必报 1062) |
| 2026-10-21 | `_next_version` 是快照读 | 请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已锚定 REPEATABLE READ 快照——父行锁只串行化写入时刻、**不刷新快照**,并发提单的 MAX 停在请求开始时刻 → 版本号重复 | 取号改锁读(`select(version_no)...with_for_update()`,当前读),最大值 Python 侧算;**不能用聚合 + FOR UPDATE**——MySQL 官方不允许聚合函数携带锁子句 |
| 2026-10-21 | `act()` 驳回清空方案的注释与行为相反 | 注释写「清空该业务的二次分配行…如需保留历史版本,在 ④ 设计时演进」,而 V11 已实现 append-only(只置 `is_active=0` 行不删)——误导后来者按旧语义改代码 | 注释改为「置历史不删行,历史方案可回溯」 |
| 2026-10-19 | 前端报表五处状态缺陷 | 打印超限判定恒假(静默截断)、部门下钻 query 未读取、回款多桶共用一个 drill 状态(面板串行)、summary 在请求序号守卫前写入(合计串号)、e2e 全页行数选择器必失败 | 分别修正判定/读取 query/按桶独立状态+数组参数/`onData` 回调/限定主桶表;补 e2e 多桶展开、下钻、截断、部门打印用例 | | 2026-10-19 | 前端报表五处状态缺陷 | 打印超限判定恒假(静默截断)、部门下钻 query 未读取、回款多桶共用一个 drill 状态(面板串行)、summary 在请求序号守卫前写入(合计串号)、e2e 全页行数选择器必失败 | 分别修正判定/读取 query/按桶独立状态+数组参数/`onData` 回调/限定主桶表;补 e2e 多桶展开、下钻、截断、部门打印用例 |
| 2026-10-19 | 工作台权限收紧后守卫仍回退 `/dashboard` | 普通用户登录/直连受限页被反复重定向到同一受限页 → **无法进入系统**(导航被判定无限重定向) | `router/access.ts` 统一准入与落地页解析(工作台 → 业务数据 → 回款);登录回跳经 `canAccessPath` 校验;菜单/面包屑复用同一判定 | | 2026-10-19 | 工作台权限收紧后守卫仍回退 `/dashboard` | 普通用户登录/直连受限页被反复重定向到同一受限页 → **无法进入系统**(导航被判定无限重定向) | `router/access.ts` 统一准入与落地页解析(工作台 → 业务数据 → 回款);登录回跳经 `canAccessPath` 校验;菜单/面包屑复用同一判定 |
| 2026-10-19 | 部门汇总应发只计一次全公司 5% | 月结每位有聚合行的经理各计一次 5%,签字稿少计 N-1 份;**导出/打印金额小于月结应发** | 团队行增 `has_aggregate/aggregate_amount`(优先冻结聚合行);对账改按聚合额求和,新增「聚合行舍入差」吸收分列 q2 尾差;补「与 `total_payable` 对拍」用例 | | 2026-10-19 | 部门汇总应发只计一次全公司 5% | 月结每位有聚合行的经理各计一次 5%,签字稿少计 N-1 份;**导出/打印金额小于月结应发** | 团队行增 `has_aggregate/aggregate_amount`(优先冻结聚合行);对账改按聚合额求和,新增「聚合行舍入差」吸收分列 q2 尾差;补「与 `total_payable` 对拍」用例 |

View File

@ -21,7 +21,6 @@
import uuid import uuid
from decimal import Decimal from decimal import Decimal
from pathlib import Path
import pytest import pytest
from sqlalchemy import delete, select, update from sqlalchemy import delete, select, update
@ -37,6 +36,7 @@ from app.models import (
BusinessRole, BusinessRole,
BusinessStatusLog, BusinessStatusLog,
Customer, Customer,
CustomerRating,
CustomerRatingGrade, CustomerRatingGrade,
MonthlySettlement, MonthlySettlement,
OperationLog, OperationLog,
@ -164,6 +164,10 @@ def _purge_test_data() -> None:
session.execute( session.execute(
delete(OwnershipChangeLog).where(OwnershipChangeLog.customer_id.in_(customer_ids)) delete(OwnershipChangeLog).where(OwnershipChangeLog.customer_id.in_(customer_ids))
) )
# 评级行 FK RESTRICT:必须先于客户删除(V7 起客户评级每客户一行)
session.execute(
delete(CustomerRating).where(CustomerRating.customer_id.in_(customer_ids))
)
session.execute(delete(Customer).where(Customer.id.in_(customer_ids))) session.execute(delete(Customer).where(Customer.id.in_(customer_ids)))
# 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条 # 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条
session.execute( session.execute(
@ -190,32 +194,103 @@ def _purge_test_data() -> None:
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删; # 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响) # ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%"))) session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 28 # 开关参数恢复种子基线:测试提交的变更批次由 t_ 前缀测试账号创建,
# (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另 # 按**批次操作人**删除本轮的参数条目与批次,解析后回退到种子值(开关=0)。
# 插入两个审批开关的 10-19 生效行 id 23/24,V10 插入预警规则参数 # 不用 id 阈值:种子条目的主键在不同环境的漂移库中并不相同(历史库
# id 26/27/28,均属种子),删新条目与新批次后解析回退到种子值 # 可能补过种子行),按操作人识别测试产物与环境数据分布无关。
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 28)).scalars()) test_batch_ids = list(
if stray_values:
stray_batches = {v.batch_id for v in stray_values}
session.execute(delete(ParamValue).where(ParamValue.id > 28))
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
session.execute( session.execute(
update(SystemSetting) select(ParamChangeBatch.id).where(ParamChangeBatch.operator_name.like("t\\_%"))
.where(SystemSetting.id == 1) ).scalars()
.values(company_name="衍水集团", logo_path=None)
) )
if test_batch_ids:
session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(test_batch_ids)))
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(test_batch_ids)))
session.commit() session.commit()
logo_dir = Path(__file__).resolve().parents[2] / "uploads" / "logo"
if logo_dir.is_dir():
for f in logo_dir.iterdir(): # ==================== 系统设置与上传文件的快照还原 ====================
f.unlink(missing_ok=True) #
# 系统设置是**真实单行数据**,logo/favicon 是真实上传文件,没有 t_ 前缀可供
# 兜底清理。测试(尤其 logo 上传用例)会改公司名、覆盖/删除真实图片 ——
# 必须在用例结束后还原,否则跑一次测试就会破坏开发环境的品牌与 logo。
_ASSET_KINDS = ("logo", "favicon")
def _snapshot_company_info() -> dict | None:
"""快照系统设置单行(无行时返回 None)。"""
factory = get_session_factory()
with factory() as session:
row = session.get(SystemSetting, 1)
if row is None:
return None
return {column.name: getattr(row, column.name) for column in SystemSetting.__table__.columns}
def _restore_company_info(snapshot: dict | None) -> None:
if snapshot is None:
return
factory = get_session_factory()
with factory() as session:
row = session.get(SystemSetting, 1)
if row is None:
return
for key, value in snapshot.items():
if key != "update_time": # 更新时间由数据库 ON UPDATE 维护
setattr(row, key, value)
session.commit()
def _snapshot_assets() -> dict[str, dict[str, bytes]]:
"""快照上传目录里的真实图片(logo/favicon)。"""
from app.core.config import get_settings
root = get_settings().upload_dir
snapshot: dict[str, dict[str, bytes]] = {}
for kind in _ASSET_KINDS:
directory = root / kind
snapshot[kind] = (
{path.name: path.read_bytes() for path in directory.glob("*") if path.is_file()}
if directory.is_dir()
else {}
)
return snapshot
def _restore_assets(snapshot: dict[str, dict[str, bytes]]) -> None:
"""还原上传目录:删除测试新增文件、回写被测试覆盖的原文件。"""
from app.core.config import get_settings
root = get_settings().upload_dir
for kind, files in snapshot.items():
directory = root / kind
directory.mkdir(parents=True, exist_ok=True)
for path in directory.glob("*"):
if path.is_file() and path.name not in files:
path.unlink(missing_ok=True)
for name, content in files.items():
(directory / name).write_bytes(content)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _purge_after_each_test(): def _preserve_company_info():
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。""" """每个用例前快照系统设置与上传图片,结束后还原(见上方说明)。"""
info = _snapshot_company_info()
assets = _snapshot_assets()
yield
# 先还原文件:数据库路径随后回指同一批文件
_restore_assets(assets)
_restore_company_info(info)
@pytest.fixture(autouse=True)
def _purge_after_each_test(_preserve_company_info):
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。
依赖 ``_preserve_company_info``:保证快照在该夹具之前建立、在该夹具
清理**之后**还原(夹具 teardown 为逆序)。
"""
yield yield
_purge_test_data() _purge_test_data()

View File

@ -388,336 +388,3 @@ class TestModuleAccess:
user = make_user([code]) user = make_user([code])
user["headers"] = auth_headers(user) user["headers"] = auth_headers(user)
assert client.get(_APPROVALS, headers=user["headers"]).status_code == 200 assert client.get(_APPROVALS, headers=user["headers"]).status_code == 200
class TestContentSnapshot:
"""审批单内容快照与版本号(code-review 遗留②)。
守的是:复核者看到的是**送审时**的方案,而不是审批期间被改过的当前
方案;历史单(升级前无快照)必须显式标注「当前内容回退」,不能把
当前内容冒充送审内容。
"""
def test_提交时快照固化_再改方案不回写旧单(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
assert first["version_no"] == 1
assert first["content_source"] == "snapshot"
snapshot = first["snapshot"]
assert snapshot["schema_version"] == 1
assert snapshot["order_type"] == 1
assert snapshot["captured_at"], "快照须固化采集时间"
assert snapshot["data"]["business_no"] == data["business_no"]
assert snapshot["data"]["allocations"][0]["ratio"] == "50.00"
assert snapshot["data"]["allocation_total"] == "50.00"
assert snapshot["data"]["allocation_balance"] == "50.00"
# 编辑方案为 80%:旧在途单撤回,新单 V2 快照取新值;旧单快照不变
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = business_payload(
detail["customer_id"],
secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
second = _latest_order(client, finance["headers"], data["business_no"])
assert second["version_no"] == 2
assert second["snapshot"]["data"]["allocations"][0]["ratio"] == "80.00"
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
assert old["status"] == 5, "重新提交须撤回旧在途单"
assert old["snapshot"]["data"]["allocations"][0]["ratio"] == "50.00"
def test_历史单无快照_回退当前内容并标注(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
"""升级前的审批单没有快照列:回退读当前内容,但来源必须标 live。"""
from app.core.db import get_session_factory
from app.models import ApprovalOrder
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
factory = get_session_factory()
with factory() as session:
row = session.get(ApprovalOrder, order["id"])
assert row is not None
row.snapshot_json = None
session.commit()
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()[
"data"
]
assert detail["content_source"] == "live"
assert detail["snapshot"]["captured_at"] is None
assert detail["snapshot"]["data"]["business_no"] == data["business_no"]
def test_引用对象缺失且无快照_内容缺失(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
from app.core.db import get_session_factory
from app.models import ApprovalOrder
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
factory = get_session_factory()
with factory() as session:
row = session.get(ApprovalOrder, order["id"])
assert row is not None
row.snapshot_json = None
row.ref_id = 999_999_999
session.commit()
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()[
"data"
]
assert detail["content_source"] == "missing"
assert detail["snapshot"] is None
def _payload_from_detail(detail: dict, **overrides: object) -> dict:
"""按业务详情原样重建提交载荷(明细行照抄,用于「内容未变」的场景)。"""
items = [
{
"product_name": it["product_name"],
"product_category": it["product_category"],
"quantity": it["quantity"],
"sale_price": it["sale_price"],
}
for it in detail["items"]
]
body = business_payload(
detail["customer_id"],
items=items,
secondary_allocations=detail["secondary_allocations"],
)
body.update(overrides)
return body
class TestNoRedundantResubmit:
"""送审内容未变不重提、方案未变不新增版本(版本与留痕降噪)。
守的是:反复保存同一份业务(只改备注)不应刷出一串方案版本与
「撤回+新建」的审批留痕;而内容真变了(金额或方案)必须重提,
否则审批人看到的是过期内容。
"""
def test_方案与金额均未变_不新增版本也不重提(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
assert first["status"] == 1
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(detail)
payload["remark"] = "只改了备注"
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
# 方案版本不增
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
(1, 1)
], "方案内容未变不应新增版本行"
# 审批单不重提:仍是原来那一张、仍待财务复核
rows = client.get(
_APPROVALS,
params={"keyword": data["business_no"], "pageSize": 10},
headers=finance["headers"],
).json()["data"]["list"]
assert len(rows) == 1, f"送审内容未变不应重提,实得 {len(rows)} 张单"
assert rows[0]["id"] == first["id"]
assert rows[0]["status"] == 1, "在途单不应被撤回"
assert rows[0]["version_no"] == 1
def test_只改金额不改方案_方案版本不变但审批单重提(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
"""金额变了 → 送审内容变了 → 必须重提;但方案本身没变 → 不新增版本。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(detail)
payload["items"][0]["sale_price"] = "200.00"
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
(1, 1)
], "方案未变不应新增版本行"
second = _latest_order(client, finance["headers"], data["business_no"])
assert second["version_no"] == 2, "金额变了必须重提"
assert second["snapshot"]["data"]["sale_amount"] == "20000.00"
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
assert old["status"] == 5
def test_改方案比例_方案版本与审批单版本同时递增(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(
detail, secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}]
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
(1, 0),
(2, 1),
], "方案变了应新增版本并把旧行置历史"
second = _latest_order(client, finance["headers"], data["business_no"])
assert second["version_no"] == 2
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
assert old["status"] == 5
def test_无在途单时保存仍提新单(
self, client: TestClient, admin: dict, finance: dict, leader: dict, switched_on
) -> None:
"""保守规则:只有「存在在途单且内容一致」才不重提。
方案已审批通过后再保存,仍按原语义重新送审。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
client.post(
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
)
client.post(
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=leader["headers"]
)
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(detail)
payload["remark"] = "已通过后再保存"
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
rows = client.get(
_APPROVALS,
params={"keyword": data["business_no"], "pageSize": 10},
headers=finance["headers"],
).json()["data"]["list"]
assert len(rows) == 2, "无在途单时保存应重新送审"
assert max(r["version_no"] for r in rows) == 2
class TestPendingSnapshotMatch:
"""在途单送审内容一致性判定(approval_service.pending_matches_snapshot)。"""
def test_三种情形(self, client: TestClient, admin: dict, finance: dict, switched_on) -> None:
from app.core.db import get_session_factory
from app.services import approval_service
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
snapshot = order["snapshot"]["data"]
factory = get_session_factory()
with factory() as session:
# 1) 内容一致 → True
assert (
approval_service.pending_matches_snapshot(
session,
order_type=1,
ref_id=data["id"],
snapshot=snapshot,
)
is True
)
# 2) 内容不同 → False
assert (
approval_service.pending_matches_snapshot(
session, order_type=1, ref_id=data["id"], snapshot={"allocations": []}
)
is False
)
# 3) 无在途单 → False(引用对象换成别的)
assert (
approval_service.pending_matches_snapshot(
session, order_type=1, ref_id=999_999_999, snapshot=snapshot
)
is False
)
# 4) 快照为 None → False
assert (
approval_service.pending_matches_snapshot(
session, order_type=1, ref_id=data["id"], snapshot=None
)
is False
)
class TestAllocationVersions:
"""二次分配方案版本化(code-review 遗留①)。
守的是:编辑替换与驳回清空只把旧行置历史(is_active=0),不物理删除,
方案参与人与比例可回溯;生效行始终唯一,核算取数不受历史行干扰。
"""
def test_编辑方案产生新版本_旧版本保留(
self, client: TestClient, admin: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in detail["allocation_history"]] == [(1, 1)]
payload = business_payload(
detail["customer_id"],
secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
history = [
(h["version_no"], h["is_active"], h["ratio"]) for h in detail["allocation_history"]
]
assert history == [(1, 0, "50.00"), (2, 1, "80.00")]
assert [a["ratio"] for a in detail["secondary_allocations"]] == ["80.00"]
def test_驳回后生效行为空_历史行保留(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 2, "opinion": "方案不合理"},
headers=finance["headers"],
)
assert resp.status_code == 200, resp.text
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert detail["secondary_allocations"] == [], "被驳回方案不得留在生效行"
assert detail["allocation_hint"] is None
history = detail["allocation_history"]
assert len(history) == 1
assert history[0]["version_no"] == 1
assert history[0]["is_active"] == 0
assert history[0]["ratio"] == "50.00"

View File

@ -1,183 +0,0 @@
"""审批单版本号并发防线(V12 唯一键 + 锁读取号)。
守的是:
- **取号必须读最新已提交值**:请求级鉴权与业务共用同一 DbSession,鉴权
的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻;父行锁只
串行化写入时刻、不刷新快照——取号若是快照读,并发提单会算出同一个
version_no(两会话法做行为级回归锁,不依赖真多线程)
- **取号查询带 FOR UPDATE 且不用聚合**:MySQL 官方不允许聚合函数携带
锁子句(Locking clauses are not permitted in queries with aggregate
functions or GROUP BY),聚合上的 FOR UPDATE 语义无保证
- **DB 层唯一键生效**:即使应用层取号缺陷被绕过,重复
(order_type, ref_id, version_no) 也必须在库上被拒(1062)
- **冲突收敛为 400**:编辑路径撞唯一键 → 审计上下文回滚 → 业务提示
可重试,业务数据不得写坏
- **正常路径版本号仍递增**:锁读改写不破坏既有 1→2→3 行为
"""
import uuid
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import event, text
from app.core.db import get_engine, get_session_factory
from app.core.db_errors import is_duplicate_entry
from app.models import ApprovalOrder
from tests.integration.factories import business_payload
from tests.integration.test_approval_flow import (
_BUSINESSES,
_create_with_plan,
_latest_order,
_person,
_set_switch,
)
pytestmark = pytest.mark.integration
@pytest.fixture
def switched_on(client: TestClient, admin: dict) -> None:
"""把二次分配审批开关置 1(用例结束由 conftest 恢复种子基线)。
pytest 夹具只在定义它的模块与 conftest 中可见,跨模块直接引用会报
``fixture 'switched_on' not found``;这里用 ``_set_switch`` 本地重建。
"""
_set_switch(client, admin["headers"], "1")
class TestNextVersionLockRead:
"""取号查询的锁读口径(真实 SQL 形态 + 行为级快照回归)。"""
def test_取号查询带FOR_UPDATE_不含聚合(self, client: TestClient, admin: dict) -> None:
"""抓 _next_version 真实发出的 SQL(比本地重造语句再编译更贴真):
必须含 FOR UPDATE、不含 MAX( —— 聚合上的锁子句 MySQL 不保证语义。"""
captured: list[str] = []
def _spy(_conn, _cursor, statement, *_rest):
if "approval_orders" in statement and "version_no" in statement:
captured.append(statement)
engine = get_engine()
event.listen(engine, "before_cursor_execute", _spy)
try:
p = _person(client, admin["headers"])
_create_with_plan(client, admin["headers"], p["id"]) # 触发一次真实提单
finally:
event.remove(engine, "before_cursor_execute", _spy)
assert captured, "未捕获到取号查询"
assert any("FOR UPDATE" in sql.upper() for sql in captured), captured
assert not any("MAX(" in sql.upper() for sql in captured), captured
def test_版本号读最新已提交值_不受事务旧快照影响(self) -> None:
"""两会话法(无需线程):s1 先锚定快照 → s2 提交新版本 →
s1 取号必须读到 3 而非旧快照里的 2——守住「快照读 vs 当前读」根因。"""
from app.services import approval_service
factory = get_session_factory()
with factory() as s1:
# 锚定快照(模拟请求级鉴权读先于业务写发生)
s1.execute(text("SELECT order_no FROM approval_orders LIMIT 1")).all()
with factory() as s2:
order = ApprovalOrder(
order_no=f"AP-{uuid.uuid4().hex[:12]}",
order_type=1,
ref_id=987654321,
refer_no="YW-SNAPSHOT-TEST",
version_no=2,
status=5,
submitter_name="t_snapshot_probe",
)
s2.add(order)
s2.commit()
# s1 的普通读仍停在旧快照(该分组在快照里不存在)
stale = s1.execute(
text(
"SELECT COALESCE(MAX(version_no),0) FROM approval_orders "
"WHERE order_type=1 AND ref_id=987654321"
)
).scalar_one()
assert stale == 0, "前置失效:快照读应停在锚定时刻,先修隔离级别再跑本用例"
assert approval_service._next_version(s1, 1, 987654321) == 3, (
"取号必须读到最新已提交值(2+1=3),读到 2 说明退化为快照读"
)
s1.rollback()
def test_唯一键生效_重复版本号被拒(self) -> None:
"""重复 (order_type, ref_id, version_no) 直插必须撞 1062——
应用层取号即使被绕过,DB 也是最后一道防线。"""
from sqlalchemy.exc import IntegrityError
factory = get_session_factory()
with factory() as s:
common = {
"order_type": 1,
"ref_id": 987654322,
"refer_no": "YW-UK-TEST",
"status": 5,
"submitter_name": "t_uk_probe",
}
s.add(ApprovalOrder(order_no=f"AP-{uuid.uuid4().hex[:12]}", version_no=1, **common))
s.flush()
s.add(ApprovalOrder(order_no=f"AP-{uuid.uuid4().hex[:12]}", version_no=1, **common))
with pytest.raises(IntegrityError) as exc_info:
s.flush()
assert is_duplicate_entry(exc_info.value), "应为唯一键冲突(1062)而非其他完整性错误"
s.rollback()
def test_正常路径版本号仍递增(self, client: TestClient, admin: dict, switched_on) -> None:
"""常规编辑两次:送审版本号 1→2→3——锁读改写不得破坏既有行为。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, admin["headers"], data["business_no"])
assert first["version_no"] == 1
for ratio in ("60.00", "70.00"):
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()[
"data"
]
payload = business_payload(
detail["customer_id"],
secondary_allocations=[{"personnel_id": p["id"], "ratio": ratio}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
third = _latest_order(client, admin["headers"], data["business_no"])
assert third["version_no"] == 3
class TestConflictConvergence:
"""编辑路径撞版本号唯一键 → 收敛为可重试的 400,业务数据不写坏。"""
def test_编辑路径版本号冲突_收敛为400(
self, client: TestClient, admin: dict, switched_on, monkeypatch
) -> None:
from app.services import approval_service
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
before = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
# 模拟取号读到过期快照:恒返回 1(库中 V1 已存在)→ 撞唯一键
monkeypatch.setattr(approval_service, "_next_version", lambda *a, **k: 1)
payload = business_payload(
before["customer_id"],
secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 400, resp.text
assert "版本号生成重复" in resp.json()["message"]
monkeypatch.undo()
# 业务数据不得写坏:整个事务回滚,方案仍是 50.00(V1 生效行)
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [a["ratio"] for a in after["secondary_allocations"]] == ["50.00"]
assert [h["version_no"] for h in after["allocation_history"]] == [1]

View File

@ -13,24 +13,21 @@ audit_service.py「上下文内不得吞掉异常后正常退出」的约定相
""" """
import uuid import uuid
from datetime import date
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import func, select from sqlalchemy import func, select
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS, MODULE_RECEIPT from app.core.constants import ACTION_CREATE, MODULE_BUSINESS
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.main import app from app.main import app
from app.models import OperationLog from app.models import OperationLog
from app.services import business_service, receipt_service from app.services import business_service
from tests.integration.factories import create_business
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
_BUSINESSES = "/api/v1/business/businesses" _BUSINESSES = "/api/v1/business/businesses"
_CUSTOMERS = "/api/v1/base/customers" _CUSTOMERS = "/api/v1/base/customers"
_RECEIPTS = "/api/v1/business/receipts"
@pytest.fixture @pytest.fixture
@ -163,94 +160,3 @@ class TestAuditRetry:
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志 # 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
assert _log_count(normal["username"], success=False) == base_fail + 1 assert _log_count(normal["username"], success=False) == base_fail + 1
assert _log_count(normal["username"], success=True) == base_ok assert _log_count(normal["username"], success=True) == base_ok
def _receipt_log_count(username: str, *, success: bool) -> int:
"""按操作人统计「新增回款」操作日志条数。"""
factory = get_session_factory()
with factory() as session:
stmt = (
select(func.count())
.select_from(OperationLog)
.where(
OperationLog.username == username,
OperationLog.module == MODULE_RECEIPT,
OperationLog.action == ACTION_CREATE,
OperationLog.summary.like("新增回款%"),
OperationLog.success == int(success),
)
)
return int(session.execute(stmt).scalar_one())
def _receipt_payload(business_id: int) -> dict:
return {
"business_id": business_id,
"receipt_date": str(date.today()),
"amount": "1200.00",
}
class TestReceiptAuditRetry:
"""回款新增的 1062 重试路径(回款与业务同构,但走独立实现)。
回款侧的重试循环本次从 create 函数体内移入 helper 调用,重试边界、
审计条数、批次号重取都必须保持原样 —— 用例会盯住这三条。
"""
def test_首次撞号重试成功_审计恰一条真成功(
self, quiet_client: TestClient, normal: dict, monkeypatch
) -> None:
client = quiet_client
business = create_business(client, normal["headers"])
first = client.post(
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
)
assert first.status_code == 200, first.text
existing_no = first.json()["data"]["receipt_batch_no"]
real_generate = receipt_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(receipt_service, "_generate_no", flaky_generate)
base_ok = _receipt_log_count(normal["username"], success=True)
base_fail = _receipt_log_count(normal["username"], success=False)
second = client.post(
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
)
assert second.status_code == 200, second.text
assert calls["n"] == 2, "重试应恰好再取一次号"
assert _receipt_log_count(normal["username"], success=True) == base_ok + 1
assert _receipt_log_count(normal["username"], success=False) == base_fail
def test_持续冲突_最终失败且审计恰一条失败(
self, quiet_client: TestClient, normal: dict, monkeypatch
) -> None:
client = quiet_client
business = create_business(client, normal["headers"])
first = client.post(
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
)
assert first.status_code == 200, first.text
existing_no = first.json()["data"]["receipt_batch_no"]
def always_conflict(session):
return existing_no
monkeypatch.setattr(receipt_service, "_generate_no", always_conflict)
base_ok = _receipt_log_count(normal["username"], success=True)
base_fail = _receipt_log_count(normal["username"], success=False)
second = client.post(
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
)
assert second.status_code == 400, second.text
assert _receipt_log_count(normal["username"], success=False) == base_fail + 1
assert _receipt_log_count(normal["username"], success=True) == base_ok

View File

@ -452,7 +452,9 @@ class TestEdit:
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "含合同负责营销人员" in resp.json()["message"] assert "含合同负责营销人员" in resp.json()["message"]
def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, normal: dict) -> None: def test_二次分配方案合法比例_保存成功且回显(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"] headers = normal["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
owner = _personnel(client, headers) owner = _personnel(client, headers)

View File

@ -11,6 +11,7 @@ append-only 完整性;「公司名称+负责人」组合唯一缺失会放行
""" """
import uuid import uuid
from decimal import Decimal
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@ -378,71 +379,102 @@ class TestAudit:
assert "→" in latest.summary, "摘要应体现新旧归属走向" assert "→" in latest.summary, "摘要应体现新旧归属走向"
class TestRating: # ==================== 客户评级列(V11) ====================
"""评级保存与列表排序 —— 评分是档案属性,只存总分与评级时间。
守的是:评分只走专属端点(编辑端点不含评分,旁路写评分会绕开口径); _RATING_KEYS = (
分值红线 0-100 在入口拦截;按评分排序时未评分客户必须垫底,不能因 "score_ship_amount",
MySQL 把 NULL 视为最小值而在升序里压过打了低分的客户。 "score_receipt_rate",
""" "score_profit_rate",
"score_contract_cooperation",
"score_site_construction",
)
def test_保存评级_回显总分与时间_且编辑端点不含评分(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
assert customer["rating_score"] is None, "新客户应无评分"
def _rate(client: TestClient, headers: dict, customer_id: int, scores: list[str]) -> dict:
"""保存客户评级(五项 0-20),返回响应 data。"""
resp = client.put( resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 88}, headers=headers f"{_CUSTOMERS}/{customer_id}/rating",
) json=dict(zip(_RATING_KEYS, scores, strict=True)),
assert resp.status_code == 200, resp.text
body = resp.json()["data"]
assert body["rating_score"] == 88
assert body["rating_time"] is not None, "保存评分必须刷新评级时间"
# 覆盖式保存:新分数替换旧分数
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 60}, headers=headers
)
assert resp.status_code == 200, resp.text
assert resp.json()["data"]["rating_score"] == 60
def test_评分越界_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 101}, headers=headers
)
assert resp.status_code == 400, resp.text
def test_按评分排序_未评分垫底_升序降序都不压过低分(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"]
high = _create_customer(client, headers)
low = _create_customer(client, headers)
unrated = _create_customer(client, headers)
for target, score in ((high, 80), (low, 60)):
resp = client.put(
f"{_CUSTOMERS}/{target['id']}/rating", json={"score": score}, headers=headers
)
assert resp.status_code == 200, resp.text
def _order_names(order: str) -> list[str]:
resp = client.get(
_CUSTOMERS,
params={"pageNum": 1, "pageSize": 50, "order_by": "rating_score", "order": order},
headers=headers, headers=headers,
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
names = [row["company_name"] for row in resp.json()["data"]["list"]] return resp.json()["data"]
return names
# 降序:高分在前,未评分最后;升序:低分在前,未评分仍垫底
desc_names = _order_names("desc") def _list_by_keyword(client: TestClient, headers: dict, keyword: str, **params) -> list[dict]:
assert desc_names.index(high["company_name"]) < desc_names.index(low["company_name"]) query: dict = {"keyword": keyword, "pageNum": 1, "pageSize": 50, **params}
assert desc_names[-1] == unrated["company_name"] resp = client.get(_CUSTOMERS, params=query, headers=headers)
asc_names = _order_names("asc") assert resp.status_code == 200, resp.text
assert asc_names.index(low["company_name"]) < asc_names.index(high["company_name"]) return resp.json()["data"]["list"]
assert asc_names[-1] == unrated["company_name"], "未评分必须垫底"
class TestRatingColumn:
"""客户评级列:列表带出评级快照,列头排序走服务端(未评级恒在末尾)。
列表是服务端分页,排序必须在 SQL 层完成 —— 前端只传
``order_field=rating`` + ``order_type``;MySQL 无 NULLS LAST,
未评级行用布尔键模拟,正序/倒序都在末尾。
"""
def _seed_three(self, client: TestClient, headers: dict) -> tuple[str, dict, dict, dict]:
"""建三名同批次客户:90 分(A)、0 分(D)、未评级。"""
token = uuid.uuid4().hex[:8]
keyword = f"t_公司_{token}"
high = _create_customer(client, headers, company_name=f"{keyword}_甲")
low = _create_customer(client, headers, company_name=f"{keyword}_乙")
none = _create_customer(client, headers, company_name=f"{keyword}_丙")
_rate(client, headers, high["id"], ["18"] * 5)
_rate(client, headers, low["id"], ["0"] * 5)
return keyword, high, low, none
def test_列表带出评级字段(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
keyword, high, low, none = self._seed_three(client, headers)
rows = _list_by_keyword(client, headers, keyword)
by_id = {row["id"]: row for row in rows}
assert Decimal(by_id[high["id"]]["rating_total"]) == Decimal("90")
assert by_id[high["id"]]["rating_grade_name"] == "A"
assert Decimal(by_id[low["id"]]["rating_total"]) == Decimal("0")
assert by_id[low["id"]]["rating_grade_name"] == "D"
assert by_id[none["id"]]["rating_total"] is None
assert by_id[none["id"]]["rating_grade_name"] is None
def test_评级列正序倒序_未评级恒末尾(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
keyword, high, low, none = self._seed_three(client, headers)
desc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="desc")
assert [row["id"] for row in desc] == [high["id"], low["id"], none["id"]]
asc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="asc")
assert [row["id"] for row in asc] == [low["id"], high["id"], none["id"]]
def test_非法排序字段_400(self, client: TestClient, normal: dict) -> None:
"""排序字段是白名单(Literal):不透传给 SQL,非法值 400。"""
resp = client.get(
_CUSTOMERS,
params={"order_field": "company_name", "order_type": "asc"},
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
def test_非法排序方向_400(self, client: TestClient, normal: dict) -> None:
resp = client.get(
_CUSTOMERS,
params={"order_field": "rating", "order_type": "sideways"},
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
def test_删除客户联动删评级行(self, client: TestClient, normal: dict) -> None:
"""评级行 FK RESTRICT:删除客户须先清评级行,否则 500。"""
headers = normal["headers"]
customer = _create_customer(client, headers)
_rate(client, headers, customer["id"], ["20"] * 5)
resp = client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers)
assert resp.status_code == 200, resp.text
assert (
client.get(f"{_CUSTOMERS}/{customer['id']}/rating", headers=headers).status_code == 404
)

View File

@ -118,20 +118,10 @@ def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> d
return resp.json()["data"] return resp.json()["data"]
def _activities(client: TestClient, headers: dict, **params) -> dict:
"""最近动态分页端点。"""
resp = client.get(f"{_DASHBOARD}/activities", params=params, headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def test_权限_普通用户403文案(client: TestClient, normal: dict): def test_权限_普通用户403文案(client: TestClient, normal: dict):
resp = client.get(_DASHBOARD, headers=normal["headers"]) resp = client.get(_DASHBOARD, headers=normal["headers"])
assert resp.status_code == 403 assert resp.status_code == 403
assert resp.json()["message"] == "无权访问工作台" assert resp.json()["message"] == "无权访问工作台"
# 最近动态分页端点同属工作台,权限一致
resp = client.get(f"{_DASHBOARD}/activities", headers=normal["headers"])
assert resp.status_code == 403
def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict): def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict):
@ -148,7 +138,9 @@ def test_总览_绩效合计与台账一致且非法月份400(client: TestClient
expected = Decimal( expected = Decimal(
str( str(
session.execute( session.execute(
select(func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)).where( select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)
).where(
PerformanceLedger.settlement_month == _month(), PerformanceLedger.settlement_month == _month(),
PerformanceLedger.calc_type == 1, PerformanceLedger.calc_type == 1,
PerformanceLedger.is_active == 1, PerformanceLedger.is_active == 1,
@ -174,12 +166,16 @@ def test_待办_待审批与未指定批次与同步降级(client: TestClient, a
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
settlement_id = resp.json()["data"]["id"] settlement_id = resp.json()["data"]["id"]
resp = client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]) resp = client.post(
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
data = _dashboard(client, admin["headers"]) data = _dashboard(client, admin["headers"])
assert data["todos"]["pending_approval_total"] >= 1 assert data["todos"]["pending_approval_total"] >= 1
assert any(item["order_type"] == 2 for item in data["todos"]["pending_approvals"]) assert any(
item["order_type"] == 2 for item in data["todos"]["pending_approvals"]
)
assert data["todos"]["unassigned_receipt_total"] >= 1 assert data["todos"]["unassigned_receipt_total"] >= 1
assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"] assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"]
# ⑥ 未建:显式降级 # ⑥ 未建:显式降级
@ -231,40 +227,9 @@ def test_预警_审批超时命中且参数关闭后不产生(client: TestClient
def test_动态_来源操作日志(client: TestClient, admin: dict): def test_动态_来源操作日志(client: TestClient, admin: dict):
_chain(client, admin["headers"]) _chain(client, admin["headers"])
data = _activities(client, admin["headers"]) data = _dashboard(client, admin["headers"])
assert data["list"], "工作台动态为空:写操作应产生操作日志" assert data["activities"], "工作台动态为空:写操作应产生操作日志"
assert {"module", "action", "summary", "create_time"} <= set(data["list"][0]) assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0])
def test_动态_分页翻页不重不漏(client: TestClient, admin: dict):
_chain(client, admin["headers"])
first = _activities(client, admin["headers"], pageNum=1, pageSize=2)
assert first["pageNum"] == 1
assert first["pageSize"] == 2
assert len(first["list"]) == 2
assert first["total"] >= 2
second = _activities(client, admin["headers"], pageNum=2, pageSize=2)
assert len(second["list"]) == 2
# 两页不重不漏(倒序取,按 id 倒排)
first_ids = {item["id"] for item in first["list"]}
second_ids = {item["id"] for item in second["list"]}
assert first_ids.isdisjoint(second_ids)
assert first["list"][0]["id"] > second["list"][0]["id"]
def test_动态_模块筛选与筛选项(client: TestClient, admin: dict):
_chain(client, admin["headers"])
page = _activities(client, admin["headers"], pageSize=50)
assert page["modules"], "模块筛选项不应为空"
module = page["modules"][0]
filtered = _activities(client, admin["headers"], module=module, pageSize=50)
assert filtered["total"] >= 1
assert filtered["list"], "按模块筛选后应有数据"
assert all(item["module"] == module for item in filtered["list"])
# 筛选项不随筛选条件变化(下拉里始终是全量模块)
assert filtered["modules"] == page["modules"]
# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)==== # ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====
@ -285,13 +250,7 @@ def leader(make_user, auth_headers) -> dict:
def _insert_approval(status: int) -> None: def _insert_approval(status: int) -> None:
"""直插在途审批单(避免依赖两次流转,专注工作台过滤口径)。 """直插在途审批单(避免依赖两次流转,专注工作台过滤口径)。"""
``ref_id`` 仅作对象分组锚点、本用例不校验:**每张单必须给不同的
ref_id**——V12 的 ``uk_approval_orders_order_type_ref_version`` 要求
(order_type, ref_id, version_no) 唯一,同一假对象连插两张 version_no=1
会撞 1062。
"""
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.models import ApprovalOrder from app.models import ApprovalOrder
@ -301,7 +260,7 @@ def _insert_approval(status: int) -> None:
ApprovalOrder( ApprovalOrder(
order_no=f"AP{uuid.uuid4().hex[:12]}", order_no=f"AP{uuid.uuid4().hex[:12]}",
order_type=2, order_type=2,
ref_id=status, ref_id=0,
refer_no="t_工作台", refer_no="t_工作台",
status=status, status=status,
submitter_name="t_admin", submitter_name="t_admin",
@ -370,17 +329,17 @@ def test_待办_按角色仅显示可操作节点(
assert _dashboard(client, both["headers"])["todos"]["pending_approval_total"] == 2 assert _dashboard(client, both["headers"])["todos"]["pending_approval_total"] == 2
finance_orders = { finance_orders = {
item["order_no"] item["order_no"] for item in _dashboard(client, finance["headers"])["todos"]["pending_approvals"]
for item in _dashboard(client, finance["headers"])["todos"]["pending_approvals"]
} }
leader_orders = { leader_orders = {
item["order_no"] item["order_no"] for item in _dashboard(client, leader["headers"])["todos"]["pending_approvals"]
for item in _dashboard(client, leader["headers"])["todos"]["pending_approvals"]
} }
assert finance_orders.isdisjoint(leader_orders) assert finance_orders.isdisjoint(leader_orders)
def test_待办_财务通过后领导出现待办(client: TestClient, admin: dict, finance: dict, leader: dict): def test_待办_财务通过后领导出现待办(
client: TestClient, admin: dict, finance: dict, leader: dict
):
"""真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。""" """真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。"""
_chain(client, admin["headers"]) _chain(client, admin["headers"])
resp = client.post( resp = client.post(
@ -388,7 +347,9 @@ def test_待办_财务通过后领导出现待办(client: TestClient, admin: dic
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
settlement_id = resp.json()["data"]["id"] settlement_id = resp.json()["data"]["id"]
resp = client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]) resp = client.post(
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
order = _dashboard(client, admin["headers"])["todos"]["pending_approvals"][0] order = _dashboard(client, admin["headers"])["todos"]["pending_approvals"][0]

View File

@ -326,38 +326,6 @@ class TestPreview:
# Fraction 自动约分:4/1000 存储为 1/250,按数值断言 # Fraction 自动约分:4/1000 存储为 1/250,按数值断言
assert Fraction(data["rate"]) == Fraction(4, 1000) assert Fraction(data["rate"]) == Fraction(4, 1000)
def test_预览参数使用顺序稳定(self, client: TestClient, admin: dict) -> None:
"""params_used 的顺序即前端「用了哪些参数」的展示顺序。
预览实现拆成多段后,各段的调用先后决定参数读取先后;顺序漂移会让
复核者看到的参数清单与核算口径对不上(不报错,但会误导判读)。
"""
payload = {
"business_role": "salesperson",
"customer_type": "personal",
"product_category": "municipal_water",
"ship_date": "2025-12-15",
"receipt_date": "2026-01-10",
"revenue": "100000",
"profit": "45000",
"received_amount": "60000",
"barter": True,
"probation": True,
}
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
"data"
]
assert [p["param_code"] for p in data["params_used"]] == [
"salesperson.rate.start",
"salesperson.rate.slope",
"salesperson.rate.floor",
"salesperson.days.boundary",
"product.threshold",
"barter.discount",
"payment.start.ratio",
"probation.annual.threshold",
]
def test_经理聚合口径_预览拒绝(self, client: TestClient, admin: dict) -> None: def test_经理聚合口径_预览拒绝(self, client: TestClient, admin: dict) -> None:
payload = { payload = {
"business_role": "sales_manager", "business_role": "sales_manager",
@ -371,11 +339,7 @@ class TestPreview:
} }
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]) resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
assert resp.status_code == 400 assert resp.status_code == 400
# 守两件事:① 聚合口径不允许单笔试算(拒绝而不是算出个错数); assert "聚合" in resp.json()["message"]
# ② 提示要能指导下一步(点名是谁的口径 + 去哪儿看),不是一句「不支持」。
message = resp.json()["message"]
assert "营销经理" in message
assert "月度结算" in message
def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None: def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None:
payload = { payload = {

View File

@ -21,15 +21,17 @@ from decimal import Decimal
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import select from sqlalchemy import func, select
from app.core.constants import ACTION_CREATE, MODULE_RATING from app.core.constants import ACTION_CREATE, MODULE_RATING
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.models import OperationLog from app.models import CustomerRating, CustomerRatingGrade, OperationLog
from app.services.rating_service import resolve_grade
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
_GRADES = "/api/v1/business/rating-grades" _GRADES = "/api/v1/business/rating-grades"
_CUSTOMERS = "/api/v1/base/customers"
# 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1)) # 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1))
_SEED = [ _SEED = [
@ -77,6 +79,41 @@ def _names(client: TestClient, headers: dict) -> list[str]:
return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]] return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]]
def _create_customer(client: TestClient, headers: dict) -> dict:
"""建一名 t_ 前缀测试客户(评级落库的被引用方,conftest 兜底清理)。"""
resp = client.post(
_CUSTOMERS,
json={
"company_name": f"t_评级客户_{uuid.uuid4().hex[:8]}",
"owner_name": "t_老板",
"customer_type": "personal",
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
_RATING_KEYS = (
"score_ship_amount",
"score_receipt_rate",
"score_profit_rate",
"score_contract_cooperation",
"score_site_construction",
)
def _rating_url(customer_id: int) -> str:
return f"{_CUSTOMERS}/{customer_id}/rating"
def _rating_payload(scores: list[str], remark: str | None = None) -> dict:
body: dict = dict(zip(_RATING_KEYS, scores, strict=True))
if remark is not None:
body["remark"] = remark
return body
def _logs_of(username: str) -> list[OperationLog]: def _logs_of(username: str) -> list[OperationLog]:
"""取某操作人的审计行(按 id 倒序,最新在前)。""" """取某操作人的审计行(按 id 倒序,最新在前)。"""
factory = get_session_factory() factory = get_session_factory()
@ -258,8 +295,8 @@ class TestEdit:
class TestDelete: class TestDelete:
"""删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表 """删除允许清空(客户评级快照固化等级名、无外键引用)—— 删除后判定表
立即反映,二次删除 404。""" 立即反映,二次删除 404,已评级客户的历史等级快照不受影响。"""
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None: def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"] headers = normal["headers"]
@ -313,3 +350,146 @@ class TestAudit:
] ]
assert failed, "失败操作未留痕" assert failed, "失败操作未留痕"
assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0] assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0]
class TestResolveGrade:
"""判定纯函数边界:与前端打分卡同规则(sort_order 升序首个 min_score ≤ 总分)。
单边判定让边界分归 sort_order 靠前的等级:90→A、75→B、60→C、59.9→D;
等级表为空或总分低于全部下限时返回 None(落库等级为 NULL)。
"""
@staticmethod
def _seed_grades() -> list[CustomerRatingGrade]:
return [
CustomerRatingGrade(
grade_name=name,
min_score=Decimal(min_score),
max_score=Decimal(max_score),
sort_order=order,
)
for name, min_score, max_score, order in _SEED
]
def test_边界分归高档(self) -> None:
grades = self._seed_grades()
assert resolve_grade(grades, Decimal("90")).grade_name == "A"
assert resolve_grade(grades, Decimal("75")).grade_name == "B"
assert resolve_grade(grades, Decimal("60")).grade_name == "C"
def test_下限下方归最低档(self) -> None:
assert resolve_grade(self._seed_grades(), Decimal("59.9")).grade_name == "D"
def test_等级表为空_未命中(self) -> None:
assert resolve_grade([], Decimal("80")) is None
def test_低于全部下限_未命中(self) -> None:
grades = [
CustomerRatingGrade(
grade_name="A",
min_score=Decimal("90"),
max_score=Decimal("100"),
sort_order=1,
)
]
assert resolve_grade(grades, Decimal("50")) is None
class TestCustomerRating:
"""客户评级落库(V11):每客户一行、服务端重算总分与等级、等级快照固化。
五项各 0-20 → 总分 0-100;等级按保存时点判定并写入 grade_name(无外键)。
未评级返回 data=null(正常态);分数越界 400 且审计留痕。
"""
def test_未评级返回null(self, client: TestClient, normal: dict) -> None:
customer = _create_customer(client, normal["headers"])
resp = client.get(_rating_url(customer["id"]), headers=normal["headers"])
assert resp.status_code == 200, resp.text
assert resp.json()["data"] is None
def test_保存评级_服务端重算总分等级并留审计(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
put = client.put(
_rating_url(customer["id"]),
json=_rating_payload(["18", "20", "19", "17", "16"], remark="t_评级依据"),
headers=headers,
)
assert put.status_code == 200, put.text
data = put.json()["data"]
assert Decimal(data["total_score"]) == Decimal("90")
assert data["grade_name"] == "A"
assert data["remark"] == "t_评级依据"
assert data["scorer_name"] == normal["username"]
# 回读一致(列表评级列与弹窗回显同源)
got = client.get(_rating_url(customer["id"]), headers=headers).json()["data"]
assert Decimal(got["total_score"]) == Decimal("90")
assert got["grade_name"] == "A"
assert Decimal(got["score_ship_amount"]) == Decimal("18")
logs = [log for log in _logs_of(normal["username"]) if log.module == MODULE_RATING]
assert logs, "保存客户评级未留操作日志"
assert logs[0].success == 1
assert customer["company_name"] in logs[0].summary
assert "评级" in logs[0].summary
def test_重复保存覆盖同一客户仅一行(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
first = client.put(
_rating_url(customer["id"]), json=_rating_payload(["0"] * 5), headers=headers
)
assert first.status_code == 200, first.text
assert first.json()["data"]["grade_name"] == "D"
second = client.put(
_rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers
)
assert second.status_code == 200, second.text
assert Decimal(second.json()["data"]["total_score"]) == Decimal("100")
assert second.json()["data"]["grade_name"] == "A"
factory = get_session_factory()
with factory() as session:
count = session.execute(
select(func.count())
.select_from(CustomerRating)
.where(CustomerRating.customer_id == customer["id"])
).scalar_one()
assert count == 1, "同一客户出现多行评级(upsert 失效)"
def test_单项分数越界_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
for bad in (["21", "0", "0", "0", "0"], ["0", "-1", "0", "0", "0"]):
resp = client.put(
_rating_url(customer["id"]), json=_rating_payload(bad), headers=headers
)
assert resp.status_code == 400, resp.text
assert "0~20" in resp.json()["message"], resp.text
def test_客户不存在_404(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
assert client.get(_rating_url(999_999_999), headers=headers).status_code == 404
put = client.put(_rating_url(999_999_999), json=_rating_payload(["0"] * 5), headers=headers)
assert put.status_code == 404, put.text
def test_等级表清空后保存_等级为null(self, client: TestClient, normal: dict) -> None:
"""等级映射允许清空(快照设计前提):清空后评分照常保存,仅等级为 NULL。"""
headers = normal["headers"]
for name in ("A", "B", "C", "D"):
assert (
client.delete(
f"{_GRADES}/{_seed_id(client, headers, name)}", headers=headers
).status_code
== 200
)
customer = _create_customer(client, headers)
data = client.put(
_rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers
).json()["data"]
assert Decimal(data["total_score"]) == Decimal("100")
assert data["grade_name"] is None

View File

@ -44,9 +44,7 @@ def _role_id(code: str) -> int:
) )
def _person( def _person(client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None) -> dict:
client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None
) -> dict:
body = { body = {
"personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}", "personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}",
"business_role_id": _role_id(role), "business_role_id": _role_id(role),
@ -196,7 +194,9 @@ def test_部门汇总_无成员经理出零值行(admin, client: TestClient):
headers=admin["headers"], headers=admin["headers"],
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
team = next((t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None) team = next(
(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None
)
assert team is not None assert team is not None
assert Decimal(str(team["team_base"])) == Decimal("0") assert Decimal(str(team["team_base"])) == Decimal("0")
assert team["member_count"] == 0 assert team["member_count"] == 0
@ -224,14 +224,7 @@ def test_回款统计_空桶恒在且分摊拆行金额只计一次(admin, clien
assert len(data["buckets"]) == 8 assert len(data["buckets"]) == 8
assert [b["code"] for b in data["buckets"]] == [ assert [b["code"] for b in data["buckets"]] == [
"le0", "le0", "1-30", "31-60", "61-90", "91-180", "181-360", "361+", "none",
"1-30",
"31-60",
"61-90",
"91-180",
"181-360",
"361+",
"none",
] ]
# 60 天前发货 → 落在 61-90 桶(发货日首日计第 1 天) # 60 天前发货 → 落在 61-90 桶(发货日首日计第 1 天)
bucket = next(b for b in data["buckets"] if b["code"] == "61-90") bucket = next(b for b in data["buckets"] if b["code"] == "61-90")
@ -300,15 +293,11 @@ def test_导出_excel含签字栏且入操作日志(admin, client: TestClient):
factory = get_session_factory() factory = get_session_factory()
with factory() as session: with factory() as session:
log = ( log = session.execute(
session.execute(
select(OperationLog) select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出") .where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc()) .order_by(OperationLog.id.desc())
) ).scalars().first()
.scalars()
.first()
)
assert log is not None assert log is not None
@ -400,21 +389,19 @@ def _latest_export_log():
factory = get_session_factory() factory = get_session_factory()
with factory() as session: with factory() as session:
return ( return session.execute(
session.execute(
select(OperationLog) select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出") .where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc()) .order_by(OperationLog.id.desc())
) ).scalars().first()
.scalars()
.first()
)
def test_部门汇总_无台账经理挂上级不报500(admin, client: TestClient): def test_部门汇总_无台账经理挂上级不报500(admin, client: TestClient):
"""经理 A 无台账且上级是经理 B:不能因 member_map 直接索引而 KeyError。""" """经理 A 无台账且上级是经理 B:不能因 member_map 直接索引而 KeyError。"""
manager_b = _person(client, admin["headers"], role="sales_manager") manager_b = _person(client, admin["headers"], role="sales_manager")
manager_a = _person(client, admin["headers"], role="sales_manager", manager_id=manager_b["id"]) manager_a = _person(
client, admin["headers"], role="sales_manager", manager_id=manager_b["id"]
)
resp = client.get( resp = client.get(
f"{_REPORTS}/department-summary", f"{_REPORTS}/department-summary",
@ -480,7 +467,9 @@ def test_部门汇总_经理个人户计入时基数等于成员合计(admin, cl
headers=admin["headers"], headers=admin["headers"],
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
team = next(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]) team = next(
t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]
)
assert Decimal(str(team["team_base"])) == Decimal("100.00") assert Decimal(str(team["team_base"])) == Decimal("100.00")
assert sum( assert sum(
(Decimal(str(m["performance_sum"])) for m in team["members"]), Decimal("0") (Decimal(str(m["performance_sum"])) for m in team["members"]), Decimal("0")
@ -527,7 +516,9 @@ def test_部门汇总_按经理筛选不误判其他团队下属(admin, client:
assert [t["manager_id"] for t in data["teams"]] == [manager_1["id"]] assert [t["manager_id"] for t in data["teams"]] == [manager_1["id"]]
assert data["unassigned"] == [] assert data["unassigned"] == []
assert all( assert all(
m["personnel_id"] != salesperson_2["id"] for team in data["teams"] for m in team["members"] m["personnel_id"] != salesperson_2["id"]
for team in data["teams"]
for m in team["members"]
) )
assert Decimal(str(data["reconciliation"]["other_team_base"])) > 0 assert Decimal(str(data["reconciliation"]["other_team_base"])) > 0
@ -566,7 +557,9 @@ def test_部门汇总_对账恒等式(admin, client: TestClient):
) )
def _two_manager_scene(client: TestClient, headers: dict) -> tuple[dict, dict, dict, dict, dict]: def _two_manager_scene(
client: TestClient, headers: dict
) -> tuple[dict, dict, dict, dict, dict]:
"""两位经理各有下属与业务 + 一位零成员经理(无聚合行)。""" """两位经理各有下属与业务 + 一位零成员经理(无聚合行)。"""
manager_1 = _person(client, headers, role="sales_manager") manager_1 = _person(client, headers, role="sales_manager")
manager_2 = _person(client, headers, role="sales_manager") manager_2 = _person(client, headers, role="sales_manager")
@ -784,7 +777,9 @@ def test_导出取数异常_失败审计留痕(admin, client: TestClient, monkey
assert "导出个人绩效工资明细表" in (log.summary or "") assert "导出个人绩效工资明细表" in (log.summary or "")
@pytest.mark.parametrize("path", ["/personal-detail", "/department-summary", "/payment-stats"]) @pytest.mark.parametrize(
"path", ["/personal-detail", "/department-summary", "/payment-stats"]
)
def test_非法月份_查询返回400(admin, client: TestClient, path: str): def test_非法月份_查询返回400(admin, client: TestClient, path: str):
"""非法月份必须是参数错误 400,不能落 500。""" """非法月份必须是参数错误 400,不能落 500。"""
resp = client.get( resp = client.get(
@ -795,7 +790,9 @@ def test_非法月份_查询返回400(admin, client: TestClient, path: str):
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
@pytest.mark.parametrize("code", ["personal-detail", "department-summary", "payment-stats"]) @pytest.mark.parametrize(
"code", ["personal-detail", "department-summary", "payment-stats"]
)
def test_非法月份_导出返回400(admin, client: TestClient, code: str): def test_非法月份_导出返回400(admin, client: TestClient, code: str):
"""导出手工构造查询模型同样要转 400。""" """导出手工构造查询模型同样要转 400。"""
resp = client.get( resp = client.get(

View File

@ -247,36 +247,6 @@ class TestGuards:
class TestDeleteProtection: class TestDeleteProtection:
"""RESTRICT 外键:被方案引用的人员禁删,方案随业务删除而清。""" """RESTRICT 外键:被方案引用的人员禁删,方案随业务删除而清。"""
def test_同一会话内旧方案行同步为历史态(self, client: TestClient, admin: dict) -> None:
"""守「置历史后同一会话内的旧行必须同步刷新」这一行为:Core UPDATE
走的是 SQLAlchemy 的会话同步策略,条件复杂化时可能退化为不刷新,
届时行对象会停留在 is_active=1 的过期内存值(库中实际已置历史),
被序列化或参与判断就会呈现「生效态」的假象。"""
from app.core.db import get_session_factory
from app.models import SecondaryAllocation
from app.repositories import business_repository
p = _person(client, admin["headers"])
data = _create_with_allocations(
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
)
factory = get_session_factory()
with factory() as session:
loaded = business_repository.list_allocations(session, data["id"])
assert loaded and all(a.is_active == 1 for a in loaded)
business_repository.deactivate_allocations(session, data["id"])
# 同一 session 内的行对象必须已是历史态,不能停留在过期内存值
assert all(a.is_active == 0 for a in loaded), "旧行未随置历史同步刷新"
# 库中同样已置历史
rows = (
session.query(SecondaryAllocation)
.filter(SecondaryAllocation.business_id == data["id"])
.all()
)
assert rows and all(r.is_active == 0 for r in rows)
session.rollback()
def test_被方案引用的人员禁删(self, client: TestClient, admin: dict) -> None: def test_被方案引用的人员禁删(self, client: TestClient, admin: dict) -> None:
p = _person(client, admin["headers"]) p = _person(client, admin["headers"])
data = _create_with_allocations( data = _create_with_allocations(

View File

@ -23,6 +23,7 @@ _SETTINGS = "/api/v1/system/settings"
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 _PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
_JPG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32 _JPG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32
_ICO_BYTES = b"\x00\x00\x01\x00" + b"\x00" * 32
_TXT_BYTES = b"not an image at all" _TXT_BYTES = b"not an image at all"
@ -31,6 +32,10 @@ def client() -> TestClient:
return TestClient(app) return TestClient(app)
# 系统设置单行与上传文件的快照/还原已上移到 integration/conftest.py,
# 对所有集成用例生效(不再只保护本模块)。
class TestCompanyInfo: class TestCompanyInfo:
"""公司名称读取与更新。""" """公司名称读取与更新。"""
@ -84,10 +89,30 @@ class TestPublicEndpoint:
resp = client.get(f"{_SETTINGS}/public") resp = client.get(f"{_SETTINGS}/public")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json()["data"] data = resp.json()["data"]
assert set(data) == {"company_name", "logo_url"}, "public 端点字段越界" assert set(data) == {
"company_name",
"logo_url",
"site_title",
"brand_slogan",
"footer_text",
"icp_no",
"favicon_url",
}, "public 端点字段越界"
assert data["company_name"] == name assert data["company_name"] == name
assert resp.headers.get("cache-control") is not None assert resp.headers.get("cache-control") is not None
def test_已删除的档案字段不再返回(self, client: TestClient) -> None:
"""V11 精简:副标题/信用代码/地址/电话/邮箱已从模型与契约中删除。"""
data = client.get(f"{_SETTINGS}/public").json()["data"]
for field in (
"system_subtitle",
"credit_code",
"company_address",
"contact_phone",
"contact_email",
):
assert field not in data, f"已删除字段仍出现在匿名端点:{field}"
def test_写操作匿名不可用(self, client: TestClient) -> None: def test_写操作匿名不可用(self, client: TestClient) -> None:
assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in { assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in {
401, 401,
@ -95,6 +120,41 @@ class TestPublicEndpoint:
} }
class TestCompanyProfile:
"""站点标题/品牌信息(V11):部分更新语义与空串清空。"""
def test_部分更新_未提交字段保持不变(self, client: TestClient, admin: dict) -> None:
name = f"站点公司_{uuid.uuid4().hex[:6]}"
client.put(_SETTINGS, json={"company_name": name}, headers=admin["headers"])
put = client.put(_SETTINGS, json={"site_title": "测试站点标题"}, headers=admin["headers"])
assert put.status_code == 200, put.text
assert put.json()["data"]["site_title"] == "测试站点标题"
# 未提交的 company_name 保持不变(部分更新语义)
assert put.json()["data"]["company_name"] == name
got = client.get(_SETTINGS, headers=admin["headers"]).json()["data"]
assert got["company_name"] == name
assert got["site_title"] == "测试站点标题"
def test_可选文本传空串_清空为null(self, client: TestClient, admin: dict) -> None:
client.put(
_SETTINGS,
json={"brand_slogan": "t_临时标语", "icp_no": "t_备案号"},
headers=admin["headers"],
)
put = client.put(
_SETTINGS, json={"brand_slogan": "", "icp_no": ""}, headers=admin["headers"]
)
assert put.status_code == 200, put.text
assert put.json()["data"]["brand_slogan"] is None
assert put.json()["data"]["icp_no"] is None
def test_超长字段被拒(self, client: TestClient, admin: dict) -> None:
resp = client.put(_SETTINGS, json={"site_title": "x" * 65}, headers=admin["headers"])
assert resp.status_code == 400, resp.text
class TestLogoUpload: class TestLogoUpload:
"""logo 上传校验链。""" """logo 上传校验链。"""
@ -135,3 +195,65 @@ class TestLogoUpload:
files={"file": ("logo.png", _PNG_BYTES, "application/octet-stream")}, files={"file": ("logo.png", _PNG_BYTES, "application/octet-stream")},
) )
assert resp.status_code == 401 assert resp.status_code == 401
def test_同扩展名替换_url版本号变化(self, client: TestClient, admin: dict) -> None:
"""同扩展名覆盖时路径与 update_time 都不变 —— 版本号必须递增,否则浏览器用旧图。"""
first = self._upload(client, admin, "logo.png", _PNG_BYTES).json()["data"]["logo_url"]
second = self._upload(client, admin, "logo.png", _PNG_BYTES).json()["data"]["logo_url"]
assert first and second
assert first != second, "同扩展名替换后图片 URL 未变化,浏览器会继续使用旧缓存"
class TestFaviconUpload:
"""站点图标上传校验链(独立于 logo;favicon 额外接受 ico)。"""
def _upload(self, client: TestClient, admin: dict, filename: str, content: bytes):
return client.post(
f"{_SETTINGS}/favicon",
files={"file": (filename, content, "application/octet-stream")},
headers=admin["headers"],
)
def test_合法ico上传成功且回读(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "favicon.ico", _ICO_BYTES)
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
assert out["favicon_url"] and "/favicon/public" in out["favicon_url"]
img = client.get(f"{_SETTINGS}/favicon/public")
assert img.status_code == 200
assert img.headers["content-type"] == "image/x-icon"
def test_合法png上传成功(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "favicon.png", _PNG_BYTES)
assert resp.status_code == 200, resp.text
assert "/favicon/public" in resp.json()["data"]["favicon_url"]
def test_伪造扩展名被magic_bytes拦截(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "fake.ico", _TXT_BYTES)
assert resp.status_code == 400, resp.text
assert "不符" in resp.json()["message"]
def test_同扩展名替换_url版本号变化(self, client: TestClient, admin: dict) -> None:
first = self._upload(client, admin, "favicon.png", _PNG_BYTES).json()["data"]["favicon_url"]
second = self._upload(client, admin, "favicon.png", _PNG_BYTES).json()["data"][
"favicon_url"
]
assert first and second
assert first != second, "同扩展名替换后站点图标 URL 未变化"
def test_非法格式被拒(self, client: TestClient, admin: dict) -> None:
assert self._upload(client, admin, "x.gif", _PNG_BYTES).status_code == 400
def test_超限被拒(self, client: TestClient, admin: dict) -> None:
big = _PNG_BYTES + b"\x00" * (2 * 1024 * 1024)
resp = self._upload(client, admin, "big.png", big)
assert resp.status_code == 400
assert "2MB" in resp.json()["message"]
def test_匿名上传被拒(self, client: TestClient) -> None:
resp = client.post(
f"{_SETTINGS}/favicon",
files={"file": ("favicon.png", _PNG_BYTES, "application/octet-stream")},
)
assert resp.status_code == 401

View File

@ -229,19 +229,6 @@ class TestSubmitAndLock:
assert out["approval_order_no"] and out["approval_order_no"].startswith("AP") assert out["approval_order_no"] and out["approval_order_no"].startswith("AP")
assert out["locked_at"] is not None assert out["locked_at"] is not None
# 自动通过路径同样固化送审快照(月结头 + 按人明细)
order = client.get(
"/api/v1/system/approvals",
params={"keyword": out["approval_order_no"], "pageSize": 1},
headers=admin["headers"],
).json()["data"]["list"][0]
detail = client.get(
f"/api/v1/system/approvals/{order['id']}", headers=admin["headers"]
).json()["data"]
assert detail["content_source"] == "snapshot"
assert detail["snapshot"]["data"]["settlement_month"] == "2026-10"
assert detail["snapshot"]["data"]["items"], "月结快照须含按人明细"
def test_锁定后回款禁改(self, client: TestClient, admin: dict) -> None: def test_锁定后回款禁改(self, client: TestClient, admin: dict) -> None:
from sqlalchemy import select from sqlalchemy import select
@ -616,21 +603,6 @@ class TestAdjustment:
out = resp.json()["data"] out = resp.json()["data"]
assert out["status"] == 2 # 已生效 assert out["status"] == 2 # 已生效
# 调整单送审快照:复核者能看到金额/方向/说明,而非只有调整单号
order = client.get(
"/api/v1/system/approvals",
params={"keyword": out["order_no"], "pageSize": 1},
headers=admin["headers"],
).json()["data"]["list"][0]
assert order["order_type"] == 3
detail = client.get(
f"/api/v1/system/approvals/{order['id']}", headers=admin["headers"]
).json()["data"]
assert detail["content_source"] == "snapshot"
assert detail["snapshot"]["data"]["amount"] == "500.00"
assert detail["snapshot"]["data"]["direction"] == 1
assert detail["snapshot"]["data"]["reason"] == "补发差额"
def test_金额恒正校验(self, client: TestClient, admin: dict) -> None: def test_金额恒正校验(self, client: TestClient, admin: dict) -> None:
"""守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。""" """守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。"""
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0") _set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")

View File

@ -1,72 +0,0 @@
"""审批与方案版本化的纯函数单元测试(秒级,无需容器)。
守的是「方案内容是否真的变了」这一判定(``business_service._plan_changed``):
它决定要不要新增一个方案版本行、要不要重提审批单。判错的后果是双向的——
判成「变了」会刷出版本与审批留痕噪音,判成「没变」会让改过的方案
沿用旧版本号,版本语义失真。
比较口径是**集合**(人员 ID + 其比例),与提交顺序无关:用户只是把行
拖了个序,不该被算成方案变了。
"""
from decimal import Decimal
import pytest
from app.models.business import SecondaryAllocation
from app.services.business_service import _plan_changed
def _row(personnel_id: int, ratio: str) -> SecondaryAllocation:
"""构造一个未入库的方案行(ORM 实例化不触数据库)。"""
return SecondaryAllocation(
business_id=1,
version_no=1,
personnel_id=personnel_id,
personnel_name=f"t_人员{personnel_id}",
ratio=Decimal(ratio),
is_active=1,
)
class TestPlanChanged:
"""方案内容比较。"""
def test_完全相同_判未变(self) -> None:
old = [_row(1, "50.00"), _row(2, "30.00")]
new = [_row(1, "50.00"), _row(2, "30.00")]
assert _plan_changed(old, new) is False
def test_顺序不同_判未变(self) -> None:
"""用户只是把行拖了个序 —— 送审快照按库内生效行顺序生成,
按顺序比较会把这种编辑误判成「方案变了」。"""
old = [_row(1, "50.00"), _row(2, "30.00")]
new = [_row(2, "30.00"), _row(1, "50.00")]
assert _plan_changed(old, new) is False
def test_比例变化_判已变(self) -> None:
old = [_row(1, "50.00")]
new = [_row(1, "80.00")]
assert _plan_changed(old, new) is True
def test_参与人增减_判已变(self) -> None:
old = [_row(1, "50.00")]
assert _plan_changed(old, [_row(1, "50.00"), _row(2, "30.00")]) is True
assert _plan_changed([_row(1, "50.00"), _row(2, "30.00")], [_row(1, "50.00")]) is True
def test_同一人的比例从一人换成另一人_判已变(self) -> None:
"""参与人不同但比例相同:人员 ID 是比较键的一部分,不能只看比例。"""
old = [_row(1, "50.00")]
new = [_row(2, "50.00")]
assert _plan_changed(old, new) is True
@pytest.mark.parametrize("written", ["50", "50.0", "50.00"])
def test_比例写法不同_判未变(self, written: str) -> None:
"""比例入参是字符串('50' 与 '50.00' 都合法),数值相同即未变。"""
assert _plan_changed([_row(1, "50.00")], [_row(1, written)]) is False
def test_空方案与空方案_判未变(self) -> None:
assert _plan_changed([], []) is False
def test_空方案与有方案_判已变(self) -> None:
assert _plan_changed([], [_row(1, "50.00")]) is True

View File

@ -1,38 +0,0 @@
"""审批管理入参校验(schema 层,秒级)。
守的是:对象类型筛选必须覆盖三类审批对象(1 二次分配方案 / 2 月度结算 /
3 调整单)——早期上界写死在 2,调整单审批单在列表里无法筛选;越界值在
参数绑定阶段即拒绝。驳回必须带意见(空驳无从追溯)。
"""
import pytest
from pydantic import ValidationError
from app.schemas.approval import ApprovalActionIn, ApprovalQuery
class TestQueryOrderType:
"""对象类型筛选边界。"""
@pytest.mark.parametrize("value", [1, 2, 3])
def test_三类对象均可筛选(self, value: int) -> None:
assert ApprovalQuery(order_type=value).order_type == value
@pytest.mark.parametrize("value", [0, 4])
def test_越界类型被拒(self, value: int) -> None:
with pytest.raises(ValidationError):
ApprovalQuery(order_type=value)
def test_不传类型表示全部(self) -> None:
assert ApprovalQuery().order_type is None
class TestActionOpinion:
"""节点动作的驳回意见要求。"""
def test_驳回必须填意见(self) -> None:
with pytest.raises(ValidationError):
ApprovalActionIn(action=2)
def test_通过可不填意见(self) -> None:
assert ApprovalActionIn(action=1).opinion is None

View File

@ -40,6 +40,7 @@ EXPECTED_TABLES = {
"shipment_items", "shipment_items",
"receipts", "receipts",
"customer_rating_grades", "customer_rating_grades",
"customer_ratings",
"performance_ledgers", "performance_ledgers",
"monthly_settlements", "monthly_settlements",
"settlement_items", "settlement_items",
@ -96,20 +97,6 @@ class TestNamingConvention:
for fk in table.foreign_keys: for fk in table.foreign_keys:
assert fk.constraint.name.startswith(f"fk_{table.name}_"), fk.constraint.name assert fk.constraint.name.startswith(f"fk_{table.name}_"), fk.constraint.name
def test_审批单版本号唯一键覆盖三列(self) -> None:
"""并发提单算重版本号时由 DB 兜底(应用层取号走锁读,见
approval_service._next_version);约束名必须与迁移 DDL 严格一致,
否则 alembic autogenerate 会误判成「约束缺失/多余」。"""
table = Base.metadata.tables["approval_orders"]
uks = [c for c in table.constraints if c.__class__.__name__ == "UniqueConstraint"]
hit = [
c
for c in uks
if {col.name for col in c.columns} == {"order_type", "ref_id", "version_no"}
]
assert hit, "审批单缺 (order_type, ref_id, version_no) 唯一键"
assert hit[0].name == "uk_approval_orders_order_type_ref_version"
class TestComments: class TestComments:
"""规范红线要求中文 COMMENT。""" """规范红线要求中文 COMMENT。"""

View File

@ -127,7 +127,8 @@ class TestExcel模板:
cell cell
for row in sheet.iter_rows() for row in sheet.iter_rows()
for cell in row for cell in row
if isinstance(cell.value, (int, float)) and Decimal(str(cell.value)) == Decimal("39.11") if isinstance(cell.value, (int, float))
and Decimal(str(cell.value)) == Decimal("39.11")
] ]
assert amount_cells, "未找到金额数值单元格(Decimal 被转成了文本?)" assert amount_cells, "未找到金额数值单元格(Decimal 被转成了文本?)"
assert amount_cells[0].number_format == "#,##0.00" assert amount_cells[0].number_format == "#,##0.00"
@ -141,14 +142,16 @@ class TestExcel模板:
workbook = load_workbook(io.BytesIO(content)) workbook = load_workbook(io.BytesIO(content))
cells = [cell for row in workbook.active.iter_rows() for cell in row] cells = [cell for row in workbook.active.iter_rows() for cell in row]
values = [ values = [
Decimal(str(cell.value)) for cell in cells if isinstance(cell.value, (int, float)) Decimal(str(cell.value))
for cell in cells
if isinstance(cell.value, (int, float))
] ]
assert Decimal("15.50") in values assert Decimal("15.50") in values
# 「合计」标签只写一次(写满每个文本列是回归缺陷) # 「合计」标签只写一次(写满每个文本列是回归缺陷)
assert sum(1 for cell in cells if cell.value == "合计") == 1 assert sum(1 for cell in cells if cell.value == "合计") == 1
def test_文本公式注入被前置单引号(self): def test_文本公式注入被前置单引号(self):
evil = '=HYPERLINK("http://evil")' evil = "=HYPERLINK(\"http://evil\")"
content = _build("personal-detail", [_personal_row(business_no=evil)]) content = _build("personal-detail", [_personal_row(business_no=evil)])
workbook = load_workbook(io.BytesIO(content)) workbook = load_workbook(io.BytesIO(content))
values = [ values = [

View File

@ -23,6 +23,7 @@ DROP TABLE IF EXISTS `monthly_settlements`;
DROP TABLE IF EXISTS `approval_steps`; DROP TABLE IF EXISTS `approval_steps`;
DROP TABLE IF EXISTS `approval_orders`; DROP TABLE IF EXISTS `approval_orders`;
DROP TABLE IF EXISTS `business_secondary_allocations`; DROP TABLE IF EXISTS `business_secondary_allocations`;
DROP TABLE IF EXISTS `customer_ratings`;
DROP TABLE IF EXISTS `system_settings`; DROP TABLE IF EXISTS `system_settings`;
DROP TABLE IF EXISTS `param_values`; DROP TABLE IF EXISTS `param_values`;
DROP TABLE IF EXISTS `param_change_batches`; DROP TABLE IF EXISTS `param_change_batches`;
@ -366,8 +367,6 @@ CREATE TABLE customers (
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal', customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)', owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
remark VARCHAR(255) COMMENT '备注', remark VARCHAR(255) COMMENT '备注',
rating_score TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)',
rating_time DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
@ -439,29 +438,29 @@ INSERT INTO param_change_batches (id, version_no, effective_date, change_type, r
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决与 2026-10-19 澄清答复)', 'system', 19); (1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决与 2026-10-19 澄清答复)', 'system', 19);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。', 1), (1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1),
(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。', 2), (2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2),
(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。', 3), (3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3),
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。', 4), (4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4),
(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。', 5), (5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5),
(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。', 6), (6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6),
(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。', 7), (7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7),
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。', 8), (8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8),
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款到账即按这个比例计提绩效,不必等到全额回款。', 9), (9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9),
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。', 10), (10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10),
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。', 11), (11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11),
(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, '选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。', 12), (12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12),
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。', 13), (13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13),
(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, '算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。', 14), (14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14),
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。', 15), (15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15),
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。', 17), (17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流。默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭', 17),
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。', 18), (18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18),
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。', 19), (19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,默认值业务未明确,暂按直接生效,业务可开启)', 19),
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。', 20), (20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20),
-- 预警规则参数:工作台预警阈值,可按业务需要调整 -- 预警规则参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)
(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。', 21), (21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21),
(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。', 22), (22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22),
(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。', 23); (23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
(1, 1, 'salesperson', '2026-02-17', '10', 1), (1, 1, 'salesperson', '2026-02-17', '10', 1),
@ -697,17 +696,50 @@ INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order
('C', 60, 74, 3, '合格'), ('C', 60, 74, 3, '合格'),
('D', 0, 59, 4, '待改进'); ('D', 0, 59, 4, '待改进');
-- ---------------------------------------------------------------------------
-- 客户评级落库(V11 迁移;每客户一行,等级名为快照固化无外键)
-- ---------------------------------------------------------------------------
CREATE TABLE customer_ratings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(唯一:一客户一份当前评级)',
score_ship_amount NUMERIC(5, 1) NOT NULL COMMENT '发货额评分(0-20)' DEFAULT '0',
score_receipt_rate NUMERIC(5, 1) NOT NULL COMMENT '回款率评分(0-20)' DEFAULT '0',
score_profit_rate NUMERIC(5, 1) NOT NULL COMMENT '毛利率评分(0-20)' DEFAULT '0',
score_contract_cooperation NUMERIC(5, 1) NOT NULL COMMENT '合同履约配合能力评分(0-20)' DEFAULT '0',
score_site_construction NUMERIC(5, 1) NOT NULL COMMENT '现场规范施工能力评分(0-20)' DEFAULT '0',
total_score NUMERIC(5, 1) NOT NULL COMMENT '总分(服务端重算,五项之和 0-100)',
grade_name VARCHAR(32) COMMENT '评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)',
remark VARCHAR(255) COMMENT '备注',
scorer_id BIGINT UNSIGNED COMMENT '评级操作人ID(账号删除后置空)',
scorer_name VARCHAR(64) 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_customer_ratings PRIMARY KEY (id),
CONSTRAINT fk_customer_ratings_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
CONSTRAINT fk_customer_ratings_scorer_id FOREIGN KEY(scorer_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT uk_customer_ratings_customer_id UNIQUE (customer_id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)' COLLATE utf8mb4_general_ci;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 系统设置/二次分配/审批(V6 迁移;公司信息单行;分摊计算待澄清另批) -- 系统设置/二次分配/审批(V6 迁移;公司信息单行;分摊计算待澄清另批)
-- V11 迁移:增加品牌标语/页脚/备案号/站点标题/站点图标与图片版本号
-- (公司名称/logo/标语/页脚/备案号 + 站点信息,企业档案字段方案已废弃未发布)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE system_settings ( CREATE TABLE system_settings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页与导出页眉展示)', company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页/侧边栏/浏览器标题展示)',
logo_path VARCHAR(255) COMMENT 'logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)', logo_path VARCHAR(255) COMMENT '公司 logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)',
logo_version INT NOT NULL COMMENT 'logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)' DEFAULT '0',
site_title VARCHAR(64) COMMENT '站点标题(浏览器标签页与侧边栏副标题;null=内置默认)',
brand_slogan VARCHAR(64) COMMENT '品牌标语(登录页与品牌预览;null=内置默认)',
footer_text VARCHAR(64) COMMENT '页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)',
icp_no VARCHAR(32) COMMENT 'ICP 备案号(登录页页脚,空=不显示)',
favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=跟随公司 logo / 内置 favicon.svg)',
favicon_version INT NOT NULL COMMENT '站点图标版本号(每次上传 +1;图片 URL 用它破缓存)' DEFAULT '0',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_system_settings PRIMARY KEY (id) CONSTRAINT pk_system_settings PRIMARY KEY (id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息,全系统单行)' COLLATE utf8mb4_general_ci; )CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息与站点信息,全系统单行)' COLLATE utf8mb4_general_ci;
CREATE TABLE approval_orders ( CREATE TABLE approval_orders (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
@ -715,8 +747,6 @@ CREATE TABLE approval_orders (
order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算(预留)', order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算(预留)',
ref_id BIGINT UNSIGNED NOT NULL COMMENT '引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)', ref_id BIGINT UNSIGNED NOT NULL COMMENT '引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)',
refer_no VARCHAR(32) NOT NULL COMMENT '引用对象单号(冗余固化,列表页免联查)', refer_no VARCHAR(32) NOT NULL COMMENT '引用对象单号(冗余固化,列表页免联查)',
version_no INT NOT NULL COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' DEFAULT '1',
snapshot_json JSON COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)',
status TINYINT NOT NULL COMMENT '审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回' DEFAULT '1', status TINYINT NOT NULL COMMENT '审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回' DEFAULT '1',
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)', submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)', submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
@ -724,14 +754,11 @@ CREATE TABLE approval_orders (
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_approval_orders PRIMARY KEY (id), CONSTRAINT pk_approval_orders PRIMARY KEY (id),
CONSTRAINT uk_approval_orders_order_no UNIQUE (order_no), CONSTRAINT uk_approval_orders_order_no UNIQUE (order_no),
CONSTRAINT uk_approval_orders_order_type_ref_version UNIQUE (order_type, ref_id, version_no),
CONSTRAINT fk_approval_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL CONSTRAINT fk_approval_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)' COLLATE utf8mb4_general_ci; )CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_approval_orders_type_status ON approval_orders (order_type, status); CREATE INDEX idx_approval_orders_type_status ON approval_orders (order_type, status);
CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id);
CREATE TABLE approval_steps ( CREATE TABLE approval_steps (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)', order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)',
@ -751,25 +778,21 @@ CREATE INDEX idx_approval_steps_order_id ON approval_steps (order_id);
CREATE TABLE business_secondary_allocations ( CREATE TABLE business_secondary_allocations (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;业务删除随删)', business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;业务删除随删)',
version_no INT NOT NULL COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' DEFAULT '1',
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '参与业务员ID(personnel.id;被方案引用禁删)', personnel_id BIGINT UNSIGNED NOT NULL COMMENT '参与业务员ID(personnel.id;被方案引用禁删)',
personnel_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)', personnel_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)',
ratio DECIMAL(5, 2) NOT NULL COMMENT '分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)', ratio DECIMAL(5, 2) NOT NULL COMMENT '分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)',
is_active TINYINT NOT NULL COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' DEFAULT '1',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_business_secondary_allocations PRIMARY KEY (id), CONSTRAINT pk_business_secondary_allocations PRIMARY KEY (id),
CONSTRAINT uk_business_secondary_allocations_business_version_personnel UNIQUE (business_id, version_no, personnel_id), CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id),
CONSTRAINT fk_business_secondary_allocations_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE, CONSTRAINT fk_business_secondary_allocations_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci; )CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondary_allocations (business_id); CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondary_allocations (business_id);
CREATE INDEX idx_business_secondary_allocations_personnel_id ON business_secondary_allocations (personnel_id); CREATE INDEX idx_business_secondary_allocations_personnel_id ON business_secondary_allocations (personnel_id);
CREATE INDEX idx_business_secondary_allocations_business_active ON business_secondary_allocations (business_id, is_active);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 绩效核算台账/月结/调整单(V7 迁移;append-only 版本行;通过即锁定走调整单) -- 绩效核算台账/月结/调整单(V7 迁移;append-only 版本行;通过即锁定走调整单)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -893,4 +916,4 @@ CREATE TABLE alembic_version (
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
INSERT INTO alembic_version (version_num) VALUES ('c3a9e1d7b2f4'); INSERT INTO alembic_version (version_num) VALUES ('c4e2a86f9b13');

View File

@ -1,10 +0,0 @@
-- V10: 客户评级落库(2026-10-23)
-- 评级计算器从独立工具页并入客户管理(行内弹窗),评分保存为客户档案属性;
-- 两列可空(null=未评分,存量客户无需回填),人工维护字段,T+ 同步不覆盖。
-- 对应迁移:alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py
-- 每列一条 ALTER:test_schema_sync 按「ALTER TABLE … ADD COLUMN」逐条解析,
-- 一条语句挂多个 ADD 只会解析出第一列,两份 DDL 会假性漂移。
ALTER TABLE `customers`
ADD COLUMN `rating_score` TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' AFTER `remark`;
ALTER TABLE `customers`
ADD COLUMN `rating_time` DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)' AFTER `rating_score`;

View File

@ -0,0 +1,38 @@
-- Running upgrade 90653814a421 -> c4e2a86f9b13
CREATE TABLE customer_ratings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(唯一:一客户一份当前评级)',
score_ship_amount NUMERIC(5, 1) NOT NULL COMMENT '发货额评分(0-20)' DEFAULT '0',
score_receipt_rate NUMERIC(5, 1) NOT NULL COMMENT '回款率评分(0-20)' DEFAULT '0',
score_profit_rate NUMERIC(5, 1) NOT NULL COMMENT '毛利率评分(0-20)' DEFAULT '0',
score_contract_cooperation NUMERIC(5, 1) NOT NULL COMMENT '合同履约配合能力评分(0-20)' DEFAULT '0',
score_site_construction NUMERIC(5, 1) NOT NULL COMMENT '现场规范施工能力评分(0-20)' DEFAULT '0',
total_score NUMERIC(5, 1) NOT NULL COMMENT '总分(服务端重算,五项之和 0-100)',
grade_name VARCHAR(32) COMMENT '评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)',
remark VARCHAR(255) COMMENT '备注',
scorer_id BIGINT UNSIGNED COMMENT '评级操作人ID(账号删除后置空)',
scorer_name VARCHAR(64) 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_customer_ratings PRIMARY KEY (id),
CONSTRAINT fk_customer_ratings_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
CONSTRAINT fk_customer_ratings_scorer_id FOREIGN KEY(scorer_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT uk_customer_ratings_customer_id UNIQUE (customer_id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)' COLLATE utf8mb4_general_ci;
ALTER TABLE system_settings ADD COLUMN site_title VARCHAR(64) COMMENT '站点标题(浏览器标签页与侧边栏副标题;null=内置默认)';
ALTER TABLE system_settings ADD COLUMN brand_slogan VARCHAR(64) COMMENT '品牌标语(登录页与品牌预览;null=内置默认)';
ALTER TABLE system_settings ADD COLUMN footer_text VARCHAR(64) COMMENT '页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)';
ALTER TABLE system_settings ADD COLUMN icp_no VARCHAR(32) COMMENT 'ICP 备案号(登录页页脚,空=不显示)';
ALTER TABLE system_settings ADD COLUMN favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)';
ALTER TABLE system_settings ADD COLUMN logo_version INT NOT NULL COMMENT 'logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)' DEFAULT '0';
ALTER TABLE system_settings ADD COLUMN favicon_version INT NOT NULL COMMENT '站点图标版本号(每次上传 +1;图片 URL 用它破缓存)' DEFAULT '0';
UPDATE alembic_version SET version_num='c4e2a86f9b13' WHERE alembic_version.version_num = '90653814a421';

View File

@ -1,19 +0,0 @@
-- Running upgrade 90653814a421 -> f3a8c2d61b47
ALTER TABLE approval_orders ADD COLUMN version_no INT NOT NULL DEFAULT 1 COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' AFTER refer_no;
ALTER TABLE approval_orders ADD COLUMN snapshot_json JSON NULL COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' AFTER version_no;
CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id);
ALTER TABLE business_secondary_allocations ADD COLUMN version_no INT NOT NULL DEFAULT 1 COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' AFTER business_id;
ALTER TABLE business_secondary_allocations ADD COLUMN is_active TINYINT NOT NULL DEFAULT 1 COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' AFTER ratio;
ALTER TABLE business_secondary_allocations DROP INDEX uk_business_secondary_allocations_business_personnel, ADD CONSTRAINT uk_business_secondary_allocations_business_version_personnel UNIQUE (business_id, version_no, personnel_id);
CREATE INDEX idx_business_secondary_allocations_business_active ON business_secondary_allocations (business_id, is_active);
ALTER TABLE business_secondary_allocations COMMENT = '业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)';
UPDATE alembic_version SET version_num='f3a8c2d61b47' WHERE alembic_version.version_num = '90653814a421';

View File

@ -1,16 +0,0 @@
-- Running upgrade f3a8c2d61b47 -> 7f4f7b918bc4
UPDATE approval_orders ao JOIN (SELECT id, ROW_NUMBER() OVER ( PARTITION BY order_type, ref_id ORDER BY id) AS rn FROM approval_orders WHERE (order_type, ref_id) IN ( SELECT order_type, ref_id FROM approval_orders GROUP BY order_type, ref_id HAVING COUNT(*) > COUNT(DISTINCT version_no))) t ON ao.id = t.id SET ao.version_no = t.rn;
SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'approval_orders' AND CONSTRAINT_NAME = 'uk_approval_orders_order_type_ref_version');
SET @ddl := IF(@uk_exists = 0, 'ALTER TABLE approval_orders ADD CONSTRAINT uk_approval_orders_order_type_ref_version UNIQUE (order_type, ref_id, version_no)', 'DO 0');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE alembic_version SET version_num='7f4f7b918bc4' WHERE alembic_version.version_num = 'f3a8c2d61b47';

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -4,7 +4,7 @@
> 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。 > 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。
> 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。 > 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。
> **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。 > **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。
> **2026-10-20 更新**:开发计划 3.3 已回填(3.3.1),SRS V2.1 已交付;二次分摊已按开发侧暂定口径实现(#1–#3 答复仍空白);code-review 遗留 2 项(方案版本承载、审批单内容快照)已随 V11 迁移收口。 > **2026-10-20 更新**:开发计划 3.3 已回填(3.3.1),SRS V2.1 已交付;二次分摊已按开发侧暂定口径实现(#1–#3 答复仍空白)。
## 一、立即待办(需项目内处理) ## 一、立即待办(需项目内处理)
@ -88,7 +88,7 @@
| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;二次分摊已按开发侧暂定口径实现(ccfd206),业务答复回填已完成(计划 3.3.1,2026-10-20);**余**:#1–#3/#20 答复与 #4/#6/#12 补值 | #20(含税口径)、#1–#3 答复后按同粒度推进 | | M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;二次分摊已按开发侧暂定口径实现(ccfd206),业务答复回填已完成(计划 3.3.1,2026-10-20);**余**:#1–#3/#20 答复与 #4/#6/#12 补值 | #20(含税口径)、#1–#3 答复后按同粒度推进 |
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 | | M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 |
| M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 | | M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 |
| code-review 遗留 2 项 | ✅ **已完成(2026-10-20,V11)**:方案行 append-only 版本化(is_active/version_no);审批单加版本号与 JSON 内容快照,详情按 snapshot/live/missing 展示 | — | | code-review 遗留 2 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 |
| 杂项收口(低优) | 超长函数收敛:calc_receipt 与 settlement_service(生成/聚合/调整单)已拆(2026-10-19);剩 preview/change_ownership/create_receipt/update_receipt 等;散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 | | 杂项收口(低优) | 超长函数收敛:calc_receipt 与 settlement_service(生成/聚合/调整单)已拆(2026-10-19);剩 preview/change_ownership/create_receipt/update_receipt 等;散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 |
## 五、文档待办(低优) ## 五、文档待办(低优)

Some files were not shown because too many files have changed in this diff Show More