Compare commits
48
Commits
fb398c3835
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbfd7659fe | ||
|
|
823d9f02ec | ||
|
|
678db0499d | ||
|
|
a00fca942d | ||
|
|
61965e0b60 | ||
|
|
111ac5425a | ||
|
|
61fe5cca98 | ||
|
|
32b7940067 | ||
|
|
b9781f7d3f | ||
|
|
8e8bcaa5fb | ||
|
|
1f65913002 | ||
|
|
e61800dcdc | ||
|
|
f88857e026 | ||
|
|
5ece9f6dab | ||
|
|
c48d24ab6d | ||
|
|
d0726487e9 | ||
|
|
a84fe9b316 | ||
|
|
94e2957359 | ||
|
|
ebc9595de2 | ||
|
|
9964281c14 | ||
|
|
694d21d1fd | ||
|
|
4c32a556de | ||
|
|
4c10475228 | ||
|
|
2e0c64849b | ||
|
|
33b9b00bb0 | ||
|
|
d03ddadd1a | ||
|
|
c319a6a428 | ||
|
|
36114745db | ||
|
|
b86eaf2b00 | ||
|
|
71df9a16ed | ||
|
|
980628bb8f | ||
|
|
c8131588bf | ||
|
|
b6d84033ce | ||
|
|
698df6d31a | ||
|
|
39a3fa7796 | ||
|
|
ebb3a92f25 | ||
|
|
c91d1db78a | ||
|
|
294d99a0d3 | ||
|
|
6ff202d637 | ||
|
|
4e54d09ccb | ||
|
|
b7cfdfa50a | ||
|
|
04a1a0d307 | ||
|
|
2a1939d2e7 | ||
|
|
cfd2a196f2 | ||
|
|
cc4c51359c | ||
|
|
036834a1d0 | ||
|
|
8903a194c9 | ||
|
|
a2bd3960e1 |
No files matched your search
+12
-2
@@ -45,8 +45,9 @@ htmlcov/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.tox/
|
||||
# 端到端测试产出的截图
|
||||
frontend/e2e/*.png
|
||||
# 端到端测试产出的截图(脚本以相对路径 e2e/failure.png 落盘,在不同 CWD
|
||||
# 下会落到 frontend/e2e/ 或 frontend/e2e/e2e/,用 ** 一并覆盖)
|
||||
frontend/e2e/**/*.png
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
@@ -64,3 +65,12 @@ CLAUDE.md
|
||||
backend/uploads/
|
||||
# 数据库备份导出目录(⑨ 备份脚本产物)
|
||||
db/backups/
|
||||
|
||||
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
|
||||
docs/**/*.rar
|
||||
docs/T_12.3数据字典/
|
||||
# macOS 压缩包残留元数据
|
||||
__MACOSX/
|
||||
|
||||
# AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库
|
||||
.tmpdocx/
|
||||
+80
-1
@@ -83,8 +83,9 @@ app/
|
||||
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
|
||||
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
|
||||
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
|
||||
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
|
||||
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选;`order_by=rating_score` 按评分排序,未评分垫底)/ 新增(分类值域;公司名+负责人唯一) |
|
||||
| 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) |
|
||||
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
|
||||
|
||||
@@ -114,6 +115,31 @@ app/
|
||||
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
|
||||
若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
|
||||
|
||||
### 报表统计接口(M4 ⑤,限管理员/财务/领导)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/reports/personal-detail` | 个人绩效工资明细表(FR-029:人员/月份/客户类型/产品类别/岗位筛选;响应带 `summary` 全筛选合计) |
|
||||
| GET | `/api/v1/reports/department-summary` | 部门绩效工资汇总表(FR-030:月份必填;团队基数与月结聚合行同源;12.5% 与 5% 分列;含成员内联明细、未分配桶、月结冻结对照额) |
|
||||
| GET | `/api/v1/reports/business-stats` | 业务统计表(FR-031:发货/回款/绩效为业务累计值,不随业务日期筛选变化) |
|
||||
| GET | `/api/v1/reports/payment-stats` | 回款情况统计表(FR-031:8 档天数区间恒在,空桶为 0) |
|
||||
| GET | `/api/v1/reports/payment-detail` | 回款区间下钻(`bucket` 必填,未注册编码 400) |
|
||||
| GET | `/api/v1/reports/payment-day-buckets` | 区间档位元数据(前端下拉唯一来源,禁止前端硬编码) |
|
||||
| GET | `/api/v1/reports/{code}/export` | Excel 导出(文件流非统一信封;含公司页眉/财务复核栏/领导签字栏;超 2 万行 400;行为入操作日志) |
|
||||
|
||||
准入 `require_roles(admin, finance, leader)`(FR-030/031;FR-029 的「营销后勤授权范围内」
|
||||
待业务权限清单 #6)。前端菜单同层过滤(路由 `allowedRoles`)。
|
||||
|
||||
### 工作台接口(M4 ⑧,限管理员/财务/领导)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/dashboard?month=YYYY-MM` | 四区块一次性返回:经营绩效总览(当月绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步降级)、风险预警(回款逾期/审批超时 + 同步降级)、最近动态(操作日志) |
|
||||
|
||||
待办与预警**实时派生**(不落表、不引调度器);预警阈值走参数体系
|
||||
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
|
||||
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
@@ -196,6 +222,59 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
|
||||
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
|
||||
- **金额契约**: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 ⑧,改动前先读)
|
||||
|
||||
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
|
||||
- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定,**已终止业务(status=3)排除**;回款未指定批次 = `receipts.shipment_id IS NULL`。
|
||||
- **待办按角色过滤**:`pending_statuses_for_roles`(approval_service)把当前用户角色映射到在途节点——财务只看待财务复核(status=1)、领导只看待领导审核(status=2)、admin 可看全部;多角色取并集。风险预警仍是全局经营视角,不按人裁剪。
|
||||
- **预警展示上限**:`items` 最多 20 条,`total` 是全部命中数(前端徽标用它,不能用 `items.length`)。
|
||||
- **趋势窗口**:回款趋势先按 `receipt_date` 范围过滤再按 `DATE_FORMAT` 分组,函数列不参与 WHERE,保证索引可用。
|
||||
- **同步降级**:⑥ 未建时 `sync_available=false`,前端显示「模块未启用」——不要返回 0 条冒充「无失败」。
|
||||
- **参数快照一次解析**:`warning.*` 三个阈值在每次请求只 `resolve_snapshot` 一次,多条规则复用。
|
||||
- **导出 SQL 的编码坑**:Windows 下 `alembic upgrade --sql > V*.sql` 默认按控制台 GBK 输出,`test_schema_sync` 按 UTF-8 读取会报 `UnicodeDecodeError`——必须 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1` 前缀。
|
||||
|
||||
### 报表口径(M4 ⑤,改动前先读)
|
||||
|
||||
- **数据源固定单笔生效行**:四张报表的明细一律 `performance_ledgers.is_active=1 AND calc_type=1`;经理聚合行(`calc_type=2`)**只在部门汇总作冻结对照**(`frozen_amount`)单读。聚合行 `receipt_id` 为 NULL、`payment_days` 为 NULL,混入明细会让合同编号/天数列失真,业务统计的绩效也会被整团队聚合额污染。
|
||||
- **调整单并入**:仅 `status=2`(生效),按 `(settlement_month, personnel_id)` 并入,**不要求该人当月有台账行**(只有调整单也要出零绩效成员行);`direction=1` 补发记 +、`2` 扣减记 −(金额恒正)。待审批/驳回/撤回的金额绝不能进报表——`report_repository.list_month_adjustments` 与调整单管理页的 `list_adjustments` 是两个口径。调整单归属跟随该人普通行的实时 `manager_id`;只有经理个人户的经理跟随本人团队。
|
||||
- **回款金额按回款单去重**:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 `DISTINCT receipt_id` 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。
|
||||
- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块)。
|
||||
- **全公司 5% 的发放口径**:月结只为有台账 bucket 的营销经理各生成一条聚合行(12.5%+5%),**每位经理各计一次 5%**;零成员团队无聚合行、不发 5%。报表的 `aggregate_amount` 优先取冻结聚合行、未生成月结时才用 `manager_team_share` 一次舍入预览;`team_subtotal`/`net_total` = 聚合额 + 调整净额。`team_share_total` 与 `company_share_total` 仅分列展示,两者之和与聚合额的差记入 `aggregate_rounding_diff`(分列各自 q2 会有 0.01~0.02 尾差)。
|
||||
- **对拍边界**:报表应发 = 月结 `total_payable` + 调整单净额(调整单不进月结 items);筛选 `manager_id` 时只汇总展示团队,隐藏金额进 `other_*`,不能与全公司月结总额对拍。
|
||||
- **人员归属按行类型**:普通台账行跟随 `manager_id`;经理个人户行在 `manager.personal.included=1` 时归经理本人团队,且经理成员行**只取个人户金额**(其普通行业绩留在上级团队或未分配),保证团队基数 = 成员绩效合计。岗位判定范围必须覆盖所有成员引用的上级,否则其他经理的下属会被误判为「无经理」。按 `manager_id` 筛选时 `unassigned` 为空(语义是「仅看该经理团队」)。
|
||||
- **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。
|
||||
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。部门汇总是**单 sheet 分块签字稿**(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);第二块展示单经理 5%、计入次数与合计,对账块展示团队分成/全公司分成(分列)、聚合行舍入差、调整净额,应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。
|
||||
- **导出审计**:`audit` 上下文覆盖取数与构建全过程,超限 400 和取数异常都会写失败操作日志(`success=0`);成功摘要为静态报表标题。
|
||||
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
|
||||
- **岗位筛选选项**:`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 是安全问题,必须启动即失败。
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""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}")
|
||||
@@ -0,0 +1,144 @@
|
||||
"""v10 工作台预警规则参数
|
||||
|
||||
Revision ID: 90653814a421
|
||||
Revises: ba0217f5091b
|
||||
Create Date: 2026-10-19 17:55:48.892998
|
||||
|
||||
新增风险预警三条参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」):
|
||||
- warning.enabled:总开关(默认 1)
|
||||
- warning.receipt.overdue.days:发货后逾期天数(默认 90)
|
||||
- warning.approval.timeout.days:审批超时天数(默认 3)
|
||||
|
||||
「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import date
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "90653814a421"
|
||||
down_revision: str | Sequence[str] | None = "ba0217f5091b"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_EFFECTIVE_DATE = date(2026, 10, 19)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""种入三个预警参数定义与生效值(append-only 时点化)。"""
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"param_definitions",
|
||||
sa.column("id", sa.BigInteger),
|
||||
sa.column("param_code", sa.String),
|
||||
sa.column("param_name", sa.String),
|
||||
sa.column("param_group", sa.String),
|
||||
sa.column("value_type", sa.String),
|
||||
sa.column("unit", sa.String),
|
||||
sa.column("dimension_type", sa.String),
|
||||
sa.column("options_text", sa.String),
|
||||
sa.column("default_value", sa.String),
|
||||
sa.column("value_min", sa.String),
|
||||
sa.column("value_max", sa.String),
|
||||
sa.column("description", sa.String),
|
||||
sa.column("sort_order", sa.Integer),
|
||||
sa.column("status", sa.Integer),
|
||||
),
|
||||
[
|
||||
{
|
||||
"id": 21,
|
||||
"param_code": "warning.enabled",
|
||||
"param_name": "风险预警总开关",
|
||||
"param_group": "预警规则",
|
||||
"value_type": "BOOL",
|
||||
"unit": None,
|
||||
"dimension_type": "NONE",
|
||||
"options_text": None,
|
||||
"default_value": "1",
|
||||
"value_min": None,
|
||||
"value_max": None,
|
||||
"description": "工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
|
||||
"sort_order": 21,
|
||||
"status": 1,
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"param_code": "warning.receipt.overdue.days",
|
||||
"param_name": "回款逾期天数阈值",
|
||||
"param_group": "预警规则",
|
||||
"value_type": "INT",
|
||||
"unit": "天",
|
||||
"dimension_type": "NONE",
|
||||
"options_text": None,
|
||||
"default_value": "90",
|
||||
"value_min": "1",
|
||||
"value_max": None,
|
||||
"description": "发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)",
|
||||
"sort_order": 22,
|
||||
"status": 1,
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"param_code": "warning.approval.timeout.days",
|
||||
"param_name": "审批超时天数阈值",
|
||||
"param_group": "预警规则",
|
||||
"value_type": "INT",
|
||||
"unit": "天",
|
||||
"dimension_type": "NONE",
|
||||
"options_text": None,
|
||||
"default_value": "3",
|
||||
"value_min": "1",
|
||||
"value_max": None,
|
||||
"description": "待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)",
|
||||
"sort_order": 23,
|
||||
"status": 1,
|
||||
},
|
||||
],
|
||||
)
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"param_values",
|
||||
sa.column("id", sa.BigInteger),
|
||||
sa.column("param_id", sa.BigInteger),
|
||||
sa.column("dimension_value", sa.String),
|
||||
sa.column("effective_date", sa.Date),
|
||||
sa.column("value_text", sa.String),
|
||||
sa.column("batch_id", sa.BigInteger),
|
||||
),
|
||||
[
|
||||
{
|
||||
"id": 26,
|
||||
"param_id": 21,
|
||||
"dimension_value": "",
|
||||
"effective_date": _EFFECTIVE_DATE,
|
||||
"value_text": "1",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"param_id": 22,
|
||||
"dimension_value": "",
|
||||
"effective_date": _EFFECTIVE_DATE,
|
||||
"value_text": "90",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"param_id": 23,
|
||||
"dimension_value": "",
|
||||
"effective_date": _EFFECTIVE_DATE,
|
||||
"value_text": "3",
|
||||
"batch_id": 1,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚种子行(行级种子不会被 drop_table 带走,必须显式删除)。"""
|
||||
op.execute("DELETE FROM param_values WHERE id IN (26, 27, 28)")
|
||||
op.execute("DELETE FROM param_definitions WHERE id IN (21, 22, 23)")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""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")
|
||||
@@ -34,10 +34,7 @@ depends_on = None
|
||||
def upgrade() -> None:
|
||||
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
|
||||
# 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 解析)
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
@@ -188,6 +185,4 @@ def downgrade() -> None:
|
||||
)
|
||||
|
||||
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)")
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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")
|
||||
@@ -119,20 +119,23 @@ AdminUser = Annotated[User, Depends(require_admin)]
|
||||
LoginUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
def require_roles(*role_codes: str):
|
||||
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
|
||||
"""要求当前用户具备任一指定角色(依赖工厂)。
|
||||
|
||||
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
|
||||
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
|
||||
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
|
||||
|
||||
``message`` 允许各模块替换 403 文案(默认值保持审批模块原文案,
|
||||
避免既有调用与测试变动)。
|
||||
|
||||
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
|
||||
"""
|
||||
|
||||
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
codes = set(user_repository.get_role_codes(current_user))
|
||||
if not codes & set(role_codes):
|
||||
raise ForbiddenError("无权访问审批管理")
|
||||
raise ForbiddenError(message)
|
||||
return current_user
|
||||
|
||||
return _checker
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api import health
|
||||
from app.api.v1 import auth
|
||||
from app.api.v1.base import business_roles as base_business_roles
|
||||
from app.api.v1.base import customers as base_customers
|
||||
from app.api.v1.base import personnel as base_personnel
|
||||
from app.api.v1.base import products as base_products
|
||||
@@ -16,6 +17,8 @@ from app.api.v1.business import ratings as business_ratings
|
||||
from app.api.v1.business import receipts as business_receipts
|
||||
from app.api.v1.business import shipments as business_shipments
|
||||
from app.api.v1.calc import router as calc_router
|
||||
from app.api.v1.dashboard import router as dashboard_router
|
||||
from app.api.v1.reports import router as reports_router
|
||||
from app.api.v1.settlements import router as settlements_router
|
||||
from app.api.v1.system import approvals as system_approvals
|
||||
from app.api.v1.system import business_roles as system_business_roles
|
||||
@@ -51,6 +54,7 @@ base_router = APIRouter(prefix="/base")
|
||||
base_router.include_router(base_personnel.router)
|
||||
base_router.include_router(base_products.router)
|
||||
base_router.include_router(base_customers.router)
|
||||
base_router.include_router(base_business_roles.router)
|
||||
v1_router.include_router(base_router)
|
||||
|
||||
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
|
||||
@@ -65,5 +69,9 @@ v1_router.include_router(business_router)
|
||||
v1_router.include_router(calc_router)
|
||||
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
|
||||
v1_router.include_router(settlements_router)
|
||||
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导
|
||||
v1_router.include_router(reports_router)
|
||||
# 工作台(M4 ⑧):/api/v1/dashboard,四区块只读聚合,限管理员/财务/领导
|
||||
v1_router.include_router(dashboard_router)
|
||||
|
||||
api_router.include_router(v1_router)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""岗位下拉选项(基础数据与报表筛选共用)。
|
||||
|
||||
岗位管理本身仅管理员可用(FR-006),但报表的「岗位」筛选对财务/领导
|
||||
开放(FR-029)。为避免把管理接口整体放开,这里提供只读、登录即可的
|
||||
启用岗位选项;写操作仍走 `/system/business-roles` 的管理员端点。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.deps import DbSession, get_current_user
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.schemas.business_role import BusinessRoleOptionOut
|
||||
from app.services import business_role_service
|
||||
|
||||
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
|
||||
|
||||
# 基础数据是业务操作:登录即可,不要求管理员
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/options",
|
||||
response_model=ApiResponse[list[BusinessRoleOptionOut]],
|
||||
summary="岗位下拉选项",
|
||||
)
|
||||
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[BusinessRoleOptionOut]]:
|
||||
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
|
||||
|
||||
岗位规模有限(内置 + 自定义),一次取全量,不做分页。
|
||||
"""
|
||||
return ok(business_role_service.list_options(session))
|
||||
@@ -21,6 +21,7 @@ from app.schemas.customer import (
|
||||
CustomerOut,
|
||||
CustomerOwnershipIn,
|
||||
CustomerQuery,
|
||||
CustomerRatingIn,
|
||||
CustomerUpdateIn,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
@@ -102,6 +103,31 @@ def delete_customer(
|
||||
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(
|
||||
"/{customer_id}/ownership",
|
||||
response_model=ApiResponse[CustomerOut],
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""工作台接口(M4 ⑧):三区块聚合 + 最近动态分页。
|
||||
|
||||
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
|
||||
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.api.deps import DbSession, require_roles
|
||||
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.schemas.dashboard import ActivityPageOut, ActivityQuery, DashboardOut
|
||||
from app.services import dashboard_service
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["工作台"])
|
||||
|
||||
_DashboardUser = Annotated[
|
||||
User,
|
||||
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台")),
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台三区块")
|
||||
def get_dashboard(
|
||||
session: DbSession,
|
||||
current_user: _DashboardUser,
|
||||
month: Annotated[
|
||||
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
|
||||
] = None,
|
||||
) -> ApiResponse[DashboardOut]:
|
||||
"""一次返回经营总览/待办/风险预警(待办与预警实时派生)。
|
||||
|
||||
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
|
||||
最近动态是分页列表,走 `GET /dashboard/activities`。
|
||||
"""
|
||||
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))
|
||||
@@ -0,0 +1,191 @@
|
||||
"""报表统计接口(M4 ⑤):四张报表只读查询 + Excel 导出。
|
||||
|
||||
准入:管理员/财务/领导(FR-030/031 明确;FR-029 的营销后勤「授权范围内」
|
||||
待权限清单 #6,暂不开放)。前后端双层:前端 allowedRoles 控菜单,本层
|
||||
require_roles 兜底。
|
||||
|
||||
导出返回文件流(非统一信封),沿用参数导出先例:服务端文件名为 ASCII
|
||||
(HTTP 头按 latin-1 编码,中文名会 UnicodeEncodeError),中文名由前端
|
||||
``link.download`` 指定。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.schemas.report import (
|
||||
BusinessStatsQuery,
|
||||
BusinessStatsRow,
|
||||
DepartmentSummaryOut,
|
||||
DepartmentSummaryQuery,
|
||||
PaymentDetailQuery,
|
||||
PaymentDetailRow,
|
||||
PaymentStatsOut,
|
||||
PaymentStatsQuery,
|
||||
PersonalDetailQuery,
|
||||
PersonalDetailRow,
|
||||
ReportPage,
|
||||
)
|
||||
from app.services import report_service
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["绩效工资 · 报表统计"])
|
||||
|
||||
_ReportViewer = Annotated[
|
||||
User,
|
||||
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问报表统计")),
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/personal-detail",
|
||||
response_model=ApiResponse[ReportPage[PersonalDetailRow]],
|
||||
summary="个人绩效工资明细表",
|
||||
)
|
||||
def personal_detail(
|
||||
session: DbSession,
|
||||
_: _ReportViewer,
|
||||
query: Annotated[PersonalDetailQuery, Query()],
|
||||
) -> ApiResponse[ReportPage[PersonalDetailRow]]:
|
||||
"""按人员/月份/客户类型/产品类别/岗位查明细(口径与台账一致)。"""
|
||||
return ok(report_service.list_personal_detail(session, query))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/department-summary",
|
||||
response_model=ApiResponse[DepartmentSummaryOut],
|
||||
summary="部门绩效工资汇总表",
|
||||
)
|
||||
def department_summary(
|
||||
session: DbSession,
|
||||
_: _ReportViewer,
|
||||
query: Annotated[DepartmentSummaryQuery, Query()],
|
||||
) -> ApiResponse[DepartmentSummaryOut]:
|
||||
"""按经理团队/月份汇总(团队基数与月结聚合行同源)。"""
|
||||
return ok(report_service.department_summary(session, query))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/business-stats",
|
||||
response_model=ApiResponse[ReportPage[BusinessStatsRow]],
|
||||
summary="业务统计表",
|
||||
)
|
||||
def business_stats(
|
||||
session: DbSession,
|
||||
_: _ReportViewer,
|
||||
query: Annotated[BusinessStatsQuery, Query()],
|
||||
) -> ApiResponse[ReportPage[BusinessStatsRow]]:
|
||||
"""按业务查询发货、回款、绩效(回款/绩效为业务累计值)。"""
|
||||
return ok(report_service.business_stats(session, query))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/payment-stats",
|
||||
response_model=ApiResponse[PaymentStatsOut],
|
||||
summary="回款情况统计表",
|
||||
)
|
||||
def payment_stats(
|
||||
session: DbSession,
|
||||
_: _ReportViewer,
|
||||
query: Annotated[PaymentStatsQuery, Query()],
|
||||
) -> ApiResponse[PaymentStatsOut]:
|
||||
"""按回款天数区间/客户类型/产品类别统计(空桶恒在且为 0)。"""
|
||||
return ok(report_service.payment_stats(session, query))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/payment-detail",
|
||||
response_model=ApiResponse[ReportPage[PaymentDetailRow]],
|
||||
summary="回款区间下钻",
|
||||
)
|
||||
def payment_detail(
|
||||
session: DbSession,
|
||||
_: _ReportViewer,
|
||||
query: Annotated[PaymentDetailQuery, Query()],
|
||||
) -> ApiResponse[ReportPage[PaymentDetailRow]]:
|
||||
"""区间桶下钻到单笔台账行(含回款金额与批次号)。"""
|
||||
return ok(report_service.payment_detail(session, query))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/payment-day-buckets", response_model=ApiResponse[list[dict]], summary="回款天数区间档位"
|
||||
)
|
||||
def payment_day_buckets(_: _ReportViewer) -> ApiResponse[list[dict]]:
|
||||
"""区间档位元数据(前端渲染下拉/表头,禁止前端硬编码档位)。"""
|
||||
return ok(report_service.get_payment_day_buckets())
|
||||
|
||||
|
||||
class ExportQuery(BaseModel):
|
||||
"""导出通用筛选参数(按报表编码取用相关字段,未用字段忽略)。"""
|
||||
|
||||
settlement_month: str | None = None
|
||||
personnel_id: int | None = None
|
||||
customer_type: str | None = None
|
||||
product_category: str | None = None
|
||||
role_code: str | None = None
|
||||
manager_id: int | None = None
|
||||
keyword: str | None = None
|
||||
customer_id: int | None = None
|
||||
owner_personnel_id: int | None = None
|
||||
status: int | None = None
|
||||
date_from: date | None = None
|
||||
date_to: date | None = None
|
||||
|
||||
|
||||
@router.get("/{code}/export", summary="导出报表 Excel")
|
||||
def export_report(
|
||||
code: Annotated[
|
||||
Literal["personal-detail", "department-summary", "business-stats", "payment-stats"],
|
||||
Path(description="报表编码"),
|
||||
],
|
||||
session: DbSession,
|
||||
current_user: _ReportViewer,
|
||||
meta: RequestMetaDep,
|
||||
query: Annotated[ExportQuery, Query()],
|
||||
) -> StreamingResponse:
|
||||
"""导出 xlsx(含公司页眉/财务复核栏/领导签字栏),行为入操作日志。"""
|
||||
content, filename = report_service.export_report(
|
||||
session,
|
||||
code,
|
||||
_build_export_query(code, query),
|
||||
operator=current_user,
|
||||
meta=meta,
|
||||
)
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": (f'attachment; filename="{filename}_{date.today():%Y%m%d}.xlsx"')
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _build_export_query(code: str, query: ExportQuery):
|
||||
"""导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。
|
||||
|
||||
手工构造模型抛出的是 `pydantic.ValidationError`,不是 FastAPI 的
|
||||
`RequestValidationError`——不在这里转换就会落到全局 500。导出参数
|
||||
非法与查询接口一样应当是 400。
|
||||
"""
|
||||
data = query.model_dump()
|
||||
model = {
|
||||
"personal-detail": PersonalDetailQuery,
|
||||
"department-summary": DepartmentSummaryQuery,
|
||||
"business-stats": BusinessStatsQuery,
|
||||
"payment-stats": PaymentStatsQuery,
|
||||
}[code]
|
||||
try:
|
||||
return model(**data)
|
||||
except ValidationError as exc:
|
||||
first = exc.errors()[0]
|
||||
location = ".".join(str(part) for part in first.get("loc", ()))
|
||||
message = first.get("msg", "参数错误")
|
||||
detail = f"{location} {message}" if location else message
|
||||
raise BadRequestError(f"参数错误:{detail}") from exc
|
||||
@@ -27,11 +27,12 @@ _WIDTH, _HEIGHT = 120, 40
|
||||
_FONT_SIZE = 25
|
||||
_PADDING = 8
|
||||
|
||||
# 与前端配色呼应:墨色字符、青灰干扰、素纸底
|
||||
_INK = (15, 54, 70)
|
||||
_INTERFERENCE = (126, 156, 170)
|
||||
_SPECKLE = (168, 183, 192)
|
||||
_PAPER = (247, 246, 242)
|
||||
# 与前端 v3 配色呼应(--ys-ink / --ys-ink-2 / --ys-ink-3 / --ys-paper):
|
||||
# 墨色字符、次级墨干扰、辅助墨噪点、纸白底
|
||||
_INK = (28, 35, 51) # #1c2333
|
||||
_INTERFERENCE = (91, 101, 119) # #5b6577
|
||||
_SPECKLE = (154, 163, 178) # #9aa3b2
|
||||
_PAPER = (255, 255, 255) # #ffffff
|
||||
|
||||
_SPECKLE_COUNT = 46
|
||||
_INTERFERENCE_LINES = 2
|
||||
|
||||
@@ -87,6 +87,7 @@ MODULE_SYSTEM_SETTINGS = "系统设置"
|
||||
MODULE_APPROVAL = "审批管理"
|
||||
MODULE_CALC = "绩效核算"
|
||||
MODULE_SETTLEMENT = "月度结算"
|
||||
MODULE_REPORT = "报表统计"
|
||||
|
||||
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
|
||||
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
|
||||
@@ -105,3 +106,4 @@ ACTION_ENABLE = "启用"
|
||||
ACTION_DISABLE = "禁用"
|
||||
ACTION_APPROVE = "审批通过"
|
||||
ACTION_REJECT = "审批驳回"
|
||||
ACTION_EXPORT = "导出"
|
||||
@@ -0,0 +1,26 @@
|
||||
"""月份参数校验(YYYY-MM)——叶子模块。
|
||||
|
||||
报表、月结与调整单共用同一套校验:只校验长度会放过 `2026-13` 这类
|
||||
非法月份,一路传到 `calendar.monthrange` 才抛 ValueError,最终被全局
|
||||
处理器当成服务端异常(500)。月份范围必须在入口处拦住,才能返回 400。
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
|
||||
|
||||
|
||||
def validate_month(value: str) -> str:
|
||||
"""校验 YYYY-MM 且月份 01-12;非法抛 ValueError(Pydantic 友好)。
|
||||
|
||||
年份 0000 也拒绝:`date(0, ...)` 同样会抛 ValueError,且业务上不存在。
|
||||
"""
|
||||
if not _MONTH_RE.match(value) or int(value[:4]) == 0:
|
||||
raise ValueError("月份格式须为 YYYY-MM(01-12)")
|
||||
return value
|
||||
|
||||
|
||||
def parse_month(value: str) -> tuple[int, int]:
|
||||
"""校验并返回 (year, month),供服务层计算月末等使用。"""
|
||||
validate_month(value)
|
||||
return int(value[:4]), int(value[5:7])
|
||||
@@ -0,0 +1,79 @@
|
||||
"""报表统计元数据(区间桶、中文标签、导出上限)——叶子模块。
|
||||
|
||||
刻意不导入 app 内任何模块:report_service 与 report_export 都要消费这里的
|
||||
定义,若它反向依赖 service 层会形成循环导入;也不走参数体系——FR-031
|
||||
「区间可配置」一期以代码常量落地(参数化需注册表 + 迁移 + init-dev +
|
||||
registry 测试四处同改,业务未提诉求前不做),配置缝收在
|
||||
get_payment_day_buckets 单函数,未来在线可配时只改该函数。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DayBucket:
|
||||
"""回款天数区间档位:min/max 均为 None 表示「无回款天数」兜底桶。"""
|
||||
|
||||
code: str
|
||||
label: str
|
||||
min_days: int | None
|
||||
max_days: int | None
|
||||
|
||||
|
||||
# 8 档区间(催办清单 R2 默认口径)。两个兜底桶必须保留:payment_days 可空
|
||||
# (回款未指定发货批次)且可为负(先款后货照实负值),缺了它们这些行会
|
||||
# 静默消失、金额对不上。
|
||||
PAYMENT_DAY_BUCKETS: tuple[DayBucket, ...] = (
|
||||
DayBucket("le0", "≤0 天(先款后货)", None, 0),
|
||||
DayBucket("1-30", "1-30 天", 1, 30),
|
||||
DayBucket("31-60", "31-60 天", 31, 60),
|
||||
DayBucket("61-90", "61-90 天", 61, 90),
|
||||
DayBucket("91-180", "91-180 天", 91, 180),
|
||||
DayBucket("181-360", "181-360 天", 181, 360),
|
||||
DayBucket("361+", "361 天以上", 361, None),
|
||||
DayBucket("none", "无回款天数", None, None),
|
||||
)
|
||||
|
||||
CUSTOMER_TYPE_LABELS: dict[str, str] = {
|
||||
"personal": "个人户",
|
||||
"manager_personal": "经理个人户",
|
||||
"enterprise": "企业户",
|
||||
}
|
||||
|
||||
# mixed 不在 core.constants.PRODUCT_CATEGORIES(那只是可维护的档案类别),
|
||||
# 但台账行可以是 mixed(混合业务行级明细入 snapshot_json),报表必须能标它
|
||||
PRODUCT_CATEGORY_LABELS: dict[str, str] = {
|
||||
"municipal_water": "市政水利",
|
||||
"rail_transit": "轨道交通",
|
||||
"mixed": "混合业务",
|
||||
}
|
||||
|
||||
# 业务状态中文标签:导出列、筛选页眉与前端标签共用同一份映射
|
||||
BUSINESS_STATUS_LABELS: dict[int, str] = {1: "进行中", 2: "已完成", 3: "已终止"}
|
||||
|
||||
REPORT_TEMPLATE_VERSION = "V1.2"
|
||||
# 导出超限即拒绝(需求「大数据量分批/超限提示」二选一取后者):普通
|
||||
# Workbook 全量载入内存,2 万行约数十 MB,超过该量级必须引导按月导出
|
||||
REPORT_EXPORT_MAX_ROWS = 20000
|
||||
|
||||
|
||||
def get_payment_day_buckets() -> tuple[DayBucket, ...]:
|
||||
"""区间档位唯一出口:前端下拉与分桶都以本函数返回为准,禁止硬编码。
|
||||
|
||||
未来「区间在线可配」时改此处读参数即可,调用方与前端零改动。
|
||||
"""
|
||||
return PAYMENT_DAY_BUCKETS
|
||||
|
||||
|
||||
def bucket_of(payment_days: int | None) -> DayBucket:
|
||||
"""把单笔回款天数落入对应区间桶;未匹配任何区间按「无回款天数」兜底。"""
|
||||
if payment_days is None:
|
||||
return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none")
|
||||
for bucket in PAYMENT_DAY_BUCKETS:
|
||||
if bucket.code == "none":
|
||||
continue
|
||||
lower_ok = bucket.min_days is None or payment_days >= bucket.min_days
|
||||
upper_ok = bucket.max_days is None or payment_days <= bucket.max_days
|
||||
if lower_ok and upper_ok:
|
||||
return bucket
|
||||
return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none")
|
||||
@@ -15,7 +15,16 @@ ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, text
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
@@ -54,7 +63,17 @@ class ApprovalOrder(Base, TimestampMixin):
|
||||
__tablename__ = "approval_orders"
|
||||
__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"),
|
||||
# 版本号按 (order_type, ref_id) 递增,须有对应索引避免全表 max 扫描
|
||||
Index("idx_approval_orders_type_ref", "order_type", "ref_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
@@ -76,6 +95,17 @@ class ApprovalOrder(Base, TimestampMixin):
|
||||
refer_no: Mapped[str] = mapped_column(
|
||||
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(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
|
||||
@@ -15,7 +15,17 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
|
||||
from sqlalchemy import (
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import (
|
||||
@@ -42,27 +52,34 @@ class SecondaryAllocation(Base, TimestampMixin):
|
||||
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
|
||||
与比例,响应以 hint 提示合计/余额,不做强制校验
|
||||
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
|
||||
- **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按
|
||||
核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表
|
||||
- **版本行 append-only**:编辑全量替换与驳回清空都把旧行置
|
||||
``is_active=0`` 而非物理删除,历史方案可回溯;当前生效行由
|
||||
``is_active=1`` 唯一标识。核算仍按「当前生效行」取快照
|
||||
(``performance_ledgers.snapshot_json.alloc_plan`` 固化),版本化只
|
||||
补齐「方案事实留存」,不改变核算口径
|
||||
|
||||
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
|
||||
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
|
||||
冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id),
|
||||
同一合同的同一参与人仅一行,重复提交在服务层聚合。
|
||||
冗余固化,人离职后方案回显仍有名。uk 锁
|
||||
(business_id, version_no, personnel_id),同一版本内同一参与人仅一行,
|
||||
重复提交在服务层聚合;跨版本允许同一人再次出现。
|
||||
"""
|
||||
|
||||
__tablename__ = "business_secondary_allocations"
|
||||
__table_args__ = mysql_table_args(
|
||||
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
|
||||
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
|
||||
"业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
|
||||
"业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)",
|
||||
UniqueConstraint(
|
||||
"business_id",
|
||||
"version_no",
|
||||
"personnel_id",
|
||||
name="uk_business_secondary_allocations_business_personnel",
|
||||
name="uk_business_secondary_allocations_business_version_personnel",
|
||||
),
|
||||
Index("idx_business_secondary_allocations_business_id", "business_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(
|
||||
@@ -74,6 +91,12 @@ class SecondaryAllocation(Base, TimestampMixin):
|
||||
nullable=False,
|
||||
comment="业务ID(businesses.id;业务删除随删)",
|
||||
)
|
||||
version_no: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)",
|
||||
)
|
||||
personnel_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||
@@ -88,6 +111,12 @@ class SecondaryAllocation(Base, TimestampMixin):
|
||||
nullable=False,
|
||||
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):
|
||||
|
||||
@@ -63,6 +63,14 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
|
||||
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
||||
)
|
||||
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):
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
models/customer.py 定义)。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Row, Select, delete, exists, func, or_, select
|
||||
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import (
|
||||
@@ -23,6 +21,8 @@ from app.models import (
|
||||
)
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
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:
|
||||
@@ -77,50 +77,32 @@ def get_with_names(
|
||||
return session.execute(stmt).first()
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
keyword: str | None,
|
||||
status: int | None,
|
||||
customer_id: int | None,
|
||||
owner_personnel_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
|
||||
"""业务列表的筛选条件(调用方须已 join Customer)。"""
|
||||
if keyword:
|
||||
if query.keyword:
|
||||
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
|
||||
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Business.business_no.contains(keyword, autoescape=True),
|
||||
Customer.company_name.contains(keyword, autoescape=True),
|
||||
Business.business_no.contains(query.keyword, autoescape=True),
|
||||
Customer.company_name.contains(query.keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Business.status == status)
|
||||
if customer_id is not None:
|
||||
stmt = stmt.where(Business.customer_id == customer_id)
|
||||
if owner_personnel_id is not None:
|
||||
stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(Business.business_date >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(Business.business_date <= date_to)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(Business.status == query.status)
|
||||
if query.customer_id is not None:
|
||||
stmt = stmt.where(Business.customer_id == query.customer_id)
|
||||
if query.owner_personnel_id is not None:
|
||||
stmt = stmt.where(Business.owner_personnel_id == query.owner_personnel_id)
|
||||
if query.date_from is not None:
|
||||
stmt = stmt.where(Business.business_date >= query.date_from)
|
||||
if query.date_to is not None:
|
||||
stmt = stmt.where(Business.business_date <= query.date_to)
|
||||
return stmt
|
||||
|
||||
|
||||
def list_businesses(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None,
|
||||
status: int | None,
|
||||
customer_id: int | None,
|
||||
owner_personnel_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
session: Session, query: BusinessQuery
|
||||
) -> list[tuple[Business, str, str | None, str | None]]:
|
||||
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
|
||||
owner = aliased(Personnel)
|
||||
@@ -128,49 +110,24 @@ def list_businesses(
|
||||
stmt = select(
|
||||
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
|
||||
).join(Customer, Customer.id == Business.customer_id)
|
||||
stmt = _apply_filters(
|
||||
stmt,
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
customer_id=customer_id,
|
||||
owner_personnel_id=owner_personnel_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
stmt = _apply_filters(stmt, query)
|
||||
stmt = (
|
||||
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
||||
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
|
||||
.order_by(Business.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_businesses(
|
||||
session: Session,
|
||||
*,
|
||||
keyword: str | None,
|
||||
status: int | None,
|
||||
customer_id: int | None,
|
||||
owner_personnel_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
) -> int:
|
||||
def count_businesses(session: Session, query: BusinessQuery) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(Business)
|
||||
.join(Customer, Customer.id == Business.customer_id)
|
||||
)
|
||||
stmt = _apply_filters(
|
||||
stmt,
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
customer_id=customer_id,
|
||||
owner_personnel_id=owner_personnel_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
stmt = _apply_filters(stmt, query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@@ -199,43 +156,75 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem])
|
||||
session.flush()
|
||||
|
||||
|
||||
def list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]:
|
||||
"""二次分配方案(详情回显;空列表=未设置方案)。"""
|
||||
def list_allocations(
|
||||
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(
|
||||
session.execute(
|
||||
select(SecondaryAllocation)
|
||||
.where(SecondaryAllocation.business_id == business_id)
|
||||
.order_by(SecondaryAllocation.id)
|
||||
stmt.order_by(SecondaryAllocation.version_no, SecondaryAllocation.id)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def list_allocation_versions(session: Session, business_id: int) -> list[SecondaryAllocation]:
|
||||
"""方案全部版本行(含历史;业务详情「方案历史」用,按版本正序)。"""
|
||||
return list_allocations(session, business_id, active_only=False)
|
||||
|
||||
|
||||
def replace_allocations(
|
||||
session: Session, business_id: int, rows: list[SecondaryAllocation]
|
||||
) -> None:
|
||||
"""全量替换二次分配方案(不 commit,事务边界在服务层)。
|
||||
"""全量替换二次分配方案:旧生效行置历史,新行按下一版本号插入。
|
||||
|
||||
本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的
|
||||
防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。
|
||||
不 commit(事务边界在服务层)。版本行 append-only——编辑与清空都不
|
||||
物理删除,驳回后仍可回溯被否决方案的参与人与比例。空 ``rows`` 表示
|
||||
只清空生效态(不产生新版本)。
|
||||
"""
|
||||
session.execute(
|
||||
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||
deactivate_allocations(session, business_id)
|
||||
if not rows:
|
||||
return
|
||||
next_version = (
|
||||
session.execute(
|
||||
select(func.coalesce(func.max(SecondaryAllocation.version_no), 0)).where(
|
||||
SecondaryAllocation.business_id == business_id
|
||||
)
|
||||
).scalar_one()
|
||||
+ 1
|
||||
)
|
||||
session.flush()
|
||||
for row in rows:
|
||||
row.business_id = business_id
|
||||
row.version_no = next_version
|
||||
row.is_active = 1
|
||||
session.add(row)
|
||||
session.flush()
|
||||
|
||||
|
||||
def delete_allocations(session: Session, business_id: int) -> None:
|
||||
"""清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。
|
||||
def deactivate_allocations(session: Session, business_id: int) -> None:
|
||||
"""把业务的当前生效方案置为历史(审批驳回/编辑替换时调用;行保留)。
|
||||
|
||||
被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到);
|
||||
方案事实由审批单留痕与操作日志 request_data 承载。
|
||||
被否决的方案不得以「当前生效行」身份继续被核算取到;``is_active=0``
|
||||
后核算侧自然读不到,而历史版本仍可在业务详情回溯。不 commit。
|
||||
|
||||
显式声明 ``synchronize_session="fetch"``:Core UPDATE 默认走 auto 策略
|
||||
(优先 evaluate,本例的条件恰好能在 Python 侧求值,故实测也会刷新在途
|
||||
对象)。显式写 fetch 是为了不把「在途对象被刷新」这件事押在 evaluate
|
||||
能否解析条件上——条件一旦复杂化,auto 会退化为不刷新,同一会话里已
|
||||
加载的方案行就会停留在 ``is_active=1`` 的过期内存值(查询虽有
|
||||
is_active 过滤,但行对象本身仍是「生效态」,被序列化或参与判断就是
|
||||
错的)。
|
||||
"""
|
||||
session.execute(
|
||||
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||
update(SecondaryAllocation)
|
||||
.where(
|
||||
SecondaryAllocation.business_id == business_id,
|
||||
SecondaryAllocation.is_active == 1,
|
||||
)
|
||||
.values(is_active=0)
|
||||
.execution_options(synchronize_session="fetch")
|
||||
)
|
||||
session.flush()
|
||||
|
||||
@@ -281,18 +270,17 @@ def list_ownership_logs(
|
||||
) -> list[Row]:
|
||||
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
|
||||
return ownership_log_repository.list_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
ref_column=OwnershipChangeLog.business_id,
|
||||
ref_id=business_id,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
session, _business_log_ref(business_id), offset=offset, limit=limit
|
||||
)
|
||||
|
||||
|
||||
def count_ownership_logs(session: Session, business_id: int) -> int:
|
||||
return ownership_log_repository.count_by_ref(
|
||||
session,
|
||||
return ownership_log_repository.count_by_ref(session, _business_log_ref(business_id))
|
||||
|
||||
|
||||
def _business_log_ref(business_id: int) -> OwnershipLogRef:
|
||||
"""业务维度的归属留痕锚点。"""
|
||||
return OwnershipLogRef(
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
ref_column=OwnershipChangeLog.business_id,
|
||||
ref_id=business_id,
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import BusinessRole
|
||||
from app.schemas.business_role import BusinessRoleQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@@ -39,41 +40,48 @@ def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, Business
|
||||
return {role.role_code: role for role in rows}
|
||||
|
||||
|
||||
def list_roles(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> list[BusinessRole]:
|
||||
def list_roles(session: Session, query: BusinessRoleQuery) -> list[BusinessRole]:
|
||||
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
|
||||
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
|
||||
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(select(BusinessRole), query)
|
||||
stmt = (
|
||||
stmt.order_by(BusinessRole.sort_order, BusinessRole.id)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
|
||||
def count_roles(session: Session, query: BusinessRoleQuery) -> int:
|
||||
"""统计符合条件的岗位总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status)
|
||||
stmt = _apply_filters(select(func.count(BusinessRole.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
|
||||
def list_all(session: Session) -> list[BusinessRole]:
|
||||
"""全部岗位(含停用,不分页)——岗位下拉选项的取数口径。
|
||||
|
||||
岗位规模有限(内置 + 自定义),一次取全量,不受分页上限约束;
|
||||
排序与 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 通配符)——岗位编码含下划线,
|
||||
不转义时 ``_`` 会当单字符通配执行。
|
||||
"""
|
||||
if keyword:
|
||||
if query.keyword:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
BusinessRole.role_code.contains(keyword, autoescape=True),
|
||||
BusinessRole.role_name.contains(keyword, autoescape=True),
|
||||
BusinessRole.role_code.contains(query.keyword, autoescape=True),
|
||||
BusinessRole.role_name.contains(query.keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(BusinessRole.status == status)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(BusinessRole.status == query.status)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ from sqlalchemy.orm import Session
|
||||
from app.models import Customer, OwnershipChangeLog, Personnel
|
||||
from app.models.customer import OWNER_TYPE_CUSTOMER
|
||||
from app.repositories import ownership_log_repository
|
||||
from app.repositories.ownership_log_repository import OwnershipLogRef
|
||||
from app.repositories.query_utils import fuzzy
|
||||
from app.schemas.customer import CustomerQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@@ -45,49 +47,27 @@ def get_by_company_owner(session: Session, company_name: str, owner_name: str) -
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_customers(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
customer_type: str | None = None,
|
||||
owner_personnel_id: int | None = None,
|
||||
) -> list[Row]:
|
||||
def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
|
||||
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
|
||||
|
||||
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
|
||||
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
|
||||
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
|
||||
排序键由查询白名单决定(见 ``CustomerQuery.order_by``):
|
||||
id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
_base_select(),
|
||||
keyword=keyword,
|
||||
customer_type=customer_type,
|
||||
owner_personnel_id=owner_personnel_id,
|
||||
)
|
||||
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(_base_select(), query)
|
||||
stmt = _apply_order(stmt, query)
|
||||
stmt = stmt.offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_customers(
|
||||
session: Session,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
customer_type: str | None = None,
|
||||
owner_personnel_id: int | None = None,
|
||||
) -> int:
|
||||
def count_customers(session: Session, query: CustomerQuery) -> int:
|
||||
"""统计符合条件的客户总数,供分页使用。
|
||||
|
||||
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||||
``_apply_filters`` 单点保证)。
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(Customer.id)),
|
||||
keyword=keyword,
|
||||
customer_type=customer_type,
|
||||
owner_personnel_id=owner_personnel_id,
|
||||
)
|
||||
stmt = _apply_filters(select(func.count(Customer.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@@ -101,9 +81,7 @@ def list_ownership_logs(
|
||||
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
|
||||
return ownership_log_repository.list_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
ref_column=OwnershipChangeLog.customer_id,
|
||||
ref_id=customer_id,
|
||||
_customer_log_ref(customer_id),
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
@@ -111,8 +89,12 @@ def list_ownership_logs(
|
||||
|
||||
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
|
||||
"""统计某客户的归属留痕总数,供分页使用。"""
|
||||
return ownership_log_repository.count_by_ref(
|
||||
session,
|
||||
return ownership_log_repository.count_by_ref(session, _customer_log_ref(customer_id))
|
||||
|
||||
|
||||
def _customer_log_ref(customer_id: int) -> OwnershipLogRef:
|
||||
"""客户维度的归属留痕锚点。"""
|
||||
return OwnershipLogRef(
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
ref_column=OwnershipChangeLog.customer_id,
|
||||
ref_id=customer_id,
|
||||
@@ -126,20 +108,14 @@ def _base_select() -> Select:
|
||||
)
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
keyword: str | None,
|
||||
customer_type: str | None,
|
||||
owner_personnel_id: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||
if keyword:
|
||||
stmt = stmt.where(_keyword_clause(keyword))
|
||||
if customer_type:
|
||||
stmt = stmt.where(Customer.customer_type == customer_type)
|
||||
if owner_personnel_id is not None:
|
||||
stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id)
|
||||
if query.keyword:
|
||||
stmt = stmt.where(_keyword_clause(query.keyword))
|
||||
if query.customer_type:
|
||||
stmt = stmt.where(Customer.customer_type == query.customer_type)
|
||||
if query.owner_personnel_id is not None:
|
||||
stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id)
|
||||
return stmt
|
||||
|
||||
|
||||
@@ -157,6 +133,23 @@ 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())
|
||||
|
||||
|
||||
# ==================== 写入 ====================
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""工作台仓储:四区块的只读聚合查询;不 commit、不抛业务异常。
|
||||
|
||||
总览只做 SQL 聚合(SUM/COUNT DISTINCT),不复用报表的
|
||||
``department_summary``——后者会加载整月台账并逐团队查人员,适合报表页,
|
||||
不适合每次打开工作台。
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import exists, func, or_, select
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from app.models import (
|
||||
ApprovalOrder,
|
||||
Business,
|
||||
BusinessRole,
|
||||
MonthlySettlement,
|
||||
PerformanceLedger,
|
||||
Personnel,
|
||||
Receipt,
|
||||
Shipment,
|
||||
)
|
||||
from app.models.approval import (
|
||||
APPROVAL_STATUS_PENDING_FINANCE,
|
||||
APPROVAL_STATUS_PENDING_LEADER,
|
||||
)
|
||||
|
||||
_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1)
|
||||
_PENDING_STATUSES = (
|
||||
APPROVAL_STATUS_PENDING_FINANCE,
|
||||
APPROVAL_STATUS_PENDING_LEADER,
|
||||
)
|
||||
|
||||
|
||||
# ==== 经营绩效总览 ====
|
||||
|
||||
|
||||
def sum_month_performance(session: Session, month: str) -> dict:
|
||||
"""当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。"""
|
||||
return (
|
||||
session.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
|
||||
"performance_total"
|
||||
),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
|
||||
func.count(func.distinct(PerformanceLedger.personnel_id)).label("person_count"),
|
||||
).where(
|
||||
PerformanceLedger.settlement_month == month,
|
||||
*_ACTIVE_SINGLE,
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
|
||||
|
||||
def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
|
||||
"""近 N 个月绩效合计(按台账归属月)。"""
|
||||
rows = session.execute(
|
||||
select(
|
||||
PerformanceLedger.settlement_month,
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0),
|
||||
)
|
||||
.where(
|
||||
PerformanceLedger.settlement_month.in_(months),
|
||||
*_ACTIVE_SINGLE,
|
||||
)
|
||||
.group_by(PerformanceLedger.settlement_month)
|
||||
).all()
|
||||
return dict(rows)
|
||||
|
||||
|
||||
def receipt_trend(session: Session, *, start_date: date, end_date: date) -> dict[str, object]:
|
||||
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
|
||||
|
||||
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
|
||||
分组,不参与 WHERE,避免函数列导致全表扫描。
|
||||
"""
|
||||
month_expr = func.date_format(Receipt.receipt_date, "%Y-%m")
|
||||
rows = session.execute(
|
||||
select(month_expr, func.coalesce(func.sum(Receipt.amount), 0))
|
||||
.where(Receipt.receipt_date >= start_date, Receipt.receipt_date < end_date)
|
||||
.group_by(month_expr)
|
||||
).all()
|
||||
return dict(rows)
|
||||
|
||||
|
||||
def count_active_managers(session: Session) -> int:
|
||||
"""在职营销经理数(团队数口径,与部门汇总的团队枚举一致)。"""
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(Personnel)
|
||||
.join(BusinessRole, Personnel.business_role_id == BusinessRole.id)
|
||||
.where(
|
||||
BusinessRole.role_code == "sales_manager",
|
||||
Personnel.status == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def get_month_settlement(session: Session, month: str) -> MonthlySettlement | None:
|
||||
"""当月月结头(状态与锁定时间角标)。"""
|
||||
return session.execute(
|
||||
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
# ==== 待办事项 ====
|
||||
|
||||
|
||||
def list_pending_approvals(
|
||||
session: Session, *, statuses: tuple[int, ...], limit: int
|
||||
) -> list[ApprovalOrder]:
|
||||
"""待处理审批单(按提交时间升序——最久的排前面)。
|
||||
|
||||
``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。
|
||||
|
||||
排除 snapshot_json 列(快照可达上百 KB,待办只取单号/类型/提交人/时间)。
|
||||
"""
|
||||
if not statuses:
|
||||
return []
|
||||
return list(
|
||||
session.execute(
|
||||
select(ApprovalOrder)
|
||||
.options(defer(ApprovalOrder.snapshot_json))
|
||||
.where(ApprovalOrder.status.in_(statuses))
|
||||
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
|
||||
.limit(limit)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def count_pending_approvals(session: Session, *, statuses: tuple[int, ...]) -> int:
|
||||
if not statuses:
|
||||
return 0
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(ApprovalOrder)
|
||||
.where(ApprovalOrder.status.in_(statuses))
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def list_unassigned_receipts(session: Session, *, limit: int) -> list:
|
||||
"""回款未指定发货批次(shipment_id IS NULL,核算前置缺失)。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(
|
||||
Receipt,
|
||||
Business.business_no.label("business_no"),
|
||||
)
|
||||
.outerjoin(Business, Business.id == Receipt.business_id)
|
||||
.where(Receipt.shipment_id.is_(None))
|
||||
.order_by(Receipt.receipt_date.asc(), Receipt.id.asc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def count_unassigned_receipts(session: Session) -> int:
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count()).select_from(Receipt).where(Receipt.shipment_id.is_(None))
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
# ==== 风险预警 ====
|
||||
|
||||
|
||||
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
|
||||
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
|
||||
|
||||
已终止业务(status=3)不再催收,排除在预警之外;业务行缺失时仍保留,
|
||||
避免历史脏数据让发货单从预警中静默消失。
|
||||
"""
|
||||
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
|
||||
return list(
|
||||
session.execute(
|
||||
select(
|
||||
Shipment,
|
||||
Business.business_no.label("business_no"),
|
||||
)
|
||||
.outerjoin(Business, Business.id == Shipment.business_id)
|
||||
.where(
|
||||
Shipment.ship_date <= cutoff,
|
||||
~has_receipt,
|
||||
or_(Business.status.is_(None), Business.status != 3),
|
||||
)
|
||||
.order_by(Shipment.ship_date.asc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def count_overdue_shipments(session: Session, *, cutoff: date) -> int:
|
||||
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(Shipment)
|
||||
.outerjoin(Business, Business.id == Shipment.business_id)
|
||||
.where(
|
||||
Shipment.ship_date <= cutoff,
|
||||
~has_receipt,
|
||||
or_(Business.status.is_(None), Business.status != 3),
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def list_overdue_approvals(
|
||||
session: Session, *, cutoff: datetime, limit: int
|
||||
) -> list[ApprovalOrder]:
|
||||
"""提交后超过阈值仍未处理的审批单(审批超时规则)。
|
||||
|
||||
排除 snapshot_json 列(同待办:预警只用单号/类型/提交人/时间)。
|
||||
"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(ApprovalOrder)
|
||||
.options(defer(ApprovalOrder.snapshot_json))
|
||||
.where(
|
||||
ApprovalOrder.status.in_(_PENDING_STATUSES),
|
||||
ApprovalOrder.create_time < cutoff,
|
||||
)
|
||||
.order_by(ApprovalOrder.create_time.asc())
|
||||
.limit(limit)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def count_overdue_approvals(session: Session, *, cutoff: datetime) -> int:
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(ApprovalOrder)
|
||||
.where(
|
||||
ApprovalOrder.status.in_(_PENDING_STATUSES),
|
||||
ApprovalOrder.create_time < cutoff,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
"""登录日志数据访问。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -10,68 +12,73 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
|
||||
_USER_AGENT_MAX_LEN = 255
|
||||
|
||||
|
||||
def add(
|
||||
session: Session,
|
||||
*,
|
||||
user_id: int | None,
|
||||
username: str,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
success: bool,
|
||||
fail_reason: str | None = None,
|
||||
) -> None:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LoginLogEntry:
|
||||
"""一条待写入的登录日志(全字段 DTO)。
|
||||
|
||||
与 ``OperationLogEntry`` 同款做法:写入字段平铺传递时,任何一处漏传
|
||||
都只表现为日志字段缺失、不报错(success 默认 0 的语义尤其隐蔽)。
|
||||
"""
|
||||
|
||||
user_id: int | None
|
||||
username: str
|
||||
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。
|
||||
|
||||
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
|
||||
|
||||
:param user_id: 账号不存在时传 None(列本身可空)
|
||||
:param username: 用户名原样记录,便于账号不存在时也能追溯
|
||||
:param entry.user_id: 账号不存在时为 None(列本身可空)
|
||||
:param entry.username: 用户名原样记录,便于账号不存在时也能追溯
|
||||
"""
|
||||
session.add(
|
||||
LoginLog(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent[:_USER_AGENT_MAX_LEN] if user_agent else None,
|
||||
success=1 if success else 0,
|
||||
fail_reason=fail_reason,
|
||||
user_id=entry.user_id,
|
||||
username=entry.username,
|
||||
ip=entry.ip,
|
||||
user_agent=entry.user_agent[:_USER_AGENT_MAX_LEN] if entry.user_agent else None,
|
||||
success=1 if entry.success else 0,
|
||||
fail_reason=entry.fail_reason,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def list_logs(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
filters: LogFilters,
|
||||
ip: str | None = None,
|
||||
) -> list[LoginLog]:
|
||||
def list_logs(session: Session, query: LoginLogListQuery) -> list[LoginLog]:
|
||||
"""分页查询登录日志。
|
||||
|
||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
||||
"""
|
||||
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
|
||||
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(select(LoginLog), query)
|
||||
stmt = stmt.order_by(LoginLog.id.desc()).offset(query.offset).limit(query.limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_logs(
|
||||
session: Session,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
ip: str | None = None,
|
||||
) -> int:
|
||||
def count_logs(session: Session, query: LoginLogListQuery) -> int:
|
||||
"""统计符合条件的登录日志总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
|
||||
stmt = _apply_filters(select(func.count(LoginLog.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
|
||||
def _apply_filters(stmt: Select, query: LoginLogListQuery) -> Select:
|
||||
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
|
||||
stmt = apply_log_filters(stmt, LoginLog, filters)
|
||||
if ip:
|
||||
stmt = stmt.where(LoginLog.ip == ip)
|
||||
stmt = apply_log_filters(stmt, LoginLog, query.filters)
|
||||
if query.ip:
|
||||
stmt = stmt.where(LoginLog.ip == query.ip)
|
||||
return stmt
|
||||
@@ -107,15 +107,18 @@ def get_by_id(session: Session, log_id: int) -> OperationLog | None:
|
||||
return session.get(OperationLog, log_id)
|
||||
|
||||
|
||||
def list_logs(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
filters: LogFilters,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
) -> list[OperationLog]:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperationLogListQuery:
|
||||
"""操作日志列表的查询条件(筛选 + 分页)。"""
|
||||
|
||||
filters: LogFilters
|
||||
offset: int = 0
|
||||
limit: int = 20
|
||||
module: str | None = None
|
||||
action: str | None = None
|
||||
|
||||
|
||||
def list_logs(session: Session, query: OperationLogListQuery) -> list[OperationLog]:
|
||||
"""分页查询操作日志。
|
||||
|
||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||
@@ -127,41 +130,34 @@ def list_logs(
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
select(OperationLog).options(defer(OperationLog.request_data)),
|
||||
filters=filters,
|
||||
module=module,
|
||||
action=action,
|
||||
query,
|
||||
)
|
||||
stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit)
|
||||
stmt = stmt.order_by(OperationLog.id.desc()).offset(query.offset).limit(query.limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_logs(
|
||||
session: Session,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
) -> int:
|
||||
def count_logs(session: Session, query: OperationLogListQuery) -> int:
|
||||
"""统计符合条件的日志总数,供分页使用。"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
|
||||
)
|
||||
stmt = _apply_filters(select(func.count(OperationLog.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
module: str | None,
|
||||
action: str | None,
|
||||
) -> Select:
|
||||
def distinct_modules(session: Session) -> list[str]:
|
||||
"""全部模块名(去重、按名称升序):工作台「最近动态」的模块筛选项。
|
||||
|
||||
交给 SQL 的 DISTINCT,不在应用层聚合 —— 应用层去重要把整列拉进内存。
|
||||
"""
|
||||
stmt = select(OperationLog.module).distinct().order_by(OperationLog.module)
|
||||
return [module for module in session.execute(stmt).scalars() if module]
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
|
||||
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
|
||||
stmt = apply_log_filters(stmt, OperationLog, filters)
|
||||
if module:
|
||||
stmt = stmt.where(OperationLog.module == module)
|
||||
if action:
|
||||
stmt = stmt.where(OperationLog.action == action)
|
||||
stmt = apply_log_filters(stmt, OperationLog, query.filters)
|
||||
if query.module:
|
||||
stmt = stmt.where(OperationLog.module == query.module)
|
||||
if query.action:
|
||||
stmt = stmt.where(OperationLog.action == query.action)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -5,18 +5,32 @@ owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧
|
||||
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Row, func, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
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(
|
||||
session: Session,
|
||||
ref: OwnershipLogRef,
|
||||
*,
|
||||
owner_type: int,
|
||||
ref_column,
|
||||
ref_id: int,
|
||||
offset: int,
|
||||
limit: int,
|
||||
) -> list[Row]:
|
||||
@@ -32,7 +46,7 @@ def list_by_ref(
|
||||
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
|
||||
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
|
||||
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
|
||||
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
|
||||
.where(OwnershipChangeLog.owner_type == ref.owner_type, ref.ref_column == ref.ref_id)
|
||||
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -40,11 +54,11 @@ def list_by_ref(
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_by_ref(session: Session, *, owner_type: int, ref_column, ref_id: int) -> int:
|
||||
def count_by_ref(session: Session, ref: OwnershipLogRef) -> int:
|
||||
"""统计某引用对象的归属留痕总数,供分页使用。"""
|
||||
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
||||
OwnershipChangeLog.owner_type == owner_type,
|
||||
ref_column == ref_id,
|
||||
OwnershipChangeLog.owner_type == ref.owner_type,
|
||||
ref.ref_column == ref.ref_id,
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import Select, delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import ParamChangeBatch, ParamDefinition, ParamValue
|
||||
from app.schemas.param import ParamBatchQuery, ParamHistoryQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@@ -97,27 +98,27 @@ def list_definitions_by_codes(
|
||||
|
||||
|
||||
def list_history(
|
||||
session: Session,
|
||||
param_id: int,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
dimension_value: str | None = None,
|
||||
session: Session, param_id: int, query: ParamHistoryQuery
|
||||
) -> list[tuple[ParamValue, ParamChangeBatch]]:
|
||||
"""单参数的版本历史(含批次信息),最新在前。"""
|
||||
stmt = _history_stmt(session, param_id, dimension_value=dimension_value)
|
||||
return [(value, batch) for value, batch in session.execute(stmt.offset(offset).limit(limit))]
|
||||
"""单参数的版本历史(含批次信息),最新在前。
|
||||
|
||||
``param_id`` 是「查哪个参数」的主锚点(由路径参数 param_code 解析而来),
|
||||
不属于列表筛选项,故仍以位置参数传入;筛选与分页走 ``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, *, dimension_value: str | None = None) -> int:
|
||||
def count_history(session: Session, param_id: int, query: ParamHistoryQuery) -> int:
|
||||
"""统计单参数的历史条目数。"""
|
||||
stmt = select(func.count()).select_from(
|
||||
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
|
||||
)
|
||||
stmt = select(func.count()).select_from(_history_stmt(param_id, query).subquery())
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select:
|
||||
def _history_stmt(param_id: int, query: ParamHistoryQuery) -> Select:
|
||||
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
|
||||
stmt = (
|
||||
select(ParamValue, ParamChangeBatch)
|
||||
@@ -125,8 +126,8 @@ def _history_stmt(session: Session, param_id: int, *, dimension_value: str | Non
|
||||
.where(ParamValue.param_id == param_id)
|
||||
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
|
||||
)
|
||||
if dimension_value is not None:
|
||||
stmt = stmt.where(ParamValue.dimension_value == dimension_value)
|
||||
if query.dimension_value is not None:
|
||||
stmt = stmt.where(ParamValue.dimension_value == query.dimension_value)
|
||||
return stmt
|
||||
|
||||
|
||||
@@ -150,41 +151,30 @@ def get_batch_by_id(
|
||||
return session.get(ParamChangeBatch, batch_id)
|
||||
|
||||
|
||||
def list_batches(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
) -> list[ParamChangeBatch]:
|
||||
def list_batches(session: Session, query: ParamBatchQuery) -> list[ParamChangeBatch]:
|
||||
"""分页查询变更批次(按创建时间倒序)。"""
|
||||
stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date)
|
||||
stmt = _batch_filters(select(ParamChangeBatch), query)
|
||||
return list(
|
||||
session.execute(
|
||||
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def count_batches(
|
||||
session: Session, *, start_date: date | None = None, end_date: date | None = None
|
||||
) -> int:
|
||||
def count_batches(session: Session, query: ParamBatchQuery) -> int:
|
||||
"""统计符合条件的批次总数。"""
|
||||
stmt = _batch_filters(
|
||||
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
|
||||
)
|
||||
stmt = _batch_filters(select(func.count(ParamChangeBatch.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select:
|
||||
def _batch_filters(stmt: Select, query: ParamBatchQuery) -> Select:
|
||||
"""批次筛选(生效日期区间,含边界)。列表与计数共用。"""
|
||||
if start_date is not None:
|
||||
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
|
||||
if end_date is not None:
|
||||
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
|
||||
if query.start_date is not None:
|
||||
stmt = stmt.where(ParamChangeBatch.effective_date >= query.start_date)
|
||||
if query.end_date is not None:
|
||||
stmt = stmt.where(ParamChangeBatch.effective_date <= query.end_date)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
|
||||
from app.repositories.query_utils import fuzzy
|
||||
from app.schemas.personnel import PersonnelQuery
|
||||
|
||||
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
|
||||
_manager = aliased(Personnel)
|
||||
@@ -30,44 +31,23 @@ def get_with_names(session: Session, personnel_id: int) -> Row | None:
|
||||
return session.execute(stmt).first()
|
||||
|
||||
|
||||
def list_personnel(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
business_role_id: int | None = None,
|
||||
) -> list[Row]:
|
||||
def list_personnel(session: Session, query: PersonnelQuery) -> list[Row]:
|
||||
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
|
||||
|
||||
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
_base_select(), keyword=keyword, status=status, business_role_id=business_role_id
|
||||
)
|
||||
stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(_base_select(), query)
|
||||
stmt = stmt.order_by(Personnel.id.desc()).offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_personnel(
|
||||
session: Session,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
business_role_id: int | None = None,
|
||||
) -> int:
|
||||
def count_personnel(session: Session, query: PersonnelQuery) -> int:
|
||||
"""统计符合条件的人员总数,供分页使用。
|
||||
|
||||
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||||
``_apply_filters`` 单点保证)。
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(Personnel.id)),
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
business_role_id=business_role_id,
|
||||
)
|
||||
stmt = _apply_filters(select(func.count(Personnel.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@@ -154,20 +134,14 @@ def _base_select() -> Select:
|
||||
)
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
keyword: str | None,
|
||||
status: int | None,
|
||||
business_role_id: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: PersonnelQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||
if keyword:
|
||||
stmt = fuzzy(stmt, Personnel.personnel_name, keyword)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Personnel.status == status)
|
||||
if business_role_id is not None:
|
||||
stmt = stmt.where(Personnel.business_role_id == business_role_id)
|
||||
if query.keyword:
|
||||
stmt = fuzzy(stmt, Personnel.personnel_name, query.keyword)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(Personnel.status == query.status)
|
||||
if query.business_role_id is not None:
|
||||
stmt = stmt.where(Personnel.business_role_id == query.business_role_id)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import BusinessItem, Product, ShipmentItem
|
||||
from app.schemas.product import ProductQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@@ -34,50 +35,20 @@ def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Pr
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_products(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
product_category: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> list[Product]:
|
||||
def list_products(session: Session, query: ProductQuery) -> list[Product]:
|
||||
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
|
||||
stmt = _apply_filters(
|
||||
select(Product),
|
||||
keyword=keyword,
|
||||
product_category=product_category,
|
||||
status=status,
|
||||
)
|
||||
stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(select(Product), query)
|
||||
stmt = stmt.order_by(Product.id).offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_products(
|
||||
session: Session,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
product_category: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> int:
|
||||
def count_products(session: Session, query: ProductQuery) -> int:
|
||||
"""统计符合条件的产品总数,供分页使用。"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(Product.id)),
|
||||
keyword=keyword,
|
||||
product_category=product_category,
|
||||
status=status,
|
||||
)
|
||||
stmt = _apply_filters(select(func.count(Product.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
keyword: str | None,
|
||||
product_category: str | None,
|
||||
status: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
|
||||
@@ -86,17 +57,17 @@ def _apply_filters(
|
||||
写 contains —— M1 business_role_repository 的双列关键词同款写法。
|
||||
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
|
||||
"""
|
||||
if keyword:
|
||||
if query.keyword:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Product.product_name.contains(keyword, autoescape=True),
|
||||
Product.spec_model.contains(keyword, autoescape=True),
|
||||
Product.product_name.contains(query.keyword, autoescape=True),
|
||||
Product.spec_model.contains(query.keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if product_category:
|
||||
stmt = stmt.where(Product.product_category == product_category)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Product.status == status)
|
||||
if query.product_category:
|
||||
stmt = stmt.where(Product.product_category == query.product_category)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(Product.status == query.status)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -32,8 +32,9 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati
|
||||
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
|
||||
"""按主键查询。
|
||||
|
||||
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
|
||||
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
|
||||
等级表无外键引用(客户档案只存总分,等级由分数按当前映射实时推导,
|
||||
不存等级 id),无需 ``for_update`` 锁行 —— product_repository 锁行防的
|
||||
是并发引用悬空,此处不存在该风险:删掉一个等级,只是以后不再判到它。
|
||||
"""
|
||||
return session.get(CustomerRatingGrade, grade_id)
|
||||
|
||||
@@ -60,6 +61,10 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
|
||||
|
||||
|
||||
def delete(session: Session, grade: CustomerRatingGrade) -> None:
|
||||
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
|
||||
"""物理删除等级。
|
||||
|
||||
等级表无外键引用(客户档案只存总分,不存等级 id),调用方无需引用保护
|
||||
校验:删掉等级只是「以后不再判到它」,已存的总分不受影响。
|
||||
"""
|
||||
session.delete(grade)
|
||||
session.flush()
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Business, Receipt, Shipment
|
||||
from app.schemas.receipt import ReceiptQuery
|
||||
|
||||
|
||||
def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
|
||||
@@ -43,72 +44,42 @@ def get_shipment(session: Session, shipment_id: int) -> Shipment | None:
|
||||
return session.get(Shipment, shipment_id)
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
business_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
is_barter: int | None,
|
||||
settlement_status: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select:
|
||||
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
|
||||
if business_id is not None:
|
||||
stmt = stmt.where(Receipt.business_id == business_id)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(Receipt.receipt_date >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(Receipt.receipt_date <= date_to)
|
||||
if is_barter is not None:
|
||||
stmt = stmt.where(Receipt.is_barter == is_barter)
|
||||
if settlement_status is not None:
|
||||
stmt = stmt.where(Receipt.settlement_status == settlement_status)
|
||||
if query.business_id is not None:
|
||||
stmt = stmt.where(Receipt.business_id == query.business_id)
|
||||
if query.date_from is not None:
|
||||
stmt = stmt.where(Receipt.receipt_date >= query.date_from)
|
||||
if query.date_to is not None:
|
||||
stmt = stmt.where(Receipt.receipt_date <= query.date_to)
|
||||
if query.is_barter is not None:
|
||||
stmt = stmt.where(Receipt.is_barter == query.is_barter)
|
||||
# settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段
|
||||
if query.settlement_status is not None:
|
||||
stmt = stmt.where(Receipt.settlement_status == query.settlement_status)
|
||||
return stmt
|
||||
|
||||
|
||||
def list_receipts(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
business_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
is_barter: int | None,
|
||||
settlement_status: int | None,
|
||||
session: Session, query: ReceiptQuery
|
||||
) -> list[tuple[Receipt, str, str | None, date | None]]:
|
||||
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
|
||||
stmt = _apply_filters(
|
||||
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
|
||||
.join(Business, Business.id == Receipt.business_id)
|
||||
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
|
||||
business_id=business_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
is_barter=is_barter,
|
||||
settlement_status=settlement_status,
|
||||
query,
|
||||
)
|
||||
return list(
|
||||
session.execute(
|
||||
stmt.order_by(Receipt.id.desc()).offset(query.offset).limit(query.page_size)
|
||||
).all()
|
||||
)
|
||||
return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all())
|
||||
|
||||
|
||||
def count_receipts(
|
||||
session: Session,
|
||||
*,
|
||||
business_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
is_barter: int | None,
|
||||
settlement_status: int | None,
|
||||
) -> int:
|
||||
def count_receipts(session: Session, query: ReceiptQuery) -> int:
|
||||
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count()).select_from(Receipt),
|
||||
business_id=business_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
is_barter=is_barter,
|
||||
settlement_status=settlement_status,
|
||||
)
|
||||
stmt = _apply_filters(select(func.count()).select_from(Receipt), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
"""报表统计仓储:只读聚合查询;不 commit、不抛业务异常。
|
||||
|
||||
报表与台账分页查询刻意分开而非复用 ``performance_ledger_repository``:
|
||||
- 台账查询允许聚合行(calc_type=2),报表明细必须显式排除——经理聚合
|
||||
行 receipt_id 为 NULL,混入明细会让合同编号/天数列失真、业务统计的
|
||||
绩效被整团队聚合额污染;
|
||||
- 月结的 ``_confirm_scope`` 带 ``settlement_id IS NULL``(可入结口径),
|
||||
报表要看的是同月**全量**生效单笔行,口径不同;
|
||||
- 聚合需求(SUM/GROUP BY)台账仓储完全没有,报表在此统一实现。
|
||||
|
||||
发货额/回款额直接读 businesses 的汇总冗余列(服务层事务内单点维护),
|
||||
不逐业务子查询发货/回款表;绩效额 businesses 无冗余列,用子查询联台账。
|
||||
"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.report_meta import DayBucket
|
||||
from app.models import (
|
||||
AdjustmentOrder,
|
||||
Business,
|
||||
Customer,
|
||||
MonthlySettlement,
|
||||
PerformanceLedger,
|
||||
Personnel,
|
||||
)
|
||||
from app.schemas.report import (
|
||||
BusinessStatsQuery,
|
||||
PaymentDetailQuery,
|
||||
PaymentStatsQuery,
|
||||
PersonalDetailQuery,
|
||||
)
|
||||
|
||||
# 单笔生效行基础条件:报表四表共用(聚合行与已替代版本一律排除)
|
||||
_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1)
|
||||
|
||||
|
||||
# ==== 个人绩效明细(FR-029)====
|
||||
|
||||
|
||||
def _personal_filters(query: PersonalDetailQuery) -> tuple:
|
||||
filters = [*_ACTIVE_SINGLE]
|
||||
if query.settlement_month:
|
||||
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
|
||||
if query.personnel_id is not None:
|
||||
filters.append(PerformanceLedger.personnel_id == query.personnel_id)
|
||||
if query.customer_type:
|
||||
filters.append(PerformanceLedger.customer_type == query.customer_type)
|
||||
if query.product_category:
|
||||
filters.append(PerformanceLedger.product_category == query.product_category)
|
||||
if query.role_code:
|
||||
filters.append(PerformanceLedger.role_code == query.role_code)
|
||||
return tuple(filters)
|
||||
|
||||
|
||||
def list_personal_detail(
|
||||
session: Session, query: PersonalDetailQuery, *, offset: int, limit: int
|
||||
) -> list[PerformanceLedger]:
|
||||
"""个人绩效明细分页(回款日倒序,同人按版本可追溯)。"""
|
||||
stmt = select(PerformanceLedger).where(*_personal_filters(query))
|
||||
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_personal_detail(session: Session, query: PersonalDetailQuery) -> int:
|
||||
"""个人绩效明细计数(与 list_personal_detail 同筛选)。"""
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count()).select_from(PerformanceLedger).where(*_personal_filters(query))
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def sum_personal_detail(session: Session, query: PersonalDetailQuery) -> dict:
|
||||
"""全筛选口径合计(分页与合计同条件,避免「页脚合计≠全部结果」)。"""
|
||||
return (
|
||||
session.execute(
|
||||
select(
|
||||
func.count().label("row_count"),
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
|
||||
"performance_total"
|
||||
),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
|
||||
func.coalesce(func.sum(PerformanceLedger.converted_profit), 0).label(
|
||||
"converted_profit_total"
|
||||
),
|
||||
).where(*_personal_filters(query))
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
|
||||
|
||||
# ==== 部门汇总取数(FR-030)====
|
||||
|
||||
|
||||
def list_month_receipt_rows(session: Session, month: str) -> list[PerformanceLedger]:
|
||||
"""同月全量生效单笔台账行(部门汇总基数与成员明细的取数)。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.settlement_month == month, *_ACTIVE_SINGLE
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def list_month_manager_aggregates(session: Session, month: str) -> list[PerformanceLedger]:
|
||||
"""同月经理聚合行(calc_type=2):已生成月结时的冻结对照值。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.settlement_month == month,
|
||||
PerformanceLedger.calc_type == 2,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def list_month_adjustments(session: Session, month: str) -> list[AdjustmentOrder]:
|
||||
"""同月已生效调整单(status=2)——报表汇总并入的口径。
|
||||
|
||||
与 list_adjustments(调整单管理页)不同:必须按月筛选且只取生效行,
|
||||
待审批/已驳回/已撤回的金额绝不能混进报表。
|
||||
"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(AdjustmentOrder).where(
|
||||
AdjustmentOrder.settlement_month == month,
|
||||
AdjustmentOrder.status == 2,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def list_month_settlement(session: Session, month: str) -> MonthlySettlement | None:
|
||||
"""同月月结头(部门汇总展示锁定状态用;无则 None)。"""
|
||||
return session.execute(
|
||||
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
# ==== 业务统计(FR-031 之一)====
|
||||
|
||||
|
||||
def _business_perf_subquery():
|
||||
"""业务维度绩效子查询(排除聚合行,防 12.5%/5% 聚合额污染单业务绩效)。"""
|
||||
return (
|
||||
select(
|
||||
PerformanceLedger.business_id.label("business_id"),
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label("perf"),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable"),
|
||||
)
|
||||
.where(*_ACTIVE_SINGLE)
|
||||
.group_by(PerformanceLedger.business_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
|
||||
def _business_filters(query: BusinessStatsQuery) -> tuple:
|
||||
filters = []
|
||||
if query.keyword:
|
||||
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 转义通配符,
|
||||
# 与 business_repository._apply_filters 同口径(双列 OR 用法为该处既定)
|
||||
filters.append(
|
||||
Business.business_no.contains(query.keyword, autoescape=True)
|
||||
| Customer.company_name.contains(query.keyword, autoescape=True)
|
||||
)
|
||||
if query.customer_id is not None:
|
||||
filters.append(Business.customer_id == query.customer_id)
|
||||
if query.owner_personnel_id is not None:
|
||||
filters.append(Business.owner_personnel_id == query.owner_personnel_id)
|
||||
if query.status is not None:
|
||||
filters.append(Business.status == query.status)
|
||||
if query.date_from is not None:
|
||||
filters.append(Business.business_date >= query.date_from)
|
||||
if query.date_to is not None:
|
||||
filters.append(Business.business_date <= query.date_to)
|
||||
return tuple(filters)
|
||||
|
||||
|
||||
def _business_select(query: BusinessStatsQuery):
|
||||
perf_sq = _business_perf_subquery()
|
||||
return (
|
||||
select(
|
||||
Business,
|
||||
Customer.company_name.label("customer_name"),
|
||||
Personnel.personnel_name.label("owner_personnel_name"),
|
||||
perf_sq.c.perf.label("performance_amount"),
|
||||
perf_sq.c.payable.label("payable_amount"),
|
||||
)
|
||||
.outerjoin(Customer, Customer.id == Business.customer_id)
|
||||
.outerjoin(Personnel, Personnel.id == Business.owner_personnel_id)
|
||||
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
|
||||
.where(*_business_filters(query))
|
||||
)
|
||||
|
||||
|
||||
def list_business_stats(
|
||||
session: Session, query: BusinessStatsQuery, *, offset: int, limit: int
|
||||
) -> list:
|
||||
"""业务统计分页(业务日期倒序)。"""
|
||||
stmt = _business_select(query).order_by(Business.id.desc()).offset(offset).limit(limit)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_business_stats(session: Session, query: BusinessStatsQuery) -> int:
|
||||
"""业务统计计数(联客户表因关键字筛选涉及客户名)。"""
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(Business)
|
||||
.outerjoin(Customer, Customer.id == Business.customer_id)
|
||||
.where(*_business_filters(query))
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict:
|
||||
"""业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。"""
|
||||
perf_sq = _business_perf_subquery()
|
||||
return (
|
||||
session.execute(
|
||||
select(
|
||||
func.count().label("row_count"),
|
||||
func.coalesce(func.sum(Business.total_ship_amount), 0).label("ship_total"),
|
||||
func.coalesce(func.sum(Business.total_received_amount), 0).label("received_total"),
|
||||
func.coalesce(func.sum(Business.total_sale_amount), 0).label("sale_total"),
|
||||
func.coalesce(func.sum(perf_sq.c.perf), 0).label("performance_total"),
|
||||
func.coalesce(func.sum(perf_sq.c.payable), 0).label("payable_total"),
|
||||
)
|
||||
.outerjoin(Customer, Customer.id == Business.customer_id)
|
||||
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
|
||||
.where(*_business_filters(query))
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
|
||||
|
||||
# ==== 回款统计(FR-031 之二)====
|
||||
|
||||
|
||||
def _payment_filters(query: PaymentStatsQuery | PaymentDetailQuery) -> tuple:
|
||||
filters = [*_ACTIVE_SINGLE]
|
||||
if query.settlement_month:
|
||||
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
|
||||
if query.date_from is not None:
|
||||
filters.append(PerformanceLedger.receipt_date >= query.date_from)
|
||||
if query.date_to is not None:
|
||||
filters.append(PerformanceLedger.receipt_date <= query.date_to)
|
||||
if query.customer_type:
|
||||
filters.append(PerformanceLedger.customer_type == query.customer_type)
|
||||
if query.product_category:
|
||||
filters.append(PerformanceLedger.product_category == query.product_category)
|
||||
return tuple(filters)
|
||||
|
||||
|
||||
def group_payment_by_days(session: Session, query: PaymentStatsQuery) -> list:
|
||||
"""按(回款天数精确值, 客户类型, 产品类别)聚合绩效与应付。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(
|
||||
PerformanceLedger.payment_days,
|
||||
PerformanceLedger.customer_type,
|
||||
PerformanceLedger.product_category,
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
|
||||
"performance_total"
|
||||
),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
|
||||
func.count().label("ledger_count"),
|
||||
)
|
||||
.where(*_payment_filters(query))
|
||||
.group_by(
|
||||
PerformanceLedger.payment_days,
|
||||
PerformanceLedger.customer_type,
|
||||
PerformanceLedger.product_category,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def group_payment_receipt_by_days(session: Session, query: PaymentStatsQuery) -> list:
|
||||
"""按维度聚合回款金额与笔数——金额侧必须按 receipt_id 去重。
|
||||
|
||||
二次分摊拆行使同一回款在台账出现多行,直接 SUM(receipts.amount) 会
|
||||
按拆行数翻倍;台账拆行只改归属人,不改维度值(payment_days/客户类型/
|
||||
产品类别),因此先对台账行 DISTINCT 出回款维度,再联回款表求和。
|
||||
"""
|
||||
distinct_receipts = (
|
||||
select(
|
||||
PerformanceLedger.receipt_id.label("receipt_id"),
|
||||
PerformanceLedger.payment_days.label("payment_days"),
|
||||
PerformanceLedger.customer_type.label("customer_type"),
|
||||
PerformanceLedger.product_category.label("product_category"),
|
||||
)
|
||||
.where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None))
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
from app.models import Receipt
|
||||
|
||||
return list(
|
||||
session.execute(
|
||||
select(
|
||||
distinct_receipts.c.payment_days,
|
||||
distinct_receipts.c.customer_type,
|
||||
distinct_receipts.c.product_category,
|
||||
func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"),
|
||||
func.count(distinct_receipts.c.receipt_id).label("receipt_count"),
|
||||
)
|
||||
.outerjoin(Receipt, Receipt.id == distinct_receipts.c.receipt_id)
|
||||
.group_by(
|
||||
distinct_receipts.c.payment_days,
|
||||
distinct_receipts.c.customer_type,
|
||||
distinct_receipts.c.product_category,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def group_payment_by_customer_type(session: Session, query: PaymentStatsQuery) -> list:
|
||||
"""按客户类型聚合(绩效 + 回款金额)。"""
|
||||
return _group_by_dimension(session, query, PerformanceLedger.customer_type)
|
||||
|
||||
|
||||
def group_payment_by_product_category(session: Session, query: PaymentStatsQuery) -> list:
|
||||
"""按产品类别聚合(绩效 + 回款金额)。"""
|
||||
return _group_by_dimension(session, query, PerformanceLedger.product_category)
|
||||
|
||||
|
||||
def _group_by_dimension(session: Session, query: PaymentStatsQuery, dim_column) -> list:
|
||||
"""维度聚合共用实现(绩效侧 GROUP BY + 金额侧 DISTINCT 去重联回款)。"""
|
||||
from app.models import Receipt
|
||||
|
||||
dim_perf = (
|
||||
select(
|
||||
dim_column.label("dim"),
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
|
||||
"performance_total"
|
||||
),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
|
||||
func.count().label("ledger_count"),
|
||||
)
|
||||
.where(*_payment_filters(query))
|
||||
.group_by(dim_column)
|
||||
)
|
||||
dim_receipts = (
|
||||
select(
|
||||
dim_column.label("dim"),
|
||||
PerformanceLedger.receipt_id.label("receipt_id"),
|
||||
)
|
||||
.where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None))
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
perf_rows = {row.dim: row for row in session.execute(dim_perf).all()}
|
||||
receipt_rows = session.execute(
|
||||
select(
|
||||
dim_receipts.c.dim,
|
||||
func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"),
|
||||
)
|
||||
.outerjoin(Receipt, Receipt.id == dim_receipts.c.receipt_id)
|
||||
.group_by(dim_receipts.c.dim)
|
||||
).all()
|
||||
receipt_amounts = {row.dim: row.receipt_amount for row in receipt_rows}
|
||||
return [
|
||||
(
|
||||
row.dim,
|
||||
receipt_amounts.get(row.dim),
|
||||
row.performance_total,
|
||||
row.payable_total,
|
||||
row.ledger_count,
|
||||
)
|
||||
for row in perf_rows.values()
|
||||
]
|
||||
|
||||
|
||||
def _bucket_condition(bucket: DayBucket) -> tuple:
|
||||
"""区间桶的 days 过滤条件:none 桶 IS NULL,其余按 min/max 闭区间。"""
|
||||
if bucket.code == "none":
|
||||
return (PerformanceLedger.payment_days.is_(None),)
|
||||
conds = []
|
||||
if bucket.min_days is not None:
|
||||
conds.append(PerformanceLedger.payment_days >= bucket.min_days)
|
||||
if bucket.max_days is not None:
|
||||
conds.append(PerformanceLedger.payment_days <= bucket.max_days)
|
||||
return tuple(conds)
|
||||
|
||||
|
||||
def list_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> list:
|
||||
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
|
||||
|
||||
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
|
||||
"""
|
||||
from app.models import Receipt
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
PerformanceLedger,
|
||||
Receipt.amount.label("receipt_amount"),
|
||||
Receipt.receipt_batch_no.label("receipt_batch_no"),
|
||||
)
|
||||
.outerjoin(Receipt, Receipt.id == PerformanceLedger.receipt_id)
|
||||
.where(*_payment_filters(query), *_bucket_condition(bucket))
|
||||
)
|
||||
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
|
||||
stmt = stmt.offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> int:
|
||||
"""区间下钻计数(与 list_payment_detail 同筛选)。"""
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(PerformanceLedger)
|
||||
.where(*_payment_filters(query), *_bucket_condition(bucket))
|
||||
).scalar_one()
|
||||
)
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Role, UserRole
|
||||
from app.schemas.role import RoleQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@@ -47,44 +48,37 @@ def get_all_roles(session: Session) -> list[Role]:
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def list_roles(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> list[Role]:
|
||||
def list_roles(session: Session, query: RoleQuery) -> list[Role]:
|
||||
"""分页查询角色(管理列表:含停用角色)。
|
||||
|
||||
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
|
||||
"""
|
||||
stmt = _apply_filters(select(Role), keyword=keyword, status=status)
|
||||
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(select(Role), query)
|
||||
stmt = stmt.order_by(Role.sort_order, Role.id).offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
|
||||
def count_roles(session: Session, query: RoleQuery) -> int:
|
||||
"""统计符合条件的角色总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
|
||||
stmt = _apply_filters(select(func.count(Role.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
|
||||
def _apply_filters(stmt: Select, query: RoleQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
|
||||
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
|
||||
"""
|
||||
if keyword:
|
||||
if query.keyword:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Role.role_code.contains(keyword, autoescape=True),
|
||||
Role.role_name.contains(keyword, autoescape=True),
|
||||
Role.role_code.contains(query.keyword, autoescape=True),
|
||||
Role.role_name.contains(query.keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Role.status == status)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(Role.status == query.status)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,16 @@ from app.models import MonthlySettlement, SettlementItem
|
||||
from app.schemas.settlement import SettlementQuery
|
||||
|
||||
|
||||
def get_by_id(session: Session, settlement_id: int) -> MonthlySettlement | None:
|
||||
def get_by_id(
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
业务级汇总仍收口 business_service._recalc_totals
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Select, delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Business, Receipt, Shipment, ShipmentItem
|
||||
from app.schemas.shipment import ShipmentQuery
|
||||
|
||||
|
||||
def get_by_id(session: Session, shipment_id: int) -> Shipment | None:
|
||||
@@ -31,59 +31,37 @@ def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment,
|
||||
).first()
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
business_id: int | None,
|
||||
ship_date_from: date | None,
|
||||
ship_date_to: date | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: ShipmentQuery) -> Select:
|
||||
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
|
||||
if business_id is not None:
|
||||
stmt = stmt.where(Shipment.business_id == business_id)
|
||||
if ship_date_from is not None:
|
||||
stmt = stmt.where(Shipment.ship_date >= ship_date_from)
|
||||
if ship_date_to is not None:
|
||||
stmt = stmt.where(Shipment.ship_date <= ship_date_to)
|
||||
if query.business_id is not None:
|
||||
stmt = stmt.where(Shipment.business_id == query.business_id)
|
||||
if query.ship_date_from is not None:
|
||||
stmt = stmt.where(Shipment.ship_date >= query.ship_date_from)
|
||||
if query.ship_date_to is not None:
|
||||
stmt = stmt.where(Shipment.ship_date <= query.ship_date_to)
|
||||
return stmt
|
||||
|
||||
|
||||
def list_shipments(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
business_id: int | None,
|
||||
ship_date_from: date | None,
|
||||
ship_date_to: date | None,
|
||||
) -> list[tuple[Shipment, str]]:
|
||||
def list_shipments(session: Session, query: ShipmentQuery) -> list[tuple[Shipment, str]]:
|
||||
"""分页查询发货单(附业务号),按 id 倒序。"""
|
||||
stmt = _apply_filters(
|
||||
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
|
||||
business_id=business_id,
|
||||
ship_date_from=ship_date_from,
|
||||
ship_date_to=ship_date_to,
|
||||
query,
|
||||
)
|
||||
return list(
|
||||
session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all()
|
||||
session.execute(
|
||||
stmt.order_by(Shipment.id.desc()).offset(query.offset).limit(query.page_size)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def count_shipments(
|
||||
session: Session,
|
||||
*,
|
||||
business_id: int | None,
|
||||
ship_date_from: date | None,
|
||||
ship_date_to: date | None,
|
||||
) -> int:
|
||||
def count_shipments(session: Session, query: ShipmentQuery) -> int:
|
||||
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count())
|
||||
.select_from(Shipment)
|
||||
.join(Business, Business.id == Shipment.business_id),
|
||||
business_id=business_id,
|
||||
ship_date_from=ship_date_from,
|
||||
ship_date_to=ship_date_to,
|
||||
query,
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models import Role, User, UserRole
|
||||
from app.repositories.query_utils import fuzzy
|
||||
from app.schemas.user import UserQuery
|
||||
|
||||
|
||||
def _base_query() -> Select[tuple[User]]:
|
||||
@@ -48,16 +49,7 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None:
|
||||
# ==================== 列表查询 ====================
|
||||
|
||||
|
||||
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]:
|
||||
def list_users(session: Session, query: UserQuery) -> list[User]:
|
||||
"""分页查询用户列表。
|
||||
|
||||
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
|
||||
@@ -65,48 +57,35 @@ def list_users(
|
||||
id 是自增主键,天然唯一且与创建顺序一致。
|
||||
"""
|
||||
stmt = _base_query()
|
||||
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
|
||||
if role_code:
|
||||
stmt = _apply_filters(stmt, query)
|
||||
if query.role_code:
|
||||
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
|
||||
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
|
||||
stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit)
|
||||
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
|
||||
stmt = stmt.order_by(User.id.desc()).offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).scalars().unique())
|
||||
|
||||
|
||||
def count_users(
|
||||
session: Session,
|
||||
*,
|
||||
username: str | None = None,
|
||||
real_name: str | None = None,
|
||||
status: int | None = None,
|
||||
role_code: str | None = None,
|
||||
) -> int:
|
||||
def count_users(session: Session, query: UserQuery) -> int:
|
||||
"""统计符合条件的用户总数,供分页使用。"""
|
||||
stmt = select(func.count(User.id))
|
||||
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
|
||||
if role_code:
|
||||
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
|
||||
stmt = _apply_filters(stmt, query)
|
||||
if query.role_code:
|
||||
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
username: str | None,
|
||||
real_name: str | None,
|
||||
status: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: UserQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
|
||||
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
|
||||
"""
|
||||
if username:
|
||||
stmt = fuzzy(stmt, User.username, username)
|
||||
if real_name:
|
||||
stmt = fuzzy(stmt, User.real_name, real_name)
|
||||
if status is not None:
|
||||
stmt = stmt.where(User.status == status)
|
||||
if query.username:
|
||||
stmt = fuzzy(stmt, User.username, query.username)
|
||||
if query.real_name:
|
||||
stmt = fuzzy(stmt, User.real_name, query.real_name)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(User.status == query.status)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
@@ -31,6 +32,7 @@ class ApprovalOrderOut(BaseModel):
|
||||
order_type: int
|
||||
ref_id: int
|
||||
refer_no: str
|
||||
version_no: int
|
||||
status: int
|
||||
submitter_id: int | None = None
|
||||
submitter_name: str
|
||||
@@ -38,9 +40,21 @@ class ApprovalOrderOut(BaseModel):
|
||||
|
||||
|
||||
class ApprovalOrderDetailOut(ApprovalOrderOut):
|
||||
"""审批单详情:附节点时间线。"""
|
||||
"""审批单详情:附节点时间线与审批内容。
|
||||
|
||||
``content_source`` 语义:``snapshot``=提交时快照(可信);
|
||||
``live``=历史单无快照、回退读当前业务内容(仅供参考);
|
||||
``missing``=引用对象已不存在且无快照。
|
||||
"""
|
||||
|
||||
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):
|
||||
@@ -48,7 +62,7 @@ class ApprovalQuery(PageQuery):
|
||||
|
||||
keyword: str | None = Field(default=None, max_length=32, description="审批单号/引用单号,模糊")
|
||||
status: int | None = Field(default=None, ge=1, le=5, description="状态筛选")
|
||||
order_type: int | None = Field(default=None, ge=1, le=2, description="对象类型筛选")
|
||||
order_type: int | None = Field(default=None, ge=1, le=3, description="对象类型筛选")
|
||||
|
||||
|
||||
class ApprovalActionIn(BaseModel):
|
||||
|
||||
@@ -180,6 +180,14 @@ class SecondaryAllocationOut(BaseModel):
|
||||
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:
|
||||
"""同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示
|
||||
(让 400 文案说「重复参与人」而不是裸唯一键冲突)。"""
|
||||
@@ -191,13 +199,16 @@ def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
|
||||
|
||||
|
||||
class BusinessDetailOut(BusinessOut):
|
||||
"""业务详情:附产品明细行与二次分配方案。"""
|
||||
"""业务详情:附产品明细行、当前方案与方案版本历史。"""
|
||||
|
||||
items: list[BusinessItemOut] = Field(default_factory=list)
|
||||
secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list)
|
||||
allocation_hint: str | None = Field(
|
||||
default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)"
|
||||
)
|
||||
allocation_history: list[AllocationVersionOut] = Field(
|
||||
default_factory=list, description="方案全部版本(含历史;被驳回方案亦保留)"
|
||||
)
|
||||
|
||||
|
||||
class BusinessCreateIn(BaseModel):
|
||||
|
||||
@@ -27,6 +27,19 @@ class BusinessRoleOut(BaseModel):
|
||||
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):
|
||||
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""客户管理的请求/响应模型(需求 2.1.3)。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -28,6 +29,8 @@ class CustomerOut(BaseModel):
|
||||
owner_personnel_id: int | None = None
|
||||
owner_personnel_name: str | None = None
|
||||
remark: str | None = None
|
||||
rating_score: int | None = None
|
||||
rating_time: datetime | None = None
|
||||
|
||||
|
||||
class CustomerCreateIn(BaseModel):
|
||||
@@ -90,13 +93,32 @@ class CustomerUpdateIn(BaseModel):
|
||||
|
||||
|
||||
class CustomerQuery(PageQuery):
|
||||
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。"""
|
||||
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
|
||||
|
||||
排序用 ``Literal`` 白名单而不是自由字符串:``order_by`` 直接拼进
|
||||
``ORDER BY``,自由值等于把注入面开到排序键上;客户列表只有两个
|
||||
有意义的排序键,白名单足够。
|
||||
"""
|
||||
|
||||
keyword: str | None = Field(
|
||||
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
||||
)
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
||||
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
||||
order_by: Literal["id", "rating_score"] = Field(
|
||||
default="id", description="排序键:id 建档序 / rating_score 评级总分"
|
||||
)
|
||||
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):
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""工作台的请求/响应模型(M4 ⑧)。
|
||||
|
||||
聚合端点一次返回三区块(前端一次请求渲染整页,避免多次往返):
|
||||
经营绩效总览 / 待办事项 / 风险预警。金额一律 Decimal
|
||||
(序列化为字符串),前端不做数值运算。
|
||||
|
||||
**最近动态独立成分页端点**:它可能有很长(操作日志全量),塞进聚合
|
||||
响应既撑爆页面又只能截断前 20 条;拆开后翻页只重取这张列表,不必
|
||||
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
|
||||
|
||||
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
|
||||
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
|
||||
不返回伪造的 0 条。
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
class TrendPoint(BaseModel):
|
||||
"""近 6 个月趋势点(绩效按台账归属月,回款按到账月)。"""
|
||||
|
||||
month: str
|
||||
performance_total: Decimal | None = None
|
||||
receipt_total: Decimal | None = None
|
||||
|
||||
|
||||
class SettlementBrief(BaseModel):
|
||||
"""当月月结状态(工作台总览角标)。"""
|
||||
|
||||
exists: bool = False
|
||||
status: int | None = Field(default=None, description="1草稿 2待审批 3已审定 4已驳回")
|
||||
total_performance: Decimal | None = None
|
||||
total_payable: Decimal | None = None
|
||||
locked_at: datetime | None = None
|
||||
|
||||
|
||||
class OverviewBlock(BaseModel):
|
||||
"""经营绩效总览(FR-033):口径与台账/月结一致。"""
|
||||
|
||||
month: str
|
||||
performance_total: Decimal | None = Field(default=None, description="当月单笔绩效合计")
|
||||
payable_total: Decimal | None = Field(default=None, description="当月应付绩效合计")
|
||||
person_count: int = Field(default=0, description="当月有台账的人员数(拆行去重)")
|
||||
team_count: int = Field(default=0, description="在职营销经理数(团队数)")
|
||||
settlement: SettlementBrief
|
||||
trend: list[TrendPoint] = Field(default_factory=list, description="近 6 个月")
|
||||
note: str = Field(default="", description="口径说明(页面展示)")
|
||||
|
||||
|
||||
class ApprovalTodoItem(BaseModel):
|
||||
"""待审批条目。"""
|
||||
|
||||
order_id: int
|
||||
order_no: str
|
||||
order_type: int
|
||||
submitter_name: str | None = None
|
||||
create_time: datetime
|
||||
|
||||
|
||||
class ReceiptTodoItem(BaseModel):
|
||||
"""回款未指定发货批次条目。"""
|
||||
|
||||
receipt_id: int
|
||||
receipt_batch_no: str
|
||||
business_id: int
|
||||
business_no: str | None = None
|
||||
amount: Decimal
|
||||
receipt_date: date
|
||||
|
||||
|
||||
class TodoBlock(BaseModel):
|
||||
"""待办事项(FR-034,澄清 #11 起步范围)。"""
|
||||
|
||||
pending_approval_total: int = 0
|
||||
pending_approvals: list[ApprovalTodoItem] = Field(default_factory=list)
|
||||
unassigned_receipt_total: int = 0
|
||||
unassigned_receipts: list[ReceiptTodoItem] = Field(default_factory=list)
|
||||
sync_available: bool = Field(default=False, description="⑥ 同步模块是否已启用")
|
||||
sync_failure_total: int = 0
|
||||
|
||||
|
||||
class WarningItem(BaseModel):
|
||||
"""风险预警条目(实时派生,不改变业务数据)。"""
|
||||
|
||||
code: str = Field(description="规则编码:receipt_overdue / approval_timeout")
|
||||
level: str = Field(description="级别:warning / danger")
|
||||
object_type: str
|
||||
object_id: int
|
||||
title: str
|
||||
detail: str
|
||||
suggest: str
|
||||
link: str | None = Field(default=None, description="前端跳转路径")
|
||||
|
||||
|
||||
class WarningBlock(BaseModel):
|
||||
"""风险预警(FR-035,首批两条可算规则;同步规则待 ⑥)。"""
|
||||
|
||||
enabled: bool = True
|
||||
receipt_overdue_days: int = 90
|
||||
approval_timeout_days: int = 3
|
||||
items: list[WarningItem] = Field(default_factory=list)
|
||||
total: int = Field(default=0, description="全部规则命中总数(items 仅为展示上限内的子集)")
|
||||
receipt_overdue_total: int = 0
|
||||
approval_timeout_total: int = 0
|
||||
sync_available: bool = False
|
||||
|
||||
|
||||
class ActivityItem(BaseModel):
|
||||
"""最近动态条目(来源 operation_logs,只读聚合)。"""
|
||||
|
||||
id: int
|
||||
module: str
|
||||
action: str
|
||||
summary: str
|
||||
username: str | None = None
|
||||
success: int
|
||||
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):
|
||||
"""工作台三区块一次性响应(最近动态走独立分页端点)。"""
|
||||
|
||||
month: str
|
||||
overview: OverviewBlock
|
||||
todos: TodoBlock
|
||||
warnings: WarningBlock
|
||||
@@ -0,0 +1,371 @@
|
||||
"""报表统计的请求/响应模型(M4 ⑤)。
|
||||
|
||||
金额与比例一律 `Decimal`(pydantic v2 序列化为字符串,零精度损失);
|
||||
比例原样输出 `rate_text`(如 88/9000),禁止转 float 或截断展示。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.month import validate_month
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
def _required_month(value: str) -> str:
|
||||
"""必填月份:非法直接抛 ValueError,由 FastAPI 压成 400。"""
|
||||
return validate_month(value)
|
||||
|
||||
|
||||
def _optional_month(value: str | None) -> str | None:
|
||||
"""可选月份:空串规范为 None(axios 未剔除时也不会误报格式错误)。"""
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return validate_month(value)
|
||||
|
||||
|
||||
# ==== 查询入参 ====
|
||||
|
||||
|
||||
class PersonalDetailQuery(PageQuery):
|
||||
"""个人绩效明细查询条件(FR-029:人员/月份/客户类型/产品类别/岗位)。"""
|
||||
|
||||
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
|
||||
personnel_id: int | None = Field(default=None, ge=1, description="人员筛选")
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
||||
role_code: str | None = Field(default=None, max_length=32, description="岗位筛选")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str | None) -> str | None:
|
||||
return _optional_month(value)
|
||||
|
||||
|
||||
class DepartmentSummaryQuery(BaseModel):
|
||||
"""部门汇总查询条件(月份必填——部门表按单月聚合,无跨月视角)。"""
|
||||
|
||||
settlement_month: str = Field(min_length=7, max_length=7, description="归属月 YYYY-MM")
|
||||
manager_id: int | None = Field(default=None, ge=1, description="仅看某经理团队")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str) -> str:
|
||||
return _required_month(value)
|
||||
|
||||
|
||||
class BusinessStatsQuery(PageQuery):
|
||||
"""业务统计查询条件;回款/绩效为该业务累计值,不随业务日期筛选而变。"""
|
||||
|
||||
keyword: str | None = Field(default=None, max_length=64, description="业务编号/客户名称")
|
||||
customer_id: int | None = Field(default=None, ge=1, description="客户筛选")
|
||||
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人筛选")
|
||||
status: int | None = Field(default=None, description="业务状态筛选")
|
||||
date_from: date | None = Field(default=None, description="业务日期起")
|
||||
date_to: date | None = Field(default=None, description="业务日期止")
|
||||
|
||||
|
||||
class PaymentStatsQuery(BaseModel):
|
||||
"""回款统计查询条件(按回款天数区间/客户类型/产品类别统计,FR-031)。"""
|
||||
|
||||
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
|
||||
date_from: date | None = Field(default=None, description="回款日期起")
|
||||
date_to: date | None = Field(default=None, description="回款日期止")
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str | None) -> str | None:
|
||||
return _optional_month(value)
|
||||
|
||||
|
||||
class PaymentDetailQuery(PageQuery):
|
||||
"""回款区间下钻(桶编码必填,入参由 payment-stats 页面展开行携带)。"""
|
||||
|
||||
bucket: str = Field(min_length=1, max_length=16, description="区间桶编码")
|
||||
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
|
||||
date_from: date | None = Field(default=None, description="回款日期起")
|
||||
date_to: date | None = Field(default=None, description="回款日期止")
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str | None) -> str | None:
|
||||
return _optional_month(value)
|
||||
|
||||
|
||||
# ==== 响应出参 ====
|
||||
|
||||
|
||||
class ReportSummary(BaseModel):
|
||||
"""全筛选口径合计(与「当前页合计」区分,前端页脚需注明)。
|
||||
|
||||
字段按报表取用:个人明细用绩效/应付/折算毛利;业务统计用发货/回款/
|
||||
销售额;不涉及的留 None。
|
||||
"""
|
||||
|
||||
row_count: int = Field(default=0, description="筛选结果行数")
|
||||
performance_total: Decimal | None = Field(default=None, description="绩效金额合计")
|
||||
payable_total: Decimal | None = Field(default=None, description="应付绩效合计")
|
||||
converted_profit_total: Decimal | None = Field(default=None, description="折算毛利润合计")
|
||||
ship_total: Decimal | None = Field(default=None, description="发货额合计")
|
||||
received_total: Decimal | None = Field(default=None, description="回款额合计")
|
||||
sale_total: Decimal | None = Field(default=None, description="销售额合计")
|
||||
|
||||
|
||||
class ReportPage[T](BaseModel):
|
||||
"""报表分页:标准分页四字段 + 全筛选合计。
|
||||
|
||||
序列化字段与 PageResult 一致(list/total/pageNum/pageSize),前端
|
||||
usePagedList 可直接消费;追加的 summary 由各页页脚单独读取。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
records: list[T] = 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="每页条数"
|
||||
)
|
||||
summary: ReportSummary = Field(default_factory=ReportSummary, description="全筛选合计")
|
||||
|
||||
|
||||
class PersonalDetailRow(BaseModel):
|
||||
"""个人绩效明细行(需规 2.4.1 列全覆盖)。
|
||||
|
||||
二次分摊拆行后同一回款按人多行(负责人余额行 + 参与人份额行),行内
|
||||
不放「关联回款金额」列——同回款金额重复展示会让合计无法自洽,金额
|
||||
下沉到快照下钻。
|
||||
"""
|
||||
|
||||
ledger_id: int
|
||||
business_id: int
|
||||
business_no: str
|
||||
personnel_id: int
|
||||
personnel_name: str
|
||||
role_code: str
|
||||
role_name: str
|
||||
customer_type: str
|
||||
product_category: str
|
||||
ship_date: date | None = None
|
||||
receipt_date: date
|
||||
payment_days: int | None = None
|
||||
rate_text: str
|
||||
weight_r: int | None = None
|
||||
payment_weight: Decimal | None = None
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
converted_profit: Decimal | None = None
|
||||
is_probation: int
|
||||
missing_cost: int
|
||||
calc_version: int
|
||||
settlement_id: int | None = None
|
||||
|
||||
|
||||
class DepartmentMemberRow(BaseModel):
|
||||
"""部门汇总 · 团队成员明细(内联于团队行)。"""
|
||||
|
||||
personnel_id: int
|
||||
personnel_name: str
|
||||
role_code: str
|
||||
is_probation: int
|
||||
member_kind: str = Field(
|
||||
default="regular",
|
||||
description="行来源:regular 普通台账 / manager_personal 经理个人户 / adjustment 仅调整单",
|
||||
)
|
||||
unassigned_reason: str | None = Field(
|
||||
default=None, description="未纳入团队基数的原因(仅未分配块)"
|
||||
)
|
||||
performance_sum: Decimal | None = None
|
||||
payable_sum: Decimal | None = None
|
||||
adjust_extra: Decimal = Field(default=Decimal("0"), description="调整单生效补发合计")
|
||||
adjust_deduct: Decimal = Field(default=Decimal("0"), description="调整单生效扣减合计")
|
||||
adjust_net: Decimal = Field(default=Decimal("0"), description="调整净额(补发为正)")
|
||||
net_total: Decimal | None = None
|
||||
ledger_ids: list[int] = Field(default_factory=list, description="台账行 id 列表(下钻锚点)")
|
||||
|
||||
|
||||
class DepartmentTeamRow(BaseModel):
|
||||
"""部门汇总 · 经理团队行。"""
|
||||
|
||||
manager_id: int
|
||||
manager_name: str
|
||||
manager_status: int = Field(description="经理在职状态(1 在职 0 离职)")
|
||||
member_count: int
|
||||
team_base: Decimal = Field(description="团队基数(12.5% 的底,等于成员绩效合计)")
|
||||
team_share_amount: Decimal = Field(description="12.5% 团队分成(分列展示)")
|
||||
company_share_amount: Decimal = Field(
|
||||
description="单经理 5% 名义值;该团队无月结聚合行时为 0(不发放)"
|
||||
)
|
||||
has_aggregate: bool = Field(
|
||||
default=False, description="本月是否有月结聚合行(决定是否发放 5%)"
|
||||
)
|
||||
aggregate_amount: Decimal = Field(
|
||||
default=Decimal("0"),
|
||||
description="月结聚合行应发(12.5%+5% 一次舍入);无聚合行时 0",
|
||||
)
|
||||
team_subtotal: Decimal = Field(
|
||||
default=Decimal("0"),
|
||||
description="团队应发小计 = 聚合行金额 + 调整净额",
|
||||
)
|
||||
company_base_ratio: Decimal = Field(description="团队基数占全公司基数比例(除零为 0)")
|
||||
adjust_extra: Decimal = Decimal("0")
|
||||
adjust_deduct: Decimal = Decimal("0")
|
||||
adjust_net: Decimal = Decimal("0")
|
||||
net_total: Decimal = Field(description="团队净额 = 团队应发小计")
|
||||
frozen_amount: Decimal | None = Field(default=None, description="月结聚合行冻结额(对照)")
|
||||
aggregate_ledger_id: int | None = None
|
||||
members: list[DepartmentMemberRow] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DepartmentCompanyBlock(BaseModel):
|
||||
"""部门汇总 · 全公司口径块(FR-030「全公司 5% 部分单列」)。"""
|
||||
|
||||
team_base_total: Decimal = Field(description="各团队基数之和(= 全部单笔绩效合计)")
|
||||
company_base: Decimal = Field(description="全公司基数(5% 的底)")
|
||||
company_share_pct: str = Field(description="分成比例参数原文")
|
||||
company_share_amount: Decimal = Field(description="单经理 5% 名义值")
|
||||
company_share_count: int = Field(
|
||||
default=0, description="计入应发的经理数(展示团队中有月结聚合行的数量)"
|
||||
)
|
||||
company_share_total: Decimal = Field(
|
||||
default=Decimal("0"), description="5% 合计 = 单份 × 计入次数"
|
||||
)
|
||||
|
||||
|
||||
class DepartmentReconciliation(BaseModel):
|
||||
"""部门汇总 · 对账块(导出/打印签字稿的合计口径,前端不自行求和)。
|
||||
|
||||
应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额。
|
||||
聚合行金额与月结 ``total_payable`` 同口径(12.5%+5% 一次舍入);
|
||||
``team_share_total`` 与 ``company_share_total`` 仅作分列展示,两者
|
||||
之和与聚合额的差记入 ``aggregate_rounding_diff``。
|
||||
"""
|
||||
|
||||
team_member_payable_total: Decimal = Field(description="展示团队成员的应付绩效合计")
|
||||
unassigned_payable_total: Decimal = Field(description="展示未分配人员的应付绩效合计")
|
||||
team_share_total: Decimal = Field(description="展示团队的 12.5% 分成合计(分列)")
|
||||
company_share_total: Decimal = Field(
|
||||
description="展示团队的全公司分成合计(每位有聚合行的经理各计一次,分列)"
|
||||
)
|
||||
aggregate_payable_total: Decimal = Field(
|
||||
description="展示团队的经理聚合行应发合计(12.5%+5% 一次舍入)"
|
||||
)
|
||||
aggregate_rounding_diff: Decimal = Field(
|
||||
default=Decimal("0"),
|
||||
description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)",
|
||||
)
|
||||
adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)")
|
||||
payable_total: Decimal = Field(
|
||||
description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额"
|
||||
)
|
||||
team_base_total: Decimal = Field(description="展示团队基数合计")
|
||||
unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计")
|
||||
other_team_base: Decimal = Field(
|
||||
default=Decimal("0"), description="未展示的基数(其他团队 + 全公司未分配)"
|
||||
)
|
||||
other_team_member_payable_total: Decimal = Field(default=Decimal("0"))
|
||||
other_team_share_total: Decimal = Field(default=Decimal("0"))
|
||||
other_team_company_share_total: Decimal = Field(default=Decimal("0"))
|
||||
other_team_aggregate_total: Decimal = Field(default=Decimal("0"))
|
||||
other_adjust_net_total: Decimal = Field(default=Decimal("0"))
|
||||
company_base: Decimal = Field(description="全公司基数(5% 的底)")
|
||||
|
||||
|
||||
class DepartmentSummaryOut(BaseModel):
|
||||
"""部门绩效工资汇总响应(FR-030)。"""
|
||||
|
||||
settlement_month: str
|
||||
params: dict | None = Field(default=None, description="分成参数原文与取值时点(对账用)")
|
||||
teams: list[DepartmentTeamRow] = Field(default_factory=list)
|
||||
unassigned: list[DepartmentMemberRow] = Field(
|
||||
default_factory=list, description="无经理人员的绩效(不参与分成,仅展示)"
|
||||
)
|
||||
company: DepartmentCompanyBlock | None = None
|
||||
reconciliation: DepartmentReconciliation | None = Field(
|
||||
default=None, description="签字稿对账块(应发公式各项)"
|
||||
)
|
||||
totals: dict = Field(default_factory=dict, description="全表合计(成员/团队对账自证)")
|
||||
|
||||
|
||||
class BusinessStatsRow(BaseModel):
|
||||
"""业务统计行(发货/回款/绩效为该业务累计值)。"""
|
||||
|
||||
business_id: int
|
||||
business_no: str
|
||||
business_date: date | None = None
|
||||
status: int
|
||||
customer_name: str | None = None
|
||||
owner_personnel_name: str | None = None
|
||||
total_sale_amount: Decimal | None = None
|
||||
total_ship_amount: Decimal | None = None
|
||||
total_received_amount: Decimal | None = None
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
|
||||
|
||||
class BucketRow(BaseModel):
|
||||
"""回款统计 · 区间桶行(空桶恒在且为 0,防金额静默消失)。"""
|
||||
|
||||
code: str
|
||||
label: str
|
||||
min_days: int | None = None
|
||||
max_days: int | None = None
|
||||
receipt_amount: Decimal | None = None
|
||||
receipt_count: int = 0
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
ledger_count: int = 0
|
||||
|
||||
|
||||
class GroupRow(BaseModel):
|
||||
"""回款统计 · 维度对比行(客户类型 / 产品类别)。"""
|
||||
|
||||
code: str
|
||||
label: str
|
||||
receipt_amount: Decimal | None = None
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
ledger_count: int = 0
|
||||
|
||||
|
||||
class PaymentStatsOut(BaseModel):
|
||||
"""回款情况统计响应(FR-031)。
|
||||
|
||||
口径:已计提回款(有台账行)的金额与绩效;进度登记回款无台账行,
|
||||
不在本表(页面需注明)。
|
||||
"""
|
||||
|
||||
settlement_month: str | None = None
|
||||
buckets: list[BucketRow] = Field(default_factory=list)
|
||||
by_customer_type: list[GroupRow] = Field(default_factory=list)
|
||||
by_product_category: list[GroupRow] = Field(default_factory=list)
|
||||
totals: dict = Field(default_factory=dict, description="全表合计")
|
||||
|
||||
|
||||
class PaymentDetailRow(BaseModel):
|
||||
"""回款区间下钻行(行级展示回款金额与批次号)。"""
|
||||
|
||||
ledger_id: int
|
||||
receipt_id: int | None = None
|
||||
receipt_batch_no: str | None = None
|
||||
amount: Decimal | None = Field(default=None, description="回款金额(同回款拆行重复展示)")
|
||||
business_id: int
|
||||
business_no: str
|
||||
personnel_id: int
|
||||
personnel_name: str
|
||||
customer_type: str
|
||||
product_category: str
|
||||
ship_date: date | None = None
|
||||
receipt_date: date
|
||||
payment_days: int | None = None
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
@@ -1,21 +1,17 @@
|
||||
"""月度结算的请求/响应模型(M3 ④)。"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.month import validate_month
|
||||
from app.core.response import PageQuery
|
||||
|
||||
_MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
|
||||
|
||||
|
||||
def _check_month(value: str) -> str:
|
||||
"""月份格式 YYYY-MM(settlement_month 全表唯一的维度)。"""
|
||||
if not _MONTH_RE.match(value):
|
||||
raise ValueError("月份格式须为 YYYY-MM")
|
||||
return value
|
||||
"""月份格式 YYYY-MM 且月份 01-12(settlement_month 全表唯一的维度)。"""
|
||||
return validate_month(value)
|
||||
|
||||
|
||||
class SettlementGenerateIn(BaseModel):
|
||||
@@ -93,3 +89,8 @@ class AdjustmentCreateIn(BaseModel):
|
||||
description="金额(元,两位小数,恒正)",
|
||||
)
|
||||
reason: str = Field(min_length=1, max_length=255, description="扣补说明")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str) -> str:
|
||||
return _check_month(value)
|
||||
@@ -19,22 +19,25 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from collections.abc import Iterable
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from app.core.constants import (
|
||||
ACTION_APPROVE,
|
||||
ACTION_REJECT,
|
||||
MODULE_APPROVAL,
|
||||
NO_PREFIX_APPROVAL,
|
||||
ROLE_ADMIN,
|
||||
ROLE_FINANCE,
|
||||
ROLE_LEADER,
|
||||
)
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import ApprovalOrder, ApprovalStep, User
|
||||
from app.models import ApprovalOrder, ApprovalStep, Business, User
|
||||
from app.models.approval import (
|
||||
APPROVAL_ACTION_APPROVE,
|
||||
APPROVAL_ACTION_AUTO_APPROVE,
|
||||
@@ -84,6 +87,18 @@ _NODE_ROLE_BY_STATUS = {
|
||||
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
|
||||
|
||||
|
||||
def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]:
|
||||
"""工作台「待我审批」:按当前用户角色映射在途审批状态。
|
||||
|
||||
admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表
|
||||
的可见性一致);财务/领导只看到本节点可操作的条目。
|
||||
"""
|
||||
codes = set(role_codes)
|
||||
if ROLE_ADMIN in codes:
|
||||
return _PENDING_STATUSES
|
||||
return tuple(status for status, role in _NODE_ROLE_BY_STATUS.items() if role in codes)
|
||||
|
||||
|
||||
def is_enabled(session: Session, order_type: int) -> bool:
|
||||
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
|
||||
code = ENABLE_CODE_BY_TYPE.get(order_type)
|
||||
@@ -99,6 +114,7 @@ def submit_order(
|
||||
ref_id: int,
|
||||
refer_no: str,
|
||||
submitter: User,
|
||||
snapshot: dict | None = None,
|
||||
) -> ApprovalOrder:
|
||||
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
|
||||
|
||||
@@ -108,6 +124,10 @@ def submit_order(
|
||||
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
|
||||
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
|
||||
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
|
||||
|
||||
``snapshot`` 是「提交时」的业务内容(由调用方组装,Decimal 已转 str);
|
||||
落库时包一层信封并固化采集时间——审批期间业务再改,复核者看到的仍是
|
||||
送审内容。开关关闭的自动通过路径同样写快照,留痕不走样。
|
||||
"""
|
||||
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
|
||||
|
||||
@@ -121,6 +141,17 @@ def submit_order(
|
||||
order_type=order_type,
|
||||
ref_id=ref_id,
|
||||
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_name=submitter.username,
|
||||
)
|
||||
@@ -151,6 +182,35 @@ def submit_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(
|
||||
session: Session, *, order_type: int, ref_id: int, operator: User
|
||||
) -> int:
|
||||
@@ -236,15 +296,15 @@ def act(
|
||||
operator_name=operator.username,
|
||||
)
|
||||
)
|
||||
# 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以
|
||||
# 「当前生效行」身份留在表里、被 M3 核算快照算进工资;方案事实由
|
||||
# 审批单留痕与操作日志 request_data 承载(分摊语义落定后如需保留
|
||||
# 历史版本,在 ④ 设计时演进)。审批单的清理由调用方(act 内联)
|
||||
# 委托 business_repository 完成 —— service 层互调、不触 commit。
|
||||
# 驳回=方案未获批准:把该业务的生效方案行置历史(is_active=0,
|
||||
# **不删行**——V11 起 append-only 版本化),防止被否决的方案以
|
||||
# 「当前生效行」身份被 M3 核算快照算进工资;历史方案仍可在业务
|
||||
# 详情 allocation_history 回溯。委托 business_repository 完成
|
||||
# —— service 层互调、不触 commit。
|
||||
if rejected and order.order_type == 1:
|
||||
from app.repositories import business_repository
|
||||
|
||||
business_repository.delete_allocations(session, order.ref_id)
|
||||
business_repository.deactivate_allocations(session, order.ref_id)
|
||||
|
||||
# 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿;
|
||||
# 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。
|
||||
@@ -285,11 +345,18 @@ def act(
|
||||
|
||||
|
||||
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)
|
||||
rows = (
|
||||
session.execute(
|
||||
select(ApprovalOrder)
|
||||
.options(defer(ApprovalOrder.snapshot_json))
|
||||
.where(*filters)
|
||||
.order_by(ApprovalOrder.id.desc())
|
||||
.offset(query.offset)
|
||||
@@ -346,8 +413,152 @@ def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operato
|
||||
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:
|
||||
"""出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。"""
|
||||
"""出参组装(含 steps 时间线与审批内容)。调用方须保证处于 commit 之前。"""
|
||||
steps = (
|
||||
session.execute(
|
||||
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
|
||||
@@ -357,6 +568,7 @@ def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut
|
||||
)
|
||||
detail = ApprovalOrderDetailOut.model_validate(order)
|
||||
detail.steps = [ApprovalStepOut.model_validate(s) for s in steps]
|
||||
detail.snapshot, detail.content_source = _content_of(session, order)
|
||||
return detail
|
||||
|
||||
|
||||
|
||||
@@ -37,12 +37,14 @@ def _record(
|
||||
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
|
||||
login_log_repository.add(
|
||||
session,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=success,
|
||||
fail_reason=fail_reason,
|
||||
login_log_repository.LoginLogEntry(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=success,
|
||||
fail_reason=fail_reason,
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from app.models import BusinessRole, User
|
||||
from app.repositories import business_role_repository
|
||||
from app.schemas.business_role import (
|
||||
BusinessRoleCreateIn,
|
||||
BusinessRoleOptionOut,
|
||||
BusinessRoleOut,
|
||||
BusinessRoleQuery,
|
||||
BusinessRoleUpdateIn,
|
||||
@@ -67,16 +68,8 @@ def _ensure_template(calc_template: str) -> None:
|
||||
|
||||
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
|
||||
"""分页查询岗位(含停用)。"""
|
||||
roles = business_role_repository.list_roles(
|
||||
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
|
||||
)
|
||||
roles = business_role_repository.list_roles(session, query)
|
||||
total = business_role_repository.count_roles(session, query)
|
||||
return PageResult[BusinessRoleOut](
|
||||
records=[_to_out(role) for role in roles],
|
||||
total=total,
|
||||
@@ -85,6 +78,23 @@ 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:
|
||||
"""岗位详情。
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
@@ -54,6 +56,7 @@ from app.models.business import (
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
from app.repositories import business_repository, customer_repository, personnel_repository
|
||||
from app.schemas.business import (
|
||||
AllocationVersionOut,
|
||||
BusinessCreateIn,
|
||||
BusinessDetailOut,
|
||||
BusinessItemIn,
|
||||
@@ -233,26 +236,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
|
||||
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
|
||||
"""分页查询业务(附客户名与归属人名)。"""
|
||||
rows = business_repository.list_businesses(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
customer_id=query.customer_id,
|
||||
owner_personnel_id=query.owner_personnel_id,
|
||||
date_from=query.date_from,
|
||||
date_to=query.date_to,
|
||||
)
|
||||
total = business_repository.count_businesses(
|
||||
session,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
customer_id=query.customer_id,
|
||||
owner_personnel_id=query.owner_personnel_id,
|
||||
date_from=query.date_from,
|
||||
date_to=query.date_to,
|
||||
)
|
||||
rows = business_repository.list_businesses(session, query)
|
||||
total = business_repository.count_businesses(session, query)
|
||||
return PageResult[BusinessOut](
|
||||
records=[_to_out(row) for row in rows],
|
||||
total=total,
|
||||
@@ -281,6 +266,17 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
||||
for a in 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
|
||||
|
||||
|
||||
@@ -335,6 +331,20 @@ def _build_allocation_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:
|
||||
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
|
||||
|
||||
@@ -433,6 +443,7 @@ def create_business(
|
||||
ref_id=business.id,
|
||||
refer_no=business.business_no,
|
||||
submitter=operator,
|
||||
snapshot=approval_service.allocation_content(session, business) or None,
|
||||
)
|
||||
customer = customer_repository.get_by_id(session, business.customer_id)
|
||||
out = _to_out((business, customer.company_name if customer else None, None, None))
|
||||
@@ -507,28 +518,51 @@ def update_business(
|
||||
business_repository.replace_items(
|
||||
session, business_id, _build_item_rows(business_id, payload.items)
|
||||
)
|
||||
# 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
|
||||
# 前置场景(月结在 ④,快照固化后按同一哲学隔离)
|
||||
old_ids = {
|
||||
a.personnel_id for a in business_repository.list_allocations(session, business_id)
|
||||
}
|
||||
business_repository.replace_allocations(
|
||||
# 方案:内容真的变了才动版本行(内容未变不新增版本,避免「只改备注」
|
||||
# 也堆出一个方案版本);空数组=清空=把生效行置历史
|
||||
old_allocations = business_repository.list_allocations(session, business_id)
|
||||
new_rows = _build_allocation_rows(
|
||||
session,
|
||||
business_id,
|
||||
_build_allocation_rows(
|
||||
session, business_id, payload.secondary_allocations, old_ids=old_ids
|
||||
),
|
||||
payload.secondary_allocations,
|
||||
old_ids={a.personnel_id for a in old_allocations},
|
||||
)
|
||||
# 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回
|
||||
# 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批
|
||||
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:
|
||||
approval_service.submit_order(
|
||||
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,
|
||||
refer_no=business.business_no,
|
||||
submitter=operator,
|
||||
)
|
||||
snapshot=snapshot,
|
||||
):
|
||||
try:
|
||||
approval_service.submit_order(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
ref_id=business_id,
|
||||
refer_no=business.business_no,
|
||||
submitter=operator,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
# version_no 唯一键的并发冲突(取号是锁读后此项理论不可达,
|
||||
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062
|
||||
# 翻译成可重试的业务提示,其余完整性错误原样上抛
|
||||
if _is_duplicate_entry(exc):
|
||||
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
|
||||
raise
|
||||
else:
|
||||
approval_service.withdraw_pending_orders(
|
||||
session,
|
||||
@@ -536,7 +570,6 @@ def update_business(
|
||||
ref_id=business_id,
|
||||
operator=operator,
|
||||
)
|
||||
_recalc_totals(session, business)
|
||||
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
||||
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
|
||||
out = _to_out((business, customer.company_name, owner_name, assistant_name))
|
||||
@@ -699,6 +732,67 @@ def change_status(
|
||||
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(
|
||||
session: Session,
|
||||
business_id: int,
|
||||
@@ -730,41 +824,12 @@ def change_ownership(
|
||||
if row is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
business, _, old_owner_name, old_assistant_name = row
|
||||
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
|
||||
# 固化标量,新归属人名必须按新 ID 重取
|
||||
new_owner = (
|
||||
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
|
||||
if payload.new_owner_personnel_id is not None
|
||||
else None
|
||||
)
|
||||
new_assistant = (
|
||||
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
|
||||
if payload.new_assistant_personnel_id is not None
|
||||
else None
|
||||
)
|
||||
resolution = _resolve_business_ownership(session, business, payload)
|
||||
|
||||
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
|
||||
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
|
||||
if not owner_changed and not assistant_changed:
|
||||
raise BadRequestError("归属未变化")
|
||||
|
||||
old_owner_id = business.owner_personnel_id
|
||||
old_assistant_id = business.assistant_personnel_id
|
||||
business.owner_personnel_id = payload.new_owner_personnel_id
|
||||
business.assistant_personnel_id = payload.new_assistant_personnel_id
|
||||
business_repository.add_ownership_log(
|
||||
session,
|
||||
OwnershipChangeLog(
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
business_id=business_id,
|
||||
old_owner_personnel_id=old_owner_id,
|
||||
new_owner_personnel_id=payload.new_owner_personnel_id,
|
||||
old_assistant_personnel_id=old_assistant_id,
|
||||
new_assistant_personnel_id=payload.new_assistant_personnel_id,
|
||||
change_reason=payload.reason,
|
||||
operator_id=operator.id,
|
||||
operator_name=operator.username,
|
||||
),
|
||||
session, _business_ownership_log(business, payload, resolution, operator)
|
||||
)
|
||||
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
|
||||
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
|
||||
@@ -772,17 +837,19 @@ def change_ownership(
|
||||
(
|
||||
business,
|
||||
row[1],
|
||||
new_owner.personnel_name if new_owner is not None else old_owner_name,
|
||||
new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
|
||||
resolution.owner.personnel_name if resolution.owner is not None else old_owner_name,
|
||||
resolution.assistant.personnel_name
|
||||
if resolution.assistant is not None
|
||||
else old_assistant_name,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
|
||||
business_id,
|
||||
old_owner_id,
|
||||
resolution.old_owner_id,
|
||||
payload.new_owner_personnel_id,
|
||||
old_assistant_id,
|
||||
resolution.old_assistant_id,
|
||||
payload.new_assistant_personnel_id,
|
||||
)
|
||||
return out
|
||||
@@ -848,6 +915,94 @@ 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:
|
||||
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
|
||||
|
||||
@@ -866,64 +1021,15 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
|
||||
def threshold_of(category: str) -> Fraction:
|
||||
return snapshot.fraction("product.threshold", product_category=category)
|
||||
|
||||
rows_out: list[GrossProfitRowOut] = []
|
||||
missing_count = 0
|
||||
# 类别聚合容器:{类别: [sale, cost]},成本缺失行不计入该类别聚合
|
||||
agg: dict[str, list[Decimal]] = {}
|
||||
for item in business_repository.list_items(session, business_id):
|
||||
threshold = threshold_of(item.product_category)
|
||||
row_kwargs: dict = {
|
||||
"item_id": item.id,
|
||||
"product_category": item.product_category,
|
||||
"product_name": item.product_name,
|
||||
"spec_model": item.spec_model,
|
||||
"quantity": str(item.quantity),
|
||||
"sale_amount": str(item.sale_amount),
|
||||
"threshold": str(threshold),
|
||||
}
|
||||
if item.cost_amount is None:
|
||||
missing_count += 1
|
||||
row_kwargs.update(
|
||||
cost_amount=None,
|
||||
profit_amount=None,
|
||||
profit_rate=None,
|
||||
converted_profit=None,
|
||||
missing_reason="成本缺失,无法计算毛利润",
|
||||
)
|
||||
else:
|
||||
profit = item.sale_amount - item.cost_amount
|
||||
rate = profit_rate(profit, item.sale_amount)
|
||||
conv = converted_profit(item.sale_amount, rate, threshold)
|
||||
row_kwargs.update(
|
||||
cost_amount=str(item.cost_amount),
|
||||
profit_amount=str(profit),
|
||||
profit_rate=str(rate),
|
||||
converted_profit=str(conv),
|
||||
missing_reason=None,
|
||||
)
|
||||
bucket = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
|
||||
bucket[0] += item.sale_amount
|
||||
bucket[1] += item.cost_amount
|
||||
rows_out.append(GrossProfitRowOut(**row_kwargs))
|
||||
|
||||
scan = _scan_gross_profit_rows(session, business_id, threshold_of)
|
||||
categories = [
|
||||
GrossProfitCategoryOut(
|
||||
product_category=category,
|
||||
sale_amount=str(sale),
|
||||
cost_amount=str(cost),
|
||||
profit_amount=str(sale - cost),
|
||||
profit_rate=str(profit_rate(sale - cost, sale)),
|
||||
threshold=str(threshold_of(category)),
|
||||
converted_profit=str(
|
||||
converted_profit(sale, profit_rate(sale - cost, sale), threshold_of(category))
|
||||
),
|
||||
)
|
||||
for category, [sale, cost] in agg.items()
|
||||
_gross_profit_category(category, sale, cost, threshold_of(category))
|
||||
for category, [sale, cost] in scan.category_agg.items()
|
||||
]
|
||||
return GrossProfitOut(
|
||||
business_id=business_id,
|
||||
as_of=as_of,
|
||||
rows=rows_out,
|
||||
rows=scan.rows,
|
||||
categories=categories,
|
||||
missing_count=missing_count,
|
||||
missing_count=scan.missing_count,
|
||||
)
|
||||
@@ -60,7 +60,7 @@ from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
|
||||
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算"
|
||||
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(未计入分成基数):绩效在月结时按团队与全公司分成计算"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -544,7 +544,7 @@ def _personal_rate(
|
||||
if customer_type == "enterprise":
|
||||
# 存储口径 ‰ → 纯比例(漏换算放大 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"
|
||||
if template == "TPL_MANAGER_SHARE":
|
||||
@@ -554,7 +554,7 @@ def _personal_rate(
|
||||
if snapshot.get("manager.personal.included") != "1":
|
||||
return _RateDecision(
|
||||
Decimal("0"),
|
||||
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
|
||||
"经理个人户:绩效按团队与全公司分成在月结时计算,单笔不计提",
|
||||
None,
|
||||
True,
|
||||
)
|
||||
@@ -563,12 +563,12 @@ def _personal_rate(
|
||||
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
|
||||
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||
note = f"经理个人户(基数开关=1):按业务员递减 {start}‰ 起,{days} 天"
|
||||
note = f"经理个人户(已开启计入分成基数):按业务员递减,回款 {days} 天,起始 {start}‰"
|
||||
return _RateDecision(rate, note, None, False)
|
||||
if template == "TPL_INFO_SALES_FIXED":
|
||||
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
|
||||
rate = parse_fraction(snapshot.get("info.rate")) / 1000
|
||||
return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False)
|
||||
return _RateDecision(rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", None, False)
|
||||
if template == "TPL_SALESPERSON_DECAY":
|
||||
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
||||
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
||||
@@ -577,9 +577,16 @@ def _personal_rate(
|
||||
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
|
||||
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||
note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)"
|
||||
note = (
|
||||
f"回款 {days} 天,从起始 {start}‰ 按日递减"
|
||||
f"(满 {boundary} 天后固定按 {floor}‰ 计算)"
|
||||
)
|
||||
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):
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Row
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -37,6 +39,7 @@ from app.schemas.customer import (
|
||||
CustomerOut,
|
||||
CustomerOwnershipIn,
|
||||
CustomerQuery,
|
||||
CustomerRatingIn,
|
||||
CustomerUpdateIn,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
@@ -60,6 +63,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
|
||||
owner_personnel_id=customer.owner_personnel_id,
|
||||
owner_personnel_name=owner_name,
|
||||
remark=customer.remark,
|
||||
rating_score=customer.rating_score,
|
||||
rating_time=customer.rating_time,
|
||||
)
|
||||
|
||||
|
||||
@@ -107,20 +112,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
|
||||
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
|
||||
"""分页查询客户(附归属人员姓名)。"""
|
||||
rows = customer_repository.list_customers(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
keyword=query.keyword,
|
||||
customer_type=query.customer_type,
|
||||
owner_personnel_id=query.owner_personnel_id,
|
||||
)
|
||||
total = customer_repository.count_customers(
|
||||
session,
|
||||
keyword=query.keyword,
|
||||
customer_type=query.customer_type,
|
||||
owner_personnel_id=query.owner_personnel_id,
|
||||
)
|
||||
rows = customer_repository.list_customers(session, query)
|
||||
total = customer_repository.count_customers(session, query)
|
||||
return PageResult[CustomerOut](
|
||||
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
|
||||
total=total,
|
||||
@@ -274,6 +267,53 @@ def update_customer(
|
||||
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(
|
||||
session: Session,
|
||||
customer_id: int,
|
||||
@@ -317,6 +357,71 @@ def delete_customer(
|
||||
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(
|
||||
session: Session,
|
||||
customer_id: int,
|
||||
@@ -335,57 +440,34 @@ def change_ownership(
|
||||
"""
|
||||
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
|
||||
# 上下文内进行,失败同样要留下可追溯的记录
|
||||
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}"
|
||||
prefetch = _prefetch_customer_ownership(session, customer_id, payload)
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_CUSTOMER,
|
||||
action=ACTION_UPDATE,
|
||||
summary=f"调整客户归属 {customer_name}:{old_label} → {new_label}",
|
||||
summary=(
|
||||
f"调整客户归属 {prefetch.customer_name}:"
|
||||
f"{prefetch.old_label} → {prefetch.new_label}"
|
||||
),
|
||||
request_data=payload.model_dump(exclude_none=True),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if row is None:
|
||||
if prefetch.row is None:
|
||||
raise NotFoundError("客户不存在")
|
||||
customer, _ = row
|
||||
customer, _ = prefetch.row
|
||||
|
||||
if payload.new_owner_personnel_id is not None:
|
||||
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
|
||||
old_owner_id = customer.owner_personnel_id
|
||||
if old_owner_id == payload.new_owner_personnel_id:
|
||||
raise BadRequestError("归属未变化")
|
||||
old_owner_id = _validate_customer_ownership(session, customer, payload)
|
||||
|
||||
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
|
||||
customer.owner_personnel_id = payload.new_owner_personnel_id
|
||||
customer_repository.add_ownership_log(
|
||||
session,
|
||||
OwnershipChangeLog(
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
customer_id=customer.id,
|
||||
old_owner_personnel_id=old_owner_id,
|
||||
new_owner_personnel_id=payload.new_owner_personnel_id,
|
||||
change_reason=payload.reason,
|
||||
operator_id=operator.id,
|
||||
operator_name=operator.username,
|
||||
),
|
||||
session, _customer_ownership_log(customer, payload, old_owner_id, operator)
|
||||
)
|
||||
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
|
||||
out = _to_out(customer, new_person.personnel_name if new_person else None)
|
||||
out = _to_out(customer, prefetch.new_person.personnel_name if prefetch.new_person else None)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"调整客户归属 | customer_id=%s | old=%s | new=%s",
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""工作台服务(M4 ⑧):区块实时聚合编排 + 最近动态分页。
|
||||
|
||||
设计决策(2026-10-19):
|
||||
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
|
||||
不复用报表的部门汇总(后者会加载整月台账,过重)
|
||||
- **待办与预警实时派生**:不建待办/预警表、不引调度器;处置动作本身
|
||||
已有操作日志可追溯。与 FR-035「预警产生/消除留痕」的差距记录在
|
||||
计划开放项,待业务确认是否物化
|
||||
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
|
||||
``sync_available=false`` 显式降级,不返回伪造的 0 条
|
||||
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
|
||||
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
|
||||
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.month import validate_month
|
||||
from app.repositories import dashboard_repository, operation_log_repository, user_repository
|
||||
from app.repositories.query_utils import LogFilters
|
||||
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
|
||||
from app.services import approval_service
|
||||
|
||||
# 工作台只展示摘要,完整列表跳转对应管理页
|
||||
_TODO_LIMIT = 5
|
||||
_WARNING_LIMIT = 20
|
||||
_TREND_MONTHS = 6
|
||||
|
||||
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
|
||||
|
||||
|
||||
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
|
||||
"""三区块一次性出数(前端一次请求渲染整页)。
|
||||
|
||||
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
|
||||
风险预警仍是全局经营视角,不按人裁剪。
|
||||
最近动态不在此返回 —— 它是分页列表,走 ``list_activities``。
|
||||
"""
|
||||
resolved = _normalize_month(month)
|
||||
warning_params = _warning_params(session)
|
||||
pending_statuses = approval_service.pending_statuses_for_roles(
|
||||
user_repository.get_role_codes(current_user)
|
||||
)
|
||||
return {
|
||||
"month": resolved,
|
||||
"overview": _overview(session, resolved),
|
||||
"todos": _todos(session, pending_statuses),
|
||||
"warnings": _warnings(session, warning_params),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_month(month: str | None) -> str:
|
||||
"""未传月份按当月;非法月份在入口拦成 400(避免 500)。"""
|
||||
if not month:
|
||||
today = date.today()
|
||||
return f"{today.year:04d}-{today.month:02d}"
|
||||
try:
|
||||
return validate_month(month)
|
||||
except ValueError as exc:
|
||||
raise BadRequestError(str(exc)) from exc
|
||||
|
||||
|
||||
def _shift_month(month: str, delta: int) -> str:
|
||||
"""YYYY-MM 平移 delta 个月(用于近 N 个月窗口)。"""
|
||||
year, mon = int(month[:4]), int(month[5:7])
|
||||
index = year * 12 + (mon - 1) + delta
|
||||
return f"{index // 12:04d}-{index % 12 + 1:02d}"
|
||||
|
||||
|
||||
def _month_start(month: str) -> date:
|
||||
"""YYYY-MM → 该月首日(范围查询边界,避免函数列过滤)。"""
|
||||
return date(int(month[:4]), int(month[5:7]), 1)
|
||||
|
||||
|
||||
def _trend_months(month: str) -> list[str]:
|
||||
"""窗口为「当月及之前共 6 个月」,升序返回便于前端画图。"""
|
||||
return [_shift_month(month, offset) for offset in range(-(_TREND_MONTHS - 1), 1)]
|
||||
|
||||
|
||||
def _overview(session: Session, month: str) -> dict:
|
||||
"""经营绩效总览(FR-033):口径与台账/月结一致。"""
|
||||
sums = dashboard_repository.sum_month_performance(session, month)
|
||||
settlement = dashboard_repository.get_month_settlement(session, month)
|
||||
months = _trend_months(month)
|
||||
performance = dashboard_repository.performance_trend(session, months)
|
||||
receipts = dashboard_repository.receipt_trend(
|
||||
session,
|
||||
start_date=_month_start(months[0]),
|
||||
end_date=_month_start(_shift_month(months[-1], 1)),
|
||||
)
|
||||
return {
|
||||
"month": month,
|
||||
"performance_total": _dec(sums["performance_total"]),
|
||||
"payable_total": _dec(sums["payable_total"]),
|
||||
"person_count": int(sums["person_count"]),
|
||||
"team_count": dashboard_repository.count_active_managers(session),
|
||||
"settlement": {
|
||||
"exists": settlement is not None,
|
||||
"status": settlement.status if settlement else None,
|
||||
"total_performance": settlement.total_performance if settlement else None,
|
||||
"total_payable": settlement.total_payable if settlement else None,
|
||||
"locked_at": settlement.locked_at if settlement else None,
|
||||
},
|
||||
"trend": [
|
||||
{
|
||||
"month": item,
|
||||
"performance_total": _dec(performance.get(item)),
|
||||
"receipt_total": _dec(receipts.get(item)),
|
||||
}
|
||||
for item in months
|
||||
],
|
||||
"note": (
|
||||
"绩效按台账归属月统计(二次分配按参与人拆分后求和,不重复不计漏);"
|
||||
"回款按到账月统计(含尚未核算的回款);不含经理团队分成与调整单"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _todos(session: Session, pending_statuses: tuple[int, ...]) -> dict:
|
||||
"""待办事项(FR-034,澄清 #11 起步范围)。
|
||||
|
||||
``pending_statuses`` 已按当前用户角色解析:财务只看待财务复核、
|
||||
领导只看待领导审核;admin 查看全部在途单。
|
||||
"""
|
||||
orders = dashboard_repository.list_pending_approvals(
|
||||
session, statuses=pending_statuses, limit=_TODO_LIMIT
|
||||
)
|
||||
receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT)
|
||||
return {
|
||||
"pending_approval_total": dashboard_repository.count_pending_approvals(
|
||||
session, statuses=pending_statuses
|
||||
),
|
||||
"pending_approvals": [
|
||||
{
|
||||
"order_id": order.id,
|
||||
"order_no": order.order_no,
|
||||
"order_type": order.order_type,
|
||||
"submitter_name": order.submitter_name,
|
||||
"create_time": order.create_time,
|
||||
}
|
||||
for order in orders
|
||||
],
|
||||
"unassigned_receipt_total": dashboard_repository.count_unassigned_receipts(session),
|
||||
"unassigned_receipts": [
|
||||
{
|
||||
"receipt_id": row[0].id,
|
||||
"receipt_batch_no": row[0].receipt_batch_no,
|
||||
"business_id": row[0].business_id,
|
||||
"business_no": row.business_no,
|
||||
"amount": row[0].amount,
|
||||
"receipt_date": row[0].receipt_date,
|
||||
}
|
||||
for row in receipts
|
||||
],
|
||||
# ⑥ T+ 同步未建:显式降级,不伪造「无失败」
|
||||
"sync_available": False,
|
||||
"sync_failure_total": 0,
|
||||
}
|
||||
|
||||
|
||||
def _warning_params(session: Session) -> dict:
|
||||
"""预警参数一次解析(阈值参数化,澄清 #10)。"""
|
||||
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
snapshot = resolve_snapshot(session, date.today())
|
||||
return {
|
||||
"enabled": snapshot.get("warning.enabled") == "1",
|
||||
"receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))),
|
||||
"approval_timeout_days": int(parse_fraction(snapshot.get("warning.approval.timeout.days"))),
|
||||
}
|
||||
|
||||
|
||||
def _warnings(session: Session, params: dict) -> dict:
|
||||
"""风险预警(FR-035):首批两条可算规则;同步规则待 ⑥。"""
|
||||
if not params["enabled"]:
|
||||
return {
|
||||
"enabled": False,
|
||||
"receipt_overdue_days": params["receipt_overdue_days"],
|
||||
"approval_timeout_days": params["approval_timeout_days"],
|
||||
"items": [],
|
||||
"total": 0,
|
||||
"receipt_overdue_total": 0,
|
||||
"approval_timeout_total": 0,
|
||||
"sync_available": False,
|
||||
}
|
||||
|
||||
receipt_cutoff = date.today() - timedelta(days=params["receipt_overdue_days"])
|
||||
approval_cutoff = datetime.now() - timedelta(days=params["approval_timeout_days"])
|
||||
shipments = dashboard_repository.list_overdue_shipments(
|
||||
session, cutoff=receipt_cutoff, limit=_WARNING_LIMIT
|
||||
)
|
||||
orders = dashboard_repository.list_overdue_approvals(
|
||||
session, cutoff=approval_cutoff, limit=_WARNING_LIMIT
|
||||
)
|
||||
|
||||
items = [
|
||||
{
|
||||
"code": "receipt_overdue",
|
||||
"level": "danger",
|
||||
"object_type": "shipment",
|
||||
"object_id": row[0].id,
|
||||
"title": f"发货批次 {row[0].shipment_no} 逾期未回款",
|
||||
"detail": (
|
||||
f"业务 {row.business_no or row[0].business_id} 发货日 {row[0].ship_date},"
|
||||
f"超过 {params['receipt_overdue_days']} 天无回款记录"
|
||||
),
|
||||
"suggest": "联系客户催收,或核对回款批次指定关系",
|
||||
"link": "/business/shipments",
|
||||
}
|
||||
for row in shipments
|
||||
]
|
||||
items += [
|
||||
{
|
||||
"code": "approval_timeout",
|
||||
"level": "warning",
|
||||
"object_type": "approval_order",
|
||||
"object_id": order.id,
|
||||
"title": f"审批单 {order.order_no} 超时未处理",
|
||||
"detail": (
|
||||
f"{_ORDER_TYPE_LABELS.get(order.order_type, '审批')}由 "
|
||||
f"{order.submitter_name or '—'} 提交于 {order.create_time:%Y-%m-%d %H:%M},"
|
||||
f"已超过 {params['approval_timeout_days']} 天"
|
||||
),
|
||||
"suggest": "在审批管理中复核或退回",
|
||||
"link": "/system/approvals",
|
||||
}
|
||||
for order in orders
|
||||
]
|
||||
receipt_overdue_total = dashboard_repository.count_overdue_shipments(
|
||||
session, cutoff=receipt_cutoff
|
||||
)
|
||||
approval_timeout_total = dashboard_repository.count_overdue_approvals(
|
||||
session, cutoff=approval_cutoff
|
||||
)
|
||||
return {
|
||||
"enabled": True,
|
||||
"receipt_overdue_days": params["receipt_overdue_days"],
|
||||
"approval_timeout_days": params["approval_timeout_days"],
|
||||
"items": items,
|
||||
# items 有展示上限(_WARNING_LIMIT),徽标计数必须用全量命中数
|
||||
"total": receipt_overdue_total + approval_timeout_total,
|
||||
"receipt_overdue_total": receipt_overdue_total,
|
||||
"approval_timeout_total": approval_timeout_total,
|
||||
"sync_available": False,
|
||||
}
|
||||
|
||||
|
||||
def list_activities(session: Session, query: ActivityQuery) -> ActivityPageOut:
|
||||
"""最近动态分页(FR-036):以操作日志为唯一来源(写操作即留痕)。
|
||||
|
||||
模块筛选项与列表同源返回(全量模块名,不随当前页变化),
|
||||
前端模块下拉不必另取一次全量。
|
||||
"""
|
||||
repo_query = operation_log_repository.OperationLogListQuery(
|
||||
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,
|
||||
)
|
||||
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:
|
||||
"""SQL 聚合结果转 Decimal(SUM 可能返回 int,None 原样返回)。"""
|
||||
if value is None:
|
||||
return None
|
||||
return value if isinstance(value, Decimal) else Decimal(str(value))
|
||||
@@ -22,18 +22,15 @@ from app.schemas.log import (
|
||||
|
||||
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
|
||||
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
|
||||
filters = _filters_from(query)
|
||||
logs = operation_log_repository.list_logs(
|
||||
session,
|
||||
repo_query = operation_log_repository.OperationLogListQuery(
|
||||
filters=_filters_from(query),
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
filters=filters,
|
||||
module=query.module,
|
||||
action=query.action,
|
||||
)
|
||||
total = operation_log_repository.count_logs(
|
||||
session, filters=filters, module=query.module, action=query.action
|
||||
)
|
||||
logs = operation_log_repository.list_logs(session, repo_query)
|
||||
total = operation_log_repository.count_logs(session, repo_query)
|
||||
return PageResult[OperationLogOut](
|
||||
records=[OperationLogOut.model_validate(log) for log in logs],
|
||||
total=total,
|
||||
@@ -55,15 +52,14 @@ def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut:
|
||||
|
||||
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
|
||||
"""分页查询登录日志。"""
|
||||
filters = _filters_from(query)
|
||||
logs = login_log_repository.list_logs(
|
||||
session,
|
||||
repo_query = login_log_repository.LoginLogListQuery(
|
||||
filters=_filters_from(query),
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
filters=filters,
|
||||
ip=query.ip,
|
||||
)
|
||||
total = login_log_repository.count_logs(session, filters=filters, ip=query.ip)
|
||||
logs = login_log_repository.list_logs(session, repo_query)
|
||||
total = login_log_repository.count_logs(session, repo_query)
|
||||
return PageResult[LoginLogOut](
|
||||
records=[LoginLogOut.model_validate(log) for log in logs],
|
||||
total=total,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""经理分成共享口径(月结聚合与报表部门汇总的唯一实现)。
|
||||
|
||||
从 settlement_service 迁出(行为零变化):经理 12.5%/5% 的取参、团队
|
||||
分桶、公司基数计算,月结生成与部门汇总报表都要用——抽为单一实现后
|
||||
「报表汇总 == 月结聚合行」不靠两处代码各自对齐,而是同源函数的直接
|
||||
结果。分成金额仍走 formula.manager_team_share 纯函数。
|
||||
"""
|
||||
|
||||
from calendar import monthrange
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.month import parse_month
|
||||
from app.models import Personnel
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagerShareParams:
|
||||
"""经理分成取参与参数原文(同一次计算共用一份,随月月末时点化)。"""
|
||||
|
||||
month: str
|
||||
param_date: date
|
||||
team_text: str
|
||||
company_text: str
|
||||
team_pct: Fraction
|
||||
company_pct: Fraction
|
||||
include_manager_personal: bool
|
||||
|
||||
|
||||
def month_end(month: str) -> date:
|
||||
"""YYYY-MM → 所属月月末(月结与报表取参锚点)。
|
||||
|
||||
月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today()
|
||||
会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」
|
||||
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
|
||||
更细的业务日期,取该月最后可确定的生效时点。
|
||||
"""
|
||||
try:
|
||||
year, mon = parse_month(month)
|
||||
except ValueError as exc:
|
||||
# schema 已拦住非法月份;内部调用绕过 schema 时也不能变成 500
|
||||
raise BadRequestError(str(exc)) from exc
|
||||
return date(year, mon, monthrange(year, mon)[1])
|
||||
|
||||
|
||||
def manager_share_params(session: Session, month: str) -> ManagerShareParams:
|
||||
"""解析经理分成参数(锚定所属月月末,取参与台账行口径同源)。
|
||||
|
||||
参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
|
||||
manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)。
|
||||
#12 开关(manager.personal.included)与分成比例同日取参,答复后调整可追溯。
|
||||
"""
|
||||
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
param_date = month_end(month)
|
||||
snapshot = resolve_snapshot(session, param_date)
|
||||
team_text = snapshot.get("manager.share.team")
|
||||
company_text = snapshot.get("manager.share.company")
|
||||
return ManagerShareParams(
|
||||
month=month,
|
||||
param_date=param_date,
|
||||
team_text=team_text,
|
||||
company_text=company_text,
|
||||
team_pct=parse_fraction(team_text),
|
||||
company_pct=parse_fraction(company_text),
|
||||
include_manager_personal=snapshot.get("manager.personal.included") == "1",
|
||||
)
|
||||
|
||||
|
||||
def add_team_performance(managers: dict[int, dict], manager_id: int, row) -> None:
|
||||
"""把一行业绩并入经理的团队桶(首行固化业务锚点,成员名去重收集)。"""
|
||||
bucket = managers.setdefault(
|
||||
manager_id,
|
||||
{
|
||||
"performance": Decimal("0"),
|
||||
"business_id": row.business_id,
|
||||
"business_no": row.business_no,
|
||||
"names": set(),
|
||||
},
|
||||
)
|
||||
bucket["performance"] += row.performance_amount or Decimal("0")
|
||||
bucket["names"].add(row.personnel_name)
|
||||
|
||||
|
||||
def collect_team_buckets(
|
||||
session: Session, ledger_rows: list, include_manager_personal: bool
|
||||
) -> dict[int, dict]:
|
||||
"""按经理采集团队业绩。
|
||||
|
||||
业务员行经 manager_id 上溯到其经理;经理个人户行(#12 开关计入时)
|
||||
归属经理本人——行 personnel_id 即经理,不走上溯(经理没有上级,
|
||||
按上溯会被跳过漏计)。
|
||||
"""
|
||||
managers: dict[int, dict] = {}
|
||||
for row in ledger_rows:
|
||||
if row.customer_type == "manager_personal":
|
||||
if include_manager_personal:
|
||||
add_team_performance(managers, row.personnel_id, row)
|
||||
continue # 默认不计入聚合基数(#12 参数化)
|
||||
person = session.get(Personnel, row.personnel_id)
|
||||
manager_id = person.manager_id if person else None
|
||||
if manager_id is not None:
|
||||
add_team_performance(managers, manager_id, row)
|
||||
return managers
|
||||
|
||||
|
||||
def company_base_of(ledger_rows: list, include_manager_personal: bool) -> Decimal:
|
||||
"""全公司基数(5% 的底):默认排除经理个人户行(#12 开关计入时不过滤)。
|
||||
|
||||
口径逐行固定,调用方在循环外算一次。
|
||||
"""
|
||||
return sum(
|
||||
(
|
||||
r.performance_amount or Decimal("0")
|
||||
for r in ledger_rows
|
||||
if include_manager_personal or r.customer_type != "manager_personal"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
@@ -3,6 +3,10 @@
|
||||
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
|
||||
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
|
||||
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
|
||||
|
||||
`description` 会直接显示在「计提参数」页的参数名下方,**面向业务人员撰写**:
|
||||
说清这个参数管什么、钱怎么变、什么时候该调它。不写英文枚举名、需求编号、
|
||||
内部阶段号,也不写实现层面的精度约束(那些留在代码注释与测试里)。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -43,7 +47,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="10",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
|
||||
description="回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。",
|
||||
sort_order=1,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -57,7 +61,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="4/360",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
|
||||
description="回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。",
|
||||
sort_order=2,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -71,7 +75,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="4",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
|
||||
description="回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。",
|
||||
sort_order=3,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -85,7 +89,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="360",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
|
||||
description="回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。",
|
||||
sort_order=4,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -99,7 +103,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="12.5",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="∑本人带领业务员绩效 × 此比例(限本团队)",
|
||||
description="经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。",
|
||||
sort_order=5,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -113,7 +117,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="5",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="∑全部业务员绩效 × 此比例(全公司)",
|
||||
description="经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。",
|
||||
sort_order=6,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -127,7 +131,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="3",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
|
||||
description="企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。",
|
||||
sort_order=7,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -141,7 +145,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="1",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="信息员绩效 = 本人销售额 × 此比例",
|
||||
description="信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。",
|
||||
sort_order=8,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -155,7 +159,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="10",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="预付款统一按此比例计提",
|
||||
description="预付款到账即按这个比例计提绩效,不必等到全额回款。",
|
||||
sort_order=9,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -169,7 +173,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="80",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="以物抵顶回款绩效按应发绩效的此比例折算",
|
||||
description="以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。",
|
||||
sort_order=10,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -183,7 +187,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="60",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
|
||||
description="单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。",
|
||||
sort_order=11,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -197,7 +201,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="FULL",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
|
||||
description="选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。",
|
||||
sort_order=12,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -211,7 +215,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="30",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
|
||||
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。",
|
||||
sort_order=13,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -225,7 +229,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="INCLUDE",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
|
||||
description="算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。",
|
||||
sort_order=14,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -239,7 +243,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="18000",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="试用期年累计绩效超过此值部分年底一次性结算",
|
||||
description="试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。",
|
||||
sort_order=15,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -253,11 +257,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description=(
|
||||
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
|
||||
"1=提交后进入财务复核→领导审核审批流。"
|
||||
"默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭"
|
||||
),
|
||||
description="二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。",
|
||||
sort_order=17,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -271,11 +271,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description=(
|
||||
"月度结算审批链开关(④ 月结提交时消费):"
|
||||
"0=结算生成即生效,1=走财务复核→领导审核审批流。"
|
||||
"默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭"
|
||||
),
|
||||
description="月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。",
|
||||
sort_order=18,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -289,11 +285,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="0",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description=(
|
||||
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
|
||||
"调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,"
|
||||
"默认值业务未明确,暂按直接生效,业务可开启)"
|
||||
),
|
||||
description="调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。",
|
||||
sort_order=19,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -307,13 +299,53 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="0",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description=(
|
||||
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
|
||||
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
|
||||
"0=不计入(默认,暂口径) 1=计入"
|
||||
),
|
||||
description="经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。",
|
||||
sort_order=20,
|
||||
),
|
||||
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
|
||||
# 首批三条中「同步连续失败」依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目
|
||||
ParamSpec(
|
||||
code="warning.enabled",
|
||||
name="风险预警总开关",
|
||||
group="预警规则",
|
||||
value_type="BOOL",
|
||||
unit=None,
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。",
|
||||
sort_order=21,
|
||||
),
|
||||
ParamSpec(
|
||||
code="warning.receipt.overdue.days",
|
||||
name="回款逾期天数阈值",
|
||||
group="预警规则",
|
||||
value_type="INT",
|
||||
unit="天",
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="90",
|
||||
value_min="1",
|
||||
value_max=None,
|
||||
description="发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。",
|
||||
sort_order=22,
|
||||
),
|
||||
ParamSpec(
|
||||
code="warning.approval.timeout.days",
|
||||
name="审批超时天数阈值",
|
||||
group="预警规则",
|
||||
value_type="INT",
|
||||
unit="天",
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="3",
|
||||
value_min="1",
|
||||
value_max=None,
|
||||
description="审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。",
|
||||
sort_order=23,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
@@ -44,7 +44,7 @@ from app.schemas.param import (
|
||||
ParamPreviewOut,
|
||||
ParamUsedOut,
|
||||
)
|
||||
from app.services import audit_service, formula
|
||||
from app.services import audit_service, calc_templates, formula
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.formula import to_decimal
|
||||
from app.services.no_generator import next_no
|
||||
@@ -379,16 +379,8 @@ def get_history(
|
||||
definition = param_repository.get_definition_by_code(session, param_code)
|
||||
if definition is None:
|
||||
raise NotFoundError("参数不存在")
|
||||
rows = param_repository.list_history(
|
||||
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
|
||||
)
|
||||
rows = param_repository.list_history(session, definition.id, query)
|
||||
total = param_repository.count_history(session, definition.id, query)
|
||||
return PageResult[ParamHistoryOut](
|
||||
records=[
|
||||
ParamHistoryOut(
|
||||
@@ -410,16 +402,8 @@ def get_history(
|
||||
|
||||
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
|
||||
"""变更批次分页(按创建时间倒序)。"""
|
||||
batches = param_repository.list_batches(
|
||||
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
|
||||
)
|
||||
batches = param_repository.list_batches(session, query)
|
||||
total = param_repository.count_batches(session, query)
|
||||
return PageResult[ParamBatchOut](
|
||||
records=[
|
||||
ParamBatchOut(
|
||||
@@ -509,42 +493,89 @@ def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDi
|
||||
return out
|
||||
|
||||
|
||||
def _preview_rate(
|
||||
session: Session,
|
||||
payload: ParamPreviewIn,
|
||||
snapshot: ParamSnapshot,
|
||||
days: int,
|
||||
take,
|
||||
) -> tuple[Fraction, str, Decimal | None]:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PreviewContext:
|
||||
"""预览上下文(会话、入参、参数取值闭包)。
|
||||
|
||||
``take`` 闭包同时负责取数与记录「用了哪些参数」——**调用顺序即
|
||||
``params_used`` 的展示顺序**,拆分时不得调换各段的先后。
|
||||
"""
|
||||
|
||||
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 表示该模板的绩效基数不是折算毛利润额(如信息员按
|
||||
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
|
||||
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
||||
"""
|
||||
payload = ctx.payload
|
||||
take = ctx.take
|
||||
if payload.customer_type == "enterprise":
|
||||
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
|
||||
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
|
||||
rate = take("enterprise.rate") / 1000
|
||||
return rate, "企业户固定计提比例", None
|
||||
return rate, "企业户:不按回款天数,统一按固定比例计提", None
|
||||
|
||||
role_code = payload.business_role
|
||||
template = "TPL_SALESPERSON_DECAY"
|
||||
if role_code:
|
||||
role = business_role_repository.get_by_code(session, role_code)
|
||||
role = business_role_repository.get_by_code(ctx.session, role_code)
|
||||
if role is None:
|
||||
raise BadRequestError(f"岗位 {role_code!r} 不存在")
|
||||
raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认")
|
||||
template = role.calc_template
|
||||
|
||||
if template == "TPL_MANAGER_SHARE":
|
||||
raise BadRequestError(
|
||||
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
|
||||
"营销经理的绩效按团队与全公司分成计算,不单笔试算;请到月度结算里查看"
|
||||
)
|
||||
if template == "TPL_INFO_SALES_FIXED":
|
||||
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
|
||||
rate = take("info.rate") / 1000
|
||||
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
|
||||
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
|
||||
return rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", Decimal(payload.revenue)
|
||||
if template == "TPL_SALESPERSON_DECAY":
|
||||
start = take("salesperson.rate.start")
|
||||
slope = take("salesperson.rate.slope")
|
||||
@@ -553,12 +584,84 @@ def _preview_rate(
|
||||
rate = formula.salesperson_rate(
|
||||
days, start=start, slope=slope, floor=floor, boundary_days=boundary
|
||||
)
|
||||
# 说明写成业务话:说清这笔为什么是这个比例、什么时候不再降
|
||||
return (
|
||||
rate,
|
||||
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
|
||||
f"回款 {days} 天,从起始 {to_decimal(start)}‰ 按日递减"
|
||||
f"(满 {boundary} 天后固定按 {to_decimal(floor)}‰ 计算)",
|
||||
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:
|
||||
@@ -566,7 +669,6 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
||||
# 参数按回款到账日取值(对照表 #11)
|
||||
snapshot = resolve_snapshot(session, payload.receipt_date)
|
||||
revenue = Decimal(payload.revenue)
|
||||
profit = Decimal(payload.profit)
|
||||
received = Decimal(payload.received_amount)
|
||||
used: list[ParamUsedOut] = []
|
||||
|
||||
@@ -589,55 +691,29 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
||||
)
|
||||
return parse_fraction(raw)
|
||||
|
||||
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
|
||||
raise BadRequestError(
|
||||
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit"
|
||||
)
|
||||
|
||||
days = formula.payment_days(payload.ship_date, payload.receipt_date)
|
||||
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)} 元部分年底一次性结算"
|
||||
ctx = _PreviewContext(session=session, payload=payload, take=take)
|
||||
# 各段顺序固定 —— take 的调用顺序即 params_used 的展示顺序
|
||||
decision = _preview_rate_decision(ctx)
|
||||
earning = _preview_earning(ctx, decision)
|
||||
barter = _preview_barter(ctx, earning.performance)
|
||||
payment = _preview_payment(ctx, received, revenue, barter.base_amount)
|
||||
probation_note = _preview_probation_note(ctx)
|
||||
|
||||
return ParamPreviewOut(
|
||||
payment_days=days,
|
||||
rate=str(rate),
|
||||
rate_text=rate_text,
|
||||
profit_rate=str(profit_rate),
|
||||
converted_profit=str(converted),
|
||||
performance_amount=str(performance),
|
||||
barter_applied=barter_applied,
|
||||
performance_after_barter=str(after_barter) if after_barter is not None else None,
|
||||
start_payment_ok=start_ok,
|
||||
payment_weight_r=weight_r,
|
||||
payment_weight=str(weight),
|
||||
payable_amount=str(payable),
|
||||
payment_days=decision.days,
|
||||
rate=str(decision.rate),
|
||||
rate_text=decision.rate_text,
|
||||
profit_rate=str(earning.profit_rate),
|
||||
converted_profit=str(earning.converted),
|
||||
performance_amount=str(earning.performance),
|
||||
barter_applied=barter.applied,
|
||||
performance_after_barter=(
|
||||
str(barter.after_barter) if barter.after_barter is not None else None
|
||||
),
|
||||
start_payment_ok=payment.start_ok,
|
||||
payment_weight_r=payment.weight_r,
|
||||
payment_weight=str(payment.weight),
|
||||
payable_amount=str(payment.payable),
|
||||
probation_note=probation_note,
|
||||
params_used=used,
|
||||
)
|
||||
|
||||
@@ -70,20 +70,8 @@ def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personn
|
||||
|
||||
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
|
||||
"""分页查询人员(含离职)。"""
|
||||
rows = personnel_repository.list_personnel(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
business_role_id=query.business_role_id,
|
||||
)
|
||||
total = personnel_repository.count_personnel(
|
||||
session,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
business_role_id=query.business_role_id,
|
||||
)
|
||||
rows = personnel_repository.list_personnel(session, query)
|
||||
total = personnel_repository.count_personnel(session, query)
|
||||
return PageResult[PersonnelOut](
|
||||
records=[
|
||||
_to_out(personnel, role_name, manager_name)
|
||||
|
||||
@@ -99,20 +99,8 @@ def _raise_if_duplicate(exc: IntegrityError) -> None:
|
||||
|
||||
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
|
||||
"""分页查询产品(含停用)。"""
|
||||
products = product_repository.list_products(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
keyword=query.keyword,
|
||||
product_category=query.product_category,
|
||||
status=query.status,
|
||||
)
|
||||
total = product_repository.count_products(
|
||||
session,
|
||||
keyword=query.keyword,
|
||||
product_category=query.product_category,
|
||||
status=query.status,
|
||||
)
|
||||
products = product_repository.list_products(session, query)
|
||||
total = product_repository.count_products(session, query)
|
||||
return PageResult[ProductOut](
|
||||
records=[_to_out(product) for product in products],
|
||||
total=total,
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -39,7 +40,7 @@ from app.core.constants import (
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import Receipt
|
||||
from app.models import Business, Receipt
|
||||
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
|
||||
from app.repositories import business_repository, receipt_repository
|
||||
@@ -129,24 +130,8 @@ def _ensure_business_writable(session: Session, business_id: int, action: str):
|
||||
|
||||
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
|
||||
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
|
||||
rows = receipt_repository.list_receipts(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
business_id=query.business_id,
|
||||
date_from=query.date_from,
|
||||
date_to=query.date_to,
|
||||
is_barter=query.is_barter,
|
||||
settlement_status=query.settlement_status,
|
||||
)
|
||||
total = receipt_repository.count_receipts(
|
||||
session,
|
||||
business_id=query.business_id,
|
||||
date_from=query.date_from,
|
||||
date_to=query.date_to,
|
||||
is_barter=query.is_barter,
|
||||
settlement_status=query.settlement_status,
|
||||
)
|
||||
rows = receipt_repository.list_receipts(session, query)
|
||||
total = receipt_repository.count_receipts(session, query)
|
||||
return PageResult[ReceiptOut](
|
||||
records=[_to_out(row) for row in rows],
|
||||
total=total,
|
||||
@@ -166,6 +151,57 @@ def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
|
||||
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(
|
||||
session: Session,
|
||||
payload: ReceiptCreateIn,
|
||||
@@ -206,35 +242,7 @@ def create_receipt(
|
||||
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
|
||||
for attempt in range(_NO_RETRY + 1):
|
||||
try:
|
||||
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
|
||||
created = _create_receipt_once(session, payload, business, batch_no)
|
||||
session.commit()
|
||||
break
|
||||
except IntegrityError as exc:
|
||||
@@ -249,11 +257,11 @@ def create_receipt(
|
||||
|
||||
logger.info(
|
||||
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
|
||||
new_id,
|
||||
new_no,
|
||||
created.receipt_id,
|
||||
created.batch_no,
|
||||
payload.business_id,
|
||||
)
|
||||
return out
|
||||
return created.out
|
||||
|
||||
|
||||
def update_receipt(
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
"""报表 Excel 模板构建(纯函数,可单测)。
|
||||
|
||||
版式(FR-032「导出模板围绕财务复核和领导签字设计」):
|
||||
公司信息页眉 → 报表标题 → 筛选条件 → 导出时间/人/模板版本 → 表头 → 数据行
|
||||
→ 合计行 → 财务复核栏 / 领导签字栏 / 日期栏。
|
||||
|
||||
精度红线:金额写 ``Decimal``(openpyxl 原生支持,不经 float)+
|
||||
``number_format='#,##0.00'``;合计在 Python 侧求和后两位收口,**不写
|
||||
Excel 公式**(避免打开时重算漂移与公式注入面)。文本列以 ``= + - @``
|
||||
开头时前置单引号,防公式注入。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
from app.core.report_meta import (
|
||||
BUSINESS_STATUS_LABELS,
|
||||
CUSTOMER_TYPE_LABELS,
|
||||
PRODUCT_CATEGORY_LABELS,
|
||||
)
|
||||
|
||||
_HEADER_FILL = PatternFill("solid", fgColor="DDE3F0")
|
||||
_THIN = Side(style="thin", color="B0B7C3")
|
||||
_BORDER = Border(left=_THIN, right=_THIN, top=_THIN, bottom=_THIN)
|
||||
_TITLE_FONT = Font(size=14, bold=True)
|
||||
_COMPANY_FONT = Font(size=16, bold=True)
|
||||
_HEADER_FONT = Font(bold=True)
|
||||
_AMOUNT_FORMAT = "#,##0.00"
|
||||
_DATE_FORMAT = "YYYY-MM-DD"
|
||||
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Column:
|
||||
"""列定义:key 取行 dict 字段,kind 决定单元格类型与格式。"""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
kind: str # text / amount / int / date / customer / category / status
|
||||
width: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Block:
|
||||
"""签字稿中的一个区块:独立表头、数据行与可选合计行。"""
|
||||
|
||||
title: str
|
||||
columns: tuple[_Column, ...]
|
||||
rows: list[dict]
|
||||
total: dict | None = None
|
||||
empty_text: str = "(无)"
|
||||
|
||||
|
||||
_COLUMNS: dict[str, tuple[_Column, ...]] = {
|
||||
"personal-detail": (
|
||||
_Column("business_no", "合同编号", "text", 20),
|
||||
_Column("ship_date", "发货日", "date", 12),
|
||||
_Column("receipt_date", "回款日", "date", 12),
|
||||
_Column("payment_days", "回款天数", "int", 10),
|
||||
_Column("product_category", "产品类别", "category", 14),
|
||||
_Column("customer_type", "客户类型", "customer", 14),
|
||||
_Column("role_name", "岗位", "text", 12),
|
||||
_Column("rate_text", "计提比例", "text", 14),
|
||||
_Column("weight_r", "支付权重R", "int", 10),
|
||||
_Column("performance_amount", "绩效金额", "amount", 14),
|
||||
_Column("payable_amount", "应付绩效", "amount", 14),
|
||||
),
|
||||
"department-summary": (
|
||||
_Column("manager_name", "经理团队", "text", 16),
|
||||
_Column("member_name", "成员", "text", 14),
|
||||
_Column("role_code", "岗位编码", "text", 12),
|
||||
_Column("performance_sum", "绩效金额", "amount", 14),
|
||||
_Column("payable_sum", "应付绩效", "amount", 14),
|
||||
_Column("adjust_net", "调整净额", "amount", 14),
|
||||
_Column("net_total", "净合计", "amount", 14),
|
||||
),
|
||||
"business-stats": (
|
||||
_Column("business_no", "业务编号", "text", 20),
|
||||
_Column("business_date", "业务日期", "date", 12),
|
||||
_Column("customer_name", "客户", "text", 22),
|
||||
_Column("owner_personnel_name", "归属人", "text", 12),
|
||||
_Column("status", "状态", "status", 10),
|
||||
_Column("total_sale_amount", "销售额", "amount", 14),
|
||||
_Column("total_ship_amount", "发货额", "amount", 14),
|
||||
_Column("total_received_amount", "回款额", "amount", 14),
|
||||
_Column("performance_amount", "绩效金额", "amount", 14),
|
||||
_Column("payable_amount", "应付绩效", "amount", 14),
|
||||
),
|
||||
"payment-stats": (
|
||||
_Column("label", "回款天数区间", "text", 20),
|
||||
_Column("receipt_amount", "回款金额", "amount", 16),
|
||||
_Column("receipt_count", "回款笔数", "int", 10),
|
||||
_Column("performance_amount", "绩效金额", "amount", 14),
|
||||
_Column("payable_amount", "应付绩效", "amount", 14),
|
||||
_Column("ledger_count", "台账行数", "int", 10),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_workbook(
|
||||
*,
|
||||
code: str,
|
||||
title: str,
|
||||
filters: str,
|
||||
rows: list[dict],
|
||||
summary: dict | None,
|
||||
buckets: dict | None,
|
||||
template_version: str,
|
||||
operator_name: str,
|
||||
company_name: str,
|
||||
department: dict | None = None,
|
||||
) -> bytes:
|
||||
"""构建并返回 xlsx 字节流(业务数据只读,不触碰数据库)。
|
||||
|
||||
部门汇总是签字对账文件,走单 sheet 分块版式;其余报表仍是单表 +
|
||||
合计行。`department` 为 `department_summary` 的完整出参。
|
||||
"""
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = _sheet_name(code)
|
||||
if code == "department-summary" and department is not None:
|
||||
_write_department_document(
|
||||
sheet,
|
||||
department,
|
||||
company_name=company_name,
|
||||
title=title,
|
||||
filters=filters,
|
||||
template_version=template_version,
|
||||
operator_name=operator_name,
|
||||
)
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
columns = _COLUMNS[code]
|
||||
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
|
||||
if rows:
|
||||
for row in rows:
|
||||
_write_row(sheet, columns, row)
|
||||
else:
|
||||
sheet.append(["本筛选条件无数据"])
|
||||
_merge_row(sheet, len(columns))
|
||||
_write_total(sheet, columns, rows)
|
||||
_write_signature(sheet, len(columns))
|
||||
_autofit(sheet, columns)
|
||||
|
||||
# 回款统计附维度对比 sheet(客户类型/产品类别),与区间表共用页眉格式
|
||||
if code == "payment-stats" and buckets:
|
||||
for dim_key, dim_title in (
|
||||
("by_customer_type", "客户类型对比"),
|
||||
("by_product_category", "产品类别对比"),
|
||||
):
|
||||
extra = workbook.create_sheet(dim_title)
|
||||
_write_dimension_sheet(
|
||||
extra,
|
||||
dim_title,
|
||||
company_name,
|
||||
filters,
|
||||
buckets.get(dim_key) or [],
|
||||
template_version,
|
||||
operator_name,
|
||||
)
|
||||
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _sheet_name(code: str) -> str:
|
||||
return {
|
||||
"personal-detail": "个人绩效明细",
|
||||
"department-summary": "部门绩效汇总",
|
||||
"business-stats": "业务统计",
|
||||
"payment-stats": "回款统计",
|
||||
}[code]
|
||||
|
||||
|
||||
def _write_header(
|
||||
sheet,
|
||||
columns: tuple[_Column, ...],
|
||||
company_name: str,
|
||||
title: str,
|
||||
filters: str,
|
||||
template_version: str,
|
||||
operator_name: str,
|
||||
) -> None:
|
||||
"""页眉四行 + 表头行;返回后 sheet.max_row 即数据行起点。"""
|
||||
_write_document_header(
|
||||
sheet,
|
||||
len(columns),
|
||||
company_name,
|
||||
title,
|
||||
filters,
|
||||
template_version,
|
||||
operator_name,
|
||||
)
|
||||
|
||||
sheet.append([column.title for column in columns])
|
||||
header_row = sheet.max_row
|
||||
for cell in sheet[header_row]:
|
||||
cell.font = _HEADER_FONT
|
||||
cell.fill = _HEADER_FILL
|
||||
cell.border = _BORDER
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
sheet.freeze_panes = sheet.cell(row=header_row + 1, column=1)
|
||||
|
||||
|
||||
def _write_document_header(
|
||||
sheet,
|
||||
column_count: int,
|
||||
company_name: str,
|
||||
title: str,
|
||||
filters: str,
|
||||
template_version: str,
|
||||
operator_name: str,
|
||||
) -> None:
|
||||
"""公司名/标题/筛选条件/导出信息四行(分块文档与单表共用)。"""
|
||||
sheet.append([company_name])
|
||||
_merge_row(sheet, column_count)
|
||||
sheet.cell(row=1, column=1).font = _COMPANY_FONT
|
||||
sheet.cell(row=1, column=1).alignment = Alignment(horizontal="left", vertical="center")
|
||||
|
||||
sheet.append([title])
|
||||
_merge_row(sheet, column_count)
|
||||
sheet.cell(row=2, column=1).font = _TITLE_FONT
|
||||
sheet.cell(row=2, column=1).alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
sheet.append([f"筛选条件:{filters}"])
|
||||
_merge_row(sheet, column_count)
|
||||
sheet.append(
|
||||
[
|
||||
f"导出时间:{datetime.now():%Y-%m-%d %H:%M:%S} | 导出人:{operator_name} | "
|
||||
f"模板版本:{template_version}"
|
||||
]
|
||||
)
|
||||
_merge_row(sheet, column_count)
|
||||
sheet.append([])
|
||||
|
||||
|
||||
def _merge_row(sheet, column_count: int) -> None:
|
||||
"""合并当前行(公司名/标题/筛选行跨全表宽)。"""
|
||||
row = sheet.max_row
|
||||
if column_count > 1:
|
||||
sheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=column_count)
|
||||
|
||||
|
||||
def _write_row(sheet, columns: tuple[_Column, ...], row: dict) -> None:
|
||||
"""数据行:按列类型写单元格(金额 Decimal、日期 date、文本防注入)。"""
|
||||
values = []
|
||||
for column in columns:
|
||||
raw = row.get(column.key)
|
||||
values.append(_cell_value(column, raw))
|
||||
sheet.append(values)
|
||||
for index, column in enumerate(columns, start=1):
|
||||
cell = sheet.cell(row=sheet.max_row, column=index)
|
||||
cell.border = _BORDER
|
||||
if column.kind == "amount":
|
||||
cell.number_format = _AMOUNT_FORMAT
|
||||
elif column.kind == "date":
|
||||
cell.number_format = _DATE_FORMAT
|
||||
|
||||
|
||||
def _cell_value(column: _Column, raw):
|
||||
"""单值转换:标签列转中文,文本列防公式注入。"""
|
||||
if raw is None:
|
||||
return None
|
||||
if column.kind == "amount":
|
||||
return raw if isinstance(raw, Decimal) else Decimal(str(raw))
|
||||
if column.kind == "date":
|
||||
return raw
|
||||
if column.kind == "int":
|
||||
return int(raw)
|
||||
if column.kind == "customer":
|
||||
return CUSTOMER_TYPE_LABELS.get(str(raw), str(raw))
|
||||
if column.kind == "category":
|
||||
return PRODUCT_CATEGORY_LABELS.get(str(raw), str(raw))
|
||||
if column.kind == "status":
|
||||
return BUSINESS_STATUS_LABELS.get(int(raw), str(raw))
|
||||
return _safe_text(raw)
|
||||
|
||||
|
||||
def _safe_text(value) -> str:
|
||||
"""文本防公式注入:以 = + - @ 或制表符开头时前置单引号。"""
|
||||
text = str(value)
|
||||
return "'" + text if text.startswith(_FORMULA_PREFIXES) else text
|
||||
|
||||
|
||||
def _write_total(sheet, columns: tuple[_Column, ...], rows: list[dict]) -> None:
|
||||
"""合计行:金额列按行求和后两位收口(不写 Excel 公式)。
|
||||
|
||||
「合计」标签只写在第一个非金额列;其余非金额列留空——否则每遇到
|
||||
一个文本列都会重复写一次标签,合计行会满屏「合计」。
|
||||
"""
|
||||
if not rows:
|
||||
return
|
||||
totals = []
|
||||
label_written = False
|
||||
for column in columns:
|
||||
if column.kind != "amount":
|
||||
totals.append("合计" if not label_written else None)
|
||||
label_written = True
|
||||
continue
|
||||
total = sum(
|
||||
(
|
||||
row.get(column.key)
|
||||
for row in rows
|
||||
if isinstance(row.get(column.key), (Decimal, int))
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
totals.append(total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
||||
sheet.append(totals)
|
||||
for index, column in enumerate(columns, start=1):
|
||||
cell = sheet.cell(row=sheet.max_row, column=index)
|
||||
cell.font = _HEADER_FONT
|
||||
cell.border = _BORDER
|
||||
if column.kind == "amount":
|
||||
cell.number_format = _AMOUNT_FORMAT
|
||||
|
||||
|
||||
def _write_signature(sheet, column_count: int) -> None:
|
||||
"""财务复核栏 / 领导签字栏 / 日期栏(FR-032 模板要素)。"""
|
||||
sheet.append([])
|
||||
sheet.append(["财务复核:________________", "", "日期:______年__月__日"])
|
||||
sheet.append(["领导签字:________________", "", "日期:______年__月__日"])
|
||||
for row_index in (sheet.max_row - 1, sheet.max_row):
|
||||
sheet.cell(row=row_index, column=1).alignment = Alignment(horizontal="left")
|
||||
|
||||
|
||||
def _autofit(sheet, columns: tuple[_Column, ...]) -> None:
|
||||
"""列宽按定义表设置(CJK 按 2 字符宽,不用默认 8.43)。"""
|
||||
for index, column in enumerate(columns, start=1):
|
||||
sheet.column_dimensions[get_column_letter(index)].width = column.width
|
||||
|
||||
|
||||
def _write_dimension_sheet(
|
||||
sheet,
|
||||
title: str,
|
||||
company_name: str,
|
||||
filters: str,
|
||||
rows: list[dict],
|
||||
template_version: str,
|
||||
operator_name: str,
|
||||
) -> None:
|
||||
"""维度对比 sheet(客户类型/产品类别),页眉筛选条件与主表一致。"""
|
||||
columns = (
|
||||
_Column("label", "维度", "text", 16),
|
||||
_Column("receipt_amount", "回款金额", "amount", 16),
|
||||
_Column("performance_amount", "绩效金额", "amount", 14),
|
||||
_Column("payable_amount", "应付绩效", "amount", 14),
|
||||
_Column("ledger_count", "台账行数", "int", 10),
|
||||
)
|
||||
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
|
||||
for row in rows:
|
||||
_write_row(sheet, columns, row)
|
||||
_write_total(sheet, columns, rows)
|
||||
_write_signature(sheet, len(columns))
|
||||
_autofit(sheet, columns)
|
||||
|
||||
|
||||
# ==== 部门汇总签字稿(FR-030 / FR-032)====
|
||||
|
||||
_MEMBER_KIND_LABELS = {
|
||||
"regular": "普通绩效",
|
||||
"manager_personal": "经理个人户",
|
||||
"adjustment": "仅调整单",
|
||||
}
|
||||
|
||||
# 各块列数不同,统一按列号取最大宽度(签字稿打印时不挤在一起)
|
||||
_DEPARTMENT_WIDTHS = (30, 16, 22, 13, 13, 15, 18, 14, 14, 13)
|
||||
|
||||
|
||||
def _write_department_document(
|
||||
sheet,
|
||||
data: dict,
|
||||
*,
|
||||
company_name: str,
|
||||
title: str,
|
||||
filters: str,
|
||||
template_version: str,
|
||||
operator_name: str,
|
||||
) -> None:
|
||||
"""部门汇总签字稿:团队分成 → 全公司 5% → 对账 → 成员 → 未分配 → 签字栏。
|
||||
|
||||
全公司 5% 每位有月结聚合行的经理各计一次,合计在第二块展示;
|
||||
团队块的调整净额仅供成员汇总核对。应发合计由后端 `reconciliation`
|
||||
按聚合行口径算好(含分列舍入差),本函数只渲染。
|
||||
"""
|
||||
params = data.get("params") or {}
|
||||
team_text = params.get("team_text", "")
|
||||
company_text = params.get("company_text", "")
|
||||
teams = data.get("teams") or []
|
||||
unassigned = data.get("unassigned") or []
|
||||
company = data.get("company") or {}
|
||||
reconciliation = data.get("reconciliation") or {}
|
||||
company_share_count = int(company.get("company_share_count") or 0)
|
||||
|
||||
_write_document_header(
|
||||
sheet,
|
||||
len(_DEPARTMENT_WIDTHS),
|
||||
company_name,
|
||||
title,
|
||||
filters,
|
||||
template_version,
|
||||
operator_name,
|
||||
)
|
||||
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block(
|
||||
"一、团队分成汇总",
|
||||
(
|
||||
_Column("seq", "序号", "int", 6),
|
||||
_Column("manager_name", "经理团队", "text", 16),
|
||||
_Column("manager_status_text", "状态", "text", 8),
|
||||
_Column("member_count", "团队人数", "int", 9),
|
||||
_Column("team_base", "团队基数", "amount", 13),
|
||||
_Column("team_share_amount", f"团队分成({team_text}%)", "amount", 15),
|
||||
_Column("adjust_net", "调整净额(仅核对)", "amount", 16),
|
||||
_Column("team_subtotal", "团队应发小计(含聚合分成)", "amount", 18),
|
||||
_Column("frozen_amount", "月结冻结额", "amount", 14),
|
||||
),
|
||||
[
|
||||
{
|
||||
"seq": index,
|
||||
"manager_name": team["manager_name"],
|
||||
"manager_status_text": "在职" if team["manager_status"] == 1 else "离职",
|
||||
"member_count": team["member_count"],
|
||||
"team_base": team["team_base"],
|
||||
"team_share_amount": team["team_share_amount"],
|
||||
"adjust_net": team["adjust_net"],
|
||||
"team_subtotal": team["team_subtotal"],
|
||||
"frozen_amount": team["frozen_amount"],
|
||||
}
|
||||
for index, team in enumerate(teams, start=1)
|
||||
],
|
||||
{
|
||||
"manager_name": "合计",
|
||||
"member_count": sum(team["member_count"] 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_subtotal": _sum_amount(team["team_subtotal"] for team in teams),
|
||||
# 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导)
|
||||
"frozen_amount": (
|
||||
_sum_amount(
|
||||
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)
|
||||
else None
|
||||
),
|
||||
},
|
||||
"(无团队)",
|
||||
),
|
||||
)
|
||||
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block(
|
||||
f"二、全公司分成(每位有聚合行的经理各计一次,共 {company_share_count} 次)",
|
||||
(
|
||||
_Column("company_base", "全公司基数", "amount", 16),
|
||||
_Column("company_share_pct", "分成比例", "text", 12),
|
||||
_Column("company_share_amount", "单经理分成", "amount", 13),
|
||||
_Column("company_share_count", "计入次数", "int", 10),
|
||||
_Column("company_share_total", "分成合计", "amount", 14),
|
||||
),
|
||||
[
|
||||
{
|
||||
"company_base": company.get("company_base"),
|
||||
"company_share_pct": f"{company_text}%",
|
||||
"company_share_amount": company.get("company_share_amount"),
|
||||
"company_share_count": company_share_count,
|
||||
"company_share_total": company.get("company_share_total"),
|
||||
}
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block(
|
||||
"三、对账汇总",
|
||||
(
|
||||
_Column("item", "项目", "text", 30),
|
||||
_Column("amount", "金额", "amount", 16),
|
||||
_Column("note", "说明", "text", 40),
|
||||
),
|
||||
[
|
||||
{
|
||||
"item": "团队成员应付合计",
|
||||
"amount": reconciliation.get("team_member_payable_total"),
|
||||
},
|
||||
{
|
||||
"item": "未分配人员应付合计",
|
||||
"amount": reconciliation.get("unassigned_payable_total"),
|
||||
},
|
||||
{"item": "团队分成合计(分列)", "amount": reconciliation.get("team_share_total")},
|
||||
{
|
||||
"item": f"全公司分成合计({company_share_count} 次,分列)",
|
||||
"amount": reconciliation.get("company_share_total"),
|
||||
"note": "每位有聚合行的经理各计一次",
|
||||
},
|
||||
{
|
||||
"item": "聚合行舍入差",
|
||||
"amount": reconciliation.get("aggregate_rounding_diff"),
|
||||
"note": "分列舍入与聚合行一次舍入的差",
|
||||
},
|
||||
{
|
||||
"item": "调整净额合计(明细层)",
|
||||
"amount": reconciliation.get("adjust_net_total"),
|
||||
},
|
||||
{
|
||||
"item": "应发合计",
|
||||
"amount": reconciliation.get("payable_total"),
|
||||
"note": "上列各项之和",
|
||||
},
|
||||
{"item": "团队基数合计", "amount": reconciliation.get("team_base_total")},
|
||||
{
|
||||
"item": "未分配绩效合计",
|
||||
"amount": reconciliation.get("unassigned_performance_total"),
|
||||
},
|
||||
{
|
||||
"item": "其他未展示基数",
|
||||
"amount": reconciliation.get("other_team_base"),
|
||||
"note": "筛选时含其他团队与全公司未分配",
|
||||
},
|
||||
{"item": "全公司基数", "amount": reconciliation.get("company_base")},
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
member_columns = (
|
||||
_Column("manager_name", "经理团队", "text", 15),
|
||||
_Column("personnel_name", "成员", "text", 12),
|
||||
_Column("role_code", "岗位", "text", 10),
|
||||
_Column("member_kind_text", "来源", "text", 13),
|
||||
_Column("performance_sum", "绩效金额", "amount", 13),
|
||||
_Column("payable_sum", "应付绩效", "amount", 13),
|
||||
_Column("adjust_extra", "调整补发", "amount", 12),
|
||||
_Column("adjust_deduct", "调整扣减", "amount", 12),
|
||||
_Column("adjust_net", "调整净额", "amount", 12),
|
||||
_Column("net_total", "净合计", "amount", 13),
|
||||
)
|
||||
member_rows = [
|
||||
{
|
||||
"manager_name": team["manager_name"],
|
||||
"personnel_name": member["personnel_name"],
|
||||
"role_code": member["role_code"],
|
||||
"member_kind_text": _MEMBER_KIND_LABELS.get(
|
||||
member["member_kind"], member["member_kind"]
|
||||
),
|
||||
"performance_sum": member["performance_sum"],
|
||||
"payable_sum": member["payable_sum"],
|
||||
"adjust_extra": member["adjust_extra"],
|
||||
"adjust_deduct": member["adjust_deduct"],
|
||||
"adjust_net": member["adjust_net"],
|
||||
"net_total": member["net_total"],
|
||||
}
|
||||
for team in teams
|
||||
for member in team["members"]
|
||||
]
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block("四、团队成员明细", member_columns, member_rows, empty_text="(无成员)"),
|
||||
sum_amounts=True,
|
||||
)
|
||||
|
||||
unassigned_columns = (
|
||||
_Column("personnel_name", "人员", "text", 14),
|
||||
_Column("role_code", "岗位", "text", 10),
|
||||
_Column("unassigned_reason", "未纳入原因", "text", 24),
|
||||
_Column("performance_sum", "绩效金额", "amount", 13),
|
||||
_Column("payable_sum", "应付绩效", "amount", 13),
|
||||
_Column("adjust_extra", "调整补发", "amount", 12),
|
||||
_Column("adjust_deduct", "调整扣减", "amount", 12),
|
||||
_Column("adjust_net", "调整净额", "amount", 12),
|
||||
_Column("net_total", "净合计", "amount", 13),
|
||||
)
|
||||
unassigned_rows = [
|
||||
{
|
||||
"personnel_name": row["personnel_name"],
|
||||
"role_code": row["role_code"],
|
||||
"unassigned_reason": row.get("unassigned_reason") or "",
|
||||
"performance_sum": row["performance_sum"],
|
||||
"payable_sum": row["payable_sum"],
|
||||
"adjust_extra": row["adjust_extra"],
|
||||
"adjust_deduct": row["adjust_deduct"],
|
||||
"adjust_net": row["adjust_net"],
|
||||
"net_total": row["net_total"],
|
||||
}
|
||||
for row in unassigned
|
||||
]
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block("五、未分配人员明细", unassigned_columns, unassigned_rows),
|
||||
sum_amounts=True,
|
||||
)
|
||||
|
||||
_write_signature(sheet, len(_DEPARTMENT_WIDTHS))
|
||||
for index, width in enumerate(_DEPARTMENT_WIDTHS, start=1):
|
||||
sheet.column_dimensions[get_column_letter(index)].width = width
|
||||
|
||||
|
||||
def _write_block(sheet, block: _Block, *, sum_amounts: bool = False) -> None:
|
||||
"""写一个区块:标题、表头、数据/空提示,以及自定义或自动合计。"""
|
||||
sheet.append([block.title])
|
||||
title_row = sheet.max_row
|
||||
_merge_row(sheet, len(block.columns))
|
||||
sheet.cell(row=title_row, column=1).font = _HEADER_FONT
|
||||
|
||||
sheet.append([column.title for column in block.columns])
|
||||
header_row = sheet.max_row
|
||||
for cell in sheet[header_row]:
|
||||
cell.font = _HEADER_FONT
|
||||
cell.fill = _HEADER_FILL
|
||||
cell.border = _BORDER
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
if block.rows:
|
||||
for row in block.rows:
|
||||
_write_row(sheet, block.columns, row)
|
||||
else:
|
||||
sheet.append([block.empty_text])
|
||||
_merge_row(sheet, len(block.columns))
|
||||
|
||||
if block.total is not None:
|
||||
_write_custom_total(sheet, block.columns, block.total)
|
||||
elif sum_amounts:
|
||||
_write_total(sheet, block.columns, block.rows)
|
||||
sheet.append([])
|
||||
|
||||
|
||||
def _write_custom_total(sheet, columns: tuple[_Column, ...], total: dict) -> None:
|
||||
"""写后端预计算的合计行(不自动求和,避免重复计入共享基数)。"""
|
||||
values = []
|
||||
for column in columns:
|
||||
raw = total.get(column.key)
|
||||
values.append(_cell_value(column, raw) if raw is not None else None)
|
||||
sheet.append(values)
|
||||
for index, column in enumerate(columns, start=1):
|
||||
cell = sheet.cell(row=sheet.max_row, column=index)
|
||||
cell.font = _HEADER_FONT
|
||||
cell.border = _BORDER
|
||||
if column.kind == "amount":
|
||||
cell.number_format = _AMOUNT_FORMAT
|
||||
|
||||
|
||||
def _sum_amount(values) -> Decimal:
|
||||
"""金额列求和后两位收口(None 跳过)。"""
|
||||
return sum((value for value in values if value is not None), Decimal("0")).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -68,14 +68,8 @@ def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
|
||||
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
|
||||
下拉只给启用项。
|
||||
"""
|
||||
roles = role_repository.list_roles(
|
||||
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)
|
||||
roles = role_repository.list_roles(session, query)
|
||||
total = role_repository.count_roles(session, query)
|
||||
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
|
||||
return PageResult[RoleManageOut](
|
||||
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
|
||||
|
||||
@@ -9,18 +9,17 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from calendar import monthrange
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import (
|
||||
MODULE_SETTLEMENT,
|
||||
)
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.models import (
|
||||
MonthlySettlement,
|
||||
@@ -45,7 +44,7 @@ from app.schemas.settlement import (
|
||||
SettlementOut,
|
||||
SettlementQuery,
|
||||
)
|
||||
from app.services import approval_service, audit_service
|
||||
from app.services import approval_service, audit_service, manager_share
|
||||
from app.services.approval_service import AuditRecord, RequestMeta
|
||||
from app.services.formula import manager_team_share, monthly_salary
|
||||
|
||||
@@ -251,19 +250,30 @@ def submit(
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
settlement = settlement_repository.get_by_id(session, settlement_id)
|
||||
settlement = settlement_repository.get_by_id(session, settlement_id, for_update=True)
|
||||
if settlement is None:
|
||||
raise NotFoundError("月结不存在")
|
||||
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
|
||||
raise BadRequestError("仅草稿或已驳回状态可提交")
|
||||
|
||||
order = approval_service.submit_order(
|
||||
session,
|
||||
order_type=_SETTLEMENT_ORDER_TYPE,
|
||||
ref_id=settlement.id,
|
||||
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
|
||||
submitter=operator,
|
||||
)
|
||||
try:
|
||||
order = approval_service.submit_order(
|
||||
session,
|
||||
order_type=_SETTLEMENT_ORDER_TYPE,
|
||||
ref_id=settlement.id,
|
||||
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
|
||||
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
|
||||
if order.status == SETTLEMENT_STATUS_APPROVED:
|
||||
mark_locked(session, settlement, operator=operator)
|
||||
@@ -352,82 +362,28 @@ def _confirm_scope(session: Session, month: str) -> list:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ShareParams:
|
||||
"""经理分成取参与参数原文(同一次生成共用一份,随月月末时点化)。"""
|
||||
def _manager_share_params(session: Session, month: str) -> manager_share.ManagerShareParams:
|
||||
"""薄委托 manager_share 共享模块:月结聚合与报表部门汇总同源取参。
|
||||
|
||||
month: str
|
||||
param_date: date
|
||||
team_text: str
|
||||
company_text: str
|
||||
team_pct: Fraction
|
||||
company_pct: Fraction
|
||||
include_manager_personal: bool
|
||||
|
||||
|
||||
def _manager_share_params(session: Session, month: str) -> _ShareParams:
|
||||
"""解析经理分成参数(锚定所属月月末,取参与台账行口径同源)。
|
||||
|
||||
参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
|
||||
manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)。
|
||||
#12 开关(manager.personal.included)与分成比例同日取参,答复后调整可追溯。
|
||||
迁移说明(2026-10-19):实现原样迁至 manager_share,行为零变化;
|
||||
参数按百分数存储(12.5 表示 12.5%),调用方不得再乘 100。
|
||||
"""
|
||||
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
param_date = _month_end(month)
|
||||
snapshot = resolve_snapshot(session, param_date)
|
||||
team_text = snapshot.get("manager.share.team")
|
||||
company_text = snapshot.get("manager.share.company")
|
||||
return _ShareParams(
|
||||
month=month,
|
||||
param_date=param_date,
|
||||
team_text=team_text,
|
||||
company_text=company_text,
|
||||
team_pct=parse_fraction(team_text),
|
||||
company_pct=parse_fraction(company_text),
|
||||
include_manager_personal=snapshot.get("manager.personal.included") == "1",
|
||||
)
|
||||
|
||||
|
||||
def _add_team_performance(managers: dict[int, dict], manager_id: int, row) -> None:
|
||||
"""把一行业绩并入经理的团队桶(首行固化业务锚点,成员名去重收集)。"""
|
||||
bucket = managers.setdefault(
|
||||
manager_id,
|
||||
{
|
||||
"performance": Decimal("0"),
|
||||
"business_id": row.business_id,
|
||||
"business_no": row.business_no,
|
||||
"names": set(),
|
||||
},
|
||||
)
|
||||
bucket["performance"] += row.performance_amount or Decimal("0")
|
||||
bucket["names"].add(row.personnel_name)
|
||||
return manager_share.manager_share_params(session, month)
|
||||
|
||||
|
||||
def _collect_team_buckets(
|
||||
session: Session, ledger_rows: list, include_manager_personal: bool
|
||||
) -> dict[int, dict]:
|
||||
"""按经理采集团队业绩。
|
||||
|
||||
业务员行经 manager_id 上溯到其经理;经理个人户行(#12 开关计入时)
|
||||
归属经理本人——行 personnel_id 即经理,不走上溯(经理没有上级,
|
||||
按上溯会被跳过漏计)。
|
||||
"""
|
||||
managers: dict[int, dict] = {}
|
||||
for row in ledger_rows:
|
||||
if row.customer_type == "manager_personal":
|
||||
if include_manager_personal:
|
||||
_add_team_performance(managers, row.personnel_id, row)
|
||||
continue # 默认不计入聚合基数(#12 参数化)
|
||||
person = session.get(Personnel, row.personnel_id)
|
||||
manager_id = person.manager_id if person else None
|
||||
if manager_id is not None:
|
||||
_add_team_performance(managers, manager_id, row)
|
||||
return managers
|
||||
"""薄委托 manager_share 共享模块:按经理采集团队业绩(行为零变化)。"""
|
||||
return manager_share.collect_team_buckets(session, ledger_rows, include_manager_personal)
|
||||
|
||||
|
||||
def _aggregate_row(
|
||||
session: Session, manager, bucket: dict, company_base: Decimal, share: _ShareParams
|
||||
session: Session,
|
||||
manager,
|
||||
bucket: dict,
|
||||
company_base: Decimal,
|
||||
share: manager_share.ManagerShareParams,
|
||||
):
|
||||
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
|
||||
from app.models import PerformanceLedger
|
||||
@@ -467,9 +423,10 @@ def _aggregate_row(
|
||||
"members": member_names,
|
||||
"params_as_of": str(share.param_date),
|
||||
"manager_personal_included": 1 if share.include_manager_personal else 0,
|
||||
# 说明写给业务人员看:说清这一笔的基数是怎么凑出来的
|
||||
"note": (
|
||||
f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}"
|
||||
"经理个人户业绩;9.8 方案公式"
|
||||
f"团队分成:基数{'含' if share.include_manager_personal else '不含'}"
|
||||
"经理本人名下业绩,按本团队与全公司两级比例合计计提"
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -487,16 +444,8 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
|
||||
|
||||
share = _manager_share_params(session, month)
|
||||
managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal)
|
||||
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);
|
||||
# 口径逐行固定,循环外算一次
|
||||
company_base = sum(
|
||||
(
|
||||
r.performance_amount or Decimal("0")
|
||||
for r in ledger_rows
|
||||
if share.include_manager_personal or r.customer_type != "manager_personal"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);共享口径单点
|
||||
company_base = manager_share.company_base_of(ledger_rows, share.include_manager_personal)
|
||||
aggregates: list = []
|
||||
for manager_id, bucket in managers.items():
|
||||
manager = session.get(Personnel, manager_id)
|
||||
@@ -514,15 +463,12 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
|
||||
|
||||
|
||||
def _month_end(month: str) -> date:
|
||||
"""YYYY-MM → 所属月月末(月结取参锚点)。
|
||||
"""薄委托 manager_share 共享模块:YYYY-MM → 所属月月末(取参锚点)。
|
||||
|
||||
月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today()
|
||||
会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」
|
||||
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
|
||||
更细的业务日期,取该月最后可确定的生效时点。
|
||||
保留包装:tests/unit/test_settlement_helpers.py 直接 import 本名字;
|
||||
「补生成历史月份结果不随操作日漂移」的原因说明见 manager_share.month_end。
|
||||
"""
|
||||
year, mon = int(month[:4]), int(month[5:7])
|
||||
return date(year, mon, monthrange(year, mon)[1])
|
||||
return manager_share.month_end(month)
|
||||
|
||||
|
||||
def _salary_mode_of(session: Session, role_code: str, month: str) -> str | None:
|
||||
@@ -641,6 +587,7 @@ def _wire_adjustment_approval(session: Session, order, operator: User) -> None:
|
||||
ref_id=order.id,
|
||||
refer_no=order.order_no,
|
||||
submitter=operator,
|
||||
snapshot=approval_service.adjustment_content(session, order.id),
|
||||
)
|
||||
order.status = (
|
||||
ADJUSTMENT_STATUS_EFFECTIVE
|
||||
|
||||
@@ -157,20 +157,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
|
||||
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
|
||||
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
|
||||
rows = shipment_repository.list_shipments(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
business_id=query.business_id,
|
||||
ship_date_from=query.ship_date_from,
|
||||
ship_date_to=query.ship_date_to,
|
||||
)
|
||||
total = shipment_repository.count_shipments(
|
||||
session,
|
||||
business_id=query.business_id,
|
||||
ship_date_from=query.ship_date_from,
|
||||
ship_date_to=query.ship_date_to,
|
||||
)
|
||||
rows = shipment_repository.list_shipments(session, query)
|
||||
total = shipment_repository.count_shipments(session, query)
|
||||
return PageResult[ShipmentOut](
|
||||
records=[_to_out(row) for row in rows],
|
||||
total=total,
|
||||
|
||||
@@ -68,22 +68,8 @@ def _to_out(user: User) -> UserOut:
|
||||
|
||||
def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]:
|
||||
"""分页查询用户列表。"""
|
||||
users = user_repository.list_users(
|
||||
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,
|
||||
)
|
||||
users = user_repository.list_users(session, query)
|
||||
total = user_repository.count_users(session, query)
|
||||
return PageResult[UserOut](
|
||||
records=[_to_out(user) for user in users],
|
||||
total=total,
|
||||
|
||||
+78
-5
@@ -98,9 +98,9 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| --- | --- | --- |
|
||||
| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
|
||||
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 |
|
||||
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕 | 14 |
|
||||
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕、评级保存(回显总分/时间、覆盖式保存)、评分 101→400、按评分排序未评分垫底(asc/desc) | 18 |
|
||||
|
||||
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。
|
||||
运行:`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)。
|
||||
|
||||
### 业务数据管理(2026-10-13 完成)
|
||||
|
||||
@@ -136,6 +136,48 @@ uv run pytest tests/integration -q # 仅集成
|
||||
|
||||
运行:`uv run pytest tests/integration/test_{settings,secondary_allocation,approval_flow}.py tests/unit/test_secondary_allocation_schema.py -q` → **44 passed**(2026-10-16);全量回归 `uv run pytest -q` → **697 passed**(基线 625 + 新增 72,5:10)。
|
||||
|
||||
### 报表统计(M4 ⑤,2026-10-19 后端完成)
|
||||
|
||||
| 文件 | 守什么 | 用例数 |
|
||||
| --- | --- | --- |
|
||||
| `tests/integration/test_reports.py` | 个人明细与台账逐笔一致 + **pageSize=1 时 summary 仍为全筛选合计**;部门汇总团队基数=成员绩效合计、分成按参数、无成员经理出 0 值行;**无台账经理挂上级不 500、仅有调整单人员并入团队/未分配、经理个人户开关两侧口径、按经理筛选不误判其他团队下属、对账恒等式**;**应发与月结 `total_payable` 对拍(两位经理各计一次 5%)、有调整单时等于月结+调整净额、筛选态只汇总展示团队**;导出页眉 ID→名称/中文标签、回款维度 sheet 透传筛选、**导出超限与取数异常的失败审计**、非法月份查询/导出均 400;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**;业务统计排除经理聚合行;普通用户 403 | 27 |
|
||||
| `tests/unit/test_report_helpers.py` | 区间分桶边界与 8 档编码唯一;标签覆盖全部枚举;Excel 模板页眉/签字栏/空数据/金额格式/合计/公式注入/维度 sheet/状态中文;**部门汇总签字稿五块顺序、零成员团队与未分配行、5% 按计入次数累计、应发合计取对账口径、聚合行舍入差单列、比例标题参数化** | 17 |
|
||||
| `tests/unit/test_month_validation.py` | 非法月份(`2026-13`/`2026-00`/`0000-01` 等)在报表、月结、调整单 schema 入口即拒绝;可选月份空串规范为 None | 17 |
|
||||
|
||||
运行:`uv run pytest tests/integration/test_reports.py -q` → **27 passed**(2026-10-20 复核);`uv run pytest tests/unit/test_report_helpers.py tests/unit/test_month_validation.py -q` → **34 passed**;全量 **862 passed**(2026-10-20,11:32)。
|
||||
|
||||
**e2e 实跑(2026-10-20)**:`frontend/e2e/report_flow.py` 12/12 通过。回款统计改为展开目标桶后按**本次业务编号**定位下钻行(不依赖桶累计值,可重复运行);另覆盖多桶同时展开、部门下钻筛选、打印截断提示、部门汇总打印五块结构。
|
||||
|
||||
### 工作台(M4 ⑧,2026-10-19 完成)
|
||||
|
||||
| 文件 | 守什么 | 用例数 |
|
||||
| --- | --- | --- |
|
||||
| `tests/integration/test_dashboard.py` | 权限 403 且文案「无权访问工作台」;总览绩效合计与台账一致 + 非法月份 400;待办(月结提交→待审批命中、回款未指定批次命中、**同步未建显式降级**);风险预警(审批超时命中、**参数关闭后不产生条目**);最近动态来源操作日志;**待办按角色过滤(财务/领导/多角色/admin)+ 真实流转;回款逾期命中与阈值参数生效;终止业务排除;`warnings.total` 不受展示上限截断;回款趋势跨月归属** | 11 |
|
||||
| `tests/unit/test_param_registry.py`(改) | 注册表 19→**22 项**(V10 预警规则三项);编码集合与迁移种子一致 | 改 |
|
||||
|
||||
运行:`uv run pytest tests/integration/test_dashboard.py -q` → **11 passed**(2026-10-19 code-review 修复回归)。
|
||||
|
||||
**实施中发现并修复**:`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` 本地重建。
|
||||
|
||||
## 三、运行记录
|
||||
|
||||
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
||||
@@ -156,12 +198,43 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-19 | M3 定点(calc/settlements) | 34 | ✅ 通过(55s) | **code-review P0+P1 修复中**:经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前锁定/锁定拦截三路径/回填与聚合落库/月末取参(+新增 `test_settlement_helpers` 5 例、`test_calc_helpers` 4 例锁定 `_q2` HALF_UP) |
|
||||
| 2026-10-19 | 全量回归 | 780 | ✅ 780 passed(8:53) | **M3 code-review 21 项修复后全量回归**(P0 算错钱/不可用 6 项 → P1 锁定不变量与双轨漂移 7 项 → P2 健壮性 6 项 → calc_receipt 重构;新增回归约 20 例,含跨会话并入的 #12 聚合基数开关用例)。type-check 绿;e2e calc_settlement_flow 仍待补 |
|
||||
| 2026-10-19 | 拆行收口批次 | 全量 788 | ✅ 788 passed(9:20) | **2026-10-19 业务答复落实完成**:#14 首日取整 10‰(公式段/锚点不变,用例反转);#12 单笔侧开关消费(1=业务员递减计提);**二次分摊拆行**(参与人份额+负责人余额守恒,uk 三列版);权限分层(params 查看放开/写仅 admin);V8 迁移(审批开关默认开/撤试用期参数/增基数开关)。定点:calc 20 / settlements 13(含 #12 开关两档金标准 6.84→24.34)/ businesses 30(保存侧+3)/ formula 35。e2e calc_settlement_flow 交接并行会话 |
|
||||
| 2026-10-19 | **报表统计修复回归** | 全量 847 | ✅ 847 passed(10:13) | **M4 报表 code-review 15 项修复后全量回归**(后端 8 + 前端 7):归属三集合/调整单全量并入/经理个人户拆分/对账出参、分块签字稿导出、导出审计覆盖取数、筛选页眉 ID→名称、月份校验 400、岗位只读选项。报表集成 24 / 报表单元 16 / 月份校验 17。type-check + build 绿;e2e `report_flow.py` 扩至 12 步,待 `E2E_ADMIN_PASSWORD` 实跑 |
|
||||
| 2026-10-19 | 全部 | 852 | ✅ 852 passed(9:17) | **M4 ⑧ 工作台完成**(四区块实时聚合 + ECharts 5.6 前端 + V10 预警参数三项;新增 5 例;参数注册表 19→22;同步模块显式降级)。期间修复:alembic `--sql` 导出 GBK 导致 schema 同步误报 |
|
||||
| 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 | 全量回归 | 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 | 全部 | 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%` 断言未动(那是分数原文与百分比拼装,非说明文案) |
|
||||
|
||||
## 四、测试发现并修复的缺陷
|
||||
|
||||
| 日期 | 缺陷 | 影响 | 处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-10-19 | 分摊负责人份额按余额百分数乘全额导致行合计丢 q2 尾差 | 拆 30%+40% 后负责人 100−70=30% 再乘未 q2 全额(39.1111×0.30=11.7333),行合计 39.10 ≠ 39.11——**每笔分摊账丢 1 分**,守恒破坏(金标准用例 `行合计==业务级全额` 抓住) | 负责人行恒走余额公式(q2 全额 −∑参与人 q2 份额);余额百分数仅作快照展示不参与计算 |
|
||||
| 2026-10-19 | 部门汇总导出/打印只展平成员行 | 团队分成、全公司 5% 块、未分配人员、零成员团队全部缺失——**签字稿合计小于应发**,财务按签字稿复核会漏发经理分成与未分配绩效 | 导出改为单 sheet 分块签字稿(团队/公司/对账/成员/未分配)+ 打印五块;应发公式与对账块由后端 Decimal 计算 |
|
||||
| 2026-10-19 | `_member_rows` 直接 `member_map[pid]` 索引 | 无台账行的营销经理自身挂上级时 KeyError → **部门汇总接口与导出 500**(当月完全无台账也会触发) | 归属只从 member_map/adjustments 生成;`manager_role` 覆盖所有成员引用的上级 |
|
||||
| 2026-10-19 | 生效调整单只并入有台账人员 | 只有调整单、当月无回款的人被静默丢弃——补发/扣减金额不出现,**按报表发放会少发** | 调整单人员补零值成员后参与团队/未分配归属;每人调整净额只计一次 |
|
||||
| 2026-10-19 | 经理个人户成员行取该人全部绩效 | 团队基数只含个人户行,成员行却含普通行——**基数≠成员合计,同一笔业绩在成员行与未分配重复** | 普通行与个人户分行记账;经理成员行只取个人户金额,普通行留在上级团队/未分配 |
|
||||
| 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-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 | 工作台权限收紧后守卫仍回退 `/dashboard` | 普通用户登录/直连受限页被反复重定向到同一受限页 → **无法进入系统**(导航被判定无限重定向) | `router/access.ts` 统一准入与落地页解析(工作台 → 业务数据 → 回款);登录回跳经 `canAccessPath` 校验;菜单/面包屑复用同一判定 |
|
||||
| 2026-10-19 | 部门汇总应发只计一次全公司 5% | 月结每位有聚合行的经理各计一次 5%,签字稿少计 N-1 份;**导出/打印金额小于月结应发** | 团队行增 `has_aggregate/aggregate_amount`(优先冻结聚合行);对账改按聚合额求和,新增「聚合行舍入差」吸收分列 q2 尾差;补「与 `total_payable` 对拍」用例 |
|
||||
| 2026-10-19 | 工作台待办未按角色过滤 | 财务看得到「待领导审核」、领导看得到「待财务复核」,与 FR-034「仅看到与自己职责相关」不符 | `pending_statuses_for_roles` 按角色映射在途节点;repository 参数化 statuses;admin 保留全部在途视角 |
|
||||
| 2026-10-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 |
|
||||
| 2026-10-09 | `PasswordHash.recommended()` 在 pwdlib 0.3.x 推荐 Argon2 而非 BCrypt | 密码算法与规范不符,且随库版本变化——同一批密码可能混用两种算法 | 改为显式 `PasswordHash((BcryptHasher(),))` 钉死算法 |
|
||||
| 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` |
|
||||
@@ -215,7 +288,7 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
||||
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
||||
| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 |
|
||||
| 绩效工资核算(M3-core) | ✅ 已完成 | 2026-10-17 完成(核算/月结/调整单,见运行记录);2026-10-19 code-review 21 项修复并补回归约 20 例。**待补**:e2e `calc_settlement_flow`(台账/月结两页真实浏览器链路);业务答复回填批次(88 会话:首日 10‰/聚合基数开关/二次分摊拆行)落地后按同粒度补测 |
|
||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
||||
| 绩效工资核算(M3-core) | ✅ 已完成 | 2026-10-17 完成(核算/月结/调整单,见运行记录);2026-10-19 code-review 21 项修复并补回归约 20 例;2026-10-20 e2e `calc_settlement_flow` 8/8 实跑通过(台账/月结真实浏览器链路) |
|
||||
| 报表统计 | ✅ 已完成 | 2026-10-19 完成并补齐回归;2026-10-20 e2e `report_flow` 12/12 实跑通过,全量 862 passed |
|
||||
|
||||
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
||||
@@ -190,14 +190,14 @@ def _purge_test_data() -> None:
|
||||
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
|
||||
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
|
||||
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
|
||||
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 25
|
||||
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 28
|
||||
# (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另
|
||||
# 插入两个审批开关的 10-19 生效行 id 23/24,均属种子),删新条目与
|
||||
# 新批次后解析回退到种子值
|
||||
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 25)).scalars())
|
||||
# 插入两个审批开关的 10-19 生效行 id 23/24,V10 插入预警规则参数
|
||||
# id 26/27/28,均属种子),删新条目与新批次后解析回退到种子值
|
||||
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 28)).scalars())
|
||||
if stray_values:
|
||||
stray_batches = {v.batch_id for v in stray_values}
|
||||
session.execute(delete(ParamValue).where(ParamValue.id > 25))
|
||||
session.execute(delete(ParamValue).where(ParamValue.id > 28))
|
||||
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
|
||||
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
|
||||
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
|
||||
|
||||
@@ -388,3 +388,336 @@ class TestModuleAccess:
|
||||
user = make_user([code])
|
||||
user["headers"] = auth_headers(user)
|
||||
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"
|
||||
@@ -0,0 +1,183 @@
|
||||
"""审批单版本号并发防线(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,21 +13,24 @@ audit_service.py「上下文内不得吞掉异常后正常退出」的约定相
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS
|
||||
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS, MODULE_RECEIPT
|
||||
from app.core.db import get_session_factory
|
||||
from app.main import app
|
||||
from app.models import OperationLog
|
||||
from app.services import business_service
|
||||
from app.services import business_service, receipt_service
|
||||
from tests.integration.factories import create_business
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_CUSTOMERS = "/api/v1/base/customers"
|
||||
_RECEIPTS = "/api/v1/business/receipts"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -160,3 +163,94 @@ class TestAuditRetry:
|
||||
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
|
||||
assert _log_count(normal["username"], success=False) == base_fail + 1
|
||||
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
|
||||
@@ -147,9 +147,18 @@ class TestBuiltinProtection:
|
||||
|
||||
|
||||
class TestPermission:
|
||||
"""权限:普通用户全部 403(当前默认管理员专用)。"""
|
||||
"""权限:管理接口普通用户 403;只读选项登录即可(报表筛选依赖)。"""
|
||||
|
||||
def test_普通用户访问岗位列表_403(self, client: TestClient, make_user, auth_headers) -> None:
|
||||
user = make_user([ROLE_USER])
|
||||
headers = auth_headers(user)
|
||||
assert client.get(_ROLES, headers=headers).status_code == 403
|
||||
|
||||
def test_普通用户可读取岗位选项(self, client: TestClient, make_user, auth_headers) -> None:
|
||||
"""财务/领导做报表岗位筛选时不能因管理员接口 403 而失去筛选能力。"""
|
||||
user = make_user([ROLE_USER])
|
||||
headers = auth_headers(user)
|
||||
resp = client.get("/api/v1/base/business-roles/options", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
codes = {row["role_code"] for row in resp.json()["data"]}
|
||||
assert {"salesperson", "sales_manager", "info_officer"} <= codes
|
||||
@@ -452,9 +452,7 @@ class TestEdit:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "含合同负责营销人员" in resp.json()["message"]
|
||||
|
||||
def test_二次分配方案合法比例_保存成功且回显(
|
||||
self, client: TestClient, normal: dict
|
||||
) -> None:
|
||||
def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
|
||||
@@ -376,3 +376,73 @@ class TestAudit:
|
||||
latest = logs[0]
|
||||
assert customer["company_name"] in latest.summary, "摘要应含客户名"
|
||||
assert "→" in latest.summary, "摘要应体现新旧归属走向"
|
||||
|
||||
|
||||
class TestRating:
|
||||
"""评级保存与列表排序 —— 评分是档案属性,只存总分与评级时间。
|
||||
|
||||
守的是:评分只走专属端点(编辑端点不含评分,旁路写评分会绕开口径);
|
||||
分值红线 0-100 在入口拦截;按评分排序时未评分客户必须垫底,不能因
|
||||
MySQL 把 NULL 视为最小值而在升序里压过打了低分的客户。
|
||||
"""
|
||||
|
||||
def test_保存评级_回显总分与时间_且编辑端点不含评分(
|
||||
self, client: TestClient, normal: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
customer = _create_customer(client, headers)
|
||||
assert customer["rating_score"] is None, "新客户应无评分"
|
||||
|
||||
resp = client.put(
|
||||
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 88}, headers=headers
|
||||
)
|
||||
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,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = [row["company_name"] for row in resp.json()["data"]["list"]]
|
||||
return names
|
||||
|
||||
# 降序:高分在前,未评分最后;升序:低分在前,未评分仍垫底
|
||||
desc_names = _order_names("desc")
|
||||
assert desc_names.index(high["company_name"]) < desc_names.index(low["company_name"])
|
||||
assert desc_names[-1] == unrated["company_name"]
|
||||
asc_names = _order_names("asc")
|
||||
assert asc_names.index(low["company_name"]) < asc_names.index(high["company_name"])
|
||||
assert asc_names[-1] == unrated["company_name"], "未评分必须垫底"
|
||||
@@ -0,0 +1,492 @@
|
||||
"""工作台集成测试(M4 ⑧)。
|
||||
|
||||
守的口径:
|
||||
- 权限:普通用户 403 且文案「无权访问工作台」
|
||||
- 总览:绩效合计/人数与台账一致;月份参数非法 400
|
||||
- 待办:月结提交后出现待审批;回款未指定批次可命中;同步未建时显式降级
|
||||
- 风险预警:审批超时命中、参数关闭后不产生条目、阈值参数化生效
|
||||
- 最近动态:来源操作日志(写操作可见)
|
||||
|
||||
待办与预警为实时派生(不落表),测试只验证查询结果,不清理派生数据。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_DASHBOARD = "/api/v1/dashboard"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_SHIPMENTS = "/api/v1/business/shipments"
|
||||
_RECEIPTS = "/api/v1/business/receipts"
|
||||
_CALC = "/api/v1/calc"
|
||||
_SETTLEMENTS = "/api/v1/settlements"
|
||||
_PARAMS = "/api/v1/system/params"
|
||||
|
||||
|
||||
def _role_id(code: str) -> int:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import BusinessRole
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
return int(
|
||||
session.execute(
|
||||
select(BusinessRole.id).where(
|
||||
BusinessRole.role_code == code, BusinessRole.status == 1
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _person(client: TestClient, headers: dict) -> dict:
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json={
|
||||
"personnel_name": f"t_工作台_{uuid.uuid4().hex[:8]}",
|
||||
"business_role_id": _role_id("salesperson"),
|
||||
"salary_standard": "3000.00",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _chain(client: TestClient, headers: dict, *, assign_shipment: bool = True) -> dict:
|
||||
"""客户/业务(100×100,成本 3000)→ 发货(60 天前)→ 回款 → 核算。"""
|
||||
from tests.integration.factories import business_item, business_payload, customer
|
||||
|
||||
cust = customer(client, headers)
|
||||
person = _person(client, headers)
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(
|
||||
cust["id"],
|
||||
owner_personnel_id=person["id"],
|
||||
items=[business_item(cost_amount="3000.00")],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
business = resp.json()["data"]
|
||||
|
||||
ship_date = (date.today() - timedelta(days=60)).isoformat()
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": business["id"],
|
||||
"ship_date": ship_date,
|
||||
"items": [{"product_name": "t_工作台发货", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
shipment = resp.json()["data"]
|
||||
|
||||
payload = {
|
||||
"business_id": business["id"],
|
||||
"receipt_date": date.today().isoformat(),
|
||||
"amount": "10000.00",
|
||||
}
|
||||
if assign_shipment:
|
||||
payload["shipment_id"] = shipment["id"]
|
||||
resp = client.post(_RECEIPTS, json=payload, headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
receipt = resp.json()["data"]
|
||||
if assign_shipment:
|
||||
resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return {"business": business, "person": person, "receipt": receipt}
|
||||
|
||||
|
||||
def _month() -> str:
|
||||
return date.today().strftime("%Y-%m")
|
||||
|
||||
|
||||
def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> dict:
|
||||
params = {"month": month} if month else {}
|
||||
resp = client.get(_DASHBOARD, params=params, headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
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):
|
||||
resp = client.get(_DASHBOARD, headers=normal["headers"])
|
||||
assert resp.status_code == 403
|
||||
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):
|
||||
_chain(client, admin["headers"])
|
||||
data = _dashboard(client, admin["headers"], _month())
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
expected = Decimal(
|
||||
str(
|
||||
session.execute(
|
||||
select(func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)).where(
|
||||
PerformanceLedger.settlement_month == _month(),
|
||||
PerformanceLedger.calc_type == 1,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
)
|
||||
assert Decimal(str(data["overview"]["performance_total"])) == expected
|
||||
assert data["overview"]["person_count"] >= 1
|
||||
assert len(data["overview"]["trend"]) == 6
|
||||
|
||||
resp = client.get(_DASHBOARD, params={"month": "2026-13"}, headers=admin["headers"])
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_待办_待审批与未指定批次与同步降级(client: TestClient, admin: dict):
|
||||
# 先造一笔已核算业务(月结生成要求有可结算记录),再补一笔未指定批次的回款
|
||||
_chain(client, admin["headers"], assign_shipment=True)
|
||||
ctx = _chain(client, admin["headers"], assign_shipment=False)
|
||||
# 月结提交 → 产生待审批单
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
settlement_id = resp.json()["data"]["id"]
|
||||
resp = client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
data = _dashboard(client, admin["headers"])
|
||||
assert data["todos"]["pending_approval_total"] >= 1
|
||||
assert any(item["order_type"] == 2 for item in data["todos"]["pending_approvals"])
|
||||
assert data["todos"]["unassigned_receipt_total"] >= 1
|
||||
assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"]
|
||||
# ⑥ 未建:显式降级
|
||||
assert data["todos"]["sync_available"] is False
|
||||
|
||||
|
||||
def test_预警_审批超时命中且参数关闭后不产生(client: TestClient, admin: dict):
|
||||
_chain(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
|
||||
)
|
||||
settlement_id = resp.json()["data"]["id"]
|
||||
client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
|
||||
|
||||
# 把待审批单提交时间改到 4 天前(阈值默认 3 天)
|
||||
from sqlalchemy import update
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import ApprovalOrder
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.execute(
|
||||
update(ApprovalOrder)
|
||||
.where(ApprovalOrder.status.in_((1, 2)))
|
||||
.values(create_time=datetime.now() - timedelta(days=4))
|
||||
)
|
||||
session.commit()
|
||||
|
||||
data = _dashboard(client, admin["headers"])
|
||||
assert data["warnings"]["enabled"] is True
|
||||
assert any(item["code"] == "approval_timeout" for item in data["warnings"]["items"])
|
||||
|
||||
# 关闭预警开关 → 不产生条目
|
||||
resp = client.post(
|
||||
f"{_PARAMS}/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": "e2e 工作台测试:关闭风险预警",
|
||||
"items": [{"param_code": "warning.enabled", "dimension_value": "", "value": "0"}],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = _dashboard(client, admin["headers"])
|
||||
assert data["warnings"]["enabled"] is False
|
||||
assert data["warnings"]["items"] == []
|
||||
|
||||
|
||||
def test_动态_来源操作日志(client: TestClient, admin: dict):
|
||||
_chain(client, admin["headers"])
|
||||
data = _activities(client, admin["headers"])
|
||||
assert data["list"], "工作台动态为空:写操作应产生操作日志"
|
||||
assert {"module", "action", "summary", "create_time"} <= set(data["list"][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 修复回归)====
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finance(make_user, auth_headers) -> dict:
|
||||
user = make_user(["finance"])
|
||||
user["headers"] = auth_headers(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def leader(make_user, auth_headers) -> dict:
|
||||
user = make_user(["leader"])
|
||||
user["headers"] = auth_headers(user)
|
||||
return user
|
||||
|
||||
|
||||
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.models import ApprovalOrder
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.add(
|
||||
ApprovalOrder(
|
||||
order_no=f"AP{uuid.uuid4().hex[:12]}",
|
||||
order_type=2,
|
||||
ref_id=status,
|
||||
refer_no="t_工作台",
|
||||
status=status,
|
||||
submitter_name="t_admin",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _set_param(client: TestClient, headers: dict, code: str, value: str) -> None:
|
||||
resp = client.post(
|
||||
f"{_PARAMS}/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": f"t_工作台:{code}",
|
||||
"items": [{"param_code": code, "dimension_value": "", "value": value}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def _shipment_scene(client: TestClient, headers: dict, *, days_ago: int) -> tuple[dict, dict]:
|
||||
"""造一笔无回款的发货(用于回款逾期预警)。"""
|
||||
from tests.integration.factories import business_item, business_payload, customer
|
||||
|
||||
person = _person(client, headers)
|
||||
cust = customer(client, headers)
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(
|
||||
cust["id"],
|
||||
owner_personnel_id=person["id"],
|
||||
items=[business_item(cost_amount="3000.00")],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
business = resp.json()["data"]
|
||||
ship_date = (date.today() - timedelta(days=days_ago)).isoformat()
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": business["id"],
|
||||
"ship_date": ship_date,
|
||||
"items": [{"product_name": "t_工作台逾期", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return business, resp.json()["data"]
|
||||
|
||||
|
||||
def test_待办_按角色仅显示可操作节点(
|
||||
client: TestClient, admin: dict, finance: dict, leader: dict, make_user, auth_headers
|
||||
):
|
||||
"""财务只看到待财务复核、领导只看到待领导审核;admin 与多角色看并集。"""
|
||||
_insert_approval(1)
|
||||
_insert_approval(2)
|
||||
|
||||
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 1
|
||||
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 1
|
||||
assert _dashboard(client, admin["headers"])["todos"]["pending_approval_total"] == 2
|
||||
|
||||
both = make_user(["finance", "leader"])
|
||||
both["headers"] = auth_headers(both)
|
||||
assert _dashboard(client, both["headers"])["todos"]["pending_approval_total"] == 2
|
||||
|
||||
finance_orders = {
|
||||
item["order_no"]
|
||||
for item in _dashboard(client, finance["headers"])["todos"]["pending_approvals"]
|
||||
}
|
||||
leader_orders = {
|
||||
item["order_no"]
|
||||
for item in _dashboard(client, leader["headers"])["todos"]["pending_approvals"]
|
||||
}
|
||||
assert finance_orders.isdisjoint(leader_orders)
|
||||
|
||||
|
||||
def test_待办_财务通过后领导出现待办(client: TestClient, admin: dict, finance: dict, leader: dict):
|
||||
"""真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。"""
|
||||
_chain(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
settlement_id = resp.json()["data"]["id"]
|
||||
resp = client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
order = _dashboard(client, admin["headers"])["todos"]["pending_approvals"][0]
|
||||
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 1
|
||||
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 0
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/system/approvals/{order['order_id']}/action",
|
||||
json={"action": 1},
|
||||
headers=finance["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 0
|
||||
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 1
|
||||
|
||||
|
||||
def test_预警_回款逾期命中且阈值参数生效(client: TestClient, admin: dict):
|
||||
"""发货超阈值且无任何回款 → 命中;调大阈值后不再命中。"""
|
||||
_, shipment = _shipment_scene(client, admin["headers"], days_ago=120)
|
||||
|
||||
data = _dashboard(client, admin["headers"])
|
||||
assert any(
|
||||
item["code"] == "receipt_overdue" and item["object_id"] == shipment["id"]
|
||||
for item in data["warnings"]["items"]
|
||||
), "120 天未回款的发货批次未命中预警"
|
||||
|
||||
_set_param(client, admin["headers"], "warning.receipt.overdue.days", "200")
|
||||
data = _dashboard(client, admin["headers"])
|
||||
assert not any(
|
||||
item["code"] == "receipt_overdue" and item["object_id"] == shipment["id"]
|
||||
for item in data["warnings"]["items"]
|
||||
), "阈值调大后仍命中,说明参数未生效"
|
||||
|
||||
|
||||
def test_预警_终止业务不再产生回款逾期(client: TestClient, admin: dict):
|
||||
"""已终止业务不再催收,其未回款发货单不得留在预警列表。"""
|
||||
business, shipment = _shipment_scene(client, admin["headers"], days_ago=120)
|
||||
resp = client.put(
|
||||
f"{_BUSINESSES}/{business['id']}/status",
|
||||
json={"status": 3, "reason": "t_工作台终止"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
data = _dashboard(client, admin["headers"])
|
||||
assert not any(item["object_id"] == shipment["id"] for item in data["warnings"]["items"])
|
||||
assert data["warnings"]["receipt_overdue_total"] == 0
|
||||
|
||||
|
||||
def test_预警_总数不受展示上限截断(client: TestClient, admin: dict, monkeypatch):
|
||||
"""items 只展示前 N 条,徽标 total 必须仍是全量命中数。"""
|
||||
from app.services import dashboard_service
|
||||
|
||||
_shipment_scene(client, admin["headers"], days_ago=120)
|
||||
_shipment_scene(client, admin["headers"], days_ago=120)
|
||||
monkeypatch.setattr(dashboard_service, "_WARNING_LIMIT", 1)
|
||||
|
||||
data = _dashboard(client, admin["headers"])
|
||||
assert data["warnings"]["total"] == 2
|
||||
assert len(data["warnings"]["items"]) == 1
|
||||
assert data["warnings"]["total"] == data["warnings"]["receipt_overdue_total"]
|
||||
|
||||
|
||||
def test_总览_回款趋势按到账月跨月归属(client: TestClient, admin: dict):
|
||||
"""趋势窗口按 receipt_date 范围过滤,月初/月末不得串月。"""
|
||||
from tests.integration.factories import business_item, business_payload, customer
|
||||
|
||||
person = _person(client, admin["headers"])
|
||||
cust = customer(client, admin["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(
|
||||
cust["id"],
|
||||
owner_personnel_id=person["id"],
|
||||
items=[business_item(cost_amount="3000.00")],
|
||||
),
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
business = resp.json()["data"]
|
||||
|
||||
first_of_month = date.today().replace(day=1)
|
||||
previous_last = first_of_month - timedelta(days=1)
|
||||
for receipt_date in (previous_last, first_of_month):
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": business["id"],
|
||||
"receipt_date": receipt_date.isoformat(),
|
||||
"amount": "1000.00",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
trend = {
|
||||
item["month"]: Decimal(str(item["receipt_total"] or 0))
|
||||
for item in _dashboard(client, admin["headers"])["overview"]["trend"]
|
||||
}
|
||||
assert trend[previous_last.strftime("%Y-%m")] == Decimal("1000.00")
|
||||
assert trend[first_of_month.strftime("%Y-%m")] == Decimal("1000.00")
|
||||
@@ -86,10 +86,10 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of:
|
||||
class TestCatalog:
|
||||
"""参数目录:种子完整性与时点化回溯。"""
|
||||
|
||||
def test_目录共19项且覆盖八个分组(self, client: TestClient, admin: dict) -> None:
|
||||
"""V7 起含调整单审批开关(16 计提项 + 3 BOOL 开关)。"""
|
||||
def test_目录共22项且覆盖九个分组(self, client: TestClient, admin: dict) -> None:
|
||||
"""V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。"""
|
||||
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
||||
assert len(items) == 19
|
||||
assert len(items) == 22
|
||||
groups = {item["param_group"] for item in items}
|
||||
assert groups == {
|
||||
"业务员个人户",
|
||||
@@ -100,6 +100,7 @@ class TestCatalog:
|
||||
"产品阈值",
|
||||
"月工资口径",
|
||||
"审批开关",
|
||||
"预警规则",
|
||||
}
|
||||
|
||||
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
|
||||
@@ -325,6 +326,38 @@ class TestPreview:
|
||||
# Fraction 自动约分:4/1000 存储为 1/250,按数值断言
|
||||
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:
|
||||
payload = {
|
||||
"business_role": "sales_manager",
|
||||
@@ -338,7 +371,11 @@ class TestPreview:
|
||||
}
|
||||
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
|
||||
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:
|
||||
payload = {
|
||||
@@ -444,7 +481,7 @@ class TestPermission:
|
||||
headers = auth_headers(user)
|
||||
resp = client.get(_PARAMS, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["data"]) == 19
|
||||
assert len(resp.json()["data"]) == 22
|
||||
|
||||
def test_未登录访问目录_403(self, client: TestClient) -> None:
|
||||
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
|
||||
|
||||
@@ -0,0 +1,806 @@
|
||||
"""报表统计集成测试(M4 ⑤)。
|
||||
|
||||
守的口径(破坏了就是报表数字与台账对不上):
|
||||
- 个人明细与台账逐笔一致;summary 为**全筛选**合计而非当前页合计
|
||||
- 部门汇总团队基数 = 成员绩效合计;12.5%/5% 分成按参数;无成员经理出 0 行
|
||||
- 回款统计 8 个区间桶恒在(空桶为 0);**二次分摊拆行时回款金额只计一次**
|
||||
- 业务统计排除经理聚合行(calc_type=2),绩效不被整团队聚合额污染
|
||||
- 导出 xlsx 可解析且含签字栏;导出行为入操作日志
|
||||
- 权限:普通用户 403 且文案为「无权访问报表统计」
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_REPORTS = "/api/v1/reports"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_SHIPMENTS = "/api/v1/business/shipments"
|
||||
_RECEIPTS = "/api/v1/business/receipts"
|
||||
_CALC = "/api/v1/calc"
|
||||
_SETTLEMENTS = "/api/v1/settlements"
|
||||
|
||||
|
||||
def _role_id(code: str) -> int:
|
||||
"""取内置业务岗位 id(salesperson / sales_manager)。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import BusinessRole
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
return int(
|
||||
session.execute(
|
||||
select(BusinessRole.id).where(
|
||||
BusinessRole.role_code == code, BusinessRole.status == 1
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _person(
|
||||
client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None
|
||||
) -> dict:
|
||||
body = {
|
||||
"personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}",
|
||||
"business_role_id": _role_id(role),
|
||||
"salary_standard": "3000.00",
|
||||
}
|
||||
if manager_id is not None:
|
||||
body["manager_id"] = manager_id
|
||||
resp = client.post(_PERSONNEL, json=body, headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _full_chain(
|
||||
client: TestClient,
|
||||
headers: dict,
|
||||
*,
|
||||
owner_id: int,
|
||||
customer_id: int | None = None,
|
||||
allocations: list[dict] | None = None,
|
||||
) -> tuple[dict, dict]:
|
||||
"""业务(单行 100×100,成本 3000)→ 发货(60 天前)→ 回款 → 核算。"""
|
||||
from tests.integration.factories import business_item, business_payload, customer
|
||||
|
||||
cust = customer(client, headers) if customer_id is None else {"id": customer_id}
|
||||
payload = business_payload(
|
||||
cust["id"],
|
||||
owner_personnel_id=owner_id,
|
||||
items=[business_item(cost_amount="3000.00")],
|
||||
)
|
||||
if allocations:
|
||||
payload["secondary_allocations"] = allocations
|
||||
resp = client.post(_BUSINESSES, json=payload, headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
business = resp.json()["data"]
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
ship_date = (date.today() - timedelta(days=60)).isoformat()
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": business["id"],
|
||||
"ship_date": ship_date,
|
||||
"items": [{"product_name": "t_发货物", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
shipment = resp.json()["data"]
|
||||
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": business["id"],
|
||||
"receipt_date": date.today().isoformat(),
|
||||
"amount": "10000.00",
|
||||
"shipment_id": shipment["id"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
receipt = resp.json()["data"]
|
||||
|
||||
resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return business, receipt
|
||||
|
||||
|
||||
def _month() -> str:
|
||||
from datetime import date
|
||||
|
||||
return date.today().strftime("%Y-%m")
|
||||
|
||||
|
||||
def _ledger_sum(headers: dict, month: str) -> Decimal:
|
||||
"""台账(单笔生效行)同月绩效合计——报表 summary 的对照基准。"""
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
return Decimal(
|
||||
str(
|
||||
session.execute(
|
||||
select(func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)).where(
|
||||
PerformanceLedger.settlement_month == month,
|
||||
PerformanceLedger.calc_type == 1,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_个人明细_与台账一致且summary为全量合计(client: TestClient, admin: dict):
|
||||
"""明细行金额必须等于台账;pageSize=1 时 summary 仍是全部筛选结果之和。"""
|
||||
person = _person(client, admin["headers"])
|
||||
_full_chain(client, admin["headers"], owner_id=person["id"])
|
||||
month = _month()
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/personal-detail",
|
||||
params={"settlement_month": month, "pageSize": 1},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()["data"]
|
||||
assert data["total"] == 1
|
||||
expected = _ledger_sum(admin["headers"], month)
|
||||
assert Decimal(str(data["list"][0]["performance_amount"])) == expected
|
||||
assert Decimal(str(data["summary"]["performance_total"])) == expected
|
||||
|
||||
|
||||
def test_部门汇总_团队基数等于成员绩效且分成按参数(admin, client: TestClient):
|
||||
"""团队基数=成员绩效合计;12.5% 团队分成与 5% 全公司分成分别可核。"""
|
||||
manager = _person(client, admin["headers"], role="sales_manager")
|
||||
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
|
||||
_full_chain(client, admin["headers"], owner_id=salesperson["id"])
|
||||
month = _month()
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": month},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()["data"]
|
||||
|
||||
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
|
||||
ledger_total = _ledger_sum(admin["headers"], month)
|
||||
assert Decimal(str(team["team_base"])) == ledger_total
|
||||
expected_share = (ledger_total * Decimal("0.125")).quantize(Decimal("0.01"))
|
||||
assert Decimal(str(team["team_share_amount"])) == expected_share
|
||||
assert team["member_count"] == 1
|
||||
assert data["company"]["company_base"] == team["team_base"]
|
||||
|
||||
|
||||
def test_部门汇总_无成员经理出零值行(admin, client: TestClient):
|
||||
"""无团队成员 → 0 行展示(FR-030 异常处理)。"""
|
||||
manager = _person(client, admin["headers"], role="sales_manager")
|
||||
month = _month()
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": month},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
team = next((t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None)
|
||||
assert team is not None
|
||||
assert Decimal(str(team["team_base"])) == Decimal("0")
|
||||
assert team["member_count"] == 0
|
||||
|
||||
|
||||
def test_回款统计_空桶恒在且分摊拆行金额只计一次(admin, client: TestClient):
|
||||
"""8 个区间桶恒在;二次分配拆行使台账多行但回款金额不得翻倍。"""
|
||||
owner = _person(client, admin["headers"])
|
||||
participant = _person(client, admin["headers"])
|
||||
_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
owner_id=owner["id"],
|
||||
allocations=[{"personnel_id": participant["id"], "ratio": "30.00"}],
|
||||
)
|
||||
month = _month()
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/payment-stats",
|
||||
params={"settlement_month": month},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()["data"]
|
||||
|
||||
assert len(data["buckets"]) == 8
|
||||
assert [b["code"] for b in data["buckets"]] == [
|
||||
"le0",
|
||||
"1-30",
|
||||
"31-60",
|
||||
"61-90",
|
||||
"91-180",
|
||||
"181-360",
|
||||
"361+",
|
||||
"none",
|
||||
]
|
||||
# 60 天前发货 → 落在 61-90 桶(发货日首日计第 1 天)
|
||||
bucket = next(b for b in data["buckets"] if b["code"] == "61-90")
|
||||
assert Decimal(str(bucket["receipt_amount"])) == Decimal("10000.00")
|
||||
assert bucket["receipt_count"] == 1
|
||||
# 拆行后台账两行,但绩效合计仍等于台账总和
|
||||
assert bucket["ledger_count"] == 2
|
||||
empty = next(b for b in data["buckets"] if b["code"] == "361+")
|
||||
assert Decimal(str(empty["performance_amount"])) == Decimal("0")
|
||||
|
||||
|
||||
def test_业务统计_绩效排除经理聚合行(admin, client: TestClient):
|
||||
"""生成月结后出现 calc_type=2 聚合行,业务统计不得把聚合额算进单业务。"""
|
||||
manager = _person(client, admin["headers"], role="sales_manager")
|
||||
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
|
||||
business, _ = _full_chain(client, admin["headers"], owner_id=salesperson["id"])
|
||||
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/business-stats",
|
||||
params={"keyword": business["business_no"]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
row = resp.json()["data"]["list"][0]
|
||||
assert Decimal(str(row["performance_amount"])) == _ledger_sum(admin["headers"], _month())
|
||||
|
||||
|
||||
def test_导出_excel含签字栏且入操作日志(admin, client: TestClient):
|
||||
"""导出是文件流(非统一信封);模板含复核/签字栏;行为入操作日志。"""
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
person = _person(client, admin["headers"])
|
||||
_full_chain(client, admin["headers"], owner_id=person["id"])
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/personal-detail/export",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"].startswith(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
workbook = load_workbook(BytesIO(resp.content))
|
||||
text = "\n".join(
|
||||
str(cell.value)
|
||||
for row in workbook.active.iter_rows()
|
||||
for cell in row
|
||||
if cell.value is not None
|
||||
)
|
||||
assert "个人绩效工资明细表" in text
|
||||
assert "财务复核" in text
|
||||
assert "领导签字" in text
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import OperationLog
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
log = (
|
||||
session.execute(
|
||||
select(OperationLog)
|
||||
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
|
||||
.order_by(OperationLog.id.desc())
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
assert log is not None
|
||||
|
||||
|
||||
def test_权限_普通用户403文案(admin, normal, client: TestClient):
|
||||
"""报表限管理员/财务/领导;普通用户被后端拦截且文案正确。"""
|
||||
resp = client.get(f"{_REPORTS}/personal-detail", headers=normal["headers"])
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["message"] == "无权访问报表统计"
|
||||
|
||||
|
||||
# ==== 审查回归:归属、调整单、经理个人户、导出审计 ====
|
||||
|
||||
|
||||
def _set_param(client: TestClient, headers: dict, code: str, value: str) -> None:
|
||||
"""立即生效的参数变更(用例不依赖种子默认值,结束显式恢复)。"""
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": f"用例设置 {code}={value}",
|
||||
"items": [{"param_code": code, "dimension_value": "", "value": value}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def _insert_adjustment(person: dict, month: str, amount: str, direction: int = 1) -> None:
|
||||
"""直插生效调整单(status=2):绕开审批链,聚焦报表归属口径。"""
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import AdjustmentOrder
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.add(
|
||||
AdjustmentOrder(
|
||||
order_no=f"TZ{uuid.uuid4().hex[:12]}",
|
||||
settlement_month=month,
|
||||
personnel_id=person["id"],
|
||||
personnel_name=person["personnel_name"],
|
||||
direction=direction,
|
||||
amount=Decimal(amount),
|
||||
reason="t_报表用例",
|
||||
status=2,
|
||||
submitter_name="t_admin",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _insert_manager_personal_ledger(manager: dict, month: str, amount: str) -> None:
|
||||
"""直插一条经理个人户台账行(模拟单笔侧已计提的结果)。"""
|
||||
from datetime import date
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.add(
|
||||
PerformanceLedger(
|
||||
receipt_id=None,
|
||||
calc_version=1,
|
||||
calc_type=1,
|
||||
business_id=0,
|
||||
business_no="t_个人户",
|
||||
personnel_id=manager["id"],
|
||||
personnel_name=manager["personnel_name"],
|
||||
role_code="sales_manager",
|
||||
customer_type="manager_personal",
|
||||
product_category="mixed",
|
||||
receipt_date=date.today(),
|
||||
rate_text="0",
|
||||
performance_amount=Decimal(amount),
|
||||
barter_applied=0,
|
||||
payable_amount=Decimal(amount),
|
||||
settlement_month=month,
|
||||
is_probation=0,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _latest_export_log():
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import OperationLog
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
return (
|
||||
session.execute(
|
||||
select(OperationLog)
|
||||
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
|
||||
.order_by(OperationLog.id.desc())
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def test_部门汇总_无台账经理挂上级不报500(admin, client: TestClient):
|
||||
"""经理 A 无台账且上级是经理 B:不能因 member_map 直接索引而 KeyError。"""
|
||||
manager_b = _person(client, admin["headers"], role="sales_manager")
|
||||
manager_a = _person(client, admin["headers"], role="sales_manager", manager_id=manager_b["id"])
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()["data"]
|
||||
team_a = next(t for t in data["teams"] if t["manager_id"] == manager_a["id"])
|
||||
assert team_a["member_count"] == 0
|
||||
team_b = next(t for t in data["teams"] if t["manager_id"] == manager_b["id"])
|
||||
assert all(m["personnel_id"] != manager_a["id"] for m in team_b["members"])
|
||||
|
||||
|
||||
def test_部门汇总_仅有调整单人员并入对应团队(admin, client: TestClient):
|
||||
"""只有调整单、当月无台账行的人也要出现在团队明细中。"""
|
||||
manager = _person(client, admin["headers"], role="sales_manager")
|
||||
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
|
||||
_insert_adjustment(salesperson, _month(), "500.00")
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()["data"]
|
||||
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
|
||||
member = next(m for m in team["members"] if m["personnel_id"] == salesperson["id"])
|
||||
assert member["member_kind"] == "adjustment"
|
||||
assert Decimal(str(member["performance_sum"])) == Decimal("0")
|
||||
assert Decimal(str(member["adjust_net"])) == Decimal("500.00")
|
||||
assert Decimal(str(team["adjust_net"])) == Decimal("500.00")
|
||||
assert Decimal(str(data["reconciliation"]["adjust_net_total"])) == Decimal("500.00")
|
||||
|
||||
|
||||
def test_部门汇总_仅调整单无经理人员进入未分配(admin, client: TestClient):
|
||||
"""无经理人员的调整单不能被静默丢弃。"""
|
||||
salesperson = _person(client, admin["headers"])
|
||||
_insert_adjustment(salesperson, _month(), "300.00")
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()["data"]
|
||||
row = next(u for u in data["unassigned"] if u["personnel_id"] == salesperson["id"])
|
||||
assert row["member_kind"] == "adjustment"
|
||||
assert Decimal(str(row["adjust_net"])) == Decimal("300.00")
|
||||
|
||||
|
||||
def test_部门汇总_经理个人户计入时基数等于成员合计(admin, client: TestClient):
|
||||
"""开关 1:经理成员行只取个人户金额,团队基数 == 成员绩效合计。"""
|
||||
manager = _person(client, admin["headers"], role="sales_manager")
|
||||
_insert_manager_personal_ledger(manager, _month(), "100.00")
|
||||
_set_param(client, admin["headers"], "manager.personal.included", "1")
|
||||
try:
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
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 sum(
|
||||
(Decimal(str(m["performance_sum"])) for m in team["members"]), Decimal("0")
|
||||
) == Decimal("100.00")
|
||||
assert all(m["member_kind"] == "manager_personal" for m in team["members"])
|
||||
finally:
|
||||
_set_param(client, admin["headers"], "manager.personal.included", "0")
|
||||
|
||||
|
||||
def test_部门汇总_经理个人户关闭时不展示个人户金额(admin, client: TestClient):
|
||||
"""开关 0:个人户金额既不计入团队基数,也不进未分配(与全公司口径一致)。"""
|
||||
manager = _person(client, admin["headers"], role="sales_manager")
|
||||
_insert_manager_personal_ledger(manager, _month(), "100.00")
|
||||
_set_param(client, admin["headers"], "manager.personal.included", "0")
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()["data"]
|
||||
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
|
||||
assert team["member_count"] == 0
|
||||
assert all(u["personnel_id"] != manager["id"] for u in data["unassigned"])
|
||||
|
||||
|
||||
def test_部门汇总_按经理筛选不误判其他团队下属(admin, client: TestClient):
|
||||
"""筛选只影响展示的团队;其他经理的下属不能进入未分配。"""
|
||||
manager_1 = _person(client, admin["headers"], role="sales_manager")
|
||||
manager_2 = _person(client, admin["headers"], role="sales_manager")
|
||||
salesperson_1 = _person(client, admin["headers"], manager_id=manager_1["id"])
|
||||
salesperson_2 = _person(client, admin["headers"], manager_id=manager_2["id"])
|
||||
_full_chain(client, admin["headers"], owner_id=salesperson_1["id"])
|
||||
_full_chain(client, admin["headers"], owner_id=salesperson_2["id"])
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month(), "manager_id": manager_1["id"]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()["data"]
|
||||
assert [t["manager_id"] for t in data["teams"]] == [manager_1["id"]]
|
||||
assert data["unassigned"] == []
|
||||
assert all(
|
||||
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
|
||||
|
||||
|
||||
def test_部门汇总_对账恒等式(admin, client: TestClient):
|
||||
"""未筛选:团队基数 + 未分配绩效 = 全公司基数;应发公式成立。"""
|
||||
manager = _person(client, admin["headers"], role="sales_manager")
|
||||
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
|
||||
unassigned = _person(client, admin["headers"])
|
||||
_full_chain(client, admin["headers"], owner_id=salesperson["id"])
|
||||
_full_chain(client, admin["headers"], owner_id=unassigned["id"])
|
||||
_insert_adjustment(salesperson, _month(), "200.00")
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
reconciliation = resp.json()["data"]["reconciliation"]
|
||||
assert Decimal(str(reconciliation["team_base_total"])) + Decimal(
|
||||
str(reconciliation["unassigned_performance_total"])
|
||||
) == Decimal(str(reconciliation["company_base"]))
|
||||
assert Decimal(str(reconciliation["other_team_base"])) == Decimal("0")
|
||||
assert Decimal(str(reconciliation["payable_total"])) == (
|
||||
Decimal(str(reconciliation["team_member_payable_total"]))
|
||||
+ Decimal(str(reconciliation["unassigned_payable_total"]))
|
||||
+ Decimal(str(reconciliation["aggregate_payable_total"]))
|
||||
+ Decimal(str(reconciliation["adjust_net_total"]))
|
||||
)
|
||||
# 分列合计 + 舍入差 = 聚合额(月结一次舍入口径)
|
||||
assert Decimal(str(reconciliation["aggregate_rounding_diff"])) == (
|
||||
Decimal(str(reconciliation["aggregate_payable_total"]))
|
||||
- Decimal(str(reconciliation["team_share_total"]))
|
||||
- Decimal(str(reconciliation["company_share_total"]))
|
||||
)
|
||||
|
||||
|
||||
def _two_manager_scene(client: TestClient, headers: dict) -> tuple[dict, dict, dict, dict, dict]:
|
||||
"""两位经理各有下属与业务 + 一位零成员经理(无聚合行)。"""
|
||||
manager_1 = _person(client, headers, role="sales_manager")
|
||||
manager_2 = _person(client, headers, role="sales_manager")
|
||||
empty_manager = _person(client, headers, role="sales_manager")
|
||||
salesperson_1 = _person(client, headers, manager_id=manager_1["id"])
|
||||
salesperson_2 = _person(client, headers, manager_id=manager_2["id"])
|
||||
_full_chain(client, headers, owner_id=salesperson_1["id"])
|
||||
_full_chain(client, headers, owner_id=salesperson_2["id"])
|
||||
return manager_1, manager_2, empty_manager, salesperson_1, salesperson_2
|
||||
|
||||
|
||||
def test_部门汇总_应发合计与月结对拍_两位经理(admin, client: TestClient):
|
||||
"""月结每位有聚合行的经理各计一次 5%;签字稿应发必须等于 total_payable。"""
|
||||
_two_manager_scene(client, admin["headers"])
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
before = resp.json()["data"]
|
||||
assert before["company"]["company_share_count"] == 2
|
||||
assert Decimal(str(before["company"]["company_share_total"])) == (
|
||||
Decimal(str(before["company"]["company_share_amount"])) * 2
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
settlement = resp.json()["data"]
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()["data"]
|
||||
teams = {team["manager_id"]: team for team in data["teams"]}
|
||||
frozen_total = sum(
|
||||
(
|
||||
Decimal(str(team["frozen_amount"]))
|
||||
for team in teams.values()
|
||||
if team["frozen_amount"] is not None
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
reconciliation = data["reconciliation"]
|
||||
assert Decimal(str(reconciliation["aggregate_payable_total"])) == frozen_total
|
||||
assert Decimal(str(reconciliation["payable_total"])) == Decimal(
|
||||
str(settlement["total_payable"])
|
||||
)
|
||||
|
||||
|
||||
def test_部门汇总_有调整单时应发等于月结加调整(admin, client: TestClient):
|
||||
"""调整单不进月结 items;报表应发 = 月结总额 + 调整净额。"""
|
||||
_, _, _, salesperson, _ = _two_manager_scene(client, admin["headers"])
|
||||
_insert_adjustment(salesperson, _month(), "500.00")
|
||||
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
settlement = resp.json()["data"]
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
reconciliation = resp.json()["data"]["reconciliation"]
|
||||
assert Decimal(str(reconciliation["adjust_net_total"])) == Decimal("500.00")
|
||||
assert Decimal(str(reconciliation["payable_total"])) == (
|
||||
Decimal(str(settlement["total_payable"])) + Decimal("500.00")
|
||||
)
|
||||
|
||||
|
||||
def test_部门汇总_按经理筛选时5百分比只计该团队(admin, client: TestClient):
|
||||
"""筛选态只汇总展示团队;隐藏团队的金额进 other_*,不参与应发。"""
|
||||
manager_1, _, _, _, _ = _two_manager_scene(client, admin["headers"])
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month(), "manager_id": manager_1["id"]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()["data"]
|
||||
assert data["company"]["company_share_count"] == 1
|
||||
reconciliation = data["reconciliation"]
|
||||
assert Decimal(str(reconciliation["other_team_aggregate_total"])) > 0
|
||||
assert Decimal(str(reconciliation["other_team_company_share_total"])) > 0
|
||||
|
||||
|
||||
def test_导出_筛选页眉ID转名称与中文标签(admin, client: TestClient):
|
||||
"""页眉是签字稿追溯筛选范围的唯一凭据,ID 必须转名称。"""
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import BusinessRole
|
||||
|
||||
person = _person(client, admin["headers"])
|
||||
_full_chain(client, admin["headers"], owner_id=person["id"])
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
role_name = (
|
||||
session.query(BusinessRole.role_name)
|
||||
.filter(BusinessRole.role_code == "salesperson")
|
||||
.scalar()
|
||||
)
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/personal-detail/export",
|
||||
params={
|
||||
"settlement_month": _month(),
|
||||
"personnel_id": person["id"],
|
||||
"role_code": "salesperson",
|
||||
"customer_type": "personal",
|
||||
"product_category": "municipal_water",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
workbook = load_workbook(BytesIO(resp.content))
|
||||
text = "\n".join(
|
||||
str(cell.value)
|
||||
for row in workbook.active.iter_rows()
|
||||
for cell in row
|
||||
if cell.value is not None
|
||||
)
|
||||
assert person["personnel_name"] in text
|
||||
assert f"人员 {person['personnel_name']}(#{person['id']})" in text
|
||||
assert "个人户" in text
|
||||
assert "市政水利" in text
|
||||
assert role_name in text
|
||||
|
||||
|
||||
def test_导出_回款维度sheet透传筛选条件(admin, client: TestClient):
|
||||
"""两个维度 sheet 的页眉不能固定写「全部」。"""
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/payment-stats/export",
|
||||
params={"customer_type": "personal"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
workbook = load_workbook(BytesIO(resp.content))
|
||||
for sheet_name in ("客户类型对比", "产品类别对比"):
|
||||
text = "\n".join(
|
||||
str(cell.value)
|
||||
for row in workbook[sheet_name].iter_rows()
|
||||
for cell in row
|
||||
if cell.value is not None
|
||||
)
|
||||
assert "客户类型 个人户" in text
|
||||
assert "筛选条件:全部" not in text
|
||||
|
||||
|
||||
def test_导出超限_失败审计留痕(admin, client: TestClient, monkeypatch):
|
||||
"""超限 400 发生在审计上下文内,必须留下失败记录。"""
|
||||
from app.services import report_service
|
||||
|
||||
person = _person(client, admin["headers"])
|
||||
_full_chain(client, admin["headers"], owner_id=person["id"])
|
||||
monkeypatch.setattr(report_service, "REPORT_EXPORT_MAX_ROWS", 0)
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/personal-detail/export",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
log = _latest_export_log()
|
||||
assert log is not None
|
||||
assert log.success == 0
|
||||
assert "超过上限" in (log.error_message or "")
|
||||
|
||||
|
||||
def test_导出取数异常_失败审计留痕(admin, client: TestClient, monkeypatch):
|
||||
"""取数阶段的异常同样要留下失败记录。
|
||||
|
||||
TestClient 默认重抛服务端异常,这里直接调 service 层,聚焦审计落库。
|
||||
"""
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import User
|
||||
from app.schemas.report import PersonalDetailQuery
|
||||
from app.services import report_service
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise RuntimeError("t_取数异常")
|
||||
|
||||
monkeypatch.setattr(report_service, "_export_payload", _boom)
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
operator = session.get(User, admin["id"])
|
||||
with pytest.raises(RuntimeError, match="t_取数异常"):
|
||||
report_service.export_report(
|
||||
session,
|
||||
"personal-detail",
|
||||
PersonalDetailQuery(settlement_month=_month()),
|
||||
operator=operator,
|
||||
)
|
||||
log = _latest_export_log()
|
||||
assert log is not None
|
||||
assert log.success == 0
|
||||
# 未预期异常按安全口径收敛为通用文案,摘要仍能定位到报表
|
||||
assert "导出个人绩效工资明细表" in (log.summary or "")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/personal-detail", "/department-summary", "/payment-stats"])
|
||||
def test_非法月份_查询返回400(admin, client: TestClient, path: str):
|
||||
"""非法月份必须是参数错误 400,不能落 500。"""
|
||||
resp = client.get(
|
||||
f"{_REPORTS}{path}",
|
||||
params={"settlement_month": "2026-13"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", ["personal-detail", "department-summary", "payment-stats"])
|
||||
def test_非法月份_导出返回400(admin, client: TestClient, code: str):
|
||||
"""导出手工构造查询模型同样要转 400。"""
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/{code}/export",
|
||||
params={"settlement_month": "2026-13"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
@@ -247,6 +247,36 @@ class TestGuards:
|
||||
class TestDeleteProtection:
|
||||
"""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:
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_allocations(
|
||||
|
||||
@@ -229,6 +229,19 @@ class TestSubmitAndLock:
|
||||
assert out["approval_order_no"] and out["approval_order_no"].startswith("AP")
|
||||
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:
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -603,6 +616,21 @@ class TestAdjustment:
|
||||
out = resp.json()["data"]
|
||||
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:
|
||||
"""守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。"""
|
||||
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
|
||||
@@ -749,3 +777,24 @@ class TestLockInterception:
|
||||
resp = client.post(f"{_CALC}/receipts/{unsettled['id']}/calc", headers=admin["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
|
||||
def test_非法月份_月结生成与调整单均返回400(client: TestClient, admin: dict) -> None:
|
||||
"""月份范围必须在 schema 拦住:`2026-13` 不能传到 monthrange 变成 500。"""
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-13"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/adjustments",
|
||||
json={
|
||||
"settlement_month": "2026-13",
|
||||
"personnel_id": 1,
|
||||
"direction": 1,
|
||||
"amount": "100.00",
|
||||
"reason": "t_非法月份",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
@@ -0,0 +1,72 @@
|
||||
"""审批与方案版本化的纯函数单元测试(秒级,无需容器)。
|
||||
|
||||
守的是「方案内容是否真的变了」这一判定(``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
|
||||
@@ -0,0 +1,38 @@
|
||||
"""审批管理入参校验(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
|
||||
@@ -96,6 +96,20 @@ class TestNamingConvention:
|
||||
for fk in table.foreign_keys:
|
||||
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:
|
||||
"""规范红线要求中文 COMMENT。"""
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""月份校验单元测试。
|
||||
|
||||
守的口径:报表、月结、调整单入口都必须拦住 `2026-13`、`0000-01` 这类
|
||||
非法月份。只校验长度会让 ValueError 一路传到 `calendar.monthrange`,
|
||||
最终变成 500 而不是参数错误 400。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.month import parse_month, validate_month
|
||||
from app.schemas.report import DepartmentSummaryQuery, PersonalDetailQuery
|
||||
from app.schemas.settlement import AdjustmentCreateIn, SettlementGenerateIn
|
||||
|
||||
|
||||
class Test月份校验:
|
||||
"""合法月份通过,非法月份在入口即拒绝。"""
|
||||
|
||||
@pytest.mark.parametrize("month", ["2026-01", "2026-12", "2000-06"])
|
||||
def test_合法月份_通过(self, month: str):
|
||||
assert validate_month(month) == month
|
||||
year, mon = parse_month(month)
|
||||
assert (year, mon) == (int(month[:4]), int(month[5:7]))
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"month",
|
||||
["2026-13", "2026-00", "2026-1", "2026-1a", "0000-01", "abc", "2026/10"],
|
||||
)
|
||||
def test_非法月份_抛ValueError(self, month: str):
|
||||
with pytest.raises(ValueError):
|
||||
validate_month(month)
|
||||
|
||||
@pytest.mark.parametrize("month", ["2026-13", "2026-00", "0000-01"])
|
||||
def test_月结与调整单_schema拒绝非法月份(self, month: str):
|
||||
with pytest.raises(ValidationError):
|
||||
SettlementGenerateIn(month=month)
|
||||
with pytest.raises(ValidationError):
|
||||
AdjustmentCreateIn(
|
||||
settlement_month=month,
|
||||
personnel_id=1,
|
||||
direction=1,
|
||||
amount="100.00",
|
||||
reason="测试",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("month", ["2026-13", "2026-00", "0000-01"])
|
||||
def test_报表查询_schema拒绝非法月份(self, month: str):
|
||||
with pytest.raises(ValidationError):
|
||||
DepartmentSummaryQuery(settlement_month=month)
|
||||
with pytest.raises(ValidationError):
|
||||
PersonalDetailQuery(settlement_month=month)
|
||||
|
||||
def test_报表可选月份_空串规范为None(self):
|
||||
"""axios 未剔除空串时不能误报格式错误。"""
|
||||
assert PersonalDetailQuery(settlement_month="").settlement_month is None
|
||||
@@ -18,8 +18,9 @@ from app.services.param_registry import (
|
||||
validate_and_normalize,
|
||||
)
|
||||
|
||||
# 与 db/migration 迁移种子保持一致的 19 项编码(V3 十六项 + V6 审批开关两项
|
||||
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12)
|
||||
# 与 db/migration 迁移种子保持一致的 22 项编码(V3 十六项 + V6 审批开关两项
|
||||
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12;
|
||||
# V10 增工作台预警规则三项 #10)
|
||||
EXPECTED_CODES = {
|
||||
"salesperson.rate.start",
|
||||
"salesperson.rate.slope",
|
||||
@@ -40,14 +41,17 @@ EXPECTED_CODES = {
|
||||
"approval.monthly_settlement.enabled",
|
||||
"approval.adjustment.enabled",
|
||||
"manager.personal.included",
|
||||
"warning.enabled",
|
||||
"warning.receipt.overdue.days",
|
||||
"warning.approval.timeout.days",
|
||||
}
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
"""注册表元数据完整性(与迁移种子同源)。"""
|
||||
|
||||
def test_注册表共19项(self) -> None:
|
||||
assert len(PARAM_REGISTRY) == 19
|
||||
def test_注册表共22项(self) -> None:
|
||||
assert len(PARAM_REGISTRY) == 22
|
||||
|
||||
def test_编码集合与迁移种子一致(self) -> None:
|
||||
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"""报表辅助纯函数与 Excel 模板构建单元测试(M4 ⑤)。
|
||||
|
||||
这组用例守的是「不测会静默出错」的三类点:
|
||||
- 区间分桶边界错一档 → 金额进错桶,但报表仍然「能打开」,没人会发现
|
||||
- 标签映射漏一个枚举值 → 报表少一类数据或显示英文编码
|
||||
- 导出模板缺签字栏 / 金额被转 float / 文本公式注入 → 交给财务的签字稿失真
|
||||
"""
|
||||
|
||||
import io
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from app.core import report_meta
|
||||
from app.core.constants import CUSTOMER_TYPES, PRODUCT_CATEGORIES
|
||||
from app.services import report_export
|
||||
|
||||
|
||||
class Test区间分桶:
|
||||
"""回款天数 → 8 档区间(含 ≤0 与无天数两个兜底桶)。"""
|
||||
|
||||
def test_边界值全部落入正确区间(self):
|
||||
cases = [
|
||||
(None, "none"),
|
||||
(-5, "le0"),
|
||||
(0, "le0"),
|
||||
(1, "1-30"),
|
||||
(30, "1-30"),
|
||||
(31, "31-60"),
|
||||
(60, "31-60"),
|
||||
(61, "61-90"),
|
||||
(90, "61-90"),
|
||||
(91, "91-180"),
|
||||
(180, "91-180"),
|
||||
(181, "181-360"),
|
||||
(360, "181-360"),
|
||||
(361, "361+"),
|
||||
(1000, "361+"),
|
||||
]
|
||||
for days, code in cases:
|
||||
assert report_meta.bucket_of(days).code == code, f"days={days}"
|
||||
|
||||
def test_八档且编码唯一(self):
|
||||
buckets = report_meta.get_payment_day_buckets()
|
||||
assert len(buckets) == 8
|
||||
assert len({b.code for b in buckets}) == 8
|
||||
|
||||
|
||||
class Test标签覆盖:
|
||||
"""标签键集合必须覆盖业务常量——防常量新增后报表静默少算/显示编码。"""
|
||||
|
||||
def test_客户类型标签覆盖全部类型(self):
|
||||
assert set(report_meta.CUSTOMER_TYPE_LABELS) >= CUSTOMER_TYPES
|
||||
|
||||
def test_产品类别标签覆盖常量与混合业务(self):
|
||||
assert set(report_meta.PRODUCT_CATEGORY_LABELS) >= PRODUCT_CATEGORIES
|
||||
assert "mixed" in report_meta.PRODUCT_CATEGORY_LABELS
|
||||
|
||||
|
||||
def _build(code: str, rows: list[dict], buckets: dict | None = None) -> bytes:
|
||||
return report_export.build_workbook(
|
||||
code=code,
|
||||
title="测试报表",
|
||||
filters="统计月份 2026-10",
|
||||
rows=rows,
|
||||
summary=None,
|
||||
buckets=buckets,
|
||||
template_version="V1.0",
|
||||
operator_name="admin",
|
||||
company_name="衍水集团",
|
||||
)
|
||||
|
||||
|
||||
def _sheet_text(content: bytes) -> str:
|
||||
workbook = load_workbook(io.BytesIO(content))
|
||||
return "\n".join(
|
||||
str(cell.value)
|
||||
for row in workbook.active.iter_rows()
|
||||
for cell in row
|
||||
if cell.value is not None
|
||||
)
|
||||
|
||||
|
||||
def _personal_row(**overrides) -> dict:
|
||||
row = {
|
||||
"business_no": "YW001",
|
||||
"ship_date": date(2026, 10, 1),
|
||||
"receipt_date": date(2026, 10, 20),
|
||||
"payment_days": 20,
|
||||
"product_category": "municipal_water",
|
||||
"customer_type": "personal",
|
||||
"role_name": "营销员",
|
||||
"rate_text": "88/9000",
|
||||
"weight_r": 100,
|
||||
"performance_amount": Decimal("39.11"),
|
||||
"payable_amount": Decimal("39.11"),
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
class TestExcel模板:
|
||||
"""导出稿是交给财务复核与领导签字的,结构缺失等同于没有模板。"""
|
||||
|
||||
def test_页眉标题签字栏齐全(self):
|
||||
text = _sheet_text(_build("personal-detail", [_personal_row()]))
|
||||
assert "衍水集团" in text
|
||||
assert "测试报表" in text
|
||||
assert "统计月份 2026-10" in text
|
||||
assert "模板版本:V1.0" in text
|
||||
assert "财务复核" in text
|
||||
assert "领导签字" in text
|
||||
|
||||
def test_空数据不炸且给提示行(self):
|
||||
text = _sheet_text(_build("personal-detail", []))
|
||||
assert "本筛选条件无数据" in text
|
||||
assert "财务复核" in text
|
||||
|
||||
def test_金额为数值单元格且两位格式(self):
|
||||
# openpyxl 写入 Decimal 后读回为 int/float(Excel 单元格本身是数值),
|
||||
# 这里断言「是数值而非文本」+ 显示格式;计算精度在服务层 Decimal 侧保证
|
||||
content = _build("personal-detail", [_personal_row()])
|
||||
workbook = load_workbook(io.BytesIO(content))
|
||||
sheet = workbook.active
|
||||
amount_cells = [
|
||||
cell
|
||||
for row in sheet.iter_rows()
|
||||
for cell in row
|
||||
if isinstance(cell.value, (int, float)) and Decimal(str(cell.value)) == Decimal("39.11")
|
||||
]
|
||||
assert amount_cells, "未找到金额数值单元格(Decimal 被转成了文本?)"
|
||||
assert amount_cells[0].number_format == "#,##0.00"
|
||||
|
||||
def test_合计行等于明细之和且标签只出现一次(self):
|
||||
rows = [
|
||||
_personal_row(performance_amount=Decimal("10.00"), payable_amount=Decimal("10.00")),
|
||||
_personal_row(performance_amount=Decimal("5.50"), payable_amount=Decimal("5.50")),
|
||||
]
|
||||
content = _build("personal-detail", rows)
|
||||
workbook = load_workbook(io.BytesIO(content))
|
||||
cells = [cell for row in workbook.active.iter_rows() for cell in row]
|
||||
values = [
|
||||
Decimal(str(cell.value)) for cell in cells if isinstance(cell.value, (int, float))
|
||||
]
|
||||
assert Decimal("15.50") in values
|
||||
# 「合计」标签只写一次(写满每个文本列是回归缺陷)
|
||||
assert sum(1 for cell in cells if cell.value == "合计") == 1
|
||||
|
||||
def test_文本公式注入被前置单引号(self):
|
||||
evil = '=HYPERLINK("http://evil")'
|
||||
content = _build("personal-detail", [_personal_row(business_no=evil)])
|
||||
workbook = load_workbook(io.BytesIO(content))
|
||||
values = [
|
||||
cell.value
|
||||
for row in workbook.active.iter_rows()
|
||||
for cell in row
|
||||
if isinstance(cell.value, str)
|
||||
]
|
||||
assert any(value.startswith("'=") for value in values), "公式注入未转义"
|
||||
|
||||
def test_回款统计附两个维度对比sheet(self):
|
||||
buckets = {
|
||||
"by_customer_type": [
|
||||
{
|
||||
"label": "个人户",
|
||||
"receipt_amount": Decimal("100.00"),
|
||||
"performance_amount": Decimal("1.00"),
|
||||
"payable_amount": Decimal("1.00"),
|
||||
"ledger_count": 1,
|
||||
}
|
||||
],
|
||||
"by_product_category": [],
|
||||
}
|
||||
content = _build(
|
||||
"payment-stats",
|
||||
[
|
||||
{
|
||||
"label": "1-30 天",
|
||||
"receipt_amount": Decimal("100.00"),
|
||||
"receipt_count": 1,
|
||||
"performance_amount": Decimal("1.00"),
|
||||
"payable_amount": Decimal("1.00"),
|
||||
"ledger_count": 1,
|
||||
}
|
||||
],
|
||||
buckets=buckets,
|
||||
)
|
||||
workbook = load_workbook(io.BytesIO(content))
|
||||
assert workbook.sheetnames == ["回款统计", "客户类型对比", "产品类别对比"]
|
||||
|
||||
def test_业务统计状态转中文(self):
|
||||
rows = [
|
||||
{
|
||||
"business_no": "YW001",
|
||||
"business_date": date(2026, 10, 1),
|
||||
"customer_name": "测试客户",
|
||||
"owner_personnel_name": "张三",
|
||||
"status": 2,
|
||||
"total_sale_amount": Decimal("100.00"),
|
||||
"total_ship_amount": Decimal("100.00"),
|
||||
"total_received_amount": Decimal("100.00"),
|
||||
"performance_amount": Decimal("1.00"),
|
||||
"payable_amount": Decimal("1.00"),
|
||||
}
|
||||
]
|
||||
text = _sheet_text(_build("business-stats", rows))
|
||||
assert "已完成" in text
|
||||
|
||||
|
||||
def _member(
|
||||
personnel_id: int,
|
||||
name: str,
|
||||
*,
|
||||
kind: str = "regular",
|
||||
performance: str = "0.00",
|
||||
payable: str = "0.00",
|
||||
adjust: str = "0.00",
|
||||
reason: str | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"personnel_id": personnel_id,
|
||||
"personnel_name": name,
|
||||
"role_code": "salesperson",
|
||||
"is_probation": 0,
|
||||
"member_kind": kind,
|
||||
"unassigned_reason": reason,
|
||||
"performance_sum": Decimal(performance),
|
||||
"payable_sum": Decimal(payable),
|
||||
"adjust_extra": Decimal(adjust),
|
||||
"adjust_deduct": Decimal("0"),
|
||||
"adjust_net": Decimal(adjust),
|
||||
"net_total": Decimal(payable) + Decimal(adjust),
|
||||
"ledger_ids": [],
|
||||
}
|
||||
|
||||
|
||||
def _department_data(
|
||||
team_text: str = "12.5",
|
||||
company_text: str = "5",
|
||||
*,
|
||||
second_has_aggregate: bool = False,
|
||||
) -> dict:
|
||||
"""一份含成员、零成员团队与未分配人员的部门汇总出参。
|
||||
|
||||
``second_has_aggregate`` 打开后两位经理都有月结聚合行,用于验证
|
||||
全公司 5% 按人数累计(零成员团队默认不发 5%)。
|
||||
"""
|
||||
second_aggregate = Decimal("600.00") if second_has_aggregate else Decimal("0")
|
||||
share_count = 2 if second_has_aggregate else 1
|
||||
aggregate_total = Decimal("1850.00") + second_aggregate
|
||||
payable_total = Decimal("10000.00") + Decimal("2000.00") + aggregate_total
|
||||
return {
|
||||
"settlement_month": "2026-10",
|
||||
"params": {
|
||||
"team_text": team_text,
|
||||
"company_text": company_text,
|
||||
"manager_personal_included": 0,
|
||||
"params_as_of": "2026-10-31",
|
||||
},
|
||||
"teams": [
|
||||
{
|
||||
"manager_id": 1,
|
||||
"manager_name": "张经理",
|
||||
"manager_status": 1,
|
||||
"member_count": 1,
|
||||
"team_base": Decimal("10000.00"),
|
||||
"team_share_amount": Decimal("1250.00"),
|
||||
"company_share_amount": Decimal("600.00"),
|
||||
"has_aggregate": True,
|
||||
"aggregate_amount": Decimal("1850.00"),
|
||||
"team_subtotal": Decimal("1850.00"),
|
||||
"company_base_ratio": Decimal("0.8333"),
|
||||
"adjust_extra": Decimal("0"),
|
||||
"adjust_deduct": Decimal("0"),
|
||||
"adjust_net": Decimal("0"),
|
||||
"net_total": Decimal("1850.00"),
|
||||
"frozen_amount": None,
|
||||
"aggregate_ledger_id": None,
|
||||
"members": [
|
||||
_member(
|
||||
2,
|
||||
"李业务",
|
||||
performance="10000.00",
|
||||
payable="10000.00",
|
||||
)
|
||||
],
|
||||
},
|
||||
{
|
||||
"manager_id": 3,
|
||||
"manager_name": "王经理",
|
||||
"manager_status": 1,
|
||||
"member_count": 0,
|
||||
"team_base": Decimal("0"),
|
||||
"team_share_amount": Decimal("0"),
|
||||
"company_share_amount": second_aggregate,
|
||||
"has_aggregate": second_has_aggregate,
|
||||
"aggregate_amount": second_aggregate,
|
||||
"team_subtotal": second_aggregate,
|
||||
"company_base_ratio": Decimal("0"),
|
||||
"adjust_extra": Decimal("0"),
|
||||
"adjust_deduct": Decimal("0"),
|
||||
"adjust_net": Decimal("0"),
|
||||
"net_total": second_aggregate,
|
||||
"frozen_amount": None,
|
||||
"aggregate_ledger_id": None,
|
||||
"members": [],
|
||||
},
|
||||
],
|
||||
"unassigned": [
|
||||
_member(
|
||||
4,
|
||||
"赵未分配",
|
||||
performance="2000.00",
|
||||
payable="2000.00",
|
||||
reason="无经理或上级非营销经理",
|
||||
)
|
||||
],
|
||||
"company": {
|
||||
"team_base_total": Decimal("10000.00"),
|
||||
"company_base": Decimal("12000.00"),
|
||||
"company_share_pct": company_text,
|
||||
"company_share_amount": Decimal("600.00"),
|
||||
"company_share_count": share_count,
|
||||
"company_share_total": Decimal("600.00") * share_count,
|
||||
},
|
||||
"reconciliation": {
|
||||
"team_member_payable_total": Decimal("10000.00"),
|
||||
"unassigned_payable_total": Decimal("2000.00"),
|
||||
"team_share_total": Decimal("1250.00"),
|
||||
"company_share_total": Decimal("600.00") * share_count,
|
||||
"aggregate_payable_total": aggregate_total,
|
||||
"aggregate_rounding_diff": Decimal("0"),
|
||||
"adjust_net_total": Decimal("0"),
|
||||
"payable_total": payable_total,
|
||||
"team_base_total": Decimal("10000.00"),
|
||||
"unassigned_performance_total": Decimal("2000.00"),
|
||||
"other_team_base": Decimal("0"),
|
||||
"other_team_member_payable_total": Decimal("0"),
|
||||
"other_team_share_total": Decimal("0"),
|
||||
"other_team_company_share_total": Decimal("0"),
|
||||
"other_team_aggregate_total": Decimal("0"),
|
||||
"other_adjust_net_total": Decimal("0"),
|
||||
"company_base": Decimal("12000.00"),
|
||||
},
|
||||
"totals": {},
|
||||
}
|
||||
|
||||
|
||||
def _build_department(data: dict) -> bytes:
|
||||
return report_export.build_workbook(
|
||||
code="department-summary",
|
||||
title="部门绩效工资汇总表",
|
||||
filters="统计月份 2026-10",
|
||||
rows=[],
|
||||
summary=None,
|
||||
buckets=None,
|
||||
department=data,
|
||||
template_version="V1.2",
|
||||
operator_name="admin",
|
||||
company_name="衍水集团",
|
||||
)
|
||||
|
||||
|
||||
class Test部门汇总签字稿:
|
||||
"""签字稿少了团队分成/5% 块/未分配/零成员团队,合计就会小于应发。"""
|
||||
|
||||
def test_五块顺序完整(self):
|
||||
text = _sheet_text(_build_department(_department_data()))
|
||||
titles = [
|
||||
"一、团队分成汇总",
|
||||
"二、全公司分成(每位有聚合行",
|
||||
"三、对账汇总",
|
||||
"四、团队成员明细",
|
||||
"五、未分配人员明细",
|
||||
]
|
||||
positions = [text.index(title) for title in titles]
|
||||
assert positions == sorted(positions), "签字稿块顺序错乱"
|
||||
assert "财务复核" in text and "领导签字" in text
|
||||
|
||||
def test_零成员团队与未分配行存在(self):
|
||||
text = _sheet_text(_build_department(_department_data()))
|
||||
assert "王经理" in text
|
||||
assert "赵未分配" in text
|
||||
assert "无经理或上级非营销经理" in text
|
||||
|
||||
def test_全公司分成按计入次数累计(self):
|
||||
"""两位有聚合行的经理各计一次 5%;零成员团队不产生分成。"""
|
||||
one = _department_data()
|
||||
text = _sheet_text(_build_department(one))
|
||||
assert text.count("全公司分成(每位有聚合行") == 1
|
||||
assert "共 1 次" in text
|
||||
assert "13850" in text
|
||||
|
||||
two = _department_data(second_has_aggregate=True)
|
||||
text = _sheet_text(_build_department(two))
|
||||
assert "共 2 次" in text
|
||||
# 团队成员应付 10000 + 未分配 2000 + 聚合 2450
|
||||
assert "14450" in text
|
||||
|
||||
def test_应发合计取对账口径(self):
|
||||
text = _sheet_text(_build_department(_department_data()))
|
||||
assert "应发合计" in text
|
||||
# Excel 数值单元格读回为 int/float,金额断言只看数值本身
|
||||
assert "13850" in text
|
||||
|
||||
def test_聚合行舍入差单列(self):
|
||||
data = _department_data()
|
||||
data["reconciliation"]["aggregate_rounding_diff"] = Decimal("-0.02")
|
||||
text = _sheet_text(_build_department(data))
|
||||
assert "聚合行舍入差" in text
|
||||
assert "-0.02" in text
|
||||
|
||||
def test_比例标题参数化(self):
|
||||
text = _sheet_text(_build_department(_department_data("20", "6")))
|
||||
assert "团队分成(20%)" in text
|
||||
assert "6%" in text
|
||||
assert "12.5%" not in text
|
||||
assert "5%" not in text.replace("6%", "")
|
||||
+42
-23
@@ -366,6 +366,8 @@ CREATE TABLE customers (
|
||||
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
|
||||
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
|
||||
remark VARCHAR(255) COMMENT '备注',
|
||||
rating_score TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)',
|
||||
rating_time DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||
@@ -437,25 +439,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);
|
||||
|
||||
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),
|
||||
(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, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3),
|
||||
(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),
|
||||
(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),
|
||||
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8),
|
||||
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9),
|
||||
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10),
|
||||
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11),
|
||||
(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),
|
||||
(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),
|
||||
(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, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18),
|
||||
(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, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20);
|
||||
(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),
|
||||
(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。', 3),
|
||||
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。', 4),
|
||||
(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),
|
||||
(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),
|
||||
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款到账即按这个比例计提绩效,不必等到全额回款。', 9),
|
||||
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。', 10),
|
||||
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。', 11),
|
||||
(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, '选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。', 12),
|
||||
(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),
|
||||
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。', 15),
|
||||
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。', 17),
|
||||
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。', 18),
|
||||
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。', 19),
|
||||
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。', 20),
|
||||
-- 预警规则参数:工作台预警阈值,可按业务需要调整
|
||||
(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。', 21),
|
||||
(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。', 22),
|
||||
(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。', 23);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
|
||||
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
||||
@@ -481,7 +487,11 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t
|
||||
(20, 17, '', '2026-01-01', '1', 1),
|
||||
(21, 18, '', '2026-01-01', '1', 1),
|
||||
(22, 19, '', '2026-01-01', '0', 1),
|
||||
(25, 20, '', '2026-01-01', '0', 1);
|
||||
(25, 20, '', '2026-01-01', '0', 1),
|
||||
-- 预警规则参数生效值(M4 ⑧)
|
||||
(26, 21, '', '2026-10-19', '1', 1),
|
||||
(27, 22, '', '2026-10-19', '90', 1),
|
||||
(28, 23, '', '2026-10-19', '3', 1);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
|
||||
@@ -705,6 +715,8 @@ CREATE TABLE approval_orders (
|
||||
order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算(预留)',
|
||||
ref_id BIGINT UNSIGNED NOT NULL COMMENT '引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)',
|
||||
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',
|
||||
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
|
||||
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
|
||||
@@ -712,11 +724,14 @@ CREATE TABLE approval_orders (
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_approval_orders PRIMARY KEY (id),
|
||||
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
|
||||
)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_ref ON approval_orders (order_type, ref_id);
|
||||
|
||||
CREATE TABLE approval_steps (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)',
|
||||
@@ -736,21 +751,25 @@ CREATE INDEX idx_approval_steps_order_id ON approval_steps (order_id);
|
||||
CREATE TABLE business_secondary_allocations (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
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_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)',
|
||||
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,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_business_secondary_allocations PRIMARY KEY (id),
|
||||
CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id),
|
||||
CONSTRAINT uk_business_secondary_allocations_business_version_personnel UNIQUE (business_id, version_no, personnel_id),
|
||||
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
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
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_business_active ON business_secondary_allocations (business_id, is_active);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 绩效核算台账/月结/调整单(V7 迁移;append-only 版本行;通过即锁定走调整单)
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -874,4 +893,4 @@ CREATE TABLE alembic_version (
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||
|
||||
INSERT INTO alembic_version (version_num) VALUES ('ba0217f5091b');
|
||||
INSERT INTO alembic_version (version_num) VALUES ('c3a9e1d7b2f4');
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 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`;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Running upgrade ba0217f5091b -> 90653814a421
|
||||
|
||||
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, status) VALUES (21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21, 1);
|
||||
|
||||
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, status) VALUES (22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22, 1);
|
||||
|
||||
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, status) VALUES (23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23, 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (26, 21, '', '2026-10-19', '1', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (27, 22, '', '2026-10-19', '90', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (28, 23, '', '2026-10-19', '3', 1);
|
||||
|
||||
UPDATE alembic_version SET version_num='90653814a421' WHERE alembic_version.version_num = 'ba0217f5091b';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 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';
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 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';
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
# T+ 12.3 字段映射草案(v0.1,2026-10-20)
|
||||
|
||||
> 本文仅记录字段级事实与页面索引,不复制厂商文档正文。
|
||||
> 基线:`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`(75 项)+ v1.3 IT 18 项 + 《T+12.3数据字典》。
|
||||
|
||||
## 0. 结论摘要
|
||||
|
||||
- 覆盖:清单 **75/75** 项均已给出 T+ 侧候选字段与系统目标列;
|
||||
- 状态分布:候选待确认 42 / 字典明确 23 / 未检出 9 / 需 IT 1;
|
||||
- 数据字典无法让 IT 18 项中的任何一项完全闭环;但 9 项可改写为「候选字段选择题」,6 项部分可答,2 项纯 IT,1 项转业务。
|
||||
- 收敛后:**13 项 IT 待确认 + 1 组业务裁决**(见第 12 节)。
|
||||
- 关键缺口:人员工号/入职日期/试用期无字段、客户 status 缺失、回款 payment_method/confirm_status 待补、明细行无显式行号、无源端更新时间与负向事件模型。
|
||||
|
||||
## 1. 编制说明与证据等级
|
||||
|
||||
| 标记 | 含义 |
|
||||
| --- | --- |
|
||||
| `D` | 字典明确 |
|
||||
| `D?` | 候选待确认 |
|
||||
| `IT` | 需 IT |
|
||||
| `B` | 需业务 |
|
||||
| `S` | 系统自维护 |
|
||||
| `N` | 未检出 |
|
||||
|
||||
- `D` 仅用于字段存在、类型明确且语义直接匹配的项;涉及「取哪个字段 / 业务口径」一律标 `D?` 或 `IT`。
|
||||
- 字典来源为 T+ 12.3 SchemaDoc;同一字段可能标注 `T+12.1/12.2/12.3` 版本,映射按 12.3 可用为准。
|
||||
|
||||
## 2. 公共字段与同步元数据
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | T+ 原始单据号 / 档案编码 | 各表 code(人员/存货/客户/订单/出库/收付款单均有) | nvarchar(30~64) | — | personnel/products/customers/businesses/shipments/receipts.source_no | TRIM;作废重开是否换号待 IT(#4) | 必须 | D? | AA_Person.code、AA_Inventory.code、AA_Partner.code、SA_SaleOrder.code、SA_SaleDelivery.code、ARAP_ReceivePayment.code |
|
||||
| 2 | T+ 系统内部标识 | 各表 id(整型主键) | int(4) | — | 六表 t_plus_id | int → 字符串存储;辅助对账/改名追溯 | 建议 | D | 各表 id(主键) |
|
||||
|
||||
系统侧六张主表已预留 `source_no / t_plus_id / sync_batch_no / synced_at`;`source_no` 当前为普通索引,IT 确认幂等键后升级唯一约束。
|
||||
|
||||
## 3. 人员档案
|
||||
|
||||
人员主档;隶属关系需经部门树/部门负责人推断,T+ 无直属上级字段。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 人员唯一编码(工号)★ | AA_Person.code | nvarchar(32) | — | personnel.source_no(建议补 personnel_code) | TRIM;编码优先、姓名辅助核对;是否即工号待 IT(#19) | 必须 | D? | AA_Person.code(编号) |
|
||||
| 2 | 姓名 | AA_Person.name | nvarchar(200) | — | personnel.personnel_name | TRIM;仅辅助核对,不作唯一键 | 必须 | D | AA_Person.name(名称) |
|
||||
| 3 | 在职状态 | AA_Person.disabled | tinyint(1) | 0 启用 / 1 停用(候选) | personnel.status(1 在职 / 0 离职) | disabled=1 → 0;无离职日期字段,离职时间需 IT 确认 | 必须 | D? | AA_Person.disabled(停用) |
|
||||
| 4 | 是否试用期 | 未检出 | — | — | personnel.is_probation | T+ 人员档案无试用期字段;系统内人工维护或按入职日期规则推导 | 必须 | N | AA_Person 全表检索无 probation/试用 字段 |
|
||||
| 5 | 入职日期 | 未检出(仅 madeDate 制单日期、birthday 出生日期) | — | — | personnel.hire_date | T+ 无入职日期字段;确认是否有自定义项承载,否则人工维护 | 必须 | N | AA_Person 全表检索无 hire/entry/入职 字段 |
|
||||
| 6 | 联系电话 | AA_Person.mobilePhoneNo / officePhoneNo | nvarchar(20) / nvarchar(100) | — | personnel.phone | 优先 mobilePhoneNo,空则 officePhoneNo;日志脱敏 | 建议 | D | AA_Person.mobilePhoneNo(手机)、officePhoneNo(办公电话) |
|
||||
| 7 | 业务员 ↔ 营销经理隶属关系 | AA_Person.iddepartment → AA_Department.idparent / iddirector | int(4) | — | personnel.manager_id | T+ 人员档案无直属上级字段;可经部门树/部门负责人推断,规则待确认;否则系统人工指定 | 必须 | D? | AA_Person.iddepartment(部门id);AA_Department.idparent(上级id)、iddirector(部门负责人) |
|
||||
| 8 | 人员负责区域 / 产品辐射范围 | 未检出(可用 priuserdefnvc* 自定义项承载,需 IT 确认) | — | — | personnel_regions.region_name | 无专门字段则系统内人工维护 | 建议 | N | AA_Person 无区域/辐射范围字段 |
|
||||
| 9 | 银行账户名 / 账号 / 开户行 | 未检出(AA_Partner 有银行账号字段,人员档案无) | — | — | 暂不落库 | 一期不采集;如后续代发工资再扩展 | 可选 | N | AA_Person 无银行账户字段 |
|
||||
|
||||
## 4. 产品档案
|
||||
|
||||
产品主档不含库存量与售价,数量取现存量表、价格取价目表/订单明细。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 产品名称 | AA_Inventory.name | nvarchar(200) | — | products.product_name | TRIM;与规格型号组成系统组合唯一键 | 必须 | D | AA_Inventory.name(名称) |
|
||||
| 2 | 规格型号 | AA_Inventory.specification | nvarchar(200) | — | products.spec_model | 空值归一为空串 | 必须 | D | AA_Inventory.specification(规格型号) |
|
||||
| 3 | 计量单位 | AA_Inventory.idunit → 计量单位表 | int(4) | — | products.unit | 按 idunit join 单位表取名称;单位表名待索引确认 | 建议 | D? | AA_Inventory.idunit(计量单位id)、idunitgroup |
|
||||
| 4 | 数量 | ST_CurrentStock.baseQuantity(现存量) | decimal(13) | — | products.stock_quantity | 按存货+仓库汇总;语义(现存量/在途/累计销量)待 IT(#12) | 必须 | D? | ST_CurrentStock.baseQuantity(主计量数量);AA_Inventory 无库存量字段 |
|
||||
| 5 | 销售单价 | AA_InventoryPrice(零售价/批发价)、AA_InventoryUnitPrice.retailPrice;订单明细实际成交价 | decimal(13~17) / nvarchar(1000) 公式 | — | products.sale_price | 产品档案参考价取价目表;绩效以订单/发货明细实际单价为准 | 必须 | D? | AA_InventoryPrice、AA_InventoryUnitPrice;AA_Inventory 无销售单价字段 |
|
||||
| 6 | 产品分类(类目) | AA_Inventory.idinventoryclass → AA_InventoryClass.name | int(4) | — | products.product_category | T+ 分类仅辅助判定市政水利/轨交;系统分类人工维护不覆盖 | 必须 | D? | AA_Inventory.idinventoryclass(存货分类id)、AA_InventoryClass.name |
|
||||
| 7 | 计量单位换算关系(如存在) | AA_Inventory_MultiUnit / AA_InventoryUnitPrice.rateOfExchange | decimal(13) | — | 暂不实现(系统单单位存储) | 如业务有多单位换算需求,另行设计 | 可选 | D? | AA_Inventory_MultiUnit、AA_InventoryUnitPrice.rateOfExchange(换算率) |
|
||||
|
||||
## 5. 业务单据(销售订单)
|
||||
|
||||
成本额在订单明细仅有最新成本,出库明细才有成本金额;销售额需按数量×单价计算。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 订单单号 | SA_SaleOrder.code | nvarchar(30) | — | businesses.source_no | TRIM;不得写入内部 business_no | 必须 | D | SA_SaleOrder.code(编号) |
|
||||
| 2 | 业务(订单)日期 | SA_SaleOrder.voucherdate | datetime(8) | — | businesses.business_date | 截断为 DATE | 必须 | D | SA_SaleOrder.voucherdate(单据日期) |
|
||||
| 3 | 客户标识 | SA_SaleOrder.idcustomer → AA_Partner.code | int(4) | — | businesses.customer_id | 按客户编码解析;回退公司名称+负责人 | 必须 | D? | SA_SaleOrder.idcustomer(客户id)、idsettlecustomer(结算客户id) |
|
||||
| 4 | 负责营销人员 | SA_SaleOrder.idclerk → AA_Person.code | int(4) | — | businesses.owner_personnel_id | 按人员编码解析;新插入写 T+ 负责人、更新是否覆盖人工归属待裁决 | 必须 | D? | SA_SaleOrder.idclerk(业务员id) |
|
||||
| 5 | 协助营销人员 | 未检出独立协助人字段 | — | — | businesses.assistant_personnel_id | 业务已回馈 T+ 合同只挂单一负责人;协助人系统内记录 | 建议 | N | SA_SaleOrder 无 assist/协助 字段 |
|
||||
| 6 | 特殊业务标记 | SA_SaleOrder.idbusinesstype → 业务类型表 | int(4) | 业务类型枚举待字典/IT 提供 | businesses.is_special | 业务类型是否等同特殊业务待确认;无则人工标记 | 必须 | D? | SA_SaleOrder.idbusinesstype(业务类型id) |
|
||||
| 7 | 明细行 · 产品名称 + 规格 | SA_SaleOrder_b.idinventory → AA_Inventory.name/specification | int(4) | — | business_items.product_name / spec_model | 冗余固化名称与规格;未建档产品允许自由录入 | 必须 | D | SA_SaleOrder_b.idinventory(存货id) |
|
||||
| 8 | 明细行 · 数量 | SA_SaleOrder_b.quantity | decimal(13) | — | business_items.quantity | 主计量数量;需 >0 校验 | 必须 | D | SA_SaleOrder_b.quantity(数量)、baseQuantity(主计量数量) |
|
||||
| 9 | 明细行 · 销售单价 | SA_SaleOrder_b.price / taxPrice | decimal(13) | — | business_items.sale_price | price 为不含税单价、taxPrice 为含税单价;取哪个待确认 | 必须 | D? | SA_SaleOrder_b.price(单价)、taxPrice(含税单价) |
|
||||
| 10 | 明细行 · 销售额 | SA_SaleOrder_b 无「销售额」字段;由数量×单价计算,或 taxAmount−tax | decimal(13) | — | business_items.sale_amount | 系统按数量×单价固化;T+ 用于对账 | 必须 | D? | SA_SaleOrder_b.quantity、price、taxAmount、tax |
|
||||
| 11 | 明细行 · 成本额 ★ | SA_SaleOrder_b.LatestCost(最新成本);SA_SaleDelivery_b.costPrice/costAmount | decimal(13) | — | business_items.cost_amount | 取订单最新成本还是出库成本、成本类型/时点待 IT(#1) | 必须 | D? | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType |
|
||||
| 12 | 项目 / 工程编号或名称(如存在) | SA_SaleOrder.idproject → 项目表 | int(4) | — | 建议预留 project_code(当前不落库) | 按项目查询与报表归集;是否建列待业务 | 可选 | D? | SA_SaleOrder.idproject(项目id)、SA_SaleOrder_b.idproject |
|
||||
| 13 | 服务类 / 维修类及其他收入单据(如存在) | 未检索到服务/维修类独立单据表 | — | — | 暂人工录入 | 业务规则未定前不纳入自动同步 | 可选 | N | SA_* 表索引未发现服务/维修类主表 |
|
||||
|
||||
## 6. 发货单据(销售出库/发货)
|
||||
|
||||
发货日期有四个候选字段;运费未在发货单据检出。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 发货单号 | SA_SaleDelivery.code | nvarchar(30) | — | shipments.source_no | TRIM;不得写入内部 shipment_no | 必须 | D | SA_SaleDelivery.code(编号) |
|
||||
| 2 | 发货日期 ★ | SA_SaleDelivery.voucherdate / SendDate / deliveryDate / auditeddate | datetime(8) | — | shipments.ship_date | 四个日期候选:单据日期/送货日期(T+12.3)/交货日期/审核日期;取哪个待 IT(#2) | 必须 | D? | SA_SaleDelivery.voucherdate、SendDate、deliveryDate、auditeddate |
|
||||
| 3 | 关联订单号 | SA_SaleDelivery.SourceVoucherCode / idsourcevouchertype;明细 saleOrderCode/saleOrderId | nvarchar(50) / int(4) | — | shipments.business_id | 按订单号解析;主关联路径待 IT(#5) | 必须 | D? | SA_SaleDelivery.SourceVoucherCode、SA_SaleDelivery_b.saleOrderCode/saleOrderId/saleOrderDetailId |
|
||||
| 4 | 运费金额 | 未检出运费字段(明细有 origExpenseAmount 费用分摊金额) | decimal(13) | — | shipments.freight_amount | T+ 运费可能在费用单/其他单据,需 IT 确认;系统暂人工 | 必须 | N | SA_SaleDelivery/SA_SaleDelivery_b 无 freight/运费字段 |
|
||||
| 5 | 发货总金额 | SA_SaleDelivery.amount / taxAmount | decimal(13) | — | shipments.total_amount | 系统按明细汇总,不直写;T+ 金额用于对账 | 必须 | D | SA_SaleDelivery.amount(金额)、taxAmount(含税金额) |
|
||||
| 6 | 明细行 · 产品/规格/数量/单价/行金额 | SA_SaleDelivery_b.idinventory / quantity / price / taxPrice / taxAmount | decimal(13) | — | shipment_items.product_name/spec_model/quantity/sale_price/amount | 行金额由系统按数量×单价固化;T+ 用于对账 | 必须 | D | SA_SaleDelivery_b.quantity、price、taxPrice、taxAmount |
|
||||
| 7 | 发货单状态 | SA_SaleDelivery.voucherState / isCancel / auditeddate | int(4) / tinyint(1) | 单据状态枚举待 IT 提供 | 不落库(v1.4 统一) | 仅推已生效单据;状态随行供同步校验 | 必须 | D | SA_SaleDelivery.voucherState(单据状态)、isCancel(状态) |
|
||||
| 8 | 承运商 / 物流单号 | SA_SaleDelivery.deliveryMode(运输方式 id) | int(4) | 运输方式枚举表待确认 | 暂不落库 | 承运商/物流单号无独立字段;如需建列再扩展 | 可选 | D? | SA_SaleDelivery.deliveryMode(运输方式) |
|
||||
|
||||
## 7. 回款单据(收款/核销)
|
||||
|
||||
到账日期无银行到账日字段;收款方式取结算方式表;确认/核销状态字段存在但枚举待 IT。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 回款单号 | ARAP_ReceivePayment.code | nvarchar(30) | — | receipts.source_no | TRIM;不得写入内部 receipt_batch_no | 必须 | D | ARAP_ReceivePayment.code(编号) |
|
||||
| 2 | 到账日期 ★ | ARAP_ReceivePayment.voucherDate / auditedDate / createdTime | datetime(8) | — | receipts.receipt_date | 无银行到账日字段;取单据日期/审核日期/创建时间哪个待 IT(#3) | 必须 | D? | ARAP_ReceivePayment.voucherDate、auditedDate、createdTime |
|
||||
| 3 | 回款金额 | ARAP_ReceivePayment.amount(本币)/ origAmount(原币) | decimal(13) | — | receipts.amount | 取本币金额;一期禁负数,红字规则待 #8 | 必须 | D | ARAP_ReceivePayment.amount、origAmount |
|
||||
| 4 | 关联订单号 | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;明细 saleordercode/saleorderid/sourceordercode | nvarchar(50) / int(4) | — | receipts.business_id | 回款↔订单按订单号解析;回款↔发货批次系统人工指定 | 必须 | D? | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_ReceivePayment_b.saleordercode/sourceordercode |
|
||||
| 5 | 收款方式 | ARAP_ReceivePayment.IdSettleStyle → AA_SettleStyle.code/name | int(4) | 结算方式枚举表(AA_SettleStyle:code/name/停用) | receipts.payment_method(待补列) | 枚举映射;完整枚举清单需 IT 提供 | 必须 | D? | ARAP_ReceivePayment.IdSettleStyle;AA_SettleStyle |
|
||||
| 6 | 以物抵顶物说明 | 收款方式(以物抵顶枚举值)+ ARAP_ReceivePayment.memo | int(4) / nvarchar(200) | 以物抵顶对应结算方式代码待 IT 提供 | receipts.is_barter / remark | 按结算方式识别;抵顶物说明暂入备注 | 建议 | D? | ARAP_ReceivePayment.IdSettleStyle、memo |
|
||||
| 7 | 回款确认 / 核销状态 | ARAP_ReceivePayment.cancelState / voucherstate / auditedDate | int(4) / datetime(8) | 核销/单据状态枚举待 IT 提供 | receipts.confirm_status(待补列) | 字段存在;哪个可作为人工确认依据待 IT(#16) | 必须 | D? | ARAP_ReceivePayment.cancelState(核销状态)、voucherstate(单据状态) |
|
||||
| 8 | 银行流水号 / 回单参考号 | 未检出银行流水号/回单号字段(MultiSettle.documentCode 为票据号) | — | — | 建议预留 bank_serial_no | 需 IT 确认是否有对应字段 | 建议 | N | ARAP_ReceivePayment/ARAP_ReceivePayment_MultiSettle 仅票据号 |
|
||||
| 9 | 票据信息(票号 / 到期日 / 贴现说明) | ARAP_ReceivePayment_MultiSettle.documentCode / amount / idsettlestyle | nvarchar(30) / decimal(13) | 票据类型枚举待 IT | 系统暂以备注承载 | 无票据到期日字段;承兑到期核销信息需 IT 确认 | 建议 | D? | ARAP_ReceivePayment_MultiSettle.documentCode(票据号) |
|
||||
| 10 | 币种 / 汇率(如存在外币业务) | ARAP_ReceivePayment.idcurrency / exchangeRate | int(4) / decimal(13) | 币种表待索引 | 一期不落库(人民币记账) | 如存在外币业务再扩展 | 可选 | D | ARAP_ReceivePayment.idcurrency、exchangeRate |
|
||||
|
||||
## 8. 客户档案
|
||||
|
||||
采用往来单位表 AA_Partner;客户编码/停用字段存在,status 列需系统补。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 公司名称 | AA_Partner.name | nvarchar(200) | — | customers.company_name | TRIM;与负责人组成系统唯一键 | 必须 | D | AA_Partner.name(名称) |
|
||||
| 2 | 负责人姓名 | AA_Partner.Contact(联系人)/ representative(法人代表) | nvarchar(50) | — | customers.owner_name | 「负责人」对应 Contact 还是 representative 待 IT/业务确认 | 必须 | D? | AA_Partner.Contact、representative |
|
||||
| 3 | 客户编码 / 状态 / 启用停用时间 | AA_Partner.code / disabled / madeDate / updated | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用(候选) | customers.source_no / status(待补列) | 启停时间无独立列,可用 updated 近似(需 IT 确认) | 必须 | D? | AA_Partner.code、disabled、madeDate、updated |
|
||||
| 4 | 联系人 / 电话 / 地址 / 行业 | AA_Partner.Contact / TelephoneNo / MobilePhone / CustomerAddress / iddistrict | nvarchar(30~200) | — | customers.contact_person / contact_phone / address / industry | 行业无直接字段;地区按 iddistrict 关联 | 可选 | D? | AA_Partner.Contact、TelephoneNo、MobilePhone、CustomerAddress、iddistrict |
|
||||
| 5 | 开票信息(税号 / 开票名称 / 开票地址电话) | AA_Partner.taxRegcode / name / CustomerAddress | nvarchar(50~200) | — | 系统暂仅供展示,不落库 | 税号存在;开票地址电话需 IT 确认字段 | 可选 | D? | AA_Partner.taxRegcode(纳税号)、CustomerAddress |
|
||||
|
||||
## 9. 同步完整性与补充字段
|
||||
|
||||
时间戳与负向字段部分存在;无统一事件表、无明细行号、无手续费字段。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 数据最后更新时间 / 单据创建时间 / 修改时间 | 各表 createdTime/createdtime、updated、madeDate、auditedDate、ts | datetime(8) / timestamp(8) | — | 建议新增 source_created_at / source_updated_at(当前无) | 本地 update_time 不可作为源端水位;增量水位字段待 IT(#9) | 必须 | D? | 各表 updated/createdTime;ts(分布时间戳) |
|
||||
| 2 | 变更类型 / 单据事件:新增、修改、删除、作废、红冲、退货、冲销 | 各表 isCancel(状态)、isCounteractDelivery(冲抵销货)、returnQuntity(退货数量) | int(4) / tinyint(1) / decimal(13) | 状态/事件枚举待 IT 提供 | 建议事件表(当前无) | 无统一变更事件表;负向事实需事件模型,规则待 #8 | 必须 | D? | SA_SaleOrder.isCancel、SA_SaleDelivery.isCounteractDelivery、SA_SaleDelivery_b.returnQuntity |
|
||||
| 3 | 单据状态 / 财务审核状态 / 状态变更时间 | voucherState / auditedDate / auditor / auditorid | int(4) / datetime(8) / nvarchar(50) | 单据状态枚举待 IT | 不落库(仅同步校验) | 仅推已生效单据;状态随行校验 | 必须 | D | SA_SaleOrder/SA_SaleDelivery/ARAP_ReceivePayment 的 voucherState、auditedDate |
|
||||
| 4 | 明细行行号 / 行状态 / 行级变更事件 | 明细表主键 ID、code(明细编号);无显式行号字段 | int(4) / nvarchar(30) | — | 建议补 source_line_no / source_item_id | 确认 code 是否即行号;否则用 T+ 明细 ID 作行级幂等键(待 IT) | 必须 | D? | SA_SaleOrder_b.id/code、SA_SaleDelivery_b.ID/code、ARAP_ReceivePayment_b.id/detailCode |
|
||||
| 5 | 税率 / 税额 / 价税合计 / 折扣或折让 | 明细表 taxRate、tax、taxAmount、origTaxAmount、discount、discountRate、discountAmount | decimal(13) | — | 待业务 #20 答复后决定是否建列 | 字段齐全;金额是否含税、折扣关联方式待业务 | 必须 | D? | SA_SaleOrder_b/SA_SaleDelivery_b 税额与折扣字段 |
|
||||
| 6 | 发货客户 / 收货人 / 收货地址 / 联系电话 | SA_SaleDelivery.idcustomer / address / contactPhone / idSendPerson / idConsignor | int(4) / nvarchar(100~200) | — | 建议预留(当前无列) | 用于核对发货对象与代发情形 | 必须 | D | SA_SaleDelivery.idcustomer、address、contactPhone、idSendPerson、idConsignor |
|
||||
| 7 | 发货业务员 / 发货组织 / 仓库 | SA_SaleDelivery.idclerk / iddepartment / idwarehouse | int(4) | — | 建议预留(当前无列) | 识别代发、调拨与多组织差异 | 必须 | D | SA_SaleDelivery.idclerk、iddepartment、idwarehouse |
|
||||
| 8 | 发货退回 / 红字发货 / 退回数量 / 退回金额 / 原发货单关联 | SA_SaleDelivery.isCounteractDelivery、_b.returnQuntity/returnQuntity2/backSentQuantity、sourceVoucher* | tinyint(1) / decimal(13) / nvarchar(50) | 冲抵/退货枚举待 IT | 待业务 #21 答复后决定是否建列 | 原单关联字段存在;是否冲减绩效待业务 | 必须 | D? | SA_SaleDelivery.isCounteractDelivery、SA_SaleDelivery_b.returnQuntity、backSentQuantity |
|
||||
| 9 | 收款客户 / 经手人 / 财务审核状态 | ARAP_ReceivePayment.idpartner / idHandler / auditor / voucherstate | int(4) / nvarchar(50) / int(4) | — | 建议预留(当前无列) | 核对实际付款主体与经办责任 | 必须 | D | ARAP_ReceivePayment.idpartner、idHandler、auditor、voucherstate |
|
||||
| 10 | 收款分配明细 / 分配金额 / 未分配金额 / 分配对象 | ARAP_ReceivePayment_b(源单/订单/金额/未结算)、ARAP_StrikeBalance(核销) | decimal(13) / int(4) | — | 人工指定发货批次的辅助参考 | 分配关系仅参考;系统仍人工指定(对照表 #18) | 必须 | D? | ARAP_ReceivePayment_b.saleordercode/amount/origNoSettleAmount;ARAP_StrikeBalance |
|
||||
| 11 | 预收款 / 保证金 / 代收款 / 折让 / 抵扣 | ARAP_ReceivePayment.paymentAmount/origPaymentAmount(预收/预付)、overPayment(超收转预收)、allowances(折让) | decimal(13) | — | 待业务 #22 答复后决定是否建列 | 字段存在;是否计入绩效回款待业务 | 必须 | D? | ARAP_ReceivePayment.paymentAmount、overPayment、allowances |
|
||||
| 12 | 手续费 / 交易费用 / 收款净额 | 未检出手续费/交易费用字段 | — | — | 待业务 #23 答复后决定 | 需 IT 确认手续费是否在其他单据/字段 | 必须 | N | ARAP_ReceivePayment 无 fee/charge 字段 |
|
||||
| 13 | 红字收款 / 收款作废 / 收款退回 / 冲销及原单关联 | ARAP_ReceivePayment.voucherstate / isReceiveFlag;ARAP_StrikeBalance 冲销 | int(4) / tinyint(1) | 作废/红字状态枚举待 IT | 待业务 #24 答复后决定 | 无红字收款专用字段;负向事实需事件模型 | 必须 | D? | ARAP_ReceivePayment.voucherstate、isReceiveFlag;ARAP_StrikeBalance |
|
||||
| 14 | 客户编码 / 客户状态 / 启用停用时间 | AA_Partner.code / disabled | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用 | customers.source_no / status(待补列) | 同六-3;客户编码唯一性待 IT | 必须 | D | AA_Partner.code、disabled |
|
||||
| 15 | 客户分类 / 渠道 / 区域 / 所属组织 | AA_Partner.idpartnerclass / customerType / iddistrict / idMarketingOrgan | int(4) | 分类/客商类型枚举待 IT | customers.customer_type(不覆盖);渠道无列 | 系统客户分类人工维护;渠道/区域作备考 | 必须 | D? | AA_Partner.idpartnerclass、customerType、iddistrict、idMarketingOrgan |
|
||||
| 16 | 客户名称变更 / 编码停用合并前后的对应关系 | AA_Partner.code(终身不变)+ updated;无变更历史表(待确认) | nvarchar(32) | — | 靠 source_no 追溯同一主体 | 改名/换负责人/合并靠 code 串同一主体;变更历史需 IT 确认 | 必须 | D? | AA_Partner.code、updated、HasEverChanged |
|
||||
| 17 | 人员部门 / 岗位 / 调岗时间 / 离职和组织变更时间 | AA_Person.iddepartment / position / updated;调岗历史表待确认 | int(4) / datetime(8) | — | 不落库(备考/排查) | 字段部分存在;调岗/离职时间需 IT 确认 | 必须 | D? | AA_Person.iddepartment、position、updated |
|
||||
| 18 | 产品编码 / 产品状态 / 启用停用时间 | AA_Inventory.code / disabled / madeDate | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用 | products.source_no / status | 稳定匹配;停用后历史明细仍可回溯 | 必须 | D | AA_Inventory.code、disabled、MadeRecordDate |
|
||||
| 19 | 字典和枚举取值范围 / 编码与名称对照说明 | 枚举表:AA_SettleStyle、业务类型表、状态字典(需随数据导出) | — | 枚举字典随数据提供 | 同步映射表 | 不能只给代码;需编码与名称对照 | 必须 | D? | AA_SettleStyle、AA_BusinessType 等枚举表 |
|
||||
| 20 | 对账汇总信息 / 数据记录数 / 数量合计 / 金额合计 / 抽取时间 | 由 IT 提供抽取汇总(接口/视图层) | — | — | 同步日志/对账表 | 记录明细条数、金额合计、抽取时间 | 必须 | IT | 字典不提供;需对接方式确定 |
|
||||
| 21 | 单据历史版本 / 修改留痕(如 T+ 提供) | 未检索到统一单据历史版本表(待 IT 确认) | — | — | 系统同步快照可比对 | 无此数据不阻塞 | 可选 | D? | 表索引未见 history 类表 |
|
||||
|
||||
## 10. 跨表关联与键
|
||||
|
||||
| 关系 | T+ 字段(字典) | 系统目标 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 人员唯一键 | AA_Person.code | personnel.source_no(建议补 personnel_code) | D? |
|
||||
| 客户唯一键 | AA_Partner.code;名称+联系人 | customers.source_no;company_name+owner_name | D? |
|
||||
| 订单→发货 | SA_SaleDelivery.SourceVoucherCode / _b.saleOrderCode/saleOrderId | shipments.business_id | D? |
|
||||
| 订单/发货→回款 | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;_b.saleordercode | receipts.business_id | D? |
|
||||
| 回款→发货批次 | 系统人工指定(对照表 #18) | receipts.shipment_id | S |
|
||||
| 明细行幂等 | 明细表 ID/code;无显式行号 | 建议补 source_line_no | D? |
|
||||
|
||||
## 11. 枚举与状态
|
||||
|
||||
| 枚举 | T+ 来源 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| 收款/结算方式 | AA_SettleStyle(code/name/disabled) | 完整枚举需随数据导出 |
|
||||
| 单据状态 | 各单据 voucherState / isCancel | 仅同步校验,不落库 |
|
||||
| 核销状态 | ARAP_ReceivePayment.cancelState | 含义待 IT |
|
||||
| 业务类型 | SA_SaleOrder.idbusinesstype → 业务类型表 | 是否等同特殊业务待确认 |
|
||||
| 产品分类 | AA_InventoryClass | 仅辅助系统分类人工维护 |
|
||||
|
||||
## 12. 系统侧目标表缺口
|
||||
|
||||
| 缺口 | 说明 | 建议 |
|
||||
| --- | --- | --- |
|
||||
| `personnel.personnel_code` | T+ 工号/编码是否即 `source_no` 未定 | 若不等则补唯一列 |
|
||||
| `customers.status` / 启停时间 | T+ 有 disabled,系统无列 | 补 status 与停用时间 |
|
||||
| `receipts.payment_method` / `confirm_status` | T+ 有结算方式与核销状态 | 补两列并接枚举 |
|
||||
| `source_created_at` / `source_updated_at` | 本地 update_time 不可作源端水位 | 补列或建 staging 表 |
|
||||
| `source_line_no` / `source_item_id` | 明细表无行号 | 补列支持行级幂等 |
|
||||
| 负向事件模型 | 无删除/作废/红冲/退货事件表 | 设计事件表或冲销单 |
|
||||
| `sync_log` / 失败明细表 | 完全缺失 | 同步模块前置 |
|
||||
| T+ 配置项 | `.env` 有 `T_PLUS_*`,`Settings` 未定义 | 补配置字段与校验 |
|
||||
|
||||
## 13. IT 待确认事项收敛(18 → 13 + 1 组业务)
|
||||
|
||||
| 新# | 原# | 事项 | 字典证据 | 字典可答 | 仍需确认 | 优先级 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 1 | 成本额字段与成本口径 | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType;AA_Inventory.invSCost/latestCost/avagCost | 字段存在性、类型精度、挂在订单还是出库单 | 请确认绩效成本取哪个字段(订单最新成本 / 出库成本 / 其他),取数时点与精度 | 高(阻塞核算) |
|
||||
| 2 | 2+3 | 发货日与到账日口径 | 发货:SA_SaleDelivery.voucherdate/SendDate/deliveryDate/auditeddate;到账:ARAP_ReceivePayment.voucherDate/auditedDate/createdTime | 各日期字段存在性与类型 | 请分别确认发货日、到账日取哪个字段;签收日/银行到账日能否从 T+ 获取 | 高 |
|
||||
| 3 | 4 | 幂等键与作废重开 | 各表 code + id 主键;SA_SaleOrder.ismodifiedcode、HasEverChanged | 编码字段与主键关系 | 各单据 code 是否唯一终身不变;作废重开是否换号;建议以 code 作 source_no | 高 |
|
||||
| 4 | 5 | 三单关联与核销路径 | SA_SaleDelivery.SourceVoucherCode/idsourcevouchertype;SA_SaleDelivery_b.saleOrderCode/saleOrderId;ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_ReceivePayment_b;ARAP_StrikeBalance | 关联字段与核销表存在性 | 请确认订单→发货→回款的主关联路径(按单号还是 ID),以及分批发货/部分核销如何取 | 高 |
|
||||
| 5 | 6 | 人员编码/工号与经理隶属 | AA_Person.code/name/iddepartment/isSalesman;AA_Department.idparent/iddirector | 编码/部门/负责人字段存在;无直属上级字段 | AA_Person.code 是否即工号;业务员→营销经理隶属在 T+ 如何维护(部门负责人?自定义项?) | 高 |
|
||||
| 6 | 7 | 对接方式与安全 | 字典不涉及 | 无 | REST/视图/中间表/文件;只读账号与凭据、接口文档、样例数据 | 高 |
|
||||
| 7 | 8 | 负数/红字/退货/冲销规则 | SA_SaleDelivery.isCounteractDelivery;SA_SaleDelivery_b.returnQuntity/backSentQuantity;ARAP_ReceivePayment.voucherstate;ARAP_StrikeBalance | 负向字段/单据形式存在性 | 负向单据的实际使用形式、原单关联字段、是否推送;绩效影响转业务 | 高 |
|
||||
| 8 | 9 | 存量起始与增量水位 | 各表 updated/createdTime/madeDate/auditedDate/ts | 时间戳字段存在性 | 存量起始日期;增量用哪个时间戳(updated? ts?);时区 | 中 |
|
||||
| 9 | 10+v1.4#18 | 客户主数据匹配与变更 | AA_Partner.code/name/Contact/representative/disabled/updated | 编码/名称/负责人/停用字段 | 客户唯一键;改名/换负责人/合并如何映射同一主体;负责人取 Contact 还是 representative | 中 |
|
||||
| 10 | 11+16 | 收款方式与确认/核销枚举 | ARAP_ReceivePayment.IdSettleStyle;AA_SettleStyle.code/name;ARAP_ReceivePayment.cancelState/voucherstate | 枚举表与状态字段存在性 | 收款方式完整枚举与以物抵顶对应代码;核销/确认状态枚举含义及可否作为人工确认依据 | 高 |
|
||||
| 11 | 12+13 | 产品数量语义与发货单价 | ST_CurrentStock.baseQuantity;AA_InventoryPrice/AA_InventoryUnitPrice;SA_SaleDelivery_b.price/taxPrice | 数量/价格字段存在性 | 产品「数量」取现存量/在途/累计销量;发货单价取订单价还是发货时价、含税与否 | 中 |
|
||||
| 12 | 14+17 | 低频字段存在性:特殊业务标记/市场拓展计划 | SA_SaleOrder.idbusinesstype;AA_Person 自定义项;未检出市场拓展计划表 | 字段/表存在性 | 特殊业务是否有可识别字段;市场拓展计划 T+ 是否有表可提供;无则人工维护 | 低 |
|
||||
| 13 | 15 | 同步窗口 | 字典不涉及 | 无 | 每周同步时间点,避开结账/备份;失败重试策略 | 低 |
|
||||
| B1 | 18 | 业务裁决 5 项 | 税额/折扣/退货/预收/手续费字段已定位(待答复后决定是否建列) | 字段存在性 | 含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述 | 业务裁决 |
|
||||
|
||||
### 原 18 项对照
|
||||
|
||||
| 原# | 事项 | 原优先级 | 收敛去向 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 成本额取数口径 | 高(阻塞核算) | 1 |
|
||||
| 2 | 发货日期口径 | 高 | 2 |
|
||||
| 3 | 到账日期口径 | 高 | 2 |
|
||||
| 4 | 幂等唯一键 | 高 | 3 |
|
||||
| 5 | 三单关联键 | 高 | 4 |
|
||||
| 6 | 人员匹配键与经理隶属(v1.2 更新) | 高 | 5 |
|
||||
| 7 | 对接方式与安全 | 高 | 6 |
|
||||
| 8 | 负数 / 红字 / 退货 / 冲销处理规则 | 高 | 7 |
|
||||
| 9 | 存量范围与增量口径 | 中 | 8 |
|
||||
| 10 | 客户档案匹配与唯一键 | 中 | 9 |
|
||||
| 11 | 收款方式枚举 | 中 | 10 |
|
||||
| 12 | 产品「数量」语义 | 中 | 11 |
|
||||
| 13 | 发货单价口径 | 中 | 11 |
|
||||
| 14 | 特殊业务标记 | 低 | 12 |
|
||||
| 15 | 同步窗口 | 低 | 13 |
|
||||
| 16 | 回款确认 / 核销状态 | 高 | 10 |
|
||||
| 17 | 市场拓展计划数据 | 低 | 12 |
|
||||
| 18 | 口径裁决 5 项(移交业务方) | 业务裁决 | B1 |
|
||||
|
||||
## 14. 待确认清单
|
||||
|
||||
**IT(13 项)**:见第 13 节新# 1–13。
|
||||
|
||||
**业务(1 组)**:含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述(原 #18 / 待澄清 #20–#24)。
|
||||
|
||||
**系统侧(不阻塞 IT)**:补 `personnel_code`、`customers.status`、`receipts.payment_method/confirm_status`、`source_updated_at`、`source_line_no`、`sync_log` 与 T+ 配置项。
|
||||
|
||||
## 15. 验证记录
|
||||
|
||||
- 解包:1,457 个 HTML、`.hhc/.hhk` 齐全、零字节 0;
|
||||
- 全量解析:1,457 页、938 个 Table 类型页、**931 张表、21,019 个字段、0 失败页**;
|
||||
- 编码:逐页检测,GB18030(无替换字符);
|
||||
- 抽样核验:六类主表结构一致(6 列字段表),关键字段逐项比对通过;
|
||||
- 输入 SHA-256:`3e8cd3146e22bdebe8bacda56b5dafbbd8be744c4279d7b2bbf3d7ecdc7e2726`;
|
||||
|
||||
## 附录 A:六类主表索引
|
||||
|
||||
| 类别 | T+ 表 | 字典页 GUID |
|
||||
| --- | --- | --- |
|
||||
| 人员 | AA_Person | `fcd00c17-6778-4c6a-90ae-3f0dbaf2c102` |
|
||||
| 产品 | AA_Inventory | `93b57dfa-4ca4-411d-a883-6d26c8e01787` |
|
||||
| 客户 | AA_Partner | `871e93dd-c2cf-4bd2-af91-cbeb5951ac5e` |
|
||||
| 业务 | SA_SaleOrder | `9a51f8a9-658a-4f7c-9372-8af2257511ee` |
|
||||
| 发货 | SA_SaleDelivery | `bef98b52-c9f0-4d80-8e62-8968cced5b52` |
|
||||
| 回款 | ARAP_ReceivePayment | `709de6e4-3b3d-4cf8-b5ed-5f657d42599a` |
|
||||
|
||||
## 附录 B:75 项覆盖矩阵
|
||||
|
||||
| 节 | 项数 | 已映射 |
|
||||
| --- | --- | --- |
|
||||
| 公共要求 | 2 | 2 |
|
||||
| 人员 | 9 | 9 |
|
||||
| 产品 | 7 | 7 |
|
||||
| 业务 | 13 | 13 |
|
||||
| 发货 | 8 | 8 |
|
||||
| 回款 | 10 | 10 |
|
||||
| 客户 | 5 | 5 |
|
||||
| 同步完整性 | 21 | 21 |
|
||||
|
||||
## 附录 C:失败页
|
||||
|
||||
无(`failures.csv` 为空)。
|
||||
|
||||
## 附录 D:复现方法
|
||||
|
||||
```text
|
||||
1. C:/Windows/hh.exe -decompile <out> <TPlus123.chm>
|
||||
2. py -3.14 -I 03_extract_fields.py --extracted <out> --out <out>
|
||||
3. py -3.14 -I 08_build_md.py --out <out> --target <md>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*本文档由数据字典解析与人工核验生成,字段候选未经 IT 确认前不得视为同步契约。*
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+927
-98
File diff suppressed because it is too large.
Load diff
@@ -45,12 +45,12 @@
|
||||
② 基础数据管理 ✅ ← 人员引用 ① 岗位角色(外键上游)
|
||||
③ 业务数据管理 ✅ ← 引用 ② 人员/产品/客户
|
||||
│ 增量(2026-10-15):二次分配方案(合同刚建立时录入)
|
||||
④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
|
||||
│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(语义待澄清)
|
||||
⑦ 复核审批流(新) ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
|
||||
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约
|
||||
⑧ 工作台(新) ← 消费 ④⑤⑥⑦ 数据的聚合展示层
|
||||
⑨ 备份恢复与运维检查(新) ← 独立于业务块,可与任一批次并行,建议尽早
|
||||
④ 绩效工资核算 ✅ ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
|
||||
│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(#1–#3 答复空白,开发侧暂定口径已实现,见 3.3.1)
|
||||
⑦ 复核审批流(新)✅ ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
|
||||
⑤ 报表统计 ✅(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约(待 IT 18 项答复)
|
||||
⑧ 工作台(新)✅ ← 消费 ④⑤⑥⑦ 数据的聚合展示层
|
||||
⑨ 备份恢复与运维检查(新)✅ ← 独立于业务块,可与任一批次并行,建议尽早
|
||||
```
|
||||
|
||||
### 1.2 顺序论证(要点)
|
||||
@@ -68,12 +68,12 @@
|
||||
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览。**✅ 已完成**。增量(2026-10-15):公司信息配置(公司名称、logo 等基本信息);审批开关配置走参数体系;新增参数一律进参数注册表(用户重申:参数配置尽量灵活) | (原验收已达成)增量:公司信息可配置并用于登录页与导出页眉;审批开关参数化,关闭直接生效、开启走审批 |
|
||||
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更。**✅ 已完成** | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
|
||||
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器。**✅ 已完成**。增量(2026-10-15):二次分配方案 —— 只出现在合同刚建立时;合同建立时可手动选择参与二次分配的业务员;分配比例不要求合计 100%;审批开关开启时走审批 | (原验收已达成)增量:合同建立时可录入参与业务员与比例(不强制合计 100%);开关关闭直接生效;方案修改不影响已结算月份 |
|
||||
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(语义待澄清,见 3.3) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
|
||||
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
|
||||
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
|
||||
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions) | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
|
||||
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态 | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
|
||||
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本 | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
|
||||
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(#1–#3 答复空白,开发侧暂定口径已实现,见 3.3.1) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
|
||||
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计。**✅ 已完成(2026-10-19)** | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
|
||||
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发。**⏳ 阻塞(IT 18 项未答复)** | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
|
||||
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions)。**✅ 已完成(2026-10-19;权限细目待业务清单)** | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
|
||||
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态。**✅ 已完成(2026-10-19)** | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
|
||||
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本。**✅ 已完成(2026-10-16)** | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
|
||||
|
||||
### 1.4 里程碑(交付批次)
|
||||
|
||||
@@ -81,9 +81,9 @@
|
||||
| --- | --- | --- |
|
||||
| **M1** | ① 系统设置与参数体系 | ✅ 已完成(2026-10-13 推送 feat/system-params) |
|
||||
| **M2** | ② + ③ | ✅ 已完成(2026-10-13 分支 feat/base-business-data) |
|
||||
| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流 | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」;开工前必须澄清:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7);内部建议顺序 ①增量 → ③增量 → ⑦ 审批框架 → ④ 核算 → 月结与审批串联 |
|
||||
| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台 | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
|
||||
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付) | 数据持续积累,备份先行;不依赖业务模块进度 |
|
||||
| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流。**✅ 已完成(2026-10-19,feat/m3-increments)** | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」已交付;余 3.3-1 答复与审批权限细目。开工前澄清项:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7,已参数化) |
|
||||
| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台。**✅ ⑤⑧ 已完成(2026-10-19);⑥ 待 IT 18 项答复** | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
|
||||
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付)。**✅ 已完成(2026-10-16)** | 数据持续积累,备份先行;不依赖业务模块进度 |
|
||||
|
||||
### 1.5 需求依据对照
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
| ④ | 需规 2.3.1–2.3.3;对照表 #3/#4/#6/#7/#11/#15/#16/#17/#20–#24;增量见 3.1(一)(四) | 协助 25%;经理 8‰;26 天示例;含运费公式 |
|
||||
| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R);增量见 3.1(四) | — |
|
||||
| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — |
|
||||
| ⑦⑧⑨ 及各块增量 | **2026-10-15 功能清单核查补充(本文第三章,原文照录)**——尚未回填需规与对照表,引用以第三章原文为准 | 二次分配 ≠ 协助 25%:#15 作废的是「协助 25% 分成」条款,二次分配是合同建立时的新机制,关系待业务澄清(3.3-1) |
|
||||
| ⑦⑧⑨ 及各块增量 | **2026-10-15 功能清单核查补充(本文第三章,原文照录)**——已随 SRS V2.1(2026-10-19)回填;《问题与解决方案对照表》是否回填由用户决定 | 二次分配 ≠ 协助 25%:#15 作废的是「协助 25% 分成」条款,二次分配是合同建立时的新机制;#1–#3 答复仍空白,开发侧暂定口径已实现(3.3.1) |
|
||||
|
||||
---
|
||||
|
||||
@@ -610,11 +610,13 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
| 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 |
|
||||
| 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) |
|
||||
|
||||
> **2026-10-20 回填**:上述开放问题均已有结论,原表保留为 2026-10-12 设计时快照。#1 → 澄清 #14(首日整 10‰,A 口径);#2 → #18(按 2‰ 回溯,双种子保留);#3 → #15(一期仅 FULL,BALANCE 留参数位);#4 → #16(撤 `probation.salary.basic`,读 `personnel.salary_standard`);#5 → #17(查看放开登录用户、修改仅管理员);#6 → 催办清单三、R3;#7 → #12(`manager.personal.included` 参数,默认 0=不计入)。
|
||||
|
||||
---
|
||||
|
||||
## 第三章 2026-10-15 功能清单核查补充(新增需求)
|
||||
|
||||
> **来源与地位:** 用户以现有功能清单核查后补充的需求(2026-10-15)。本章 3.1 为**原文照录**(未做任何改写),3.2 起为开发侧归属与推演,**两者严格区分**;引用新需求一律以 3.1 原文为准。本批内容尚未回填需规文档与《问题与解决方案对照表》,是否回填由用户决定。任务级实施计划在对应里程碑开工前按第二章同粒度补齐。
|
||||
> **来源与地位:** 用户以现有功能清单核查后补充的需求(2026-10-15)。本章 3.1 为**原文照录**(未做任何改写),3.2 起为开发侧归属与推演,**两者严格区分**;引用新需求一律以 3.1 原文为准。本批内容已随《需求规格说明书》V2.1(2026-10-19)回填;《问题与解决方案对照表》是否回填由用户决定。任务级实施计划在对应里程碑开工前按第二章同粒度补齐。
|
||||
|
||||
### 3.1 需求原文(照录)
|
||||
|
||||
@@ -656,6 +658,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
#### (六)系统设置
|
||||
|
||||
- 公司信息配置:公司名称、logo 等基本信息
|
||||
- **2026-10-20 回填**:参数注册表已扩至 22 项(V8/V10 增补审批开关、试用期迁移、预警阈值等)
|
||||
|
||||
#### (七)参数配置强调
|
||||
|
||||
@@ -666,7 +669,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
#### (一) 二次分配方案 → ③ 录入 + ④ 分摊消费 + ⑦ 审批联动 + ④ 调整单衔接
|
||||
|
||||
- 「只出现在合同刚建立时」→ 录入入口放业务创建流程(与 `businesses` 创建同事务或紧随其后);合同建立后是否允许补录/修改未明说,方案修改的留痕方式待设计;
|
||||
- 「分配比例不要求合计 100%」→ 余额归属规则需求未说明(3.3-1),存储上只存各参与人比例、不强制校验合计;
|
||||
- 「分配比例不要求合计 100%」→ 余额归属规则需求未说明(3.3-1);存储上只存各参与人比例、不强制校验合计。**2026-10-20 回填**:#1–#3 答复仍空白,开发侧已按暂定口径实现分摊拆行(余额归负责人、行合计守恒,见 3.3.1),答复后如有变更走口径调整;
|
||||
- 「修改不影响已结算月份」→ 月结快照天然隔离(快照固化当月结果),方案表需支持编辑 + 修改留痕;
|
||||
- 「已结算月份调整只能通过调整单或后续月份扣补」→ 调整单为统一承载机制,见 (四)。
|
||||
|
||||
@@ -676,7 +679,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
| --- | --- |
|
||||
| 经营绩效总览 | ④ 台账/月结(按人/团队/月份汇总) |
|
||||
| 待办事项 | ⑦ 待审批单、⑥ 同步失败、回款未指定批次(M2 已有该状态可复用) |
|
||||
| 风险预警 | 规则未定义(3.3-5),先落参数化规则容器 |
|
||||
| 风险预警 | 规则未定义(3.3-5),先落参数化规则容器。**2026-10-20 回填**:方向已答复参数化(#10),首批三条按默认落 V10 参数(逾期 90 天/审批超时 3 天/同步失败待 ⑥) |
|
||||
| 最近同步/计算/审批动态 | ⑥ 同步日志 + ④ 核算/月结记录 + ⑦ 审批记录 |
|
||||
|
||||
#### (三) 复核审批 → ⑦ 新模块
|
||||
@@ -687,9 +690,11 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
- 「审批意见与版本」→ 审批单带版本号:重算/改单产生新版本,旧版本与意见留痕不覆盖;
|
||||
- **登录角色体系扩展**:营销后勤/财务/领导是系统新登录角色(`roles` 表基础设施已有;`permissions`/`role_permissions` 自 M1 起闲置待权限清单,此处激活)——打破一期「营销人员不登录」的前提,但与该原则不矛盾(营销人员仍不登录,后勤/财务/领导是职能角色)。
|
||||
|
||||
> **2026-10-20 回填**:审批框架与月结审批对象已交付(2026-10-19);权限点清单仍待业务方给出(澄清 #6,催办 B-6),当前按 role_code 粗粒度运行,营销后勤暂不可见审批列表。
|
||||
|
||||
#### (四) 月结、审批、报表 → ④ 月结子块 + ⑦ 审批 + ⑤ 导出模板
|
||||
|
||||
- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」定义待澄清(3.3-3);
|
||||
- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」已答复为人工确认、可手动关闭(3.3-3),月结按已确认回款取数;
|
||||
- 「支持负数工资」与既有裁决「负绩效按公式正常计算(不归零)」一致,无需改公式口径;
|
||||
- 调整单:新单据类型(补发/扣减方向、关联月份、扣补说明、审批状态);
|
||||
- 「结算后锁定」:已锁月份的快照金额不可变;源数据修改只产生提示、历史记录和后续调整 —— 与 #19「归属变更不回溯历史」同一哲学,扩展到全部源数据;
|
||||
@@ -708,6 +713,8 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
|
||||
### 3.3 待澄清问题(不阻塞收录,对应模块开工前须澄清)
|
||||
|
||||
> **回填状态(2026-10-20):** 下表保留 2026-10-15 原始问题与建议默认(历史快照);业务答复与落实情况见 3.3.1。3.3-1 仍无业务答复,开发侧已按暂定口径实现分摊拆行,答复后如有变更按口径调整。
|
||||
|
||||
| # | 事项 | 影响模块 | 建议默认处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 二次分配的**分配标的**(绩效金额?折算毛利润?业绩额?)与**余额归属**(合计 ≠100% 时差额归谁);与已作废的「协助 25%」(#15)是何关系 | ④ 分摊逻辑与台账归属字段 | 未确认前 ④ 只落录入与存储,不实现分摊 |
|
||||
@@ -718,27 +725,41 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
| 6 | 调整单是否走同一审批链;能否跨月调整已锁定月份 | ④⑦ | 建议走同一审批链;「后续月份扣补」为默认路径,跨月调整需授权留痕 —— 待确认 |
|
||||
| 7 | 待办事项的范围(除审批/同步失败外还含哪些,回款未指定批次是否算待办) | ⑧ | 按 3.2(二) 默认清单起步,业务确认后扩充 |
|
||||
|
||||
### 3.3.1 答复回填(2026-10-19)
|
||||
### 3.3.1 答复回填(2026-10-19,2026-10-20 修订)
|
||||
|
||||
> 业务方 24 项答复已到(2026-10-16,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列),2026-10-19 全部落实并实现;本节为 3.3 各项的落定状态,供后续引用。答复与默认不同处以答复为准;仍未答复项见 `docs/待答复催办清单_2026-10-19.md`。
|
||||
> **修订说明(2026-10-20):** 2026-10-19 版本把 24 项统一写成「全部落实并实现」,与 3.3-1 答复空白、开发侧暂定分摊的事实不符。本次按「业务答复 / 落实状态 / 关联项」重写;3.3-1 明确标注为开发侧暂定口径,不视为业务确认。
|
||||
>
|
||||
> 业务方 24 项答复于 2026-10-16 到达(回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列)。分组:确认按建议默认 13 项(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#22/#23/#24,其中 #22 为模糊确认按不计入);与默认不同、已按答复落实 4 项(#5/#14/#16/#19);部分答复 3 项(#4/#6/#12);仍开放 4 项(#1/#2/#3/#20)。未答复项对外跟催见 `docs/待答复催办清单_2026-10-19.md`。
|
||||
|
||||
| 3.3# | 事项 | 落定状态 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 二次分配标的与余额归属(含与 #15 协助 25% 关系) | **答复仍空白;2026-10-19 已按建议默认实现分摊拆行**——分配标的=绩效金额份额(参与人份额×比例、负责人拿余额、行合计守恒、合计>100% 与负责人自分 400 拦截);协助 25% 维持仅记录不核算;业务答复后如有变更走口径调整 |
|
||||
| 2 | 二次分配审批开关默认值 | 已答复:**默认开启**,可手动关闭(已落实 V8 参数种子) |
|
||||
| 3 | 「确认回款」定义 | 已答复:需人工确认动作,可手动关闭(系统已按此预留并实现) |
|
||||
| 4 | 审批角色权限清单 | 部分答复:先预设角色,名称与职权待客户确认;现按最小授权运行(后勤=提交录入,财务=复核+导出,领导=审核只读),营销后勤暂不可见审批列表 |
|
||||
| 5 | 风险预警规则 | 已答复:方向确认参数化;首批三条(逾期 90 天/同步失败/超时 3 天)未点名,按默认起步——⑧ 工作台实现 |
|
||||
| 6 | 调整单是否走同一审批链;能否跨月调整已锁月份 | 已答复:同一审批链;跨月调整需管理员授权并留痕(已实现) |
|
||||
| 7 | 待办事项范围 | 已答复:待审批 + 同步失败 + 回款未指定批次起步——⑧ 工作台实现 |
|
||||
| 3.3# | 事项 | 业务答复(2026-10-16) | 落实状态(2026-10-20) | 关联项 / 证据 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | 二次分配标的与余额归属(含与 #15 协助 25% 的关系) | **未答复**(澄清 #1–#3 空白) | **开发侧暂定口径已实现**(非业务答复):分配标的=绩效金额份额;参与人=份额×比例,负责人=全额−已分累计(余额归负责人);行合计守恒;合计>100% 与负责人自分 400 拦截;重算作废全部拆行。协助 25% 维持仅记录不核算。**答复后校准** | 澄清 #1/#2/#3;催办一、A-1 至 A-3;实现 ccfd206 |
|
||||
| 2 | 二次分配审批开关默认值 | 默认开启,可手动关闭 | 已落实:V8 参数种子默认开启;关闭时提交即生效(自动通过留痕) | 澄清 #5;`approval.secondary_allocation.enabled` |
|
||||
| 3 | 「确认回款」定义 | 需人工确认动作,可手动关闭 | 已落实:月结按已确认回款取数;回款确认状态可手动关闭 | 澄清 #8 |
|
||||
| 4 | 审批角色权限清单 | 部分答复:先预设角色,名称与职权待客户确认 | 按最小授权运行:后勤=提交录入,财务=复核+导出,领导=审核只读;营销后勤暂不可见审批列表;permissions/role_permissions 细目待清单 | 澄清 #6;催办一、B-6 |
|
||||
| 5 | 风险预警规则 | 方向确认参数化;首批三条未点名 | 已按默认三条实现:`warning.enabled`、`warning.receipt.overdue.days=90`、`warning.approval.timeout.days=3`(V10);同步失败类待 ⑥ | 澄清 #10 |
|
||||
| 6 | 调整单是否走同一审批链;能否跨月调整已锁月份 | 同一审批链;跨月需管理员授权并留痕 | 已落实:调整单走同一审批链;跨月调整需管理员授权并留痕 | 澄清 #9 |
|
||||
| 7 | 待办事项范围 | 待审批 + 同步失败 + 回款未指定批次起步 | 已落实:工作台待办三项;⑥ 未建时同步项显式降级为「模块未启用」;待办按角色过滤 | 澄清 #11 |
|
||||
|
||||
#### 关联项表(与《待确认事项汇总_2026-10-16》及催办清单的对应)
|
||||
|
||||
| 3.3# | 澄清清单编号 | 催办清单位置 | 实现落点 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | #1/#2/#3(#15 为作废条款关系) | 一、A-1 至 A-3 | 二次分摊拆行(ccfd206) |
|
||||
| 2 | #5 | 已按答复执行(供核阅) | V8 迁移参数种子 |
|
||||
| 3 | #8 | 已按答复执行(供核阅) | 月结取数(settlement_service) |
|
||||
| 4 | #6 | 一、B-6 | permissions/role_permissions 待清单;当前按 role_code 粗粒度 |
|
||||
| 5 | #10 | 已按答复执行(供核阅) | V10 参数 + dashboard_service |
|
||||
| 6 | #9 | 已按答复执行(供核阅) | 调整单审批链与跨月授权 |
|
||||
| 7 | #11 | 已按答复执行(供核阅) | dashboard_service 待办三源 |
|
||||
|
||||
### 3.4 对路线图的影响(本次已修订)
|
||||
|
||||
1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容;第三章待 3.3 各项澄清后,按第二章同粒度补齐 M3 各增量块的任务级实施计划。
|
||||
1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容。M3 各增量块已于 2026-10-19 交付;3.3-1 仍无业务答复,二次分摊当前按开发侧暂定口径运行,答复后补口径变更计划;3.3-4 的权限细目与 3.3-5 的预警规则仍待业务确认。⑥ T+ 同步在 IT 答复前不排任务级计划。
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
|
||||
2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose);
|
||||
3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测);
|
||||
4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量 301+ 用例与 e2e 全绿。
|
||||
4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量后端用例与 e2e 全绿(以 `backend/tests/README.md` 最新运行记录为准)。
|
||||
@@ -42,7 +42,11 @@
|
||||
|
||||
## 二、后端任务
|
||||
|
||||
### T1 常量与元数据(`core/constants.py` 改 + `services/report_meta.py` 新)
|
||||
### T1 常量与元数据(`core/constants.py` 改 + `core/report_meta.py` 新)
|
||||
|
||||
> 实施修订(2026-10-19):`report_meta` 放在 `core/` 而非 `services/`——仓储层
|
||||
> 的区间下钻需要 `DayBucket` 类型,放 services 会让 repository 反向依赖
|
||||
> service 层(违反六层红线)。
|
||||
|
||||
- `MODULE_REPORT = "报表统计"`、`ACTION_EXPORT = "导出"`
|
||||
- `report_meta.py`(叶子模块,禁 app 内导入防循环依赖):`DayBucket` frozen dataclass + `PAYMENT_DAY_BUCKETS` 8 档(≤0 天(先款后货)/1-30/31-60/61-90/91-180/181-360/361+/无天数)、`CUSTOMER_TYPE_LABELS`、`PRODUCT_CATEGORY_LABELS`(含 mixed)、`REPORT_TEMPLATE_VERSION = "V1.0"`、`REPORT_EXPORT_MAX_ROWS = 20000`
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user