Compare commits
53
Commits
e48cfb752d
...
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 | ||
|
|
fb398c3835 | ||
|
|
c5f219423b | ||
|
|
a536608b53 | ||
|
|
ccfd20651b | ||
|
|
48c0a86279 |
No files matched your search
+12
-2
@@ -45,8 +45,9 @@ htmlcov/
|
|||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.tox/
|
.tox/
|
||||||
# 端到端测试产出的截图
|
# 端到端测试产出的截图(脚本以相对路径 e2e/failure.png 落盘,在不同 CWD
|
||||||
frontend/e2e/*.png
|
# 下会落到 frontend/e2e/ 或 frontend/e2e/e2e/,用 ** 一并覆盖)
|
||||||
|
frontend/e2e/**/*.png
|
||||||
|
|
||||||
# 临时文件
|
# 临时文件
|
||||||
*.tmp
|
*.tmp
|
||||||
@@ -64,3 +65,12 @@ CLAUDE.md
|
|||||||
backend/uploads/
|
backend/uploads/
|
||||||
# 数据库备份导出目录(⑨ 备份脚本产物)
|
# 数据库备份导出目录(⑨ 备份脚本产物)
|
||||||
db/backups/
|
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 / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
|
||||||
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
|
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
|
||||||
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
|
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
|
||||||
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
|
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选;`order_by=rating_score` 按评分排序,未评分垫底)/ 新增(分类值域;公司名+负责人唯一) |
|
||||||
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
|
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
|
||||||
|
| PUT | `/api/v1/base/customers/{id}/rating` | 保存评级(评级计算器「保存评分」;只写 `rating_score`/`rating_time`,五项明细不落库 #25) |
|
||||||
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
|
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
|
||||||
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
|
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
|
||||||
|
|
||||||
@@ -114,6 +115,31 @@ app/
|
|||||||
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
|
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
|
||||||
若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
|
若登录接口自身返回 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
|
```bash
|
||||||
@@ -196,6 +222,59 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
|
|||||||
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
|
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
|
||||||
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
|
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
|
||||||
|
|
||||||
|
### 审批快照与方案版本化(2026-10-20 定稿,改动前先读)
|
||||||
|
|
||||||
|
- **审批单带版本号与内容快照**:`approval_orders.version_no` 按 `(order_type, ref_id)` 从 1 递增,重提先撤回旧在途单、但**旧单快照不覆盖**;`snapshot_json` 统一信封 `{schema_version, order_type, captured_at, data}`,`data` 由 `approval_service.{allocation,settlement,adjustment}_content` 组装(Decimal 转 str、日期转 ISO),开关关闭的自动通过路径同样写快照。审批期间业务再改,复核者看到的仍是送审版本。
|
||||||
|
- **两个 `version_no` 语义不同,别混用**:`business_secondary_allocations.version_no` = 方案内容第几版,**仅方案内容(参与人+比例)变化时 +1**;`approval_orders.version_no` = 同一对象第几次送审,**仅送审内容变化时 +1**。改备注不动金额 → 两者都不动;改金额 → 只有审批单版本号动;改方案比例 → 两者都动。
|
||||||
|
- **送审内容未变不重提**:编辑业务时若**存在在途审批单**且其快照 `data` 与本次送审内容逐键相等(`approval_service.pending_matches_snapshot`,只比 `data` 不比 `captured_at`),则保留原单不撤回不重建;否则撤旧提新。方案内容未变时**不新增方案版本行**(`business_service._plan_changed` 按「人员+比例」集合比较,与行序无关)。
|
||||||
|
- **快照的覆盖范围是「方案 + 钱 + 归属」**(业务编号/日期、客户、负责与协助人、销售额、成本额、方案参与人与比例、合计与余额),**不含产品明细行**。因此只改产品构成而金额不变时不会重提——分摊依据(谁分、分多少、基于多少钱)未变;产品明细由复核者按业务编号回业务页查看。
|
||||||
|
- **列表不加载快照列**:快照可达上百 KB,审批单列表、工作台待办、工作台审批超时预警三处查询一律 `defer(ApprovalOrder.snapshot_json)`(与操作日志列表排除 `request_data` 同一理由,参考 `operation_log_repository.list_logs`);详情走 `get_order` 按需读取。
|
||||||
|
- **详情内容来源三态**:`content_source=snapshot`(送审快照)/ `live`(V11 前的历史单体无快照,只读回退当前内容并显式告警)/ `missing`(引用对象不存在且无快照)。**禁止回填历史单快照**——回填等于伪造提交时内容。
|
||||||
|
- **二次分配方案 append-only**:`business_secondary_allocations` 行不物理删除,编辑全量替换与驳回清空只把旧行 `is_active=0`;唯一键为 `(business_id, version_no, personnel_id)`,同一版本内参与人唯一、跨版本可重复。`business_repository.list_allocations(active_only=True)` 是核算与详情回显的唯一取数口径;业务详情 `allocation_history` 返回全部版本供回溯。
|
||||||
|
- **月结提交要锁行**:`settlement_service.submit` 取月结行必须 `for_update=True`——并发提交会让两次 `submit_order` 算出同一个审批单 `version_no`,送审次序失真。
|
||||||
|
- **审批单 `version_no` 有唯一键兜底(V12)**:`uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`——同一对象的第 N 次送审在库里有唯一一行。ORM 侧约束名必须与迁移 DDL 严格一致,否则 autogenerate 误判。
|
||||||
|
- **取版本号必须是「当前读」(`approval_service._next_version`)**,两个禁忌:(a) 不能用普通 `MAX` 查询——请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻,父行锁只串行化写入时刻、**不刷新快照**,MAX 会读到旧值;(b) 不能写成 `select(func.max(...)).with_for_update()`——MySQL 官方明确「Locking clauses are not permitted in queries with aggregate functions or GROUP BY」,聚合上的 FOR UPDATE 语义无保证。正解:锁读该 ref 的现有版本行(`select(version_no)...with_for_update()`),最大值 Python 侧算(同 `no_generator` 套路)。
|
||||||
|
- **版本号冲突的收敛口径**:`submit_order` 内不 catch、不 rollback(红线)。create 路径由外层编号重试循环整体重来(`business_service._NO_RETRY`);编辑/月结提交路径由审计上下文统一回滚,再把 1062 翻译为 400「提交冲突(版本号生成重复),请重试」(同 `param_service` 版本号冲突模板)。唯一键是最后一道防线,不是取号正确性的前提。
|
||||||
|
- **迁移前先修存量**:V11 把存量行 `version_no` 一律置 1,而 V11 前每次重提都新建行——同一 `(order_type, ref_id)` 存在多行 version_no=1,直接加唯一键必报 1062。V12 先重排「组内有重复」的分组(按 id 升序 1..n),其余分组不动(全量重排会改掉前端已展示过的历史版本号)。
|
||||||
|
- **新增审批对象类型**须同时提供快照组装函数并接入 `_content_of` 的 live 回退分支,不要在各业务服务内联 JSON 结构。
|
||||||
|
|
||||||
|
### 工作台口径(M4 ⑧,改动前先读)
|
||||||
|
|
||||||
|
- **只读聚合**:总览只做 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 是安全问题,必须启动即失败。
|
**起服务报 `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:
|
def upgrade() -> None:
|
||||||
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
|
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
|
||||||
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
|
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
|
||||||
op.execute(
|
op.execute("UPDATE param_definitions SET default_value = '1' WHERE id IN (17, 18)")
|
||||||
"UPDATE param_definitions SET default_value = '1' "
|
|
||||||
"WHERE id IN (17, 18)"
|
|
||||||
)
|
|
||||||
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
|
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
|
||||||
op.bulk_insert(
|
op.bulk_insert(
|
||||||
sa.table(
|
sa.table(
|
||||||
@@ -188,6 +185,4 @@ def downgrade() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
|
op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
|
||||||
op.execute(
|
op.execute("UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)")
|
||||||
"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)]
|
LoginUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
def require_roles(*role_codes: str):
|
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
|
||||||
"""要求当前用户具备任一指定角色(依赖工厂)。
|
"""要求当前用户具备任一指定角色(依赖工厂)。
|
||||||
|
|
||||||
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
|
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
|
||||||
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
|
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
|
||||||
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
|
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
|
||||||
|
|
||||||
|
``message`` 允许各模块替换 403 文案(默认值保持审批模块原文案,
|
||||||
|
避免既有调用与测试变动)。
|
||||||
|
|
||||||
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
|
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
|
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||||
codes = set(user_repository.get_role_codes(current_user))
|
codes = set(user_repository.get_role_codes(current_user))
|
||||||
if not codes & set(role_codes):
|
if not codes & set(role_codes):
|
||||||
raise ForbiddenError("无权访问审批管理")
|
raise ForbiddenError(message)
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
return _checker
|
return _checker
|
||||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.api import health
|
from app.api import health
|
||||||
from app.api.v1 import auth
|
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 customers as base_customers
|
||||||
from app.api.v1.base import personnel as base_personnel
|
from app.api.v1.base import personnel as base_personnel
|
||||||
from app.api.v1.base import products as base_products
|
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 receipts as business_receipts
|
||||||
from app.api.v1.business import shipments as business_shipments
|
from app.api.v1.business import shipments as business_shipments
|
||||||
from app.api.v1.calc import router as calc_router
|
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.settlements import router as settlements_router
|
||||||
from app.api.v1.system import approvals as system_approvals
|
from app.api.v1.system import approvals as system_approvals
|
||||||
from app.api.v1.system import business_roles as system_business_roles
|
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_personnel.router)
|
||||||
base_router.include_router(base_products.router)
|
base_router.include_router(base_products.router)
|
||||||
base_router.include_router(base_customers.router)
|
base_router.include_router(base_customers.router)
|
||||||
|
base_router.include_router(base_business_roles.router)
|
||||||
v1_router.include_router(base_router)
|
v1_router.include_router(base_router)
|
||||||
|
|
||||||
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
|
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
|
||||||
@@ -65,5 +69,9 @@ v1_router.include_router(business_router)
|
|||||||
v1_router.include_router(calc_router)
|
v1_router.include_router(calc_router)
|
||||||
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
|
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
|
||||||
v1_router.include_router(settlements_router)
|
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)
|
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,
|
CustomerOut,
|
||||||
CustomerOwnershipIn,
|
CustomerOwnershipIn,
|
||||||
CustomerQuery,
|
CustomerQuery,
|
||||||
|
CustomerRatingIn,
|
||||||
CustomerUpdateIn,
|
CustomerUpdateIn,
|
||||||
OwnershipChangeLogOut,
|
OwnershipChangeLogOut,
|
||||||
)
|
)
|
||||||
@@ -102,6 +103,31 @@ def delete_customer(
|
|||||||
return ok(message="客户已删除")
|
return ok(message="客户已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{customer_id}/rating",
|
||||||
|
response_model=ApiResponse[CustomerOut],
|
||||||
|
summary="保存客户评级",
|
||||||
|
)
|
||||||
|
def rate_customer(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: CustomerRatingIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CustomerOut]:
|
||||||
|
"""保存评级总分(评级计算器「保存评分」)。
|
||||||
|
|
||||||
|
评分是客户档案属性:存总分与评级时间,五项明细不落库(对照表 #25);
|
||||||
|
编辑端点不含评分 —— 评分只走本端点,由计算器统一入口写入。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
customer_service.rate_customer(
|
||||||
|
session, customer_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="评级已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/{customer_id}/ownership",
|
"/{customer_id}/ownership",
|
||||||
response_model=ApiResponse[CustomerOut],
|
response_model=ApiResponse[CustomerOut],
|
||||||
|
|||||||
@@ -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
|
_FONT_SIZE = 25
|
||||||
_PADDING = 8
|
_PADDING = 8
|
||||||
|
|
||||||
# 与前端配色呼应:墨色字符、青灰干扰、素纸底
|
# 与前端 v3 配色呼应(--ys-ink / --ys-ink-2 / --ys-ink-3 / --ys-paper):
|
||||||
_INK = (15, 54, 70)
|
# 墨色字符、次级墨干扰、辅助墨噪点、纸白底
|
||||||
_INTERFERENCE = (126, 156, 170)
|
_INK = (28, 35, 51) # #1c2333
|
||||||
_SPECKLE = (168, 183, 192)
|
_INTERFERENCE = (91, 101, 119) # #5b6577
|
||||||
_PAPER = (247, 246, 242)
|
_SPECKLE = (154, 163, 178) # #9aa3b2
|
||||||
|
_PAPER = (255, 255, 255) # #ffffff
|
||||||
|
|
||||||
_SPECKLE_COUNT = 46
|
_SPECKLE_COUNT = 46
|
||||||
_INTERFERENCE_LINES = 2
|
_INTERFERENCE_LINES = 2
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ MODULE_SYSTEM_SETTINGS = "系统设置"
|
|||||||
MODULE_APPROVAL = "审批管理"
|
MODULE_APPROVAL = "审批管理"
|
||||||
MODULE_CALC = "绩效核算"
|
MODULE_CALC = "绩效核算"
|
||||||
MODULE_SETTLEMENT = "月度结算"
|
MODULE_SETTLEMENT = "月度结算"
|
||||||
|
MODULE_REPORT = "报表统计"
|
||||||
|
|
||||||
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
|
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
|
||||||
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
|
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
|
||||||
@@ -105,3 +106,4 @@ ACTION_ENABLE = "启用"
|
|||||||
ACTION_DISABLE = "禁用"
|
ACTION_DISABLE = "禁用"
|
||||||
ACTION_APPROVE = "审批通过"
|
ACTION_APPROVE = "审批通过"
|
||||||
ACTION_REJECT = "审批驳回"
|
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 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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||||
@@ -54,7 +63,17 @@ class ApprovalOrder(Base, TimestampMixin):
|
|||||||
__tablename__ = "approval_orders"
|
__tablename__ = "approval_orders"
|
||||||
__table_args__ = mysql_table_args(
|
__table_args__ = mysql_table_args(
|
||||||
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
|
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
|
||||||
|
# 同一引用对象的第 N 次送审在库里有唯一一行:并发提单算重版本号时
|
||||||
|
# 由 DB 兜住(应用层取号走锁读,见 approval_service._next_version)
|
||||||
|
UniqueConstraint(
|
||||||
|
"order_type",
|
||||||
|
"ref_id",
|
||||||
|
"version_no",
|
||||||
|
name="uk_approval_orders_order_type_ref_version",
|
||||||
|
),
|
||||||
Index("idx_approval_orders_type_status", "order_type", "status"),
|
Index("idx_approval_orders_type_status", "order_type", "status"),
|
||||||
|
# 版本号按 (order_type, ref_id) 递增,须有对应索引避免全表 max 扫描
|
||||||
|
Index("idx_approval_orders_type_ref", "order_type", "ref_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
id: Mapped[int] = mapped_column(
|
||||||
@@ -76,6 +95,17 @@ class ApprovalOrder(Base, TimestampMixin):
|
|||||||
refer_no: Mapped[str] = mapped_column(
|
refer_no: Mapped[str] = mapped_column(
|
||||||
String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)"
|
String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)"
|
||||||
)
|
)
|
||||||
|
version_no: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖;"
|
||||||
|
"同一对象内唯一,由uk_approval_orders_order_type_ref_version强制)",
|
||||||
|
)
|
||||||
|
snapshot_json: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
comment="提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)",
|
||||||
|
)
|
||||||
status: Mapped[int] = mapped_column(
|
status: Mapped[int] = mapped_column(
|
||||||
TINYINT_TYPE,
|
TINYINT_TYPE,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@@ -15,7 +15,17 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.models.base import (
|
from app.models.base import (
|
||||||
@@ -42,27 +52,34 @@ class SecondaryAllocation(Base, TimestampMixin):
|
|||||||
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
|
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
|
||||||
与比例,响应以 hint 提示合计/余额,不做强制校验
|
与比例,响应以 hint 提示合计/余额,不做强制校验
|
||||||
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
|
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
|
||||||
- **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按
|
- **版本行 append-only**:编辑全量替换与驳回清空都把旧行置
|
||||||
核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表
|
``is_active=0`` 而非物理删除,历史方案可回溯;当前生效行由
|
||||||
|
``is_active=1`` 唯一标识。核算仍按「当前生效行」取快照
|
||||||
|
(``performance_ledgers.snapshot_json.alloc_plan`` 固化),版本化只
|
||||||
|
补齐「方案事实留存」,不改变核算口径
|
||||||
|
|
||||||
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
|
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
|
||||||
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
|
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
|
||||||
冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id),
|
冗余固化,人离职后方案回显仍有名。uk 锁
|
||||||
同一合同的同一参与人仅一行,重复提交在服务层聚合。
|
(business_id, version_no, personnel_id),同一版本内同一参与人仅一行,
|
||||||
|
重复提交在服务层聚合;跨版本允许同一人再次出现。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "business_secondary_allocations"
|
__tablename__ = "business_secondary_allocations"
|
||||||
__table_args__ = mysql_table_args(
|
__table_args__ = mysql_table_args(
|
||||||
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
|
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
|
||||||
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
|
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
|
||||||
"业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
|
"业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)",
|
||||||
UniqueConstraint(
|
UniqueConstraint(
|
||||||
"business_id",
|
"business_id",
|
||||||
|
"version_no",
|
||||||
"personnel_id",
|
"personnel_id",
|
||||||
name="uk_business_secondary_allocations_business_personnel",
|
name="uk_business_secondary_allocations_business_version_personnel",
|
||||||
),
|
),
|
||||||
Index("idx_business_secondary_allocations_business_id", "business_id"),
|
Index("idx_business_secondary_allocations_business_id", "business_id"),
|
||||||
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
|
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
|
||||||
|
# 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1
|
||||||
|
Index("idx_business_secondary_allocations_business_active", "business_id", "is_active"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
id: Mapped[int] = mapped_column(
|
||||||
@@ -74,6 +91,12 @@ class SecondaryAllocation(Base, TimestampMixin):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
comment="业务ID(businesses.id;业务删除随删)",
|
comment="业务ID(businesses.id;业务删除随删)",
|
||||||
)
|
)
|
||||||
|
version_no: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)",
|
||||||
|
)
|
||||||
personnel_id: Mapped[int] = mapped_column(
|
personnel_id: Mapped[int] = mapped_column(
|
||||||
BIGINT_UNSIGNED,
|
BIGINT_UNSIGNED,
|
||||||
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
@@ -88,6 +111,12 @@ class SecondaryAllocation(Base, TimestampMixin):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
|
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
|
||||||
)
|
)
|
||||||
|
is_active: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Business(Base, TimestampMixin, TPlusSyncMixin):
|
class Business(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
|
|||||||
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
||||||
)
|
)
|
||||||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
rating_score: Mapped[int | None] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
comment="评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)",
|
||||||
|
)
|
||||||
|
rating_time: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
comment="最近评级时间(评级计算器保存评分时刷新;人工维护字段)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OwnershipChangeLog(Base):
|
class OwnershipChangeLog(Base):
|
||||||
|
|||||||
@@ -5,9 +5,7 @@
|
|||||||
models/customer.py 定义)。
|
models/customer.py 定义)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date
|
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update
|
||||||
|
|
||||||
from sqlalchemy import Row, Select, delete, exists, func, or_, select
|
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -23,6 +21,8 @@ from app.models import (
|
|||||||
)
|
)
|
||||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||||
from app.repositories import ownership_log_repository
|
from app.repositories import ownership_log_repository
|
||||||
|
from app.repositories.ownership_log_repository import OwnershipLogRef
|
||||||
|
from app.schemas.business import BusinessQuery
|
||||||
|
|
||||||
|
|
||||||
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
|
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
|
||||||
@@ -77,50 +77,32 @@ def get_with_names(
|
|||||||
return session.execute(stmt).first()
|
return session.execute(stmt).first()
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
keyword: str | None,
|
|
||||||
status: int | None,
|
|
||||||
customer_id: int | None,
|
|
||||||
owner_personnel_id: int | None,
|
|
||||||
date_from: date | None,
|
|
||||||
date_to: date | None,
|
|
||||||
) -> Select:
|
|
||||||
"""业务列表的筛选条件(调用方须已 join Customer)。"""
|
"""业务列表的筛选条件(调用方须已 join Customer)。"""
|
||||||
if keyword:
|
if query.keyword:
|
||||||
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
|
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
|
||||||
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
|
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
Business.business_no.contains(keyword, autoescape=True),
|
Business.business_no.contains(query.keyword, autoescape=True),
|
||||||
Customer.company_name.contains(keyword, autoescape=True),
|
Customer.company_name.contains(query.keyword, autoescape=True),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if status is not None:
|
if query.status is not None:
|
||||||
stmt = stmt.where(Business.status == status)
|
stmt = stmt.where(Business.status == query.status)
|
||||||
if customer_id is not None:
|
if query.customer_id is not None:
|
||||||
stmt = stmt.where(Business.customer_id == customer_id)
|
stmt = stmt.where(Business.customer_id == query.customer_id)
|
||||||
if owner_personnel_id is not None:
|
if query.owner_personnel_id is not None:
|
||||||
stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id)
|
stmt = stmt.where(Business.owner_personnel_id == query.owner_personnel_id)
|
||||||
if date_from is not None:
|
if query.date_from is not None:
|
||||||
stmt = stmt.where(Business.business_date >= date_from)
|
stmt = stmt.where(Business.business_date >= query.date_from)
|
||||||
if date_to is not None:
|
if query.date_to is not None:
|
||||||
stmt = stmt.where(Business.business_date <= date_to)
|
stmt = stmt.where(Business.business_date <= query.date_to)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
def list_businesses(
|
def list_businesses(
|
||||||
session: Session,
|
session: Session, query: BusinessQuery
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
keyword: str | None,
|
|
||||||
status: int | None,
|
|
||||||
customer_id: int | None,
|
|
||||||
owner_personnel_id: int | None,
|
|
||||||
date_from: date | None,
|
|
||||||
date_to: date | None,
|
|
||||||
) -> list[tuple[Business, str, str | None, str | None]]:
|
) -> list[tuple[Business, str, str | None, str | None]]:
|
||||||
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
|
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
|
||||||
owner = aliased(Personnel)
|
owner = aliased(Personnel)
|
||||||
@@ -128,49 +110,24 @@ def list_businesses(
|
|||||||
stmt = select(
|
stmt = select(
|
||||||
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
|
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
|
||||||
).join(Customer, Customer.id == Business.customer_id)
|
).join(Customer, Customer.id == Business.customer_id)
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(stmt, query)
|
||||||
stmt,
|
|
||||||
keyword=keyword,
|
|
||||||
status=status,
|
|
||||||
customer_id=customer_id,
|
|
||||||
owner_personnel_id=owner_personnel_id,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
)
|
|
||||||
stmt = (
|
stmt = (
|
||||||
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
||||||
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
|
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
|
||||||
.order_by(Business.id.desc())
|
.order_by(Business.id.desc())
|
||||||
.offset(offset)
|
.offset(query.offset)
|
||||||
.limit(limit)
|
.limit(query.page_size)
|
||||||
)
|
)
|
||||||
return list(session.execute(stmt).all())
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
def count_businesses(
|
def count_businesses(session: Session, query: BusinessQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
keyword: str | None,
|
|
||||||
status: int | None,
|
|
||||||
customer_id: int | None,
|
|
||||||
owner_personnel_id: int | None,
|
|
||||||
date_from: date | None,
|
|
||||||
date_to: date | None,
|
|
||||||
) -> int:
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(Business)
|
.select_from(Business)
|
||||||
.join(Customer, Customer.id == Business.customer_id)
|
.join(Customer, Customer.id == Business.customer_id)
|
||||||
)
|
)
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(stmt, query)
|
||||||
stmt,
|
|
||||||
keyword=keyword,
|
|
||||||
status=status,
|
|
||||||
customer_id=customer_id,
|
|
||||||
owner_personnel_id=owner_personnel_id,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
@@ -199,43 +156,75 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem])
|
|||||||
session.flush()
|
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(
|
return list(
|
||||||
session.execute(
|
session.execute(
|
||||||
select(SecondaryAllocation)
|
stmt.order_by(SecondaryAllocation.version_no, SecondaryAllocation.id)
|
||||||
.where(SecondaryAllocation.business_id == business_id)
|
|
||||||
.order_by(SecondaryAllocation.id)
|
|
||||||
).scalars()
|
).scalars()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_allocation_versions(session: Session, business_id: int) -> list[SecondaryAllocation]:
|
||||||
|
"""方案全部版本行(含历史;业务详情「方案历史」用,按版本正序)。"""
|
||||||
|
return list_allocations(session, business_id, active_only=False)
|
||||||
|
|
||||||
|
|
||||||
def replace_allocations(
|
def replace_allocations(
|
||||||
session: Session, business_id: int, rows: list[SecondaryAllocation]
|
session: Session, business_id: int, rows: list[SecondaryAllocation]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""全量替换二次分配方案(不 commit,事务边界在服务层)。
|
"""全量替换二次分配方案:旧生效行置历史,新行按下一版本号插入。
|
||||||
|
|
||||||
本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的
|
不 commit(事务边界在服务层)。版本行 append-only——编辑与清空都不
|
||||||
防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。
|
物理删除,驳回后仍可回溯被否决方案的参与人与比例。空 ``rows`` 表示
|
||||||
|
只清空生效态(不产生新版本)。
|
||||||
"""
|
"""
|
||||||
|
deactivate_allocations(session, business_id)
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
next_version = (
|
||||||
session.execute(
|
session.execute(
|
||||||
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
select(func.coalesce(func.max(SecondaryAllocation.version_no), 0)).where(
|
||||||
|
SecondaryAllocation.business_id == business_id
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
+ 1
|
||||||
)
|
)
|
||||||
session.flush()
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
row.business_id = business_id
|
row.business_id = business_id
|
||||||
|
row.version_no = next_version
|
||||||
|
row.is_active = 1
|
||||||
session.add(row)
|
session.add(row)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
def delete_allocations(session: Session, business_id: int) -> None:
|
def deactivate_allocations(session: Session, business_id: int) -> None:
|
||||||
"""清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。
|
"""把业务的当前生效方案置为历史(审批驳回/编辑替换时调用;行保留)。
|
||||||
|
|
||||||
被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到);
|
被否决的方案不得以「当前生效行」身份继续被核算取到;``is_active=0``
|
||||||
方案事实由审批单留痕与操作日志 request_data 承载。
|
后核算侧自然读不到,而历史版本仍可在业务详情回溯。不 commit。
|
||||||
|
|
||||||
|
显式声明 ``synchronize_session="fetch"``:Core UPDATE 默认走 auto 策略
|
||||||
|
(优先 evaluate,本例的条件恰好能在 Python 侧求值,故实测也会刷新在途
|
||||||
|
对象)。显式写 fetch 是为了不把「在途对象被刷新」这件事押在 evaluate
|
||||||
|
能否解析条件上——条件一旦复杂化,auto 会退化为不刷新,同一会话里已
|
||||||
|
加载的方案行就会停留在 ``is_active=1`` 的过期内存值(查询虽有
|
||||||
|
is_active 过滤,但行对象本身仍是「生效态」,被序列化或参与判断就是
|
||||||
|
错的)。
|
||||||
"""
|
"""
|
||||||
session.execute(
|
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()
|
session.flush()
|
||||||
|
|
||||||
@@ -281,18 +270,17 @@ def list_ownership_logs(
|
|||||||
) -> list[Row]:
|
) -> list[Row]:
|
||||||
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
|
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
|
||||||
return ownership_log_repository.list_by_ref(
|
return ownership_log_repository.list_by_ref(
|
||||||
session,
|
session, _business_log_ref(business_id), offset=offset, limit=limit
|
||||||
owner_type=OWNER_TYPE_BUSINESS,
|
|
||||||
ref_column=OwnershipChangeLog.business_id,
|
|
||||||
ref_id=business_id,
|
|
||||||
offset=offset,
|
|
||||||
limit=limit,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def count_ownership_logs(session: Session, business_id: int) -> int:
|
def count_ownership_logs(session: Session, business_id: int) -> int:
|
||||||
return ownership_log_repository.count_by_ref(
|
return ownership_log_repository.count_by_ref(session, _business_log_ref(business_id))
|
||||||
session,
|
|
||||||
|
|
||||||
|
def _business_log_ref(business_id: int) -> OwnershipLogRef:
|
||||||
|
"""业务维度的归属留痕锚点。"""
|
||||||
|
return OwnershipLogRef(
|
||||||
owner_type=OWNER_TYPE_BUSINESS,
|
owner_type=OWNER_TYPE_BUSINESS,
|
||||||
ref_column=OwnershipChangeLog.business_id,
|
ref_column=OwnershipChangeLog.business_id,
|
||||||
ref_id=business_id,
|
ref_id=business_id,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import BusinessRole
|
from app.models import BusinessRole
|
||||||
|
from app.schemas.business_role import BusinessRoleQuery
|
||||||
|
|
||||||
# ==================== 读取 ====================
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
@@ -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}
|
return {role.role_code: role for role in rows}
|
||||||
|
|
||||||
|
|
||||||
def list_roles(
|
def list_roles(session: Session, query: BusinessRoleQuery) -> list[BusinessRole]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
keyword: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
) -> list[BusinessRole]:
|
|
||||||
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
|
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
|
||||||
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
|
stmt = _apply_filters(select(BusinessRole), query)
|
||||||
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit)
|
stmt = (
|
||||||
|
stmt.order_by(BusinessRole.sort_order, BusinessRole.id)
|
||||||
|
.offset(query.offset)
|
||||||
|
.limit(query.page_size)
|
||||||
|
)
|
||||||
return list(session.execute(stmt).scalars())
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
def count_roles(session: Session, *, 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())
|
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 通配符)——岗位编码含下划线,
|
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
|
||||||
不转义时 ``_`` 会当单字符通配执行。
|
不转义时 ``_`` 会当单字符通配执行。
|
||||||
"""
|
"""
|
||||||
if keyword:
|
if query.keyword:
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
BusinessRole.role_code.contains(keyword, autoescape=True),
|
BusinessRole.role_code.contains(query.keyword, autoescape=True),
|
||||||
BusinessRole.role_name.contains(keyword, autoescape=True),
|
BusinessRole.role_name.contains(query.keyword, autoescape=True),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if status is not None:
|
if query.status is not None:
|
||||||
stmt = stmt.where(BusinessRole.status == status)
|
stmt = stmt.where(BusinessRole.status == query.status)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ from sqlalchemy.orm import Session
|
|||||||
from app.models import Customer, OwnershipChangeLog, Personnel
|
from app.models import Customer, OwnershipChangeLog, Personnel
|
||||||
from app.models.customer import OWNER_TYPE_CUSTOMER
|
from app.models.customer import OWNER_TYPE_CUSTOMER
|
||||||
from app.repositories import ownership_log_repository
|
from app.repositories import ownership_log_repository
|
||||||
|
from app.repositories.ownership_log_repository import OwnershipLogRef
|
||||||
from app.repositories.query_utils import fuzzy
|
from app.repositories.query_utils import fuzzy
|
||||||
|
from app.schemas.customer import CustomerQuery
|
||||||
|
|
||||||
# ==================== 读取 ====================
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
@@ -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()
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
def list_customers(
|
def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
keyword: str | None = None,
|
|
||||||
customer_type: str | None = None,
|
|
||||||
owner_personnel_id: int | None = None,
|
|
||||||
) -> list[Row]:
|
|
||||||
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
|
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
|
||||||
|
|
||||||
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
|
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
|
||||||
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
|
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
|
||||||
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
|
排序键由查询白名单决定(见 ``CustomerQuery.order_by``):
|
||||||
|
id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。
|
||||||
"""
|
"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(_base_select(), query)
|
||||||
_base_select(),
|
stmt = _apply_order(stmt, query)
|
||||||
keyword=keyword,
|
stmt = stmt.offset(query.offset).limit(query.page_size)
|
||||||
customer_type=customer_type,
|
|
||||||
owner_personnel_id=owner_personnel_id,
|
|
||||||
)
|
|
||||||
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit)
|
|
||||||
return list(session.execute(stmt).all())
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
def count_customers(
|
def count_customers(session: Session, query: CustomerQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
keyword: str | None = None,
|
|
||||||
customer_type: str | None = None,
|
|
||||||
owner_personnel_id: int | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""统计符合条件的客户总数,供分页使用。
|
"""统计符合条件的客户总数,供分页使用。
|
||||||
|
|
||||||
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||||||
``_apply_filters`` 单点保证)。
|
``_apply_filters`` 单点保证)。
|
||||||
"""
|
"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(select(func.count(Customer.id)), query)
|
||||||
select(func.count(Customer.id)),
|
|
||||||
keyword=keyword,
|
|
||||||
customer_type=customer_type,
|
|
||||||
owner_personnel_id=owner_personnel_id,
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
@@ -101,9 +81,7 @@ def list_ownership_logs(
|
|||||||
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
|
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
|
||||||
return ownership_log_repository.list_by_ref(
|
return ownership_log_repository.list_by_ref(
|
||||||
session,
|
session,
|
||||||
owner_type=OWNER_TYPE_CUSTOMER,
|
_customer_log_ref(customer_id),
|
||||||
ref_column=OwnershipChangeLog.customer_id,
|
|
||||||
ref_id=customer_id,
|
|
||||||
offset=offset,
|
offset=offset,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
@@ -111,8 +89,12 @@ def list_ownership_logs(
|
|||||||
|
|
||||||
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
|
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
|
||||||
"""统计某客户的归属留痕总数,供分页使用。"""
|
"""统计某客户的归属留痕总数,供分页使用。"""
|
||||||
return ownership_log_repository.count_by_ref(
|
return ownership_log_repository.count_by_ref(session, _customer_log_ref(customer_id))
|
||||||
session,
|
|
||||||
|
|
||||||
|
def _customer_log_ref(customer_id: int) -> OwnershipLogRef:
|
||||||
|
"""客户维度的归属留痕锚点。"""
|
||||||
|
return OwnershipLogRef(
|
||||||
owner_type=OWNER_TYPE_CUSTOMER,
|
owner_type=OWNER_TYPE_CUSTOMER,
|
||||||
ref_column=OwnershipChangeLog.customer_id,
|
ref_column=OwnershipChangeLog.customer_id,
|
||||||
ref_id=customer_id,
|
ref_id=customer_id,
|
||||||
@@ -126,20 +108,14 @@ def _base_select() -> Select:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
keyword: str | None,
|
|
||||||
customer_type: str | None,
|
|
||||||
owner_personnel_id: int | None,
|
|
||||||
) -> Select:
|
|
||||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||||
if keyword:
|
if query.keyword:
|
||||||
stmt = stmt.where(_keyword_clause(keyword))
|
stmt = stmt.where(_keyword_clause(query.keyword))
|
||||||
if customer_type:
|
if query.customer_type:
|
||||||
stmt = stmt.where(Customer.customer_type == customer_type)
|
stmt = stmt.where(Customer.customer_type == query.customer_type)
|
||||||
if owner_personnel_id is not None:
|
if query.owner_personnel_id is not None:
|
||||||
stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id)
|
stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id)
|
||||||
return stmt
|
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 import Select, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -10,68 +12,73 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
|
|||||||
_USER_AGENT_MAX_LEN = 255
|
_USER_AGENT_MAX_LEN = 255
|
||||||
|
|
||||||
|
|
||||||
def add(
|
@dataclass(frozen=True, slots=True)
|
||||||
session: Session,
|
class LoginLogEntry:
|
||||||
*,
|
"""一条待写入的登录日志(全字段 DTO)。
|
||||||
user_id: int | None,
|
|
||||||
username: str,
|
与 ``OperationLogEntry`` 同款做法:写入字段平铺传递时,任何一处漏传
|
||||||
ip: str | None,
|
都只表现为日志字段缺失、不报错(success 默认 0 的语义尤其隐蔽)。
|
||||||
user_agent: str | None,
|
"""
|
||||||
success: bool,
|
|
||||||
fail_reason: str | None = None,
|
user_id: int | None
|
||||||
) -> 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。
|
"""写入一条登录日志。只 flush 不 commit。
|
||||||
|
|
||||||
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
|
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
|
||||||
|
|
||||||
:param user_id: 账号不存在时传 None(列本身可空)
|
:param entry.user_id: 账号不存在时为 None(列本身可空)
|
||||||
:param username: 用户名原样记录,便于账号不存在时也能追溯
|
:param entry.username: 用户名原样记录,便于账号不存在时也能追溯
|
||||||
"""
|
"""
|
||||||
session.add(
|
session.add(
|
||||||
LoginLog(
|
LoginLog(
|
||||||
user_id=user_id,
|
user_id=entry.user_id,
|
||||||
username=username,
|
username=entry.username,
|
||||||
ip=ip,
|
ip=entry.ip,
|
||||||
user_agent=user_agent[:_USER_AGENT_MAX_LEN] if user_agent else None,
|
user_agent=entry.user_agent[:_USER_AGENT_MAX_LEN] if entry.user_agent else None,
|
||||||
success=1 if success else 0,
|
success=1 if entry.success else 0,
|
||||||
fail_reason=fail_reason,
|
fail_reason=entry.fail_reason,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
def list_logs(
|
def list_logs(session: Session, query: LoginLogListQuery) -> list[LoginLog]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
filters: LogFilters,
|
|
||||||
ip: str | None = None,
|
|
||||||
) -> list[LoginLog]:
|
|
||||||
"""分页查询登录日志。
|
"""分页查询登录日志。
|
||||||
|
|
||||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||||
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
||||||
"""
|
"""
|
||||||
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
|
stmt = _apply_filters(select(LoginLog), query)
|
||||||
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
|
stmt = stmt.order_by(LoginLog.id.desc()).offset(query.offset).limit(query.limit)
|
||||||
return list(session.execute(stmt).scalars())
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
def count_logs(
|
def count_logs(session: Session, query: LoginLogListQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
filters: LogFilters,
|
|
||||||
ip: str | None = None,
|
|
||||||
) -> 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())
|
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,与操作日志同口径)。"""
|
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
|
||||||
stmt = apply_log_filters(stmt, LoginLog, filters)
|
stmt = apply_log_filters(stmt, LoginLog, query.filters)
|
||||||
if ip:
|
if query.ip:
|
||||||
stmt = stmt.where(LoginLog.ip == ip)
|
stmt = stmt.where(LoginLog.ip == query.ip)
|
||||||
return stmt
|
return stmt
|
||||||
@@ -107,15 +107,18 @@ def get_by_id(session: Session, log_id: int) -> OperationLog | None:
|
|||||||
return session.get(OperationLog, log_id)
|
return session.get(OperationLog, log_id)
|
||||||
|
|
||||||
|
|
||||||
def list_logs(
|
@dataclass(frozen=True, slots=True)
|
||||||
session: Session,
|
class OperationLogListQuery:
|
||||||
*,
|
"""操作日志列表的查询条件(筛选 + 分页)。"""
|
||||||
offset: int,
|
|
||||||
limit: int,
|
filters: LogFilters
|
||||||
filters: LogFilters,
|
offset: int = 0
|
||||||
module: str | None = None,
|
limit: int = 20
|
||||||
action: str | None = None,
|
module: str | None = None
|
||||||
) -> list[OperationLog]:
|
action: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def list_logs(session: Session, query: OperationLogListQuery) -> list[OperationLog]:
|
||||||
"""分页查询操作日志。
|
"""分页查询操作日志。
|
||||||
|
|
||||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||||
@@ -127,41 +130,34 @@ def list_logs(
|
|||||||
"""
|
"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(
|
||||||
select(OperationLog).options(defer(OperationLog.request_data)),
|
select(OperationLog).options(defer(OperationLog.request_data)),
|
||||||
filters=filters,
|
query,
|
||||||
module=module,
|
|
||||||
action=action,
|
|
||||||
)
|
)
|
||||||
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())
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
def count_logs(
|
def count_logs(session: Session, query: OperationLogListQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
filters: LogFilters,
|
|
||||||
module: str | None = None,
|
|
||||||
action: str | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""统计符合条件的日志总数,供分页使用。"""
|
"""统计符合条件的日志总数,供分页使用。"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(select(func.count(OperationLog.id)), query)
|
||||||
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def distinct_modules(session: Session) -> list[str]:
|
||||||
stmt: Select,
|
"""全部模块名(去重、按名称升序):工作台「最近动态」的模块筛选项。
|
||||||
*,
|
|
||||||
filters: LogFilters,
|
交给 SQL 的 DISTINCT,不在应用层聚合 —— 应用层去重要把整列拉进内存。
|
||||||
module: str | None,
|
"""
|
||||||
action: str | None,
|
stmt = select(OperationLog.module).distinct().order_by(OperationLog.module)
|
||||||
) -> Select:
|
return [module for module in session.execute(stmt).scalars() if module]
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
|
||||||
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
|
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
|
||||||
stmt = apply_log_filters(stmt, OperationLog, filters)
|
stmt = apply_log_filters(stmt, OperationLog, query.filters)
|
||||||
if module:
|
if query.module:
|
||||||
stmt = stmt.where(OperationLog.module == module)
|
stmt = stmt.where(OperationLog.module == query.module)
|
||||||
if action:
|
if query.action:
|
||||||
stmt = stmt.where(OperationLog.action == action)
|
stmt = stmt.where(OperationLog.action == query.action)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,32 @@ owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧
|
|||||||
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
|
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import Row, func, select
|
from sqlalchemy import Row, func, select
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app.models import OwnershipChangeLog, Personnel
|
from app.models import OwnershipChangeLog, Personnel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OwnershipLogRef:
|
||||||
|
"""归属留痕的引用锚点(维度类型 + 引用列 + 引用 ID)。
|
||||||
|
|
||||||
|
引用列是 SQLAlchemy 列表达式(如 ``OwnershipChangeLog.customer_id``),
|
||||||
|
不是可序列化值 —— 故用冻结 dataclass 而非 Pydantic 模型。
|
||||||
|
"""
|
||||||
|
|
||||||
|
owner_type: int
|
||||||
|
ref_column: Any
|
||||||
|
ref_id: int
|
||||||
|
|
||||||
|
|
||||||
def list_by_ref(
|
def list_by_ref(
|
||||||
session: Session,
|
session: Session,
|
||||||
|
ref: OwnershipLogRef,
|
||||||
*,
|
*,
|
||||||
owner_type: int,
|
|
||||||
ref_column,
|
|
||||||
ref_id: int,
|
|
||||||
offset: int,
|
offset: int,
|
||||||
limit: int,
|
limit: int,
|
||||||
) -> list[Row]:
|
) -> list[Row]:
|
||||||
@@ -32,7 +46,7 @@ def list_by_ref(
|
|||||||
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
|
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
|
||||||
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
|
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
|
||||||
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
|
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
|
||||||
.where(OwnershipChangeLog.owner_type == 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())
|
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -40,11 +54,11 @@ def list_by_ref(
|
|||||||
return list(session.execute(stmt).all())
|
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(
|
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
||||||
OwnershipChangeLog.owner_type == owner_type,
|
OwnershipChangeLog.owner_type == ref.owner_type,
|
||||||
ref_column == ref_id,
|
ref.ref_column == ref.ref_id,
|
||||||
)
|
)
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy import Select, delete, func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import ParamChangeBatch, ParamDefinition, ParamValue
|
from app.models import ParamChangeBatch, ParamDefinition, ParamValue
|
||||||
|
from app.schemas.param import ParamBatchQuery, ParamHistoryQuery
|
||||||
|
|
||||||
# ==================== 读取 ====================
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
@@ -97,27 +98,27 @@ def list_definitions_by_codes(
|
|||||||
|
|
||||||
|
|
||||||
def list_history(
|
def list_history(
|
||||||
session: Session,
|
session: Session, param_id: int, query: ParamHistoryQuery
|
||||||
param_id: int,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
dimension_value: str | None = None,
|
|
||||||
) -> list[tuple[ParamValue, ParamChangeBatch]]:
|
) -> list[tuple[ParamValue, ParamChangeBatch]]:
|
||||||
"""单参数的版本历史(含批次信息),最新在前。"""
|
"""单参数的版本历史(含批次信息),最新在前。
|
||||||
stmt = _history_stmt(session, param_id, dimension_value=dimension_value)
|
|
||||||
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(
|
stmt = select(func.count()).select_from(_history_stmt(param_id, query).subquery())
|
||||||
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select:
|
def _history_stmt(param_id: int, query: ParamHistoryQuery) -> Select:
|
||||||
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
|
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
|
||||||
stmt = (
|
stmt = (
|
||||||
select(ParamValue, ParamChangeBatch)
|
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)
|
.where(ParamValue.param_id == param_id)
|
||||||
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
|
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
|
||||||
)
|
)
|
||||||
if dimension_value is not None:
|
if query.dimension_value is not None:
|
||||||
stmt = stmt.where(ParamValue.dimension_value == dimension_value)
|
stmt = stmt.where(ParamValue.dimension_value == query.dimension_value)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
@@ -150,41 +151,30 @@ def get_batch_by_id(
|
|||||||
return session.get(ParamChangeBatch, batch_id)
|
return session.get(ParamChangeBatch, batch_id)
|
||||||
|
|
||||||
|
|
||||||
def list_batches(
|
def list_batches(session: Session, query: ParamBatchQuery) -> list[ParamChangeBatch]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
start_date: date | None = None,
|
|
||||||
end_date: date | None = None,
|
|
||||||
) -> list[ParamChangeBatch]:
|
|
||||||
"""分页查询变更批次(按创建时间倒序)。"""
|
"""分页查询变更批次(按创建时间倒序)。"""
|
||||||
stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date)
|
stmt = _batch_filters(select(ParamChangeBatch), query)
|
||||||
return list(
|
return list(
|
||||||
session.execute(
|
session.execute(
|
||||||
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
|
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
|
||||||
.offset(offset)
|
.offset(query.offset)
|
||||||
.limit(limit)
|
.limit(query.page_size)
|
||||||
).scalars()
|
).scalars()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def count_batches(
|
def count_batches(session: Session, query: ParamBatchQuery) -> int:
|
||||||
session: Session, *, start_date: date | None = None, end_date: date | None = None
|
|
||||||
) -> int:
|
|
||||||
"""统计符合条件的批次总数。"""
|
"""统计符合条件的批次总数。"""
|
||||||
stmt = _batch_filters(
|
stmt = _batch_filters(select(func.count(ParamChangeBatch.id)), query)
|
||||||
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select:
|
def _batch_filters(stmt: Select, query: ParamBatchQuery) -> Select:
|
||||||
"""批次筛选(生效日期区间,含边界)。列表与计数共用。"""
|
"""批次筛选(生效日期区间,含边界)。列表与计数共用。"""
|
||||||
if start_date is not None:
|
if query.start_date is not None:
|
||||||
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
|
stmt = stmt.where(ParamChangeBatch.effective_date >= query.start_date)
|
||||||
if end_date is not None:
|
if query.end_date is not None:
|
||||||
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
|
stmt = stmt.where(ParamChangeBatch.effective_date <= query.end_date)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ def get_by_id(session: Session, ledger_id: int) -> PerformanceLedger | None:
|
|||||||
def get_active_by_receipt(
|
def get_active_by_receipt(
|
||||||
session: Session, receipt_id: int, *, calc_type: int = 1
|
session: Session, receipt_id: int, *, calc_type: int = 1
|
||||||
) -> PerformanceLedger | None:
|
) -> PerformanceLedger | None:
|
||||||
"""取某回款的当前生效核算行(is_active=1;无则 None)。"""
|
"""取某回款的当前生效核算行(is_active=1;无则 None)。
|
||||||
|
|
||||||
|
二次分摊拆行后同回款可有多行(负责人+参与人)——重算作废用
|
||||||
|
``list_active_by_receipt``;本函数仅限确知单行的调用方(重算前的
|
||||||
|
存在性判断按业务维度走 has_active_for_business)。
|
||||||
|
"""
|
||||||
return session.execute(
|
return session.execute(
|
||||||
select(PerformanceLedger).where(
|
select(PerformanceLedger).where(
|
||||||
PerformanceLedger.receipt_id == receipt_id,
|
PerformanceLedger.receipt_id == receipt_id,
|
||||||
@@ -26,6 +31,21 @@ def get_active_by_receipt(
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def list_active_by_receipt(
|
||||||
|
session: Session, receipt_id: int, *, calc_type: int = 1
|
||||||
|
) -> list[PerformanceLedger]:
|
||||||
|
"""取某回款当前生效的**全部**核算行(分摊拆行后负责人+参与人各一行)。"""
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(PerformanceLedger).where(
|
||||||
|
PerformanceLedger.receipt_id == receipt_id,
|
||||||
|
PerformanceLedger.calc_type == calc_type,
|
||||||
|
PerformanceLedger.is_active == 1,
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def has_active_for_business(session: Session, business_id: int, *, calc_type: int = 1) -> bool:
|
def has_active_for_business(session: Session, business_id: int, *, calc_type: int = 1) -> bool:
|
||||||
"""该业务是否已有生效核算行(核算粒度 #25:业务级一次性计提的判定)。"""
|
"""该业务是否已有生效核算行(核算粒度 #25:业务级一次性计提的判定)。"""
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session, aliased
|
|||||||
|
|
||||||
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
|
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
|
||||||
from app.repositories.query_utils import fuzzy
|
from app.repositories.query_utils import fuzzy
|
||||||
|
from app.schemas.personnel import PersonnelQuery
|
||||||
|
|
||||||
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
|
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
|
||||||
_manager = aliased(Personnel)
|
_manager = aliased(Personnel)
|
||||||
@@ -30,44 +31,23 @@ def get_with_names(session: Session, personnel_id: int) -> Row | None:
|
|||||||
return session.execute(stmt).first()
|
return session.execute(stmt).first()
|
||||||
|
|
||||||
|
|
||||||
def list_personnel(
|
def list_personnel(session: Session, query: PersonnelQuery) -> list[Row]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
keyword: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
business_role_id: int | None = None,
|
|
||||||
) -> list[Row]:
|
|
||||||
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
|
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
|
||||||
|
|
||||||
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
|
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
|
||||||
"""
|
"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(_base_select(), query)
|
||||||
_base_select(), keyword=keyword, status=status, business_role_id=business_role_id
|
stmt = stmt.order_by(Personnel.id.desc()).offset(query.offset).limit(query.page_size)
|
||||||
)
|
|
||||||
stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit)
|
|
||||||
return list(session.execute(stmt).all())
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
def count_personnel(
|
def count_personnel(session: Session, query: PersonnelQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
keyword: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
business_role_id: int | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""统计符合条件的人员总数,供分页使用。
|
"""统计符合条件的人员总数,供分页使用。
|
||||||
|
|
||||||
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||||||
``_apply_filters`` 单点保证)。
|
``_apply_filters`` 单点保证)。
|
||||||
"""
|
"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(select(func.count(Personnel.id)), query)
|
||||||
select(func.count(Personnel.id)),
|
|
||||||
keyword=keyword,
|
|
||||||
status=status,
|
|
||||||
business_role_id=business_role_id,
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
@@ -154,20 +134,14 @@ def _base_select() -> Select:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(stmt: Select, query: PersonnelQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
keyword: str | None,
|
|
||||||
status: int | None,
|
|
||||||
business_role_id: int | None,
|
|
||||||
) -> Select:
|
|
||||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||||
if keyword:
|
if query.keyword:
|
||||||
stmt = fuzzy(stmt, Personnel.personnel_name, keyword)
|
stmt = fuzzy(stmt, Personnel.personnel_name, query.keyword)
|
||||||
if status is not None:
|
if query.status is not None:
|
||||||
stmt = stmt.where(Personnel.status == status)
|
stmt = stmt.where(Personnel.status == query.status)
|
||||||
if business_role_id is not None:
|
if query.business_role_id is not None:
|
||||||
stmt = stmt.where(Personnel.business_role_id == business_role_id)
|
stmt = stmt.where(Personnel.business_role_id == query.business_role_id)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import BusinessItem, Product, ShipmentItem
|
from app.models import BusinessItem, Product, ShipmentItem
|
||||||
|
from app.schemas.product import ProductQuery
|
||||||
|
|
||||||
# ==================== 读取 ====================
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
@@ -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()
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
def list_products(
|
def list_products(session: Session, query: ProductQuery) -> list[Product]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
keyword: str | None = None,
|
|
||||||
product_category: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
) -> list[Product]:
|
|
||||||
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
|
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(select(Product), query)
|
||||||
select(Product),
|
stmt = stmt.order_by(Product.id).offset(query.offset).limit(query.page_size)
|
||||||
keyword=keyword,
|
|
||||||
product_category=product_category,
|
|
||||||
status=status,
|
|
||||||
)
|
|
||||||
stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
|
|
||||||
return list(session.execute(stmt).scalars())
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
def count_products(
|
def count_products(session: Session, query: ProductQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
keyword: str | None = None,
|
|
||||||
product_category: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""统计符合条件的产品总数,供分页使用。"""
|
"""统计符合条件的产品总数,供分页使用。"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(select(func.count(Product.id)), query)
|
||||||
select(func.count(Product.id)),
|
|
||||||
keyword=keyword,
|
|
||||||
product_category=product_category,
|
|
||||||
status=status,
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
keyword: str | None,
|
|
||||||
product_category: str | None,
|
|
||||||
status: int | None,
|
|
||||||
) -> Select:
|
|
||||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||||
|
|
||||||
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
|
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
|
||||||
@@ -86,17 +57,17 @@ def _apply_filters(
|
|||||||
写 contains —— M1 business_role_repository 的双列关键词同款写法。
|
写 contains —— M1 business_role_repository 的双列关键词同款写法。
|
||||||
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
|
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
|
||||||
"""
|
"""
|
||||||
if keyword:
|
if query.keyword:
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
Product.product_name.contains(keyword, autoescape=True),
|
Product.product_name.contains(query.keyword, autoescape=True),
|
||||||
Product.spec_model.contains(keyword, autoescape=True),
|
Product.spec_model.contains(query.keyword, autoescape=True),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if product_category:
|
if query.product_category:
|
||||||
stmt = stmt.where(Product.product_category == product_category)
|
stmt = stmt.where(Product.product_category == query.product_category)
|
||||||
if status is not None:
|
if query.status is not None:
|
||||||
stmt = stmt.where(Product.status == status)
|
stmt = stmt.where(Product.status == query.status)
|
||||||
return stmt
|
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:
|
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)
|
return session.get(CustomerRatingGrade, grade_id)
|
||||||
|
|
||||||
@@ -60,6 +61,10 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
|
|||||||
|
|
||||||
|
|
||||||
def delete(session: Session, grade: CustomerRatingGrade) -> None:
|
def delete(session: Session, grade: CustomerRatingGrade) -> None:
|
||||||
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
|
"""物理删除等级。
|
||||||
|
|
||||||
|
等级表无外键引用(客户档案只存总分,不存等级 id),调用方无需引用保护
|
||||||
|
校验:删掉等级只是「以后不再判到它」,已存的总分不受影响。
|
||||||
|
"""
|
||||||
session.delete(grade)
|
session.delete(grade)
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -14,6 +14,7 @@ from sqlalchemy import Select, func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Business, Receipt, Shipment
|
from app.models import Business, Receipt, Shipment
|
||||||
|
from app.schemas.receipt import ReceiptQuery
|
||||||
|
|
||||||
|
|
||||||
def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
|
def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
|
||||||
@@ -43,72 +44,42 @@ def get_shipment(session: Session, shipment_id: int) -> Shipment | None:
|
|||||||
return session.get(Shipment, shipment_id)
|
return session.get(Shipment, shipment_id)
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
business_id: int | None,
|
|
||||||
date_from: date | None,
|
|
||||||
date_to: date | None,
|
|
||||||
is_barter: int | None,
|
|
||||||
settlement_status: int | None,
|
|
||||||
) -> Select:
|
|
||||||
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
|
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
|
||||||
if business_id is not None:
|
if query.business_id is not None:
|
||||||
stmt = stmt.where(Receipt.business_id == business_id)
|
stmt = stmt.where(Receipt.business_id == query.business_id)
|
||||||
if date_from is not None:
|
if query.date_from is not None:
|
||||||
stmt = stmt.where(Receipt.receipt_date >= date_from)
|
stmt = stmt.where(Receipt.receipt_date >= query.date_from)
|
||||||
if date_to is not None:
|
if query.date_to is not None:
|
||||||
stmt = stmt.where(Receipt.receipt_date <= date_to)
|
stmt = stmt.where(Receipt.receipt_date <= query.date_to)
|
||||||
if is_barter is not None:
|
if query.is_barter is not None:
|
||||||
stmt = stmt.where(Receipt.is_barter == is_barter)
|
stmt = stmt.where(Receipt.is_barter == query.is_barter)
|
||||||
if settlement_status is not None:
|
# settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段
|
||||||
stmt = stmt.where(Receipt.settlement_status == settlement_status)
|
if query.settlement_status is not None:
|
||||||
|
stmt = stmt.where(Receipt.settlement_status == query.settlement_status)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
def list_receipts(
|
def list_receipts(
|
||||||
session: Session,
|
session: Session, query: ReceiptQuery
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
business_id: int | None,
|
|
||||||
date_from: date | None,
|
|
||||||
date_to: date | None,
|
|
||||||
is_barter: int | None,
|
|
||||||
settlement_status: int | None,
|
|
||||||
) -> list[tuple[Receipt, str, str | None, date | None]]:
|
) -> list[tuple[Receipt, str, str | None, date | None]]:
|
||||||
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
|
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(
|
||||||
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
|
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
|
||||||
.join(Business, Business.id == Receipt.business_id)
|
.join(Business, Business.id == Receipt.business_id)
|
||||||
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
|
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
|
||||||
business_id=business_id,
|
query,
|
||||||
date_from=date_from,
|
)
|
||||||
date_to=date_to,
|
return list(
|
||||||
is_barter=is_barter,
|
session.execute(
|
||||||
settlement_status=settlement_status,
|
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(
|
def count_receipts(session: Session, query: ReceiptQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
business_id: int | None,
|
|
||||||
date_from: date | None,
|
|
||||||
date_to: date | None,
|
|
||||||
is_barter: int | None,
|
|
||||||
settlement_status: int | None,
|
|
||||||
) -> int:
|
|
||||||
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(select(func.count()).select_from(Receipt), query)
|
||||||
select(func.count()).select_from(Receipt),
|
|
||||||
business_id=business_id,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
is_barter=is_barter,
|
|
||||||
settlement_status=settlement_status,
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Role, UserRole
|
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())
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
def list_roles(
|
def list_roles(session: Session, query: RoleQuery) -> list[Role]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
keyword: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
) -> list[Role]:
|
|
||||||
"""分页查询角色(管理列表:含停用角色)。
|
"""分页查询角色(管理列表:含停用角色)。
|
||||||
|
|
||||||
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
|
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
|
||||||
"""
|
"""
|
||||||
stmt = _apply_filters(select(Role), keyword=keyword, status=status)
|
stmt = _apply_filters(select(Role), query)
|
||||||
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
|
stmt = stmt.order_by(Role.sort_order, Role.id).offset(query.offset).limit(query.page_size)
|
||||||
return list(session.execute(stmt).scalars())
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
def count_roles(session: Session, *, 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())
|
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 通配符)——
|
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
|
||||||
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
|
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
|
||||||
"""
|
"""
|
||||||
if keyword:
|
if query.keyword:
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
Role.role_code.contains(keyword, autoescape=True),
|
Role.role_code.contains(query.keyword, autoescape=True),
|
||||||
Role.role_name.contains(keyword, autoescape=True),
|
Role.role_name.contains(query.keyword, autoescape=True),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if status is not None:
|
if query.status is not None:
|
||||||
stmt = stmt.where(Role.status == status)
|
stmt = stmt.where(Role.status == query.status)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,16 @@ from app.models import MonthlySettlement, SettlementItem
|
|||||||
from app.schemas.settlement import SettlementQuery
|
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)
|
return session.get(MonthlySettlement, settlement_id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
业务级汇总仍收口 business_service._recalc_totals
|
业务级汇总仍收口 business_service._recalc_totals
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import Select, delete, func, select
|
from sqlalchemy import Select, delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Business, Receipt, Shipment, ShipmentItem
|
from app.models import Business, Receipt, Shipment, ShipmentItem
|
||||||
|
from app.schemas.shipment import ShipmentQuery
|
||||||
|
|
||||||
|
|
||||||
def get_by_id(session: Session, shipment_id: int) -> Shipment | None:
|
def get_by_id(session: Session, shipment_id: int) -> Shipment | None:
|
||||||
@@ -31,59 +31,37 @@ def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment,
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(stmt: Select, query: ShipmentQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
business_id: int | None,
|
|
||||||
ship_date_from: date | None,
|
|
||||||
ship_date_to: date | None,
|
|
||||||
) -> Select:
|
|
||||||
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
|
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
|
||||||
if business_id is not None:
|
if query.business_id is not None:
|
||||||
stmt = stmt.where(Shipment.business_id == business_id)
|
stmt = stmt.where(Shipment.business_id == query.business_id)
|
||||||
if ship_date_from is not None:
|
if query.ship_date_from is not None:
|
||||||
stmt = stmt.where(Shipment.ship_date >= ship_date_from)
|
stmt = stmt.where(Shipment.ship_date >= query.ship_date_from)
|
||||||
if ship_date_to is not None:
|
if query.ship_date_to is not None:
|
||||||
stmt = stmt.where(Shipment.ship_date <= ship_date_to)
|
stmt = stmt.where(Shipment.ship_date <= query.ship_date_to)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
def list_shipments(
|
def list_shipments(session: Session, query: ShipmentQuery) -> list[tuple[Shipment, str]]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
business_id: int | None,
|
|
||||||
ship_date_from: date | None,
|
|
||||||
ship_date_to: date | None,
|
|
||||||
) -> list[tuple[Shipment, str]]:
|
|
||||||
"""分页查询发货单(附业务号),按 id 倒序。"""
|
"""分页查询发货单(附业务号),按 id 倒序。"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(
|
||||||
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
|
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
|
||||||
business_id=business_id,
|
query,
|
||||||
ship_date_from=ship_date_from,
|
|
||||||
ship_date_to=ship_date_to,
|
|
||||||
)
|
)
|
||||||
return list(
|
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(
|
def count_shipments(session: Session, query: ShipmentQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
business_id: int | None,
|
|
||||||
ship_date_from: date | None,
|
|
||||||
ship_date_to: date | None,
|
|
||||||
) -> int:
|
|
||||||
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(Shipment)
|
.select_from(Shipment)
|
||||||
.join(Business, Business.id == Shipment.business_id),
|
.join(Business, Business.id == Shipment.business_id),
|
||||||
business_id=business_id,
|
query,
|
||||||
ship_date_from=ship_date_from,
|
|
||||||
ship_date_to=ship_date_to,
|
|
||||||
)
|
)
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session, selectinload
|
|||||||
|
|
||||||
from app.models import Role, User, UserRole
|
from app.models import Role, User, UserRole
|
||||||
from app.repositories.query_utils import fuzzy
|
from app.repositories.query_utils import fuzzy
|
||||||
|
from app.schemas.user import UserQuery
|
||||||
|
|
||||||
|
|
||||||
def _base_query() -> Select[tuple[User]]:
|
def _base_query() -> Select[tuple[User]]:
|
||||||
@@ -48,16 +49,7 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None:
|
|||||||
# ==================== 列表查询 ====================
|
# ==================== 列表查询 ====================
|
||||||
|
|
||||||
|
|
||||||
def list_users(
|
def list_users(session: Session, query: UserQuery) -> list[User]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
username: str | None = None,
|
|
||||||
real_name: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
role_code: str | None = None,
|
|
||||||
) -> list[User]:
|
|
||||||
"""分页查询用户列表。
|
"""分页查询用户列表。
|
||||||
|
|
||||||
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
|
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
|
||||||
@@ -65,48 +57,35 @@ def list_users(
|
|||||||
id 是自增主键,天然唯一且与创建顺序一致。
|
id 是自增主键,天然唯一且与创建顺序一致。
|
||||||
"""
|
"""
|
||||||
stmt = _base_query()
|
stmt = _base_query()
|
||||||
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
|
stmt = _apply_filters(stmt, query)
|
||||||
if role_code:
|
if query.role_code:
|
||||||
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
|
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
|
||||||
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
|
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
|
||||||
stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit)
|
stmt = stmt.order_by(User.id.desc()).offset(query.offset).limit(query.page_size)
|
||||||
return list(session.execute(stmt).scalars().unique())
|
return list(session.execute(stmt).scalars().unique())
|
||||||
|
|
||||||
|
|
||||||
def count_users(
|
def count_users(session: Session, query: UserQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
username: str | None = None,
|
|
||||||
real_name: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
role_code: str | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""统计符合条件的用户总数,供分页使用。"""
|
"""统计符合条件的用户总数,供分页使用。"""
|
||||||
stmt = select(func.count(User.id))
|
stmt = select(func.count(User.id))
|
||||||
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
|
stmt = _apply_filters(stmt, query)
|
||||||
if role_code:
|
if query.role_code:
|
||||||
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
|
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(stmt: Select, query: UserQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
username: str | None,
|
|
||||||
real_name: str | None,
|
|
||||||
status: int | None,
|
|
||||||
) -> Select:
|
|
||||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||||
|
|
||||||
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
|
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
|
||||||
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
|
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
|
||||||
"""
|
"""
|
||||||
if username:
|
if query.username:
|
||||||
stmt = fuzzy(stmt, User.username, username)
|
stmt = fuzzy(stmt, User.username, query.username)
|
||||||
if real_name:
|
if query.real_name:
|
||||||
stmt = fuzzy(stmt, User.real_name, real_name)
|
stmt = fuzzy(stmt, User.real_name, query.real_name)
|
||||||
if status is not None:
|
if query.status is not None:
|
||||||
stmt = stmt.where(User.status == status)
|
stmt = stmt.where(User.status == query.status)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。"""
|
"""审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ class ApprovalOrderOut(BaseModel):
|
|||||||
order_type: int
|
order_type: int
|
||||||
ref_id: int
|
ref_id: int
|
||||||
refer_no: str
|
refer_no: str
|
||||||
|
version_no: int
|
||||||
status: int
|
status: int
|
||||||
submitter_id: int | None = None
|
submitter_id: int | None = None
|
||||||
submitter_name: str
|
submitter_name: str
|
||||||
@@ -38,9 +40,21 @@ class ApprovalOrderOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ApprovalOrderDetailOut(ApprovalOrderOut):
|
class ApprovalOrderDetailOut(ApprovalOrderOut):
|
||||||
"""审批单详情:附节点时间线。"""
|
"""审批单详情:附节点时间线与审批内容。
|
||||||
|
|
||||||
|
``content_source`` 语义:``snapshot``=提交时快照(可信);
|
||||||
|
``live``=历史单无快照、回退读当前业务内容(仅供参考);
|
||||||
|
``missing``=引用对象已不存在且无快照。
|
||||||
|
"""
|
||||||
|
|
||||||
steps: list[ApprovalStepOut] = Field(default_factory=list)
|
steps: list[ApprovalStepOut] = Field(default_factory=list)
|
||||||
|
snapshot: dict[str, Any] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="提交时内容快照(信封:schema_version/order_type/captured_at/data)",
|
||||||
|
)
|
||||||
|
content_source: Literal["snapshot", "live", "missing"] = Field(
|
||||||
|
default="missing", description="审批内容来源:提交时快照/当前内容回退/缺失"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ApprovalQuery(PageQuery):
|
class ApprovalQuery(PageQuery):
|
||||||
@@ -48,7 +62,7 @@ class ApprovalQuery(PageQuery):
|
|||||||
|
|
||||||
keyword: str | None = Field(default=None, max_length=32, description="审批单号/引用单号,模糊")
|
keyword: str | None = Field(default=None, max_length=32, description="审批单号/引用单号,模糊")
|
||||||
status: int | None = Field(default=None, ge=1, le=5, description="状态筛选")
|
status: int | None = Field(default=None, ge=1, le=5, description="状态筛选")
|
||||||
order_type: int | None = Field(default=None, ge=1, le=2, description="对象类型筛选")
|
order_type: int | None = Field(default=None, ge=1, le=3, description="对象类型筛选")
|
||||||
|
|
||||||
|
|
||||||
class ApprovalActionIn(BaseModel):
|
class ApprovalActionIn(BaseModel):
|
||||||
|
|||||||
@@ -180,6 +180,14 @@ class SecondaryAllocationOut(BaseModel):
|
|||||||
ratio: str
|
ratio: str
|
||||||
|
|
||||||
|
|
||||||
|
class AllocationVersionOut(SecondaryAllocationOut):
|
||||||
|
"""方案版本行(含历史;业务详情抽屉回溯用)。"""
|
||||||
|
|
||||||
|
version_no: int
|
||||||
|
is_active: int = Field(description="1当前生效 0历史版本")
|
||||||
|
create_time: datetime
|
||||||
|
|
||||||
|
|
||||||
def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
|
def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
|
||||||
"""同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示
|
"""同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示
|
||||||
(让 400 文案说「重复参与人」而不是裸唯一键冲突)。"""
|
(让 400 文案说「重复参与人」而不是裸唯一键冲突)。"""
|
||||||
@@ -191,13 +199,16 @@ def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class BusinessDetailOut(BusinessOut):
|
class BusinessDetailOut(BusinessOut):
|
||||||
"""业务详情:附产品明细行与二次分配方案。"""
|
"""业务详情:附产品明细行、当前方案与方案版本历史。"""
|
||||||
|
|
||||||
items: list[BusinessItemOut] = Field(default_factory=list)
|
items: list[BusinessItemOut] = Field(default_factory=list)
|
||||||
secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list)
|
secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list)
|
||||||
allocation_hint: str | None = Field(
|
allocation_hint: str | None = Field(
|
||||||
default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)"
|
default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)"
|
||||||
)
|
)
|
||||||
|
allocation_history: list[AllocationVersionOut] = Field(
|
||||||
|
default_factory=list, description="方案全部版本(含历史;被驳回方案亦保留)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BusinessCreateIn(BaseModel):
|
class BusinessCreateIn(BaseModel):
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ class BusinessRoleOut(BaseModel):
|
|||||||
is_builtin: int = 0
|
is_builtin: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessRoleOptionOut(BaseModel):
|
||||||
|
"""岗位下拉选项(人员档案与报表筛选共用)。
|
||||||
|
|
||||||
|
与 ``BusinessRoleOut`` 分开:下拉只需渲染用的最小字段集,不必顺带
|
||||||
|
暴露计提模板等管理字段。字段名直出蛇形(前端按同名取值)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int = Field(description="岗位ID")
|
||||||
|
role_code: str = Field(description="岗位编码")
|
||||||
|
role_name: str = Field(description="岗位名称")
|
||||||
|
status: int = Field(description="状态:1启用 0禁用")
|
||||||
|
|
||||||
|
|
||||||
class TemplateParamOut(BaseModel):
|
class TemplateParamOut(BaseModel):
|
||||||
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
|
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""客户管理的请求/响应模型(需求 2.1.3)。"""
|
"""客户管理的请求/响应模型(需求 2.1.3)。"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ class CustomerOut(BaseModel):
|
|||||||
owner_personnel_id: int | None = None
|
owner_personnel_id: int | None = None
|
||||||
owner_personnel_name: str | None = None
|
owner_personnel_name: str | None = None
|
||||||
remark: str | None = None
|
remark: str | None = None
|
||||||
|
rating_score: int | None = None
|
||||||
|
rating_time: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class CustomerCreateIn(BaseModel):
|
class CustomerCreateIn(BaseModel):
|
||||||
@@ -90,13 +93,32 @@ class CustomerUpdateIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CustomerQuery(PageQuery):
|
class CustomerQuery(PageQuery):
|
||||||
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。"""
|
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
|
||||||
|
|
||||||
|
排序用 ``Literal`` 白名单而不是自由字符串:``order_by`` 直接拼进
|
||||||
|
``ORDER BY``,自由值等于把注入面开到排序键上;客户列表只有两个
|
||||||
|
有意义的排序键,白名单足够。
|
||||||
|
"""
|
||||||
|
|
||||||
keyword: str | None = Field(
|
keyword: str | None = Field(
|
||||||
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
||||||
)
|
)
|
||||||
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
||||||
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
||||||
|
order_by: Literal["id", "rating_score"] = Field(
|
||||||
|
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):
|
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 ④)。"""
|
"""月度结算的请求/响应模型(M3 ④)。"""
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.month import validate_month
|
||||||
from app.core.response import PageQuery
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
_MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
|
|
||||||
|
|
||||||
|
|
||||||
def _check_month(value: str) -> str:
|
def _check_month(value: str) -> str:
|
||||||
"""月份格式 YYYY-MM(settlement_month 全表唯一的维度)。"""
|
"""月份格式 YYYY-MM 且月份 01-12(settlement_month 全表唯一的维度)。"""
|
||||||
if not _MONTH_RE.match(value):
|
return validate_month(value)
|
||||||
raise ValueError("月份格式须为 YYYY-MM")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementGenerateIn(BaseModel):
|
class SettlementGenerateIn(BaseModel):
|
||||||
@@ -93,3 +89,8 @@ class AdjustmentCreateIn(BaseModel):
|
|||||||
description="金额(元,两位小数,恒正)",
|
description="金额(元,两位小数,恒正)",
|
||||||
)
|
)
|
||||||
reason: str = Field(min_length=1, max_length=255, 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
|
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 import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, defer
|
||||||
|
|
||||||
from app.core.constants import (
|
from app.core.constants import (
|
||||||
ACTION_APPROVE,
|
ACTION_APPROVE,
|
||||||
ACTION_REJECT,
|
ACTION_REJECT,
|
||||||
MODULE_APPROVAL,
|
MODULE_APPROVAL,
|
||||||
NO_PREFIX_APPROVAL,
|
NO_PREFIX_APPROVAL,
|
||||||
|
ROLE_ADMIN,
|
||||||
ROLE_FINANCE,
|
ROLE_FINANCE,
|
||||||
ROLE_LEADER,
|
ROLE_LEADER,
|
||||||
)
|
)
|
||||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||||
from app.core.response import PageResult
|
from app.core.response import PageResult
|
||||||
from app.models import ApprovalOrder, ApprovalStep, User
|
from app.models import ApprovalOrder, ApprovalStep, Business, User
|
||||||
from app.models.approval import (
|
from app.models.approval import (
|
||||||
APPROVAL_ACTION_APPROVE,
|
APPROVAL_ACTION_APPROVE,
|
||||||
APPROVAL_ACTION_AUTO_APPROVE,
|
APPROVAL_ACTION_AUTO_APPROVE,
|
||||||
@@ -84,6 +87,18 @@ _NODE_ROLE_BY_STATUS = {
|
|||||||
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
|
_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:
|
def is_enabled(session: Session, order_type: int) -> bool:
|
||||||
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
|
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
|
||||||
code = ENABLE_CODE_BY_TYPE.get(order_type)
|
code = ENABLE_CODE_BY_TYPE.get(order_type)
|
||||||
@@ -99,6 +114,7 @@ def submit_order(
|
|||||||
ref_id: int,
|
ref_id: int,
|
||||||
refer_no: str,
|
refer_no: str,
|
||||||
submitter: User,
|
submitter: User,
|
||||||
|
snapshot: dict | None = None,
|
||||||
) -> ApprovalOrder:
|
) -> ApprovalOrder:
|
||||||
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
|
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
|
||||||
|
|
||||||
@@ -108,6 +124,10 @@ def submit_order(
|
|||||||
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
|
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
|
||||||
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
|
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
|
||||||
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
|
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
|
||||||
|
|
||||||
|
``snapshot`` 是「提交时」的业务内容(由调用方组装,Decimal 已转 str);
|
||||||
|
落库时包一层信封并固化采集时间——审批期间业务再改,复核者看到的仍是
|
||||||
|
送审内容。开关关闭的自动通过路径同样写快照,留痕不走样。
|
||||||
"""
|
"""
|
||||||
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
|
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
|
||||||
|
|
||||||
@@ -121,6 +141,17 @@ def submit_order(
|
|||||||
order_type=order_type,
|
order_type=order_type,
|
||||||
ref_id=ref_id,
|
ref_id=ref_id,
|
||||||
refer_no=refer_no,
|
refer_no=refer_no,
|
||||||
|
version_no=_next_version(session, order_type, ref_id),
|
||||||
|
snapshot_json=(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"order_type": order_type,
|
||||||
|
"captured_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"data": snapshot,
|
||||||
|
}
|
||||||
|
if snapshot is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
submitter_id=submitter.id,
|
submitter_id=submitter.id,
|
||||||
submitter_name=submitter.username,
|
submitter_name=submitter.username,
|
||||||
)
|
)
|
||||||
@@ -151,6 +182,35 @@ def submit_order(
|
|||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def pending_matches_snapshot(
|
||||||
|
session: Session, *, order_type: int, ref_id: int, snapshot: dict | None
|
||||||
|
) -> bool:
|
||||||
|
"""在途审批单的送审内容是否与给定快照一致(用于「内容未变不重提」)。
|
||||||
|
|
||||||
|
只比较快照信封里的 ``data`` 部分——``captured_at`` 每次采集都不同,
|
||||||
|
参与比较会让判断恒为不一致。V11 之前的历史单没有快照,一律视为不一致
|
||||||
|
(保守重提:宁可多提一次,不能漏审)。
|
||||||
|
"""
|
||||||
|
if snapshot is None:
|
||||||
|
return False
|
||||||
|
pending = (
|
||||||
|
session.execute(
|
||||||
|
select(ApprovalOrder).where(
|
||||||
|
ApprovalOrder.order_type == order_type,
|
||||||
|
ApprovalOrder.ref_id == ref_id,
|
||||||
|
ApprovalOrder.status.in_(_PENDING_STATUSES),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for order in pending:
|
||||||
|
stored = order.snapshot_json or {}
|
||||||
|
if stored.get("data") == snapshot:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def withdraw_pending_orders(
|
def withdraw_pending_orders(
|
||||||
session: Session, *, order_type: int, ref_id: int, operator: User
|
session: Session, *, order_type: int, ref_id: int, operator: User
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -236,15 +296,15 @@ def act(
|
|||||||
operator_name=operator.username,
|
operator_name=operator.username,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以
|
# 驳回=方案未获批准:把该业务的生效方案行置历史(is_active=0,
|
||||||
# 「当前生效行」身份留在表里、被 M3 核算快照算进工资;方案事实由
|
# **不删行**——V11 起 append-only 版本化),防止被否决的方案以
|
||||||
# 审批单留痕与操作日志 request_data 承载(分摊语义落定后如需保留
|
# 「当前生效行」身份被 M3 核算快照算进工资;历史方案仍可在业务
|
||||||
# 历史版本,在 ④ 设计时演进)。审批单的清理由调用方(act 内联)
|
# 详情 allocation_history 回溯。委托 business_repository 完成
|
||||||
# 委托 business_repository 完成 —— service 层互调、不触 commit。
|
# —— service 层互调、不触 commit。
|
||||||
if rejected and order.order_type == 1:
|
if rejected and order.order_type == 1:
|
||||||
from app.repositories import business_repository
|
from app.repositories import business_repository
|
||||||
|
|
||||||
business_repository.delete_allocations(session, order.ref_id)
|
business_repository.deactivate_allocations(session, order.ref_id)
|
||||||
|
|
||||||
# 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿;
|
# 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿;
|
||||||
# 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。
|
# 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。
|
||||||
@@ -285,11 +345,18 @@ def act(
|
|||||||
|
|
||||||
|
|
||||||
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
|
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
|
||||||
"""分页查询审批单(准入由 api 层依赖判定)。"""
|
"""分页查询审批单(准入由 api 层依赖判定)。
|
||||||
|
|
||||||
|
排除 snapshot_json 列(月结快照含按人明细,可达数十~上百 KB):列表出参
|
||||||
|
本就不含快照,整列加载会让翻页负担随快照体积线性放大;详情走
|
||||||
|
`get_order` 按需读取(与操作日志列表排除 request_data 同一理由,
|
||||||
|
见 operation_log_repository.list_logs)。
|
||||||
|
"""
|
||||||
filters = _order_filters(query)
|
filters = _order_filters(query)
|
||||||
rows = (
|
rows = (
|
||||||
session.execute(
|
session.execute(
|
||||||
select(ApprovalOrder)
|
select(ApprovalOrder)
|
||||||
|
.options(defer(ApprovalOrder.snapshot_json))
|
||||||
.where(*filters)
|
.where(*filters)
|
||||||
.order_by(ApprovalOrder.id.desc())
|
.order_by(ApprovalOrder.id.desc())
|
||||||
.offset(query.offset)
|
.offset(query.offset)
|
||||||
@@ -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)
|
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_version(session: Session, order_type: int, ref_id: int) -> int:
|
||||||
|
"""同一对象类型+引用对象的下一个提交版本号(从 1 递增)。
|
||||||
|
|
||||||
|
**必须用锁读(当前读),不能用普通 MAX 查询**:普通 SELECT 在
|
||||||
|
REPEATABLE READ 下是快照读,读到的是本事务首次一致读的快照;请求级
|
||||||
|
鉴权(deps.get_current_user 与业务共用同一 DbSession)那次 SELECT 已把
|
||||||
|
快照锚定在请求开始时刻,此后即使父行锁等到并发事务提交,MAX 仍停在
|
||||||
|
旧快照上——两个请求会算出同一个 version_no。锁读是当前读,永远读最新
|
||||||
|
已提交数据。
|
||||||
|
|
||||||
|
**不能写成 select(func.max(...)).with_for_update()**:MySQL 官方明确
|
||||||
|
「Locking clauses are not permitted in queries with aggregate functions
|
||||||
|
or GROUP BY」,聚合结果上的 FOR UPDATE 语义无保证。改锁「该 ref 的现有
|
||||||
|
版本行」,最大值在 Python 侧算——同 no_generator 的既有套路。只取
|
||||||
|
version_no 单列,不把 snapshot_json 拉进内存。
|
||||||
|
"""
|
||||||
|
versions = (
|
||||||
|
session.execute(
|
||||||
|
select(ApprovalOrder.version_no)
|
||||||
|
.where(
|
||||||
|
ApprovalOrder.order_type == order_type,
|
||||||
|
ApprovalOrder.ref_id == ref_id,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return (max(versions) if versions else 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def allocation_content(session: Session, business: Business) -> dict:
|
||||||
|
"""二次分配方案的审批内容(提单快照与历史单回退共用同一结构)。
|
||||||
|
|
||||||
|
业务主行已存在但客户联查落空(客户被删等异常)时返回空字典——调用方
|
||||||
|
以 ``or None`` 归一,避免生成「有快照但内容为空」的信封。
|
||||||
|
"""
|
||||||
|
from app.repositories import business_repository
|
||||||
|
|
||||||
|
row = business_repository.get_with_names(session, business.id)
|
||||||
|
if row is None:
|
||||||
|
return {}
|
||||||
|
_, customer_name, owner_name, assistant_name = row
|
||||||
|
allocations = business_repository.list_allocations(session, business.id)
|
||||||
|
total = sum((a.ratio for a in allocations), Decimal("0"))
|
||||||
|
return {
|
||||||
|
"business_no": business.business_no,
|
||||||
|
"business_date": business.business_date.isoformat() if business.business_date else None,
|
||||||
|
"customer_name": customer_name,
|
||||||
|
"owner_personnel_name": owner_name,
|
||||||
|
"assistant_personnel_name": assistant_name,
|
||||||
|
"sale_amount": _amount_text(business.total_sale_amount),
|
||||||
|
"cost_amount": _amount_text(business.total_cost_amount),
|
||||||
|
"allocations": [
|
||||||
|
{
|
||||||
|
"personnel_id": a.personnel_id,
|
||||||
|
"personnel_name": a.personnel_name,
|
||||||
|
"ratio": str(a.ratio),
|
||||||
|
}
|
||||||
|
for a in allocations
|
||||||
|
],
|
||||||
|
"allocation_total": str(total.quantize(Decimal("0.01"))),
|
||||||
|
"allocation_balance": str((Decimal("100") - total).quantize(Decimal("0.01"))),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _amount_text(value: Decimal | None) -> str | None:
|
||||||
|
"""金额文本化(Decimal 全程不转 float;None 保留为空,不伪造 0.00)。"""
|
||||||
|
return str(value) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def settlement_content(session: Session, settlement_id: int) -> dict | None:
|
||||||
|
"""月结审批内容(汇总 + 按人明细;提单快照与历史单回退共用)。"""
|
||||||
|
from app.models import MonthlySettlement
|
||||||
|
from app.repositories import settlement_repository
|
||||||
|
|
||||||
|
settlement = session.get(MonthlySettlement, settlement_id)
|
||||||
|
if settlement is None:
|
||||||
|
return None
|
||||||
|
items = settlement_repository.list_items(session, settlement_id)
|
||||||
|
return {
|
||||||
|
"settlement_month": settlement.settlement_month,
|
||||||
|
"total_performance": _amount_text(settlement.total_performance),
|
||||||
|
"total_payable": _amount_text(settlement.total_payable),
|
||||||
|
"person_count": settlement.person_count,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"personnel_name": item.personnel_name,
|
||||||
|
"role_code": item.role_code,
|
||||||
|
"calc_type": item.calc_type,
|
||||||
|
"performance_sum": str(item.performance_sum),
|
||||||
|
"payable_sum": str(item.payable_sum),
|
||||||
|
"basic_amount": _amount_text(item.basic_amount),
|
||||||
|
"salary_mode": item.salary_mode,
|
||||||
|
"net_amount": _amount_text(item.net_amount),
|
||||||
|
"is_probation": item.is_probation,
|
||||||
|
}
|
||||||
|
for item in items
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def adjustment_content(session: Session, adjustment_id: int) -> dict | None:
|
||||||
|
"""调整单审批内容(提单快照与历史单回退共用)。"""
|
||||||
|
from app.models import AdjustmentOrder
|
||||||
|
|
||||||
|
adjustment = session.get(AdjustmentOrder, adjustment_id)
|
||||||
|
if adjustment is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"order_no": adjustment.order_no,
|
||||||
|
"settlement_month": adjustment.settlement_month,
|
||||||
|
"personnel_name": adjustment.personnel_name,
|
||||||
|
"direction": adjustment.direction,
|
||||||
|
"amount": str(adjustment.amount),
|
||||||
|
"reason": adjustment.reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _content_of(session: Session, order: ApprovalOrder) -> tuple[dict | None, str]:
|
||||||
|
"""审批内容出参:优先提交时快照,历史单回退当前内容并显式标注来源。"""
|
||||||
|
if order.snapshot_json:
|
||||||
|
return order.snapshot_json, "snapshot"
|
||||||
|
|
||||||
|
if order.order_type == 1:
|
||||||
|
business = session.get(Business, order.ref_id)
|
||||||
|
data = allocation_content(session, business) if business is not None else None
|
||||||
|
elif order.order_type == 2:
|
||||||
|
data = settlement_content(session, order.ref_id)
|
||||||
|
elif order.order_type == 3:
|
||||||
|
data = adjustment_content(session, order.ref_id)
|
||||||
|
else:
|
||||||
|
data = None
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return None, "missing"
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"order_type": order.order_type,
|
||||||
|
"captured_at": None,
|
||||||
|
"data": data,
|
||||||
|
}, "live"
|
||||||
|
|
||||||
|
|
||||||
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
|
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
|
||||||
"""出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。"""
|
"""出参组装(含 steps 时间线与审批内容)。调用方须保证处于 commit 之前。"""
|
||||||
steps = (
|
steps = (
|
||||||
session.execute(
|
session.execute(
|
||||||
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
|
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
|
||||||
@@ -357,6 +568,7 @@ def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut
|
|||||||
)
|
)
|
||||||
detail = ApprovalOrderDetailOut.model_validate(order)
|
detail = ApprovalOrderDetailOut.model_validate(order)
|
||||||
detail.steps = [ApprovalStepOut.model_validate(s) for s in steps]
|
detail.steps = [ApprovalStepOut.model_validate(s) for s in steps]
|
||||||
|
detail.snapshot, detail.content_source = _content_of(session, order)
|
||||||
return detail
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,14 @@ def _record(
|
|||||||
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
|
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
|
||||||
login_log_repository.add(
|
login_log_repository.add(
|
||||||
session,
|
session,
|
||||||
|
login_log_repository.LoginLogEntry(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
username=username,
|
username=username,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
success=success,
|
success=success,
|
||||||
fail_reason=fail_reason,
|
fail_reason=fail_reason,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from app.models import BusinessRole, User
|
|||||||
from app.repositories import business_role_repository
|
from app.repositories import business_role_repository
|
||||||
from app.schemas.business_role import (
|
from app.schemas.business_role import (
|
||||||
BusinessRoleCreateIn,
|
BusinessRoleCreateIn,
|
||||||
|
BusinessRoleOptionOut,
|
||||||
BusinessRoleOut,
|
BusinessRoleOut,
|
||||||
BusinessRoleQuery,
|
BusinessRoleQuery,
|
||||||
BusinessRoleUpdateIn,
|
BusinessRoleUpdateIn,
|
||||||
@@ -67,16 +68,8 @@ def _ensure_template(calc_template: str) -> None:
|
|||||||
|
|
||||||
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
|
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
|
||||||
"""分页查询岗位(含停用)。"""
|
"""分页查询岗位(含停用)。"""
|
||||||
roles = business_role_repository.list_roles(
|
roles = business_role_repository.list_roles(session, query)
|
||||||
session,
|
total = business_role_repository.count_roles(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
keyword=query.keyword,
|
|
||||||
status=query.status,
|
|
||||||
)
|
|
||||||
total = business_role_repository.count_roles(
|
|
||||||
session, keyword=query.keyword, status=query.status
|
|
||||||
)
|
|
||||||
return PageResult[BusinessRoleOut](
|
return PageResult[BusinessRoleOut](
|
||||||
records=[_to_out(role) for role in roles],
|
records=[_to_out(role) for role in roles],
|
||||||
total=total,
|
total=total,
|
||||||
@@ -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:
|
def get_role(session: Session, role_id: int) -> BusinessRoleOut:
|
||||||
"""岗位详情。
|
"""岗位详情。
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
@@ -54,6 +56,7 @@ from app.models.business import (
|
|||||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||||
from app.repositories import business_repository, customer_repository, personnel_repository
|
from app.repositories import business_repository, customer_repository, personnel_repository
|
||||||
from app.schemas.business import (
|
from app.schemas.business import (
|
||||||
|
AllocationVersionOut,
|
||||||
BusinessCreateIn,
|
BusinessCreateIn,
|
||||||
BusinessDetailOut,
|
BusinessDetailOut,
|
||||||
BusinessItemIn,
|
BusinessItemIn,
|
||||||
@@ -233,26 +236,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
|||||||
|
|
||||||
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
|
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
|
||||||
"""分页查询业务(附客户名与归属人名)。"""
|
"""分页查询业务(附客户名与归属人名)。"""
|
||||||
rows = business_repository.list_businesses(
|
rows = business_repository.list_businesses(session, query)
|
||||||
session,
|
total = business_repository.count_businesses(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
keyword=query.keyword,
|
|
||||||
status=query.status,
|
|
||||||
customer_id=query.customer_id,
|
|
||||||
owner_personnel_id=query.owner_personnel_id,
|
|
||||||
date_from=query.date_from,
|
|
||||||
date_to=query.date_to,
|
|
||||||
)
|
|
||||||
total = business_repository.count_businesses(
|
|
||||||
session,
|
|
||||||
keyword=query.keyword,
|
|
||||||
status=query.status,
|
|
||||||
customer_id=query.customer_id,
|
|
||||||
owner_personnel_id=query.owner_personnel_id,
|
|
||||||
date_from=query.date_from,
|
|
||||||
date_to=query.date_to,
|
|
||||||
)
|
|
||||||
return PageResult[BusinessOut](
|
return PageResult[BusinessOut](
|
||||||
records=[_to_out(row) for row in rows],
|
records=[_to_out(row) for row in rows],
|
||||||
total=total,
|
total=total,
|
||||||
@@ -281,6 +266,17 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
|||||||
for a in allocations
|
for a in allocations
|
||||||
]
|
]
|
||||||
out.allocation_hint = _allocation_hint(allocations)
|
out.allocation_hint = _allocation_hint(allocations)
|
||||||
|
out.allocation_history = [
|
||||||
|
AllocationVersionOut(
|
||||||
|
personnel_id=a.personnel_id,
|
||||||
|
personnel_name=a.personnel_name,
|
||||||
|
ratio=str(a.ratio),
|
||||||
|
version_no=a.version_no,
|
||||||
|
is_active=a.is_active,
|
||||||
|
create_time=a.create_time,
|
||||||
|
)
|
||||||
|
for a in business_repository.list_allocation_versions(session, business_id)
|
||||||
|
]
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -298,8 +294,24 @@ def _build_allocation_rows(
|
|||||||
编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对
|
编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对
|
||||||
**新加入**的参与人做在职校验。
|
**新加入**的参与人做在职校验。
|
||||||
|
|
||||||
:raises BadRequestError: 新增参与人员不存在或已离职
|
:raises BadRequestError: 新增参与人员不存在或已离职 / 比例合计超 100%
|
||||||
|
/ 参与人含合同负责人(口径见澄清 #1/#2 答复 2026-10-19:分配绩效、
|
||||||
|
余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人
|
||||||
|
自分份额则双重计入)
|
||||||
"""
|
"""
|
||||||
|
business = business_repository.get_by_id(session, business_id)
|
||||||
|
if business is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
total = sum((Decimal(item.ratio) for item in items), Decimal("0"))
|
||||||
|
if total > Decimal("100"):
|
||||||
|
raise BadRequestError(
|
||||||
|
f"二次分配比例合计 {total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}%"
|
||||||
|
" 超过 100%(余额归合同负责营销人员,比例不可超满额)"
|
||||||
|
)
|
||||||
|
owner_id = business.owner_personnel_id
|
||||||
|
if owner_id is not None and any(item.personnel_id == owner_id for item in items):
|
||||||
|
raise BadRequestError("分摊参与人不可包含合同负责营销人员(负责人得余额份额)")
|
||||||
|
|
||||||
rows: list[SecondaryAllocation] = []
|
rows: list[SecondaryAllocation] = []
|
||||||
for item in items:
|
for item in items:
|
||||||
if item.personnel_id in old_ids:
|
if item.personnel_id in old_ids:
|
||||||
@@ -319,8 +331,22 @@ def _build_allocation_rows(
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_changed(old_rows: list[SecondaryAllocation], new_rows: list[SecondaryAllocation]) -> bool:
|
||||||
|
"""方案内容(参与人 + 比例)是否真的变了。
|
||||||
|
|
||||||
|
比较口径是**集合**(人员 ID 与其比例的组合),与提交顺序无关——送审
|
||||||
|
快照同样按库内生效行的顺序生成,若按顺序比较,用户只是把行拖了个
|
||||||
|
序就会被判成「变了」,进而无谓地新增版本与重提审批单。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _key(row: SecondaryAllocation) -> tuple[int, Decimal]:
|
||||||
|
return (row.personnel_id, Decimal(row.ratio).quantize(Decimal("0.01")))
|
||||||
|
|
||||||
|
return {_key(r) for r in old_rows} != {_key(r) for r in new_rows}
|
||||||
|
|
||||||
|
|
||||||
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
|
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
|
||||||
"""方案合计/余额提示(不强制合计满额是需求原文;余额归属待澄清 #2)。
|
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
|
||||||
|
|
||||||
无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。
|
无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。
|
||||||
"""
|
"""
|
||||||
@@ -331,7 +357,7 @@ def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
|
|||||||
if total == Decimal("100"):
|
if total == Decimal("100"):
|
||||||
return f"二次分配合计 {total_text}%"
|
return f"二次分配合计 {total_text}%"
|
||||||
rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
return f"二次分配合计 {total_text}%,余额 {rest}%(归属待业务确认)"
|
return f"二次分配合计 {total_text}%,余额 {rest}%(归合同负责营销人员)"
|
||||||
|
|
||||||
|
|
||||||
def create_business(
|
def create_business(
|
||||||
@@ -417,6 +443,7 @@ def create_business(
|
|||||||
ref_id=business.id,
|
ref_id=business.id,
|
||||||
refer_no=business.business_no,
|
refer_no=business.business_no,
|
||||||
submitter=operator,
|
submitter=operator,
|
||||||
|
snapshot=approval_service.allocation_content(session, business) or None,
|
||||||
)
|
)
|
||||||
customer = customer_repository.get_by_id(session, business.customer_id)
|
customer = customer_repository.get_by_id(session, business.customer_id)
|
||||||
out = _to_out((business, customer.company_name if customer else None, None, None))
|
out = _to_out((business, customer.company_name if customer else None, None, None))
|
||||||
@@ -491,28 +518,51 @@ def update_business(
|
|||||||
business_repository.replace_items(
|
business_repository.replace_items(
|
||||||
session, business_id, _build_item_rows(business_id, payload.items)
|
session, business_id, _build_item_rows(business_id, payload.items)
|
||||||
)
|
)
|
||||||
# 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
|
# 方案:内容真的变了才动版本行(内容未变不新增版本,避免「只改备注」
|
||||||
# 前置场景(月结在 ④,快照固化后按同一哲学隔离)
|
# 也堆出一个方案版本);空数组=清空=把生效行置历史
|
||||||
old_ids = {
|
old_allocations = business_repository.list_allocations(session, business_id)
|
||||||
a.personnel_id for a in business_repository.list_allocations(session, business_id)
|
new_rows = _build_allocation_rows(
|
||||||
}
|
|
||||||
business_repository.replace_allocations(
|
|
||||||
session,
|
session,
|
||||||
business_id,
|
business_id,
|
||||||
_build_allocation_rows(
|
payload.secondary_allocations,
|
||||||
session, business_id, payload.secondary_allocations, old_ids=old_ids
|
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:
|
if payload.secondary_allocations:
|
||||||
|
snapshot = approval_service.allocation_content(session, business) or None
|
||||||
|
if not approval_service.pending_matches_snapshot(
|
||||||
|
session,
|
||||||
|
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||||
|
ref_id=business_id,
|
||||||
|
snapshot=snapshot,
|
||||||
|
):
|
||||||
|
try:
|
||||||
approval_service.submit_order(
|
approval_service.submit_order(
|
||||||
session,
|
session,
|
||||||
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||||
ref_id=business_id,
|
ref_id=business_id,
|
||||||
refer_no=business.business_no,
|
refer_no=business.business_no,
|
||||||
submitter=operator,
|
submitter=operator,
|
||||||
|
snapshot=snapshot,
|
||||||
)
|
)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# version_no 唯一键的并发冲突(取号是锁读后此项理论不可达,
|
||||||
|
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062
|
||||||
|
# 翻译成可重试的业务提示,其余完整性错误原样上抛
|
||||||
|
if _is_duplicate_entry(exc):
|
||||||
|
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
|
||||||
|
raise
|
||||||
else:
|
else:
|
||||||
approval_service.withdraw_pending_orders(
|
approval_service.withdraw_pending_orders(
|
||||||
session,
|
session,
|
||||||
@@ -520,7 +570,6 @@ def update_business(
|
|||||||
ref_id=business_id,
|
ref_id=business_id,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
)
|
)
|
||||||
_recalc_totals(session, business)
|
|
||||||
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
||||||
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
|
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
|
||||||
out = _to_out((business, customer.company_name, owner_name, assistant_name))
|
out = _to_out((business, customer.company_name, owner_name, assistant_name))
|
||||||
@@ -683,6 +732,67 @@ def change_status(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _BusinessOwnershipResolution:
|
||||||
|
"""业务归属变更的解析结果(新归属人行 + 变更前固化 ID)。"""
|
||||||
|
|
||||||
|
owner: Personnel | None
|
||||||
|
assistant: Personnel | None
|
||||||
|
old_owner_id: int | None
|
||||||
|
old_assistant_id: int | None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_business_ownership(
|
||||||
|
session: Session, business: Business, payload: BusinessOwnershipIn
|
||||||
|
) -> _BusinessOwnershipResolution:
|
||||||
|
"""校验新归属人(须在职)并判定归属是否真的变化。
|
||||||
|
|
||||||
|
校验在审计上下文内执行 —— 失败也留痕。两者均未变化 → 400「归属未变化」。
|
||||||
|
"""
|
||||||
|
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
|
||||||
|
# 固化标量,新归属人名必须按新 ID 重取
|
||||||
|
owner = (
|
||||||
|
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
|
||||||
|
if payload.new_owner_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
assistant = (
|
||||||
|
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
|
||||||
|
if payload.new_assistant_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
|
||||||
|
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
|
||||||
|
if not owner_changed and not assistant_changed:
|
||||||
|
raise BadRequestError("归属未变化")
|
||||||
|
return _BusinessOwnershipResolution(
|
||||||
|
owner=owner,
|
||||||
|
assistant=assistant,
|
||||||
|
old_owner_id=business.owner_personnel_id,
|
||||||
|
old_assistant_id=business.assistant_personnel_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _business_ownership_log(
|
||||||
|
business: Business,
|
||||||
|
payload: BusinessOwnershipIn,
|
||||||
|
resolution: _BusinessOwnershipResolution,
|
||||||
|
operator: User,
|
||||||
|
) -> OwnershipChangeLog:
|
||||||
|
"""归属变更留痕行(负责/协助两个字段的前后值都记)。"""
|
||||||
|
return OwnershipChangeLog(
|
||||||
|
owner_type=OWNER_TYPE_BUSINESS,
|
||||||
|
business_id=business.id,
|
||||||
|
old_owner_personnel_id=resolution.old_owner_id,
|
||||||
|
new_owner_personnel_id=payload.new_owner_personnel_id,
|
||||||
|
old_assistant_personnel_id=resolution.old_assistant_id,
|
||||||
|
new_assistant_personnel_id=payload.new_assistant_personnel_id,
|
||||||
|
change_reason=payload.reason,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def change_ownership(
|
def change_ownership(
|
||||||
session: Session,
|
session: Session,
|
||||||
business_id: int,
|
business_id: int,
|
||||||
@@ -714,41 +824,12 @@ def change_ownership(
|
|||||||
if row is None:
|
if row is None:
|
||||||
raise NotFoundError("业务不存在")
|
raise NotFoundError("业务不存在")
|
||||||
business, _, old_owner_name, old_assistant_name = row
|
business, _, old_owner_name, old_assistant_name = row
|
||||||
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
|
resolution = _resolve_business_ownership(session, business, payload)
|
||||||
# 固化标量,新归属人名必须按新 ID 重取
|
|
||||||
new_owner = (
|
|
||||||
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
|
|
||||||
if payload.new_owner_personnel_id is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
new_assistant = (
|
|
||||||
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
|
|
||||||
if payload.new_assistant_personnel_id is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
|
|
||||||
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
|
|
||||||
if not owner_changed and not assistant_changed:
|
|
||||||
raise BadRequestError("归属未变化")
|
|
||||||
|
|
||||||
old_owner_id = business.owner_personnel_id
|
|
||||||
old_assistant_id = business.assistant_personnel_id
|
|
||||||
business.owner_personnel_id = payload.new_owner_personnel_id
|
business.owner_personnel_id = payload.new_owner_personnel_id
|
||||||
business.assistant_personnel_id = payload.new_assistant_personnel_id
|
business.assistant_personnel_id = payload.new_assistant_personnel_id
|
||||||
business_repository.add_ownership_log(
|
business_repository.add_ownership_log(
|
||||||
session,
|
session, _business_ownership_log(business, payload, resolution, operator)
|
||||||
OwnershipChangeLog(
|
|
||||||
owner_type=OWNER_TYPE_BUSINESS,
|
|
||||||
business_id=business_id,
|
|
||||||
old_owner_personnel_id=old_owner_id,
|
|
||||||
new_owner_personnel_id=payload.new_owner_personnel_id,
|
|
||||||
old_assistant_personnel_id=old_assistant_id,
|
|
||||||
new_assistant_personnel_id=payload.new_assistant_personnel_id,
|
|
||||||
change_reason=payload.reason,
|
|
||||||
operator_id=operator.id,
|
|
||||||
operator_name=operator.username,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
|
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
|
||||||
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
|
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
|
||||||
@@ -756,17 +837,19 @@ def change_ownership(
|
|||||||
(
|
(
|
||||||
business,
|
business,
|
||||||
row[1],
|
row[1],
|
||||||
new_owner.personnel_name if new_owner is not None else old_owner_name,
|
resolution.owner.personnel_name if resolution.owner is not None else old_owner_name,
|
||||||
new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
|
resolution.assistant.personnel_name
|
||||||
|
if resolution.assistant is not None
|
||||||
|
else old_assistant_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
|
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
|
||||||
business_id,
|
business_id,
|
||||||
old_owner_id,
|
resolution.old_owner_id,
|
||||||
payload.new_owner_personnel_id,
|
payload.new_owner_personnel_id,
|
||||||
old_assistant_id,
|
resolution.old_assistant_id,
|
||||||
payload.new_assistant_personnel_id,
|
payload.new_assistant_personnel_id,
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
@@ -832,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:
|
def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut:
|
||||||
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
|
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
|
||||||
|
|
||||||
@@ -850,64 +1021,15 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
|
|||||||
def threshold_of(category: str) -> Fraction:
|
def threshold_of(category: str) -> Fraction:
|
||||||
return snapshot.fraction("product.threshold", product_category=category)
|
return snapshot.fraction("product.threshold", product_category=category)
|
||||||
|
|
||||||
rows_out: list[GrossProfitRowOut] = []
|
scan = _scan_gross_profit_rows(session, business_id, threshold_of)
|
||||||
missing_count = 0
|
|
||||||
# 类别聚合容器:{类别: [sale, cost]},成本缺失行不计入该类别聚合
|
|
||||||
agg: dict[str, list[Decimal]] = {}
|
|
||||||
for item in business_repository.list_items(session, business_id):
|
|
||||||
threshold = threshold_of(item.product_category)
|
|
||||||
row_kwargs: dict = {
|
|
||||||
"item_id": item.id,
|
|
||||||
"product_category": item.product_category,
|
|
||||||
"product_name": item.product_name,
|
|
||||||
"spec_model": item.spec_model,
|
|
||||||
"quantity": str(item.quantity),
|
|
||||||
"sale_amount": str(item.sale_amount),
|
|
||||||
"threshold": str(threshold),
|
|
||||||
}
|
|
||||||
if item.cost_amount is None:
|
|
||||||
missing_count += 1
|
|
||||||
row_kwargs.update(
|
|
||||||
cost_amount=None,
|
|
||||||
profit_amount=None,
|
|
||||||
profit_rate=None,
|
|
||||||
converted_profit=None,
|
|
||||||
missing_reason="成本缺失,无法计算毛利润",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
profit = item.sale_amount - item.cost_amount
|
|
||||||
rate = profit_rate(profit, item.sale_amount)
|
|
||||||
conv = converted_profit(item.sale_amount, rate, threshold)
|
|
||||||
row_kwargs.update(
|
|
||||||
cost_amount=str(item.cost_amount),
|
|
||||||
profit_amount=str(profit),
|
|
||||||
profit_rate=str(rate),
|
|
||||||
converted_profit=str(conv),
|
|
||||||
missing_reason=None,
|
|
||||||
)
|
|
||||||
bucket = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
|
|
||||||
bucket[0] += item.sale_amount
|
|
||||||
bucket[1] += item.cost_amount
|
|
||||||
rows_out.append(GrossProfitRowOut(**row_kwargs))
|
|
||||||
|
|
||||||
categories = [
|
categories = [
|
||||||
GrossProfitCategoryOut(
|
_gross_profit_category(category, sale, cost, threshold_of(category))
|
||||||
product_category=category,
|
for category, [sale, cost] in scan.category_agg.items()
|
||||||
sale_amount=str(sale),
|
|
||||||
cost_amount=str(cost),
|
|
||||||
profit_amount=str(sale - cost),
|
|
||||||
profit_rate=str(profit_rate(sale - cost, sale)),
|
|
||||||
threshold=str(threshold_of(category)),
|
|
||||||
converted_profit=str(
|
|
||||||
converted_profit(sale, profit_rate(sale - cost, sale), threshold_of(category))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for category, [sale, cost] in agg.items()
|
|
||||||
]
|
]
|
||||||
return GrossProfitOut(
|
return GrossProfitOut(
|
||||||
business_id=business_id,
|
business_id=business_id,
|
||||||
as_of=as_of,
|
as_of=as_of,
|
||||||
rows=rows_out,
|
rows=scan.rows,
|
||||||
categories=categories,
|
categories=categories,
|
||||||
missing_count=missing_count,
|
missing_count=scan.missing_count,
|
||||||
)
|
)
|
||||||
@@ -6,9 +6,13 @@
|
|||||||
按行汇总,对照表 #14)。两者的任何口径修改必须同步——预览给业务对数、
|
按行汇总,对照表 #14)。两者的任何口径修改必须同步——预览给业务对数、
|
||||||
核算才是算钱的法定固化点。
|
核算才是算钱的法定固化点。
|
||||||
|
|
||||||
策略点(待业务答复,全部集中为单函数,答复后只改函数 + 按回款批量重算):
|
策略点(业务答复 2026-10-19 已回填,后续调整仍集中在对应函数 + 按回款批量重算):
|
||||||
- ``_personal_rate``:#12 经理个人户单笔落台账 rate=0/绩效 0(12.5%+5%
|
- ``_personal_rate``:#12 经理个人户单笔由参数 manager.personal.included
|
||||||
聚合口径在月结时由 manager_team_share 计算);#20 在 ``_row_profit``
|
分流——0=单笔落 0(聚合口径在月结时由 manager_team_share 计算)、
|
||||||
|
1=按业务员递减公式计提;#20 在 ``_row_profit``(暂按不含税原样相减)
|
||||||
|
- 二次分摊(澄清 #1 答复「分配绩效」,算后分):``_load_allocations``
|
||||||
|
读生效方案,``_persist_ledger`` 按比例拆行(参与人份额 + 负责人余额,
|
||||||
|
行合计守恒);#2 余额归负责人、#3 二次分配即协助场景的核算载体
|
||||||
- 核算粒度 #25:业务级一次性计提——首个触发核算的回款计算全额应发,
|
- 核算粒度 #25:业务级一次性计提——首个触发核算的回款计算全额应发,
|
||||||
后续回款仅置 settlement_status=1(repeated=True),不重复计提
|
后续回款仅置 settlement_status=1(repeated=True),不重复计提
|
||||||
"""
|
"""
|
||||||
@@ -29,6 +33,7 @@ from app.models import (
|
|||||||
BusinessItem,
|
BusinessItem,
|
||||||
Customer,
|
Customer,
|
||||||
PerformanceLedger,
|
PerformanceLedger,
|
||||||
|
Personnel,
|
||||||
Receipt,
|
Receipt,
|
||||||
Shipment,
|
Shipment,
|
||||||
User,
|
User,
|
||||||
@@ -54,8 +59,8 @@ from app.services.param_service import parse_fraction, resolve_snapshot
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# snapshot_json 里的策略点注记(答复回填时的检索锚点)
|
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
|
||||||
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(#12 待答复):聚合口径在月结时由团队聚合计算"
|
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(未计入分成基数):绩效在月结时按团队与全公司分成计算"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -115,31 +120,34 @@ def calc_receipt(
|
|||||||
person = _load_person(session, business)
|
person = _load_person(session, business)
|
||||||
snapshot = resolve_snapshot(session, receipt.receipt_date)
|
snapshot = resolve_snapshot(session, receipt.receipt_date)
|
||||||
items = _load_items(session, business)
|
items = _load_items(session, business)
|
||||||
|
allocations = _load_allocations(session, business, person.id)
|
||||||
|
|
||||||
conversion = _convert_items(snapshot, person, items)
|
conversion = _convert_items(snapshot, person, items)
|
||||||
amounts = _compose_amounts(
|
amounts = _compose_amounts(
|
||||||
session, snapshot, person, business, receipt, shipment, conversion
|
session, snapshot, person, business, receipt, shipment, conversion
|
||||||
)
|
)
|
||||||
row = _persist_ledger(
|
rows = _persist_ledger(
|
||||||
session,
|
session,
|
||||||
receipt=receipt,
|
receipt=receipt,
|
||||||
business=business,
|
business=business,
|
||||||
person=person,
|
person=person,
|
||||||
shipment=shipment,
|
shipment=shipment,
|
||||||
items=items,
|
items=items,
|
||||||
|
allocations=allocations,
|
||||||
conversion=conversion,
|
conversion=conversion,
|
||||||
amounts=amounts,
|
amounts=amounts,
|
||||||
recalc_reason=recalc_reason,
|
recalc_reason=recalc_reason,
|
||||||
is_recalc=is_recalc,
|
is_recalc=is_recalc,
|
||||||
)
|
)
|
||||||
session.flush()
|
session.flush()
|
||||||
out = CalcReceiptOut(ledger=_to_out(row), repeated=False)
|
out = CalcReceiptOut(ledger=_to_out(rows.owner_row), repeated=False)
|
||||||
session.commit()
|
session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"核算完成 | receipt=%s | ledger=%s | payable=%s",
|
"核算完成 | receipt=%s | ledger=%s | payable=%s | 分摊行=%s",
|
||||||
receipt_id,
|
receipt_id,
|
||||||
row.id,
|
rows.owner_row.id,
|
||||||
amounts.payable,
|
amounts.payable,
|
||||||
|
len(rows.share_rows),
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -184,9 +192,11 @@ def _prepare_mode(
|
|||||||
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
|
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
|
||||||
# 两行生效行导致双重计提 → 一律 400
|
# 两行生效行导致双重计提 → 一律 400
|
||||||
if is_recalc:
|
if is_recalc:
|
||||||
old = performance_ledger_repository.get_active_by_receipt(session, receipt.id)
|
# 分摊拆行后旧版本可能多行(负责人+参与人)——全部作废再落新版本
|
||||||
if old is None:
|
olds = performance_ledger_repository.list_active_by_receipt(session, receipt.id)
|
||||||
|
if not olds:
|
||||||
raise BadRequestError("该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算")
|
raise BadRequestError("该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算")
|
||||||
|
for old in olds:
|
||||||
performance_ledger_repository.mark_inactive(session, old)
|
performance_ledger_repository.mark_inactive(session, old)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -231,6 +241,27 @@ def _load_items(session: Session, business: Business) -> list[BusinessItem]:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _load_allocations(session: Session, business: Business, owner_id: int) -> list:
|
||||||
|
"""业务生效的二次分配方案(澄清 #1 答复 2026-10-19:分配绩效——算后分)。
|
||||||
|
|
||||||
|
校验双保险之一(保存侧在 business_service):比例合计 >100% 会把负责人
|
||||||
|
份额算成负数(多算参与人绩效);合同负责人不可出现在参与人列表(自分
|
||||||
|
份额会双重计入)。无方案返回空列表 = 全额归负责人。
|
||||||
|
"""
|
||||||
|
allocations = business_repository.list_allocations(session, business.id)
|
||||||
|
if not allocations:
|
||||||
|
return []
|
||||||
|
total = sum((a.ratio for a in allocations), Decimal("0"))
|
||||||
|
if total > Decimal("100"):
|
||||||
|
raise BadRequestError(
|
||||||
|
f"二次分配比例合计 {total.quantize(Decimal('0.01'))}% 超过 100%,"
|
||||||
|
"无法核算(先调整方案比例)"
|
||||||
|
)
|
||||||
|
if any(a.personnel_id == owner_id for a in allocations):
|
||||||
|
raise BadRequestError("二次分配参与人含合同负责营销人员,无法核算(先调整方案)")
|
||||||
|
return list(allocations)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class _Conversion:
|
class _Conversion:
|
||||||
"""逐行折算结果(行级明细入台账快照,汇总值驱动计提与起付判定)。"""
|
"""逐行折算结果(行级明细入台账快照,汇总值驱动计提与起付判定)。"""
|
||||||
@@ -347,6 +378,14 @@ def _compose_amounts(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _LedgerRows:
|
||||||
|
"""拆行落库结果:负责人行恒存在(出参与 CAS 锚点),参与人份额行可空。"""
|
||||||
|
|
||||||
|
owner_row: PerformanceLedger
|
||||||
|
share_rows: list[PerformanceLedger]
|
||||||
|
|
||||||
|
|
||||||
def _persist_ledger(
|
def _persist_ledger(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -355,14 +394,23 @@ def _persist_ledger(
|
|||||||
person,
|
person,
|
||||||
shipment: Shipment,
|
shipment: Shipment,
|
||||||
items: list[BusinessItem],
|
items: list[BusinessItem],
|
||||||
|
allocations: list,
|
||||||
conversion: _Conversion,
|
conversion: _Conversion,
|
||||||
amounts: _Amounts,
|
amounts: _Amounts,
|
||||||
recalc_reason: str | None,
|
recalc_reason: str | None,
|
||||||
is_recalc: bool,
|
is_recalc: bool,
|
||||||
) -> PerformanceLedger:
|
) -> _LedgerRows:
|
||||||
"""落台账行(版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。"""
|
"""落台账行(分摊拆行;版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。
|
||||||
|
|
||||||
|
分摊(澄清 #1 答复 2026-10-19「分配绩效」,算后分):业务级全额(含
|
||||||
|
递减比例、抵顶、支付权重全环节)算完后按方案比例拆给参与人,余额归
|
||||||
|
合同负责人。行合计与业务级全额**守恒**(负责人行取 q2 全额减参与人
|
||||||
|
q2 份额的差额,防分漂);每行快照带完整方案快照,行级可独立回溯。
|
||||||
|
参与人离职照分(历史方案事实,方案调整是业务侧责任——入职校验只在
|
||||||
|
保存侧,核算侧只要求人员存在,FK RESTRICT 已保证)。
|
||||||
|
"""
|
||||||
receipt_date = receipt.receipt_date
|
receipt_date = receipt.receipt_date
|
||||||
snapshot_json: dict[str, Any] = {
|
base_snapshot: dict[str, Any] = {
|
||||||
"params_as_of": str(receipt_date),
|
"params_as_of": str(receipt_date),
|
||||||
"line_details": conversion.line_details,
|
"line_details": conversion.line_details,
|
||||||
"start_payment_ok": amounts.start_ok,
|
"start_payment_ok": amounts.start_ok,
|
||||||
@@ -371,43 +419,100 @@ def _persist_ledger(
|
|||||||
"profit_total": str(conversion.total_profit),
|
"profit_total": str(conversion.total_profit),
|
||||||
}
|
}
|
||||||
if amounts.decision.manager_pending:
|
if amounts.decision.manager_pending:
|
||||||
snapshot_json["strategy_note"] = _NOTE_MANAGER_PENDING
|
base_snapshot["strategy_note"] = _NOTE_MANAGER_PENDING
|
||||||
|
if allocations:
|
||||||
|
base_snapshot["alloc_plan"] = [
|
||||||
|
{
|
||||||
|
"personnel_id": a.personnel_id,
|
||||||
|
"personnel_name": a.personnel_name,
|
||||||
|
"ratio": str(a.ratio),
|
||||||
|
}
|
||||||
|
for a in allocations
|
||||||
|
]
|
||||||
|
|
||||||
|
version = performance_ledger_repository.next_version(session, receipt.id)
|
||||||
|
customer_type = _customer_type(session, business)
|
||||||
|
category = _category_label(items)
|
||||||
|
|
||||||
|
# 份额清单:(person, ratio, tag)——ratio 为 None 时走余额公式(q2 全额
|
||||||
|
# 减已分累计,守恒);负责人余额的百分数(100−∑参与人)仅作快照展示,
|
||||||
|
# 不参与计算——按它乘全额会丢 q2 尾差(39.1111×30% ≠ 39.11−27.37)
|
||||||
|
shares: list[tuple[Any, Decimal | None, str]] = []
|
||||||
|
owner_ratio_text: str | None = None
|
||||||
|
if not allocations:
|
||||||
|
shares.append((person, None, "owner"))
|
||||||
|
owner_ratio_text = "100"
|
||||||
|
else:
|
||||||
|
total_ratio = Decimal("0")
|
||||||
|
for a in allocations:
|
||||||
|
shares.append((session.get(Personnel, a.personnel_id), a.ratio, "participant"))
|
||||||
|
total_ratio += a.ratio
|
||||||
|
owner_ratio_text = str((Decimal("100") - total_ratio).quantize(Decimal("0.01")))
|
||||||
|
shares.append((person, None, "owner"))
|
||||||
|
|
||||||
|
def _share(full: Decimal, ratio: Decimal | None, *, added: Decimal = Decimal("0")) -> Decimal:
|
||||||
|
"""单份额金额:参与人 = 全额×比例(q2);负责人 = q2 全额 − 已分累计。"""
|
||||||
|
if ratio is None:
|
||||||
|
return _q2(full - added) or Decimal("0")
|
||||||
|
# Fraction 乘积先 to_decimal 再 q2(Fraction 无 quantize,直接喂 _q2 会炸)
|
||||||
|
return _q2(param_service.to_decimal(Fraction(full) * Fraction(ratio) / 100)) or Decimal("0")
|
||||||
|
|
||||||
|
added_perf = Decimal("0")
|
||||||
|
added_conv = Decimal("0")
|
||||||
|
added_pay = Decimal("0")
|
||||||
|
owner_row: PerformanceLedger | None = None
|
||||||
|
share_rows: list[PerformanceLedger] = []
|
||||||
|
for target, ratio, tag in shares:
|
||||||
|
perf = _share(amounts.performance, ratio, added=added_perf)
|
||||||
|
conv = _share(amounts.converted, ratio, added=added_conv)
|
||||||
|
pay = _share(amounts.payable, ratio, added=added_pay)
|
||||||
|
added_perf += perf
|
||||||
|
added_conv += conv
|
||||||
|
added_pay += pay
|
||||||
row = performance_ledger_repository.add(
|
row = performance_ledger_repository.add(
|
||||||
session,
|
session,
|
||||||
PerformanceLedger(
|
PerformanceLedger(
|
||||||
receipt_id=receipt.id,
|
receipt_id=receipt.id,
|
||||||
calc_version=performance_ledger_repository.next_version(session, receipt.id),
|
calc_version=version,
|
||||||
calc_type=CALC_TYPE_RECEIPT,
|
calc_type=CALC_TYPE_RECEIPT,
|
||||||
business_id=business.id,
|
business_id=business.id,
|
||||||
business_no=business.business_no,
|
business_no=business.business_no,
|
||||||
personnel_id=person.id,
|
personnel_id=target.id,
|
||||||
personnel_name=person.personnel_name,
|
personnel_name=target.personnel_name,
|
||||||
role_code=role_code_of(person),
|
role_code=role_code_of(target),
|
||||||
customer_type=_customer_type(session, business),
|
customer_type=customer_type,
|
||||||
product_category=_category_label(items),
|
product_category=category,
|
||||||
ship_date=shipment.ship_date,
|
ship_date=shipment.ship_date,
|
||||||
receipt_date=receipt_date,
|
receipt_date=receipt_date,
|
||||||
payment_days=amounts.days,
|
payment_days=amounts.days,
|
||||||
rate_text=str(amounts.decision.rate),
|
rate_text=str(amounts.decision.rate),
|
||||||
converted_profit=_q2(amounts.converted),
|
converted_profit=conv,
|
||||||
performance_amount=_q2(amounts.performance),
|
performance_amount=perf,
|
||||||
barter_applied=1 if amounts.barter_applied else 0,
|
barter_applied=1 if amounts.barter_applied else 0,
|
||||||
weight_r=amounts.weight_r,
|
weight_r=amounts.weight_r,
|
||||||
payment_weight=param_service.to_decimal(amounts.weight),
|
payment_weight=param_service.to_decimal(amounts.weight),
|
||||||
payable_amount=_q2(amounts.payable),
|
payable_amount=pay,
|
||||||
settlement_month=receipt_date.strftime("%Y-%m"),
|
settlement_month=receipt_date.strftime("%Y-%m"),
|
||||||
is_probation=1 if person.is_probation == 1 else 0,
|
is_probation=1 if target.is_probation == 1 else 0,
|
||||||
missing_cost=1 if conversion.missing_cost else 0,
|
missing_cost=1 if conversion.missing_cost else 0,
|
||||||
snapshot_json=snapshot_json,
|
snapshot_json={
|
||||||
|
**base_snapshot,
|
||||||
|
"alloc_role": tag,
|
||||||
|
"alloc_ratio": owner_ratio_text if tag == "owner" else str(ratio),
|
||||||
|
},
|
||||||
calc_reason=recalc_reason,
|
calc_reason=recalc_reason,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if tag == "owner":
|
||||||
|
owner_row = row
|
||||||
|
else:
|
||||||
|
share_rows.append(row)
|
||||||
|
|
||||||
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
|
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
|
||||||
# 会 rowcount=0 误报并发撞车)
|
# 会 rowcount=0 误报并发撞车)
|
||||||
if not is_recalc:
|
if not is_recalc:
|
||||||
_mark_settled(session, receipt)
|
_mark_settled(session, receipt)
|
||||||
return row
|
return _LedgerRows(owner_row=owner_row, share_rows=share_rows)
|
||||||
|
|
||||||
|
|
||||||
def _q2(value: Decimal | None) -> Decimal | None:
|
def _q2(value: Decimal | None) -> Decimal | None:
|
||||||
@@ -439,20 +544,31 @@ def _personal_rate(
|
|||||||
if customer_type == "enterprise":
|
if customer_type == "enterprise":
|
||||||
# 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线)
|
# 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线)
|
||||||
rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000
|
rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000
|
||||||
return _RateDecision(rate, "企业户固定计提比例", None, False)
|
return _RateDecision(rate, "企业户:不按回款天数,统一按固定比例计提", None, False)
|
||||||
|
|
||||||
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
|
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
|
||||||
if template == "TPL_MANAGER_SHARE":
|
if template == "TPL_MANAGER_SHARE":
|
||||||
|
# #12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户
|
||||||
|
# 单按业务员递减公式单笔计提(个人户单本质是正常业务,有发货/回款/
|
||||||
|
# 毛利;聚合基数开关在 settlement_service 同参数消费)——0 时单笔落 0
|
||||||
|
if snapshot.get("manager.personal.included") != "1":
|
||||||
return _RateDecision(
|
return _RateDecision(
|
||||||
Decimal("0"),
|
Decimal("0"),
|
||||||
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
|
"经理个人户:绩效按团队与全公司分成在月结时计算,单笔不计提",
|
||||||
None,
|
None,
|
||||||
True,
|
True,
|
||||||
)
|
)
|
||||||
|
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
||||||
|
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
||||||
|
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"经理个人户(已开启计入分成基数):按业务员递减,回款 {days} 天,起始 {start}‰"
|
||||||
|
return _RateDecision(rate, note, None, False)
|
||||||
if template == "TPL_INFO_SALES_FIXED":
|
if template == "TPL_INFO_SALES_FIXED":
|
||||||
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
|
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
|
||||||
rate = parse_fraction(snapshot.get("info.rate")) / 1000
|
rate = parse_fraction(snapshot.get("info.rate")) / 1000
|
||||||
return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False)
|
return _RateDecision(rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", None, False)
|
||||||
if template == "TPL_SALESPERSON_DECAY":
|
if template == "TPL_SALESPERSON_DECAY":
|
||||||
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
||||||
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
||||||
@@ -461,9 +577,16 @@ def _personal_rate(
|
|||||||
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
|
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
|
||||||
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||||
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||||
note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)"
|
note = (
|
||||||
|
f"回款 {days} 天,从起始 {start}‰ 按日递减"
|
||||||
|
f"(满 {boundary} 天后固定按 {floor}‰ 计算)"
|
||||||
|
)
|
||||||
return _RateDecision(rate, note, None, False)
|
return _RateDecision(rate, note, None, False)
|
||||||
raise BadRequestError(f"公式模板 {template} 暂不支持核算")
|
# 提示给模板中文名而非编码:业务人员在岗位角色页看到的是「营销经理·团队与全公司分成」
|
||||||
|
from app.services.calc_templates import TEMPLATES
|
||||||
|
|
||||||
|
tpl_name = TEMPLATES[template].name if template in TEMPLATES else template
|
||||||
|
raise BadRequestError(f"「{tpl_name}」不支持单笔核算,请检查该人员的岗位设置")
|
||||||
|
|
||||||
|
|
||||||
def _role_of(session: Session, person):
|
def _role_of(session: Session, person):
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Row
|
from sqlalchemy import Row
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -37,6 +39,7 @@ from app.schemas.customer import (
|
|||||||
CustomerOut,
|
CustomerOut,
|
||||||
CustomerOwnershipIn,
|
CustomerOwnershipIn,
|
||||||
CustomerQuery,
|
CustomerQuery,
|
||||||
|
CustomerRatingIn,
|
||||||
CustomerUpdateIn,
|
CustomerUpdateIn,
|
||||||
OwnershipChangeLogOut,
|
OwnershipChangeLogOut,
|
||||||
)
|
)
|
||||||
@@ -60,6 +63,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
|
|||||||
owner_personnel_id=customer.owner_personnel_id,
|
owner_personnel_id=customer.owner_personnel_id,
|
||||||
owner_personnel_name=owner_name,
|
owner_personnel_name=owner_name,
|
||||||
remark=customer.remark,
|
remark=customer.remark,
|
||||||
|
rating_score=customer.rating_score,
|
||||||
|
rating_time=customer.rating_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -107,20 +112,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
|||||||
|
|
||||||
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
|
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
|
||||||
"""分页查询客户(附归属人员姓名)。"""
|
"""分页查询客户(附归属人员姓名)。"""
|
||||||
rows = customer_repository.list_customers(
|
rows = customer_repository.list_customers(session, query)
|
||||||
session,
|
total = customer_repository.count_customers(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
keyword=query.keyword,
|
|
||||||
customer_type=query.customer_type,
|
|
||||||
owner_personnel_id=query.owner_personnel_id,
|
|
||||||
)
|
|
||||||
total = customer_repository.count_customers(
|
|
||||||
session,
|
|
||||||
keyword=query.keyword,
|
|
||||||
customer_type=query.customer_type,
|
|
||||||
owner_personnel_id=query.owner_personnel_id,
|
|
||||||
)
|
|
||||||
return PageResult[CustomerOut](
|
return PageResult[CustomerOut](
|
||||||
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
|
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
|
||||||
total=total,
|
total=total,
|
||||||
@@ -274,6 +267,53 @@ def update_customer(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def rate_customer(
|
||||||
|
session: Session,
|
||||||
|
customer_id: int,
|
||||||
|
payload: CustomerRatingIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> CustomerOut:
|
||||||
|
"""保存客户评级(评级计算器「保存评分」)。
|
||||||
|
|
||||||
|
评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用
|
||||||
|
等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯
|
||||||
|
「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够
|
||||||
|
回答「这个分是什么时候打的」。等级由前端按当前映射实时算并展示。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
"""
|
||||||
|
# 客户名进审计摘要,预取放在审计上下文之前(同 change_ownership 口径)
|
||||||
|
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||||
|
customer_name = row[0].company_name if row else f"#{customer_id}"
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_CUSTOMER,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"保存客户评级 {customer_name}:{payload.score} 分",
|
||||||
|
request_data=payload.model_dump(),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
customer, owner_name = row
|
||||||
|
|
||||||
|
customer.rating_score = payload.score
|
||||||
|
# 评级时间取应用层本地时间(库列 TZ=Asia/Shanghai,与 TimestampMixin
|
||||||
|
# 的服务器默认值同一时区口径),不用数据库 NOW():行已在本会话
|
||||||
|
customer.rating_time = datetime.now()
|
||||||
|
|
||||||
|
# 响应在 commit 之前构造(同 user_service 顶部说明)
|
||||||
|
out = _to_out(customer, owner_name)
|
||||||
|
session.commit()
|
||||||
|
logger.info("保存客户评级 | customer_id=%s | score=%s", customer_id, payload.score)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def delete_customer(
|
def delete_customer(
|
||||||
session: Session,
|
session: Session,
|
||||||
customer_id: int,
|
customer_id: int,
|
||||||
@@ -317,6 +357,71 @@ def delete_customer(
|
|||||||
logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name)
|
logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _CustomerOwnershipPrefetch:
|
||||||
|
"""客户归属变更的审计前只读预取(摘要要客户名与新旧归属人名)。"""
|
||||||
|
|
||||||
|
row: Row | None
|
||||||
|
new_person: Personnel | None
|
||||||
|
old_label: str
|
||||||
|
new_label: str
|
||||||
|
customer_name: str
|
||||||
|
|
||||||
|
|
||||||
|
def _prefetch_customer_ownership(
|
||||||
|
session: Session, customer_id: int, payload: CustomerOwnershipIn
|
||||||
|
) -> _CustomerOwnershipPrefetch:
|
||||||
|
"""审计上下文之前的只读预取(预取不写库,失败路径也要有可读摘要)。"""
|
||||||
|
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||||
|
old_person = (
|
||||||
|
personnel_repository.get_by_id(session, row[0].owner_personnel_id)
|
||||||
|
if row is not None and row[0].owner_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
new_person = (
|
||||||
|
personnel_repository.get_by_id(session, payload.new_owner_personnel_id)
|
||||||
|
if payload.new_owner_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return _CustomerOwnershipPrefetch(
|
||||||
|
row=row,
|
||||||
|
new_person=new_person,
|
||||||
|
old_label=old_person.personnel_name if old_person else "无",
|
||||||
|
new_label=new_person.personnel_name if new_person else "无",
|
||||||
|
customer_name=row[0].company_name if row else f"#{customer_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_customer_ownership(
|
||||||
|
session: Session, customer: Customer, payload: CustomerOwnershipIn
|
||||||
|
) -> int | None:
|
||||||
|
"""校验新归属人(须在职)并判定归属是否变化,返回变更前归属人 ID。"""
|
||||||
|
if payload.new_owner_personnel_id is not None:
|
||||||
|
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
|
||||||
|
old_owner_id = customer.owner_personnel_id
|
||||||
|
if old_owner_id == payload.new_owner_personnel_id:
|
||||||
|
raise BadRequestError("归属未变化")
|
||||||
|
return old_owner_id
|
||||||
|
|
||||||
|
|
||||||
|
def _customer_ownership_log(
|
||||||
|
customer: Customer,
|
||||||
|
payload: CustomerOwnershipIn,
|
||||||
|
old_owner_id: int | None,
|
||||||
|
operator: User,
|
||||||
|
) -> OwnershipChangeLog:
|
||||||
|
"""客户归属变更留痕行。"""
|
||||||
|
return OwnershipChangeLog(
|
||||||
|
owner_type=OWNER_TYPE_CUSTOMER,
|
||||||
|
customer_id=customer.id,
|
||||||
|
old_owner_personnel_id=old_owner_id,
|
||||||
|
new_owner_personnel_id=payload.new_owner_personnel_id,
|
||||||
|
change_reason=payload.reason,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def change_ownership(
|
def change_ownership(
|
||||||
session: Session,
|
session: Session,
|
||||||
customer_id: int,
|
customer_id: int,
|
||||||
@@ -335,57 +440,34 @@ def change_ownership(
|
|||||||
"""
|
"""
|
||||||
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
|
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
|
||||||
# 上下文内进行,失败同样要留下可追溯的记录
|
# 上下文内进行,失败同样要留下可追溯的记录
|
||||||
row = customer_repository.get_with_owner_name(session, customer_id)
|
prefetch = _prefetch_customer_ownership(session, customer_id, payload)
|
||||||
old_person = (
|
|
||||||
personnel_repository.get_by_id(session, row[0].owner_personnel_id)
|
|
||||||
if row is not None and row[0].owner_personnel_id is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
new_person = (
|
|
||||||
personnel_repository.get_by_id(session, payload.new_owner_personnel_id)
|
|
||||||
if payload.new_owner_personnel_id is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
old_label = old_person.personnel_name if old_person else "无"
|
|
||||||
new_label = new_person.personnel_name if new_person else "无"
|
|
||||||
customer_name = row[0].company_name if row else f"#{customer_id}"
|
|
||||||
with audit_service.audit(
|
with audit_service.audit(
|
||||||
session,
|
session,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
record=AuditRecord(
|
record=AuditRecord(
|
||||||
module=MODULE_CUSTOMER,
|
module=MODULE_CUSTOMER,
|
||||||
action=ACTION_UPDATE,
|
action=ACTION_UPDATE,
|
||||||
summary=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),
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
),
|
),
|
||||||
meta=meta,
|
meta=meta,
|
||||||
):
|
):
|
||||||
if row is None:
|
if prefetch.row is None:
|
||||||
raise NotFoundError("客户不存在")
|
raise NotFoundError("客户不存在")
|
||||||
customer, _ = row
|
customer, _ = prefetch.row
|
||||||
|
|
||||||
if payload.new_owner_personnel_id is not None:
|
old_owner_id = _validate_customer_ownership(session, customer, payload)
|
||||||
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
|
|
||||||
old_owner_id = customer.owner_personnel_id
|
|
||||||
if old_owner_id == payload.new_owner_personnel_id:
|
|
||||||
raise BadRequestError("归属未变化")
|
|
||||||
|
|
||||||
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
|
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
|
||||||
customer.owner_personnel_id = payload.new_owner_personnel_id
|
customer.owner_personnel_id = payload.new_owner_personnel_id
|
||||||
customer_repository.add_ownership_log(
|
customer_repository.add_ownership_log(
|
||||||
session,
|
session, _customer_ownership_log(customer, payload, old_owner_id, operator)
|
||||||
OwnershipChangeLog(
|
|
||||||
owner_type=OWNER_TYPE_CUSTOMER,
|
|
||||||
customer_id=customer.id,
|
|
||||||
old_owner_personnel_id=old_owner_id,
|
|
||||||
new_owner_personnel_id=payload.new_owner_personnel_id,
|
|
||||||
change_reason=payload.reason,
|
|
||||||
operator_id=operator.id,
|
|
||||||
operator_name=operator.username,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
|
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
|
||||||
out = _to_out(customer, 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()
|
session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"调整客户归属 | customer_id=%s | old=%s | new=%s",
|
"调整客户归属 | 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]:
|
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
|
||||||
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
|
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
|
||||||
filters = _filters_from(query)
|
repo_query = operation_log_repository.OperationLogListQuery(
|
||||||
logs = operation_log_repository.list_logs(
|
filters=_filters_from(query),
|
||||||
session,
|
|
||||||
offset=query.offset,
|
offset=query.offset,
|
||||||
limit=query.page_size,
|
limit=query.page_size,
|
||||||
filters=filters,
|
|
||||||
module=query.module,
|
module=query.module,
|
||||||
action=query.action,
|
action=query.action,
|
||||||
)
|
)
|
||||||
total = operation_log_repository.count_logs(
|
logs = operation_log_repository.list_logs(session, repo_query)
|
||||||
session, filters=filters, module=query.module, action=query.action
|
total = operation_log_repository.count_logs(session, repo_query)
|
||||||
)
|
|
||||||
return PageResult[OperationLogOut](
|
return PageResult[OperationLogOut](
|
||||||
records=[OperationLogOut.model_validate(log) for log in logs],
|
records=[OperationLogOut.model_validate(log) for log in logs],
|
||||||
total=total,
|
total=total,
|
||||||
@@ -55,15 +52,14 @@ def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut:
|
|||||||
|
|
||||||
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
|
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
|
||||||
"""分页查询登录日志。"""
|
"""分页查询登录日志。"""
|
||||||
filters = _filters_from(query)
|
repo_query = login_log_repository.LoginLogListQuery(
|
||||||
logs = login_log_repository.list_logs(
|
filters=_filters_from(query),
|
||||||
session,
|
|
||||||
offset=query.offset,
|
offset=query.offset,
|
||||||
limit=query.page_size,
|
limit=query.page_size,
|
||||||
filters=filters,
|
|
||||||
ip=query.ip,
|
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](
|
return PageResult[LoginLogOut](
|
||||||
records=[LoginLogOut.model_validate(log) for log in logs],
|
records=[LoginLogOut.model_validate(log) for log in logs],
|
||||||
total=total,
|
total=total,
|
||||||
|
|||||||
@@ -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 —— 见算法红线)。
|
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
|
||||||
|
|
||||||
|
`description` 会直接显示在「计提参数」页的参数名下方,**面向业务人员撰写**:
|
||||||
|
说清这个参数管什么、钱怎么变、什么时候该调它。不写英文枚举名、需求编号、
|
||||||
|
内部阶段号,也不写实现层面的精度约束(那些留在代码注释与测试里)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -43,7 +47,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="10",
|
default_value="10",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
|
description="回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。",
|
||||||
sort_order=1,
|
sort_order=1,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -57,7 +61,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="4/360",
|
default_value="4/360",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
|
description="回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。",
|
||||||
sort_order=2,
|
sort_order=2,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -71,7 +75,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="4",
|
default_value="4",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
|
description="回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。",
|
||||||
sort_order=3,
|
sort_order=3,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -85,7 +89,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="360",
|
default_value="360",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
|
description="回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。",
|
||||||
sort_order=4,
|
sort_order=4,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -99,7 +103,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="12.5",
|
default_value="12.5",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max="100",
|
value_max="100",
|
||||||
description="∑本人带领业务员绩效 × 此比例(限本团队)",
|
description="经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。",
|
||||||
sort_order=5,
|
sort_order=5,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -113,7 +117,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="5",
|
default_value="5",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max="100",
|
value_max="100",
|
||||||
description="∑全部业务员绩效 × 此比例(全公司)",
|
description="经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。",
|
||||||
sort_order=6,
|
sort_order=6,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -127,7 +131,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="3",
|
default_value="3",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
|
description="企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。",
|
||||||
sort_order=7,
|
sort_order=7,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -141,7 +145,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="1",
|
default_value="1",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="信息员绩效 = 本人销售额 × 此比例",
|
description="信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。",
|
||||||
sort_order=8,
|
sort_order=8,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -155,7 +159,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="10",
|
default_value="10",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="预付款统一按此比例计提",
|
description="预付款到账即按这个比例计提绩效,不必等到全额回款。",
|
||||||
sort_order=9,
|
sort_order=9,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -169,7 +173,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="80",
|
default_value="80",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max="100",
|
value_max="100",
|
||||||
description="以物抵顶回款绩效按应发绩效的此比例折算",
|
description="以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。",
|
||||||
sort_order=10,
|
sort_order=10,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -183,7 +187,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="60",
|
default_value="60",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max="100",
|
value_max="100",
|
||||||
description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
|
description="单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。",
|
||||||
sort_order=11,
|
sort_order=11,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -197,7 +201,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="FULL",
|
default_value="FULL",
|
||||||
value_min=None,
|
value_min=None,
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
|
description="选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。",
|
||||||
sort_order=12,
|
sort_order=12,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -211,7 +215,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="30",
|
default_value="30",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max="100",
|
value_max="100",
|
||||||
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
|
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。",
|
||||||
sort_order=13,
|
sort_order=13,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -225,7 +229,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="INCLUDE",
|
default_value="INCLUDE",
|
||||||
value_min=None,
|
value_min=None,
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
|
description="算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。",
|
||||||
sort_order=14,
|
sort_order=14,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -239,7 +243,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="18000",
|
default_value="18000",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="试用期年累计绩效超过此值部分年底一次性结算",
|
description="试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。",
|
||||||
sort_order=15,
|
sort_order=15,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -253,11 +257,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="1",
|
default_value="1",
|
||||||
value_min=None,
|
value_min=None,
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description=(
|
description="二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。",
|
||||||
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
|
|
||||||
"1=提交后进入财务复核→领导审核审批流。"
|
|
||||||
"默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭"
|
|
||||||
),
|
|
||||||
sort_order=17,
|
sort_order=17,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -271,11 +271,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="1",
|
default_value="1",
|
||||||
value_min=None,
|
value_min=None,
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description=(
|
description="月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。",
|
||||||
"月度结算审批链开关(④ 月结提交时消费):"
|
|
||||||
"0=结算生成即生效,1=走财务复核→领导审核审批流。"
|
|
||||||
"默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭"
|
|
||||||
),
|
|
||||||
sort_order=18,
|
sort_order=18,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -289,11 +285,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="0",
|
default_value="0",
|
||||||
value_min=None,
|
value_min=None,
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description=(
|
description="调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。",
|
||||||
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
|
|
||||||
"调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,"
|
|
||||||
"默认值业务未明确,暂按直接生效,业务可开启)"
|
|
||||||
),
|
|
||||||
sort_order=19,
|
sort_order=19,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -307,13 +299,53 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="0",
|
default_value="0",
|
||||||
value_min=None,
|
value_min=None,
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description=(
|
description="经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。",
|
||||||
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
|
|
||||||
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
|
|
||||||
"0=不计入(默认,暂口径) 1=计入"
|
|
||||||
),
|
|
||||||
sort_order=20,
|
sort_order=20,
|
||||||
),
|
),
|
||||||
|
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
|
||||||
|
# 首批三条中「同步连续失败」依赖 ⑥ 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
|
import logging
|
||||||
from collections.abc import Iterable
|
from collections.abc import Callable, Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -44,7 +44,7 @@ from app.schemas.param import (
|
|||||||
ParamPreviewOut,
|
ParamPreviewOut,
|
||||||
ParamUsedOut,
|
ParamUsedOut,
|
||||||
)
|
)
|
||||||
from app.services import audit_service, formula
|
from app.services import audit_service, calc_templates, formula
|
||||||
from app.services.audit_service import AuditRecord, RequestMeta
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
from app.services.formula import to_decimal
|
from app.services.formula import to_decimal
|
||||||
from app.services.no_generator import next_no
|
from app.services.no_generator import next_no
|
||||||
@@ -379,16 +379,8 @@ def get_history(
|
|||||||
definition = param_repository.get_definition_by_code(session, param_code)
|
definition = param_repository.get_definition_by_code(session, param_code)
|
||||||
if definition is None:
|
if definition is None:
|
||||||
raise NotFoundError("参数不存在")
|
raise NotFoundError("参数不存在")
|
||||||
rows = param_repository.list_history(
|
rows = param_repository.list_history(session, definition.id, query)
|
||||||
session,
|
total = param_repository.count_history(session, definition.id, query)
|
||||||
definition.id,
|
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
dimension_value=query.dimension_value,
|
|
||||||
)
|
|
||||||
total = param_repository.count_history(
|
|
||||||
session, definition.id, dimension_value=query.dimension_value
|
|
||||||
)
|
|
||||||
return PageResult[ParamHistoryOut](
|
return PageResult[ParamHistoryOut](
|
||||||
records=[
|
records=[
|
||||||
ParamHistoryOut(
|
ParamHistoryOut(
|
||||||
@@ -410,16 +402,8 @@ def get_history(
|
|||||||
|
|
||||||
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
|
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
|
||||||
"""变更批次分页(按创建时间倒序)。"""
|
"""变更批次分页(按创建时间倒序)。"""
|
||||||
batches = param_repository.list_batches(
|
batches = param_repository.list_batches(session, query)
|
||||||
session,
|
total = param_repository.count_batches(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
start_date=query.start_date,
|
|
||||||
end_date=query.end_date,
|
|
||||||
)
|
|
||||||
total = param_repository.count_batches(
|
|
||||||
session, start_date=query.start_date, end_date=query.end_date
|
|
||||||
)
|
|
||||||
return PageResult[ParamBatchOut](
|
return PageResult[ParamBatchOut](
|
||||||
records=[
|
records=[
|
||||||
ParamBatchOut(
|
ParamBatchOut(
|
||||||
@@ -509,42 +493,89 @@ def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDi
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _preview_rate(
|
@dataclass(frozen=True, slots=True)
|
||||||
session: Session,
|
class _PreviewContext:
|
||||||
payload: ParamPreviewIn,
|
"""预览上下文(会话、入参、参数取值闭包)。
|
||||||
snapshot: ParamSnapshot,
|
|
||||||
days: int,
|
``take`` 闭包同时负责取数与记录「用了哪些参数」——**调用顺序即
|
||||||
take,
|
``params_used`` 的展示顺序**,拆分时不得调换各段的先后。
|
||||||
) -> tuple[Fraction, str, Decimal | None]:
|
"""
|
||||||
|
|
||||||
|
session: Session
|
||||||
|
payload: ParamPreviewIn
|
||||||
|
take: Callable[[str], Fraction]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _PreviewRateDecision:
|
||||||
|
"""计提比例判定(回款天数、纯比例、人读说明、绩效基数覆盖值)。"""
|
||||||
|
|
||||||
|
days: int
|
||||||
|
rate: Fraction
|
||||||
|
rate_text: str
|
||||||
|
base_override: Decimal | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _PreviewEarning:
|
||||||
|
"""折算与绩效(毛利润率、折算额、绩效额)。"""
|
||||||
|
|
||||||
|
profit_rate: Fraction
|
||||||
|
converted: Decimal
|
||||||
|
performance: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _PreviewBarter:
|
||||||
|
"""以物抵顶(是否适用、折算后绩效、计发基数)。"""
|
||||||
|
|
||||||
|
applied: bool
|
||||||
|
after_barter: Decimal | None
|
||||||
|
base_amount: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _PreviewPayment:
|
||||||
|
"""支付环节(起付判定、R 权重、权重、应付金额)。"""
|
||||||
|
|
||||||
|
start_ok: bool
|
||||||
|
weight_r: int
|
||||||
|
weight: Fraction
|
||||||
|
payable: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decimal | None]:
|
||||||
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
|
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
|
||||||
|
|
||||||
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
|
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
|
||||||
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
|
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
|
||||||
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
||||||
"""
|
"""
|
||||||
|
payload = ctx.payload
|
||||||
|
take = ctx.take
|
||||||
if payload.customer_type == "enterprise":
|
if payload.customer_type == "enterprise":
|
||||||
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
|
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
|
||||||
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
|
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
|
||||||
rate = take("enterprise.rate") / 1000
|
rate = take("enterprise.rate") / 1000
|
||||||
return rate, "企业户固定计提比例", None
|
return rate, "企业户:不按回款天数,统一按固定比例计提", None
|
||||||
|
|
||||||
role_code = payload.business_role
|
role_code = payload.business_role
|
||||||
template = "TPL_SALESPERSON_DECAY"
|
template = "TPL_SALESPERSON_DECAY"
|
||||||
if role_code:
|
if role_code:
|
||||||
role = business_role_repository.get_by_code(session, role_code)
|
role = business_role_repository.get_by_code(ctx.session, role_code)
|
||||||
if role is None:
|
if role is None:
|
||||||
raise BadRequestError(f"岗位 {role_code!r} 不存在")
|
raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认")
|
||||||
template = role.calc_template
|
template = role.calc_template
|
||||||
|
|
||||||
if template == "TPL_MANAGER_SHARE":
|
if template == "TPL_MANAGER_SHARE":
|
||||||
raise BadRequestError(
|
raise BadRequestError(
|
||||||
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
|
"营销经理的绩效按团队与全公司分成计算,不单笔试算;请到月度结算里查看"
|
||||||
)
|
)
|
||||||
if template == "TPL_INFO_SALES_FIXED":
|
if template == "TPL_INFO_SALES_FIXED":
|
||||||
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
|
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
|
||||||
rate = take("info.rate") / 1000
|
rate = take("info.rate") / 1000
|
||||||
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
|
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
|
||||||
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
|
return rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", Decimal(payload.revenue)
|
||||||
if template == "TPL_SALESPERSON_DECAY":
|
if template == "TPL_SALESPERSON_DECAY":
|
||||||
start = take("salesperson.rate.start")
|
start = take("salesperson.rate.start")
|
||||||
slope = take("salesperson.rate.slope")
|
slope = take("salesperson.rate.slope")
|
||||||
@@ -553,12 +584,84 @@ def _preview_rate(
|
|||||||
rate = formula.salesperson_rate(
|
rate = formula.salesperson_rate(
|
||||||
days, start=start, slope=slope, floor=floor, boundary_days=boundary
|
days, start=start, slope=slope, floor=floor, boundary_days=boundary
|
||||||
)
|
)
|
||||||
|
# 说明写成业务话:说清这笔为什么是这个比例、什么时候不再降
|
||||||
return (
|
return (
|
||||||
rate,
|
rate,
|
||||||
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
|
f"回款 {days} 天,从起始 {to_decimal(start)}‰ 按日递减"
|
||||||
|
f"(满 {boundary} 天后固定按 {to_decimal(floor)}‰ 计算)",
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
|
# 提示里给模板中文名而不是编码:编码是内部标识,业务人员在岗位角色页看不到它
|
||||||
|
tpl_name = calc_templates.TEMPLATES[template].name if template in calc_templates.TEMPLATES else template
|
||||||
|
raise BadRequestError(
|
||||||
|
f"「{tpl_name}」不支持单笔试算,请在岗位角色里改用业务员递减或信息员固定计提"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_rate_decision(ctx: _PreviewContext) -> _PreviewRateDecision:
|
||||||
|
"""产品类别校验 + 回款天数 + 计提比例判定。"""
|
||||||
|
payload = ctx.payload
|
||||||
|
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
|
||||||
|
# 提示里用中文类别名:前端下拉显示的是「市政水利」,报错却回英文编码会让人对不上
|
||||||
|
raise BadRequestError(
|
||||||
|
f"产品类别「{payload.product_category}」不存在,可选:市政水利、轨道交通"
|
||||||
|
)
|
||||||
|
days = formula.payment_days(payload.ship_date, payload.receipt_date)
|
||||||
|
rate, rate_text, base_override = _preview_rate(ctx, days)
|
||||||
|
return _PreviewRateDecision(
|
||||||
|
days=days, rate=rate, rate_text=rate_text, base_override=base_override
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_earning(ctx: _PreviewContext, decision: _PreviewRateDecision) -> _PreviewEarning:
|
||||||
|
"""毛利润率 → 折算额(阈值)→ 绩效额(比例)。"""
|
||||||
|
payload = ctx.payload
|
||||||
|
revenue = Decimal(payload.revenue)
|
||||||
|
profit = Decimal(payload.profit)
|
||||||
|
profit_rate = formula.profit_rate(profit, revenue)
|
||||||
|
if decision.base_override is not None:
|
||||||
|
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
|
||||||
|
converted = decision.base_override
|
||||||
|
else:
|
||||||
|
threshold = ctx.take("product.threshold")
|
||||||
|
converted = formula.converted_profit(revenue, profit_rate, threshold)
|
||||||
|
performance = formula.performance_amount(converted, decision.rate)
|
||||||
|
return _PreviewEarning(profit_rate=profit_rate, converted=converted, performance=performance)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_barter(ctx: _PreviewContext, performance: Decimal) -> _PreviewBarter:
|
||||||
|
"""以物抵顶折算(按应发绩效的固定比例折算,比例来自参数)。"""
|
||||||
|
applied = ctx.payload.barter
|
||||||
|
after_barter: Decimal | None = None
|
||||||
|
if applied:
|
||||||
|
discount = ctx.take("barter.discount")
|
||||||
|
after_barter = formula.apply_barter(performance, discount)
|
||||||
|
base_amount = after_barter if applied else performance
|
||||||
|
return _PreviewBarter(applied=applied, after_barter=after_barter, base_amount=base_amount)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_payment(
|
||||||
|
ctx: _PreviewContext, received: Decimal, revenue: Decimal, base_amount: Decimal
|
||||||
|
) -> _PreviewPayment:
|
||||||
|
"""起付比例判定 + R 权重 + 应付金额。"""
|
||||||
|
start_ratio = ctx.take("payment.start.ratio")
|
||||||
|
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
||||||
|
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
|
||||||
|
weight_r = formula.clamp_weight_r(received, revenue)
|
||||||
|
weight = formula.payment_weight(weight_r)
|
||||||
|
payable = to_decimal(Fraction(base_amount) * weight)
|
||||||
|
return _PreviewPayment(start_ok=start_ok, weight_r=weight_r, weight=weight, payable=payable)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_probation_note(ctx: _PreviewContext) -> str | None:
|
||||||
|
"""试用期说明(绩效照算不发放,年累计超阈值的部分年底一次性结算)。"""
|
||||||
|
if not ctx.payload.probation:
|
||||||
|
return None
|
||||||
|
annual = ctx.take("probation.annual.threshold")
|
||||||
|
return (
|
||||||
|
f"试用期人员:绩效照常计算但不发放;年内累计超过 {to_decimal(annual)} 元的部分,"
|
||||||
|
"年底一次性结清。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
||||||
@@ -566,7 +669,6 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
|||||||
# 参数按回款到账日取值(对照表 #11)
|
# 参数按回款到账日取值(对照表 #11)
|
||||||
snapshot = resolve_snapshot(session, payload.receipt_date)
|
snapshot = resolve_snapshot(session, payload.receipt_date)
|
||||||
revenue = Decimal(payload.revenue)
|
revenue = Decimal(payload.revenue)
|
||||||
profit = Decimal(payload.profit)
|
|
||||||
received = Decimal(payload.received_amount)
|
received = Decimal(payload.received_amount)
|
||||||
used: list[ParamUsedOut] = []
|
used: list[ParamUsedOut] = []
|
||||||
|
|
||||||
@@ -589,55 +691,29 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
|||||||
)
|
)
|
||||||
return parse_fraction(raw)
|
return parse_fraction(raw)
|
||||||
|
|
||||||
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
|
ctx = _PreviewContext(session=session, payload=payload, take=take)
|
||||||
raise BadRequestError(
|
# 各段顺序固定 —— take 的调用顺序即 params_used 的展示顺序
|
||||||
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit"
|
decision = _preview_rate_decision(ctx)
|
||||||
)
|
earning = _preview_earning(ctx, decision)
|
||||||
|
barter = _preview_barter(ctx, earning.performance)
|
||||||
days = formula.payment_days(payload.ship_date, payload.receipt_date)
|
payment = _preview_payment(ctx, received, revenue, barter.base_amount)
|
||||||
rate, rate_text, base_override = _preview_rate(session, payload, snapshot, days, take)
|
probation_note = _preview_probation_note(ctx)
|
||||||
|
|
||||||
profit_rate = formula.profit_rate(profit, revenue)
|
|
||||||
if base_override is not None:
|
|
||||||
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
|
|
||||||
converted = base_override
|
|
||||||
else:
|
|
||||||
threshold = take("product.threshold")
|
|
||||||
converted = formula.converted_profit(revenue, profit_rate, threshold)
|
|
||||||
performance = formula.performance_amount(converted, rate)
|
|
||||||
|
|
||||||
barter_applied = payload.barter
|
|
||||||
after_barter: Decimal | None = None
|
|
||||||
if barter_applied:
|
|
||||||
discount = take("barter.discount")
|
|
||||||
after_barter = formula.apply_barter(performance, discount)
|
|
||||||
|
|
||||||
start_ratio = take("payment.start.ratio")
|
|
||||||
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
|
||||||
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
|
|
||||||
weight_r = formula.clamp_weight_r(received, revenue)
|
|
||||||
weight = formula.payment_weight(weight_r)
|
|
||||||
base_amount = after_barter if barter_applied else performance
|
|
||||||
payable = to_decimal(Fraction(base_amount) * weight)
|
|
||||||
|
|
||||||
probation_note: str | None = None
|
|
||||||
if payload.probation:
|
|
||||||
annual = take("probation.annual.threshold")
|
|
||||||
probation_note = f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算"
|
|
||||||
|
|
||||||
return ParamPreviewOut(
|
return ParamPreviewOut(
|
||||||
payment_days=days,
|
payment_days=decision.days,
|
||||||
rate=str(rate),
|
rate=str(decision.rate),
|
||||||
rate_text=rate_text,
|
rate_text=decision.rate_text,
|
||||||
profit_rate=str(profit_rate),
|
profit_rate=str(earning.profit_rate),
|
||||||
converted_profit=str(converted),
|
converted_profit=str(earning.converted),
|
||||||
performance_amount=str(performance),
|
performance_amount=str(earning.performance),
|
||||||
barter_applied=barter_applied,
|
barter_applied=barter.applied,
|
||||||
performance_after_barter=str(after_barter) if after_barter is not None else None,
|
performance_after_barter=(
|
||||||
start_payment_ok=start_ok,
|
str(barter.after_barter) if barter.after_barter is not None else None
|
||||||
payment_weight_r=weight_r,
|
),
|
||||||
payment_weight=str(weight),
|
start_payment_ok=payment.start_ok,
|
||||||
payable_amount=str(payable),
|
payment_weight_r=payment.weight_r,
|
||||||
|
payment_weight=str(payment.weight),
|
||||||
|
payable_amount=str(payment.payable),
|
||||||
probation_note=probation_note,
|
probation_note=probation_note,
|
||||||
params_used=used,
|
params_used=used,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -70,20 +70,8 @@ def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personn
|
|||||||
|
|
||||||
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
|
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
|
||||||
"""分页查询人员(含离职)。"""
|
"""分页查询人员(含离职)。"""
|
||||||
rows = personnel_repository.list_personnel(
|
rows = personnel_repository.list_personnel(session, query)
|
||||||
session,
|
total = personnel_repository.count_personnel(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
keyword=query.keyword,
|
|
||||||
status=query.status,
|
|
||||||
business_role_id=query.business_role_id,
|
|
||||||
)
|
|
||||||
total = personnel_repository.count_personnel(
|
|
||||||
session,
|
|
||||||
keyword=query.keyword,
|
|
||||||
status=query.status,
|
|
||||||
business_role_id=query.business_role_id,
|
|
||||||
)
|
|
||||||
return PageResult[PersonnelOut](
|
return PageResult[PersonnelOut](
|
||||||
records=[
|
records=[
|
||||||
_to_out(personnel, role_name, manager_name)
|
_to_out(personnel, role_name, manager_name)
|
||||||
|
|||||||
@@ -99,20 +99,8 @@ def _raise_if_duplicate(exc: IntegrityError) -> None:
|
|||||||
|
|
||||||
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
|
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
|
||||||
"""分页查询产品(含停用)。"""
|
"""分页查询产品(含停用)。"""
|
||||||
products = product_repository.list_products(
|
products = product_repository.list_products(session, query)
|
||||||
session,
|
total = product_repository.count_products(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
keyword=query.keyword,
|
|
||||||
product_category=query.product_category,
|
|
||||||
status=query.status,
|
|
||||||
)
|
|
||||||
total = product_repository.count_products(
|
|
||||||
session,
|
|
||||||
keyword=query.keyword,
|
|
||||||
product_category=query.product_category,
|
|
||||||
status=query.status,
|
|
||||||
)
|
|
||||||
return PageResult[ProductOut](
|
return PageResult[ProductOut](
|
||||||
records=[_to_out(product) for product in products],
|
records=[_to_out(product) for product in products],
|
||||||
total=total,
|
total=total,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
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.db_errors import is_duplicate_entry
|
||||||
from app.core.exceptions import BadRequestError, NotFoundError
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
from app.core.response import PageResult
|
from app.core.response import PageResult
|
||||||
from app.models import Receipt
|
from app.models import Business, Receipt
|
||||||
from app.models.business import BUSINESS_STATUS_ACTIVE
|
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||||
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
|
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
|
||||||
from app.repositories import business_repository, receipt_repository
|
from app.repositories import business_repository, receipt_repository
|
||||||
@@ -129,24 +130,8 @@ def _ensure_business_writable(session: Session, business_id: int, action: str):
|
|||||||
|
|
||||||
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
|
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
|
||||||
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
|
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
|
||||||
rows = receipt_repository.list_receipts(
|
rows = receipt_repository.list_receipts(session, query)
|
||||||
session,
|
total = receipt_repository.count_receipts(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
business_id=query.business_id,
|
|
||||||
date_from=query.date_from,
|
|
||||||
date_to=query.date_to,
|
|
||||||
is_barter=query.is_barter,
|
|
||||||
settlement_status=query.settlement_status,
|
|
||||||
)
|
|
||||||
total = receipt_repository.count_receipts(
|
|
||||||
session,
|
|
||||||
business_id=query.business_id,
|
|
||||||
date_from=query.date_from,
|
|
||||||
date_to=query.date_to,
|
|
||||||
is_barter=query.is_barter,
|
|
||||||
settlement_status=query.settlement_status,
|
|
||||||
)
|
|
||||||
return PageResult[ReceiptOut](
|
return PageResult[ReceiptOut](
|
||||||
records=[_to_out(row) for row in rows],
|
records=[_to_out(row) for row in rows],
|
||||||
total=total,
|
total=total,
|
||||||
@@ -166,6 +151,57 @@ def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
|
|||||||
return _to_out(row)
|
return _to_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _CreatedReceipt:
|
||||||
|
"""建行结果(出参 + 固化的 ID/批次号,commit 后不再触碰 ORM 属性)。"""
|
||||||
|
|
||||||
|
out: ReceiptOut
|
||||||
|
receipt_id: int
|
||||||
|
batch_no: str
|
||||||
|
|
||||||
|
|
||||||
|
def _create_receipt_once(
|
||||||
|
session: Session,
|
||||||
|
payload: ReceiptCreateIn,
|
||||||
|
business: Business | None,
|
||||||
|
batch_no: str,
|
||||||
|
) -> _CreatedReceipt:
|
||||||
|
"""一次建行尝试:准入校验 → 建行 → 重算合计 → 出参。
|
||||||
|
|
||||||
|
**不 commit**:提交与回滚由调用方(重试循环)统一控制,保证「一次尝试
|
||||||
|
= 一个事务边界」。
|
||||||
|
"""
|
||||||
|
if business is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError("业务已完成或已终止,禁止新增回款")
|
||||||
|
# 月结锁定拦截(2026-10-15 补充需求):比需求原文「提示」取硬,
|
||||||
|
# 一期安全侧(策略点注释);未指定日期时以当天月近似
|
||||||
|
from app.services import settlement_service
|
||||||
|
|
||||||
|
_lock_month = (payload.receipt_date or date.today()).strftime("%Y-%m")
|
||||||
|
settlement_service.ensure_month_not_locked(session, _lock_month)
|
||||||
|
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||||
|
session, payload.shipment_id, payload.business_id
|
||||||
|
)
|
||||||
|
receipt = receipt_repository.create(
|
||||||
|
session,
|
||||||
|
Receipt(
|
||||||
|
receipt_batch_no=batch_no,
|
||||||
|
business_id=payload.business_id,
|
||||||
|
shipment_id=payload.shipment_id,
|
||||||
|
receipt_date=payload.receipt_date,
|
||||||
|
amount=Decimal(payload.amount),
|
||||||
|
is_barter=payload.is_barter,
|
||||||
|
settlement_status=SETTLEMENT_PENDING,
|
||||||
|
remark=payload.remark,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
||||||
|
return _CreatedReceipt(out=out, receipt_id=receipt.id, batch_no=batch_no)
|
||||||
|
|
||||||
|
|
||||||
def create_receipt(
|
def create_receipt(
|
||||||
session: Session,
|
session: Session,
|
||||||
payload: ReceiptCreateIn,
|
payload: ReceiptCreateIn,
|
||||||
@@ -206,35 +242,7 @@ def create_receipt(
|
|||||||
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
|
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
|
||||||
for attempt in range(_NO_RETRY + 1):
|
for attempt in range(_NO_RETRY + 1):
|
||||||
try:
|
try:
|
||||||
if business is None:
|
created = _create_receipt_once(session, payload, business, batch_no)
|
||||||
raise NotFoundError("业务不存在")
|
|
||||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
|
||||||
raise BadRequestError("业务已完成或已终止,禁止新增回款")
|
|
||||||
# 月结锁定拦截(2026-10-15 补充需求):比需求原文「提示」取硬,
|
|
||||||
# 一期安全侧(策略点注释);未指定日期时以当天月近似
|
|
||||||
from app.services import settlement_service
|
|
||||||
|
|
||||||
_lock_month = (payload.receipt_date or date.today()).strftime("%Y-%m")
|
|
||||||
settlement_service.ensure_month_not_locked(session, _lock_month)
|
|
||||||
shipment_no, ship_date = _ensure_shipment_of_business(
|
|
||||||
session, payload.shipment_id, payload.business_id
|
|
||||||
)
|
|
||||||
receipt = receipt_repository.create(
|
|
||||||
session,
|
|
||||||
Receipt(
|
|
||||||
receipt_batch_no=batch_no,
|
|
||||||
business_id=payload.business_id,
|
|
||||||
shipment_id=payload.shipment_id,
|
|
||||||
receipt_date=payload.receipt_date,
|
|
||||||
amount=Decimal(payload.amount),
|
|
||||||
is_barter=payload.is_barter,
|
|
||||||
settlement_status=SETTLEMENT_PENDING,
|
|
||||||
remark=payload.remark,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
_recalc_totals(session, business)
|
|
||||||
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
|
||||||
new_id, new_no = receipt.id, batch_no # 固化:commit 后不碰 ORM
|
|
||||||
session.commit()
|
session.commit()
|
||||||
break
|
break
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
@@ -249,11 +257,11 @@ def create_receipt(
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
|
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
|
||||||
new_id,
|
created.receipt_id,
|
||||||
new_no,
|
created.batch_no,
|
||||||
payload.business_id,
|
payload.business_id,
|
||||||
)
|
)
|
||||||
return out
|
return created.out
|
||||||
|
|
||||||
|
|
||||||
def update_receipt(
|
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`)口径不同:**管理列表含停用角色**,
|
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
|
||||||
下拉只给启用项。
|
下拉只给启用项。
|
||||||
"""
|
"""
|
||||||
roles = role_repository.list_roles(
|
roles = role_repository.list_roles(session, query)
|
||||||
session,
|
total = role_repository.count_roles(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
keyword=query.keyword,
|
|
||||||
status=query.status,
|
|
||||||
)
|
|
||||||
total = role_repository.count_roles(session, keyword=query.keyword, status=query.status)
|
|
||||||
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
|
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
|
||||||
return PageResult[RoleManageOut](
|
return PageResult[RoleManageOut](
|
||||||
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
|
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
|
||||||
|
|||||||
@@ -9,18 +9,17 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from calendar import monthrange
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
from fractions import Fraction
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.constants import (
|
from app.core.constants import (
|
||||||
MODULE_SETTLEMENT,
|
MODULE_SETTLEMENT,
|
||||||
)
|
)
|
||||||
|
from app.core.db_errors import is_duplicate_entry
|
||||||
from app.core.exceptions import BadRequestError, NotFoundError
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
from app.models import (
|
from app.models import (
|
||||||
MonthlySettlement,
|
MonthlySettlement,
|
||||||
@@ -45,7 +44,7 @@ from app.schemas.settlement import (
|
|||||||
SettlementOut,
|
SettlementOut,
|
||||||
SettlementQuery,
|
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.approval_service import AuditRecord, RequestMeta
|
||||||
from app.services.formula import manager_team_share, monthly_salary
|
from app.services.formula import manager_team_share, monthly_salary
|
||||||
|
|
||||||
@@ -251,19 +250,30 @@ def submit(
|
|||||||
),
|
),
|
||||||
meta=meta,
|
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:
|
if settlement is None:
|
||||||
raise NotFoundError("月结不存在")
|
raise NotFoundError("月结不存在")
|
||||||
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
|
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
|
||||||
raise BadRequestError("仅草稿或已驳回状态可提交")
|
raise BadRequestError("仅草稿或已驳回状态可提交")
|
||||||
|
|
||||||
|
try:
|
||||||
order = approval_service.submit_order(
|
order = approval_service.submit_order(
|
||||||
session,
|
session,
|
||||||
order_type=_SETTLEMENT_ORDER_TYPE,
|
order_type=_SETTLEMENT_ORDER_TYPE,
|
||||||
ref_id=settlement.id,
|
ref_id=settlement.id,
|
||||||
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
|
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
|
||||||
submitter=operator,
|
submitter=operator,
|
||||||
|
# 月结明细在生成时已固化;提交时快照让复核者看到的是送审版本,
|
||||||
|
# 而不是审批期间若发生重生成后的内容
|
||||||
|
snapshot=approval_service.settlement_content(session, settlement.id),
|
||||||
)
|
)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# version_no 唯一键的并发冲突(提交前已锁月结行,此项理论不可达,
|
||||||
|
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062 翻译成
|
||||||
|
# 可重试的业务提示,其余完整性错误原样上抛
|
||||||
|
if is_duplicate_entry(exc):
|
||||||
|
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
|
||||||
|
raise
|
||||||
settlement.approval_order_no = order.order_no
|
settlement.approval_order_no = order.order_no
|
||||||
if order.status == SETTLEMENT_STATUS_APPROVED:
|
if order.status == SETTLEMENT_STATUS_APPROVED:
|
||||||
mark_locked(session, settlement, operator=operator)
|
mark_locked(session, settlement, operator=operator)
|
||||||
@@ -352,82 +362,28 @@ def _confirm_scope(session: Session, month: str) -> list:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
def _manager_share_params(session: Session, month: str) -> manager_share.ManagerShareParams:
|
||||||
class _ShareParams:
|
"""薄委托 manager_share 共享模块:月结聚合与报表部门汇总同源取参。
|
||||||
"""经理分成取参与参数原文(同一次生成共用一份,随月月末时点化)。"""
|
|
||||||
|
|
||||||
month: str
|
迁移说明(2026-10-19):实现原样迁至 manager_share,行为零变化;
|
||||||
param_date: date
|
参数按百分数存储(12.5 表示 12.5%),调用方不得再乘 100。
|
||||||
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)与分成比例同日取参,答复后调整可追溯。
|
|
||||||
"""
|
"""
|
||||||
from app.services.param_service import parse_fraction, resolve_snapshot
|
return manager_share.manager_share_params(session, month)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_team_buckets(
|
def _collect_team_buckets(
|
||||||
session: Session, ledger_rows: list, include_manager_personal: bool
|
session: Session, ledger_rows: list, include_manager_personal: bool
|
||||||
) -> dict[int, dict]:
|
) -> dict[int, dict]:
|
||||||
"""按经理采集团队业绩。
|
"""薄委托 manager_share 共享模块:按经理采集团队业绩(行为零变化)。"""
|
||||||
|
return manager_share.collect_team_buckets(session, ledger_rows, include_manager_personal)
|
||||||
业务员行经 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 _aggregate_row(
|
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 与报表下钻的锚点)。"""
|
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
|
||||||
from app.models import PerformanceLedger
|
from app.models import PerformanceLedger
|
||||||
@@ -467,9 +423,10 @@ def _aggregate_row(
|
|||||||
"members": member_names,
|
"members": member_names,
|
||||||
"params_as_of": str(share.param_date),
|
"params_as_of": str(share.param_date),
|
||||||
"manager_personal_included": 1 if share.include_manager_personal else 0,
|
"manager_personal_included": 1 if share.include_manager_personal else 0,
|
||||||
|
# 说明写给业务人员看:说清这一笔的基数是怎么凑出来的
|
||||||
"note": (
|
"note": (
|
||||||
f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}"
|
f"团队分成:基数{'含' if share.include_manager_personal else '不含'}"
|
||||||
"经理个人户业绩;9.8 方案公式"
|
"经理本人名下业绩,按本团队与全公司两级比例合计计提"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -487,16 +444,8 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
|
|||||||
|
|
||||||
share = _manager_share_params(session, month)
|
share = _manager_share_params(session, month)
|
||||||
managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal)
|
managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal)
|
||||||
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);
|
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);共享口径单点
|
||||||
# 口径逐行固定,循环外算一次
|
company_base = manager_share.company_base_of(ledger_rows, share.include_manager_personal)
|
||||||
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"),
|
|
||||||
)
|
|
||||||
aggregates: list = []
|
aggregates: list = []
|
||||||
for manager_id, bucket in managers.items():
|
for manager_id, bucket in managers.items():
|
||||||
manager = session.get(Personnel, manager_id)
|
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:
|
def _month_end(month: str) -> date:
|
||||||
"""YYYY-MM → 所属月月末(月结取参锚点)。
|
"""薄委托 manager_share 共享模块:YYYY-MM → 所属月月末(取参锚点)。
|
||||||
|
|
||||||
月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today()
|
保留包装:tests/unit/test_settlement_helpers.py 直接 import 本名字;
|
||||||
会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」
|
「补生成历史月份结果不随操作日漂移」的原因说明见 manager_share.month_end。
|
||||||
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
|
|
||||||
更细的业务日期,取该月最后可确定的生效时点。
|
|
||||||
"""
|
"""
|
||||||
year, mon = int(month[:4]), int(month[5:7])
|
return manager_share.month_end(month)
|
||||||
return date(year, mon, monthrange(year, mon)[1])
|
|
||||||
|
|
||||||
|
|
||||||
def _salary_mode_of(session: Session, role_code: str, month: str) -> str | None:
|
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,
|
ref_id=order.id,
|
||||||
refer_no=order.order_no,
|
refer_no=order.order_no,
|
||||||
submitter=operator,
|
submitter=operator,
|
||||||
|
snapshot=approval_service.adjustment_content(session, order.id),
|
||||||
)
|
)
|
||||||
order.status = (
|
order.status = (
|
||||||
ADJUSTMENT_STATUS_EFFECTIVE
|
ADJUSTMENT_STATUS_EFFECTIVE
|
||||||
|
|||||||
@@ -157,20 +157,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
|||||||
|
|
||||||
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
|
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
|
||||||
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
|
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
|
||||||
rows = shipment_repository.list_shipments(
|
rows = shipment_repository.list_shipments(session, query)
|
||||||
session,
|
total = shipment_repository.count_shipments(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
business_id=query.business_id,
|
|
||||||
ship_date_from=query.ship_date_from,
|
|
||||||
ship_date_to=query.ship_date_to,
|
|
||||||
)
|
|
||||||
total = shipment_repository.count_shipments(
|
|
||||||
session,
|
|
||||||
business_id=query.business_id,
|
|
||||||
ship_date_from=query.ship_date_from,
|
|
||||||
ship_date_to=query.ship_date_to,
|
|
||||||
)
|
|
||||||
return PageResult[ShipmentOut](
|
return PageResult[ShipmentOut](
|
||||||
records=[_to_out(row) for row in rows],
|
records=[_to_out(row) for row in rows],
|
||||||
total=total,
|
total=total,
|
||||||
|
|||||||
@@ -68,22 +68,8 @@ def _to_out(user: User) -> UserOut:
|
|||||||
|
|
||||||
def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]:
|
def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]:
|
||||||
"""分页查询用户列表。"""
|
"""分页查询用户列表。"""
|
||||||
users = user_repository.list_users(
|
users = user_repository.list_users(session, query)
|
||||||
session,
|
total = user_repository.count_users(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
username=query.username,
|
|
||||||
real_name=query.real_name,
|
|
||||||
status=query.status,
|
|
||||||
role_code=query.role_code,
|
|
||||||
)
|
|
||||||
total = user_repository.count_users(
|
|
||||||
session,
|
|
||||||
username=query.username,
|
|
||||||
real_name=query.real_name,
|
|
||||||
status=query.status,
|
|
||||||
role_code=query.role_code,
|
|
||||||
)
|
|
||||||
return PageResult[UserOut](
|
return PageResult[UserOut](
|
||||||
records=[_to_out(user) for user in users],
|
records=[_to_out(user) for user in users],
|
||||||
total=total,
|
total=total,
|
||||||
|
|||||||
+79
-4
@@ -98,9 +98,9 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
|
| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
|
||||||
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 |
|
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 |
|
||||||
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕 | 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 完成)
|
### 业务数据管理(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)。
|
运行:`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` 本地重建。
|
||||||
|
|
||||||
## 三、运行记录
|
## 三、运行记录
|
||||||
|
|
||||||
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
||||||
@@ -155,11 +197,44 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 2026-10-16 | 全量回归 + 7 个 e2e | 697 | ✅ 697 passed(5:10);e2e 七脚本全过 | **M3 增量批次完成**(+72 例:settings 11 / 分配 schema 13+集成 9 / 审批 11 / registry 与 schema_sync 断言扩充等)。e2e:登录 8 + 系统管理 13 + 基础数据 12 + 业务 16 + 参数 7 步回归全过;**新增 settings_flow 4 步 / secondary_allocation_flow 6 步**(首跑即抓出 public 缓存与 logo 清理两处缺陷,见下表) |
|
| 2026-10-16 | 全量回归 + 7 个 e2e | 697 | ✅ 697 passed(5:10);e2e 七脚本全过 | **M3 增量批次完成**(+72 例:settings 11 / 分配 schema 13+集成 9 / 审批 11 / registry 与 schema_sync 断言扩充等)。e2e:登录 8 + 系统管理 13 + 基础数据 12 + 业务 16 + 参数 7 步回归全过;**新增 settings_flow 4 步 / secondary_allocation_flow 6 步**(首跑即抓出 public 缓存与 logo 清理两处缺陷,见下表) |
|
||||||
| 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 | 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 | 全量回归 | 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 | 部门汇总导出/打印只展平成员行 | 团队分成、全公司 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 | 日志脱敏在 `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 | `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")` |
|
| 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` |
|
||||||
@@ -213,7 +288,7 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
||||||
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
||||||
| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 |
|
| 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 完成并补齐用例,见上表。
|
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
||||||
@@ -190,14 +190,14 @@ def _purge_test_data() -> None:
|
|||||||
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
|
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
|
||||||
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
|
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
|
||||||
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
|
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
|
||||||
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 25
|
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 28
|
||||||
# (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另
|
# (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另
|
||||||
# 插入两个审批开关的 10-19 生效行 id 23/24,均属种子),删新条目与
|
# 插入两个审批开关的 10-19 生效行 id 23/24,V10 插入预警规则参数
|
||||||
# 新批次后解析回退到种子值
|
# id 26/27/28,均属种子),删新条目与新批次后解析回退到种子值
|
||||||
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 25)).scalars())
|
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 28)).scalars())
|
||||||
if stray_values:
|
if stray_values:
|
||||||
stray_batches = {v.batch_id for v in 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)))
|
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
|
||||||
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
|
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
|
||||||
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
|
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
|
||||||
|
|||||||
@@ -388,3 +388,336 @@ class TestModuleAccess:
|
|||||||
user = make_user([code])
|
user = make_user([code])
|
||||||
user["headers"] = auth_headers(user)
|
user["headers"] = auth_headers(user)
|
||||||
assert client.get(_APPROVALS, headers=user["headers"]).status_code == 200
|
assert client.get(_APPROVALS, headers=user["headers"]).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestContentSnapshot:
|
||||||
|
"""审批单内容快照与版本号(code-review 遗留②)。
|
||||||
|
|
||||||
|
守的是:复核者看到的是**送审时**的方案,而不是审批期间被改过的当前
|
||||||
|
方案;历史单(升级前无快照)必须显式标注「当前内容回退」,不能把
|
||||||
|
当前内容冒充送审内容。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_提交时快照固化_再改方案不回写旧单(
|
||||||
|
self, client: TestClient, admin: dict, finance: dict, switched_on
|
||||||
|
) -> None:
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
first = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
|
||||||
|
assert first["version_no"] == 1
|
||||||
|
assert first["content_source"] == "snapshot"
|
||||||
|
snapshot = first["snapshot"]
|
||||||
|
assert snapshot["schema_version"] == 1
|
||||||
|
assert snapshot["order_type"] == 1
|
||||||
|
assert snapshot["captured_at"], "快照须固化采集时间"
|
||||||
|
assert snapshot["data"]["business_no"] == data["business_no"]
|
||||||
|
assert snapshot["data"]["allocations"][0]["ratio"] == "50.00"
|
||||||
|
assert snapshot["data"]["allocation_total"] == "50.00"
|
||||||
|
assert snapshot["data"]["allocation_balance"] == "50.00"
|
||||||
|
|
||||||
|
# 编辑方案为 80%:旧在途单撤回,新单 V2 快照取新值;旧单快照不变
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||||
|
payload = business_payload(
|
||||||
|
detail["customer_id"],
|
||||||
|
secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}],
|
||||||
|
)
|
||||||
|
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
second = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
assert second["version_no"] == 2
|
||||||
|
assert second["snapshot"]["data"]["allocations"][0]["ratio"] == "80.00"
|
||||||
|
|
||||||
|
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
|
||||||
|
assert old["status"] == 5, "重新提交须撤回旧在途单"
|
||||||
|
assert old["snapshot"]["data"]["allocations"][0]["ratio"] == "50.00"
|
||||||
|
|
||||||
|
def test_历史单无快照_回退当前内容并标注(
|
||||||
|
self, client: TestClient, admin: dict, finance: dict, switched_on
|
||||||
|
) -> None:
|
||||||
|
"""升级前的审批单没有快照列:回退读当前内容,但来源必须标 live。"""
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import ApprovalOrder
|
||||||
|
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
row = session.get(ApprovalOrder, order["id"])
|
||||||
|
assert row is not None
|
||||||
|
row.snapshot_json = None
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert detail["content_source"] == "live"
|
||||||
|
assert detail["snapshot"]["captured_at"] is None
|
||||||
|
assert detail["snapshot"]["data"]["business_no"] == data["business_no"]
|
||||||
|
|
||||||
|
def test_引用对象缺失且无快照_内容缺失(
|
||||||
|
self, client: TestClient, admin: dict, finance: dict, switched_on
|
||||||
|
) -> None:
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import ApprovalOrder
|
||||||
|
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
row = session.get(ApprovalOrder, order["id"])
|
||||||
|
assert row is not None
|
||||||
|
row.snapshot_json = None
|
||||||
|
row.ref_id = 999_999_999
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert detail["content_source"] == "missing"
|
||||||
|
assert detail["snapshot"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_from_detail(detail: dict, **overrides: object) -> dict:
|
||||||
|
"""按业务详情原样重建提交载荷(明细行照抄,用于「内容未变」的场景)。"""
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"product_name": it["product_name"],
|
||||||
|
"product_category": it["product_category"],
|
||||||
|
"quantity": it["quantity"],
|
||||||
|
"sale_price": it["sale_price"],
|
||||||
|
}
|
||||||
|
for it in detail["items"]
|
||||||
|
]
|
||||||
|
body = business_payload(
|
||||||
|
detail["customer_id"],
|
||||||
|
items=items,
|
||||||
|
secondary_allocations=detail["secondary_allocations"],
|
||||||
|
)
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoRedundantResubmit:
|
||||||
|
"""送审内容未变不重提、方案未变不新增版本(版本与留痕降噪)。
|
||||||
|
|
||||||
|
守的是:反复保存同一份业务(只改备注)不应刷出一串方案版本与
|
||||||
|
「撤回+新建」的审批留痕;而内容真变了(金额或方案)必须重提,
|
||||||
|
否则审批人看到的是过期内容。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_方案与金额均未变_不新增版本也不重提(
|
||||||
|
self, client: TestClient, admin: dict, finance: dict, switched_on
|
||||||
|
) -> None:
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
first = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
assert first["status"] == 1
|
||||||
|
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||||
|
payload = _payload_from_detail(detail)
|
||||||
|
payload["remark"] = "只改了备注"
|
||||||
|
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
# 方案版本不增
|
||||||
|
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||||
|
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
|
||||||
|
(1, 1)
|
||||||
|
], "方案内容未变不应新增版本行"
|
||||||
|
|
||||||
|
# 审批单不重提:仍是原来那一张、仍待财务复核
|
||||||
|
rows = client.get(
|
||||||
|
_APPROVALS,
|
||||||
|
params={"keyword": data["business_no"], "pageSize": 10},
|
||||||
|
headers=finance["headers"],
|
||||||
|
).json()["data"]["list"]
|
||||||
|
assert len(rows) == 1, f"送审内容未变不应重提,实得 {len(rows)} 张单"
|
||||||
|
assert rows[0]["id"] == first["id"]
|
||||||
|
assert rows[0]["status"] == 1, "在途单不应被撤回"
|
||||||
|
assert rows[0]["version_no"] == 1
|
||||||
|
|
||||||
|
def test_只改金额不改方案_方案版本不变但审批单重提(
|
||||||
|
self, client: TestClient, admin: dict, finance: dict, switched_on
|
||||||
|
) -> None:
|
||||||
|
"""金额变了 → 送审内容变了 → 必须重提;但方案本身没变 → 不新增版本。"""
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
first = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||||
|
payload = _payload_from_detail(detail)
|
||||||
|
payload["items"][0]["sale_price"] = "200.00"
|
||||||
|
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||||
|
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
|
||||||
|
(1, 1)
|
||||||
|
], "方案未变不应新增版本行"
|
||||||
|
|
||||||
|
second = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
assert second["version_no"] == 2, "金额变了必须重提"
|
||||||
|
assert second["snapshot"]["data"]["sale_amount"] == "20000.00"
|
||||||
|
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
|
||||||
|
assert old["status"] == 5
|
||||||
|
|
||||||
|
def test_改方案比例_方案版本与审批单版本同时递增(
|
||||||
|
self, client: TestClient, admin: dict, finance: dict, switched_on
|
||||||
|
) -> None:
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
first = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||||
|
payload = _payload_from_detail(
|
||||||
|
detail, secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}]
|
||||||
|
)
|
||||||
|
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||||
|
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
|
||||||
|
(1, 0),
|
||||||
|
(2, 1),
|
||||||
|
], "方案变了应新增版本并把旧行置历史"
|
||||||
|
|
||||||
|
second = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
assert second["version_no"] == 2
|
||||||
|
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
|
||||||
|
assert old["status"] == 5
|
||||||
|
|
||||||
|
def test_无在途单时保存仍提新单(
|
||||||
|
self, client: TestClient, admin: dict, finance: dict, leader: dict, switched_on
|
||||||
|
) -> None:
|
||||||
|
"""保守规则:只有「存在在途单且内容一致」才不重提。
|
||||||
|
方案已审批通过后再保存,仍按原语义重新送审。"""
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=leader["headers"]
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||||
|
payload = _payload_from_detail(detail)
|
||||||
|
payload["remark"] = "已通过后再保存"
|
||||||
|
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
rows = client.get(
|
||||||
|
_APPROVALS,
|
||||||
|
params={"keyword": data["business_no"], "pageSize": 10},
|
||||||
|
headers=finance["headers"],
|
||||||
|
).json()["data"]["list"]
|
||||||
|
assert len(rows) == 2, "无在途单时保存应重新送审"
|
||||||
|
assert max(r["version_no"] for r in rows) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestPendingSnapshotMatch:
|
||||||
|
"""在途单送审内容一致性判定(approval_service.pending_matches_snapshot)。"""
|
||||||
|
|
||||||
|
def test_三种情形(self, client: TestClient, admin: dict, finance: dict, switched_on) -> None:
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.services import approval_service
|
||||||
|
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
snapshot = order["snapshot"]["data"]
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
# 1) 内容一致 → True
|
||||||
|
assert (
|
||||||
|
approval_service.pending_matches_snapshot(
|
||||||
|
session,
|
||||||
|
order_type=1,
|
||||||
|
ref_id=data["id"],
|
||||||
|
snapshot=snapshot,
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
# 2) 内容不同 → False
|
||||||
|
assert (
|
||||||
|
approval_service.pending_matches_snapshot(
|
||||||
|
session, order_type=1, ref_id=data["id"], snapshot={"allocations": []}
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
# 3) 无在途单 → False(引用对象换成别的)
|
||||||
|
assert (
|
||||||
|
approval_service.pending_matches_snapshot(
|
||||||
|
session, order_type=1, ref_id=999_999_999, snapshot=snapshot
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
# 4) 快照为 None → False
|
||||||
|
assert (
|
||||||
|
approval_service.pending_matches_snapshot(
|
||||||
|
session, order_type=1, ref_id=data["id"], snapshot=None
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllocationVersions:
|
||||||
|
"""二次分配方案版本化(code-review 遗留①)。
|
||||||
|
|
||||||
|
守的是:编辑替换与驳回清空只把旧行置历史(is_active=0),不物理删除,
|
||||||
|
方案参与人与比例可回溯;生效行始终唯一,核算取数不受历史行干扰。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_编辑方案产生新版本_旧版本保留(
|
||||||
|
self, client: TestClient, admin: dict, switched_on
|
||||||
|
) -> None:
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||||
|
assert [(h["version_no"], h["is_active"]) for h in detail["allocation_history"]] == [(1, 1)]
|
||||||
|
|
||||||
|
payload = business_payload(
|
||||||
|
detail["customer_id"],
|
||||||
|
secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}],
|
||||||
|
)
|
||||||
|
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||||
|
history = [
|
||||||
|
(h["version_no"], h["is_active"], h["ratio"]) for h in detail["allocation_history"]
|
||||||
|
]
|
||||||
|
assert history == [(1, 0, "50.00"), (2, 1, "80.00")]
|
||||||
|
assert [a["ratio"] for a in detail["secondary_allocations"]] == ["80.00"]
|
||||||
|
|
||||||
|
def test_驳回后生效行为空_历史行保留(
|
||||||
|
self, client: TestClient, admin: dict, finance: dict, switched_on
|
||||||
|
) -> None:
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action",
|
||||||
|
json={"action": 2, "opinion": "方案不合理"},
|
||||||
|
headers=finance["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||||
|
assert detail["secondary_allocations"] == [], "被驳回方案不得留在生效行"
|
||||||
|
assert detail["allocation_hint"] is None
|
||||||
|
history = detail["allocation_history"]
|
||||||
|
assert len(history) == 1
|
||||||
|
assert history[0]["version_no"] == 1
|
||||||
|
assert history[0]["is_active"] == 0
|
||||||
|
assert history[0]["ratio"] == "50.00"
|
||||||
@@ -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
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS
|
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS, MODULE_RECEIPT
|
||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import OperationLog
|
from app.models import OperationLog
|
||||||
from app.services import business_service
|
from app.services import business_service, receipt_service
|
||||||
|
from tests.integration.factories import create_business
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
_BUSINESSES = "/api/v1/business/businesses"
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
_CUSTOMERS = "/api/v1/base/customers"
|
_CUSTOMERS = "/api/v1/base/customers"
|
||||||
|
_RECEIPTS = "/api/v1/business/receipts"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -160,3 +163,94 @@ class TestAuditRetry:
|
|||||||
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
|
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
|
||||||
assert _log_count(normal["username"], success=False) == base_fail + 1
|
assert _log_count(normal["username"], success=False) == base_fail + 1
|
||||||
assert _log_count(normal["username"], success=True) == base_ok
|
assert _log_count(normal["username"], success=True) == base_ok
|
||||||
|
|
||||||
|
|
||||||
|
def _receipt_log_count(username: str, *, success: bool) -> int:
|
||||||
|
"""按操作人统计「新增回款」操作日志条数。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(OperationLog)
|
||||||
|
.where(
|
||||||
|
OperationLog.username == username,
|
||||||
|
OperationLog.module == MODULE_RECEIPT,
|
||||||
|
OperationLog.action == ACTION_CREATE,
|
||||||
|
OperationLog.summary.like("新增回款%"),
|
||||||
|
OperationLog.success == int(success),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def _receipt_payload(business_id: int) -> dict:
|
||||||
|
return {
|
||||||
|
"business_id": business_id,
|
||||||
|
"receipt_date": str(date.today()),
|
||||||
|
"amount": "1200.00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestReceiptAuditRetry:
|
||||||
|
"""回款新增的 1062 重试路径(回款与业务同构,但走独立实现)。
|
||||||
|
|
||||||
|
回款侧的重试循环本次从 create 函数体内移入 helper 调用,重试边界、
|
||||||
|
审计条数、批次号重取都必须保持原样 —— 用例会盯住这三条。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_首次撞号重试成功_审计恰一条真成功(
|
||||||
|
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
client = quiet_client
|
||||||
|
business = create_business(client, normal["headers"])
|
||||||
|
first = client.post(
|
||||||
|
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert first.status_code == 200, first.text
|
||||||
|
existing_no = first.json()["data"]["receipt_batch_no"]
|
||||||
|
|
||||||
|
real_generate = receipt_service._generate_no
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def flaky_generate(session):
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] == 1:
|
||||||
|
return existing_no # 首次返回库内已存在批次号 → flush 撞 1062
|
||||||
|
return real_generate(session)
|
||||||
|
|
||||||
|
monkeypatch.setattr(receipt_service, "_generate_no", flaky_generate)
|
||||||
|
base_ok = _receipt_log_count(normal["username"], success=True)
|
||||||
|
base_fail = _receipt_log_count(normal["username"], success=False)
|
||||||
|
|
||||||
|
second = client.post(
|
||||||
|
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert second.status_code == 200, second.text
|
||||||
|
assert calls["n"] == 2, "重试应恰好再取一次号"
|
||||||
|
assert _receipt_log_count(normal["username"], success=True) == base_ok + 1
|
||||||
|
assert _receipt_log_count(normal["username"], success=False) == base_fail
|
||||||
|
|
||||||
|
def test_持续冲突_最终失败且审计恰一条失败(
|
||||||
|
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
client = quiet_client
|
||||||
|
business = create_business(client, normal["headers"])
|
||||||
|
first = client.post(
|
||||||
|
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert first.status_code == 200, first.text
|
||||||
|
existing_no = first.json()["data"]["receipt_batch_no"]
|
||||||
|
|
||||||
|
def always_conflict(session):
|
||||||
|
return existing_no
|
||||||
|
|
||||||
|
monkeypatch.setattr(receipt_service, "_generate_no", always_conflict)
|
||||||
|
base_ok = _receipt_log_count(normal["username"], success=True)
|
||||||
|
base_fail = _receipt_log_count(normal["username"], success=False)
|
||||||
|
|
||||||
|
second = client.post(
|
||||||
|
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert second.status_code == 400, second.text
|
||||||
|
assert _receipt_log_count(normal["username"], success=False) == base_fail + 1
|
||||||
|
assert _receipt_log_count(normal["username"], success=True) == base_ok
|
||||||
@@ -147,9 +147,18 @@ class TestBuiltinProtection:
|
|||||||
|
|
||||||
|
|
||||||
class TestPermission:
|
class TestPermission:
|
||||||
"""权限:普通用户全部 403(当前默认管理员专用)。"""
|
"""权限:管理接口普通用户 403;只读选项登录即可(报表筛选依赖)。"""
|
||||||
|
|
||||||
def test_普通用户访问岗位列表_403(self, client: TestClient, make_user, auth_headers) -> None:
|
def test_普通用户访问岗位列表_403(self, client: TestClient, make_user, auth_headers) -> None:
|
||||||
user = make_user([ROLE_USER])
|
user = make_user([ROLE_USER])
|
||||||
headers = auth_headers(user)
|
headers = auth_headers(user)
|
||||||
assert client.get(_ROLES, headers=headers).status_code == 403
|
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
|
||||||
@@ -412,6 +412,69 @@ class TestEdit:
|
|||||||
assert len(detail["items"]) == 1
|
assert len(detail["items"]) == 1
|
||||||
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100")
|
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100")
|
||||||
|
|
||||||
|
def test_二次分配方案合计超100_保存400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""口径(澄清 #1/#2 答复 2026-10-19):分配绩效、余额归合同负责人——
|
||||||
|
超 100% 会把负责人份额算成负数,保存侧先拦(核算侧另有防御)。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
owner = _personnel(client, headers)
|
||||||
|
part = _personnel(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}",
|
||||||
|
json=_payload(
|
||||||
|
customer["id"],
|
||||||
|
secondary_allocations=[
|
||||||
|
{"personnel_id": part["id"], "ratio": "60"},
|
||||||
|
{"personnel_id": _personnel(client, headers)["id"], "ratio": "50"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "超过 100%" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_二次分配参与人含负责人_保存400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
owner = _personnel(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}",
|
||||||
|
json=_payload(
|
||||||
|
customer["id"],
|
||||||
|
secondary_allocations=[{"personnel_id": owner["id"], "ratio": "30"}],
|
||||||
|
),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "含合同负责营销人员" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
owner = _personnel(client, headers)
|
||||||
|
part = _personnel(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}",
|
||||||
|
json=_payload(
|
||||||
|
customer["id"],
|
||||||
|
secondary_allocations=[{"personnel_id": part["id"], "ratio": "40"}],
|
||||||
|
),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
# 方案在详情回包(更新回包为简版 Out,不含方案区)
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert len(detail["secondary_allocations"]) == 1
|
||||||
|
assert Decimal(detail["secondary_allocations"][0]["ratio"]) == Decimal("40")
|
||||||
|
# 余额 60% 归合同负责人(hint 文案 = #2 答复口径)
|
||||||
|
assert detail["allocation_hint"] is not None and "60" in detail["allocation_hint"]
|
||||||
|
|
||||||
|
|
||||||
class TestStatus:
|
class TestStatus:
|
||||||
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
|
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from fastapi.testclient import TestClient
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
from app.models import PerformanceLedger, Receipt
|
from app.models import PerformanceLedger, Personnel, Receipt, SecondaryAllocation
|
||||||
from app.models.receipt import SETTLEMENT_DONE
|
from app.models.receipt import SETTLEMENT_DONE
|
||||||
from tests.integration.factories import business_payload, customer
|
from tests.integration.factories import business_payload, customer
|
||||||
|
|
||||||
@@ -584,3 +584,207 @@ class TestBatch:
|
|||||||
assert by_id[r1["id"]]["ok"] is True and by_id[r1["id"]]["repeated"] is False
|
assert by_id[r1["id"]]["ok"] is True and by_id[r1["id"]]["repeated"] is False
|
||||||
assert by_id[r2["id"]]["ok"] is True and by_id[r2["id"]]["repeated"] is True
|
assert by_id[r2["id"]]["ok"] is True and by_id[r2["id"]]["repeated"] is True
|
||||||
assert by_id[999999]["ok"] is False
|
assert by_id[999999]["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSecondaryAllocation:
|
||||||
|
"""二次分摊拆行(澄清 #1 答复 2026-10-19:分配绩效——算后分)。
|
||||||
|
|
||||||
|
方案行直插入库(跳过保存侧校验,聚焦核算引擎;保存侧校验用例在
|
||||||
|
test_businesses)。金标准:双类别混合业务绩效 39.11 / 折算 4000.00,
|
||||||
|
拆 30%+40% 后参与人 11.73/15.64、负责人余额 11.74,行合计守恒。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _add_plan(self, business_id: int, rows: list[tuple[int, str]]) -> None:
|
||||||
|
"""直插方案行(测试隔离造数;核算引擎按「当前落库方案」消费)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
for pid, ratio in rows:
|
||||||
|
person = session.get(Personnel, pid)
|
||||||
|
session.add(
|
||||||
|
SecondaryAllocation(
|
||||||
|
business_id=business_id,
|
||||||
|
personnel_id=pid,
|
||||||
|
personnel_name=person.personnel_name,
|
||||||
|
ratio=Decimal(ratio),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def test_分摊拆行_参与人比例与负责人余额守恒(self, client: TestClient, admin: dict) -> None:
|
||||||
|
biz, receipt = _create_full_chain(
|
||||||
|
client,
|
||||||
|
admin["headers"],
|
||||||
|
items=_GOLD_ITEMS,
|
||||||
|
ship_date="2026-10-01",
|
||||||
|
receipt_date="2026-10-20",
|
||||||
|
amount="20000.00",
|
||||||
|
)
|
||||||
|
parts = [_person(client, admin["headers"]) for _ in range(2)]
|
||||||
|
self._add_plan(biz["id"], [(parts[0]["id"], "30"), (parts[1]["id"], "40")])
|
||||||
|
|
||||||
|
resp = _calc(client, admin["headers"], receipt["id"])
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
rows = (
|
||||||
|
session.execute(
|
||||||
|
select(PerformanceLedger).where(
|
||||||
|
PerformanceLedger.receipt_id == receipt["id"],
|
||||||
|
PerformanceLedger.is_active == 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert len(rows) == 3, "负责人+两参与人各一行"
|
||||||
|
by_pid = {r.personnel_id: r for r in rows}
|
||||||
|
a, b, o = (
|
||||||
|
by_pid[parts[0]["id"]],
|
||||||
|
by_pid[parts[1]["id"]],
|
||||||
|
by_pid[biz["owner_personnel_id"]],
|
||||||
|
)
|
||||||
|
# 绩效份额:A=39.11×30%=11.73、B=39.11×40%=15.64、负责人=守恒余额 11.74
|
||||||
|
assert (a.performance_amount, b.performance_amount, o.performance_amount) == (
|
||||||
|
Decimal("11.73"),
|
||||||
|
Decimal("15.64"),
|
||||||
|
Decimal("11.74"),
|
||||||
|
)
|
||||||
|
# 行合计 == 业务级全额(守恒是分摊红线:多一分少一分都是错账)
|
||||||
|
assert a.payable_amount + b.payable_amount + o.payable_amount == Decimal("39.11")
|
||||||
|
# 折算额同比例拆分:4000 的 30%/40%/余额
|
||||||
|
assert (a.converted_profit, b.converted_profit, o.converted_profit) == (
|
||||||
|
Decimal("1200.00"),
|
||||||
|
Decimal("1600.00"),
|
||||||
|
Decimal("1200.00"),
|
||||||
|
)
|
||||||
|
# 行级身份固化:参与人各自岗位;快照带方案与角色标签
|
||||||
|
# (ratio 为 NUMERIC(5,2) 读回两位 scale,断言用数值比较)
|
||||||
|
assert a.role_code == b.role_code == "salesperson"
|
||||||
|
assert a.snapshot_json["alloc_role"] == "participant"
|
||||||
|
assert Decimal(a.snapshot_json["alloc_ratio"]) == Decimal("30")
|
||||||
|
assert o.snapshot_json["alloc_role"] == "owner"
|
||||||
|
assert Decimal(o.snapshot_json["alloc_plan"][0]["ratio"]) == Decimal("30")
|
||||||
|
|
||||||
|
def test_分摊后第二笔回款仅登记进度(self, client: TestClient, admin: dict) -> None:
|
||||||
|
"""一次性计提(#25)与分摊叠加:第二笔不新增行、不重复拆分。"""
|
||||||
|
biz, first = _create_full_chain(
|
||||||
|
client,
|
||||||
|
admin["headers"],
|
||||||
|
items=_GOLD_ITEMS,
|
||||||
|
ship_date="2026-10-01",
|
||||||
|
receipt_date="2026-10-20",
|
||||||
|
amount="10000.00",
|
||||||
|
)
|
||||||
|
part = _person(client, admin["headers"])
|
||||||
|
self._add_plan(biz["id"], [(part["id"], "50")])
|
||||||
|
assert _calc(client, admin["headers"], first["id"]).status_code == 200
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
_RECEIPTS,
|
||||||
|
json={
|
||||||
|
"business_id": biz["id"],
|
||||||
|
"receipt_date": "2026-10-25",
|
||||||
|
"amount": "5000.00",
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
second = resp.json()["data"]
|
||||||
|
out = _calc(client, admin["headers"], second["id"]).json()["data"]
|
||||||
|
assert out["repeated"] is True
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
rows = (
|
||||||
|
session.execute(
|
||||||
|
select(PerformanceLedger).where(
|
||||||
|
PerformanceLedger.business_id == biz["id"],
|
||||||
|
PerformanceLedger.is_active == 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert len(rows) == 2, "第二笔回款不得新增拆分行(一次性计提)"
|
||||||
|
|
||||||
|
def test_重算作废全部拆分行并落新版本(
|
||||||
|
self, client: TestClient, admin: dict, finance: dict
|
||||||
|
) -> None:
|
||||||
|
biz, receipt = _create_full_chain(
|
||||||
|
client,
|
||||||
|
admin["headers"],
|
||||||
|
items=_GOLD_ITEMS,
|
||||||
|
ship_date="2026-10-01",
|
||||||
|
receipt_date="2026-10-20",
|
||||||
|
amount="20000.00",
|
||||||
|
)
|
||||||
|
parts = [_person(client, admin["headers"]) for _ in range(2)]
|
||||||
|
self._add_plan(biz["id"], [(parts[0]["id"], "30"), (parts[1]["id"], "40")])
|
||||||
|
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"{_CALC}/receipts/{receipt['id']}/recalc",
|
||||||
|
json={"reason": "用例:分摊重算"},
|
||||||
|
headers=finance["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
rows = (
|
||||||
|
session.execute(
|
||||||
|
select(PerformanceLedger).where(PerformanceLedger.receipt_id == receipt["id"])
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
active = [r for r in rows if r.is_active == 1]
|
||||||
|
inactive = [r for r in rows if r.is_active == 0]
|
||||||
|
assert len(active) == 3 and len(inactive) == 3
|
||||||
|
assert {r.calc_version for r in active} == {2}, "新版本三行"
|
||||||
|
assert {r.calc_version for r in inactive} == {1}, "旧版本三行全部作废"
|
||||||
|
|
||||||
|
def test_合计超100核算侧拦截(self, client: TestClient, admin: dict) -> None:
|
||||||
|
biz, receipt = _create_full_chain(
|
||||||
|
client,
|
||||||
|
admin["headers"],
|
||||||
|
items=_GOLD_ITEMS,
|
||||||
|
ship_date="2026-10-01",
|
||||||
|
receipt_date="2026-10-20",
|
||||||
|
amount="100.00",
|
||||||
|
)
|
||||||
|
part = _person(client, admin["headers"])
|
||||||
|
self._add_plan(biz["id"], [(part["id"], "60")])
|
||||||
|
# 再直插另一行绕过保存校验(合计 110),核算侧防御必须拦
|
||||||
|
other = _person(client, admin["headers"])
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
person = session.get(Personnel, other["id"])
|
||||||
|
session.add(
|
||||||
|
SecondaryAllocation(
|
||||||
|
business_id=biz["id"],
|
||||||
|
personnel_id=other["id"],
|
||||||
|
personnel_name=person.personnel_name,
|
||||||
|
ratio=Decimal("50"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resp = _calc(client, admin["headers"], receipt["id"])
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "超过 100%" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_参与人含负责人核算侧拦截(self, client: TestClient, admin: dict) -> None:
|
||||||
|
biz, receipt = _create_full_chain(
|
||||||
|
client,
|
||||||
|
admin["headers"],
|
||||||
|
items=_GOLD_ITEMS,
|
||||||
|
ship_date="2026-10-01",
|
||||||
|
receipt_date="2026-10-20",
|
||||||
|
amount="100.00",
|
||||||
|
)
|
||||||
|
self._add_plan(biz["id"], [(biz["owner_personnel_id"], "30")])
|
||||||
|
resp = _calc(client, admin["headers"], receipt["id"])
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "含合同负责营销人员" in resp.json()["message"]
|
||||||
@@ -376,3 +376,73 @@ class TestAudit:
|
|||||||
latest = logs[0]
|
latest = logs[0]
|
||||||
assert customer["company_name"] in latest.summary, "摘要应含客户名"
|
assert customer["company_name"] in latest.summary, "摘要应含客户名"
|
||||||
assert "→" 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:
|
class TestCatalog:
|
||||||
"""参数目录:种子完整性与时点化回溯。"""
|
"""参数目录:种子完整性与时点化回溯。"""
|
||||||
|
|
||||||
def test_目录共19项且覆盖八个分组(self, client: TestClient, admin: dict) -> None:
|
def test_目录共22项且覆盖九个分组(self, client: TestClient, admin: dict) -> None:
|
||||||
"""V7 起含调整单审批开关(16 计提项 + 3 BOOL 开关)。"""
|
"""V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。"""
|
||||||
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
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}
|
groups = {item["param_group"] for item in items}
|
||||||
assert groups == {
|
assert groups == {
|
||||||
"业务员个人户",
|
"业务员个人户",
|
||||||
@@ -100,6 +100,7 @@ class TestCatalog:
|
|||||||
"产品阈值",
|
"产品阈值",
|
||||||
"月工资口径",
|
"月工资口径",
|
||||||
"审批开关",
|
"审批开关",
|
||||||
|
"预警规则",
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
|
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
|
||||||
@@ -325,6 +326,38 @@ class TestPreview:
|
|||||||
# Fraction 自动约分:4/1000 存储为 1/250,按数值断言
|
# Fraction 自动约分:4/1000 存储为 1/250,按数值断言
|
||||||
assert Fraction(data["rate"]) == Fraction(4, 1000)
|
assert Fraction(data["rate"]) == Fraction(4, 1000)
|
||||||
|
|
||||||
|
def test_预览参数使用顺序稳定(self, client: TestClient, admin: dict) -> None:
|
||||||
|
"""params_used 的顺序即前端「用了哪些参数」的展示顺序。
|
||||||
|
|
||||||
|
预览实现拆成多段后,各段的调用先后决定参数读取先后;顺序漂移会让
|
||||||
|
复核者看到的参数清单与核算口径对不上(不报错,但会误导判读)。
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"business_role": "salesperson",
|
||||||
|
"customer_type": "personal",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"ship_date": "2025-12-15",
|
||||||
|
"receipt_date": "2026-01-10",
|
||||||
|
"revenue": "100000",
|
||||||
|
"profit": "45000",
|
||||||
|
"received_amount": "60000",
|
||||||
|
"barter": True,
|
||||||
|
"probation": True,
|
||||||
|
}
|
||||||
|
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert [p["param_code"] for p in data["params_used"]] == [
|
||||||
|
"salesperson.rate.start",
|
||||||
|
"salesperson.rate.slope",
|
||||||
|
"salesperson.rate.floor",
|
||||||
|
"salesperson.days.boundary",
|
||||||
|
"product.threshold",
|
||||||
|
"barter.discount",
|
||||||
|
"payment.start.ratio",
|
||||||
|
"probation.annual.threshold",
|
||||||
|
]
|
||||||
|
|
||||||
def test_经理聚合口径_预览拒绝(self, client: TestClient, admin: dict) -> None:
|
def test_经理聚合口径_预览拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"business_role": "sales_manager",
|
"business_role": "sales_manager",
|
||||||
@@ -338,7 +371,11 @@ class TestPreview:
|
|||||||
}
|
}
|
||||||
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
|
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert "聚合" in resp.json()["message"]
|
# 守两件事:① 聚合口径不允许单笔试算(拒绝而不是算出个错数);
|
||||||
|
# ② 提示要能指导下一步(点名是谁的口径 + 去哪儿看),不是一句「不支持」。
|
||||||
|
message = resp.json()["message"]
|
||||||
|
assert "营销经理" in message
|
||||||
|
assert "月度结算" in message
|
||||||
|
|
||||||
def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None:
|
def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
@@ -444,7 +481,7 @@ class TestPermission:
|
|||||||
headers = auth_headers(user)
|
headers = auth_headers(user)
|
||||||
resp = client.get(_PARAMS, headers=headers)
|
resp = client.get(_PARAMS, headers=headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert len(resp.json()["data"]) == 19
|
assert len(resp.json()["data"]) == 22
|
||||||
|
|
||||||
def test_未登录访问目录_403(self, client: TestClient) -> None:
|
def test_未登录访问目录_403(self, client: TestClient) -> None:
|
||||||
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
|
# 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:
|
class TestDeleteProtection:
|
||||||
"""RESTRICT 外键:被方案引用的人员禁删,方案随业务删除而清。"""
|
"""RESTRICT 外键:被方案引用的人员禁删,方案随业务删除而清。"""
|
||||||
|
|
||||||
|
def test_同一会话内旧方案行同步为历史态(self, client: TestClient, admin: dict) -> None:
|
||||||
|
"""守「置历史后同一会话内的旧行必须同步刷新」这一行为:Core UPDATE
|
||||||
|
走的是 SQLAlchemy 的会话同步策略,条件复杂化时可能退化为不刷新,
|
||||||
|
届时行对象会停留在 is_active=1 的过期内存值(库中实际已置历史),
|
||||||
|
被序列化或参与判断就会呈现「生效态」的假象。"""
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import SecondaryAllocation
|
||||||
|
from app.repositories import business_repository
|
||||||
|
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_allocations(
|
||||||
|
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
loaded = business_repository.list_allocations(session, data["id"])
|
||||||
|
assert loaded and all(a.is_active == 1 for a in loaded)
|
||||||
|
business_repository.deactivate_allocations(session, data["id"])
|
||||||
|
# 同一 session 内的行对象必须已是历史态,不能停留在过期内存值
|
||||||
|
assert all(a.is_active == 0 for a in loaded), "旧行未随置历史同步刷新"
|
||||||
|
# 库中同样已置历史
|
||||||
|
rows = (
|
||||||
|
session.query(SecondaryAllocation)
|
||||||
|
.filter(SecondaryAllocation.business_id == data["id"])
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert rows and all(r.is_active == 0 for r in rows)
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
def test_被方案引用的人员禁删(self, client: TestClient, admin: dict) -> None:
|
def test_被方案引用的人员禁删(self, client: TestClient, admin: dict) -> None:
|
||||||
p = _person(client, admin["headers"])
|
p = _person(client, admin["headers"])
|
||||||
data = _create_with_allocations(
|
data = _create_with_allocations(
|
||||||
|
|||||||
@@ -229,6 +229,19 @@ class TestSubmitAndLock:
|
|||||||
assert out["approval_order_no"] and out["approval_order_no"].startswith("AP")
|
assert out["approval_order_no"] and out["approval_order_no"].startswith("AP")
|
||||||
assert out["locked_at"] is not None
|
assert out["locked_at"] is not None
|
||||||
|
|
||||||
|
# 自动通过路径同样固化送审快照(月结头 + 按人明细)
|
||||||
|
order = client.get(
|
||||||
|
"/api/v1/system/approvals",
|
||||||
|
params={"keyword": out["approval_order_no"], "pageSize": 1},
|
||||||
|
headers=admin["headers"],
|
||||||
|
).json()["data"]["list"][0]
|
||||||
|
detail = client.get(
|
||||||
|
f"/api/v1/system/approvals/{order['id']}", headers=admin["headers"]
|
||||||
|
).json()["data"]
|
||||||
|
assert detail["content_source"] == "snapshot"
|
||||||
|
assert detail["snapshot"]["data"]["settlement_month"] == "2026-10"
|
||||||
|
assert detail["snapshot"]["data"]["items"], "月结快照须含按人明细"
|
||||||
|
|
||||||
def test_锁定后回款禁改(self, client: TestClient, admin: dict) -> None:
|
def test_锁定后回款禁改(self, client: TestClient, admin: dict) -> None:
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -603,6 +616,21 @@ class TestAdjustment:
|
|||||||
out = resp.json()["data"]
|
out = resp.json()["data"]
|
||||||
assert out["status"] == 2 # 已生效
|
assert out["status"] == 2 # 已生效
|
||||||
|
|
||||||
|
# 调整单送审快照:复核者能看到金额/方向/说明,而非只有调整单号
|
||||||
|
order = client.get(
|
||||||
|
"/api/v1/system/approvals",
|
||||||
|
params={"keyword": out["order_no"], "pageSize": 1},
|
||||||
|
headers=admin["headers"],
|
||||||
|
).json()["data"]["list"][0]
|
||||||
|
assert order["order_type"] == 3
|
||||||
|
detail = client.get(
|
||||||
|
f"/api/v1/system/approvals/{order['id']}", headers=admin["headers"]
|
||||||
|
).json()["data"]
|
||||||
|
assert detail["content_source"] == "snapshot"
|
||||||
|
assert detail["snapshot"]["data"]["amount"] == "500.00"
|
||||||
|
assert detail["snapshot"]["data"]["direction"] == 1
|
||||||
|
assert detail["snapshot"]["data"]["reason"] == "补发差额"
|
||||||
|
|
||||||
def test_金额恒正校验(self, client: TestClient, admin: dict) -> None:
|
def test_金额恒正校验(self, client: TestClient, admin: dict) -> None:
|
||||||
"""守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。"""
|
"""守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。"""
|
||||||
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
|
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
|
||||||
@@ -749,3 +777,24 @@ class TestLockInterception:
|
|||||||
resp = client.post(f"{_CALC}/receipts/{unsettled['id']}/calc", headers=admin["headers"])
|
resp = client.post(f"{_CALC}/receipts/{unsettled['id']}/calc", headers=admin["headers"])
|
||||||
assert resp.status_code == 400, resp.text
|
assert resp.status_code == 400, resp.text
|
||||||
assert "锁定" in resp.json()["message"]
|
assert "锁定" in resp.json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_非法月份_月结生成与调整单均返回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:
|
for fk in table.foreign_keys:
|
||||||
assert fk.constraint.name.startswith(f"fk_{table.name}_"), fk.constraint.name
|
assert fk.constraint.name.startswith(f"fk_{table.name}_"), fk.constraint.name
|
||||||
|
|
||||||
|
def test_审批单版本号唯一键覆盖三列(self) -> None:
|
||||||
|
"""并发提单算重版本号时由 DB 兜底(应用层取号走锁读,见
|
||||||
|
approval_service._next_version);约束名必须与迁移 DDL 严格一致,
|
||||||
|
否则 alembic autogenerate 会误判成「约束缺失/多余」。"""
|
||||||
|
table = Base.metadata.tables["approval_orders"]
|
||||||
|
uks = [c for c in table.constraints if c.__class__.__name__ == "UniqueConstraint"]
|
||||||
|
hit = [
|
||||||
|
c
|
||||||
|
for c in uks
|
||||||
|
if {col.name for col in c.columns} == {"order_type", "ref_id", "version_no"}
|
||||||
|
]
|
||||||
|
assert hit, "审批单缺 (order_type, ref_id, version_no) 唯一键"
|
||||||
|
assert hit[0].name == "uk_approval_orders_order_type_ref_version"
|
||||||
|
|
||||||
|
|
||||||
class TestComments:
|
class TestComments:
|
||||||
"""规范红线要求中文 COMMENT。"""
|
"""规范红线要求中文 COMMENT。"""
|
||||||
|
|||||||
@@ -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,
|
validate_and_normalize,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 与 db/migration 迁移种子保持一致的 19 项编码(V3 十六项 + V6 审批开关两项
|
# 与 db/migration 迁移种子保持一致的 22 项编码(V3 十六项 + V6 审批开关两项
|
||||||
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12)
|
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12;
|
||||||
|
# V10 增工作台预警规则三项 #10)
|
||||||
EXPECTED_CODES = {
|
EXPECTED_CODES = {
|
||||||
"salesperson.rate.start",
|
"salesperson.rate.start",
|
||||||
"salesperson.rate.slope",
|
"salesperson.rate.slope",
|
||||||
@@ -40,14 +41,17 @@ EXPECTED_CODES = {
|
|||||||
"approval.monthly_settlement.enabled",
|
"approval.monthly_settlement.enabled",
|
||||||
"approval.adjustment.enabled",
|
"approval.adjustment.enabled",
|
||||||
"manager.personal.included",
|
"manager.personal.included",
|
||||||
|
"warning.enabled",
|
||||||
|
"warning.receipt.overdue.days",
|
||||||
|
"warning.approval.timeout.days",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestRegistry:
|
class TestRegistry:
|
||||||
"""注册表元数据完整性(与迁移种子同源)。"""
|
"""注册表元数据完整性(与迁移种子同源)。"""
|
||||||
|
|
||||||
def test_注册表共19项(self) -> None:
|
def test_注册表共22项(self) -> None:
|
||||||
assert len(PARAM_REGISTRY) == 19
|
assert len(PARAM_REGISTRY) == 22
|
||||||
|
|
||||||
def test_编码集合与迁移种子一致(self) -> None:
|
def test_编码集合与迁移种子一致(self) -> None:
|
||||||
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
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',
|
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
|
||||||
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
|
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
|
||||||
remark VARCHAR(255) COMMENT '备注',
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
rating_score TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)',
|
||||||
|
rating_time DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)',
|
||||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
@@ -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);
|
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决与 2026-10-19 澄清答复)', 'system', 19);
|
||||||
|
|
||||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES
|
||||||
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1),
|
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。', 1),
|
||||||
(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2),
|
(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, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3),
|
(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。', 3),
|
||||||
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4),
|
(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),
|
(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。', 5),
|
||||||
(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6),
|
(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。', 6),
|
||||||
(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7),
|
(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。', 7),
|
||||||
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8),
|
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。', 8),
|
||||||
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9),
|
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款到账即按这个比例计提绩效,不必等到全额回款。', 9),
|
||||||
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10),
|
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。', 10),
|
||||||
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11),
|
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。', 11),
|
||||||
(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12),
|
(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),
|
(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),
|
(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),
|
(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),
|
(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, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18),
|
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。', 18),
|
||||||
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,默认值业务未明确,暂按直接生效,业务可开启)', 19),
|
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。', 19),
|
||||||
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20);
|
(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
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
|
||||||
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
||||||
@@ -481,7 +487,11 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t
|
|||||||
(20, 17, '', '2026-01-01', '1', 1),
|
(20, 17, '', '2026-01-01', '1', 1),
|
||||||
(21, 18, '', '2026-01-01', '1', 1),
|
(21, 18, '', '2026-01-01', '1', 1),
|
||||||
(22, 19, '', '2026-01-01', '0', 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 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
|
-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
|
||||||
@@ -705,6 +715,8 @@ CREATE TABLE approval_orders (
|
|||||||
order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算(预留)',
|
order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算(预留)',
|
||||||
ref_id BIGINT UNSIGNED NOT NULL COMMENT '引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)',
|
ref_id BIGINT UNSIGNED NOT NULL COMMENT '引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)',
|
||||||
refer_no VARCHAR(32) NOT NULL COMMENT '引用对象单号(冗余固化,列表页免联查)',
|
refer_no VARCHAR(32) NOT NULL COMMENT '引用对象单号(冗余固化,列表页免联查)',
|
||||||
|
version_no INT NOT NULL COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' DEFAULT '1',
|
||||||
|
snapshot_json JSON COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)',
|
||||||
status TINYINT NOT NULL COMMENT '审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回' DEFAULT '1',
|
status TINYINT NOT NULL COMMENT '审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回' DEFAULT '1',
|
||||||
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
|
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
|
||||||
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
|
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
|
||||||
@@ -712,11 +724,14 @@ CREATE TABLE approval_orders (
|
|||||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
CONSTRAINT pk_approval_orders PRIMARY KEY (id),
|
CONSTRAINT pk_approval_orders PRIMARY KEY (id),
|
||||||
CONSTRAINT uk_approval_orders_order_no UNIQUE (order_no),
|
CONSTRAINT uk_approval_orders_order_no UNIQUE (order_no),
|
||||||
|
CONSTRAINT uk_approval_orders_order_type_ref_version UNIQUE (order_type, ref_id, version_no),
|
||||||
CONSTRAINT fk_approval_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL
|
CONSTRAINT fk_approval_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL
|
||||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)' COLLATE utf8mb4_general_ci;
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
CREATE INDEX idx_approval_orders_type_status ON approval_orders (order_type, status);
|
CREATE INDEX idx_approval_orders_type_status ON approval_orders (order_type, status);
|
||||||
|
|
||||||
|
CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id);
|
||||||
|
|
||||||
CREATE TABLE approval_steps (
|
CREATE TABLE approval_steps (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)',
|
order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)',
|
||||||
@@ -736,21 +751,25 @@ CREATE INDEX idx_approval_steps_order_id ON approval_steps (order_id);
|
|||||||
CREATE TABLE business_secondary_allocations (
|
CREATE TABLE business_secondary_allocations (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;业务删除随删)',
|
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;业务删除随删)',
|
||||||
|
version_no INT NOT NULL COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' DEFAULT '1',
|
||||||
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '参与业务员ID(personnel.id;被方案引用禁删)',
|
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '参与业务员ID(personnel.id;被方案引用禁删)',
|
||||||
personnel_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)',
|
personnel_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)',
|
||||||
ratio DECIMAL(5, 2) NOT NULL COMMENT '分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)',
|
ratio DECIMAL(5, 2) NOT NULL COMMENT '分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)',
|
||||||
|
is_active TINYINT NOT NULL COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' DEFAULT '1',
|
||||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
CONSTRAINT pk_business_secondary_allocations PRIMARY KEY (id),
|
CONSTRAINT pk_business_secondary_allocations PRIMARY KEY (id),
|
||||||
CONSTRAINT uk_business_secondary_allocations_business_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_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE,
|
||||||
CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' 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_business_id ON business_secondary_allocations (business_id);
|
||||||
|
|
||||||
CREATE INDEX idx_business_secondary_allocations_personnel_id ON business_secondary_allocations (personnel_id);
|
CREATE INDEX idx_business_secondary_allocations_personnel_id ON business_secondary_allocations (personnel_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_business_secondary_allocations_business_active ON business_secondary_allocations (business_id, is_active);
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- 绩效核算台账/月结/调整单(V7 迁移;append-only 版本行;通过即锁定走调整单)
|
-- 绩效核算台账/月结/调整单(V7 迁移;append-only 版本行;通过即锁定走调整单)
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
@@ -874,4 +893,4 @@ CREATE TABLE alembic_version (
|
|||||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
INSERT INTO alembic_version (version_num) VALUES ('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.
+925
-96
File diff suppressed because it is too large.
Load diff
@@ -45,12 +45,12 @@
|
|||||||
② 基础数据管理 ✅ ← 人员引用 ① 岗位角色(外键上游)
|
② 基础数据管理 ✅ ← 人员引用 ① 岗位角色(外键上游)
|
||||||
③ 业务数据管理 ✅ ← 引用 ② 人员/产品/客户
|
③ 业务数据管理 ✅ ← 引用 ② 人员/产品/客户
|
||||||
│ 增量(2026-10-15):二次分配方案(合同刚建立时录入)
|
│ 增量(2026-10-15):二次分配方案(合同刚建立时录入)
|
||||||
④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
|
④ 绩效工资核算 ✅ ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
|
||||||
│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(语义待澄清)
|
│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(#1–#3 答复空白,开发侧暂定口径已实现,见 3.3.1)
|
||||||
⑦ 复核审批流(新) ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
|
⑦ 复核审批流(新)✅ ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
|
||||||
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约
|
⑤ 报表统计 ✅(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约(待 IT 18 项答复)
|
||||||
⑧ 工作台(新) ← 消费 ④⑤⑥⑦ 数据的聚合展示层
|
⑧ 工作台(新)✅ ← 消费 ④⑤⑥⑦ 数据的聚合展示层
|
||||||
⑨ 备份恢复与运维检查(新) ← 独立于业务块,可与任一批次并行,建议尽早
|
⑨ 备份恢复与运维检查(新)✅ ← 独立于业务块,可与任一批次并行,建议尽早
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.2 顺序论证(要点)
|
### 1.2 顺序论证(要点)
|
||||||
@@ -68,12 +68,12 @@
|
|||||||
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览。**✅ 已完成**。增量(2026-10-15):公司信息配置(公司名称、logo 等基本信息);审批开关配置走参数体系;新增参数一律进参数注册表(用户重申:参数配置尽量灵活) | (原验收已达成)增量:公司信息可配置并用于登录页与导出页眉;审批开关参数化,关闭直接生效、开启走审批 |
|
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览。**✅ 已完成**。增量(2026-10-15):公司信息配置(公司名称、logo 等基本信息);审批开关配置走参数体系;新增参数一律进参数注册表(用户重申:参数配置尽量灵活) | (原验收已达成)增量:公司信息可配置并用于登录页与导出页眉;审批开关参数化,关闭直接生效、开启走审批 |
|
||||||
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更。**✅ 已完成** | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
|
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更。**✅ 已完成** | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
|
||||||
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器。**✅ 已完成**。增量(2026-10-15):二次分配方案 —— 只出现在合同刚建立时;合同建立时可手动选择参与二次分配的业务员;分配比例不要求合计 100%;审批开关开启时走审批 | (原验收已达成)增量:合同建立时可录入参与业务员与比例(不强制合计 100%);开关关闭直接生效;方案修改不影响已结算月份 |
|
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器。**✅ 已完成**。增量(2026-10-15):二次分配方案 —— 只出现在合同刚建立时;合同建立时可手动选择参与二次分配的业务员;分配比例不要求合计 100%;审批开关开启时走审批 | (原验收已达成)增量:合同建立时可录入参与业务员与比例(不强制合计 100%);开关关闭直接生效;方案修改不影响已结算月份 |
|
||||||
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(语义待澄清,见 3.3) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
|
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(#1–#3 答复空白,开发侧暂定口径已实现,见 3.3.1) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
|
||||||
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
|
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计。**✅ 已完成(2026-10-19)** | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
|
||||||
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
|
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发。**⏳ 阻塞(IT 18 项未答复)** | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
|
||||||
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions) | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
|
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions)。**✅ 已完成(2026-10-19;权限细目待业务清单)** | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
|
||||||
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态 | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
|
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态。**✅ 已完成(2026-10-19)** | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
|
||||||
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本 | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
|
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本。**✅ 已完成(2026-10-16)** | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
|
||||||
|
|
||||||
### 1.4 里程碑(交付批次)
|
### 1.4 里程碑(交付批次)
|
||||||
|
|
||||||
@@ -81,9 +81,9 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **M1** | ① 系统设置与参数体系 | ✅ 已完成(2026-10-13 推送 feat/system-params) |
|
| **M1** | ① 系统设置与参数体系 | ✅ 已完成(2026-10-13 推送 feat/system-params) |
|
||||||
| **M2** | ② + ③ | ✅ 已完成(2026-10-13 分支 feat/base-business-data) |
|
| **M2** | ② + ③ | ✅ 已完成(2026-10-13 分支 feat/base-business-data) |
|
||||||
| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流 | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」;开工前必须澄清:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7);内部建议顺序 ①增量 → ③增量 → ⑦ 审批框架 → ④ 核算 → 月结与审批串联 |
|
| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流。**✅ 已完成(2026-10-19,feat/m3-increments)** | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」已交付;余 3.3-1 答复与审批权限细目。开工前澄清项:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7,已参数化) |
|
||||||
| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台 | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
|
| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台。**✅ ⑤⑧ 已完成(2026-10-19);⑥ 待 IT 18 项答复** | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
|
||||||
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付) | 数据持续积累,备份先行;不依赖业务模块进度 |
|
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付)。**✅ 已完成(2026-10-16)** | 数据持续积累,备份先行;不依赖业务模块进度 |
|
||||||
|
|
||||||
### 1.5 需求依据对照
|
### 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.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(四) | — |
|
| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R);增量见 3.1(四) | — |
|
||||||
| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — |
|
| ⑥ | 需规各「同步 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(四) 字段为准,导出前与业务对一次列 |
|
| 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 |
|
||||||
| 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) |
|
| 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 功能清单核查补充(新增需求)
|
||||||
|
|
||||||
> **来源与地位:** 用户以现有功能清单核查后补充的需求(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 需求原文(照录)
|
### 3.1 需求原文(照录)
|
||||||
|
|
||||||
@@ -656,6 +658,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
|||||||
#### (六)系统设置
|
#### (六)系统设置
|
||||||
|
|
||||||
- 公司信息配置:公司名称、logo 等基本信息
|
- 公司信息配置:公司名称、logo 等基本信息
|
||||||
|
- **2026-10-20 回填**:参数注册表已扩至 22 项(V8/V10 增补审批开关、试用期迁移、预警阈值等)
|
||||||
|
|
||||||
#### (七)参数配置强调
|
#### (七)参数配置强调
|
||||||
|
|
||||||
@@ -666,7 +669,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
|||||||
#### (一) 二次分配方案 → ③ 录入 + ④ 分摊消费 + ⑦ 审批联动 + ④ 调整单衔接
|
#### (一) 二次分配方案 → ③ 录入 + ④ 分摊消费 + ⑦ 审批联动 + ④ 调整单衔接
|
||||||
|
|
||||||
- 「只出现在合同刚建立时」→ 录入入口放业务创建流程(与 `businesses` 创建同事务或紧随其后);合同建立后是否允许补录/修改未明说,方案修改的留痕方式待设计;
|
- 「只出现在合同刚建立时」→ 录入入口放业务创建流程(与 `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 已有该状态可复用) |
|
| 待办事项 | ⑦ 待审批单、⑥ 同步失败、回款未指定批次(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 起闲置待权限清单,此处激活)——打破一期「营销人员不登录」的前提,但与该原则不矛盾(营销人员仍不登录,后勤/财务/领导是职能角色)。
|
- **登录角色体系扩展**:营销后勤/财务/领导是系统新登录角色(`roles` 表基础设施已有;`permissions`/`role_permissions` 自 M1 起闲置待权限清单,此处激活)——打破一期「营销人员不登录」的前提,但与该原则不矛盾(营销人员仍不登录,后勤/财务/领导是职能角色)。
|
||||||
|
|
||||||
|
> **2026-10-20 回填**:审批框架与月结审批对象已交付(2026-10-19);权限点清单仍待业务方给出(澄清 #6,催办 B-6),当前按 role_code 粗粒度运行,营销后勤暂不可见审批列表。
|
||||||
|
|
||||||
#### (四) 月结、审批、报表 → ④ 月结子块 + ⑦ 审批 + ⑤ 导出模板
|
#### (四) 月结、审批、报表 → ④ 月结子块 + ⑦ 审批 + ⑤ 导出模板
|
||||||
|
|
||||||
- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」定义待澄清(3.3-3);
|
- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」已答复为人工确认、可手动关闭(3.3-3),月结按已确认回款取数;
|
||||||
- 「支持负数工资」与既有裁决「负绩效按公式正常计算(不归零)」一致,无需改公式口径;
|
- 「支持负数工资」与既有裁决「负绩效按公式正常计算(不归零)」一致,无需改公式口径;
|
||||||
- 调整单:新单据类型(补发/扣减方向、关联月份、扣补说明、审批状态);
|
- 调整单:新单据类型(补发/扣减方向、关联月份、扣补说明、审批状态);
|
||||||
- 「结算后锁定」:已锁月份的快照金额不可变;源数据修改只产生提示、历史记录和后续调整 —— 与 #19「归属变更不回溯历史」同一哲学,扩展到全部源数据;
|
- 「结算后锁定」:已锁月份的快照金额不可变;源数据修改只产生提示、历史记录和后续调整 —— 与 #19「归属变更不回溯历史」同一哲学,扩展到全部源数据;
|
||||||
@@ -708,6 +713,8 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
|||||||
|
|
||||||
### 3.3 待澄清问题(不阻塞收录,对应模块开工前须澄清)
|
### 3.3 待澄清问题(不阻塞收录,对应模块开工前须澄清)
|
||||||
|
|
||||||
|
> **回填状态(2026-10-20):** 下表保留 2026-10-15 原始问题与建议默认(历史快照);业务答复与落实情况见 3.3.1。3.3-1 仍无业务答复,开发侧已按暂定口径实现分摊拆行,答复后如有变更按口径调整。
|
||||||
|
|
||||||
| # | 事项 | 影响模块 | 建议默认处理 |
|
| # | 事项 | 影响模块 | 建议默认处理 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 1 | 二次分配的**分配标的**(绩效金额?折算毛利润?业绩额?)与**余额归属**(合计 ≠100% 时差额归谁);与已作废的「协助 25%」(#15)是何关系 | ④ 分摊逻辑与台账归属字段 | 未确认前 ④ 只落录入与存储,不实现分摊 |
|
| 1 | 二次分配的**分配标的**(绩效金额?折算毛利润?业绩额?)与**余额归属**(合计 ≠100% 时差额归谁);与已作废的「协助 25%」(#15)是何关系 | ④ 分摊逻辑与台账归属字段 | 未确认前 ④ 只落录入与存储,不实现分摊 |
|
||||||
@@ -718,13 +725,41 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
|||||||
| 6 | 调整单是否走同一审批链;能否跨月调整已锁定月份 | ④⑦ | 建议走同一审批链;「后续月份扣补」为默认路径,跨月调整需授权留痕 —— 待确认 |
|
| 6 | 调整单是否走同一审批链;能否跨月调整已锁定月份 | ④⑦ | 建议走同一审批链;「后续月份扣补」为默认路径,跨月调整需授权留痕 —— 待确认 |
|
||||||
| 7 | 待办事项的范围(除审批/同步失败外还含哪些,回款未指定批次是否算待办) | ⑧ | 按 3.2(二) 默认清单起步,业务确认后扩充 |
|
| 7 | 待办事项的范围(除审批/同步失败外还含哪些,回款未指定批次是否算待办) | ⑧ | 按 3.2(二) 默认清单起步,业务确认后扩充 |
|
||||||
|
|
||||||
|
### 3.3.1 答复回填(2026-10-19,2026-10-20 修订)
|
||||||
|
|
||||||
|
> **修订说明(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# | 事项 | 业务答复(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 对路线图的影响(本次已修订)
|
### 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` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
|
1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
|
||||||
2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose);
|
2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose);
|
||||||
3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测);
|
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` 最新运行记录为准)。
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# M4 ⑤ 报表统计 · 任务级实施计划
|
||||||
|
|
||||||
|
> 立项日期 2026-10-19;分支 feat/m3-increments。依据:需规 2.4 /《需求规格说明书》FR-029~FR-032 / 开发计划第五章 1.3 验收行 / 2026-10-15 补充(四)「导出模板围绕财务复核和领导签字设计」。
|
||||||
|
> 前置已满足:④ 台账/月结/调整单全部交付(台账即「报表 2.4.1 的数据源」,`performance_ledger.py` 注释锚点)。
|
||||||
|
> **纯只读模块,无数据库迁移**。
|
||||||
|
|
||||||
|
## 〇、代码核实结论(实施时按此避坑)
|
||||||
|
|
||||||
|
| # | 结论 | 影响 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `require_roles` 403 文案硬编码「无权访问审批管理」(`api/deps.py:135`) | 给 `require_roles` 增可选 `message` 参数(默认值不变),报表传「无权访问报表统计」 |
|
||||||
|
| 2 | `PageQuery.page_size` 上限 200(`core/response.py:57`) | 打印视图循环翻页取全量;导出走专用无分页查询 |
|
||||||
|
| 3 | `settlement_service._confirm_scope` 带 `settlement_id IS NULL`(可入结口径) | 报表取数**不复用**,新增同月全量生效单笔行查询 |
|
||||||
|
| 4 | `list_adjustments` 无月份筛选、limit 200、含全部状态 | 调整单并入需新写仓储查询(月份 + status=2) |
|
||||||
|
| 5 | `GET /system/settings` 是 AdminUser,`/public` 才无角色限制 | 打印视图页眉必须走 `getPublicSettings()` |
|
||||||
|
| 6 | 台账 `payment_days` 可空可负(先款后货);进度登记回款无台账行(#25) | 区间桶含「≤0 天」「无天数」兜底;回款统计注明「不含进度登记回款」 |
|
||||||
|
| 7 | 二次分摊拆行使同一回款多行(负责人+参与人,business_id 相同) | 回款金额按 receipt_id 去重;个人明细不放「关联回款金额」列 |
|
||||||
|
| 8 | 经理聚合行 calc_type=2 的 business_id 是锚点、receipt_id=NULL、payment_days=NULL | 业务统计/个人明细/回款统计一律 `calc_type=1 AND is_active=1`,防聚合额污染 |
|
||||||
|
| 9 | openpyxl `NUMERIC_TYPES` 含 Decimal,原样落数值不转 float | 精度红线导出侧成立;`number_format='#,##0.00'` 控显示 |
|
||||||
|
| 10 | `test_param_registry.py` 锁 registry 19 项 | 新增参数需四处同改(本计划不新增参数,规避) |
|
||||||
|
| 11 | `PRODUCT_CATEGORIES` 常量不含 `mixed`,台账行有 `mixed` | 标签映射额外覆盖 mixed,单测防漂移 |
|
||||||
|
| 12 | `audit_service.audit` 可用于只读导出留痕(独立 commit) | 直接复用;上下文内 bytes 在 commit 前固化 |
|
||||||
|
| 13 | `test_settlement_helpers.py` 直接 import `settlement_service._month_end` | 抽共享模块时保留薄委托包装 |
|
||||||
|
| 14 | 前端无任何打印/PDF/xlsx 代码 | 打印从零建:独立顶层路由 + `@media print` |
|
||||||
|
| 15 | Starlette 响应头 latin-1,中文文件名会 UnicodeEncodeError | 导出文件名服务端 ASCII、中文名前端 `link.download` 赋 |
|
||||||
|
|
||||||
|
## 一、全局口径(写进 report_service docstring)
|
||||||
|
|
||||||
|
1. 数据源 `performance_ledgers` 固定 `is_active=1 AND calc_type=1`;聚合行只在部门汇总作冻结对照单读;月份维度 `settlement_month`。
|
||||||
|
2. 金额全程 `Decimal` 出参字符串;比例原样 `rate_text`,禁 float/截断。
|
||||||
|
3. 调整单并入:仅 `status=2`,按 `(settlement_month, personnel_id)` 并入,`direction=1` 记 +、`2` 记 −;归属按实时 `manager_id`。
|
||||||
|
4. 离职人员不按 `status` 过滤(历史业绩留在原团队);零行经理枚举仅在职经理。
|
||||||
|
5. 汇总 = 明细合计(团队/公司基数 == 成员明细绩效合计,测试逐项断言)。
|
||||||
|
|
||||||
|
### 已定设计决策
|
||||||
|
|
||||||
|
- 部门口径 = `personnel.manager_id` 实时上溯(无独立部门表,人员模型 docstring 已声明;已入催办清单 R1 向业务确认)
|
||||||
|
- 导出 = 后端 openpyxl + StreamingResponse(ASCII 文件名);打印 = 前端独立打印视图 + `@media print`,PDF 走浏览器另存为;不引前端 PDF/xlsx 依赖
|
||||||
|
- 不做图表(需求无要求,不引 ECharts)
|
||||||
|
- 区间桶 = 代码常量 + 服务端下发元数据(前端零硬编码);不进参数体系避免迁移;配置缝收在 `get_payment_day_buckets()` 单函数,升级路径:`report.payment_days.buckets` 参数 → V10 迁移种子 → init-dev → registry 测试 19→20
|
||||||
|
- 权限 `require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER)` 粗粒度,前后端双层(admin 显式列入)
|
||||||
|
|
||||||
|
## 二、后端任务
|
||||||
|
|
||||||
|
### 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`
|
||||||
|
|
||||||
|
### T2 仓储 `repositories/report_repository.py`(新)
|
||||||
|
|
||||||
|
| 函数 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `list_personal_detail / count_personal_detail` | 单笔生效行分页(month/personnel/customer_type/product_category/role_code 筛选,id desc) |
|
||||||
|
| `sum_personal_detail` | 全筛选口径 SUM(分页合计与页内合计分开,禁内存当前页求和) |
|
||||||
|
| `list_month_receipt_rows` | 同月全量生效单笔行(部门汇总取数,不复用 _confirm_scope) |
|
||||||
|
| `list_month_manager_aggregates` | 同月 calc_type=2 生效行(冻结对照) |
|
||||||
|
| `list_month_adjustments` | status=2 + 月份 |
|
||||||
|
| `list_business_stats / count / sum` | Business×Customer×Personnel 左联 + 三个 scalar_subquery(发货额 SUM(shipments.total_amount)、回款额 SUM(receipts.amount)、绩效 SUM(台账 performance_amount WHERE calc_type=1 AND is_active=1));keyword 双列 OR contains(autoescape=True) |
|
||||||
|
| `list_payment_group_by_days` | GROUP BY (payment_days, customer_type, product_category) 出 SUM 绩效/应付/COUNT |
|
||||||
|
| `list_payment_receipt_group_by_days` | 先 DISTINCT receipt_id 再 JOIN receipts 求和(拆行防翻倍) |
|
||||||
|
| `list_payment_detail / count` | 区间下钻分页(闭区间 between、开区间 >=、none 桶 IS NULL),JOIN receipts 取金额与批次号 |
|
||||||
|
|
||||||
|
### T3 经理分成共享口径(`services/manager_share.py` 新 + `settlement_service.py` 改薄委托)
|
||||||
|
|
||||||
|
- 迁出(复制实现 + 薄委托,行为零变化):`month_end`、`ManagerShareParams`(含 `manager_share_params(session, month)`,锚定月末 resolve_snapshot)、`collect_team_buckets`、`company_base_of`
|
||||||
|
- 报表侧分成额调用 `formula.manager_team_share(...)`——月结聚合行与报表汇总同源,杜绝口径漂移
|
||||||
|
- **协调点**:84 会话已拆 settlement_service(028a94a);本任务在其基础上做,验证 `test_settlement_helpers.py` 全绿
|
||||||
|
|
||||||
|
### T4 服务与 schemas(`services/report_service.py` + `schemas/report.py` 新)
|
||||||
|
|
||||||
|
1. **个人明细**:`ReportPage[T]` = 标准分页四字段 + `summary`(全筛选合计);行含 ledger_id/business_id/business_no/人员/岗位/客户类型/产品类别/ship_date/receipt_date/payment_days/rate_text/weight_r/payment_weight/performance_amount/payable_amount;**不含回款金额列**
|
||||||
|
2. **部门汇总**(月份必填):`collect_team_buckets` 分团队 → 团队基数/成员内联明细(personnel_id 聚合 + 台账 id 列表 + 调整 extra/deduct/net);`company` 块 5% 单列(team_base_total/company_base/company_share_amount;各团队 5% 之和 ≠ 全局 5%,UI 注明);零行经理并集枚举(在职 sales_manager JOIN business_roles + 数据中出现的 manager_id);对比 `company_base_ratio` 除零→0;冻结对照 `aggregate_ledger_id + frozen_amount`;出参 `params`(team_pct/company_pct/manager_personal_included/params_as_of)
|
||||||
|
3. **业务统计**:分页 + summary;回款/绩效为该业务累计值(不随日期筛选变,页面 hint)
|
||||||
|
4. **回款统计**:SQL 精确值聚合 → Python 按 `PAYMENT_DAY_BUCKETS` 分桶(恒含全桶、空桶 0);正负绩效直接求和不 ABS;出参 buckets/by_customer_type/by_product_category/totals
|
||||||
|
5. **区间下钻**:`GET /reports/payment-detail`(bucket 必填,分页)
|
||||||
|
6. **导出编排** `export_report(session, code, q, *, operator, meta)`:先 count 判上限(超限 400 中文提示按月缩小);`audit_service.audit(MODULE_REPORT, ACTION_EXPORT, ...)` 包取数+构建;出参 `(bytes, filename)` 在 commit 语义闭合前固化
|
||||||
|
|
||||||
|
### T5 Excel 模板(`services/report_export.py` 新,纯函数)
|
||||||
|
|
||||||
|
- 普通 `Workbook`(上限封顶 2 万行内存可承受);版式:公司页眉行(logo `read_logo` try/except 降级纯文本)→ 标题(合并居中)→ 筛选条件行 → 导出时间/人/模板版本 → 表头(加粗底纹边框 + freeze_panes)→ 数据行 → 合计行(service 侧 Decimal SUM quantize HALF_UP,**不写 Excel 公式**)→ 空两行 → 财务复核栏 / 领导签字栏 / 日期栏
|
||||||
|
- 精度:金额 Decimal 直接写 + `#,##0.00`;比例 `'0.00"%"'`;`rate_text` 原样文本;日期 `date` + `YYYY-MM-DD`
|
||||||
|
- 防公式注入:文本以 `= + - @ \t` 开头前置 `'`;CJK 列宽;部门汇总/回款统计多 sheet(各带页眉与签字栏);空数据允许(表头 + 提示行 + 签字栏)
|
||||||
|
|
||||||
|
### T6 API 与路由(`api/v1/reports.py` 新 + `router.py` 改 + `deps.py` 改)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 入参(Query 蛇形) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/v1/reports/personal-detail` | pageNum/pageSize + settlement_month, personnel_id, customer_type, product_category, role_code |
|
||||||
|
| GET | `/api/v1/reports/department-summary` | settlement_month(必填), manager_id |
|
||||||
|
| GET | `/api/v1/reports/business-stats` | pageNum/pageSize + keyword, customer_id, owner_personnel_id, status, date_from, date_to |
|
||||||
|
| GET | `/api/v1/reports/payment-stats` | settlement_month, date_from, date_to, customer_type, product_category |
|
||||||
|
| GET | `/api/v1/reports/payment-detail` | pageNum/pageSize + bucket(必填) + 同上 |
|
||||||
|
| GET | `/api/v1/reports/{code}/export` | code ∈ 四表,参数同对应页 → StreamingResponse xlsx |
|
||||||
|
|
||||||
|
- 全部 `def` 同步路由 + `Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问报表统计"))]`
|
||||||
|
- Content-Disposition 文件名 ASCII(`personal_detail_20261019.xlsx`),沿用 `system/params.py` 导出先例
|
||||||
|
|
||||||
|
### T7 后端文档(`backend/README.md` 改)
|
||||||
|
|
||||||
|
报表接口表 + 「报表口径」小节(生效行/调整单并入/回款去重/不含进度登记回款/区间桶常量与升级路径)。
|
||||||
|
|
||||||
|
## 三、前端任务
|
||||||
|
|
||||||
|
### T8 API 与常量(`api/reports.ts` 新 + `utils/constants.ts` 改)
|
||||||
|
|
||||||
|
类型逐字段对齐(金额 string);`exportReport(code, params)` 裸 axios `responseType:'blob'`;MODULE_OPTIONS 与后端 `core/constants.py` 全量对齐
|
||||||
|
|
||||||
|
### T9 四个页面(`views/reports/{personal-detail,department-summary,business-stats,payment-stats}/index.vue` 新)
|
||||||
|
|
||||||
|
统一骨架:`filters(reactive)` + `usePagedList`(非分页表 ref+手动 load)+ `el-table stripe` + `AppPagination` + 工具条「导出 Excel」「打印」+ `handledError`;日期区间 `el-date-picker value-format="YYYY-MM-DD"`(必写);下拉复用 `useDropdownOptions`
|
||||||
|
|
||||||
|
| 页面 | 路由 order | 关键列与下钻 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 个人绩效明细表 | `/reports/personal-detail` 19 | 需规 10 列 + 页脚全筛选合计(注明非当前页);expand 懒加载 `getLedger(ledger_id)` 快照下钻 |
|
||||||
|
| 部门绩效汇总表 | `/reports/department-summary` 20 | 团队表 + 全公司 5% 卡片 + 参数取值提示;展开成员 → 跨页下钻个人明细(router.push query)→ 台账下钻复用 `listLedger` |
|
||||||
|
| 业务统计表 | `/reports/business-stats` 21 | 发货额/回款额/绩效三列;expand 两小表(复用 `listReceipts`/`listLedger`);hint「累计值」 |
|
||||||
|
| 回款统计表 | `/reports/payment-stats` 22 | buckets 元数据渲染(空桶 0)+ 桶展开下钻 `payment-detail` + 客户类型/产品类别对比小表 |
|
||||||
|
|
||||||
|
路由 meta:`{ title, group: '报表统计', order: 19..22, allowedRoles: [ROLE_ADMIN, 'finance', 'leader'] }`(admin 显式列入)
|
||||||
|
|
||||||
|
### T10 打印视图(`views/reports/print/index.vue` 新 + `router/index.ts` 改)
|
||||||
|
|
||||||
|
- 顶层路由 `/reports/print/:code`(不挂 BasicLayout),`menu:false` + allowedRoles 同上
|
||||||
|
- 循环翻页取全量(pageSize 200、上限 2000 截断提示);页眉 `getPublicSettings()`(非管理员拿不到 `/system/settings`)
|
||||||
|
- `window.print()`(按钮区 `@media print { display:none }`);`@page A4 landscape`;`thead { display: table-header-group }` 每页带表头;打印态白底黑字沿用 `--ys-*`
|
||||||
|
|
||||||
|
### T11 前端文档(`frontend/README.md` 改)
|
||||||
|
|
||||||
|
报表页与打印视图小节 + 权限矩阵
|
||||||
|
|
||||||
|
## 四、测试(模块整体完成时统一编写)
|
||||||
|
|
||||||
|
### T12 后端(`tests/unit/test_report_helpers.py` + `tests/integration/test_reports.py` 新)
|
||||||
|
|
||||||
|
- 单元:分桶边界(0/1/30/31/60/61/180/181/360/361/负值/NULL);标签键集合 ⊇ CUSTOMER_TYPES ∪ PRODUCT_CATEGORIES ∪ {"mixed"};Excel 结构断言(load_workbook 读回:页眉/合计/签字栏/number_format/注入前缀/空数据);占比除零→0
|
||||||
|
- 集成(factories 造数):① 个人明细对拍 `/calc/ledger`、summary==全量和;② 部门汇总成员合计==团队基数==公司基数、`aggregate_amount==manager_team_share` 预期、生成月结后 computed==frozen;③ 无团队成员经理 0 行、离职成员计入原团队;④ 调整单生效行并入(status≠2 不计);⑤ 回款统计空桶恒在、负绩效计入、**分摊拆行金额只计一次**;⑥ 业务统计排除 calc_type=2;⑦ 导出 200/400/审计留痕(成功与失败);⑧ 普通 user 403(文案「无权访问报表统计」)
|
||||||
|
|
||||||
|
### T13 e2e(`frontend/e2e/report_flow.py` 新)
|
||||||
|
|
||||||
|
菜单可见 → 四页筛选与数字 → `page.expect_download()` 断言导出 → 打印视图注入 `window.print` 桩断言 → console 无错;造数用 API + 登录 token(远未来月份隔离,参照 calc_settlement_flow.py)
|
||||||
|
|
||||||
|
### T14 收尾
|
||||||
|
|
||||||
|
回归(pytest 全量 + ruff + type-check + build + e2e report_flow 与复跑两条既有脚本);三 README 更新;提交拆分 `feat(report): 报表统计后端取数与导出` → `feat(report): 报表统计前端四页与打印视图` → `test(report): 报表模块用例与测试记录`
|
||||||
|
|
||||||
|
## 五、开放项(不阻塞,随催办确认)
|
||||||
|
|
||||||
|
| 事项 | 默认 |
|
||||||
|
| --- | --- |
|
||||||
|
| 部门口径(R1) | manager_id 实时上溯 |
|
||||||
|
| 区间档位(R2) | 8 档代码常量 |
|
||||||
|
| 签字栏要素(R3) | 页眉/标题/筛选/时间人/版本/复核/签字/日期 |
|
||||||
|
| 导出上限 | 20000 行超限 400 |
|
||||||
|
| 打印上限 | 2000 行截断提示 |
|
||||||
|
| Excel logo | 有则嵌、无则纯文本 |
|
||||||
Binary file not shown.
@@ -4,26 +4,27 @@
|
|||||||
> 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。
|
> 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。
|
||||||
> 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。
|
> 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。
|
||||||
> **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。
|
> **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。
|
||||||
|
> **2026-10-20 更新**:开发计划 3.3 已回填(3.3.1),SRS V2.1 已交付;二次分摊已按开发侧暂定口径实现(#1–#3 答复仍空白);code-review 遗留 2 项(方案版本承载、审批单内容快照)已随 V11 迁移收口。
|
||||||
|
|
||||||
## 一、立即待办(需项目内处理)
|
## 一、立即待办(需项目内处理)
|
||||||
|
|
||||||
| # | 事项 | 状态 | 下一步动作 |
|
| # | 事项 | 状态 | 下一步动作 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 1 | **推送 feat/m3-increments** | 🔄 远端已恢复 | 2026-10-19 实测 `git ls-remote` 连通;远端已推进至 35fe37f(V8/权限/增量备份),**本地领先 8 提交**(code-review 修复 4 提交 + #14 首日口径 + 相关测试/文档)——推送前与并行会话对账,避免重复推送 |
|
| 1 | **推送 feat/m3-increments** | ✅ 已完成(2026-10-19) | 远端 Git 服务恢复后已推送,远端现为 e48cfb7(含 code-review 修复 4 提交 + #14 首日口径 + V8 系列 + 测试/文档);并行会话(88:calc_service 分摊拆行)收口后继续推送其后提交 |
|
||||||
| 2 | **M2 分支合并 main** | 未开始 | feat/base-business-data(M2 全部 + code-review 32 条修复)尚未合入 main;与 #1 一并处理(PR 评审或本地合并) |
|
| 2 | **M2 分支合并 main** | 未开始 | feat/base-business-data(M2 全部 + code-review 32 条修复)尚未合入 main;与 #1 一并处理(PR 评审或本地合并) |
|
||||||
|
|
||||||
## 二、业务方澄清(24 项,**2026-10-16 答复已到**,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列)
|
## 二、业务方澄清(24 项,**2026-10-16 答复已到**,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列)
|
||||||
|
|
||||||
> 答复汇总:**12 项确认按默认执行**(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#23/#24);**4 项答复与默认不同,以答复为准**——#5 二次分配审批开关**默认开启**、#14 **首日整 10‰**、#16 试用期基本工资**按人员档案逐人维护**、#19 备份**加增量补充**;仍未落定见下表「答复状态」列。答复后待回填开发计划 3.3 与需求记录。
|
> 答复汇总:**13 项确认按默认执行**(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#22/#23/#24,其中 #22 为模糊确认、按不计入);**4 项答复与默认不同,以答复为准**——#5 二次分配审批开关**默认开启**、#14 **首日整 10‰**、#16 试用期基本工资**按人员档案逐人维护**、#19 备份**加增量补充**;部分答复 3 项(#4/#6/#12),仍开放 4 项(#1–#3/#20)。开发计划 3.3 已于 2026-10-20 回填(3.3.1),未答复项仍按催办清单跟进。
|
||||||
|
|
||||||
### 高优先级(阻塞对应模块开工,8 项)
|
### 高优先级(阻塞对应模块开工,8 项)
|
||||||
|
|
||||||
| 清单编号 | 问题 | 建议默认(未答复时执行) | 阻塞模块 | 答复状态 |
|
| 清单编号 | 问题 | 建议默认(未答复时执行) | 阻塞模块 | 答复状态 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| 1 | 二次分配的分配标的:绩效金额、折算毛利润还是业绩额? | 未答复前只做方案录入,不实现分摊(已按此交付) | ④ 分摊计算 | ❌ 未答复 |
|
| 1 | 二次分配的分配标的:绩效金额、折算毛利润还是业绩额? | 方案录入已交付;2026-10-19 已按开发侧暂定口径实现分摊拆行(非业务答复,答复后校准) | ④ 分摊计算 | ❌ 未答复 |
|
||||||
| 2 | 分配比例合计不足 100% 时余额归谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 | ❌ 未答复 |
|
| 2 | 分配比例合计不足 100% 时余额归谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 | ❌ 未答复 |
|
||||||
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ | ❌ 未答复 |
|
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ | ❌ 未答复 |
|
||||||
| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定;分摊实现(88 批次)进行中 |
|
| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定;分摊已于 2026-10-19 实现(ccfd206) |
|
||||||
| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认;**#17 参数查看放开已落实(2026-10-19)** |
|
| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认;**#17 参数查看放开已落实(2026-10-19)** |
|
||||||
| 8 | 回款何时算「确认回款」? | 需人工确认动作(系统已按此预留) | ④ 月结取数 | ✅ 确认:人工确认,可手动关闭 |
|
| 8 | 回款何时算「确认回款」? | 需人工确认动作(系统已按此预留) | ④ 月结取数 | ✅ 确认:人工确认,可手动关闭 |
|
||||||
| 9 | 调整单是否走同一审批链?能否跨月调整已锁月份? | 同一审批链;后续月份扣补为默认路径 | ④/⑦ | ✅ 确认默认;跨月需管理员授权并留痕 |
|
| 9 | 调整单是否走同一审批链?能否跨月调整已锁月份? | 同一审批链;后续月份扣补为默认路径 | ④/⑦ | ✅ 确认默认;跨月需管理员授权并留痕 |
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
| 15 | 支付权重「余额模式」是否需要? | ✅ 一期只用 FULL |
|
| 15 | 支付权重「余额模式」是否需要? | ✅ 一期只用 FULL |
|
||||||
| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | ✅ 已落实(2026-10-19,V8):撤销 `probation.salary.basic` 参数,基本工资读 `personnel.salary_standard` |
|
| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | ✅ 已落实(2026-10-19,V8):撤销 `probation.salary.basic` 参数,基本工资读 `personnel.salary_standard` |
|
||||||
| 20 | 含税口径:毛利/运费/价税合计是否含税? | ❌ 未答复:「需与客户再次确认具体计算方式」 |
|
| 20 | 含税口径:毛利/运费/价税合计是否含税? | ❌ 未答复:「需与客户再次确认具体计算方式」 |
|
||||||
| 22 | 预收款/保证金/代收款是否计入绩效回款? | ❌ 未答复:「当前未提到预收款情况」,暂按不计入 |
|
| 22 | 预收款/保证金/代收款是否计入绩效回款? | ⚠️ 模糊答复:「当前未提到预收款情况」,暂按不计入 |
|
||||||
|
|
||||||
### 低优先级(已有默认口径,顺手确认,7 项)
|
### 低优先级(已有默认口径,顺手确认,7 项)
|
||||||
|
|
||||||
@@ -55,8 +56,9 @@
|
|||||||
| 23 | 回款按到账金额还是扣手续费净额? | ✅ 按到账金额 |
|
| 23 | 回款按到账金额还是扣手续费净额? | ✅ 按到账金额 |
|
||||||
| 24 | 「首期不生成负绩效」与裁决「负绩效不归零」冲突,以哪个为准? | ✅ 维持裁决:负数照算不归零、月结支持扣补(业务补注:按当前算法首期不会出现负绩效) |
|
| 24 | 「首期不生成负绩效」与裁决「负绩效不归零」冲突,以哪个为准? | ✅ 维持裁决:负数照算不归零、月结支持扣补(业务补注:按当前算法首期不会出现负绩效) |
|
||||||
|
|
||||||
## 三、待 IT / T+ 侧答复(18 项,见《T+对接数据需求清单》v1.3「待IT确认事项」表)
|
## 三、待 IT / T+ 侧答复(18 项 → 收敛为 13 项 + 1 组业务裁决)
|
||||||
|
|
||||||
|
> **2026-10-20 更新**:T+12.3 数据字典已解析(1,457 页 / 931 张表 / 21,019 个字段),字段级映射已产出:`docs/T+字段映射草案_v0.1_2026-10-20.md`、`docs/T+对接数据需求清单_v1.5_2026-10-20.xlsx`。原 18 项收敛为 13 项 IT 待确认(每项附字典候选字段)+ 1 组业务裁决,详见催办清单第二节与 v1.5「待IT确认事项」sheet。以下原表保留为 2026-10-19 基线。
|
||||||
> **2026-10-19 清单已升 v1.4**(`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`,七节:人员/产品/业务单据/发货单/回款单/客户档案/同步完整性与补充字段)——本节口径待收编到 v1.4 后再对外;待 IT 逐项确认的状态以最新版文件为准。
|
> **2026-10-19 清单已升 v1.4**(`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`,七节:人员/产品/业务单据/发货单/回款单/客户档案/同步完整性与补充字段)——本节口径待收编到 v1.4 后再对外;待 IT 逐项确认的状态以最新版文件为准。
|
||||||
> **2026-10-16 业务方已对「口径与用途说明」修订,已收编清单升 v1.3**:#18 回款↔发货批次定稿**人工指定**(T+ 核销关系仅作辅助,`shipment_id` 保持「同步不覆盖」,v1.2.1「T+ 主源」修订作废,M2 回款页现状不动);「确认回款」两处 #8 开放引用收敛;特殊业务标记待确认降为低优。
|
> **2026-10-16 业务方已对「口径与用途说明」修订,已收编清单升 v1.3**:#18 回款↔发货批次定稿**人工指定**(T+ 核销关系仅作辅助,`shipment_id` 保持「同步不覆盖」,v1.2.1「T+ 主源」修订作废,M2 回款页现状不动);「确认回款」两处 #8 开放引用收敛;特殊业务标记待确认降为低优。
|
||||||
> **新增追问 2 条**(随下一轮回执发业务方):① 「负责营销人员」口径被改为「人员编码**以姓名**为唯一识别」,与「编码是业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误;② 数据清单回款「关联订单号」仍写「由 T+ 收款分配关系指定,人工辅助」,与不覆盖表「人工指定」主次相反,疑漏改。
|
> **新增追问 2 条**(随下一轮回执发业务方):① 「负责营销人员」口径被改为「人员编码**以姓名**为唯一识别」,与「编码是业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误;② 数据清单回款「关联订单号」仍写「由 T+ 收款分配关系指定,人工辅助」,与不覆盖表「人工指定」主次相反,疑漏改。
|
||||||
@@ -83,15 +85,15 @@
|
|||||||
|
|
||||||
| 里程碑 | 内容 | 前置条件 |
|
| 里程碑 | 内容 | 前置条件 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;**余**:二次分摊实现(#1–#3 答复空白)、业务答复回填批次收口(88 会话) | #20(含税口径)、#1–#3 答复后按同粒度推进 |
|
| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;二次分摊已按开发侧暂定口径实现(ccfd206),业务答复回填已完成(计划 3.3.1,2026-10-20);**余**:#1–#3/#20 答复与 #4/#6/#12 补值 | #20(含税口径)、#1–#3 答复后按同粒度推进 |
|
||||||
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 |
|
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 |
|
||||||
| M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 |
|
| M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 |
|
||||||
| code-review 遗留 2 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 |
|
| code-review 遗留 2 项 | ✅ **已完成(2026-10-20,V11)**:方案行 append-only 版本化(is_active/version_no);审批单加版本号与 JSON 内容快照,详情按 snapshot/live/missing 展示 | — |
|
||||||
| 杂项收口(低优) | 超长函数收敛:calc_receipt 与 settlement_service(生成/聚合/调整单)已拆(2026-10-19);剩 preview/change_ownership/create_receipt/update_receipt 等;散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 |
|
| 杂项收口(低优) | 超长函数收敛:calc_receipt 与 settlement_service(生成/聚合/调整单)已拆(2026-10-19);剩 preview/change_ownership/create_receipt/update_receipt 等;散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 |
|
||||||
|
|
||||||
## 五、文档待办(低优)
|
## 五、文档待办(低优)
|
||||||
|
|
||||||
- 需规过时条款未修订(26 天示例、含运费公式、协助 25%);2026-10-15 补充需求未回填需规——对外引用需规前须先修订或注明以对照表/计划第三章为准
|
- 需规过时条款未修订(26 天示例、含运费公式、协助 25%);2026-10-15 补充需求未回填需规——对外引用需规前须先修订或注明以对照表/计划第三章为准
|
||||||
- 澄清答复已到(2026-10-16):待回填开发计划 3.3、AI_CONTEXT、《需求规格说明书》升 V2.1;《T+对接数据需求清单》已升 **v1.4**(2026-10-19,本节口径待收编;生成脚本 build_tplus_xlsx.py 已对齐 v1.3、待对齐 v1.4)
|
- 澄清答复已到(2026-10-16):开发计划 3.3 已回填(3.3.1,2026-10-20)、SRS V2.1 已交付(2026-10-19);《T+对接数据需求清单》已升 **v1.5**(2026-10-20,含 75 项映射 + 13 项 IT 收敛 + 不覆盖 12 条),映射草案 v0.1 已产出
|
||||||
- ~~新口径待落实(业务答复与默认不同的 4 项)~~ ✅ **已全部落实(2026-10-19,见第二节标注)**
|
- ~~新口径待落实(业务答复与默认不同的 4 项)~~ ✅ **已全部落实(2026-10-19,见第二节标注)**
|
||||||
- **修复批次引入口径记录(2026-10-19,供业务知会)**:月结的经理聚合与月工资口径按**所属月月末**取参(单笔台账行仍按回款日取参,裁决 #11)——补生成历史月份时结果不随生成日漂移;如业务对月末时点有异议,改 `settlement_service._month_end` 单点即可
|
- **修复批次引入口径记录(2026-10-19,供业务知会)**:月结的经理聚合与月工资口径按**所属月月末**取参(单笔台账行仍按回款日取参,裁决 #11)——补生成历史月份时结果不随生成日漂移;如业务对月末时点有异议,改 `settlement_service._month_end` 单点即可
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 待答复事项催办清单(2026-10-19)
|
||||||
|
|
||||||
|
> **用途**:对外跟催材料,分三块可直接拆分发送——一、业务方;二、IT/T+ 侧;三、报表口径补充确认(本轮新增)。
|
||||||
|
> **基线**:《待澄清问题清单_2026-10-15(1)(2).xlsx》(业务 24 项,2026-10-16 已答复 19 项,其中部分答复 3 项;#1–#3/#20 仍空白)、《T+对接数据需求清单》v1.3「待IT确认事项」18 项 + v1.4(2026-10-19 勘误)、《问题与解决方案对照表_v2》(28 项已裁决口径)。
|
||||||
|
> **原则**:已答复项不重复询问;未答复项按清单内「建议默认」执行中,答复后以答复为准并回填文档。
|
||||||
|
|
||||||
|
## 一、业务方(建议回复期限:____)
|
||||||
|
|
||||||
|
### A. 仍未答复 —— 阻塞对应模块开工(4 项)
|
||||||
|
|
||||||
|
| # | 问题(原文口径) | 当前执行默认 | 阻塞 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 二次分配的分配标的是什么——绩效工资金额、折算毛利润额,还是销售额(业绩额)? | 方案录入已交付;**分摊已按开发侧暂定口径实现**(非业务答复,答复后校准) | ④ 分摊计算 |
|
||||||
|
| 2 | 分配比例合计不足 100% 时,剩余部分归属谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 |
|
||||||
|
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ 分摊 |
|
||||||
|
| 20 | 含税口径:销售/成本/毛利润/运费各项是否含税?价税合计与折扣折让如何处理? | 按不含税口径实现中 | ④ 核算口径终验、⑥ 同步建列(是否建税率/税额列) |
|
||||||
|
|
||||||
|
> 说明:#1–#3 未答复期间,二次分配方案可正常录入与审批;开发侧已按暂定口径把分摊拆行计入绩效(参与人份额×比例、负责人余额),**标的与余额归属仍待业务答复,答复后可能调整**;#20 影响历史算出的绩效是否需要按含税口径重算,越早答复返工面越小。
|
||||||
|
|
||||||
|
### B. 已有答复但需补具体值 / 勾选(4 项)
|
||||||
|
|
||||||
|
| # | 事项 | 需补充 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 12 | 经理个人户业绩是否计入其 12.5% 团队基数 | 答复为「做参数可调」,**请给出初始值**:计入 / 不计入(系统已实现为开关参数,当前默认不计入) |
|
||||||
|
| 14 | 「第 1 天 10‰」实现口径 | 请勾选:**A 仅首日覆盖**(推荐,保「360 天 = 6‰」锚点)或 B 整体平移(破坏锚点)。系统当前按 A 实现(首日 10‰、其余按公式 10 − 天数 × 4/360) |
|
||||||
|
| 4 | 二次分配方案修改窗口与权限 | 答复「暂定允许修改,走调整单」——请定:首次月结前可改(留痕)?月结后只读、仅走调整单?谁有权改(管理员 / 营销后勤)? |
|
||||||
|
| 6 | 营销后勤/财务/领导三类角色的账号归属、可见菜单、可操作点 | 答复「先预设角色,需与客户确认」——请给权限清单(三类角色各能看什么、能操作什么)。系统已按最小授权运行:后勤=提交录入,财务=复核+导出,领导=审核只读,营销后勤暂不可见审批列表 |
|
||||||
|
|
||||||
|
### C. 请确认两处勘误(已按勘误实施,需业务回执存档)(2 项)
|
||||||
|
|
||||||
|
| 项 | 原表述 | 现行勘误(v1.4) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 人员识别 | 「负责营销人员以姓名为唯一识别」 | 改为「以人员编码(工号)为唯一识别、姓名辅助核对」——原表述与「编码为业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误,请确认 |
|
||||||
|
| 回款↔发货批次 | 「由 T+ 收款分配关系指定,人工辅助」 | 改为「**系统人工指定**(对照表 #18 定稿),T+ 收款分配关系仅作辅助」——主次相反疑漏改,请确认 |
|
||||||
|
|
||||||
|
### D. 2026-10-20 新增:需业务定口径(2 项)
|
||||||
|
|
||||||
|
> 随「审批单内容快照 + 二次分配方案版本化」收口带出,开发侧已按现行默认实现,答复后按答复调整。
|
||||||
|
|
||||||
|
| # | 事项 | 当前默认(开发侧已实现) | 需确认 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 25 | **业务硬删除时,方案版本历史是否保留?** | 随业务一并删除(与既有「终态审批单随业务删除一并清理」规则一致)——业务被删后,方案参与人与比例的版本痕迹也不复存在 | 是否需要保留(如留归档表/只留审批单快照),还是维持一并删除 |
|
||||||
|
| 26 | **离职的历史参与人重新加入方案,是否允许?** | 按新参与人处理:曾参与过但当前不在生效方案里的人,若已离职,重新加入会被「离职人员禁入」拦截 | 是否应放行(把「历史参与过」视为豁免),还是维持按现任职校验 |
|
||||||
|
|
||||||
|
### 已按默认执行、无需回复的项(供核阅)
|
||||||
|
|
||||||
|
\#7 月结审批链默认开启、#8 人工确认回款可手动关闭、#9 调整单同链且跨月需授权、#10 风险预警参数化、#11 待办范围(待审批+同步失败+回款未指定批次)、#13 支付环节 5 默认值、#15 一期仅 FULL 模式、#17 参数查看放开/修改限管理员、#18 企业户 2‰ 回溯、#21 退货当期冲减已结算走调整单、#22 预收/保证金/代收款暂不计入、#23 按到账金额、#24 负绩效照算不归零。
|
||||||
|
|
||||||
|
## 二、IT / T+ 侧(建议回复期限:____)
|
||||||
|
|
||||||
|
> **2026-10-20 更新**:已用 T+12.3 数据字典完成字段级核对(1,457 页 / 931 张表 / 21,019 个字段),下列问题由 18 项收敛为 **13 项 IT 待确认 + 1 组业务裁决**,每项附字典候选字段。字段级证据与来源页见《T+字段映射草案_v0.1_2026-10-20.md》,完整清单见《T+对接数据需求清单_v1.5_2026-10-20.xlsx》。
|
||||||
|
|
||||||
|
### A. 待确认(按优先级)
|
||||||
|
|
||||||
|
| 新# | 原# | 事项 | 字典候选(已定位) | 仍需答复 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | 1 | 成本额字段与成本口径 | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType | 取订单最新成本还是出库成本;取数时点与精度 |
|
||||||
|
| 2 | 2+3 | 发货日与到账日口径 | 发货:voucherdate/SendDate/deliveryDate/auditeddate;到账:voucherDate/auditedDate/createdTime | 分别取哪个字段;签收日/银行到账日能否提供 |
|
||||||
|
| 3 | 4 | 幂等键与作废重开 | 各表 code + id 主键 | code 是否终身唯一;作废重开是否换号 |
|
||||||
|
| 4 | 5 | 三单关联与核销路径 | SA_SaleDelivery.SourceVoucherCode、_b.saleOrderCode/saleOrderId;ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_StrikeBalance | 主关联路径;分批发货/部分核销如何取 |
|
||||||
|
| 5 | 6 | 人员编码/工号与经理隶属 | AA_Person.code/name/iddepartment;AA_Department.idparent/iddirector(无直属上级字段) | code 是否即工号;业务员→经理隶属如何维护 |
|
||||||
|
| 6 | 7 | 对接方式与安全 | 字典不涉及 | REST/视图/中间表/文件;只读账号、接口文档、样例数据 |
|
||||||
|
| 7 | 8 | 负数/红字/退货/冲销规则 | isCounteractDelivery、returnQuntity/backSentQuantity、voucherstate、ARAP_StrikeBalance | 实际使用形式、原单关联、是否推送 |
|
||||||
|
| 8 | 9 | 存量起始与增量水位 | updated/createdTime/madeDate/auditedDate/ts | 存量起始日;增量用哪个时间戳;时区 |
|
||||||
|
| 9 | 10+v1.4#18 | 客户主数据匹配与变更 | AA_Partner.code/name/Contact/representative/disabled/updated | 唯一键;改名/换负责人/合并;负责人取 Contact 还是 representative |
|
||||||
|
| 10 | 11+16 | 收款方式与确认/核销枚举 | AA_SettleStyle;ARAP_ReceivePayment.IdSettleStyle/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 自定义项;未检出市场拓展计划表 | 有无可识别字段/表;无则人工维护 |
|
||||||
|
| 13 | 15 | 同步窗口 | 字典不涉及 | 每周时间点,避结账/备份;失败重试策略 |
|
||||||
|
|
||||||
|
### B. 口径裁决(1 组,转业务方)
|
||||||
|
|
||||||
|
含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述(原 #18,对应《待澄清问题清单》#20–#24)。
|
||||||
|
|
||||||
|
### C. 已由字典补齐、仅需确认可用性
|
||||||
|
|
||||||
|
1. **人员编码**:字典已定位 `AA_Person.code`,请确认是否即工号;若为自动内部编码,请指明工号字段。
|
||||||
|
2. **客户档案**:字典已定位 `AA_Partner` 主表(编码/名称/联系人/停用/地区等),请确认可提供。
|
||||||
|
3. **映射列**:v1.5 清单已回填「T+ 表.字段 / 类型 / 枚举 / 系统目标列」,请按实际环境核对并修正。
|
||||||
|
|
||||||
|
## 三、报表口径补充确认(2026-10-19 新增,随本轮开发一并排)
|
||||||
|
|
||||||
|
> 报表统计模块(需规 2.4 / FR-029~FR-032)开工在即,以下三项请确认;未答复按「建议默认」实现,后续可调。
|
||||||
|
|
||||||
|
| # | 事项 | 建议默认(将按此实现) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| R1 | 部门绩效汇总表的「部门」口径 | 部门 = 经理团队(经理 + 其直属业务员),按人员档案的隶属关系展开;离职人员历史业绩留在原团队;经理的 5% 全公司部分单独一列 |
|
||||||
|
| R2 | 回款统计的「回款天数区间」档位 | ≤0 天(先款后货)|1-30|31-60|61-90|91-180|181-360|361 以上|无回款天数,共 8 档;未命中区间显示 0 |
|
||||||
|
| R3 | 导出/打印模板要素 | 公司信息页眉(名称+logo)、报表标题、筛选条件、导出时间与导出人、模板版本号、财务复核栏、领导签字栏、日期栏 |
|
||||||
|
|
||||||
|
另:报表查看权限(FR-029 参与者含「营销后勤(授权范围内)」)——请随第一部分 B-6 权限清单一并明确营销后勤是否需要查看报表、可见哪些列。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**发送建议**:第一部分发业务方(含 C 两项勘误确认);第二部分发 IT/T+(v1.4 附表一并);第三部分随开发进度与业务方顺带确认。
|
||||||
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user