Compare commits
2 Commits
main
...
feat/setti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac4ba7514b | ||
|
|
27533eb465 |
8
.gitignore
vendored
8
.gitignore
vendored
@ -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/
|
|
||||||
|
|||||||
@ -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 是安全问题,必须启动即失败。
|
||||||
|
|||||||
@ -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}")
|
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
|
||||||
212
backend/alembic/versions/c4e2a86f9b13_v11_客户评级落库与站点信息.py
Normal file
212
backend/alembic/versions/c4e2a86f9b13_v11_客户评级落库与站点信息.py
Normal 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")
|
||||||
@ -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)"
|
||||||
|
)
|
||||||
|
|||||||
@ -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")
|
|
||||||
@ -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
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|||||||
@ -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="评级已保存",
|
||||||
|
)
|
||||||
|
|||||||
@ -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))
|
|
||||||
|
|||||||
@ -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"},
|
||||||
|
)
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
77
backend/app/models/customer_rating.py
Normal file
77
backend/app/models/customer_rating.py
Normal 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="评级操作人用户名(冗余固化)"
|
||||||
|
)
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 用它破缓存)",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
33
backend/app/repositories/customer_rating_repository.py
Normal file
33
backend/app/repositories/customer_rating_repository.py
Normal 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()
|
||||||
@ -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())
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 写入 ====================
|
# ==================== 写入 ====================
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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())
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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())
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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):
|
||||||
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
|
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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="评分")
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 备案号")
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
"""岗位详情。
|
"""岗位详情。
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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` 对拍」用例 |
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
|
||||||
|
|||||||
@ -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]
|
|
||||||
@ -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
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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。"""
|
||||||
|
|||||||
@ -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 = [
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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`;
|
|
||||||
38
db/migration/V11__add_customer_ratings_and_site_info.sql
Normal file
38
db/migration/V11__add_customer_ratings_and_site_info.sql
Normal 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';
|
||||||
@ -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';
|
|
||||||
@ -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';
|
|
||||||
|
|
||||||
BIN
docs/T+对接数据需求清单_2026-10-15.xlsx
Normal file
BIN
docs/T+对接数据需求清单_2026-10-15.xlsx
Normal file
Binary file not shown.
BIN
docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx
Normal file
BIN
docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx
Normal file
Binary file not shown.
BIN
docs/T+对接数据需求清单_v1.5_2026-10-20.xlsx
Normal file
BIN
docs/T+对接数据需求清单_v1.5_2026-10-20.xlsx
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
docs/待澄清问题清单_2026-10-15(1)(2).xlsx
Normal file
BIN
docs/待澄清问题清单_2026-10-15(1)(2).xlsx
Normal file
Binary file not shown.
BIN
docs/待澄清问题清单_2026-10-15.xlsx
Normal file
BIN
docs/待澄清问题清单_2026-10-15.xlsx
Normal file
Binary file not shown.
BIN
docs/待确认事项排序表_2026-10-16.xlsx
Normal file
BIN
docs/待确认事项排序表_2026-10-16.xlsx
Normal file
Binary file not shown.
BIN
docs/待确认事项排序表_2026-10-16_new.xlsx
Normal file
BIN
docs/待确认事项排序表_2026-10-16_new.xlsx
Normal file
Binary file not shown.
@ -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
Loading…
Reference in New Issue
Block a user