Compare commits
103
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac4ba7514b | ||
|
|
27533eb465 | ||
|
|
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 | ||
|
|
e48cfb752d | ||
|
|
028a94a6eb | ||
|
|
a89d540e3f | ||
|
|
7a99994d17 | ||
|
|
45c268b146 | ||
|
|
828b57350a | ||
|
|
76ded6d5ec | ||
|
|
7a460cb682 | ||
|
|
cb4f38dfd9 | ||
|
|
35fe37fb0c | ||
|
|
7008de0c69 | ||
|
|
2652397720 | ||
|
|
0e8c5713fa | ||
|
|
238a38c356 | ||
|
|
a1217f4482 | ||
|
|
e7e47d88bc | ||
|
|
22a2872100 | ||
|
|
df1a3839a1 | ||
|
|
a5c86640e2 | ||
|
|
30a179fc4c | ||
|
|
1c93ea0a4b | ||
|
|
fc8a42bcd9 | ||
|
|
b5e27810fd | ||
|
|
927592b472 | ||
|
|
74f1ef1cf9 | ||
|
|
8ef26e22fb | ||
|
|
680661a80b | ||
|
|
efed29647c | ||
|
|
e95dc4b2de | ||
|
|
acf87fdfdc | ||
|
|
94c49138bf | ||
|
|
f75a9d534e | ||
|
|
0ef3adec21 | ||
|
|
60f6af6494 | ||
|
|
409e26614f | ||
|
|
5ef48abf47 | ||
|
|
7f8f88c148 | ||
|
|
c2b462df3b | ||
|
|
e6c2c71d1b | ||
|
|
91fc6f6293 | ||
|
|
9a70264b51 | ||
|
|
6d612daf30 | ||
|
|
a0086554b1 | ||
|
|
5a3778a424 | ||
|
|
cb977979da | ||
|
|
64c57e9141 | ||
|
|
d8dba27fb2 | ||
|
|
d77728265b | ||
|
|
8c4acb82fe | ||
|
|
fba8cfc215 | ||
|
|
299b72483c | ||
|
|
c512cb77fb | ||
|
|
ca4cc72eea | ||
|
|
77e578085a | ||
|
|
06cdf4141f | ||
|
|
5207d14daa | ||
|
|
09831dbb7f | ||
|
|
a02b6c3ec8 | ||
|
|
4aea2be103 | ||
|
|
0cdc6246c4 | ||
|
|
1625907380 | ||
|
|
899bf3afa5 | ||
|
|
20fcf06f51 | ||
|
|
ca88ee7a0b | ||
|
|
15de81dd2c | ||
|
|
b8ba9e4626 | ||
|
|
7eeed85c96 | ||
|
|
a694570dd1 | ||
|
|
f22d14e439 | ||
|
|
4552f2e703 |
No files matched your search
+11
@@ -59,3 +59,14 @@ frontend/e2e/*.png
|
|||||||
backup/
|
backup/
|
||||||
.claude
|
.claude
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
# 运行期上传物(公司 logo 等):与构建产物隔离,不入版本库
|
||||||
|
backend/uploads/
|
||||||
|
# 数据库备份导出目录(⑨ 备份脚本产物)
|
||||||
|
db/backups/
|
||||||
|
|
||||||
|
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
|
||||||
|
docs/**/*.rar
|
||||||
|
docs/T_12.3数据字典/
|
||||||
|
# macOS 压缩包残留元数据
|
||||||
|
__MACOSX/
|
||||||
@@ -71,6 +71,41 @@ app/
|
|||||||
| GET | `/api/v1/system/operation-logs/{id}` | 操作日志详情(含已脱敏的请求参数,展开行懒加载) |
|
| GET | `/api/v1/system/operation-logs/{id}` | 操作日志详情(含已脱敏的请求参数,展开行懒加载) |
|
||||||
| GET | `/api/v1/system/login-logs` | 登录日志查询(用户名/IP/结果/时间范围) |
|
| GET | `/api/v1/system/login-logs` | 登录日志查询(用户名/IP/结果/时间范围) |
|
||||||
|
|
||||||
|
### 基础数据接口(需求 2.1,登录即可 —— 业务操作不要求管理员)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET / POST | `/api/v1/base/personnel` | 人员列表(姓名模糊/状态/岗位筛选,附岗位与经理名)/ 新增(岗位须启用;经理非自己且在职) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/base/personnel/{id}` | 详情(附区域与计划)/ 编辑 / 删除(被团队成员或客户归属引用 → 400 引导改离职) |
|
||||||
|
| PUT | `/api/v1/base/personnel/{id}/status` | 在职 / 离职切换 |
|
||||||
|
| PUT | `/api/v1/base/personnel/{id}/regions` | 辐射区域全量替换(空列表即清空) |
|
||||||
|
| PUT | `/api/v1/base/personnel/{id}/plans` | 市场拓展计划全量替换 |
|
||||||
|
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
|
||||||
|
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
|
||||||
|
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
|
||||||
|
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
|
||||||
|
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
|
||||||
|
|
||||||
|
### 业务数据接口(需求 2.2,登录即可)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET / POST | `/api/v1/business/businesses` | 业务列表(编号/公司名模糊、状态/客户/归属/日期筛选)/ 创建(编号 `YW{日期}-{序号}` 自动生成;明细 ≥1 行;销售额服务层固化) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/business/businesses/{id}` | 详情(附明细)/ 编辑(明细全量替换 + 五项汇总重算;**归属不在此端点**)/ 删除(有发货或回款 → 400 引导改已终止) |
|
||||||
|
| PUT | `/api/v1/business/businesses/{id}/status` | 状态流转(1→2/3;终态不可再变更;终止必填原因;写状态留痕) |
|
||||||
|
| PUT | `/api/v1/business/businesses/{id}/ownership` | 负责/协助归属变更(同事务写留痕,owner_type=2) |
|
||||||
|
| GET | `/api/v1/business/businesses/{id}/ownership-logs` | 业务归属变更留痕分页 |
|
||||||
|
| GET | `/api/v1/business/businesses/{id}/status-logs` | 状态流转时间线(创建写首条 old=new=1) |
|
||||||
|
| GET | `/api/v1/business/businesses/{id}/gross-profit` | **毛利润数据视图**(只读实时计算,不落库:行级率/折算额 + 类别汇总;不含运费 #2;阈值按 `as_of` 取参数快照;成本缺失行标注无法计算) |
|
||||||
|
| GET / POST | `/api/v1/business/shipments` | 发货单列表 / 创建(编号 `SH…`;业务须执行中;行金额服务层固化;业务级发货/运费汇总回写) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/business/shipments/{id}` | 详情 / 编辑(终态业务禁改)/ 删除(被回款批次指定 → 400) |
|
||||||
|
| GET / POST | `/api/v1/business/receipts` | 回款列表(含 **payment_days 派生**:发货日首日=第 1 天 #1)/ 新增(编号 `HK…`;**shipment_id 同业务强校验**;业务须执行中) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/business/receipts/{id}` | 详情 / 编辑 / 删除(**已核算回款禁改禁删**——M3 防重锚点) |
|
||||||
|
| GET / POST | `/api/v1/business/rating-grades` | 评级等级全量列表(sort_order 升序)/ 新增(区间值域 + **与既有等级重叠 → 400**) |
|
||||||
|
| PUT / DELETE | `/api/v1/business/rating-grades/{id}` | 编辑(重叠判定)/ 删除(无下游引用,允许清空) |
|
||||||
|
|
||||||
全部写操作自动写入操作日志(成功与失败都记,机制见下)。
|
全部写操作自动写入操作日志(成功与失败都记,机制见下)。
|
||||||
|
|
||||||
统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
|
统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
|
||||||
@@ -79,6 +114,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
|
||||||
@@ -154,6 +214,39 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
|
|||||||
|
|
||||||
**角色管理保护规则**(后端是唯一防线,前端按钮禁用只是体验):admin 角色禁删禁停;有用户绑定的角色禁删(`user_roles` 外键是 CASCADE;删除前走 `SELECT ... FOR UPDATE` 锁行,堵住并发绑定窗口);`role_code` 创建后不可改;**停用角色不可再分配**(`_resolve_roles` 校验 status,停用只影响新分配、不影响既有授权)。
|
**角色管理保护规则**(后端是唯一防线,前端按钮禁用只是体验):admin 角色禁删禁停;有用户绑定的角色禁删(`user_roles` 外键是 CASCADE;删除前走 `SELECT ... FOR UPDATE` 锁行,堵住并发绑定窗口);`role_code` 创建后不可改;**停用角色不可再分配**(`_resolve_roles` 校验 status,停用只影响新分配、不影响既有授权)。
|
||||||
|
|
||||||
|
### M2 业务数据的四个关键约定(2026-10-13 定稿,改动前先读)
|
||||||
|
|
||||||
|
- **单据编号**(`services/no_generator.py`):`{前缀}{YYYYMMDD}-{当日4位序号}`,业务 YW / 发货 SH / 回款 HK(`core/constants.py` 的 `NO_PREFIX_*`)。`SELECT MAX ... FOR UPDATE` 锁当日区间防并发重号;残余竞态以唯一键 1062 冲突**重试一次**兜底(无限重试会掩盖死锁)。序号超 4 位按实际位数输出(VARCHAR(32) 余量足够)。
|
||||||
|
- **五项汇总冗余**(`businesses.total_*`):明细/发货/回款的任何写路径统一收口到 `business_service._recalc_totals` 单点重算,禁止各处手写 SUM 回写;发货与回款服务写操作前**必须 `get_by_id(for_update=True)` 锁业务行**(防两模块并发写同一业务的丢失更新)。毛利润率/折算额**不落库**——它们是明细金额 + 时点化阈值的派生值,落库会在成本调整或参数改版后漂移;视图接口(`/gross-profit`)实时算,M3 核算台账才是法定固化点。
|
||||||
|
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
|
||||||
|
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
|
||||||
|
|
||||||
|
### 工作台口径(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` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
|
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
|
||||||
|
|||||||
@@ -0,0 +1,632 @@
|
|||||||
|
"""V7 核算台账/月结/调整单四表与调整单开关种子
|
||||||
|
|
||||||
|
Revision ID: 3bca400421c6
|
||||||
|
Revises: 79bcc5aa98d4
|
||||||
|
Create Date: 2026-10-16 15:54:53.000890
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "3bca400421c6"
|
||||||
|
down_revision: str | Sequence[str] | None = "79bcc5aa98d4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移。"""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"monthly_settlements",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"settlement_month",
|
||||||
|
sa.String(length=7),
|
||||||
|
nullable=False,
|
||||||
|
comment="结算月份(YYYY-MM;全表唯一)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_performance",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="绩效金额合计(可负)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_payable",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="应付绩效合计(可负)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"person_count", mysql.TINYINT(), server_default="0", nullable=False, comment="结算人数"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"snapshot_json",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"approval_order_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"submitter_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="提交人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"submitter_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="提交人用户名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column("locked_at", sa.DateTime(), nullable=True, comment="锁定时间(status=3 时回填)"),
|
||||||
|
sa.Column(
|
||||||
|
"locked_by", sa.String(length=64), nullable=True, comment="锁定操作人(冗余固化)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["submitter_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_monthly_settlements_submitter_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_monthly_settlements")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"settlement_month", name=op.f("uk_monthly_settlements_settlement_month")
|
||||||
|
),
|
||||||
|
comment="月度结算表(按月一行;通过即锁定,锁定后走调整单)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"adjustment_orders",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"order_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"settlement_month",
|
||||||
|
sa.String(length=7),
|
||||||
|
nullable=False,
|
||||||
|
comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="调整人员ID(personnel.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="调整人员姓名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column("direction", mysql.TINYINT(), nullable=False, comment="方向:1补发 2扣减"),
|
||||||
|
sa.Column(
|
||||||
|
"amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="调整金额(恒正,方向表达语义)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"reason",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=False,
|
||||||
|
comment="扣补说明(必填,审批与追溯锚点)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="状态:1待审批 2已生效 3已驳回 4已撤回",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"submitter_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="提交人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"submitter_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="提交人用户名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_adjustment_orders_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["submitter_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_adjustment_orders_submitter_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_adjustment_orders")),
|
||||||
|
sa.UniqueConstraint("order_no", name=op.f("uk_adjustment_orders_order_no")),
|
||||||
|
comment="调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"settlement_items",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"settlement_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="月结ID(monthly_settlements.id;头删明细随删)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="结算人员ID(personnel.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="结算人员姓名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"role_code", sa.String(length=32), nullable=False, comment="岗位编码(主岗,聚合锚点)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"calc_type",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
nullable=False,
|
||||||
|
comment="核算类型:1单笔回款 2经理团队聚合",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"performance_sum",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"payable_sum",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="应付绩效合计(可负)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"basic_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="月工资标准(personnel.salary_standard 当月快照)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"salary_mode",
|
||||||
|
sa.String(length=16),
|
||||||
|
nullable=True,
|
||||||
|
comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"net_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="净工资 = 绩效±基本工资(按口径;试用期 0)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"is_probation",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"probation_note",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
comment="试用期说明(年累计超阈值部分年底一次性结算)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"snapshot_json", sa.JSON(), nullable=True, comment="个人明细快照(台账行引用列表)"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_settlement_items_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["settlement_id"],
|
||||||
|
["monthly_settlements.id"],
|
||||||
|
name=op.f("fk_settlement_items_settlement_id"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_settlement_items")),
|
||||||
|
comment="月结明细表(按人固化;支持负数工资;锁定后只读)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"performance_ledgers",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"receipt_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="回款ID(receipts.id;聚合行可为 NULL)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"calc_version",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="核算版本(重算+1,旧行 is_active=0)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"is_active",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"calc_type",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="核算类型:1单笔回款 2经理团队聚合(月度)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务编号(冗余固化,列表免联查)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="归属营销人员ID(personnel.id;核算归属锚点)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="归属营销人员姓名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"role_code",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="岗位编码(business_roles.role_code,计提公式锚点)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"customer_type",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="客户分类:personal/manager_personal/enterprise",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_category",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)",
|
||||||
|
),
|
||||||
|
sa.Column("ship_date", sa.Date(), nullable=True, comment="发货日期(回款天数基准)"),
|
||||||
|
sa.Column(
|
||||||
|
"receipt_date",
|
||||||
|
sa.Date(),
|
||||||
|
nullable=False,
|
||||||
|
comment="回款日期(参数版本匹配日,裁决 #11)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"payment_days",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=True,
|
||||||
|
comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"rate_text",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"converted_profit",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="折算毛利润额(可负;混合业务为各行汇总)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"performance_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="绩效金额(可负,裁决:负绩效照算不归零)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"barter_applied",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="以物抵顶折算:1已按 80% 折算 0否",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"weight_r",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=True,
|
||||||
|
comment="支付权重 R(回款占销售额百分比向下取整)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"payment_weight",
|
||||||
|
sa.Numeric(precision=7, scale=6),
|
||||||
|
nullable=True,
|
||||||
|
comment="支付权重系数 R×R(展示用)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"payable_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"settlement_month",
|
||||||
|
sa.String(length=7),
|
||||||
|
nullable=False,
|
||||||
|
comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"is_probation",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="试用期:1照算不发放 0否",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"missing_cost",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"snapshot_json",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
comment="计算快照(所用参数版本与行级折算明细,重算可回溯)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"settlement_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="所属月结ID(月结生成时回填;NULL=未入结)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"calc_reason",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
comment="核算/重算原因(重算必填留痕)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_performance_ledgers_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["receipt_id"],
|
||||||
|
["receipts.id"],
|
||||||
|
name=op.f("fk_performance_ledgers_receipt_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["settlement_id"],
|
||||||
|
["monthly_settlements.id"],
|
||||||
|
name=op.f("fk_performance_ledgers_settlement_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_performance_ledgers")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"receipt_id", "calc_version", name="uk_performance_ledgers_receipt_id_calc_version"
|
||||||
|
),
|
||||||
|
comment="绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_performance_ledgers_business_id", "performance_ledgers", ["business_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_performance_ledgers_month_active",
|
||||||
|
"performance_ledgers",
|
||||||
|
["settlement_month", "is_active"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_performance_ledgers_month_personnel_type",
|
||||||
|
"performance_ledgers",
|
||||||
|
["settlement_month", "personnel_id", "calc_type"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_performance_ledgers_personnel_id",
|
||||||
|
"performance_ledgers",
|
||||||
|
["personnel_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"approval_orders",
|
||||||
|
"order_type",
|
||||||
|
existing_type=mysql.TINYINT(),
|
||||||
|
comment="审批对象类型:1二次分配方案 2月度结算 3调整单",
|
||||||
|
existing_comment="审批对象类型:1二次分配方案 2月度结算(预留)",
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
# ---- 种子:调整单审批开关(V6 写法先例;definitions id 19 / values id 22)----
|
||||||
|
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": 19,
|
||||||
|
"param_code": "approval.adjustment.enabled",
|
||||||
|
"param_name": "调整单审批开关",
|
||||||
|
"param_group": "审批开关",
|
||||||
|
"value_type": "BOOL",
|
||||||
|
"unit": None,
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "0",
|
||||||
|
"value_min": None,
|
||||||
|
"value_max": None,
|
||||||
|
"description": (
|
||||||
|
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
|
||||||
|
"调整单是已锁定月份补发/扣减的唯一通道"
|
||||||
|
),
|
||||||
|
"sort_order": 19,
|
||||||
|
"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": 22,
|
||||||
|
"param_id": 19,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "0",
|
||||||
|
"batch_id": 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移。
|
||||||
|
|
||||||
|
整表回滚只保留 `op.drop_table`(V1/V3/V6 先例:外键唯一支撑索引单独
|
||||||
|
drop 会报 MySQL 1553)。行级种子(param_definitions 19 / param_values 22)
|
||||||
|
的宿主表由 V3 建、本迁移只插行,须显式 DELETE 否则重放 upgrade 撞唯一键。
|
||||||
|
删除顺序:先删引用方(ledger/items),再删被引用方(头表/调整单)。
|
||||||
|
"""
|
||||||
|
op.drop_table("performance_ledgers")
|
||||||
|
op.drop_table("settlement_items")
|
||||||
|
op.drop_table("adjustment_orders")
|
||||||
|
op.drop_table("monthly_settlements")
|
||||||
|
op.alter_column(
|
||||||
|
"approval_orders",
|
||||||
|
"order_type",
|
||||||
|
existing_type=mysql.TINYINT(),
|
||||||
|
comment="审批对象类型:1二次分配方案 2月度结算(预留)",
|
||||||
|
existing_comment="审批对象类型:1二次分配方案 2月度结算 3调整单",
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
op.execute("DELETE FROM param_values WHERE id = 22")
|
||||||
|
op.execute("DELETE FROM param_definitions WHERE id = 19")
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
"""V6 系统设置/二次分配/审批表与角色参数种子
|
||||||
|
|
||||||
|
Revision ID: 79bcc5aa98d4
|
||||||
|
Revises: 8efc189b131f
|
||||||
|
Create Date: 2026-10-15 15:42:13.467447
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "79bcc5aa98d4"
|
||||||
|
down_revision: str | Sequence[str] | None = "8efc189b131f"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移。"""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"company_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="公司名称(登录页与导出页眉展示)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"logo_path",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_system_settings")),
|
||||||
|
comment="系统设置表(公司信息,全系统单行)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"approval_orders",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"order_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"order_type",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
nullable=False,
|
||||||
|
comment="审批对象类型:1二次分配方案 2月度结算(预留)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"ref_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"refer_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="引用对象单号(冗余固化,列表页免联查)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"submitter_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="提交人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"submitter_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="提交人用户名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["submitter_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_approval_orders_submitter_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_orders")),
|
||||||
|
sa.UniqueConstraint("order_no", name=op.f("uk_approval_orders_order_no")),
|
||||||
|
comment="审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_approval_orders_type_status", "approval_orders", ["order_type", "status"], unique=False
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"approval_steps",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"order_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="审批单ID(approval_orders.id;单删记录随删)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"node",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
nullable=False,
|
||||||
|
comment="审批节点:0撤回留痕 1财务复核 2领导审核",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"action", mysql.TINYINT(), nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"opinion", sa.String(length=255), nullable=True, comment="审批意见(驳回时填写)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"operator_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="操作人ID(自动通过/系统动作置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"operator_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="操作人用户名(冗余固化;系统动作记「系统」)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["operator_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_approval_steps_operator_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["order_id"],
|
||||||
|
["approval_orders.id"],
|
||||||
|
name=op.f("fk_approval_steps_order_id"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_steps")),
|
||||||
|
comment="审批节点记录表(append-only;自动通过/撤回均有留痕)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_approval_steps_order_id", "approval_steps", ["order_id"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"business_secondary_allocations",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id;业务删除随删)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="参与业务员ID(personnel.id;被方案引用禁删)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="参与业务员姓名(冗余固化,离职后回显有名)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"ratio",
|
||||||
|
sa.Numeric(precision=5, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["business_id"],
|
||||||
|
["businesses.id"],
|
||||||
|
name=op.f("fk_business_secondary_allocations_business_id"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_business_secondary_allocations_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_secondary_allocations")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"business_id",
|
||||||
|
"personnel_id",
|
||||||
|
name="uk_business_secondary_allocations_business_personnel",
|
||||||
|
),
|
||||||
|
comment="业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_secondary_allocations_business_id",
|
||||||
|
"business_secondary_allocations",
|
||||||
|
["business_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_secondary_allocations_personnel_id",
|
||||||
|
"business_secondary_allocations",
|
||||||
|
["personnel_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
# ---- 种子数据(生产与开发同源;写法沿用 V1/V3 迁移先例)----
|
||||||
|
|
||||||
|
# 三个审批职能登录角色(2026-10-15 复核审批补充需求;roles 表无 is_builtin
|
||||||
|
# 列,内置保护由 role_service 的 BUILTIN_ROLE_CODES 白名单承担)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"roles",
|
||||||
|
sa.column("role_code", sa.String),
|
||||||
|
sa.column("role_name", sa.String),
|
||||||
|
sa.column("description", sa.String),
|
||||||
|
sa.column("sort_order", sa.Integer),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role_code": "marketing_ops",
|
||||||
|
"role_name": "营销后勤",
|
||||||
|
"description": "提交与录入(审批链第一节点:月结/二次分配提交)",
|
||||||
|
"sort_order": 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_code": "finance",
|
||||||
|
"role_name": "财务",
|
||||||
|
"description": "复核(审批链第二节点)",
|
||||||
|
"sort_order": 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_code": "leader",
|
||||||
|
"role_name": "领导",
|
||||||
|
"description": "审核(审批链第三节点;全览只读)",
|
||||||
|
"sort_order": 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 审批开关参数定义(param_definitions id 沿用 V3 种子的显式 id 方案接续 17/18)
|
||||||
|
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": 17,
|
||||||
|
"param_code": "approval.secondary_allocation.enabled",
|
||||||
|
"param_name": "二次分配审批开关",
|
||||||
|
"param_group": "审批开关",
|
||||||
|
"value_type": "BOOL",
|
||||||
|
"unit": None,
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "0",
|
||||||
|
"value_min": None,
|
||||||
|
"value_max": None,
|
||||||
|
"description": (
|
||||||
|
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
|
||||||
|
"1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)"
|
||||||
|
),
|
||||||
|
"sort_order": 17,
|
||||||
|
"status": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"param_code": "approval.monthly_settlement.enabled",
|
||||||
|
"param_name": "月度结算审批开关",
|
||||||
|
"param_group": "审批开关",
|
||||||
|
"value_type": "BOOL",
|
||||||
|
"unit": None,
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "0",
|
||||||
|
"value_min": None,
|
||||||
|
"value_max": None,
|
||||||
|
"description": (
|
||||||
|
"预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);"
|
||||||
|
"0=结算生成即生效,1=走财务复核→领导审核审批流"
|
||||||
|
),
|
||||||
|
"sort_order": 18,
|
||||||
|
"status": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 审批开关联动批次条目(batch_id=1 即 V3 的「初始版本」批次;BOOL 无维度行,
|
||||||
|
# 生效日期 2026-01-01 对齐其他支付环节种子)
|
||||||
|
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": 20,
|
||||||
|
"param_id": 17,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "0",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"param_id": 18,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "0",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 公司信息单行(system_settings 恒为一行,id=1 由迁移保底)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"system_settings",
|
||||||
|
sa.column("id", sa.BigInteger),
|
||||||
|
sa.column("company_name", sa.String),
|
||||||
|
sa.column("logo_path", sa.String),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{"id": 1, "company_name": "衍水集团", "logo_path": None},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移。
|
||||||
|
|
||||||
|
整表回滚只保留 `op.drop_table` —— DROP TABLE 会连带删除索引与外键。
|
||||||
|
autogenerate 原样输出的逐个 `op.drop_index` 会失败:idx_approval_steps_order_id
|
||||||
|
与 idx_business_secondary_allocations_personnel_id 分别是外键的唯一支撑索引,
|
||||||
|
单独删除被 MySQL 拒绝(errno 1553),回滚会中断在半途。
|
||||||
|
删除顺序:先删引用方(approval_steps/二次分配),再删被引用方。
|
||||||
|
行级种子反向清理:roles/param_definitions/param_values 三表由 V1/V3 建、
|
||||||
|
本迁移只插行(roles 三角色、参数 17/18 及其版本条目),drop_table
|
||||||
|
不会带走,须显式 DELETE,否则重放 upgrade 时唯一键冲突。
|
||||||
|
"""
|
||||||
|
op.drop_table("business_secondary_allocations")
|
||||||
|
op.drop_table("approval_steps")
|
||||||
|
op.drop_table("approval_orders")
|
||||||
|
op.drop_table("system_settings")
|
||||||
|
op.execute("DELETE FROM param_values WHERE id IN (20, 21)")
|
||||||
|
op.execute("DELETE FROM param_definitions WHERE id IN (17, 18)")
|
||||||
|
op.execute("DELETE FROM roles WHERE role_code IN ('marketing_ops', 'finance', 'leader')")
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
"""业务数据管理七表:业务/业务明细/状态留痕/发货单/发货明细/回款/评级等级配置(M2)
|
||||||
|
|
||||||
|
- businesses:业务编号自动生成(报表 2.4.1「合同编号」),五项汇总冗余列
|
||||||
|
由服务层事务内维护;毛利润相关值不落库(派生值,M3 台账才是固化点)
|
||||||
|
- business_items:行级 product_category 为折算锚点(对照表 #14),指导价
|
||||||
|
人工录入、T+ 同步不覆盖
|
||||||
|
- business_status_logs:append-only,创建时写首条(old=new=1)
|
||||||
|
- shipments/shipment_items:发货日为回款天数基准日(对照表 #1)
|
||||||
|
- receipts:手动指定发货批次(#18);settlement_status 为 M3 核算防重锚点
|
||||||
|
- customer_rating_grades:等级映射可配置,种子四条随迁移初始化
|
||||||
|
|
||||||
|
Revision ID: 8efc189b131f
|
||||||
|
Revises: e1133a7c670a
|
||||||
|
Create Date: 2026-10-13 11:14:17.545320
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "8efc189b131f"
|
||||||
|
down_revision: str | Sequence[str] | None = "e1133a7c670a"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
# 评级等级种子(与 init-dev.sql 双写一致;口径见模型 docstring)
|
||||||
|
_GRADE_SEED = [
|
||||||
|
{"grade_name": "A", "min_score": 90, "max_score": 100, "sort_order": 1, "remark": "优秀"},
|
||||||
|
{"grade_name": "B", "min_score": 75, "max_score": 89, "sort_order": 2, "remark": "良好"},
|
||||||
|
{"grade_name": "C", "min_score": 60, "max_score": 74, "sort_order": 3, "remark": "合格"},
|
||||||
|
{"grade_name": "D", "min_score": 0, "max_score": 59, "sort_order": 4, "remark": "待改进"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移。"""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"customer_rating_grades",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"grade_name", sa.String(length=32), nullable=False, comment="等级名称(如 A/优秀)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"min_score",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
nullable=False,
|
||||||
|
comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"max_score",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
nullable=False,
|
||||||
|
comment="总分上限(展示用)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sort_order",
|
||||||
|
sa.Integer(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="排序(判定顺序,升序)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="说明"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_rating_grades")),
|
||||||
|
sa.UniqueConstraint("grade_name", name=op.f("uk_customer_rating_grades_grade_name")),
|
||||||
|
comment="客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"business_status_logs",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(固化,无外键;删除保护由服务层承担)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"old_status", mysql.TINYINT(), nullable=False, comment="变更前状态(创建首条为 1)"
|
||||||
|
),
|
||||||
|
sa.Column("new_status", mysql.TINYINT(), nullable=False, comment="变更后状态"),
|
||||||
|
sa.Column(
|
||||||
|
"change_reason", sa.String(length=255), nullable=True, comment="变更原因(终止时必填)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"operator_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"operator_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="操作人用户名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["operator_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_business_status_logs_operator_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_status_logs")),
|
||||||
|
comment="业务状态变更记录表(append-only)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_status_logs_business_id",
|
||||||
|
"business_status_logs",
|
||||||
|
["business_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_status_logs_create_time",
|
||||||
|
"business_status_logs",
|
||||||
|
["create_time"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"businesses",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"customer_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="客户ID(customers.id)",
|
||||||
|
),
|
||||||
|
sa.Column("business_date", sa.Date(), nullable=True, comment="业务日期(T+ 同步口径)"),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"owner_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"assistant_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"is_special",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="特殊业务标记:1是 0否(公司参与等)",
|
||||||
|
),
|
||||||
|
sa.Column("special_note", sa.String(length=255), nullable=True, comment="特殊业务说明"),
|
||||||
|
sa.Column(
|
||||||
|
"total_sale_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_cost_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_ship_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_received_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_freight_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["assistant_personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_businesses_assistant_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["customer_id"],
|
||||||
|
["customers.id"],
|
||||||
|
name=op.f("fk_businesses_customer_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["owner_personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_businesses_owner_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_businesses")),
|
||||||
|
sa.UniqueConstraint("business_no", name=op.f("uk_businesses_business_no")),
|
||||||
|
comment="业务表(业务编号自动生成;T+同步不覆盖业务状态)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_businesses_business_date", "businesses", ["business_date"], unique=False)
|
||||||
|
op.create_index("idx_businesses_customer_id", "businesses", ["customer_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"idx_businesses_owner_personnel_id", "businesses", ["owner_personnel_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(op.f("idx_businesses_source_no"), "businesses", ["source_no"], unique=False)
|
||||||
|
op.create_index("idx_businesses_status", "businesses", ["status"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"business_items",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="产品ID(products.id;NULL=自由录入,未入产品库)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_name",
|
||||||
|
sa.String(length=128),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品名称(引用产品时冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"spec_model",
|
||||||
|
sa.String(length=128),
|
||||||
|
server_default="",
|
||||||
|
nullable=False,
|
||||||
|
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
|
||||||
|
),
|
||||||
|
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
|
||||||
|
sa.Column(
|
||||||
|
"product_category",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="数量(>0)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sale_price",
|
||||||
|
sa.Numeric(precision=14, scale=4),
|
||||||
|
nullable=True,
|
||||||
|
comment="销售单价(元;T+ 同步或人工)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sale_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"cost_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"guided_price",
|
||||||
|
sa.Numeric(precision=14, scale=4),
|
||||||
|
nullable=True,
|
||||||
|
comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["business_id"],
|
||||||
|
["businesses.id"],
|
||||||
|
name=op.f("fk_business_items_business_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["product_id"],
|
||||||
|
["products.id"],
|
||||||
|
name=op.f("fk_business_items_product_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_items")),
|
||||||
|
comment="业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_items_business_id", "business_items", ["business_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_items_product_category", "business_items", ["product_category"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index("idx_business_items_product_id", "business_items", ["product_id"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"shipments",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"shipment_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id;终态业务禁止新发货)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"ship_date",
|
||||||
|
sa.Date(),
|
||||||
|
nullable=False,
|
||||||
|
comment="发货日期(回款天数基准日,发货日首日=第 1 天)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"freight_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["business_id"],
|
||||||
|
["businesses.id"],
|
||||||
|
name=op.f("fk_shipments_business_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_shipments")),
|
||||||
|
sa.UniqueConstraint("shipment_no", name=op.f("uk_shipments_shipment_no")),
|
||||||
|
comment="发货单表(发货日为回款天数基准日,发货日首日计第 1 天)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_shipments_business_id", "shipments", ["business_id"], unique=False)
|
||||||
|
op.create_index("idx_shipments_ship_date", "shipments", ["ship_date"], unique=False)
|
||||||
|
op.create_index(op.f("idx_shipments_source_no"), "shipments", ["source_no"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"receipts",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"receipt_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"shipment_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"receipt_date",
|
||||||
|
sa.Date(),
|
||||||
|
nullable=False,
|
||||||
|
comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="回款金额(≥0;一期禁负数/红字)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"is_barter",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"settlement_status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["business_id"],
|
||||||
|
["businesses.id"],
|
||||||
|
name=op.f("fk_receipts_business_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["shipment_id"],
|
||||||
|
["shipments.id"],
|
||||||
|
name=op.f("fk_receipts_shipment_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_receipts")),
|
||||||
|
sa.UniqueConstraint("receipt_batch_no", name=op.f("uk_receipts_receipt_batch_no")),
|
||||||
|
comment="回款表(手动指定发货批次;已核算回款禁改禁删)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_receipts_business_id", "receipts", ["business_id"], unique=False)
|
||||||
|
op.create_index("idx_receipts_receipt_date", "receipts", ["receipt_date"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"idx_receipts_settlement_status", "receipts", ["settlement_status"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index("idx_receipts_shipment_id", "receipts", ["shipment_id"], unique=False)
|
||||||
|
op.create_index(op.f("idx_receipts_source_no"), "receipts", ["source_no"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"shipment_items",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"shipment_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="发货单ID(shipments.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="产品ID(products.id;NULL=自由录入)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_name",
|
||||||
|
sa.String(length=128),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品名称(引用产品时冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"spec_model",
|
||||||
|
sa.String(length=128),
|
||||||
|
server_default="",
|
||||||
|
nullable=False,
|
||||||
|
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
|
||||||
|
),
|
||||||
|
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
|
||||||
|
sa.Column(
|
||||||
|
"quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="发货数量(>0)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sale_price",
|
||||||
|
sa.Numeric(precision=14, scale=4),
|
||||||
|
nullable=True,
|
||||||
|
comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["product_id"],
|
||||||
|
["products.id"],
|
||||||
|
name=op.f("fk_shipment_items_product_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["shipment_id"],
|
||||||
|
["shipments.id"],
|
||||||
|
name=op.f("fk_shipment_items_shipment_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_shipment_items")),
|
||||||
|
comment="发货产品明细表",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_shipment_items_product_id", "shipment_items", ["product_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"idx_shipment_items_shipment_id", "shipment_items", ["shipment_id"], unique=False
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
# 评级等级种子(等级映射可配置;双轨:init-dev.sql 同步写入)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"customer_rating_grades",
|
||||||
|
sa.column("grade_name", sa.String),
|
||||||
|
sa.column("min_score", sa.Numeric),
|
||||||
|
sa.column("max_score", sa.Numeric),
|
||||||
|
sa.column("sort_order", sa.Integer),
|
||||||
|
sa.column("remark", sa.String),
|
||||||
|
),
|
||||||
|
_GRADE_SEED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移。
|
||||||
|
|
||||||
|
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
|
||||||
|
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
|
||||||
|
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
|
||||||
|
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
|
||||||
|
"""
|
||||||
|
# 整表回滚只保留 drop_table(DROP TABLE 连带删除索引与外键);
|
||||||
|
# 逐条 drop_index 会先于 drop_table 摘掉外键支撑索引而报 MySQL 1553
|
||||||
|
op.drop_table("shipment_items")
|
||||||
|
op.drop_table("receipts")
|
||||||
|
op.drop_table("shipments")
|
||||||
|
op.drop_table("business_items")
|
||||||
|
op.drop_table("businesses")
|
||||||
|
op.drop_table("business_status_logs")
|
||||||
|
op.drop_table("customer_rating_grades")
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""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 @@
|
|||||||
|
"""V9:business_secondary_allocations 唯一键补齐(存量库自愈)。
|
||||||
|
|
||||||
|
背景:uk(business_id, personnel_id) 是随「就地修改已发布的 V6 迁移」加入的
|
||||||
|
(V6 建表语句里已含、硬编码名);**旧 V6 已应用的存量库 upgrade 到当前
|
||||||
|
head 时不会重放 V6**,拿不到该约束,V8 也未补加——两条升级路径的库结构
|
||||||
|
漂移(test_schema_sync 只比对表/列集合,约束级漂移拦不住)。
|
||||||
|
|
||||||
|
本迁移是**幂等条件式**:约束已存在(新库 / init-dev 重置库 / 已重建的
|
||||||
|
开发库)时跳过,不存在(旧 V6 升级库)时补加,避免重复约束名 errno 1826。
|
||||||
|
语句写成裸 SQL 而非 op.create_unique_constraint——后者在 --sql 离线导出
|
||||||
|
时会无条件输出 DDL,无法携带存在性判断。
|
||||||
|
|
||||||
|
注意:补键时若表内已存在重复 (business_id, personnel_id) 数据,ADD
|
||||||
|
CONSTRAINT 会失败(有意如此:先由人工清理重复数据再升级,不做静默去重)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "ba0217f5091b"
|
||||||
|
down_revision: str | None = "c7d2e5a81f30"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_UK_NAME = "uk_business_secondary_allocations_business_personnel"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""条件式补唯一键(见模块 docstring 的背景与幂等说明)。"""
|
||||||
|
op.execute(
|
||||||
|
"SET @uk_exists := ("
|
||||||
|
"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
|
||||||
|
"WHERE CONSTRAINT_SCHEMA = DATABASE() "
|
||||||
|
"AND TABLE_NAME = 'business_secondary_allocations' "
|
||||||
|
f"AND CONSTRAINT_NAME = '{_UK_NAME}'"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"SET @ddl := IF(@uk_exists = 0, "
|
||||||
|
f"'ALTER TABLE business_secondary_allocations ADD CONSTRAINT {_UK_NAME} "
|
||||||
|
"UNIQUE (business_id, personnel_id)', "
|
||||||
|
"'DO 0')"
|
||||||
|
)
|
||||||
|
op.execute("PREPARE stmt FROM @ddl")
|
||||||
|
op.execute("EXECUTE stmt")
|
||||||
|
op.execute("DEALLOCATE PREPARE stmt")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""有意不撤键:新库由 V6 建表即带该约束、并非本迁移首创,撤掉会让
|
||||||
|
降级路径产生新的漂移;确需撤销请人工评估后执行 DROP INDEX。
|
||||||
|
"""
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""V11 客户评级落库与站点信息
|
||||||
|
|
||||||
|
Revision ID: c4e2a86f9b13
|
||||||
|
Revises: 90653814a421
|
||||||
|
Create Date: 2026-10-19 10:00:00.000000
|
||||||
|
|
||||||
|
与上游 V7–V10(台账/月结/调整单/口径/唯一键/预警参数)合并时重编号为 V11:
|
||||||
|
本分支三个迁移(原 V7 客户评级 + V8 公司档案精简 + V9 图片版本号)从未发布,
|
||||||
|
压成单条,最终结构 = 评级表 + 最终版站点/公司信息字段(不含中途增删的
|
||||||
|
副标题/信用代码/地址/电话/邮箱)。
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c4e2a86f9b13"
|
||||||
|
down_revision: str | Sequence[str] | None = "90653814a421"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移(双主题一次交付)。
|
||||||
|
|
||||||
|
- 客户评级弹窗化:评分每客户落库(customer_ratings,一行一客户快照)
|
||||||
|
- 公司信息补充与精简:标语/页脚/备案号/站点标题/站点图标 + 图片版本号
|
||||||
|
"""
|
||||||
|
op.create_table(
|
||||||
|
"customer_ratings",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"customer_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="客户ID(唯一:一客户一份当前评级)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"score_ship_amount",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="发货额评分(0-20)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"score_receipt_rate",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="回款率评分(0-20)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"score_profit_rate",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="毛利率评分(0-20)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"score_contract_cooperation",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="合同履约配合能力评分(0-20)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"score_site_construction",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="现场规范施工能力评分(0-20)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_score",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
nullable=False,
|
||||||
|
comment="总分(服务端重算,五项之和 0-100)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"grade_name",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"scorer_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="评级操作人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"scorer_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="评级操作人用户名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["customer_id"],
|
||||||
|
["customers.id"],
|
||||||
|
name=op.f("fk_customer_ratings_customer_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["scorer_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_customer_ratings_scorer_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_ratings")),
|
||||||
|
sa.UniqueConstraint("customer_id", name=op.f("uk_customer_ratings_customer_id")),
|
||||||
|
comment="客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"site_title",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"brand_slogan",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="品牌标语(登录页与品牌预览;null=内置默认)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"footer_text",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"icp_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="ICP 备案号(登录页页脚,空=不显示)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"favicon_path",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
comment="站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"logo_version",
|
||||||
|
sa.Integer(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column(
|
||||||
|
"favicon_version",
|
||||||
|
sa.Integer(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移:先删评级表(引用 customers/users),再逆序移除站点信息列。"""
|
||||||
|
op.drop_table("customer_ratings")
|
||||||
|
op.drop_column("system_settings", "favicon_version")
|
||||||
|
op.drop_column("system_settings", "logo_version")
|
||||||
|
op.drop_column("system_settings", "favicon_path")
|
||||||
|
op.drop_column("system_settings", "icp_no")
|
||||||
|
op.drop_column("system_settings", "footer_text")
|
||||||
|
op.drop_column("system_settings", "brand_slogan")
|
||||||
|
op.drop_column("system_settings", "site_title")
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""V8:业务答复口径落实(2026-10-19 回执)。
|
||||||
|
|
||||||
|
四件事,全部源于《待澄清问题清单》2026-10-19 答复:
|
||||||
|
|
||||||
|
1. 审批开关默认值翻转(#5/#7 答复「默认开启」):param_definitions 的
|
||||||
|
default_value 更新为 '1';param_values **插入生效日 2026-10-19 的新行**
|
||||||
|
而非改历史行——参数体系时点化(按业务日期回溯),直接改种子行会篡改
|
||||||
|
10-19 之前的口径历史。
|
||||||
|
2. 撤销试用期统一工资参数(#16 答复「按人员档案逐人维护」):删除
|
||||||
|
probation.salary.basic 的定义与版本行;月结基本工资已直接读
|
||||||
|
personnel.salary_standard(该参数自 M3 ④ 实现起即无业务消费点)。
|
||||||
|
3. 新增经理个人户聚合基数开关(#12 答复「作为参数配置,手动调整是否
|
||||||
|
纳入」):manager.personal.included,默认 0=不计入(维持暂口径)。
|
||||||
|
4. 绩效台账唯一键加 personnel_id(二次分配分摊铺路,澄清 #1 答复
|
||||||
|
「分配绩效」):分摊后同一回款同版本按人各落一行(负责人余额行 +
|
||||||
|
参与人比例行),uk(receipt_id, calc_version) 会撞;扩为三列后聚合行
|
||||||
|
(receipt_id NULL)依旧不受 MySQL 唯一索引约束。
|
||||||
|
|
||||||
|
迁移 downgrade 只保留 drop_table/行级种子反向 DELETE(V6 教训:逐个
|
||||||
|
drop_index 会撞外键支撑索引 errno 1553)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "c7d2e5a81f30"
|
||||||
|
down_revision: str | None = "3bca400421c6"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
|
||||||
|
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
|
||||||
|
op.execute(
|
||||||
|
"UPDATE param_definitions SET default_value = '1' "
|
||||||
|
"WHERE id IN (17, 18)"
|
||||||
|
)
|
||||||
|
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
|
||||||
|
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": 23,
|
||||||
|
"param_id": 17,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 10, 19),
|
||||||
|
"value_text": "1",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 24,
|
||||||
|
"param_id": 18,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 10, 19),
|
||||||
|
"value_text": "1",
|
||||||
|
"batch_id": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 撤销试用期统一工资参数(#16:按人员档案 personnel.salary_standard 逐人维护)
|
||||||
|
op.execute("DELETE FROM param_values WHERE id = 19")
|
||||||
|
op.execute("DELETE FROM param_definitions WHERE id = 16")
|
||||||
|
|
||||||
|
# 3. 经理个人户聚合基数开关(#12:参数化,默认不计入)
|
||||||
|
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": 20,
|
||||||
|
"param_code": "manager.personal.included",
|
||||||
|
"param_name": "经理个人户计入聚合基数",
|
||||||
|
"param_group": "经理分成",
|
||||||
|
"value_type": "BOOL",
|
||||||
|
"unit": None,
|
||||||
|
"dimension_type": "NONE",
|
||||||
|
"options_text": None,
|
||||||
|
"default_value": "0",
|
||||||
|
"value_min": None,
|
||||||
|
"value_max": None,
|
||||||
|
"description": (
|
||||||
|
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
|
||||||
|
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
|
||||||
|
"0=不计入(默认,暂口径) 1=计入"
|
||||||
|
),
|
||||||
|
"sort_order": 20,
|
||||||
|
"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": 25,
|
||||||
|
"param_id": 20,
|
||||||
|
"dimension_value": "",
|
||||||
|
"effective_date": date(2026, 1, 1),
|
||||||
|
"value_text": "0",
|
||||||
|
"batch_id": 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 台账唯一键扩为三列(分摊拆行:同回款同版本按人一行)
|
||||||
|
# 顺序要紧:旧 uk 是 fk_performance_ledgers_receipt_id 的唯一支撑索引,
|
||||||
|
# 先删会撞 errno 1553(外键无支撑索引);先建新键(receipt_id 仍居最左)
|
||||||
|
# 再做替换,两键短暂并存无冲突。
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uk_performance_ledgers_receipt_id_calc_version_personnel",
|
||||||
|
"performance_ledgers",
|
||||||
|
["receipt_id", "calc_version", "personnel_id"],
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"uk_performance_ledgers_receipt_id_calc_version",
|
||||||
|
"performance_ledgers",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""反向:唯一键复原 + 种子行级回退(顺序与 upgrade 相反)。
|
||||||
|
|
||||||
|
试用期参数(definitions 16 / values 19)的原始种子在 V3,按 V6 downgrade
|
||||||
|
的行级反向惯例显式重插,保证可重放。
|
||||||
|
"""
|
||||||
|
# 与 upgrade 同因的顺序:先建回旧键再删新键(旧键支撑 receipts 外键)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uk_performance_ledgers_receipt_id_calc_version",
|
||||||
|
"performance_ledgers",
|
||||||
|
["receipt_id", "calc_version"],
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"uk_performance_ledgers_receipt_id_calc_version_personnel",
|
||||||
|
"performance_ledgers",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("DELETE FROM param_values WHERE id = 25")
|
||||||
|
op.execute("DELETE FROM param_definitions WHERE id = 20")
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"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 "
|
||||||
|
"(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', "
|
||||||
|
"'元', 'NONE', NULL, '3000', '0', NULL, "
|
||||||
|
"'试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16, 1)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"INSERT INTO param_values (id, param_id, dimension_value, effective_date, "
|
||||||
|
"value_text, batch_id) VALUES (19, 16, '', '2026-01-01', '3000', 1)"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
|
||||||
|
op.execute(
|
||||||
|
"UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)"
|
||||||
|
)
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
"""基础数据管理六表:人员/辐射区域/拓展计划/产品/客户/归属变更留痕(M2)
|
||||||
|
|
||||||
|
- personnel / personnel_regions / personnel_plans:人员档案及其 1:N 子表
|
||||||
|
- products:产品主数据(类别锚定折算阈值参数)
|
||||||
|
- customers:客户档案(公司名称+负责人为业务唯一标识)
|
||||||
|
- ownership_change_logs:归属变更留痕(append-only,不回溯历史)
|
||||||
|
- 六张表的 T+ 同步预留列(source_no 等)全部可空、不设唯一键(对照表 #26)
|
||||||
|
|
||||||
|
Revision ID: e1133a7c670a
|
||||||
|
Revises: bfbda9c3191d
|
||||||
|
Create Date: 2026-10-13 09:36:02.219248
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "e1133a7c670a"
|
||||||
|
down_revision: str | Sequence[str] | None = "bfbda9c3191d"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移。"""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"products",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column("product_name", sa.String(length=128), nullable=False, comment="产品名称"),
|
||||||
|
sa.Column(
|
||||||
|
"spec_model",
|
||||||
|
sa.String(length=128),
|
||||||
|
server_default="",
|
||||||
|
nullable=False,
|
||||||
|
comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)",
|
||||||
|
),
|
||||||
|
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
|
||||||
|
sa.Column(
|
||||||
|
"stock_quantity",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="数量(T+ 同步原样存储,语义待 IT 确认)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sale_price",
|
||||||
|
sa.Numeric(precision=14, scale=4),
|
||||||
|
nullable=True,
|
||||||
|
comment="销售单价(元,T+ 同步)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_category",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="状态:1启用 0停用",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_products")),
|
||||||
|
sa.UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"),
|
||||||
|
comment="产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(op.f("idx_products_source_no"), "products", ["source_no"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"ownership_change_logs",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"owner_type", mysql.TINYINT(), nullable=False, comment="归属类型:1客户归属 2业务归属"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"customer_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="客户ID(固化,owner_type=1 时非空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="业务ID(固化,owner_type=2 时非空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"old_owner_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="变更前负责营销人员ID(固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"new_owner_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="变更后负责营销人员ID(固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"old_assistant_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="变更前协助营销人员ID(固化,仅业务归属使用)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"new_assistant_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="变更后协助营销人员ID(固化,仅业务归属使用)",
|
||||||
|
),
|
||||||
|
sa.Column("change_reason", sa.String(length=255), nullable=True, comment="变更原因"),
|
||||||
|
sa.Column(
|
||||||
|
"operator_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"operator_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="操作人用户名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["operator_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_ownership_change_logs_operator_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_ownership_change_logs")),
|
||||||
|
comment="归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_ownership_change_logs_customer_id"),
|
||||||
|
"ownership_change_logs",
|
||||||
|
["customer_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_ownership_change_logs_business_id"),
|
||||||
|
"ownership_change_logs",
|
||||||
|
["business_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_ownership_change_logs_create_time"),
|
||||||
|
"ownership_change_logs",
|
||||||
|
["create_time"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"personnel",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column("personnel_name", sa.String(length=64), nullable=False, comment="姓名"),
|
||||||
|
sa.Column(
|
||||||
|
"business_role_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"manager_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)",
|
||||||
|
),
|
||||||
|
sa.Column("phone", sa.String(length=20), nullable=True, comment="联系电话"),
|
||||||
|
sa.Column("hire_date", sa.Date(), nullable=True, comment="入职日期"),
|
||||||
|
sa.Column(
|
||||||
|
"is_probation",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="是否试用期:1是 0否(试用期绩效照算不发放)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"salary_standard",
|
||||||
|
sa.Numeric(precision=12, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="月工资标准(元;T+同步不覆盖)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["business_role_id"],
|
||||||
|
["business_roles.id"],
|
||||||
|
name=op.f("fk_personnel_business_role_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["manager_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_personnel_manager_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel")),
|
||||||
|
comment="营销人员表(T+同步不覆盖岗位与工资标准)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_personnel_business_role_id"), "personnel", ["business_role_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(op.f("idx_personnel_manager_id"), "personnel", ["manager_id"], unique=False)
|
||||||
|
op.create_index(op.f("idx_personnel_source_no"), "personnel", ["source_no"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"customers",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column("company_name", sa.String(length=128), nullable=False, comment="公司名称"),
|
||||||
|
sa.Column(
|
||||||
|
"owner_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="客户负责人姓名(客户公司负责人的人名,非系统人员)",
|
||||||
|
),
|
||||||
|
sa.Column("contact_person", sa.String(length=64), nullable=True, comment="联系人"),
|
||||||
|
sa.Column("contact_phone", sa.String(length=20), nullable=True, comment="联系电话"),
|
||||||
|
sa.Column("address", sa.String(length=255), nullable=True, comment="地址"),
|
||||||
|
sa.Column("industry", sa.String(length=64), nullable=True, comment="所属行业(自由文本)"),
|
||||||
|
sa.Column(
|
||||||
|
"customer_type",
|
||||||
|
sa.String(length=32),
|
||||||
|
server_default="personal",
|
||||||
|
nullable=False,
|
||||||
|
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"owner_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["owner_personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_customers_owner_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_customers")),
|
||||||
|
sa.UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"),
|
||||||
|
comment="客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_customers_customer_type"), "customers", ["customer_type"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_customers_owner_personnel_id"), "customers", ["owner_personnel_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(op.f("idx_customers_source_no"), "customers", ["source_no"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"personnel_plans",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="人员ID(personnel.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"plan_period",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="计划期间(如 2026Q1、2026-05)",
|
||||||
|
),
|
||||||
|
sa.Column("plan_content", sa.String(length=500), nullable=False, comment="计划内容"),
|
||||||
|
sa.Column(
|
||||||
|
"target_desc", sa.String(length=255), nullable=True, comment="指标说明(文字描述)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no", sa.String(length=64), nullable=True, comment="T+ 原始条目号(预留)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_personnel_plans_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_plans")),
|
||||||
|
comment="市场拓展计划表",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_personnel_plans_personnel_id"), "personnel_plans", ["personnel_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"personnel_regions",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="人员ID(personnel.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"region_name",
|
||||||
|
sa.String(length=128),
|
||||||
|
nullable=False,
|
||||||
|
comment="区域名称(产品辐射地理范围)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_personnel_regions_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_regions")),
|
||||||
|
comment="产品辐射区域表",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("idx_personnel_regions_personnel_id"),
|
||||||
|
"personnel_regions",
|
||||||
|
["personnel_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移。
|
||||||
|
|
||||||
|
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
|
||||||
|
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
|
||||||
|
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
|
||||||
|
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
|
||||||
|
"""
|
||||||
|
op.drop_table("personnel_regions")
|
||||||
|
op.drop_table("personnel_plans")
|
||||||
|
op.drop_table("customers")
|
||||||
|
op.drop_table("personnel")
|
||||||
|
op.drop_table("ownership_change_logs")
|
||||||
|
op.drop_table("products")
|
||||||
@@ -113,3 +113,29 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
|
|||||||
|
|
||||||
|
|
||||||
AdminUser = Annotated[User, Depends(require_admin)]
|
AdminUser = Annotated[User, Depends(require_admin)]
|
||||||
|
|
||||||
|
# 仅要求登录态、不限角色(#17 答复 2026-10-19:薪酬敏感配置「查看可、修改仅
|
||||||
|
# 管理员」——参数目录/历史/预览等只读端点用此依赖)
|
||||||
|
LoginUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
|
||||||
|
"""要求当前用户具备任一指定角色(依赖工厂)。
|
||||||
|
|
||||||
|
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
|
||||||
|
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
|
||||||
|
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
|
||||||
|
|
||||||
|
``message`` 允许各模块替换 403 文案(默认值保持审批模块原文案,
|
||||||
|
避免既有调用与测试变动)。
|
||||||
|
|
||||||
|
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||||
|
codes = set(user_repository.get_role_codes(current_user))
|
||||||
|
if not codes & set(role_codes):
|
||||||
|
raise ForbiddenError(message)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
return _checker
|
||||||
@@ -8,10 +8,24 @@ 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 personnel as base_personnel
|
||||||
|
from app.api.v1.base import products as base_products
|
||||||
|
from app.api.v1.business import businesses as business_businesses
|
||||||
|
from app.api.v1.business import ratings as business_ratings
|
||||||
|
from app.api.v1.business import receipts as business_receipts
|
||||||
|
from app.api.v1.business import shipments as business_shipments
|
||||||
|
from app.api.v1.calc import router as calc_router
|
||||||
|
from app.api.v1.dashboard import router as dashboard_router
|
||||||
|
from app.api.v1.reports import router as reports_router
|
||||||
|
from app.api.v1.settlements import router as settlements_router
|
||||||
|
from app.api.v1.system import approvals as system_approvals
|
||||||
from app.api.v1.system import business_roles as system_business_roles
|
from app.api.v1.system import business_roles as system_business_roles
|
||||||
from app.api.v1.system import logs as system_logs
|
from app.api.v1.system import logs as system_logs
|
||||||
from app.api.v1.system import params as system_params
|
from app.api.v1.system import params as system_params
|
||||||
from app.api.v1.system import roles as system_roles
|
from app.api.v1.system import roles as system_roles
|
||||||
|
from app.api.v1.system import settings as system_settings
|
||||||
from app.api.v1.system import users as system_users
|
from app.api.v1.system import users as system_users
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -29,8 +43,35 @@ system_router.include_router(system_users.router)
|
|||||||
system_router.include_router(system_roles.router)
|
system_router.include_router(system_roles.router)
|
||||||
system_router.include_router(system_business_roles.router)
|
system_router.include_router(system_business_roles.router)
|
||||||
system_router.include_router(system_params.router)
|
system_router.include_router(system_params.router)
|
||||||
|
system_router.include_router(system_settings.router)
|
||||||
|
system_router.include_router(system_approvals.router)
|
||||||
system_router.include_router(system_logs.operation_router)
|
system_router.include_router(system_logs.operation_router)
|
||||||
system_router.include_router(system_logs.login_router)
|
system_router.include_router(system_logs.login_router)
|
||||||
v1_router.include_router(system_router)
|
v1_router.include_router(system_router)
|
||||||
|
|
||||||
|
# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问
|
||||||
|
base_router = APIRouter(prefix="/base")
|
||||||
|
base_router.include_router(base_personnel.router)
|
||||||
|
base_router.include_router(base_products.router)
|
||||||
|
base_router.include_router(base_customers.router)
|
||||||
|
base_router.include_router(base_business_roles.router)
|
||||||
|
v1_router.include_router(base_router)
|
||||||
|
|
||||||
|
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
|
||||||
|
business_router = APIRouter(prefix="/business")
|
||||||
|
business_router.include_router(business_businesses.router)
|
||||||
|
business_router.include_router(business_shipments.router)
|
||||||
|
business_router.include_router(business_receipts.router)
|
||||||
|
business_router.include_router(business_ratings.router)
|
||||||
|
v1_router.include_router(business_router)
|
||||||
|
|
||||||
|
# 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤
|
||||||
|
v1_router.include_router(calc_router)
|
||||||
|
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
|
||||||
|
v1_router.include_router(settlements_router)
|
||||||
|
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导
|
||||||
|
v1_router.include_router(reports_router)
|
||||||
|
# 工作台(M4 ⑧):/api/v1/dashboard,四区块只读聚合,限管理员/财务/领导
|
||||||
|
v1_router.include_router(dashboard_router)
|
||||||
|
|
||||||
api_router.include_router(v1_router)
|
api_router.include_router(v1_router)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""基础数据管理接口(需求 2.1)。
|
||||||
|
|
||||||
|
挂载于 `/api/v1/base/*`,含人员管理、辐射区域、拓展计划等基础数据的维护入口。
|
||||||
|
|
||||||
|
与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问
|
||||||
|
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
|
||||||
|
由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""岗位下拉选项(基础数据与报表筛选共用)。
|
||||||
|
|
||||||
|
岗位管理本身仅管理员可用(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.repositories import business_role_repository
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/options", response_model=ApiResponse[list[dict]], summary="岗位下拉选项")
|
||||||
|
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[dict]]:
|
||||||
|
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
|
||||||
|
|
||||||
|
岗位规模有限(内置 + 自定义),一次取足够上限,不做分页。
|
||||||
|
"""
|
||||||
|
roles = business_role_repository.list_roles(session, offset=0, limit=1000)
|
||||||
|
return ok(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": role.id,
|
||||||
|
"role_code": role.role_code,
|
||||||
|
"role_name": role.role_name,
|
||||||
|
"status": role.status,
|
||||||
|
}
|
||||||
|
for role in roles
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""客户管理接口(需求 2.1.3)。
|
||||||
|
|
||||||
|
归属调整有专属端点 ``PUT /{id}/ownership``:它是唯一写归属留痕的入口,
|
||||||
|
编辑端点的 schema 不含归属字段 —— 防止出现「改了归属却无留痕」的旁路
|
||||||
|
(原因详见 `schemas.customer.CustomerUpdateIn`)。
|
||||||
|
|
||||||
|
与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问
|
||||||
|
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
|
||||||
|
由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageQuery, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.customer import (
|
||||||
|
CustomerCreateIn,
|
||||||
|
CustomerOut,
|
||||||
|
CustomerOwnershipIn,
|
||||||
|
CustomerQuery,
|
||||||
|
CustomerUpdateIn,
|
||||||
|
OwnershipChangeLogOut,
|
||||||
|
)
|
||||||
|
from app.schemas.rating import CustomerRatingOut, CustomerRatingSaveIn
|
||||||
|
from app.services import customer_service, rating_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/customers", tags=["客户管理"])
|
||||||
|
|
||||||
|
# 基础数据不要求管理员(见模块 docstring),登录用户即可操作
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[CustomerOut]], summary="客户列表")
|
||||||
|
def list_customers(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[CustomerQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[CustomerOut]]:
|
||||||
|
"""分页查询客户(keyword 对公司名称/客户负责人模糊匹配,附归属人员姓名)。"""
|
||||||
|
return ok(customer_service.list_customers(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户")
|
||||||
|
def create_customer(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: CustomerCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CustomerOut]:
|
||||||
|
"""新增客户。客户分类须在注册表内;「公司名称+负责人」组合唯一。"""
|
||||||
|
return ok(
|
||||||
|
customer_service.create_customer(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="客户已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="客户详情")
|
||||||
|
def get_customer(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[CustomerOut]:
|
||||||
|
"""按主键查询客户详情。"""
|
||||||
|
return ok(customer_service.get_customer(session, customer_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户")
|
||||||
|
def update_customer(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: CustomerUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CustomerOut]:
|
||||||
|
"""编辑客户档案。
|
||||||
|
|
||||||
|
**不含归属** —— 归属调整统一走 ``PUT /{id}/ownership``;编辑表单即使
|
||||||
|
提交 ownerPersonnelId 也会被忽略,归属以归属端点为准。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
customer_service.update_customer(
|
||||||
|
session, customer_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户")
|
||||||
|
def delete_customer(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除客户(物理删除)。
|
||||||
|
|
||||||
|
已存在关联业务时返回 400(先处理相关业务单据)。
|
||||||
|
"""
|
||||||
|
customer_service.delete_customer(session, customer_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="客户已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{customer_id}/ownership",
|
||||||
|
response_model=ApiResponse[CustomerOut],
|
||||||
|
summary="归属变更",
|
||||||
|
)
|
||||||
|
def change_ownership(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: CustomerOwnershipIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CustomerOut]:
|
||||||
|
"""调整客户归属(唯一写归属留痕的入口)。
|
||||||
|
|
||||||
|
只影响变更后核算,不回溯历史(对照表 #19);新旧归属人员ID任一为 null
|
||||||
|
表示清空归属。新旧归属相同报「归属未变化」。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
customer_service.change_ownership(
|
||||||
|
session, customer_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="归属已调整",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{customer_id}/ownership-logs",
|
||||||
|
response_model=ApiResponse[PageResult[OwnershipChangeLogOut]],
|
||||||
|
summary="归属变更留痕",
|
||||||
|
)
|
||||||
|
def list_ownership_logs(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
query: Annotated[PageQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
|
||||||
|
"""查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。"""
|
||||||
|
return ok(customer_service.list_ownership_logs(session, customer_id, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{customer_id}/rating",
|
||||||
|
response_model=ApiResponse[CustomerRatingOut],
|
||||||
|
summary="客户当前评级",
|
||||||
|
)
|
||||||
|
def get_customer_rating(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[CustomerRatingOut]:
|
||||||
|
"""查某客户当前评级。
|
||||||
|
|
||||||
|
未评级返回 ``data: null``(这是正常态而非错误);客户不存在才 404。
|
||||||
|
"""
|
||||||
|
return ok(rating_service.get_customer_rating(session, customer_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{customer_id}/rating",
|
||||||
|
response_model=ApiResponse[CustomerRatingOut],
|
||||||
|
summary="保存客户评级",
|
||||||
|
)
|
||||||
|
def save_customer_rating(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: CustomerRatingSaveIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CustomerRatingOut]:
|
||||||
|
"""保存客户评级(首次创建、再次保存整体覆盖)。
|
||||||
|
|
||||||
|
五项分值 0-20 由服务层在审计上下文内校验,总分与等级由服务端重算;
|
||||||
|
等级按保存时点的映射快照固化,等级表后续增删改不影响该客户结论。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
rating_service.save_customer_rating(
|
||||||
|
session, customer_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="评级已保存",
|
||||||
|
)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""人员管理接口(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。
|
||||||
|
|
||||||
|
基础数据属业务操作,**登录即可访问**(不要求管理员,对照系统管理模块)。
|
||||||
|
校验规则(岗位/经理合法性、删除保护、子表全量替换的防御写法)见
|
||||||
|
`personnel_service` 模块 docstring —— 前端提示之外,后端校验是唯一防线。
|
||||||
|
|
||||||
|
写操作经 `RequestMetaDep` 带入请求元信息由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.personnel import (
|
||||||
|
PersonnelCreateIn,
|
||||||
|
PersonnelDetailOut,
|
||||||
|
PersonnelOut,
|
||||||
|
PersonnelQuery,
|
||||||
|
PersonnelStatusIn,
|
||||||
|
PlanReplaceIn,
|
||||||
|
RegionReplaceIn,
|
||||||
|
)
|
||||||
|
from app.services import personnel_service
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/personnel", tags=["人员管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[PersonnelOut]], summary="人员列表")
|
||||||
|
def list_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[PersonnelQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[PersonnelOut]]:
|
||||||
|
"""分页查询人员(含离职)。姓名模糊匹配,可按状态与岗位筛选。"""
|
||||||
|
return ok(personnel_service.list_personnel(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员")
|
||||||
|
def create_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: PersonnelCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[PersonnelOut]:
|
||||||
|
"""新增人员,默认在职。岗位须存在且启用;直属经理不能是自己且须在职。"""
|
||||||
|
return ok(
|
||||||
|
personnel_service.create_personnel(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="人员已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{personnel_id}", response_model=ApiResponse[PersonnelDetailOut], summary="人员详情")
|
||||||
|
def get_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[PersonnelDetailOut]:
|
||||||
|
"""人员详情,附辐射区域与拓展计划,供编辑抽屉一次回显。"""
|
||||||
|
return ok(personnel_service.get_detail(session, personnel_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员")
|
||||||
|
def update_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: PersonnelCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[PersonnelOut]:
|
||||||
|
"""编辑人员(全量提交,不含状态)。校验规则同新增。"""
|
||||||
|
return ok(
|
||||||
|
personnel_service.update_personnel(
|
||||||
|
session, personnel_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员")
|
||||||
|
def delete_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除人员。被团队成员或客户归属引用时禁删,应改为离职。"""
|
||||||
|
personnel_service.delete_personnel(session, personnel_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="人员已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{personnel_id}/status",
|
||||||
|
response_model=ApiResponse[PersonnelOut],
|
||||||
|
summary="设为在职/离职",
|
||||||
|
)
|
||||||
|
def set_status(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: PersonnelStatusIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[PersonnelOut]:
|
||||||
|
"""在职/离职切换。离职人员不可再被业务与客户归属引用。"""
|
||||||
|
result = personnel_service.set_status(
|
||||||
|
session, personnel_id, payload.status, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(result, message="已设为在职" if payload.status == 1 else "已设为离职")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域")
|
||||||
|
def replace_regions(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: RegionReplaceIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""全量替换辐射区域,空列表即清空。"""
|
||||||
|
personnel_service.replace_regions(
|
||||||
|
session, personnel_id, payload, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(message="已保存")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划")
|
||||||
|
def replace_plans(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: PlanReplaceIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""全量替换拓展计划,空列表即清空。"""
|
||||||
|
personnel_service.replace_plans(
|
||||||
|
session, personnel_id, payload, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(message="已保存")
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""产品管理接口(需求 2.1.2:产品主数据维护)。
|
||||||
|
|
||||||
|
产品是折算阈值的行级锚点(product_category 决定 30%/20% 折算阈值),
|
||||||
|
类别值域由后端校验 —— 前端下拉只是展示约束,后端校验是唯一防线。
|
||||||
|
|
||||||
|
**数据提取(POST /products/extract)暂不提供**:它依赖业务明细表
|
||||||
|
(V5 迁移),待业务管理任务完成后补充。
|
||||||
|
|
||||||
|
基础数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
|
||||||
|
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.product import (
|
||||||
|
ProductCreateIn,
|
||||||
|
ProductOut,
|
||||||
|
ProductQuery,
|
||||||
|
ProductStatusIn,
|
||||||
|
ProductUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import product_service
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/products", tags=["产品管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[ProductOut]], summary="产品列表")
|
||||||
|
def list_products(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[ProductQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[ProductOut]]:
|
||||||
|
"""分页查询产品(含停用)。keyword 对名称或规格模糊匹配。"""
|
||||||
|
return ok(product_service.list_products(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品")
|
||||||
|
def create_product(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: ProductCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ProductOut]:
|
||||||
|
"""新增产品。类别须在合法值域内;新产品默认启用。"""
|
||||||
|
return ok(
|
||||||
|
product_service.create_product(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="产品已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{product_id}", response_model=ApiResponse[ProductOut], summary="产品详情")
|
||||||
|
def get_product(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
product_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ProductOut]:
|
||||||
|
"""按主键查询产品详情。"""
|
||||||
|
return ok(product_service.get_product(session, product_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品")
|
||||||
|
def update_product(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
product_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: ProductUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ProductOut]:
|
||||||
|
"""编辑产品基本信息(全量替换,不含状态;启用/停用走独立端点)。"""
|
||||||
|
return ok(
|
||||||
|
product_service.update_product(
|
||||||
|
session, product_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品")
|
||||||
|
def delete_product(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
product_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""物理删除产品(仅限误录数据,常规下架用停用)。
|
||||||
|
|
||||||
|
被业务明细/发货明细引用时返回 400(引导改走停用)。
|
||||||
|
"""
|
||||||
|
product_service.delete_product(session, product_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="产品已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品")
|
||||||
|
def set_status(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
product_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: ProductStatusIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ProductOut]:
|
||||||
|
"""启用或停用产品(1启用 0停用)。"""
|
||||||
|
result = product_service.set_status(
|
||||||
|
session, product_id, payload.status, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(result, message="已启用" if payload.status == 1 else "已停用")
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""业务数据管理接口(需求 2.2)。
|
||||||
|
|
||||||
|
挂载于 `/api/v1/business/*`,含业务单据维护、客户评级等级配置等业务数据入口。
|
||||||
|
|
||||||
|
与 `api/v1/system` 的权限分工:业务数据是**业务操作**,登录即可访问
|
||||||
|
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
|
||||||
|
由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""业务管理接口(需求 2.2.1/2.2.4)。
|
||||||
|
|
||||||
|
校验规则(编号生成、状态机、归属留痕、汇总重算)见 `business_service`
|
||||||
|
模块 docstring;基础数据属业务操作,登录即可访问。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageQuery, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.business import (
|
||||||
|
BusinessCreateIn,
|
||||||
|
BusinessDetailOut,
|
||||||
|
BusinessOut,
|
||||||
|
BusinessOwnershipIn,
|
||||||
|
BusinessQuery,
|
||||||
|
BusinessStatusIn,
|
||||||
|
BusinessStatusLogOut,
|
||||||
|
BusinessUpdateIn,
|
||||||
|
GrossProfitOut,
|
||||||
|
OwnershipChangeLogOut,
|
||||||
|
)
|
||||||
|
from app.services import business_service
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/businesses", tags=["业务管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[BusinessOut]], summary="业务列表")
|
||||||
|
def list_businesses(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[BusinessQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[BusinessOut]]:
|
||||||
|
"""分页查询业务。编号/公司名称模糊,可按状态/客户/归属人员/日期范围筛选。"""
|
||||||
|
return ok(business_service.list_businesses(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务")
|
||||||
|
def create_business(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: BusinessCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""创建业务 + 明细行(≥1)。业务编号自动生成;创建写首条状态留痕。"""
|
||||||
|
return ok(
|
||||||
|
business_service.create_business(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="业务已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{business_id}", response_model=ApiResponse[BusinessDetailOut], summary="业务详情")
|
||||||
|
def get_business(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[BusinessDetailOut]:
|
||||||
|
"""业务详情,附产品明细行,供编辑抽屉一次回显。"""
|
||||||
|
return ok(business_service.get_detail(session, business_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务")
|
||||||
|
def update_business(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: BusinessUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""编辑业务(明细全量替换并重算汇总)。归属不在此端点调整,走 /ownership。"""
|
||||||
|
return ok(
|
||||||
|
business_service.update_business(
|
||||||
|
session, business_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务")
|
||||||
|
def delete_business(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除业务。有发货或回款记录时禁删,应改为已终止。"""
|
||||||
|
business_service.delete_business(session, business_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="业务已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转")
|
||||||
|
def change_status(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: BusinessStatusIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""状态流转:执行中→已完成/已终止;终态不可再变更;终止必填原因。"""
|
||||||
|
return ok(
|
||||||
|
business_service.change_status(
|
||||||
|
session, business_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="状态已变更",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属")
|
||||||
|
def change_ownership(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: BusinessOwnershipIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""调整负责/协助营销人员,同事务写归属变更留痕(不回溯历史)。"""
|
||||||
|
return ok(
|
||||||
|
business_service.change_ownership(
|
||||||
|
session, business_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="归属已调整",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{business_id}/ownership-logs",
|
||||||
|
response_model=ApiResponse[PageResult[OwnershipChangeLogOut]],
|
||||||
|
summary="归属变更记录",
|
||||||
|
)
|
||||||
|
def list_ownership_logs(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
query: Annotated[PageQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
|
||||||
|
"""某业务的归属变更留痕(分页,按时间倒序)。"""
|
||||||
|
return ok(business_service.list_ownership_logs(session, business_id, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{business_id}/status-logs",
|
||||||
|
response_model=ApiResponse[PageResult[BusinessStatusLogOut]],
|
||||||
|
summary="状态流转记录",
|
||||||
|
)
|
||||||
|
def list_status_logs(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
query: Annotated[PageQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[BusinessStatusLogOut]]:
|
||||||
|
"""某业务的状态流转时间线(分页,按时间正序,从创建起展示)。"""
|
||||||
|
return ok(business_service.list_status_logs(session, business_id, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{business_id}/gross-profit",
|
||||||
|
response_model=ApiResponse[GrossProfitOut],
|
||||||
|
summary="毛利润数据视图",
|
||||||
|
)
|
||||||
|
def gross_profit(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
as_of: Annotated[date | None, Query()] = None,
|
||||||
|
) -> ApiResponse[GrossProfitOut]:
|
||||||
|
"""按产品逐行实时计算毛利润与折算额(不含运费口径,对照表 #2)。
|
||||||
|
|
||||||
|
阈值按 asOf(默认今日)取参数快照;M3 核算实际按回款到账日取参。
|
||||||
|
"""
|
||||||
|
return ok(business_service.gross_profit(session, business_id, as_of))
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""客户评级等级配置接口(需求 2.2.5)。
|
||||||
|
|
||||||
|
评级计算器的等级映射判定表:判定按 sort_order 升序取首个 min_score ≤ 总分
|
||||||
|
的等级,max_score 仅展示。区间合法(0≤min≤max≤100)与互不重叠由后端校验
|
||||||
|
—— 前端表单只是展示约束,后端校验是唯一防线。
|
||||||
|
|
||||||
|
列表不分页全量返回:等级映射是判定表,数量个位数,分页无意义。
|
||||||
|
业务数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
|
||||||
|
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
|
||||||
|
from app.services import rating_service
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/rating-grades", tags=["客户评级"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[list[RatingGradeOut]], summary="评级等级列表")
|
||||||
|
def list_grades(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
) -> ApiResponse[list[RatingGradeOut]]:
|
||||||
|
"""全量等级列表,按判定顺序(sort_order 升序)返回,不分页。"""
|
||||||
|
return ok(rating_service.list_grades(session))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级")
|
||||||
|
def create_grade(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: RatingGradeCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[RatingGradeOut]:
|
||||||
|
"""新增评级等级。区间须 0≤min≤max≤100 且与既有等级不重叠。"""
|
||||||
|
return ok(
|
||||||
|
rating_service.create_grade(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="评级等级已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级")
|
||||||
|
def update_grade(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
grade_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: RatingGradeUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[RatingGradeOut]:
|
||||||
|
"""编辑评级等级(全量替换)。区间须合法且与其他等级不重叠。"""
|
||||||
|
return ok(
|
||||||
|
rating_service.update_grade(session, grade_id, payload, operator=current_user, meta=meta),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级")
|
||||||
|
def delete_grade(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
grade_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""物理删除评级等级(等级表无下游引用,允许清空,理由见 service 说明)。"""
|
||||||
|
rating_service.delete_grade(session, grade_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="评级等级已删除")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""回款管理接口(需求 2.2.3)。
|
||||||
|
|
||||||
|
回款按业务登记到账款项,批次号自动生成(HK 前缀)。校验规则(发货
|
||||||
|
批次同业务强校验、已核算禁改禁删、汇总重算)见 `receipt_service` 模块
|
||||||
|
docstring;基础数据属业务操作,登录即可访问。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.receipt import (
|
||||||
|
ReceiptCreateIn,
|
||||||
|
ReceiptOut,
|
||||||
|
ReceiptQuery,
|
||||||
|
ReceiptUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import receipt_service
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/receipts", tags=["回款管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[ReceiptOut]], summary="回款列表")
|
||||||
|
def list_receipts(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[ReceiptQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[ReceiptOut]]:
|
||||||
|
"""分页查询回款。附业务号/发货单号与回款天数派生值,可按业务/日期/抵顶/核算状态筛选。"""
|
||||||
|
return ok(receipt_service.list_receipts(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款")
|
||||||
|
def create_receipt(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: ReceiptCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ReceiptOut]:
|
||||||
|
"""新增回款。批次号自动生成;发货批次须与本回款同属一个业务;业务须执行中。"""
|
||||||
|
return ok(
|
||||||
|
receipt_service.create_receipt(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="回款已登记",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="回款详情")
|
||||||
|
def get_receipt(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
receipt_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ReceiptOut]:
|
||||||
|
"""回款详情(含回款天数派生值,未指定发货批次时为空)。"""
|
||||||
|
return ok(receipt_service.get_detail(session, receipt_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款")
|
||||||
|
def update_receipt(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
receipt_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: ReceiptUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ReceiptOut]:
|
||||||
|
"""编辑回款(全量替换并重算合计)。已核算回款禁止修改;发货批次重新校验同业务。"""
|
||||||
|
return ok(
|
||||||
|
receipt_service.update_receipt(
|
||||||
|
session, receipt_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款")
|
||||||
|
def delete_receipt(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
receipt_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除回款。已核算回款禁止删除;业务已完成或已终止时禁删。"""
|
||||||
|
receipt_service.delete_receipt(session, receipt_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="回款已删除")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""发货管理接口(需求 2.2.2)。
|
||||||
|
|
||||||
|
校验规则(状态门槛、行金额固化、双重汇总回写、删除保护)见
|
||||||
|
`shipment_service` 模块 docstring;业务数据属业务操作,登录即可访问。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.shipment import (
|
||||||
|
ShipmentCreateIn,
|
||||||
|
ShipmentDetailOut,
|
||||||
|
ShipmentOut,
|
||||||
|
ShipmentQuery,
|
||||||
|
ShipmentUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import shipment_service
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/shipments", tags=["发货管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[ShipmentOut]], summary="发货单列表")
|
||||||
|
def list_shipments(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[ShipmentQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[ShipmentOut]]:
|
||||||
|
"""分页查询发货单(附业务编号),可按业务/发货日期范围筛选。"""
|
||||||
|
return ok(shipment_service.list_shipments(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单")
|
||||||
|
def create_shipment(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: ShipmentCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ShipmentOut]:
|
||||||
|
"""创建发货单(明细 ≥1)。业务须处于执行中;锁业务行后生成 SH 编号。"""
|
||||||
|
return ok(
|
||||||
|
shipment_service.create_shipment(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="发货单已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{shipment_id}", response_model=ApiResponse[ShipmentDetailOut], summary="发货单详情")
|
||||||
|
def get_shipment(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
shipment_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ShipmentDetailOut]:
|
||||||
|
"""发货单详情,附产品明细行,供编辑抽屉一次回显。"""
|
||||||
|
return ok(shipment_service.get_detail(session, shipment_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单")
|
||||||
|
def update_shipment(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
shipment_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: ShipmentUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ShipmentOut]:
|
||||||
|
"""编辑发货单(明细全量替换并重算汇总)。终态业务的发货单禁改。"""
|
||||||
|
return ok(
|
||||||
|
shipment_service.update_shipment(
|
||||||
|
session, shipment_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单")
|
||||||
|
def delete_shipment(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
shipment_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除发货单(连带明细行并回写业务汇总)。被回款批次指定时禁删。"""
|
||||||
|
shipment_service.delete_shipment(session, shipment_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="发货单已删除")
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""绩效核算接口(M3 ④):台账查询与单笔核算触发。
|
||||||
|
|
||||||
|
核算动作 = 把回款固化为台账行(每笔回款仅一次;重算走独立入口并授权)。
|
||||||
|
准入:读=登录即可;写(核算/重算/批量)=管理员与营销后勤
|
||||||
|
(权限清单待澄清 #6,答复后细化)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||||
|
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
|
||||||
|
from app.services import calc_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"])
|
||||||
|
|
||||||
|
_CalcWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
|
||||||
|
# 重算是授权动作:管理员/财务(营销后勤不可重算)
|
||||||
|
_RecalcUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE))]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
|
||||||
|
def list_ledger(
|
||||||
|
session: DbSession,
|
||||||
|
query: Annotated[LedgerQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[LedgerOut]]:
|
||||||
|
"""分页查询绩效核算台账(默认仅生效行,可筛选历史版本)。"""
|
||||||
|
return ok(calc_service.list_ledger_page(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ledger/{ledger_id}", response_model=ApiResponse[LedgerOut], summary="台账详情")
|
||||||
|
def get_ledger(
|
||||||
|
session: DbSession,
|
||||||
|
ledger_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[LedgerOut]:
|
||||||
|
"""台账行详情(含计算快照)。"""
|
||||||
|
return ok(calc_service.get_ledger(session, ledger_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/receipts/{receipt_id}/calc",
|
||||||
|
response_model=ApiResponse[CalcReceiptOut],
|
||||||
|
summary="核算回款",
|
||||||
|
)
|
||||||
|
def calc_receipt(
|
||||||
|
receipt_id: Annotated[int, Path(ge=1)],
|
||||||
|
session: DbSession,
|
||||||
|
current_user: _CalcWriter,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CalcReceiptOut]:
|
||||||
|
"""把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。"""
|
||||||
|
return ok(
|
||||||
|
calc_service.calc_receipt(session, receipt_id, operator=current_user, meta=meta),
|
||||||
|
message="核算完成",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecalcIn(BaseModel):
|
||||||
|
"""重算入参:原因必填(重算授权留痕)。"""
|
||||||
|
|
||||||
|
reason: str = Field(min_length=1, max_length=255, description="重算原因")
|
||||||
|
|
||||||
|
|
||||||
|
class BatchIn(BaseModel):
|
||||||
|
"""批量核算入参。"""
|
||||||
|
|
||||||
|
receipt_ids: list[int] = Field(min_length=1, description="回款 ID 列表")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/receipts/{receipt_id}/recalc",
|
||||||
|
response_model=ApiResponse[CalcReceiptOut],
|
||||||
|
summary="重算回款",
|
||||||
|
)
|
||||||
|
def recalc_receipt(
|
||||||
|
receipt_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: RecalcIn,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: _RecalcUser,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CalcReceiptOut]:
|
||||||
|
"""重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。"""
|
||||||
|
return ok(
|
||||||
|
calc_service.recalc_receipt(
|
||||||
|
session, receipt_id, payload.reason, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="重算完成",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch", response_model=ApiResponse[list[dict]], summary="批量核算")
|
||||||
|
def batch_calc(
|
||||||
|
payload: BatchIn,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: _CalcWriter,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[list[dict]]:
|
||||||
|
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""
|
||||||
|
return ok(
|
||||||
|
calc_service.batch_calc(session, payload.receipt_ids, operator=current_user, meta=meta),
|
||||||
|
message="批量核算完成",
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""工作台接口(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 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 可查看全部在途单)。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
dashboard_service.get_dashboard(session, month, current_user=current_user)
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""月度结算接口(M3 ④):生成/提交审批/锁定与调整单。
|
||||||
|
|
||||||
|
准入:读=登录即可;写(生成/提交/调整单)=管理员与营销后勤
|
||||||
|
(权限清单待澄清 #6,答复后细化)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||||
|
from app.core.constants import ROLE_ADMIN, ROLE_MARKETING_OPS
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.settlement import (
|
||||||
|
AdjustmentCreateIn,
|
||||||
|
SettlementDetailOut,
|
||||||
|
SettlementGenerateIn,
|
||||||
|
SettlementOut,
|
||||||
|
SettlementQuery,
|
||||||
|
)
|
||||||
|
from app.services import settlement_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settlements", tags=["绩效工资 · 月度结算"])
|
||||||
|
|
||||||
|
_SettlementWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照")
|
||||||
|
def generate(
|
||||||
|
payload: SettlementGenerateIn,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: _SettlementWriter,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[SettlementDetailOut]:
|
||||||
|
"""按确认回款口径生成指定月份的结算快照(草稿)。"""
|
||||||
|
return ok(
|
||||||
|
settlement_service.generate(session, payload.month, operator=current_user, meta=meta),
|
||||||
|
message="月结已生成",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[SettlementOut]], summary="月结列表")
|
||||||
|
def list_settlements(
|
||||||
|
session: DbSession,
|
||||||
|
query: Annotated[SettlementQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[SettlementOut]]:
|
||||||
|
"""分页查询月结(按月份倒序)。"""
|
||||||
|
return ok(settlement_service.list_page(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{settlement_id}", response_model=ApiResponse[SettlementDetailOut], summary="月结详情")
|
||||||
|
def get_settlement(
|
||||||
|
session: DbSession,
|
||||||
|
settlement_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[SettlementDetailOut]:
|
||||||
|
"""月结详情(含按人明细)。"""
|
||||||
|
return ok(settlement_service.get_detail(session, settlement_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{settlement_id}/submit",
|
||||||
|
response_model=ApiResponse[SettlementDetailOut],
|
||||||
|
summary="提交审批",
|
||||||
|
)
|
||||||
|
def submit(
|
||||||
|
settlement_id: Annotated[int, Path(ge=1)],
|
||||||
|
session: DbSession,
|
||||||
|
current_user: _SettlementWriter,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[SettlementDetailOut]:
|
||||||
|
"""提交审批:开关关闭=自动通过并锁定;开启=待财务复核。"""
|
||||||
|
return ok(
|
||||||
|
settlement_service.submit(session, settlement_id, operator=current_user, meta=meta),
|
||||||
|
message="已提交",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 调整单(已锁定月份的补发/扣减) ====================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/adjustments", response_model=ApiResponse[dict], summary="新建调整单")
|
||||||
|
def create_adjustment(
|
||||||
|
payload: AdjustmentCreateIn,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: _SettlementWriter,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[dict]:
|
||||||
|
"""新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。"""
|
||||||
|
return ok(
|
||||||
|
settlement_service.create_adjustment(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="调整单已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/adjustments", response_model=ApiResponse[list[dict]], summary="调整单列表")
|
||||||
|
def list_adjustments(session: DbSession) -> ApiResponse[list[dict]]:
|
||||||
|
"""调整单列表(一期不分页,内部系统量级有限)。"""
|
||||||
|
return ok(settlement_service.list_adjustments(session))
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""审批管理接口(2026-10-15 复核审批补充需求)。
|
||||||
|
|
||||||
|
准入:admin/财务/领导可见(营销后勤暂不可见——权限清单待澄清 #6,
|
||||||
|
答复后放开为「提交人可见本人单据」)。节点级准入由 service 按单据
|
||||||
|
状态二次校验(status=1 仅财务、status=2 仅领导,admin 不代办)。
|
||||||
|
提单不经本模块:由业务写路径(创建/编辑带二次分配方案)自动触发。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||||
|
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.approval import (
|
||||||
|
ApprovalActionIn,
|
||||||
|
ApprovalOrderDetailOut,
|
||||||
|
ApprovalOrderOut,
|
||||||
|
ApprovalQuery,
|
||||||
|
)
|
||||||
|
from app.services import approval_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/approvals", tags=["系统管理 · 审批管理"])
|
||||||
|
|
||||||
|
# 模块准入:可见集合(节点准入在 service 层按状态二次判定)
|
||||||
|
_ApprovalUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER))]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[ApprovalOrderOut]], summary="审批单列表")
|
||||||
|
def list_orders(
|
||||||
|
session: DbSession,
|
||||||
|
_: _ApprovalUser,
|
||||||
|
query: Annotated[ApprovalQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[ApprovalOrderOut]]:
|
||||||
|
"""分页查询审批单。"""
|
||||||
|
return ok(approval_service.list_orders(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{order_id}", response_model=ApiResponse[ApprovalOrderDetailOut], summary="审批详情")
|
||||||
|
def get_order(
|
||||||
|
session: DbSession,
|
||||||
|
_: _ApprovalUser,
|
||||||
|
order_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ApprovalOrderDetailOut]:
|
||||||
|
"""审批单详情(含节点时间线)。"""
|
||||||
|
return ok(approval_service.get_order(session, order_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{order_id}/action",
|
||||||
|
response_model=ApiResponse[ApprovalOrderDetailOut],
|
||||||
|
summary="节点动作(通过/驳回)",
|
||||||
|
)
|
||||||
|
def act_on_order(
|
||||||
|
payload: ApprovalActionIn,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: _ApprovalUser,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
order_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ApprovalOrderDetailOut]:
|
||||||
|
"""财务复核 / 领导审核;一驳全驳,驳回须填意见。"""
|
||||||
|
return ok(
|
||||||
|
approval_service.act(session, order_id, payload, operator=current_user, meta=meta),
|
||||||
|
message="已提交",
|
||||||
|
)
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。
|
"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。
|
||||||
|
|
||||||
全部要求管理员权限(开放问题 #5 的当前默认,后续可放宽只读);
|
权限分层(澄清 #17 业务答复 2026-10-19「查看可、修改仅管理员」):只读
|
||||||
写操作(提交/撤回)经 `RequestMetaDep` 带入请求元信息写入操作日志,
|
端点(目录/变更记录/批次明细/对比/导出/预览/历史)要求登录即可;写操作
|
||||||
变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
|
(提交/撤回)仅管理员。写操作经 `RequestMetaDep` 带入请求元信息写入操作
|
||||||
|
日志,变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
|
||||||
导出为文件流(非统一信封),前端以 blob 接收。
|
导出为文件流(非统一信封),前端以 blob 接收。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Path, Query
|
from fastapi import APIRouter, Path, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
from app.api.deps import AdminUser, DbSession, LoginUser, RequestMetaDep
|
||||||
from app.core.response import ApiResponse, PageResult, ok
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
from app.schemas.param import (
|
from app.schemas.param import (
|
||||||
ParamBatchDetailOut,
|
ParamBatchDetailOut,
|
||||||
@@ -37,7 +38,7 @@ router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"])
|
|||||||
)
|
)
|
||||||
def get_catalog(
|
def get_catalog(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
_: AdminUser,
|
_: LoginUser,
|
||||||
as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None,
|
as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None,
|
||||||
) -> ApiResponse[list[ParamCatalogItemOut]]:
|
) -> ApiResponse[list[ParamCatalogItemOut]]:
|
||||||
"""全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。"""
|
"""全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。"""
|
||||||
@@ -63,7 +64,7 @@ def submit_change(
|
|||||||
)
|
)
|
||||||
def list_changes(
|
def list_changes(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
_: AdminUser,
|
_: LoginUser,
|
||||||
query: Annotated[ParamBatchQuery, Query()],
|
query: Annotated[ParamBatchQuery, Query()],
|
||||||
) -> ApiResponse[PageResult[ParamBatchOut]]:
|
) -> ApiResponse[PageResult[ParamBatchOut]]:
|
||||||
"""按生效日期区间分页查询变更批次(创建时间倒序)。"""
|
"""按生效日期区间分页查询变更批次(创建时间倒序)。"""
|
||||||
@@ -75,7 +76,7 @@ def list_changes(
|
|||||||
)
|
)
|
||||||
def get_batch(
|
def get_batch(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
_: AdminUser,
|
_: LoginUser,
|
||||||
batch_id: Annotated[int, Path(ge=1)],
|
batch_id: Annotated[int, Path(ge=1)],
|
||||||
) -> ApiResponse[ParamBatchDetailOut]:
|
) -> ApiResponse[ParamBatchDetailOut]:
|
||||||
"""批次头 + 逐项旧值 → 新值。"""
|
"""批次头 + 逐项旧值 → 新值。"""
|
||||||
@@ -97,7 +98,7 @@ def withdraw_batch(
|
|||||||
@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比")
|
@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比")
|
||||||
def compare(
|
def compare(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
_: AdminUser,
|
_: LoginUser,
|
||||||
from_date: Annotated[date, Query(description="起始业务日期(含)")],
|
from_date: Annotated[date, Query(description="起始业务日期(含)")],
|
||||||
to_date: Annotated[date, Query(description="截止业务日期(含)")],
|
to_date: Annotated[date, Query(description="截止业务日期(含)")],
|
||||||
) -> ApiResponse[list[ParamDiffOut]]:
|
) -> ApiResponse[list[ParamDiffOut]]:
|
||||||
@@ -108,7 +109,7 @@ def compare(
|
|||||||
@router.get("/export", summary="导出变更记录 Excel")
|
@router.get("/export", summary="导出变更记录 Excel")
|
||||||
def export_changes(
|
def export_changes(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
_: AdminUser,
|
_: LoginUser,
|
||||||
start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None,
|
start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None,
|
||||||
end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None,
|
end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None,
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
@@ -125,7 +126,7 @@ def export_changes(
|
|||||||
@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览")
|
@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览")
|
||||||
def preview(
|
def preview(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
_: AdminUser,
|
_: LoginUser,
|
||||||
payload: ParamPreviewIn,
|
payload: ParamPreviewIn,
|
||||||
) -> ApiResponse[ParamPreviewOut]:
|
) -> ApiResponse[ParamPreviewOut]:
|
||||||
"""输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。"""
|
"""输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。"""
|
||||||
@@ -139,7 +140,7 @@ def preview(
|
|||||||
)
|
)
|
||||||
def get_history(
|
def get_history(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
_: AdminUser,
|
_: LoginUser,
|
||||||
param_code: Annotated[str, Path(max_length=64)],
|
param_code: Annotated[str, Path(max_length=64)],
|
||||||
query: Annotated[ParamHistoryQuery, Query()],
|
query: Annotated[ParamHistoryQuery, Query()],
|
||||||
) -> ApiResponse[PageResult[ParamHistoryOut]]:
|
) -> ApiResponse[PageResult[ParamHistoryOut]]:
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""系统设置接口(公司信息 + 站点信息)。
|
||||||
|
|
||||||
|
`GET /public`、`GET /logo/public`、`GET /favicon/public` **无鉴权**:登录页
|
||||||
|
渲染公司名/logo、浏览器加载标签页图标时尚无令牌。响应经
|
||||||
|
`SettingPublicOut` 白名单收敛(见 schemas/setting.py),仅暴露展示必需字段。
|
||||||
|
|
||||||
|
`/logo/public` 与 `/favicon/public` 返回文件流而非统一信封——与参数导出
|
||||||
|
同一例外口径(二进制响应套 JSON 信封无意义)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Response, UploadFile
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||||
|
from app.core.exceptions import BadRequestError
|
||||||
|
from app.core.response import ApiResponse, ok
|
||||||
|
from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn
|
||||||
|
from app.services import system_setting_service as service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"])
|
||||||
|
|
||||||
|
_LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"}
|
||||||
|
_FAVICON_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg", "ico": "image/x-icon"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[SettingOut], summary="公司信息")
|
||||||
|
def get_settings(session: DbSession, _: AdminUser) -> ApiResponse[SettingOut]:
|
||||||
|
"""取公司信息(管理端)。"""
|
||||||
|
return ok(service.to_out(service.get_setting(session)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司信息")
|
||||||
|
def update_settings(
|
||||||
|
payload: SettingUpdateIn,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[SettingOut]:
|
||||||
|
"""更新公司信息(部分更新;审计留痕)。"""
|
||||||
|
return ok(
|
||||||
|
service.update_company(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logo", response_model=ApiResponse[SettingOut], summary="上传 logo")
|
||||||
|
def upload_logo(
|
||||||
|
file: UploadFile,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[SettingOut]:
|
||||||
|
"""上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。
|
||||||
|
|
||||||
|
同步 `def`:读文件、磁盘写与审计提交都是阻塞操作,async 路由里会让
|
||||||
|
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
|
||||||
|
大小先用 `file.size` 拦截,避免超限文件整体读进内存。
|
||||||
|
"""
|
||||||
|
if file.size is not None and file.size > service.ASSET_MAX_BYTES:
|
||||||
|
raise BadRequestError("logo 文件不能超过 2MB")
|
||||||
|
content = file.file.read()
|
||||||
|
ext = (file.filename or "").rsplit(".", 1)[-1]
|
||||||
|
return ok(
|
||||||
|
service.save_logo(session, content, ext, operator=current_user, meta=meta),
|
||||||
|
message="logo 已更新",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/favicon", response_model=ApiResponse[SettingOut], summary="上传站点图标")
|
||||||
|
def upload_favicon(
|
||||||
|
file: UploadFile,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: AdminUser,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[SettingOut]:
|
||||||
|
"""上传站点图标(png/jpg/ico ≤2MB;固定名覆盖,旧扩展名文件清理)。
|
||||||
|
|
||||||
|
同步 `def` 与 logo 上传同口径:磁盘写与审计提交均为阻塞操作。
|
||||||
|
"""
|
||||||
|
if file.size is not None and file.size > service.ASSET_MAX_BYTES:
|
||||||
|
raise BadRequestError("站点图标文件不能超过 2MB")
|
||||||
|
content = file.file.read()
|
||||||
|
ext = (file.filename or "").rsplit(".", 1)[-1]
|
||||||
|
return ok(
|
||||||
|
service.save_favicon(session, content, ext, operator=current_user, meta=meta),
|
||||||
|
message="站点图标已更新",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)")
|
||||||
|
def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]:
|
||||||
|
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=300"
|
||||||
|
return ok(service.to_public_out(service.get_setting(session)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logo/public", summary="logo 图片(匿名)")
|
||||||
|
def get_logo_public(session: DbSession) -> FileResponse:
|
||||||
|
"""logo 文件流(无鉴权)。no-cache:图片替换后由版本参数破缓存兜底。"""
|
||||||
|
path, ext = service.read_logo(session)
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"),
|
||||||
|
headers={"Cache-Control": "no-cache"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/favicon/public", summary="站点图标(匿名)")
|
||||||
|
def get_favicon_public(session: DbSession) -> FileResponse:
|
||||||
|
"""站点图标文件流(无鉴权)。no-cache:图标替换后由版本参数破缓存兜底。"""
|
||||||
|
path, ext = service.read_favicon(session)
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type=_FAVICON_MEDIA_TYPE.get(ext, "application/octet-stream"),
|
||||||
|
headers={"Cache-Control": "no-cache"},
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -92,6 +92,15 @@ class Settings(BaseSettings):
|
|||||||
auth = f":{self.redis_password}@" if self.redis_password else ""
|
auth = f":{self.redis_password}@" if self.redis_password else ""
|
||||||
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def upload_dir(self) -> Path:
|
||||||
|
"""上传文件根目录(公司 logo 等):backend/uploads/。
|
||||||
|
|
||||||
|
不叫 static/ 是刻意的:静态目录将来可能整体交给前端构建产物,
|
||||||
|
运行期上传物必须与之隔离;该目录不入版本库(.gitignore)。
|
||||||
|
"""
|
||||||
|
return BACKEND_DIR / "uploads"
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ TOKEN_TYPE_BEARER = "Bearer"
|
|||||||
# ===== 角色编码(与 db/migration 初始化数据保持一致)=====
|
# ===== 角色编码(与 db/migration 初始化数据保持一致)=====
|
||||||
ROLE_ADMIN = "admin"
|
ROLE_ADMIN = "admin"
|
||||||
ROLE_USER = "user"
|
ROLE_USER = "user"
|
||||||
|
# 2026-10-15 复核审批补充需求新增职能登录角色:营销后勤(提交+录入)、
|
||||||
|
# 财务(复核)、领导(审核)。roles 表无 is_builtin 列,内置保护用代码
|
||||||
|
# 白名单判定(role_service 两处内置校验改查此集合);
|
||||||
|
# permissions/role_permissions 细粒度激活待业务权限清单(待澄清 #6)。
|
||||||
|
ROLE_MARKETING_OPS = "marketing_ops"
|
||||||
|
ROLE_FINANCE = "finance"
|
||||||
|
ROLE_LEADER = "leader"
|
||||||
|
BUILTIN_ROLE_CODES = {
|
||||||
|
ROLE_ADMIN,
|
||||||
|
ROLE_MARKETING_OPS,
|
||||||
|
ROLE_FINANCE,
|
||||||
|
ROLE_LEADER,
|
||||||
|
}
|
||||||
|
|
||||||
# ===== 密码策略 =====
|
# ===== 密码策略 =====
|
||||||
# bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」,
|
# bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」,
|
||||||
@@ -47,12 +60,43 @@ PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water"
|
|||||||
PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
|
PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
|
||||||
PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT}
|
PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT}
|
||||||
|
|
||||||
|
# ===== 客户分类码(customers.customer_type,计提规则匹配锚点)=====
|
||||||
|
CUSTOMER_TYPE_PERSONAL = "personal"
|
||||||
|
CUSTOMER_TYPE_MANAGER_PERSONAL = "manager_personal"
|
||||||
|
CUSTOMER_TYPE_ENTERPRISE = "enterprise"
|
||||||
|
CUSTOMER_TYPES = {
|
||||||
|
CUSTOMER_TYPE_PERSONAL,
|
||||||
|
CUSTOMER_TYPE_MANAGER_PERSONAL,
|
||||||
|
CUSTOMER_TYPE_ENTERPRISE,
|
||||||
|
}
|
||||||
|
|
||||||
# ===== 操作日志词表(operation_logs 的 module / action 取值)=====
|
# ===== 操作日志词表(operation_logs 的 module / action 取值)=====
|
||||||
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
|
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
|
||||||
MODULE_USER = "用户管理"
|
MODULE_USER = "用户管理"
|
||||||
MODULE_ROLE = "角色管理"
|
MODULE_ROLE = "角色管理"
|
||||||
MODULE_BUSINESS_ROLE = "岗位角色"
|
MODULE_BUSINESS_ROLE = "岗位角色"
|
||||||
MODULE_PARAM = "计提参数"
|
MODULE_PARAM = "计提参数"
|
||||||
|
MODULE_PERSONNEL = "人员管理"
|
||||||
|
MODULE_PRODUCT = "产品管理"
|
||||||
|
MODULE_CUSTOMER = "客户管理"
|
||||||
|
MODULE_BUSINESS = "业务管理"
|
||||||
|
MODULE_SHIPMENT = "发货管理"
|
||||||
|
MODULE_RECEIPT = "回款管理"
|
||||||
|
MODULE_RATING = "客户评级"
|
||||||
|
MODULE_SYSTEM_SETTINGS = "系统设置"
|
||||||
|
MODULE_APPROVAL = "审批管理"
|
||||||
|
MODULE_CALC = "绩效核算"
|
||||||
|
MODULE_SETTLEMENT = "月度结算"
|
||||||
|
MODULE_REPORT = "报表统计"
|
||||||
|
|
||||||
|
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
|
||||||
|
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
|
||||||
|
NO_PREFIX_BUSINESS = "YW"
|
||||||
|
NO_PREFIX_SHIPMENT = "SH"
|
||||||
|
NO_PREFIX_RECEIPT = "HK"
|
||||||
|
NO_PREFIX_PARAM = "P"
|
||||||
|
NO_PREFIX_APPROVAL = "AP"
|
||||||
|
NO_PREFIX_ADJUSTMENT = "TZ"
|
||||||
|
|
||||||
ACTION_CREATE = "新增"
|
ACTION_CREATE = "新增"
|
||||||
ACTION_UPDATE = "修改"
|
ACTION_UPDATE = "修改"
|
||||||
@@ -60,3 +104,6 @@ ACTION_DELETE = "删除"
|
|||||||
ACTION_RESET_PASSWORD = "重置密码"
|
ACTION_RESET_PASSWORD = "重置密码"
|
||||||
ACTION_ENABLE = "启用"
|
ACTION_ENABLE = "启用"
|
||||||
ACTION_DISABLE = "禁用"
|
ACTION_DISABLE = "禁用"
|
||||||
|
ACTION_APPROVE = "审批通过"
|
||||||
|
ACTION_REJECT = "审批驳回"
|
||||||
|
ACTION_EXPORT = "导出"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""数据库完整性错误的统一识别与翻译口径。
|
||||||
|
|
||||||
|
判断依据:pymysql 原生异常 ``orig.args`` 首位是 MySQL 错误码 —— 只有
|
||||||
|
1062(ER_DUP_ENTRY,唯一键冲突)是「已存在」语义,可安全转 400 业务
|
||||||
|
提示;其余完整性错误(外键 RESTRICT、数据截断等)原样上抛,由全局
|
||||||
|
异常处理器收敛 500(程序缺陷或数据异常,均不应伪装成用户错误)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
# MySQL 唯一键冲突错误码(pymysql 原生异常 args 首位)
|
||||||
|
ERR_DUP_ENTRY = 1062
|
||||||
|
|
||||||
|
|
||||||
|
def is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||||
|
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(错误码 1062)。"""
|
||||||
|
args = getattr(exc.orig, "args", ())
|
||||||
|
return bool(args) and args[0] == ERR_DUP_ENTRY
|
||||||
@@ -12,6 +12,7 @@ import logging
|
|||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
|
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
|
||||||
@@ -60,6 +61,19 @@ async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSON
|
|||||||
return _build_response(exc.status_code, message)
|
return _build_response(exc.status_code, message)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONResponse:
|
||||||
|
"""完整性错误全局兜底为 400。
|
||||||
|
|
||||||
|
service 层已判别的 1062/引用冲突会自行翻译为业务提示,不会走到这里;
|
||||||
|
本 handler 拦截的是「新增父表后某 service 漏补引用保护」一类的遗漏
|
||||||
|
(e2e 撞见一个补一个的模式无法保证全覆盖):FK RESTRICT 等数据完整性
|
||||||
|
冲突对用户表现为「这行数据被别处引用/冲突」,转 400 比 500 更贴近事实。
|
||||||
|
完整异常记录日志供排查;不在此处 rollback —— 会话由请求级依赖统一关闭。
|
||||||
|
"""
|
||||||
|
logger.error("数据库完整性冲突(未在 service 层翻译)", exc_info=exc)
|
||||||
|
return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝")
|
||||||
|
|
||||||
|
|
||||||
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
|
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
|
||||||
"""兜底处理未预期异常。
|
"""兜底处理未预期异常。
|
||||||
|
|
||||||
@@ -77,4 +91,5 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
|
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
|
||||||
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
|
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
|
||||||
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
|
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
|
||||||
|
app.add_exception_handler(IntegrityError, handle_integrity_error) # type: ignore[arg-type]
|
||||||
app.add_exception_handler(Exception, handle_unexpected_exception)
|
app.add_exception_handler(Exception, handle_unexpected_exception)
|
||||||
@@ -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")
|
||||||
@@ -9,25 +9,59 @@
|
|||||||
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
|
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from app.models.approval import ApprovalOrder, ApprovalStep
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
|
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
|
||||||
from app.models.business_role import BusinessRole
|
from app.models.business_role import BusinessRole
|
||||||
|
from app.models.customer import Customer, OwnershipChangeLog
|
||||||
|
from app.models.customer_rating import CustomerRating
|
||||||
from app.models.login_log import LoginLog
|
from app.models.login_log import LoginLog
|
||||||
from app.models.operation_log import OperationLog
|
from app.models.operation_log import OperationLog
|
||||||
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
|
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
|
||||||
|
from app.models.performance_ledger import PerformanceLedger
|
||||||
|
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
|
||||||
|
from app.models.product import Product
|
||||||
|
from app.models.rating import CustomerRatingGrade
|
||||||
|
from app.models.receipt import Receipt
|
||||||
from app.models.role import Permission, Role, RolePermission
|
from app.models.role import Permission, Role, RolePermission
|
||||||
|
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
|
||||||
|
from app.models.shipment import Shipment, ShipmentItem
|
||||||
|
from app.models.system_setting import SystemSetting
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AdjustmentOrder",
|
||||||
|
"ApprovalOrder",
|
||||||
|
"ApprovalStep",
|
||||||
"Base",
|
"Base",
|
||||||
|
"Business",
|
||||||
|
"BusinessItem",
|
||||||
"BusinessRole",
|
"BusinessRole",
|
||||||
|
"BusinessStatusLog",
|
||||||
|
"Customer",
|
||||||
|
"CustomerRating",
|
||||||
|
"CustomerRatingGrade",
|
||||||
"LoginLog",
|
"LoginLog",
|
||||||
|
"MonthlySettlement",
|
||||||
"OperationLog",
|
"OperationLog",
|
||||||
|
"OwnershipChangeLog",
|
||||||
"ParamChangeBatch",
|
"ParamChangeBatch",
|
||||||
"ParamDefinition",
|
"ParamDefinition",
|
||||||
"ParamValue",
|
"ParamValue",
|
||||||
|
"PerformanceLedger",
|
||||||
"Permission",
|
"Permission",
|
||||||
|
"Personnel",
|
||||||
|
"PersonnelPlan",
|
||||||
|
"PersonnelRegion",
|
||||||
|
"Product",
|
||||||
|
"Receipt",
|
||||||
"Role",
|
"Role",
|
||||||
"RolePermission",
|
"RolePermission",
|
||||||
|
"SecondaryAllocation",
|
||||||
|
"SettlementItem",
|
||||||
|
"Shipment",
|
||||||
|
"ShipmentItem",
|
||||||
|
"SystemSetting",
|
||||||
"TimestampMixin",
|
"TimestampMixin",
|
||||||
"User",
|
"User",
|
||||||
"UserRole",
|
"UserRole",
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""审批模型:审批单与节点记录(2026-10-15 复核审批补充需求)。
|
||||||
|
|
||||||
|
三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数体系开关驱动
|
||||||
|
(`approval.*.enabled` BOOL):关闭=提交即生成「自动通过」节点留痕,
|
||||||
|
留痕不断链(开发计划 3.2(三));开启=按节点人工审核,一驳全驳。
|
||||||
|
|
||||||
|
依赖说明:节点准入按登录角色编码判定(ROLE_FINANCE/ROLE_LEADER),
|
||||||
|
不动 `permissions`/`role_permissions` 闲置表 —— 细粒度权限清单待业务方
|
||||||
|
答复(待澄清 #6)后再激活。
|
||||||
|
|
||||||
|
ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=2
|
||||||
|
(月结预留)将指向 ④ 尚未落地的表,外键无法统一指向;单据删除保护由
|
||||||
|
服务层承担(详见 approval_service)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, String, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
# 审批单状态(approval_orders.status;流转机见 approval_service)
|
||||||
|
APPROVAL_STATUS_PENDING_FINANCE = 1 # 待财务复核
|
||||||
|
APPROVAL_STATUS_PENDING_LEADER = 2 # 待领导审核
|
||||||
|
APPROVAL_STATUS_APPROVED = 3 # 已通过(终态)
|
||||||
|
APPROVAL_STATUS_REJECTED = 4 # 已驳回(终态)
|
||||||
|
APPROVAL_STATUS_WITHDRAWN = 5 # 已撤回(被引用方重新提交时旧在途单注销)
|
||||||
|
|
||||||
|
# 节点(approval_steps.node;0 仅用于撤回留痕)
|
||||||
|
APPROVAL_NODE_FINANCE = 1
|
||||||
|
APPROVAL_NODE_LEADER = 2
|
||||||
|
APPROVAL_NODE_WITHDRAW = 0
|
||||||
|
APPROVAL_NODES = {
|
||||||
|
APPROVAL_STATUS_PENDING_FINANCE: APPROVAL_NODE_FINANCE,
|
||||||
|
APPROVAL_STATUS_PENDING_LEADER: APPROVAL_NODE_LEADER,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 动作(approval_steps.action)
|
||||||
|
APPROVAL_ACTION_APPROVE = 1 # 人工通过
|
||||||
|
APPROVAL_ACTION_REJECT = 2 # 人工驳回
|
||||||
|
APPROVAL_ACTION_AUTO_APPROVE = 3 # 系统自动通过(开关关闭时逐节点留痕)
|
||||||
|
APPROVAL_ACTION_WITHDRAW = 4 # 撤回(业务重新提交时旧在途单注销)
|
||||||
|
|
||||||
|
# 审批对象类型(approval_orders.order_type)
|
||||||
|
APPROVAL_TYPE_SECONDARY_ALLOCATION = 1
|
||||||
|
APPROVAL_TYPE_MONTHLY_SETTLEMENT = 2
|
||||||
|
APPROVAL_TYPE_ADJUSTMENT = 3
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalOrder(Base, TimestampMixin):
|
||||||
|
"""审批单:一张待审对象一条审批流(状态可流转,含 update_time)。"""
|
||||||
|
|
||||||
|
__tablename__ = "approval_orders"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
|
||||||
|
Index("idx_approval_orders_type_status", "order_type", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
order_no: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
|
||||||
|
)
|
||||||
|
order_type: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="审批对象类型:1二次分配方案 2月度结算 3调整单"
|
||||||
|
)
|
||||||
|
ref_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)",
|
||||||
|
)
|
||||||
|
refer_no: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)"
|
||||||
|
)
|
||||||
|
status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回",
|
||||||
|
)
|
||||||
|
submitter_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="提交人ID(账号删除后置空)",
|
||||||
|
)
|
||||||
|
submitter_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="提交人用户名(冗余固化)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalStep(Base):
|
||||||
|
"""审批节点记录表(append-only):每次动作只增不改,构成时间线。
|
||||||
|
|
||||||
|
不继承 TimestampMixin(留痕无更新语义,同 business_status_logs 先例)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "approval_steps"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"审批节点记录表(append-only;自动通过/撤回均有留痕)",
|
||||||
|
Index("idx_approval_steps_order_id", "order_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
order_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("approval_orders.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="审批单ID(approval_orders.id;单删记录随删)",
|
||||||
|
)
|
||||||
|
node: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="审批节点:0撤回留痕 1财务复核 2领导审核"
|
||||||
|
)
|
||||||
|
action: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回"
|
||||||
|
)
|
||||||
|
opinion: Mapped[str | None] = mapped_column(String(255), comment="审批意见(驳回时填写)")
|
||||||
|
operator_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="操作人ID(自动通过/系统动作置空)",
|
||||||
|
)
|
||||||
|
operator_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="操作人用户名(冗余固化;系统动作记「系统」)"
|
||||||
|
)
|
||||||
|
create_time: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||||
|
)
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, MetaData, text
|
from sqlalchemy import DateTime, MetaData, String, text
|
||||||
from sqlalchemy.dialects.mysql import BIGINT, TINYINT
|
from sqlalchemy.dialects.mysql import BIGINT, TINYINT
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
@@ -28,7 +28,14 @@ BIGINT_UNSIGNED = BIGINT(unsigned=True)
|
|||||||
# 状态类字段统一用 TINYINT(0/1 或少量枚举值)
|
# 状态类字段统一用 TINYINT(0/1 或少量枚举值)
|
||||||
TINYINT_TYPE = TINYINT
|
TINYINT_TYPE = TINYINT
|
||||||
|
|
||||||
__all__ = ["BIGINT_UNSIGNED", "TINYINT_TYPE", "Base", "TimestampMixin", "mysql_table_args"]
|
__all__ = [
|
||||||
|
"BIGINT_UNSIGNED",
|
||||||
|
"TINYINT_TYPE",
|
||||||
|
"Base",
|
||||||
|
"TPlusSyncMixin",
|
||||||
|
"TimestampMixin",
|
||||||
|
"mysql_table_args",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
@@ -62,6 +69,30 @@ class TimestampMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TPlusSyncMixin:
|
||||||
|
"""T+(用友)数据同步预留列,六张业务主表共用。
|
||||||
|
|
||||||
|
对照表 #26:T+ 对接的字段映射与幂等键语义未定(IT 牵头),故四列全部
|
||||||
|
可空、不设唯一键 —— 键语义未定就锁死唯一约束,对接时大概率返工;
|
||||||
|
source_no 先建普通索引承担查询与去重,IT 定键后以小迁移升级唯一约束。
|
||||||
|
|
||||||
|
同步守则(M4 同步模块必须遵守):同步只写 T+ 侧字段与本组列,
|
||||||
|
**不覆盖人工维护字段**(岗位/工资标准/归属/客户分类/业务状态等,
|
||||||
|
各表注释已标注)。同步频率:一周一次定时 + 手动触发(对照表 #10)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
source_no: Mapped[str | None] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
index=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
)
|
||||||
|
t_plus_id: Mapped[str | None] = mapped_column(String(64), comment="T+ 系统内部标识")
|
||||||
|
sync_batch_no: Mapped[str | None] = mapped_column(
|
||||||
|
String(32), comment="同步批次号(同步模块写入)"
|
||||||
|
)
|
||||||
|
synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间")
|
||||||
|
|
||||||
|
|
||||||
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
|
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
|
||||||
"""构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。
|
"""构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""业务数据模型:业务单、产品明细与状态流转留痕。
|
||||||
|
|
||||||
|
- 汇总冗余列(total_* 五列)由服务层在明细/发货/回款增删改时**事务内单点
|
||||||
|
重算**,禁手工直改 —— 列表页与 M3 起付判定直接读冗余列,避免每次 JOIN
|
||||||
|
聚合;代价是所有写路径必须重算(business_service 统一收口)
|
||||||
|
- 毛利润相关值(毛利润额/毛利润率/折算毛利润额)**不落库**:它们是
|
||||||
|
sale_amount/cost_amount + 时点化阈值参数的派生值,落库会在成本调整或
|
||||||
|
参数改版后漂移出「假快照」;M2 以只读毛利润视图实时计算(复用
|
||||||
|
formula.profit_rate/converted_profit),M3 核算台账才是法定固化点
|
||||||
|
- product_category 存在明细行级:混合业务按产品逐条计算(对照表 #14)
|
||||||
|
- 运费仅记录展示,不计入毛利润口径(对照表 #2);协助营销人员一期核算
|
||||||
|
不使用(对照表 #15 剔除协助 25% 分成),字段保留仅记录事实(需规 2.2.1)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import (
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
TINYINT_TYPE,
|
||||||
|
Base,
|
||||||
|
TimestampMixin,
|
||||||
|
TPlusSyncMixin,
|
||||||
|
mysql_table_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 业务状态(businesses.status,流转规则见 business_service:终态不可变更)
|
||||||
|
BUSINESS_STATUS_ACTIVE = 1
|
||||||
|
BUSINESS_STATUS_DONE = 2
|
||||||
|
BUSINESS_STATUS_TERMINATED = 3
|
||||||
|
|
||||||
|
|
||||||
|
class SecondaryAllocation(Base, TimestampMixin):
|
||||||
|
"""二次分配方案明细表(2026-10-15 补充需求「二次分配方案」)。
|
||||||
|
|
||||||
|
口径(AI_CONTEXT 待澄清 #1/#2 未答复前的边界):
|
||||||
|
- 只出现在合同刚建立时;修改在首次月结前留痕放行,月结后只读(月结在 ④,
|
||||||
|
终态门槛先行承担「修改不影响已结算月份」)
|
||||||
|
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
|
||||||
|
与比例,响应以 hint 提示合计/余额,不做强制校验
|
||||||
|
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
|
||||||
|
- **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按
|
||||||
|
核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表
|
||||||
|
|
||||||
|
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
|
||||||
|
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
|
||||||
|
冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id),
|
||||||
|
同一合同的同一参与人仅一行,重复提交在服务层聚合。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "business_secondary_allocations"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
|
||||||
|
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
|
||||||
|
"业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
|
||||||
|
UniqueConstraint(
|
||||||
|
"business_id",
|
||||||
|
"personnel_id",
|
||||||
|
name="uk_business_secondary_allocations_business_personnel",
|
||||||
|
),
|
||||||
|
Index("idx_business_secondary_allocations_business_id", "business_id"),
|
||||||
|
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("businesses.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id;业务删除随删)",
|
||||||
|
)
|
||||||
|
personnel_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="参与业务员ID(personnel.id;被方案引用禁删)",
|
||||||
|
)
|
||||||
|
personnel_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="参与业务员姓名(冗余固化,离职后回显有名)"
|
||||||
|
)
|
||||||
|
ratio: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 2),
|
||||||
|
nullable=False,
|
||||||
|
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Business(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
"""业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。"""
|
||||||
|
|
||||||
|
__tablename__ = "businesses"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"业务表(业务编号自动生成;T+同步不覆盖业务状态)",
|
||||||
|
Index("idx_businesses_customer_id", "customer_id"),
|
||||||
|
Index("idx_businesses_owner_personnel_id", "owner_personnel_id"),
|
||||||
|
Index("idx_businesses_status", "status"),
|
||||||
|
Index("idx_businesses_business_date", "business_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
business_no: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)",
|
||||||
|
)
|
||||||
|
customer_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("customers.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="客户ID(customers.id)",
|
||||||
|
)
|
||||||
|
business_date: Mapped[date | None] = mapped_column(Date, comment="业务日期(T+ 同步口径)")
|
||||||
|
status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)",
|
||||||
|
)
|
||||||
|
owner_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)",
|
||||||
|
)
|
||||||
|
assistant_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)",
|
||||||
|
)
|
||||||
|
is_special: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="特殊业务标记:1是 0否(公司参与等)",
|
||||||
|
)
|
||||||
|
special_note: Mapped[str | None] = mapped_column(String(255), comment="特殊业务说明")
|
||||||
|
total_sale_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)",
|
||||||
|
)
|
||||||
|
total_cost_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
)
|
||||||
|
total_ship_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
)
|
||||||
|
total_received_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)",
|
||||||
|
)
|
||||||
|
total_freight_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)",
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessItem(Base, TimestampMixin):
|
||||||
|
"""业务产品明细表(需求 2.2.1/2.2.4):毛利润的行级源数据。
|
||||||
|
|
||||||
|
product_id 可空=自由录入产品(配合「数据提取」一键入产品库);
|
||||||
|
产品名称/规格冗余固化,防产品改名影响历史业务。
|
||||||
|
sale_amount 由服务层按 数量×单价 计算固化,不信任前端传值。
|
||||||
|
cost_amount 来源待 T+ 对接确认(可空,人工可录先行)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "business_items"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)",
|
||||||
|
Index("idx_business_items_business_id", "business_id"),
|
||||||
|
Index("idx_business_items_product_id", "product_id"),
|
||||||
|
Index("idx_business_items_product_category", "product_category"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("businesses.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id)",
|
||||||
|
)
|
||||||
|
product_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("products.id", ondelete="RESTRICT"),
|
||||||
|
comment="产品ID(products.id;NULL=自由录入,未入产品库)",
|
||||||
|
)
|
||||||
|
product_name: Mapped[str] = mapped_column(
|
||||||
|
String(128), nullable=False, comment="产品名称(引用产品时冗余固化)"
|
||||||
|
)
|
||||||
|
spec_model: Mapped[str] = mapped_column(
|
||||||
|
String(128),
|
||||||
|
nullable=False,
|
||||||
|
server_default="''",
|
||||||
|
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
|
||||||
|
)
|
||||||
|
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
|
||||||
|
product_category: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
|
||||||
|
)
|
||||||
|
quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)")
|
||||||
|
sale_price: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 4), comment="销售单价(元;T+ 同步或人工)"
|
||||||
|
)
|
||||||
|
sale_amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
nullable=False,
|
||||||
|
comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)",
|
||||||
|
)
|
||||||
|
cost_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)",
|
||||||
|
)
|
||||||
|
guided_price: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 4), comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)"
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessStatusLog(Base):
|
||||||
|
"""业务状态变更记录表(append-only,需求 2.2.1「支持状态变更记录」)。
|
||||||
|
|
||||||
|
不继承 TimestampMixin(留痕无更新语义);business_id 为无外键的固化 ID
|
||||||
|
(businesses 建表在本表之后的迁移批次内 —— 实为同批,但留痕统一口径:
|
||||||
|
被引用方删除后记录仍完整可读,删除保护由服务层检查承担)。
|
||||||
|
首条记录在业务创建时写入(old=new=1 执行中),详情页时间线从创建起展示。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "business_status_logs"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"业务状态变更记录表(append-only)",
|
||||||
|
Index("idx_business_status_logs_business_id", "business_id"),
|
||||||
|
Index("idx_business_status_logs_create_time", "create_time"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
comment="业务ID(固化,无外键;删除保护由服务层承担)",
|
||||||
|
)
|
||||||
|
old_status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="变更前状态(创建首条为 1)"
|
||||||
|
)
|
||||||
|
new_status: Mapped[int] = mapped_column(TINYINT_TYPE, nullable=False, comment="变更后状态")
|
||||||
|
change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因(终止时必填)")
|
||||||
|
operator_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
)
|
||||||
|
operator_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="操作人用户名(冗余固化)"
|
||||||
|
)
|
||||||
|
create_time: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||||
|
)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""客户模型:客户档案与归属变更留痕。
|
||||||
|
|
||||||
|
- 「公司名称 + 负责人」是业务唯一标识(需规 2.1.3);负责人是客户公司的
|
||||||
|
负责人**人名**(字符串),不是系统内的营销人员 —— 系统内归属另用
|
||||||
|
owner_personnel_id 外键表达,两者语义不同
|
||||||
|
- 归属变更只改主表当前值 + 插一条留痕,**不回溯历史**(对照表 #19):
|
||||||
|
历史核算结果在 M3 台账已固化,无需 effective_date 时点化设计
|
||||||
|
- 留痕表(OwnershipChangeLog)对客户/业务/人员 ID 均为**无外键的冗余固化**:
|
||||||
|
留痕是历史记录,被引用方被删除后记录仍须完整可读;RESTRICT 外键反而会
|
||||||
|
把「删除客户」逼成 500。业务实体删除保护由服务层检查承担
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, String, UniqueConstraint, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import (
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
TINYINT_TYPE,
|
||||||
|
Base,
|
||||||
|
TimestampMixin,
|
||||||
|
TPlusSyncMixin,
|
||||||
|
mysql_table_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 归属类型(ownership_change_logs.owner_type)
|
||||||
|
OWNER_TYPE_CUSTOMER = 1
|
||||||
|
OWNER_TYPE_BUSINESS = 2
|
||||||
|
|
||||||
|
|
||||||
|
class Customer(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
"""客户表(需求 2.1.3)。customer_type 是计提规则匹配锚点(M3 消费)。"""
|
||||||
|
|
||||||
|
__tablename__ = "customers"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)",
|
||||||
|
UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
company_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="公司名称")
|
||||||
|
owner_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="客户负责人姓名(客户公司负责人的人名,非系统人员)"
|
||||||
|
)
|
||||||
|
contact_person: Mapped[str | None] = mapped_column(String(64), comment="联系人")
|
||||||
|
contact_phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
|
||||||
|
address: Mapped[str | None] = mapped_column(String(255), comment="地址")
|
||||||
|
industry: Mapped[str | None] = mapped_column(String(64), comment="所属行业(自由文本)")
|
||||||
|
customer_type: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
server_default="personal",
|
||||||
|
index=True,
|
||||||
|
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
|
||||||
|
)
|
||||||
|
owner_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
index=True,
|
||||||
|
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipChangeLog(Base):
|
||||||
|
"""归属变更记录表(append-only,需求 2.1.3 + 对照表 #19)。
|
||||||
|
|
||||||
|
owner_type 区分客户归属/业务归属:1 → customer_id 非空,2 → business_id
|
||||||
|
非空,由应用层校验(无外键,见模块 docstring 的固化论证)。
|
||||||
|
前后值冗余固化(无外键):人员被删除后留痕仍可读。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "ownership_change_logs"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)",
|
||||||
|
Index("idx_ownership_change_logs_customer_id", "customer_id"),
|
||||||
|
Index("idx_ownership_change_logs_business_id", "business_id"),
|
||||||
|
Index("idx_ownership_change_logs_create_time", "create_time"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
owner_type: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="归属类型:1客户归属 2业务归属"
|
||||||
|
)
|
||||||
|
customer_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
comment="客户ID(固化,owner_type=1 时非空)",
|
||||||
|
)
|
||||||
|
business_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
comment="业务ID(固化,owner_type=2 时非空)",
|
||||||
|
)
|
||||||
|
old_owner_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, comment="变更前负责营销人员ID(固化)"
|
||||||
|
)
|
||||||
|
new_owner_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, comment="变更后负责营销人员ID(固化)"
|
||||||
|
)
|
||||||
|
old_assistant_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, comment="变更前协助营销人员ID(固化,仅业务归属使用)"
|
||||||
|
)
|
||||||
|
new_assistant_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, comment="变更后协助营销人员ID(固化,仅业务归属使用)"
|
||||||
|
)
|
||||||
|
change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因")
|
||||||
|
operator_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
)
|
||||||
|
operator_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="操作人用户名(冗余固化)"
|
||||||
|
)
|
||||||
|
create_time: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||||
|
)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""客户评级模型(需求 2.2.5)。
|
||||||
|
|
||||||
|
评级从「纯计算器不落库」升级为**每客户一行落库**(2026-10-19 确认):
|
||||||
|
客户管理页要罗列评级列并按其排序,评分必须可回溯。设计要点:
|
||||||
|
|
||||||
|
- ``customer_id`` 唯一键 + RESTRICT 外键:一客户一份当前评级(快照语义,
|
||||||
|
非流水),删除客户由服务层先删评级行再删主行(RESTRICT 兜底并发窗口)
|
||||||
|
- ``grade_name`` 为**快照固化、无外键**:等级映射表仍允许自由增删清空
|
||||||
|
(rating_service 的既有前提),删等级不能伤及已评级客户的历史结论 ——
|
||||||
|
与归属留痕「ID/人名固化、不回溯」同一论证口径;等级未命中时为 NULL
|
||||||
|
- ``scorer_id`` 外键 SET NULL + ``scorer_name`` 冗余固化:账号删除后
|
||||||
|
记录仍可读(同 ownership_change_logs 的操作人口径)
|
||||||
|
- 分值列 DECIMAL(5,1):UI 整数 0-20,DECIMAL 留十进制余地;总分由
|
||||||
|
**服务端重算**,不采信前端回传
|
||||||
|
"""
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Numeric, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRating(Base, TimestampMixin):
|
||||||
|
"""客户评级表:每客户一行(uk_customer_ratings_customer_id)。
|
||||||
|
|
||||||
|
五项各 0-20 分、总分 = 五项之和(0-100),判定规则同等级映射表
|
||||||
|
(按 sort_order 升序首个 min_score ≤ 总分,见 rating_service.resolve_grade)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "customer_ratings"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)",
|
||||||
|
UniqueConstraint("customer_id", name="uk_customer_ratings_customer_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
customer_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("customers.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="客户ID(唯一:一客户一份当前评级)",
|
||||||
|
)
|
||||||
|
score_ship_amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 1), nullable=False, server_default="0", comment="发货额评分(0-20)"
|
||||||
|
)
|
||||||
|
score_receipt_rate: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 1), nullable=False, server_default="0", comment="回款率评分(0-20)"
|
||||||
|
)
|
||||||
|
score_profit_rate: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 1), nullable=False, server_default="0", comment="毛利率评分(0-20)"
|
||||||
|
)
|
||||||
|
score_contract_cooperation: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 1), nullable=False, server_default="0", comment="合同履约配合能力评分(0-20)"
|
||||||
|
)
|
||||||
|
score_site_construction: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 1), nullable=False, server_default="0", comment="现场规范施工能力评分(0-20)"
|
||||||
|
)
|
||||||
|
total_score: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 1), nullable=False, comment="总分(服务端重算,五项之和 0-100)"
|
||||||
|
)
|
||||||
|
grade_name: Mapped[str | None] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)",
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
scorer_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="评级操作人ID(账号删除后置空)",
|
||||||
|
)
|
||||||
|
scorer_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="评级操作人用户名(冗余固化)"
|
||||||
|
)
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""绩效核算台账模型(M3 ④:每笔回款仅核算一次的固化点)。
|
||||||
|
|
||||||
|
**append-only 版本行**:重算 = 插入 calc_version+1 的新行,旧行仅把
|
||||||
|
is_active 翻 0 —— 这是全表唯一的受控 UPDATE(模型注释与 test_models
|
||||||
|
均已声明例外),行内容永不改写;历史版本可回溯(重算授权留痕的需求口径)。
|
||||||
|
|
||||||
|
防重三层:服务层 CAS(UPDATE ... WHERE settlement_status=0,rowcount=0
|
||||||
|
即 400)+ FOR UPDATE 行锁 + 库级 uk(receipt_id, calc_version, personnel_id)
|
||||||
|
兜底。uk 含 personnel_id:二次分配分摊(澄清 #1 答复 2026-10-19,分配
|
||||||
|
绩效——按方案比例拆给参与人、余额归合同负责人)后同一回款同版本按人
|
||||||
|
各落一行,拆分仍是「一次核算」的组成部分;聚合行 receipt_id 为 NULL,
|
||||||
|
MySQL 唯一索引对 NULL 不去重,天然不约束。
|
||||||
|
|
||||||
|
经理聚合行(calc_type=2,receipt_id 为 NULL):月度批处理时按「本团队
|
||||||
|
业务员绩效 ×12.5% + 全公司 ×5%」落一行;其唯一性由月结生成的
|
||||||
|
settlement_month 串行化 + 服务层保证(MySQL 无部分唯一索引,docstring
|
||||||
|
声明即可)。
|
||||||
|
|
||||||
|
#12 答复(2026-10-19):经理个人户业绩是否计入聚合基数改为参数
|
||||||
|
``manager.personal.included``(默认 0=不计入)——消费点在 settlement_service
|
||||||
|
的聚合函数。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Date,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
# 核算类型(performance_ledgers.calc_type)
|
||||||
|
CALC_TYPE_RECEIPT = 1 # 单笔回款核算
|
||||||
|
CALC_TYPE_MANAGER_AGGREGATE = 2 # 经理团队聚合(月度批处理)
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceLedger(Base, TimestampMixin):
|
||||||
|
"""绩效核算台账表:核算事实的固化点,报表 2.4.1 的数据源。"""
|
||||||
|
|
||||||
|
__tablename__ = "performance_ledgers"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)",
|
||||||
|
# 库级防重兜底:同一回款同版本按人仅一行(分摊拆行后负责人+参与人
|
||||||
|
# 各占一行;服务层 CAS+行锁之外的第二道闸)。聚合行 receipt_id 为
|
||||||
|
# NULL,MySQL 唯一索引对 NULL 不去重,天然不约束聚合行
|
||||||
|
UniqueConstraint(
|
||||||
|
"receipt_id",
|
||||||
|
"calc_version",
|
||||||
|
"personnel_id",
|
||||||
|
name="uk_performance_ledgers_receipt_id_calc_version_personnel",
|
||||||
|
),
|
||||||
|
Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"),
|
||||||
|
Index(
|
||||||
|
"idx_performance_ledgers_month_personnel_type",
|
||||||
|
"settlement_month",
|
||||||
|
"personnel_id",
|
||||||
|
"calc_type",
|
||||||
|
),
|
||||||
|
Index("idx_performance_ledgers_personnel_id", "personnel_id"),
|
||||||
|
Index("idx_performance_ledgers_business_id", "business_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
receipt_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("receipts.id", ondelete="RESTRICT"),
|
||||||
|
comment="回款ID(receipts.id;聚合行可为 NULL)",
|
||||||
|
)
|
||||||
|
calc_version: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="核算版本(重算+1,旧行 is_active=0)",
|
||||||
|
)
|
||||||
|
is_active: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)",
|
||||||
|
)
|
||||||
|
calc_type: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="核算类型:1单笔回款 2经理团队聚合(月度)",
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)",
|
||||||
|
)
|
||||||
|
business_no: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, comment="业务编号(冗余固化,列表免联查)"
|
||||||
|
)
|
||||||
|
personnel_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="归属营销人员ID(personnel.id;核算归属锚点)",
|
||||||
|
)
|
||||||
|
personnel_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="归属营销人员姓名(冗余固化)"
|
||||||
|
)
|
||||||
|
role_code: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, comment="岗位编码(business_roles.role_code,计提公式锚点)"
|
||||||
|
)
|
||||||
|
customer_type: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, comment="客户分类:personal/manager_personal/enterprise"
|
||||||
|
)
|
||||||
|
product_category: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)",
|
||||||
|
)
|
||||||
|
ship_date: Mapped[date | None] = mapped_column(Date, comment="发货日期(回款天数基准)")
|
||||||
|
receipt_date: Mapped[date] = mapped_column(
|
||||||
|
Date, nullable=False, comment="回款日期(参数版本匹配日,裁决 #11)"
|
||||||
|
)
|
||||||
|
payment_days: Mapped[int | None] = mapped_column(
|
||||||
|
comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)"
|
||||||
|
)
|
||||||
|
rate_text: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)",
|
||||||
|
)
|
||||||
|
converted_profit: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2), comment="折算毛利润额(可负;混合业务为各行汇总)"
|
||||||
|
)
|
||||||
|
performance_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2), comment="绩效金额(可负,裁决:负绩效照算不归零)"
|
||||||
|
)
|
||||||
|
barter_applied: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="以物抵顶折算:1已按 80% 折算 0否",
|
||||||
|
)
|
||||||
|
weight_r: Mapped[int | None] = mapped_column(comment="支付权重 R(回款占销售额百分比向下取整)")
|
||||||
|
payment_weight: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(7, 6), comment="支付权重系数 R×R(展示用)"
|
||||||
|
)
|
||||||
|
payable_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2), comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)"
|
||||||
|
)
|
||||||
|
settlement_month: Mapped[str] = mapped_column(
|
||||||
|
String(7), nullable=False, comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)"
|
||||||
|
)
|
||||||
|
is_probation: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="试用期:1照算不发放 0否",
|
||||||
|
)
|
||||||
|
missing_cost: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否",
|
||||||
|
)
|
||||||
|
snapshot_json: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON, comment="计算快照(所用参数版本与行级折算明细,重算可回溯)"
|
||||||
|
)
|
||||||
|
settlement_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("monthly_settlements.id", ondelete="RESTRICT"),
|
||||||
|
comment="所属月结ID(月结生成时回填;NULL=未入结)",
|
||||||
|
)
|
||||||
|
calc_reason: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), comment="核算/重算原因(重算必填留痕)"
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""营销人员模型:人员档案、产品辐射区域与市场拓展计划。
|
||||||
|
|
||||||
|
- 岗位(business_role_id)决定计提公式模板,与工资标准同属「同步不覆盖」的
|
||||||
|
人工维护字段(对照表 #26 落地口径,表注释标注供 M4 同步模块遵守)
|
||||||
|
- manager_id 自引用表达「经理带团队」:NULL=经理本人或未分配;M3 经理
|
||||||
|
12.5% 聚合、M4 部门汇总均按该关系展开,无需独立团队表
|
||||||
|
- 一期营销人员不登录系统,不与 users 关联;若后续开放自助查工资再加列
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Date, ForeignKey, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import (
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
TINYINT_TYPE,
|
||||||
|
Base,
|
||||||
|
TimestampMixin,
|
||||||
|
TPlusSyncMixin,
|
||||||
|
mysql_table_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
# personnel.status 的在职值(对照 BUSINESS_STATUS_* 集中在 models/business.py
|
||||||
|
# 的先例:状态语义单点导出,业务/客户两侧的归属校验共用,防魔数各自漂移)
|
||||||
|
PERSONNEL_STATUS_ACTIVE = 1
|
||||||
|
|
||||||
|
|
||||||
|
class Personnel(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
"""营销人员表(需求 2.1.1)。
|
||||||
|
|
||||||
|
表名 personnel 为集合名词(单复数同形),本身即复数语义。
|
||||||
|
试用期人员绩效照算不发放(对照表 #17),is_probation 供 M3 区分。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "personnel"
|
||||||
|
__table_args__ = mysql_table_args("营销人员表(T+同步不覆盖岗位与工资标准)")
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
personnel_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="姓名")
|
||||||
|
business_role_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("business_roles.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)",
|
||||||
|
)
|
||||||
|
manager_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
index=True,
|
||||||
|
comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)",
|
||||||
|
)
|
||||||
|
phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
|
||||||
|
hire_date: Mapped[date | None] = mapped_column(Date, comment="入职日期")
|
||||||
|
is_probation: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="是否试用期:1是 0否(试用期绩效照算不发放)",
|
||||||
|
)
|
||||||
|
salary_standard: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(12, 2), comment="月工资标准(元;T+同步不覆盖)"
|
||||||
|
)
|
||||||
|
status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)",
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelRegion(Base, TimestampMixin):
|
||||||
|
"""产品辐射区域表(需求 2.1.1):人员负责的产品辐射地理范围,1:N 子表。
|
||||||
|
|
||||||
|
随人员编辑整表替换维护,无独立管理诉求,故不加 T+ 预留列。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "personnel_regions"
|
||||||
|
__table_args__ = mysql_table_args("产品辐射区域表")
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
personnel_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="人员ID(personnel.id)",
|
||||||
|
)
|
||||||
|
region_name: Mapped[str] = mapped_column(
|
||||||
|
String(128), nullable=False, comment="区域名称(产品辐射地理范围)"
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelPlan(Base, TimestampMixin):
|
||||||
|
"""市场拓展计划表(需求 2.1.1):计划期间 + 计划内容 + 指标说明的自由条目。
|
||||||
|
|
||||||
|
结构化指标粒度待 T+ 字段映射确认,先以自由条目容器落地;条目最终由
|
||||||
|
T+ 同步确定,故预留 source_no 单列(无索引:查询按 personnel_id 走)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "personnel_plans"
|
||||||
|
__table_args__ = mysql_table_args("市场拓展计划表")
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
personnel_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="人员ID(personnel.id)",
|
||||||
|
)
|
||||||
|
plan_period: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, comment="计划期间(如 2026Q1、2026-05)"
|
||||||
|
)
|
||||||
|
plan_content: Mapped[str] = mapped_column(String(500), nullable=False, comment="计划内容")
|
||||||
|
target_desc: Mapped[str | None] = mapped_column(String(255), comment="指标说明(文字描述)")
|
||||||
|
source_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 原始条目号(预留)")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""产品模型:产品主数据。
|
||||||
|
|
||||||
|
产品类别(product_category)是**折算阈值的行级锚点**:混合业务按产品逐条
|
||||||
|
计算(对照表 #14),阈值本身由 M1 参数 `product.threshold` 按 PRODUCT_CATEGORY
|
||||||
|
维度承担(#28「全部阈值统一在系统设置维护」),本表不建阈值列 —— 两处维护
|
||||||
|
必然漂移。类别值域与 M1 参数维度共用 PRODUCT_CATEGORIES 常量。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Numeric, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import (
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
TINYINT_TYPE,
|
||||||
|
Base,
|
||||||
|
TimestampMixin,
|
||||||
|
TPlusSyncMixin,
|
||||||
|
mysql_table_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Product(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
"""产品表(需求 2.1.2)。停用(status=0)为主,不物理删除。"""
|
||||||
|
|
||||||
|
__tablename__ = "products"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)",
|
||||||
|
UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
product_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="产品名称")
|
||||||
|
spec_model: Mapped[str] = mapped_column(
|
||||||
|
String(128),
|
||||||
|
nullable=False,
|
||||||
|
server_default="''",
|
||||||
|
comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)",
|
||||||
|
)
|
||||||
|
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
|
||||||
|
stock_quantity: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2), comment="数量(T+ 同步原样存储,语义待 IT 确认)"
|
||||||
|
)
|
||||||
|
sale_price: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 4), comment="销售单价(元,T+ 同步)"
|
||||||
|
)
|
||||||
|
product_category: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
|
||||||
|
)
|
||||||
|
status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""客户评级等级配置模型(需求 2.2.5)。
|
||||||
|
|
||||||
|
评级定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分自 2026-10-19
|
||||||
|
起每客户落库于 customer_ratings(快照固化等级名),本表承载可配置的等级
|
||||||
|
**映射关系**。判定规则:按 sort_order 升序取第一个 min_score ≤ 总分的
|
||||||
|
等级 —— 单边判定避免区间边界(如 75 分)在「≥75 且 ≤89」与「>74 且 <75」
|
||||||
|
两套写法间的重叠/遗漏歧义;max_score 仅展示。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRatingGrade(Base, TimestampMixin):
|
||||||
|
"""客户评级等级配置表(需求 2.2.5「等级对应关系可配置」)。
|
||||||
|
|
||||||
|
种子四条随迁移初始化:A 优秀 90-100 / B 良好 75-89 / C 合格 60-74 /
|
||||||
|
D 待改进 0-59。区间不重叠与 0≤min≤max≤100 由应用层校验。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "customer_rating_grades"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)"
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
grade_name: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, unique=True, comment="等级名称(如 A/优秀)"
|
||||||
|
)
|
||||||
|
min_score: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 1),
|
||||||
|
nullable=False,
|
||||||
|
comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分",
|
||||||
|
)
|
||||||
|
max_score: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(5, 1), nullable=False, comment="总分上限(展示用)"
|
||||||
|
)
|
||||||
|
sort_order: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, server_default="0", comment="排序(判定顺序,升序)"
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="说明")
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""回款模型:回款记录与批次追溯。
|
||||||
|
|
||||||
|
- shipment_id 为**手动指定**的发货批次(对照表 #18):可空=暂未指定;
|
||||||
|
必须与 business_id 同属一个业务(服务层强校验,无数据库级约束可表达
|
||||||
|
跨表同属关系);指定后回款天数方可由 formula.payment_days 派生
|
||||||
|
- settlement_status 是 **M3 核算防重锚点**(需规 2.3.3「每笔回款仅核算一次,
|
||||||
|
台账记录核销状态」):预置于本表避免 M3 反向迁移改 M2 表;M3 台账建立后
|
||||||
|
由核算服务写入,M2 仅维护 0(未核算)
|
||||||
|
- receipt_date 是 M3 参数版本匹配日(对照表 #11:按回款到账日取时点参数)
|
||||||
|
- 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import (
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
TINYINT_TYPE,
|
||||||
|
Base,
|
||||||
|
TimestampMixin,
|
||||||
|
TPlusSyncMixin,
|
||||||
|
mysql_table_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 绩效核算状态(receipts.settlement_status)
|
||||||
|
SETTLEMENT_PENDING = 0
|
||||||
|
SETTLEMENT_DONE = 1
|
||||||
|
|
||||||
|
|
||||||
|
class Receipt(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
"""回款表(需求 2.2.3):按业务登记到账款项,批次号系统内唯一。"""
|
||||||
|
|
||||||
|
__tablename__ = "receipts"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"回款表(手动指定发货批次;已核算回款禁改禁删)",
|
||||||
|
Index("idx_receipts_business_id", "business_id"),
|
||||||
|
Index("idx_receipts_receipt_date", "receipt_date"),
|
||||||
|
Index("idx_receipts_settlement_status", "settlement_status"),
|
||||||
|
Index("idx_receipts_shipment_id", "shipment_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
receipt_batch_no: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)",
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("businesses.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id)",
|
||||||
|
)
|
||||||
|
shipment_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("shipments.id", ondelete="RESTRICT"),
|
||||||
|
comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)",
|
||||||
|
)
|
||||||
|
receipt_date: Mapped[date] = mapped_column(
|
||||||
|
Date, nullable=False, comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)"
|
||||||
|
)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2), nullable=False, comment="回款金额(≥0;一期禁负数/红字)"
|
||||||
|
)
|
||||||
|
is_barter: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)",
|
||||||
|
)
|
||||||
|
settlement_status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)",
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""月度结算与调整单模型(M3 ④:月结审批报表补充需求)。
|
||||||
|
|
||||||
|
- 月度结算按「确认回款」和计算快照生成;**通过即锁定**(status=3 已审定
|
||||||
|
即终态,锁定后源数据改动只产生提示与后续调整)
|
||||||
|
- settlement_items 明细按人固化(可负=负数工资),锁定后无 UPDATE 路径
|
||||||
|
(重生成 = 草稿/驳回态删旧插新)
|
||||||
|
- 调整单是已锁定月份的唯一调整通道(补发/扣减,走审批;生效后不回写
|
||||||
|
已锁定月结——append-only,报表汇总并入)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, DateTime, ForeignKey, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
# 月结状态(monthly_settlements.status;通过即锁定——两态合一)
|
||||||
|
SETTLEMENT_STATUS_DRAFT = 1 # 草稿(可重新生成/提交)
|
||||||
|
SETTLEMENT_STATUS_PENDING = 2 # 待审批(提交后)
|
||||||
|
SETTLEMENT_STATUS_APPROVED = 3 # 已审定(终态=已锁定)
|
||||||
|
SETTLEMENT_STATUS_REJECTED = 4 # 已驳回(回草稿可重提)
|
||||||
|
|
||||||
|
# 调整单状态(adjustment_orders.status)
|
||||||
|
ADJUSTMENT_STATUS_PENDING = 1 # 待审批
|
||||||
|
ADJUSTMENT_STATUS_EFFECTIVE = 2 # 已生效
|
||||||
|
ADJUSTMENT_STATUS_REJECTED = 3 # 已驳回
|
||||||
|
ADJUSTMENT_STATUS_WITHDRAWN = 4 # 已撤回
|
||||||
|
|
||||||
|
# 调整方向(adjustment_orders.direction)
|
||||||
|
ADJUSTMENT_DIRECTION_EXTRA = 1 # 补发
|
||||||
|
ADJUSTMENT_DIRECTION_DEDUCT = 2 # 扣减
|
||||||
|
|
||||||
|
|
||||||
|
class MonthlySettlement(Base, TimestampMixin):
|
||||||
|
"""月度结算表:按月一行(settlement_month 唯一),快照 + 审批 + 锁定。"""
|
||||||
|
|
||||||
|
__tablename__ = "monthly_settlements"
|
||||||
|
__table_args__ = mysql_table_args("月度结算表(按月一行;通过即锁定,锁定后走调整单)")
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
settlement_month: Mapped[str] = mapped_column(
|
||||||
|
String(7), nullable=False, unique=True, comment="结算月份(YYYY-MM;全表唯一)"
|
||||||
|
)
|
||||||
|
status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)",
|
||||||
|
)
|
||||||
|
total_performance: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2), comment="绩效金额合计(可负)"
|
||||||
|
)
|
||||||
|
total_payable: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2), comment="应付绩效合计(可负)"
|
||||||
|
)
|
||||||
|
person_count: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, server_default="0", comment="结算人数"
|
||||||
|
)
|
||||||
|
snapshot_json: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON, comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)"
|
||||||
|
)
|
||||||
|
approval_order_no: Mapped[str | None] = mapped_column(
|
||||||
|
String(32), comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)"
|
||||||
|
)
|
||||||
|
submitter_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="提交人ID(账号删除后置空)",
|
||||||
|
)
|
||||||
|
submitter_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="提交人用户名(冗余固化)"
|
||||||
|
)
|
||||||
|
locked_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime, comment="锁定时间(status=3 时回填)"
|
||||||
|
)
|
||||||
|
locked_by: Mapped[str | None] = mapped_column(String(64), comment="锁定操作人(冗余固化)")
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementItem(Base):
|
||||||
|
"""月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。"""
|
||||||
|
|
||||||
|
__tablename__ = "settlement_items"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"月结明细表(按人固化;支持负数工资;锁定后只读)",
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
settlement_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("monthly_settlements.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="月结ID(monthly_settlements.id;头删明细随删)",
|
||||||
|
)
|
||||||
|
personnel_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="结算人员ID(personnel.id)",
|
||||||
|
)
|
||||||
|
personnel_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="结算人员姓名(冗余固化)"
|
||||||
|
)
|
||||||
|
role_code: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, comment="岗位编码(主岗,聚合锚点)"
|
||||||
|
)
|
||||||
|
calc_type: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="核算类型:1单笔回款 2经理团队聚合"
|
||||||
|
)
|
||||||
|
performance_sum: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
nullable=False,
|
||||||
|
comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)",
|
||||||
|
)
|
||||||
|
payable_sum: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2), nullable=False, comment="应付绩效合计(可负)"
|
||||||
|
)
|
||||||
|
basic_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2), comment="月工资标准(personnel.salary_standard 当月快照)"
|
||||||
|
)
|
||||||
|
salary_mode: Mapped[str | None] = mapped_column(
|
||||||
|
String(16), comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE"
|
||||||
|
)
|
||||||
|
net_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2), comment="净工资 = 绩效±基本工资(按口径;试用期 0)"
|
||||||
|
)
|
||||||
|
is_probation: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
|
||||||
|
)
|
||||||
|
probation_note: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), comment="试用期说明(年累计超阈值部分年底一次性结算)"
|
||||||
|
)
|
||||||
|
snapshot_json: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON, comment="个人明细快照(台账行引用列表)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdjustmentOrder(Base, TimestampMixin):
|
||||||
|
"""调整单:已锁定月份的唯一调整通道(补发/扣减,走审批;不回写月结)。"""
|
||||||
|
|
||||||
|
__tablename__ = "adjustment_orders"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)",
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
order_no: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
comment="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}",
|
||||||
|
)
|
||||||
|
settlement_month: Mapped[str] = mapped_column(
|
||||||
|
String(7), nullable=False, comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)"
|
||||||
|
)
|
||||||
|
personnel_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="调整人员ID(personnel.id)",
|
||||||
|
)
|
||||||
|
personnel_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="调整人员姓名(冗余固化)"
|
||||||
|
)
|
||||||
|
direction: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="方向:1补发 2扣减"
|
||||||
|
)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2), nullable=False, comment="调整金额(恒正,方向表达语义)"
|
||||||
|
)
|
||||||
|
reason: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, comment="扣补说明(必填,审批与追溯锚点)"
|
||||||
|
)
|
||||||
|
status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="状态:1待审批 2已生效 3已驳回 4已撤回",
|
||||||
|
)
|
||||||
|
submitter_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="提交人ID(账号删除后置空)",
|
||||||
|
)
|
||||||
|
submitter_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="提交人用户名(冗余固化)"
|
||||||
|
)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""发货模型:发货单与发货产品明细。
|
||||||
|
|
||||||
|
- ship_date 是**回款天数计算基准日**(对照表 #1:发货日首日计第 1 天),
|
||||||
|
回款指定本发货单后由 formula.payment_days 派生回款天数,不在本表落库
|
||||||
|
- 运费仅记录展示,不计入毛利润口径(对照表 #2)
|
||||||
|
- total_amount 为明细行汇总冗余,服务层事务内维护
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import (
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
Base,
|
||||||
|
TimestampMixin,
|
||||||
|
TPlusSyncMixin,
|
||||||
|
mysql_table_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Shipment(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
"""发货单表(需求 2.2.2):每笔发货对应一个业务,支持分批发货。"""
|
||||||
|
|
||||||
|
__tablename__ = "shipments"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"发货单表(发货日为回款天数基准日,发货日首日计第 1 天)",
|
||||||
|
Index("idx_shipments_business_id", "business_id"),
|
||||||
|
Index("idx_shipments_ship_date", "ship_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
shipment_no: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)",
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("businesses.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id;终态业务禁止新发货)",
|
||||||
|
)
|
||||||
|
ship_date: Mapped[date] = mapped_column(
|
||||||
|
Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)"
|
||||||
|
)
|
||||||
|
freight_amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
|
||||||
|
)
|
||||||
|
total_amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentItem(Base, TimestampMixin):
|
||||||
|
"""发货产品明细表(需求 2.2.2):记录发货的产品名称、规格、数量等。
|
||||||
|
|
||||||
|
价格口径两可(T+ 供价或人工录入,待 IT 确认):sale_price 可空人工补,
|
||||||
|
amount 由服务层按 数量×单价 计算固化。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "shipment_items"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"发货产品明细表",
|
||||||
|
Index("idx_shipment_items_shipment_id", "shipment_id"),
|
||||||
|
Index("idx_shipment_items_product_id", "product_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
shipment_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("shipments.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="发货单ID(shipments.id)",
|
||||||
|
)
|
||||||
|
product_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("products.id", ondelete="RESTRICT"),
|
||||||
|
comment="产品ID(products.id;NULL=自由录入)",
|
||||||
|
)
|
||||||
|
product_name: Mapped[str] = mapped_column(
|
||||||
|
String(128), nullable=False, comment="产品名称(引用产品时冗余固化)"
|
||||||
|
)
|
||||||
|
spec_model: Mapped[str] = mapped_column(
|
||||||
|
String(128),
|
||||||
|
nullable=False,
|
||||||
|
server_default="''",
|
||||||
|
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
|
||||||
|
)
|
||||||
|
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
|
||||||
|
quantity: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2), nullable=False, comment="发货数量(>0)"
|
||||||
|
)
|
||||||
|
sale_price: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 4), comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)"
|
||||||
|
)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
nullable=False,
|
||||||
|
comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)",
|
||||||
|
)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""系统设置模型:公司信息 + 站点信息单例行。
|
||||||
|
|
||||||
|
与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期
|
||||||
|
时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列
|
||||||
|
value_text 为 String(64) 装不下;故独立单行表。logo/favicon 只存相对路径——
|
||||||
|
文件在 `backend/uploads/{logo,favicon}/`,独立于数据库备份体积且可单独替换
|
||||||
|
(见 system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底)。
|
||||||
|
|
||||||
|
2026-10-20 精简(用户确认):企业档案字段(信用代码/地址/电话/邮箱)与
|
||||||
|
系统副标题删除,新增**站点标题**(浏览器标签页/侧边栏副标题)与**站点图标**
|
||||||
|
(浏览器标签页 favicon,独立于登录页 logo)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSetting(Base, TimestampMixin):
|
||||||
|
"""系统设置表:公司信息 + 站点信息,全系统单行 id=1。
|
||||||
|
|
||||||
|
brand_slogan/footer_text/icp_no/site_title 均可空:null 表示前端回退
|
||||||
|
内置默认值,存量行为零变化。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "system_settings"
|
||||||
|
__table_args__ = mysql_table_args("系统设置表(公司信息与站点信息,全系统单行)")
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
company_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="公司名称(登录页/侧边栏/浏览器标题展示)"
|
||||||
|
)
|
||||||
|
logo_path: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
comment="公司 logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
|
||||||
|
)
|
||||||
|
logo_version: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)",
|
||||||
|
)
|
||||||
|
site_title: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)"
|
||||||
|
)
|
||||||
|
brand_slogan: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), comment="品牌标语(登录页与品牌预览;null=内置默认)"
|
||||||
|
)
|
||||||
|
footer_text: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)"
|
||||||
|
)
|
||||||
|
icp_no: Mapped[str | None] = mapped_column(
|
||||||
|
String(32), comment="ICP 备案号(登录页页脚,空=不显示)"
|
||||||
|
)
|
||||||
|
favicon_path: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
comment="站点图标相对路径(文件存 uploads/favicon/,null=跟随公司 logo / 内置 favicon.svg)",
|
||||||
|
)
|
||||||
|
favicon_version: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)",
|
||||||
|
)
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""业务数据仓储:业务单、明细、状态留痕与归属留痕。
|
||||||
|
|
||||||
|
只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。
|
||||||
|
归属留痕复用 ownership_change_logs(owner_type=2 为业务维度,常量在
|
||||||
|
models/customer.py 定义)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import Row, Select, delete, exists, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
Business,
|
||||||
|
BusinessItem,
|
||||||
|
BusinessStatusLog,
|
||||||
|
Customer,
|
||||||
|
OwnershipChangeLog,
|
||||||
|
Personnel,
|
||||||
|
Receipt,
|
||||||
|
SecondaryAllocation,
|
||||||
|
Shipment,
|
||||||
|
)
|
||||||
|
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||||
|
from app.repositories import ownership_log_repository
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
|
||||||
|
"""按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。"""
|
||||||
|
stmt = select(Business).where(Business.id == business_id)
|
||||||
|
if for_update:
|
||||||
|
stmt = stmt.with_for_update()
|
||||||
|
return session.execute(stmt).scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
def has_docs(session: Session, business_id: int) -> bool:
|
||||||
|
"""业务名下是否已有发货单或回款(换客户/删除保护的引用检查)。
|
||||||
|
|
||||||
|
单条 OR exists 一次往返;新增挂靠业务的单据类型时在此补充条件,
|
||||||
|
编辑与删除两处共用同一判定,防「delete 补了检查、update 漏补」的分叉。
|
||||||
|
"""
|
||||||
|
stmt = select(
|
||||||
|
or_(
|
||||||
|
exists().where(Shipment.business_id == business_id),
|
||||||
|
exists().where(Receipt.business_id == business_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return bool(session.execute(stmt).scalar())
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, business: Business) -> None:
|
||||||
|
"""新增业务行(不 commit,flush 让自增 id 就位)。"""
|
||||||
|
session.add(business)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def get_with_names(
|
||||||
|
session: Session, business_id: int, *, for_update: bool = False
|
||||||
|
) -> tuple[Business, str, str | None, str | None] | None:
|
||||||
|
"""业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。
|
||||||
|
|
||||||
|
for_update 供编辑/状态流转/归属变更使用:与发货/回款写路径的
|
||||||
|
get_by_id(for_update=True) 同一锁口径,编辑事务与单据写事务互斥,
|
||||||
|
「有发货禁换客户」的快照读校验不再被并发新增旁路。
|
||||||
|
"""
|
||||||
|
owner = aliased(Personnel)
|
||||||
|
assistant = aliased(Personnel)
|
||||||
|
stmt = (
|
||||||
|
select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name)
|
||||||
|
.join(Customer, Customer.id == Business.customer_id)
|
||||||
|
.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
||||||
|
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
|
||||||
|
.where(Business.id == business_id)
|
||||||
|
)
|
||||||
|
if for_update:
|
||||||
|
stmt = stmt.with_for_update()
|
||||||
|
return session.execute(stmt).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: Select,
|
||||||
|
*,
|
||||||
|
keyword: str | None,
|
||||||
|
status: int | None,
|
||||||
|
customer_id: int | None,
|
||||||
|
owner_personnel_id: int | None,
|
||||||
|
date_from: date | None,
|
||||||
|
date_to: date | None,
|
||||||
|
) -> Select:
|
||||||
|
"""业务列表的筛选条件(调用方须已 join Customer)。"""
|
||||||
|
if keyword:
|
||||||
|
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
|
||||||
|
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Business.business_no.contains(keyword, autoescape=True),
|
||||||
|
Customer.company_name.contains(keyword, autoescape=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status is not None:
|
||||||
|
stmt = stmt.where(Business.status == status)
|
||||||
|
if customer_id is not None:
|
||||||
|
stmt = stmt.where(Business.customer_id == customer_id)
|
||||||
|
if owner_personnel_id is not None:
|
||||||
|
stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id)
|
||||||
|
if date_from is not None:
|
||||||
|
stmt = stmt.where(Business.business_date >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
stmt = stmt.where(Business.business_date <= date_to)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def list_businesses(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
keyword: str | None,
|
||||||
|
status: int | None,
|
||||||
|
customer_id: int | None,
|
||||||
|
owner_personnel_id: int | None,
|
||||||
|
date_from: date | None,
|
||||||
|
date_to: date | None,
|
||||||
|
) -> list[tuple[Business, str, str | None, str | None]]:
|
||||||
|
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
|
||||||
|
owner = aliased(Personnel)
|
||||||
|
assistant = aliased(Personnel)
|
||||||
|
stmt = select(
|
||||||
|
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
|
||||||
|
).join(Customer, Customer.id == Business.customer_id)
|
||||||
|
stmt = _apply_filters(
|
||||||
|
stmt,
|
||||||
|
keyword=keyword,
|
||||||
|
status=status,
|
||||||
|
customer_id=customer_id,
|
||||||
|
owner_personnel_id=owner_personnel_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
||||||
|
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
|
||||||
|
.order_by(Business.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
|
def count_businesses(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
keyword: str | None,
|
||||||
|
status: int | None,
|
||||||
|
customer_id: int | None,
|
||||||
|
owner_personnel_id: int | None,
|
||||||
|
date_from: date | None,
|
||||||
|
date_to: date | None,
|
||||||
|
) -> int:
|
||||||
|
stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Business)
|
||||||
|
.join(Customer, Customer.id == Business.customer_id)
|
||||||
|
)
|
||||||
|
stmt = _apply_filters(
|
||||||
|
stmt,
|
||||||
|
keyword=keyword,
|
||||||
|
status=status,
|
||||||
|
customer_id=customer_id,
|
||||||
|
owner_personnel_id=owner_personnel_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def list_items(session: Session, business_id: int) -> list[BusinessItem]:
|
||||||
|
"""业务明细行(编辑回显与汇总重算共用)。"""
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(BusinessItem)
|
||||||
|
.where(BusinessItem.business_id == business_id)
|
||||||
|
.order_by(BusinessItem.id)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_items(session: Session, business_id: int, rows: list[BusinessItem]) -> None:
|
||||||
|
"""全量替换明细:先删、立即 flush、再插入(replace_roles 先例的防御写法)。
|
||||||
|
|
||||||
|
同一 flush 中 SQLAlchemy 先处理 INSERT 后处理 DELETE,先删后插不落库
|
||||||
|
会在有唯一键的表上撞键;business_items 虽无唯一键,统一按此写法防御。
|
||||||
|
"""
|
||||||
|
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
|
||||||
|
session.flush()
|
||||||
|
for row in rows:
|
||||||
|
row.business_id = business_id
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]:
|
||||||
|
"""二次分配方案(详情回显;空列表=未设置方案)。"""
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(SecondaryAllocation)
|
||||||
|
.where(SecondaryAllocation.business_id == business_id)
|
||||||
|
.order_by(SecondaryAllocation.id)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_allocations(
|
||||||
|
session: Session, business_id: int, rows: list[SecondaryAllocation]
|
||||||
|
) -> None:
|
||||||
|
"""全量替换二次分配方案(不 commit,事务边界在服务层)。
|
||||||
|
|
||||||
|
本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的
|
||||||
|
防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。
|
||||||
|
"""
|
||||||
|
session.execute(
|
||||||
|
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
for row in rows:
|
||||||
|
row.business_id = business_id
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_allocations(session: Session, business_id: int) -> None:
|
||||||
|
"""清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。
|
||||||
|
|
||||||
|
被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到);
|
||||||
|
方案事实由审批单留痕与操作日志 request_data 承载。
|
||||||
|
"""
|
||||||
|
session.execute(
|
||||||
|
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def add_status_log(session: Session, log: BusinessStatusLog) -> None:
|
||||||
|
"""追加状态留痕(不 commit)。"""
|
||||||
|
session.add(log)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def list_status_logs(
|
||||||
|
session: Session, business_id: int, *, offset: int, limit: int
|
||||||
|
) -> list[BusinessStatusLog]:
|
||||||
|
"""状态流转时间线(详情页),按时间正序(时间线从创建起展示)。"""
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(BusinessStatusLog)
|
||||||
|
.where(BusinessStatusLog.business_id == business_id)
|
||||||
|
.order_by(BusinessStatusLog.create_time, BusinessStatusLog.id)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_status_logs(session: Session, business_id: int) -> int:
|
||||||
|
return int(
|
||||||
|
session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(BusinessStatusLog)
|
||||||
|
.where(BusinessStatusLog.business_id == business_id)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None:
|
||||||
|
"""追加归属变更留痕(共享实现,不 commit)。"""
|
||||||
|
ownership_log_repository.add(session, log)
|
||||||
|
|
||||||
|
|
||||||
|
def list_ownership_logs(
|
||||||
|
session: Session, business_id: int, *, offset: int, limit: int
|
||||||
|
) -> list[Row]:
|
||||||
|
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
|
||||||
|
return ownership_log_repository.list_by_ref(
|
||||||
|
session,
|
||||||
|
owner_type=OWNER_TYPE_BUSINESS,
|
||||||
|
ref_column=OwnershipChangeLog.business_id,
|
||||||
|
ref_id=business_id,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_ownership_logs(session: Session, business_id: int) -> int:
|
||||||
|
return ownership_log_repository.count_by_ref(
|
||||||
|
session,
|
||||||
|
owner_type=OWNER_TYPE_BUSINESS,
|
||||||
|
ref_column=OwnershipChangeLog.business_id,
|
||||||
|
ref_id=business_id,
|
||||||
|
)
|
||||||
@@ -25,6 +25,20 @@ def get_by_code(session: Session, role_code: str) -> BusinessRole | None:
|
|||||||
return session.execute(stmt).scalar_one_or_none()
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, BusinessRole]:
|
||||||
|
"""按编码批量取岗位(参数批次校验段用,消除逐条 N+1)。
|
||||||
|
|
||||||
|
返回 {编码: 岗位}。MySQL ci 校对下编码大小写变形也能命中,
|
||||||
|
键取库里的规范 role_code,调用方据此把维度值归一为规范值。
|
||||||
|
"""
|
||||||
|
if not role_codes:
|
||||||
|
return {}
|
||||||
|
rows = session.execute(
|
||||||
|
select(BusinessRole).where(BusinessRole.role_code.in_(role_codes))
|
||||||
|
).scalars()
|
||||||
|
return {role.role_code: role for role in rows}
|
||||||
|
|
||||||
|
|
||||||
def list_roles(
|
def list_roles(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""客户评级数据访问(每客户一行)。
|
||||||
|
|
||||||
|
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import CustomerRating
|
||||||
|
|
||||||
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_customer_id(session: Session, customer_id: int) -> CustomerRating | None:
|
||||||
|
"""查某客户的当前评级(uk_customer_ratings_customer_id 保证至多一行)。"""
|
||||||
|
stmt = select(CustomerRating).where(CustomerRating.customer_id == customer_id)
|
||||||
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 写入 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, rating: CustomerRating) -> CustomerRating:
|
||||||
|
"""新增评级行。只 flush 以取得自增 id。"""
|
||||||
|
session.add(rating)
|
||||||
|
session.flush()
|
||||||
|
return rating
|
||||||
|
|
||||||
|
|
||||||
|
def delete(session: Session, rating: CustomerRating) -> None:
|
||||||
|
"""删除评级行(客户物理删除前的联动清理,调用方先校验业务引用)。"""
|
||||||
|
session.delete(rating)
|
||||||
|
session.flush()
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""客户数据访问(含归属变更留痕)。
|
||||||
|
|
||||||
|
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import ColumnElement, Row, Select, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Customer, CustomerRating, OwnershipChangeLog, Personnel
|
||||||
|
from app.models.customer import OWNER_TYPE_CUSTOMER
|
||||||
|
from app.repositories import ownership_log_repository
|
||||||
|
from app.repositories.query_utils import fuzzy
|
||||||
|
|
||||||
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, customer_id: int) -> Customer | None:
|
||||||
|
"""按主键查询客户。"""
|
||||||
|
return session.get(Customer, customer_id)
|
||||||
|
|
||||||
|
|
||||||
|
def count_business_refs(session: Session, customer_id: int) -> int:
|
||||||
|
"""被业务单据引用的次数(删除保护;业务表就位后补齐的检查)。"""
|
||||||
|
from app.models import Business
|
||||||
|
|
||||||
|
return int(
|
||||||
|
session.execute(
|
||||||
|
select(func.count()).select_from(Business).where(Business.customer_id == customer_id)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_with_owner_name(session: Session, customer_id: int) -> Row | None:
|
||||||
|
"""查单个客户并带出归属人员姓名与评级快照(详情/编辑回显/归属变更共用)。"""
|
||||||
|
stmt = _base_select().where(Customer.id == customer_id)
|
||||||
|
return session.execute(stmt).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_company_owner(session: Session, company_name: str, owner_name: str) -> Customer | None:
|
||||||
|
"""按「公司名称 + 负责人」查客户(uk_customers_company_owner 的先查后写兜底)。"""
|
||||||
|
stmt = select(Customer).where(
|
||||||
|
Customer.company_name == company_name,
|
||||||
|
Customer.owner_name == owner_name,
|
||||||
|
)
|
||||||
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def list_customers(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
keyword: str | None = None,
|
||||||
|
customer_type: str | None = None,
|
||||||
|
owner_personnel_id: int | None = None,
|
||||||
|
order_field: str | None = None,
|
||||||
|
order_type: str | None = None,
|
||||||
|
) -> list[Row]:
|
||||||
|
"""分页查询客户,行内容为 (客户, 归属人员姓名, 评级总分, 评级等级)。
|
||||||
|
|
||||||
|
归属人员/评级行均用 outerjoin:关联行不可考(人员被物理删除、客户
|
||||||
|
未评级)不应让客户行整个查不出来(同 personnel_repository 韧性口径)。
|
||||||
|
默认排序按 id 倒序:新增的靠前符合列表页习惯;评级排序见 ``_apply_order``。
|
||||||
|
"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
_base_select(),
|
||||||
|
keyword=keyword,
|
||||||
|
customer_type=customer_type,
|
||||||
|
owner_personnel_id=owner_personnel_id,
|
||||||
|
)
|
||||||
|
stmt = _apply_order(stmt, order_field=order_field, order_type=order_type)
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
|
def count_customers(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
customer_type: str | None = None,
|
||||||
|
owner_personnel_id: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""统计符合条件的客户总数,供分页使用。
|
||||||
|
|
||||||
|
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||||||
|
``_apply_filters`` 单点保证)。
|
||||||
|
"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(func.count(Customer.id)),
|
||||||
|
keyword=keyword,
|
||||||
|
customer_type=customer_type,
|
||||||
|
owner_personnel_id=owner_personnel_id,
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def list_ownership_logs(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
customer_id: int,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
) -> list[Row]:
|
||||||
|
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
|
||||||
|
return ownership_log_repository.list_by_ref(
|
||||||
|
session,
|
||||||
|
owner_type=OWNER_TYPE_CUSTOMER,
|
||||||
|
ref_column=OwnershipChangeLog.customer_id,
|
||||||
|
ref_id=customer_id,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
|
||||||
|
"""统计某客户的归属留痕总数,供分页使用。"""
|
||||||
|
return ownership_log_repository.count_by_ref(
|
||||||
|
session,
|
||||||
|
owner_type=OWNER_TYPE_CUSTOMER,
|
||||||
|
ref_column=OwnershipChangeLog.customer_id,
|
||||||
|
ref_id=customer_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _base_select() -> Select:
|
||||||
|
"""客户主表外联归属人员姓名与评级快照(等级名/总分,列表评级列免 N+1)。"""
|
||||||
|
return (
|
||||||
|
select(
|
||||||
|
Customer,
|
||||||
|
Personnel.personnel_name,
|
||||||
|
CustomerRating.total_score,
|
||||||
|
CustomerRating.grade_name,
|
||||||
|
)
|
||||||
|
.outerjoin(Personnel, Customer.owner_personnel_id == Personnel.id)
|
||||||
|
.outerjoin(CustomerRating, CustomerRating.customer_id == Customer.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_order(stmt: Select, *, order_field: str | None, order_type: str | None) -> Select:
|
||||||
|
"""应用列表排序。默认 id 倒序;order_field=rating 按评级总分排序。
|
||||||
|
|
||||||
|
等级由总分判定、与总分同向,故按总分即等价按等级排序。MySQL 没有
|
||||||
|
``NULLS LAST`` 语法,用布尔键 ``total_score IS NULL`` 升序模拟 ——
|
||||||
|
未评级行恒在末尾,正序/倒序一致;同分以 id 倒序兜底保证次序稳定。
|
||||||
|
非法字段/方向不落到 SQL(schema Literal 已在入口拦 400),此处静默
|
||||||
|
回退默认排序作为纵深防御。
|
||||||
|
"""
|
||||||
|
if order_field == "rating" and order_type in ("asc", "desc"):
|
||||||
|
score_order = (
|
||||||
|
CustomerRating.total_score.asc()
|
||||||
|
if order_type == "asc"
|
||||||
|
else CustomerRating.total_score.desc()
|
||||||
|
)
|
||||||
|
return stmt.order_by(CustomerRating.total_score.is_(None), score_order, Customer.id.desc())
|
||||||
|
return stmt.order_by(Customer.id.desc())
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: Select,
|
||||||
|
*,
|
||||||
|
keyword: str | None,
|
||||||
|
customer_type: str | None,
|
||||||
|
owner_personnel_id: int | None,
|
||||||
|
) -> Select:
|
||||||
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||||
|
if keyword:
|
||||||
|
stmt = stmt.where(_keyword_clause(keyword))
|
||||||
|
if customer_type:
|
||||||
|
stmt = stmt.where(Customer.customer_type == customer_type)
|
||||||
|
if owner_personnel_id is not None:
|
||||||
|
stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def _keyword_clause(keyword: str) -> ColumnElement[bool]:
|
||||||
|
"""关键词条件:公司名称或客户负责人两列 OR 模糊。
|
||||||
|
|
||||||
|
借 ``query_utils.fuzzy`` 在空 select 上生成单列条件后取 whereclause 做
|
||||||
|
OR 合并 —— LIKE 通配符转义口径单点维护;两处手写 ``contains`` 迟早有
|
||||||
|
一处漏掉 ``autoescape``(人名含下划线是常态,不转义会当单字符通配执行)。
|
||||||
|
"""
|
||||||
|
probe = select(Customer.id)
|
||||||
|
return or_(
|
||||||
|
fuzzy(probe, Customer.company_name, keyword).whereclause,
|
||||||
|
fuzzy(probe, Customer.owner_name, keyword).whereclause,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 写入 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, customer: Customer) -> Customer:
|
||||||
|
"""新增客户。只 flush 以取得自增 id。"""
|
||||||
|
session.add(customer)
|
||||||
|
session.flush()
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
|
def delete(session: Session, customer: Customer) -> None:
|
||||||
|
"""删除客户。调用方须先完成引用保护校验。"""
|
||||||
|
session.delete(customer)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
|
||||||
|
"""写入一条归属变更留痕(共享实现)。只 flush 不 commit,与主表更新同事务。"""
|
||||||
|
return ownership_log_repository.add(session, log)
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""工作台仓储:四区块的只读聚合查询;不 commit、不抛业务异常。
|
||||||
|
|
||||||
|
总览只做 SQL 聚合(SUM/COUNT DISTINCT),不复用报表的
|
||||||
|
``department_summary``——后者会加载整月台账并逐团队查人员,适合报表页,
|
||||||
|
不适合每次打开工作台。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import exists, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
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`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。
|
||||||
|
"""
|
||||||
|
if not statuses:
|
||||||
|
return []
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(ApprovalOrder)
|
||||||
|
.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]:
|
||||||
|
"""提交后超过阈值仍未处理的审批单(审批超时规则)。"""
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(ApprovalOrder)
|
||||||
|
.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()
|
||||||
|
)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""归属变更留痕的共享数据访问(客户/业务两维度共用)。
|
||||||
|
|
||||||
|
owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧各持一套逐行
|
||||||
|
同构的 list/count/add,排序口径与 join 兜底的调整须同步两处;收口后
|
||||||
|
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Row, func, select
|
||||||
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
|
from app.models import OwnershipChangeLog, Personnel
|
||||||
|
|
||||||
|
|
||||||
|
def list_by_ref(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
owner_type: int,
|
||||||
|
ref_column,
|
||||||
|
ref_id: int,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
) -> list[Row]:
|
||||||
|
"""查某引用对象的归属留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。
|
||||||
|
|
||||||
|
人员删除后 join 出 None,留痕固化 ID 兜底可读。按 (create_time, id)
|
||||||
|
双键倒序:create_time 是秒级精度,同一秒内多条排序不确定,id 兜底
|
||||||
|
保证翻页稳定(与操作日志同一理由)。
|
||||||
|
"""
|
||||||
|
old_person = aliased(Personnel)
|
||||||
|
new_person = aliased(Personnel)
|
||||||
|
stmt = (
|
||||||
|
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
|
||||||
|
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
|
||||||
|
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
|
||||||
|
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
|
||||||
|
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
|
def count_by_ref(session: Session, *, owner_type: int, ref_column, ref_id: int) -> int:
|
||||||
|
"""统计某引用对象的归属留痕总数,供分页使用。"""
|
||||||
|
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
||||||
|
OwnershipChangeLog.owner_type == owner_type,
|
||||||
|
ref_column == ref_id,
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def add(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
|
||||||
|
"""写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。"""
|
||||||
|
session.add(log)
|
||||||
|
session.flush()
|
||||||
|
return log
|
||||||
@@ -81,6 +81,21 @@ def get_definition_by_code(session: Session, param_code: str) -> ParamDefinition
|
|||||||
return session.execute(stmt).scalar_one_or_none()
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def list_definitions_by_codes(
|
||||||
|
session: Session, param_codes: list[str]
|
||||||
|
) -> dict[str, ParamDefinition]:
|
||||||
|
"""按编码批量取参数定义(提交批次的校验段用,消除逐条 N+1)。
|
||||||
|
|
||||||
|
返回 {编码: 定义};未定义的编码不在结果中,由调用方判缺失。
|
||||||
|
"""
|
||||||
|
if not param_codes:
|
||||||
|
return {}
|
||||||
|
rows = session.execute(
|
||||||
|
select(ParamDefinition).where(ParamDefinition.param_code.in_(param_codes))
|
||||||
|
).scalars()
|
||||||
|
return {definition.param_code: definition for definition in rows}
|
||||||
|
|
||||||
|
|
||||||
def list_history(
|
def list_history(
|
||||||
session: Session,
|
session: Session,
|
||||||
param_id: int,
|
param_id: int,
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""绩效核算台账仓储:只读写数据并 flush;commit/rollback 在服务层。"""
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import PerformanceLedger
|
||||||
|
from app.models.performance_ledger import CALC_TYPE_MANAGER_AGGREGATE
|
||||||
|
from app.schemas.calc import LedgerQuery
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, ledger_id: int) -> PerformanceLedger | None:
|
||||||
|
"""按主键取台账行。"""
|
||||||
|
return session.get(PerformanceLedger, ledger_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_by_receipt(
|
||||||
|
session: Session, receipt_id: int, *, calc_type: int = 1
|
||||||
|
) -> PerformanceLedger | None:
|
||||||
|
"""取某回款的当前生效核算行(is_active=1;无则 None)。
|
||||||
|
|
||||||
|
二次分摊拆行后同回款可有多行(负责人+参与人)——重算作废用
|
||||||
|
``list_active_by_receipt``;本函数仅限确知单行的调用方(重算前的
|
||||||
|
存在性判断按业务维度走 has_active_for_business)。
|
||||||
|
"""
|
||||||
|
return session.execute(
|
||||||
|
select(PerformanceLedger).where(
|
||||||
|
PerformanceLedger.receipt_id == receipt_id,
|
||||||
|
PerformanceLedger.calc_type == calc_type,
|
||||||
|
PerformanceLedger.is_active == 1,
|
||||||
|
)
|
||||||
|
).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:
|
||||||
|
"""该业务是否已有生效核算行(核算粒度 #25:业务级一次性计提的判定)。"""
|
||||||
|
return (
|
||||||
|
session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(PerformanceLedger)
|
||||||
|
.where(
|
||||||
|
PerformanceLedger.business_id == business_id,
|
||||||
|
PerformanceLedger.calc_type == calc_type,
|
||||||
|
PerformanceLedger.is_active == 1,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
> 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_by_receipt_of_business(
|
||||||
|
session: Session, business_id: int, *, calc_type: int = 1
|
||||||
|
) -> PerformanceLedger:
|
||||||
|
"""取该业务当前生效的核算行(重复回款登记进度时回显既有台账)。"""
|
||||||
|
return (
|
||||||
|
session.execute(
|
||||||
|
select(PerformanceLedger)
|
||||||
|
.where(
|
||||||
|
PerformanceLedger.business_id == business_id,
|
||||||
|
PerformanceLedger.calc_type == calc_type,
|
||||||
|
PerformanceLedger.is_active == 1,
|
||||||
|
)
|
||||||
|
.order_by(PerformanceLedger.id.desc())
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add(session: Session, row: PerformanceLedger) -> PerformanceLedger:
|
||||||
|
"""落台账行(flush 取 id;commit 在服务层)。"""
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def mark_inactive(session: Session, row: PerformanceLedger) -> None:
|
||||||
|
"""重算时把旧版本行翻为已替代(全表唯一的受控 UPDATE 例外;不 commit)。"""
|
||||||
|
row.is_active = 0
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def deactivate_month_aggregates(session: Session, month: str) -> None:
|
||||||
|
"""月结重生成时作废该月全部经理聚合行(calc_type=2)。
|
||||||
|
|
||||||
|
聚合行每次生成重建,旧行不翻 0 会与新行重复计入(按人聚合按 is_active
|
||||||
|
过滤)。与 mark_inactive 同属 is_active 翻 0 的受控 UPDATE 例外;set 批量
|
||||||
|
版本而非逐行——聚合行一个月至多几行,且无逐行回读必要。
|
||||||
|
"""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
session.execute(
|
||||||
|
update(PerformanceLedger)
|
||||||
|
.where(
|
||||||
|
PerformanceLedger.settlement_month == month,
|
||||||
|
PerformanceLedger.calc_type == CALC_TYPE_MANAGER_AGGREGATE,
|
||||||
|
PerformanceLedger.is_active == 1,
|
||||||
|
)
|
||||||
|
.values(is_active=0)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def next_version(session: Session, receipt_id: int) -> int:
|
||||||
|
"""某回款的下一个核算版本号(max+1;无行时 1)。"""
|
||||||
|
current = session.execute(
|
||||||
|
select(func.max(PerformanceLedger.calc_version)).where(
|
||||||
|
PerformanceLedger.receipt_id == receipt_id
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
return int(current or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def list_ledger(session: Session, query: LedgerQuery) -> list[PerformanceLedger]:
|
||||||
|
"""分页查询台账(筛选:月/人员/业务/生效/类型)。"""
|
||||||
|
stmt = select(PerformanceLedger).where(*_filters(query))
|
||||||
|
stmt = stmt.order_by(PerformanceLedger.id.desc()).offset(query.offset).limit(query.page_size)
|
||||||
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def count_ledger(session: Session, query: LedgerQuery) -> int:
|
||||||
|
"""台账计数(与 list_ledger 同筛选)。"""
|
||||||
|
return int(
|
||||||
|
session.execute(
|
||||||
|
select(func.count()).select_from(PerformanceLedger).where(*_filters(query))
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _filters(query: LedgerQuery) -> tuple:
|
||||||
|
filters = []
|
||||||
|
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.business_id is not None:
|
||||||
|
filters.append(PerformanceLedger.business_id == query.business_id)
|
||||||
|
if query.is_active is not None:
|
||||||
|
filters.append(PerformanceLedger.is_active == query.is_active)
|
||||||
|
if query.calc_type is not None:
|
||||||
|
filters.append(PerformanceLedger.calc_type == query.calc_type)
|
||||||
|
return tuple(filters)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""营销人员数据访问(含辐射区域与拓展计划子表)。
|
||||||
|
|
||||||
|
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Row, Select, func, select
|
||||||
|
from sqlalchemy import delete as sa_delete
|
||||||
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
|
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
|
||||||
|
from app.repositories.query_utils import fuzzy
|
||||||
|
|
||||||
|
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
|
||||||
|
_manager = aliased(Personnel)
|
||||||
|
|
||||||
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, personnel_id: int, *, for_update: bool = False) -> Personnel | None:
|
||||||
|
"""按主键查询。删除校验走 ``for_update`` 锁行(同 business_role_repository 的理由)。"""
|
||||||
|
if for_update:
|
||||||
|
stmt = select(Personnel).where(Personnel.id == personnel_id).with_for_update()
|
||||||
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
return session.get(Personnel, personnel_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_with_names(session: Session, personnel_id: int) -> Row | None:
|
||||||
|
"""查单个人员并带出岗位名称与直属经理姓名(详情/编辑回显共用)。"""
|
||||||
|
stmt = _base_select().where(Personnel.id == personnel_id)
|
||||||
|
return session.execute(stmt).first()
|
||||||
|
|
||||||
|
|
||||||
|
def list_personnel(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
keyword: str | None = None,
|
||||||
|
status: int | None = None,
|
||||||
|
business_role_id: int | None = None,
|
||||||
|
) -> list[Row]:
|
||||||
|
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
|
||||||
|
|
||||||
|
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
|
||||||
|
"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
_base_select(), keyword=keyword, status=status, business_role_id=business_role_id
|
||||||
|
)
|
||||||
|
stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit)
|
||||||
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
|
def count_personnel(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
status: int | None = None,
|
||||||
|
business_role_id: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""统计符合条件的人员总数,供分页使用。
|
||||||
|
|
||||||
|
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||||||
|
``_apply_filters`` 单点保证)。
|
||||||
|
"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(func.count(Personnel.id)),
|
||||||
|
keyword=keyword,
|
||||||
|
status=status,
|
||||||
|
business_role_id=business_role_id,
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def count_team_members(session: Session, manager_ids: list[int]) -> dict[int, int]:
|
||||||
|
"""按直属经理批量统计团队成员数(删除保护的依据)。
|
||||||
|
|
||||||
|
一次 GROUP BY 完成,禁止调用方在列表循环内逐行查询。
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(Personnel.manager_id, func.count(Personnel.id))
|
||||||
|
.where(Personnel.manager_id.in_(manager_ids))
|
||||||
|
.group_by(Personnel.manager_id)
|
||||||
|
)
|
||||||
|
return {int(manager_id): int(count) for manager_id, count in session.execute(stmt).all()}
|
||||||
|
|
||||||
|
|
||||||
|
def count_customer_ownerships(session: Session, personnel_ids: list[int]) -> dict[int, int]:
|
||||||
|
"""按归属人员批量统计客户数(删除保护的另一半依据)。"""
|
||||||
|
stmt = (
|
||||||
|
select(Customer.owner_personnel_id, func.count(Customer.id))
|
||||||
|
.where(Customer.owner_personnel_id.in_(personnel_ids))
|
||||||
|
.group_by(Customer.owner_personnel_id)
|
||||||
|
)
|
||||||
|
return {int(owner_id): int(count) for owner_id, count in session.execute(stmt).all()}
|
||||||
|
|
||||||
|
|
||||||
|
def count_business_roles_refs(session: Session, personnel_ids: list[int]) -> dict[int, int]:
|
||||||
|
"""按人员批量统计业务单据归属引用(负责/协助合计;业务表就位后补齐的检查)。
|
||||||
|
|
||||||
|
businesses.owner/assistant_personnel_id 均为 RESTRICT 外键——漏检会让
|
||||||
|
「删除被业务引用的人员」以 500 暴露而非可读的 400。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models import Business
|
||||||
|
|
||||||
|
owner = (
|
||||||
|
select(Business.owner_personnel_id, func.count(Business.id))
|
||||||
|
.where(Business.owner_personnel_id.in_(personnel_ids))
|
||||||
|
.group_by(Business.owner_personnel_id)
|
||||||
|
)
|
||||||
|
assistant = (
|
||||||
|
select(Business.assistant_personnel_id, func.count(Business.id))
|
||||||
|
.where(Business.assistant_personnel_id.in_(personnel_ids))
|
||||||
|
.group_by(Business.assistant_personnel_id)
|
||||||
|
)
|
||||||
|
refs: dict[int, int] = {}
|
||||||
|
for stmt in (owner, assistant):
|
||||||
|
for owner_id, count in session.execute(stmt).all():
|
||||||
|
if owner_id is not None:
|
||||||
|
refs[int(owner_id)] = refs.get(int(owner_id), 0) + int(count)
|
||||||
|
return refs
|
||||||
|
|
||||||
|
|
||||||
|
def list_regions(session: Session, personnel_id: int) -> list[PersonnelRegion]:
|
||||||
|
"""查人员的全部辐射区域,按插入顺序返回。"""
|
||||||
|
stmt = (
|
||||||
|
select(PersonnelRegion)
|
||||||
|
.where(PersonnelRegion.personnel_id == personnel_id)
|
||||||
|
.order_by(PersonnelRegion.id)
|
||||||
|
)
|
||||||
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def list_plans(session: Session, personnel_id: int) -> list[PersonnelPlan]:
|
||||||
|
"""查人员的全部拓展计划,按插入顺序返回。"""
|
||||||
|
stmt = (
|
||||||
|
select(PersonnelPlan)
|
||||||
|
.where(PersonnelPlan.personnel_id == personnel_id)
|
||||||
|
.order_by(PersonnelPlan.id)
|
||||||
|
)
|
||||||
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def _base_select() -> Select:
|
||||||
|
"""人员主表联岗位名称与直属经理姓名。
|
||||||
|
|
||||||
|
岗位外键非空,但统一用 outerjoin:岗位行被物理删除的脏数据不应让
|
||||||
|
整个列表查不出来(外键 RESTRICT 正常情况下不会发生)。
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
select(Personnel, BusinessRole.role_name, _manager.personnel_name)
|
||||||
|
.outerjoin(BusinessRole, Personnel.business_role_id == BusinessRole.id)
|
||||||
|
.outerjoin(_manager, Personnel.manager_id == _manager.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: Select,
|
||||||
|
*,
|
||||||
|
keyword: str | None,
|
||||||
|
status: int | None,
|
||||||
|
business_role_id: int | None,
|
||||||
|
) -> Select:
|
||||||
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||||
|
if keyword:
|
||||||
|
stmt = fuzzy(stmt, Personnel.personnel_name, keyword)
|
||||||
|
if status is not None:
|
||||||
|
stmt = stmt.where(Personnel.status == status)
|
||||||
|
if business_role_id is not None:
|
||||||
|
stmt = stmt.where(Personnel.business_role_id == business_role_id)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 写入 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, personnel: Personnel) -> Personnel:
|
||||||
|
"""新增人员。只 flush 以取得自增 id。"""
|
||||||
|
session.add(personnel)
|
||||||
|
session.flush()
|
||||||
|
return personnel
|
||||||
|
|
||||||
|
|
||||||
|
def delete(session: Session, personnel: Personnel) -> None:
|
||||||
|
"""删除人员。调用方须先完成引用保护校验。"""
|
||||||
|
session.delete(personnel)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def replace_regions(session: Session, personnel_id: int, rows: list[PersonnelRegion]) -> None:
|
||||||
|
"""全量替换人员的辐射区域。
|
||||||
|
|
||||||
|
**先删、立即 flush、再插入**:同一 flush 内 SQLAlchemy 先执行 INSERT
|
||||||
|
后执行 DELETE,新旧行并存会在带唯一键的表上撞 1062 —— M1 的
|
||||||
|
replace_roles 在 e2e 中踩过此坑。本表当前无唯一键,仍统一按防御写法,
|
||||||
|
避免后续加约束时翻车。
|
||||||
|
"""
|
||||||
|
session.execute(sa_delete(PersonnelRegion).where(PersonnelRegion.personnel_id == personnel_id))
|
||||||
|
session.flush()
|
||||||
|
session.add_all(rows)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def replace_plans(session: Session, personnel_id: int, rows: list[PersonnelPlan]) -> None:
|
||||||
|
"""全量替换人员的拓展计划(防御写法同 ``replace_regions``)。"""
|
||||||
|
session.execute(sa_delete(PersonnelPlan).where(PersonnelPlan.personnel_id == personnel_id))
|
||||||
|
session.flush()
|
||||||
|
session.add_all(rows)
|
||||||
|
session.flush()
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""产品主数据数据访问。
|
||||||
|
|
||||||
|
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Select, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import BusinessItem, Product, ShipmentItem
|
||||||
|
|
||||||
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, product_id: int, *, for_update: bool = False) -> Product | None:
|
||||||
|
"""按主键查询。删除校验走 ``for_update`` 锁行(同 business_role_repository
|
||||||
|
的理由:校验与删除之间若发生并发引用,锁行使并发写入可见地失败,
|
||||||
|
而非引用被静默悬空)。"""
|
||||||
|
if for_update:
|
||||||
|
stmt = select(Product).where(Product.id == product_id).with_for_update()
|
||||||
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
return session.get(Product, product_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Product | None:
|
||||||
|
"""按 (名称, 规格型号) 组合查询。
|
||||||
|
|
||||||
|
组合是 uk_products_name_spec 唯一键的键值,先查用于给出即时业务提示;
|
||||||
|
并发窗口仍存在,最终由 IntegrityError 兜底(见 product_service)。
|
||||||
|
"""
|
||||||
|
stmt = select(Product).where(
|
||||||
|
Product.product_name == product_name,
|
||||||
|
Product.spec_model == spec_model,
|
||||||
|
)
|
||||||
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def list_products(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
keyword: str | None = None,
|
||||||
|
product_category: str | None = None,
|
||||||
|
status: int | None = None,
|
||||||
|
) -> list[Product]:
|
||||||
|
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(Product),
|
||||||
|
keyword=keyword,
|
||||||
|
product_category=product_category,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
|
||||||
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def count_products(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
product_category: str | None = None,
|
||||||
|
status: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""统计符合条件的产品总数,供分页使用。"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(func.count(Product.id)),
|
||||||
|
keyword=keyword,
|
||||||
|
product_category=product_category,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: Select,
|
||||||
|
*,
|
||||||
|
keyword: str | None,
|
||||||
|
product_category: str | None,
|
||||||
|
status: int | None,
|
||||||
|
) -> Select:
|
||||||
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||||
|
|
||||||
|
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
|
||||||
|
(``contains(autoescape=True)`` 转义 LIKE 通配符并附加 ESCAPE 子句);
|
||||||
|
因 fuzzy 是单列 where 语义(两次调用会叠加成 AND),双列 OR 在此直接
|
||||||
|
写 contains —— M1 business_role_repository 的双列关键词同款写法。
|
||||||
|
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
|
||||||
|
"""
|
||||||
|
if keyword:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Product.product_name.contains(keyword, autoescape=True),
|
||||||
|
Product.spec_model.contains(keyword, autoescape=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if product_category:
|
||||||
|
stmt = stmt.where(Product.product_category == product_category)
|
||||||
|
if status is not None:
|
||||||
|
stmt = stmt.where(Product.status == status)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def count_refs(session: Session, product_id: int) -> int:
|
||||||
|
"""统计引用该产品的业务明细与发货明细行数(删除保护,business_id 先例)。
|
||||||
|
|
||||||
|
两张明细表对 products 均 RESTRICT 外键:先查给出可读的 400;校验与
|
||||||
|
删除之间的并发引用由锁行 + FK 兜底(service 层再翻译 IntegrityError)。
|
||||||
|
"""
|
||||||
|
stmt = select(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(BusinessItem)
|
||||||
|
.where(BusinessItem.product_id == product_id)
|
||||||
|
.scalar_subquery()
|
||||||
|
+ select(func.count())
|
||||||
|
.select_from(ShipmentItem)
|
||||||
|
.where(ShipmentItem.product_id == product_id)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 写入 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, product: Product) -> Product:
|
||||||
|
"""新增产品。只 flush 以取得自增 id。"""
|
||||||
|
session.add(product)
|
||||||
|
session.flush()
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def delete(session: Session, product: Product) -> None:
|
||||||
|
"""物理删除产品。调用方须先完成引用保护校验(count_refs + FK 兜底)。"""
|
||||||
|
session.delete(product)
|
||||||
|
session.flush()
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""客户评级等级配置数据访问。
|
||||||
|
|
||||||
|
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import CustomerRatingGrade
|
||||||
|
|
||||||
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRatingGrade]:
|
||||||
|
"""全量等级,按 sort_order 升序、id 兜底(同序次序稳定)。
|
||||||
|
|
||||||
|
不分页:等级映射是判定表,数量个位数,分页无意义。
|
||||||
|
|
||||||
|
for_update 供区间重叠判定使用:先查后写在无锁时,两管理员并发提交
|
||||||
|
重叠区间会互判不重叠而双双入库(区间重叠无唯一键可拦)——锁全表行
|
||||||
|
串行化并发的重叠判定(先到者提交后,后到者在锁下读到新区间即判重叠)。
|
||||||
|
只读直出(list_grades)不加锁。
|
||||||
|
"""
|
||||||
|
stmt = select(CustomerRatingGrade).order_by(
|
||||||
|
CustomerRatingGrade.sort_order, CustomerRatingGrade.id
|
||||||
|
)
|
||||||
|
if for_update:
|
||||||
|
stmt = stmt.with_for_update()
|
||||||
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
|
||||||
|
"""按主键查询。
|
||||||
|
|
||||||
|
等级表对客户评级无**外键**引用(评级行快照固化等级名),无需
|
||||||
|
``for_update`` 锁行 —— product_repository 锁行防的是并发引用悬空,
|
||||||
|
此处不存在该风险。
|
||||||
|
"""
|
||||||
|
return session.get(CustomerRatingGrade, grade_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_name(session: Session, grade_name: str) -> CustomerRatingGrade | None:
|
||||||
|
"""按等级名称查询。
|
||||||
|
|
||||||
|
grade_name 是 uk_customer_rating_grades_grade_name 唯一键的键值,先查
|
||||||
|
用于给出即时业务提示;并发窗口仍存在,最终由 IntegrityError 兜底
|
||||||
|
(见 rating_service)。
|
||||||
|
"""
|
||||||
|
stmt = select(CustomerRatingGrade).where(CustomerRatingGrade.grade_name == grade_name)
|
||||||
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 写入 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
|
||||||
|
"""新增等级。只 flush 以取得自增 id。"""
|
||||||
|
session.add(grade)
|
||||||
|
session.flush()
|
||||||
|
return grade
|
||||||
|
|
||||||
|
|
||||||
|
def delete(session: Session, grade: CustomerRatingGrade) -> None:
|
||||||
|
"""物理删除等级。等级表对客户评级无外键引用,调用方无需引用保护校验。"""
|
||||||
|
session.delete(grade)
|
||||||
|
session.flush()
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""回款数据访问:回款行与关联引用(业务号/发货单号/发货日)。
|
||||||
|
|
||||||
|
只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。
|
||||||
|
|
||||||
|
- 列表与详情统一 join 出 ``business_no / shipment_no / ship_date``,
|
||||||
|
ship_date 供 service 按 ``formula.payment_days`` 派生回款天数 —— 关联
|
||||||
|
引用经本层查询一次供给,service 不再对发货单二次查询
|
||||||
|
- shipment_id 是手动指定(可空),发货单关联一律 outerjoin
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import Select, func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Business, Receipt, Shipment
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
|
||||||
|
"""按主键取回款行(供摘要预取与状态判定;不锁行,写保护锚在业务行)。"""
|
||||||
|
return session.get(Receipt, receipt_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_with_refs(
|
||||||
|
session: Session, receipt_id: int
|
||||||
|
) -> tuple[Receipt, str, str | None, date | None] | None:
|
||||||
|
"""回款行 + 业务号 + 发货单号 + 发货日(DTO 与 payment_days 派生共用)。
|
||||||
|
|
||||||
|
business 主表为 inner join(FK NOT NULL,业务与回款同存亡);发货单
|
||||||
|
可未指定,outerjoin 后 shipment_no / ship_date 可能为 None。
|
||||||
|
"""
|
||||||
|
row = session.execute(
|
||||||
|
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
|
||||||
|
.join(Business, Business.id == Receipt.business_id)
|
||||||
|
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id)
|
||||||
|
.where(Receipt.id == receipt_id)
|
||||||
|
).first()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def get_shipment(session: Session, shipment_id: int) -> Shipment | None:
|
||||||
|
"""按主键取发货单(同业务强校验的只读预查;FK RESTRICT 为最终防线)。"""
|
||||||
|
return session.get(Shipment, shipment_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: Select,
|
||||||
|
*,
|
||||||
|
business_id: int | None,
|
||||||
|
date_from: date | None,
|
||||||
|
date_to: date | None,
|
||||||
|
is_barter: int | None,
|
||||||
|
settlement_status: int | None,
|
||||||
|
) -> Select:
|
||||||
|
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
|
||||||
|
if business_id is not None:
|
||||||
|
stmt = stmt.where(Receipt.business_id == business_id)
|
||||||
|
if date_from is not None:
|
||||||
|
stmt = stmt.where(Receipt.receipt_date >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
stmt = stmt.where(Receipt.receipt_date <= date_to)
|
||||||
|
if is_barter is not None:
|
||||||
|
stmt = stmt.where(Receipt.is_barter == is_barter)
|
||||||
|
if settlement_status is not None:
|
||||||
|
stmt = stmt.where(Receipt.settlement_status == settlement_status)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def list_receipts(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
business_id: int | None,
|
||||||
|
date_from: date | None,
|
||||||
|
date_to: date | None,
|
||||||
|
is_barter: int | None,
|
||||||
|
settlement_status: int | None,
|
||||||
|
) -> list[tuple[Receipt, str, str | None, date | None]]:
|
||||||
|
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
|
||||||
|
.join(Business, Business.id == Receipt.business_id)
|
||||||
|
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
|
||||||
|
business_id=business_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
is_barter=is_barter,
|
||||||
|
settlement_status=settlement_status,
|
||||||
|
)
|
||||||
|
return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all())
|
||||||
|
|
||||||
|
|
||||||
|
def count_receipts(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
business_id: int | None,
|
||||||
|
date_from: date | None,
|
||||||
|
date_to: date | None,
|
||||||
|
is_barter: int | None,
|
||||||
|
settlement_status: int | None,
|
||||||
|
) -> int:
|
||||||
|
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(func.count()).select_from(Receipt),
|
||||||
|
business_id=business_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
is_barter=is_barter,
|
||||||
|
settlement_status=settlement_status,
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, receipt: Receipt) -> Receipt:
|
||||||
|
"""新增回款行。只 flush 以取得自增 id,编号唯一键冲突由 service 兜底。"""
|
||||||
|
session.add(receipt)
|
||||||
|
session.flush()
|
||||||
|
return receipt
|
||||||
|
|
||||||
|
|
||||||
|
def delete(session: Session, receipt: Receipt) -> None:
|
||||||
|
"""物理删除回款行(已核算与否的准入判定在 service 层)。"""
|
||||||
|
session.delete(receipt)
|
||||||
|
session.flush()
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
"""报表统计仓储:只读聚合查询;不 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, *, offset: int, limit: int
|
||||||
|
) -> 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(offset).limit(limit)
|
||||||
|
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()
|
||||||
|
)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""月度结算仓储:只读写数据并 flush;commit/rollback 在服务层。"""
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import MonthlySettlement, SettlementItem
|
||||||
|
from app.schemas.settlement import SettlementQuery
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, settlement_id: int) -> MonthlySettlement | None:
|
||||||
|
return session.get(MonthlySettlement, settlement_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_month(session: Session, month: str) -> MonthlySettlement | None:
|
||||||
|
"""按月份取月结行(settlement_month 全表唯一)。"""
|
||||||
|
return session.execute(
|
||||||
|
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def add(session: Session, settlement: MonthlySettlement) -> MonthlySettlement:
|
||||||
|
session.add(settlement)
|
||||||
|
session.flush()
|
||||||
|
return settlement
|
||||||
|
|
||||||
|
|
||||||
|
def list_settlements(session: Session, query: SettlementQuery) -> list[MonthlySettlement]:
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(MonthlySettlement)
|
||||||
|
.where(*_filters(query))
|
||||||
|
.order_by(MonthlySettlement.settlement_month.desc())
|
||||||
|
.offset(query.offset)
|
||||||
|
.limit(query.page_size)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_settlements(session: Session, query: SettlementQuery) -> int:
|
||||||
|
return int(
|
||||||
|
session.execute(
|
||||||
|
select(func.count()).select_from(MonthlySettlement).where(*_filters(query))
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _filters(query: SettlementQuery) -> tuple:
|
||||||
|
filters = []
|
||||||
|
if query.status is not None:
|
||||||
|
filters.append(MonthlySettlement.status == query.status)
|
||||||
|
return tuple(filters)
|
||||||
|
|
||||||
|
|
||||||
|
def list_items(session: Session, settlement_id: int) -> list[SettlementItem]:
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(SettlementItem)
|
||||||
|
.where(SettlementItem.settlement_id == settlement_id)
|
||||||
|
.order_by(SettlementItem.id)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_items(session: Session, settlement_id: int, rows: list[SettlementItem]) -> None:
|
||||||
|
"""全量替换明细(先删后 flush 再插,防御写法;不 commit)。"""
|
||||||
|
session.execute(delete_items_stmt(settlement_id))
|
||||||
|
session.flush()
|
||||||
|
for row in rows:
|
||||||
|
row.settlement_id = settlement_id
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_items_stmt(settlement_id: int):
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
return delete(SettlementItem).where(SettlementItem.settlement_id == settlement_id)
|
||||||
|
|
||||||
|
|
||||||
|
def update_ledger_settlement_id(
|
||||||
|
session: Session, ledger_ids: list[int], settlement_id: int
|
||||||
|
) -> None:
|
||||||
|
"""月结生成时把纳入的台账行回填 settlement_id(append-only 的受控回填)。"""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
from app.models import PerformanceLedger
|
||||||
|
|
||||||
|
if ledger_ids:
|
||||||
|
session.execute(
|
||||||
|
update(PerformanceLedger)
|
||||||
|
.where(PerformanceLedger.id.in_(ledger_ids))
|
||||||
|
.values(settlement_id=settlement_id)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_ledger_settlement_id(session: Session, settlement_id: int) -> None:
|
||||||
|
"""重生成前解绑:释放本结算名下台账行的 settlement_id(「未入结」判据复位)。
|
||||||
|
|
||||||
|
草稿/驳回态重新生成时,上一轮回填的行必须重新进入候选集——否则
|
||||||
|
``settlement_id IS NULL`` 会把它们永久排除,重生成漏算整月工资。
|
||||||
|
"""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
from app.models import PerformanceLedger
|
||||||
|
|
||||||
|
session.execute(
|
||||||
|
update(PerformanceLedger)
|
||||||
|
.where(PerformanceLedger.settlement_id == settlement_id)
|
||||||
|
.values(settlement_id=None)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""发货数据访问:发货单行、明细行与业务号关联引用。
|
||||||
|
|
||||||
|
只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。
|
||||||
|
|
||||||
|
- 列表与详情统一 join 出 ``business_no``(业务主表 inner join,FK NOT
|
||||||
|
NULL,业务与发货单同存亡)
|
||||||
|
- 明细行汇总(sum_item_amount)是发货单 total_amount 重算的唯一数据源,
|
||||||
|
业务级汇总仍收口 business_service._recalc_totals
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Select, delete, func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Business, Receipt, Shipment, ShipmentItem
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, shipment_id: int) -> Shipment | None:
|
||||||
|
"""按主键取发货单行(供摘要预取与编辑赋值;不锁行,写保护锚在业务行)。"""
|
||||||
|
return session.get(Shipment, shipment_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment, str] | None:
|
||||||
|
"""发货单行 + 业务号(DTO 与审计摘要共用)。"""
|
||||||
|
return session.execute(
|
||||||
|
select(Shipment, Business.business_no)
|
||||||
|
.join(Business, Business.id == Shipment.business_id)
|
||||||
|
.where(Shipment.id == shipment_id)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: Select,
|
||||||
|
*,
|
||||||
|
business_id: int | None,
|
||||||
|
ship_date_from: date | None,
|
||||||
|
ship_date_to: date | None,
|
||||||
|
) -> Select:
|
||||||
|
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
|
||||||
|
if business_id is not None:
|
||||||
|
stmt = stmt.where(Shipment.business_id == business_id)
|
||||||
|
if ship_date_from is not None:
|
||||||
|
stmt = stmt.where(Shipment.ship_date >= ship_date_from)
|
||||||
|
if ship_date_to is not None:
|
||||||
|
stmt = stmt.where(Shipment.ship_date <= ship_date_to)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def list_shipments(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
business_id: int | None,
|
||||||
|
ship_date_from: date | None,
|
||||||
|
ship_date_to: date | None,
|
||||||
|
) -> list[tuple[Shipment, str]]:
|
||||||
|
"""分页查询发货单(附业务号),按 id 倒序。"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
|
||||||
|
business_id=business_id,
|
||||||
|
ship_date_from=ship_date_from,
|
||||||
|
ship_date_to=ship_date_to,
|
||||||
|
)
|
||||||
|
return list(
|
||||||
|
session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_shipments(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
business_id: int | None,
|
||||||
|
ship_date_from: date | None,
|
||||||
|
ship_date_to: date | None,
|
||||||
|
) -> int:
|
||||||
|
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Shipment)
|
||||||
|
.join(Business, Business.id == Shipment.business_id),
|
||||||
|
business_id=business_id,
|
||||||
|
ship_date_from=ship_date_from,
|
||||||
|
ship_date_to=ship_date_to,
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def list_items(session: Session, shipment_id: int) -> list[ShipmentItem]:
|
||||||
|
"""发货明细行(详情回显与总金额重算共用)。"""
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(ShipmentItem)
|
||||||
|
.where(ShipmentItem.shipment_id == shipment_id)
|
||||||
|
.order_by(ShipmentItem.id)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sum_item_amount(session: Session, shipment_id: int) -> Decimal | None:
|
||||||
|
"""明细行金额合计(发货单 total_amount 重算的唯一数据源)。"""
|
||||||
|
return session.execute(
|
||||||
|
select(func.sum(ShipmentItem.amount)).where(ShipmentItem.shipment_id == shipment_id)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
def count_receipt_refs(session: Session, shipment_id: int) -> int:
|
||||||
|
"""被回款批次指定的引用数(删除保护依据,receipts.shipment_id)。"""
|
||||||
|
return int(
|
||||||
|
session.execute(
|
||||||
|
select(func.count()).select_from(Receipt).where(Receipt.shipment_id == shipment_id)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, shipment: Shipment) -> Shipment:
|
||||||
|
"""新增发货单行。只 flush 以取得自增 id,单号唯一键冲突由 service 兜底。"""
|
||||||
|
session.add(shipment)
|
||||||
|
session.flush()
|
||||||
|
return shipment
|
||||||
|
|
||||||
|
|
||||||
|
def replace_items(session: Session, shipment_id: int, rows: list[ShipmentItem]) -> None:
|
||||||
|
"""全量替换明细:先删、立即 flush、再插入(business_repository 同款防御写法)。
|
||||||
|
|
||||||
|
同一 flush 中 SQLAlchemy 先处理 INSERT 后处理 DELETE,先删后插不落库
|
||||||
|
会在有唯一键的表上撞键;shipment_items 虽无唯一键,统一按此写法防御。
|
||||||
|
"""
|
||||||
|
session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id == shipment_id))
|
||||||
|
session.flush()
|
||||||
|
for row in rows:
|
||||||
|
row.shipment_id = shipment_id
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_with_items(session: Session, shipment_id: int) -> None:
|
||||||
|
"""删除发货单及明细行(按依赖序:明细 → 主表,FK RESTRICT)。"""
|
||||||
|
session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id == shipment_id))
|
||||||
|
session.execute(delete(Shipment).where(Shipment.id == shipment_id))
|
||||||
|
session.flush()
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalStepOut(BaseModel):
|
||||||
|
"""节点记录(时间线;action=3 自动通过 operator 记「系统」)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
node: int
|
||||||
|
action: int
|
||||||
|
opinion: str | None = None
|
||||||
|
operator_id: int | None = None
|
||||||
|
operator_name: str
|
||||||
|
create_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalOrderOut(BaseModel):
|
||||||
|
"""审批单列表行。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
order_no: str
|
||||||
|
order_type: int
|
||||||
|
ref_id: int
|
||||||
|
refer_no: str
|
||||||
|
status: int
|
||||||
|
submitter_id: int | None = None
|
||||||
|
submitter_name: str
|
||||||
|
create_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalOrderDetailOut(ApprovalOrderOut):
|
||||||
|
"""审批单详情:附节点时间线。"""
|
||||||
|
|
||||||
|
steps: list[ApprovalStepOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalQuery(PageQuery):
|
||||||
|
"""审批单列表查询条件。"""
|
||||||
|
|
||||||
|
keyword: str | None = Field(default=None, max_length=32, description="审批单号/引用单号,模糊")
|
||||||
|
status: int | None = Field(default=None, ge=1, le=5, description="状态筛选")
|
||||||
|
order_type: int | None = Field(default=None, ge=1, le=2, description="对象类型筛选")
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalActionIn(BaseModel):
|
||||||
|
"""节点动作:1 通过 / 2 驳回(驳回须填意见——空驳无从追溯)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
action: int = Field(ge=1, le=2, description="1通过 2驳回")
|
||||||
|
opinion: str | None = Field(default=None, max_length=255, description="审批意见")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_opinion(self) -> "ApprovalActionIn":
|
||||||
|
if self.action == 2 and not self.opinion:
|
||||||
|
raise ValueError("驳回必须填写审批意见")
|
||||||
|
return self
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
"""业务管理的请求/响应模型(需求 2.2.1/2.2.4)。
|
||||||
|
|
||||||
|
金额/数量入参出参一律 str(服务层 Decimal/Fraction,全程禁 float);
|
||||||
|
派生值(毛利润额/毛利润率/折算毛利润额)不在写接口入参内 —— 落库列只有
|
||||||
|
源数据,派生值由毛利润视图实时计算。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
PRODUCT_CATEGORIES,
|
||||||
|
PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
||||||
|
PRODUCT_CATEGORY_RAIL_TRANSIT,
|
||||||
|
)
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
|
# DECIMAL(14,2) 容量上限:整数部分 12 位(同 personnel.py 的金额校验口径)
|
||||||
|
AMOUNT_MAX = Decimal("999999999999.99")
|
||||||
|
# DECIMAL(14,4) 容量上限:整数部分 10 位
|
||||||
|
PRICE_MAX = Decimal("9999999999.9999")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_amount(
|
||||||
|
value: str | None,
|
||||||
|
label: str,
|
||||||
|
cap: Decimal,
|
||||||
|
allow_positive_only: bool = False,
|
||||||
|
scale: int = 2,
|
||||||
|
) -> str | None:
|
||||||
|
"""字符串金额的通用校验:数字/有限/非负/容量/小数位上限。
|
||||||
|
|
||||||
|
小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱(工资系统红线)。
|
||||||
|
``scale`` 按列口径区分:金额/数量列 DECIMAL(14,2) 为 2;单价类列
|
||||||
|
DECIMAL(14,4) 为 4(回显 "5000.0000" 必须能原样提交,否则任何带
|
||||||
|
精度的历史单价都被编辑端点永远拒收)。
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
amount = Decimal(value)
|
||||||
|
except InvalidOperation:
|
||||||
|
raise ValueError(f"{label}须为有效数字") from None
|
||||||
|
if not amount.is_finite():
|
||||||
|
raise ValueError(f"{label}须为有效数字")
|
||||||
|
if allow_positive_only:
|
||||||
|
if amount <= 0:
|
||||||
|
raise ValueError(f"{label}必须大于 0")
|
||||||
|
elif amount < 0:
|
||||||
|
raise ValueError(f"{label}不能为负数")
|
||||||
|
if amount > cap:
|
||||||
|
raise ValueError(f"{label}超出可录入上限")
|
||||||
|
if -amount.as_tuple().exponent > scale:
|
||||||
|
raise ValueError(f"{label}最多{'两' if scale == 2 else '四'}位小数")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessItemIn(BaseModel):
|
||||||
|
"""业务产品明细行(需求 2.2.1「支持添加多种产品,录入指导价」)。
|
||||||
|
|
||||||
|
product_category 保留蛇形:该值与 M1 参数维度值域同源(枚举码本就是
|
||||||
|
蛇形);其余标识类字段驼峰兼容(snake/驼峰双入,同 customer.py 契约)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
product_id: int | None = Field(default=None, ge=1, description="产品ID;空=自由录入")
|
||||||
|
product_name: str = Field(min_length=1, max_length=128, description="产品名称")
|
||||||
|
spec_model: str | None = Field(
|
||||||
|
default=None, max_length=128, description="规格型号,空=空串归一"
|
||||||
|
)
|
||||||
|
unit: str | None = Field(default=None, max_length=16, description="计量单位")
|
||||||
|
product_category: str = Field(
|
||||||
|
description="产品类别:municipal_water/rail_transit(行级折算锚点)"
|
||||||
|
)
|
||||||
|
quantity: str = Field(description="数量,>0")
|
||||||
|
sale_price: str | None = Field(default=None, description="销售单价(元)")
|
||||||
|
cost_amount: str | None = Field(
|
||||||
|
default=None, description="成本额(元);毛利润额 = 销售额 − 成本额"
|
||||||
|
)
|
||||||
|
guided_price: str | None = Field(default=None, description="指导价(元)")
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("product_category")
|
||||||
|
@classmethod
|
||||||
|
def _check_category(cls, value: str) -> str:
|
||||||
|
if value not in PRODUCT_CATEGORIES:
|
||||||
|
raise ValueError("产品类别不合法")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("quantity")
|
||||||
|
@classmethod
|
||||||
|
def _check_quantity(cls, value: str) -> str:
|
||||||
|
return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value
|
||||||
|
|
||||||
|
@field_validator("sale_price", "guided_price")
|
||||||
|
@classmethod
|
||||||
|
def _check_unit_prices(cls, value: str | None) -> str | None:
|
||||||
|
"""单价类字段按 DECIMAL(14,4) 口径校验(4 位小数)。"""
|
||||||
|
return _check_amount(value, "单价", PRICE_MAX, scale=4)
|
||||||
|
|
||||||
|
@field_validator("cost_amount")
|
||||||
|
@classmethod
|
||||||
|
def _check_cost(cls, value: str | None) -> str | None:
|
||||||
|
return _check_amount(value, "金额", AMOUNT_MAX)
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessItemOut(BaseModel):
|
||||||
|
"""业务产品明细行响应。金额/数量一律 str。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
product_id: int | None = None
|
||||||
|
product_name: str
|
||||||
|
spec_model: str | None = None
|
||||||
|
unit: str | None = None
|
||||||
|
product_category: str
|
||||||
|
quantity: str
|
||||||
|
sale_price: str | None = None
|
||||||
|
sale_amount: str
|
||||||
|
cost_amount: str | None = None
|
||||||
|
guided_price: str | None = None
|
||||||
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessOut(BaseModel):
|
||||||
|
"""业务响应(列表与写操作回包共用)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
business_no: str
|
||||||
|
customer_id: int
|
||||||
|
customer_name: str | None = None
|
||||||
|
business_date: date | None = None
|
||||||
|
status: int = 1
|
||||||
|
owner_personnel_id: int | None = None
|
||||||
|
owner_personnel_name: str | None = None
|
||||||
|
assistant_personnel_id: int | None = None
|
||||||
|
assistant_personnel_name: str | None = None
|
||||||
|
is_special: int = 0
|
||||||
|
special_note: str | None = None
|
||||||
|
total_sale_amount: str | None = None
|
||||||
|
total_cost_amount: str | None = None
|
||||||
|
total_ship_amount: str | None = None
|
||||||
|
total_received_amount: str | None = None
|
||||||
|
total_freight_amount: str | None = None
|
||||||
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SecondaryAllocationIn(BaseModel):
|
||||||
|
"""二次分配参与人入参(2026-10-15 补充需求「二次分配方案」)。
|
||||||
|
|
||||||
|
比例**不要求合计 100%**(需求原文):余额归属待业务澄清(待澄清 #2),
|
||||||
|
服务层仅以 hint 展示合计/余额,不做强制校验。ratio 沿金额红线口径:
|
||||||
|
字符串入参、两位小数、超限拒绝不静默舍入。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
personnel_id: int = Field(ge=1, description="参与业务员ID(personnel.id)")
|
||||||
|
ratio: str = Field(description="分摊比例(0-100,两位小数)")
|
||||||
|
|
||||||
|
@field_validator("ratio")
|
||||||
|
@classmethod
|
||||||
|
def _check_ratio(cls, value: str) -> str:
|
||||||
|
return _check_amount(value, "分摊比例", Decimal("100"), allow_positive_only=True) or value
|
||||||
|
|
||||||
|
|
||||||
|
class SecondaryAllocationOut(BaseModel):
|
||||||
|
"""二次分配参与人响应(personnel_name 冗余固化,离职后回显有名)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
personnel_id: int
|
||||||
|
personnel_name: str
|
||||||
|
ratio: str
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
|
||||||
|
"""同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示
|
||||||
|
(让 400 文案说「重复参与人」而不是裸唯一键冲突)。"""
|
||||||
|
seen: set[int] = set()
|
||||||
|
for item in items:
|
||||||
|
if item.personnel_id in seen:
|
||||||
|
raise ValueError(f"二次分配参与人重复:人员ID {item.personnel_id}")
|
||||||
|
seen.add(item.personnel_id)
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessDetailOut(BusinessOut):
|
||||||
|
"""业务详情:附产品明细行与二次分配方案。"""
|
||||||
|
|
||||||
|
items: list[BusinessItemOut] = Field(default_factory=list)
|
||||||
|
secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list)
|
||||||
|
allocation_hint: str | None = Field(
|
||||||
|
default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessCreateIn(BaseModel):
|
||||||
|
"""创建业务:主表信息 + 明细行数组(明细 ≥1 行)。
|
||||||
|
|
||||||
|
字段名全线蛇形直出(M1 既有契约:前端 api 封装与 schemas 同名对应,
|
||||||
|
仅分页信封 list/pageNum 驼峰)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
customer_id: int = Field(ge=1, description="客户ID")
|
||||||
|
business_date: date | None = Field(default=None, description="业务日期,ISO 格式")
|
||||||
|
owner_personnel_id: int | None = Field(
|
||||||
|
default=None, ge=1, description="负责营销人员ID,可空=待指定"
|
||||||
|
)
|
||||||
|
assistant_personnel_id: int | None = Field(default=None, ge=1, description="协助营销人员ID")
|
||||||
|
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
|
||||||
|
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
|
||||||
|
items: list[BusinessItemIn] = Field(min_length=1, description="产品明细行,至少 1 行")
|
||||||
|
secondary_allocations: list[SecondaryAllocationIn] = Field(
|
||||||
|
default_factory=list, description="二次分配方案(只出现在合同刚建立时;可空)"
|
||||||
|
)
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_special_note(self) -> "BusinessCreateIn":
|
||||||
|
"""特殊业务须有说明:标记的意义就在于可追溯「特殊在哪」。"""
|
||||||
|
if self.is_special == 1 and not self.special_note:
|
||||||
|
raise ValueError("特殊业务必须填写说明")
|
||||||
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_allocations(self) -> "BusinessCreateIn":
|
||||||
|
_ensure_allocations_unique(self.secondary_allocations)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessUpdateIn(BaseModel):
|
||||||
|
"""编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
customer_id: int = Field(ge=1, description="客户ID")
|
||||||
|
business_date: date | None = Field(default=None, description="业务日期,ISO 格式")
|
||||||
|
owner_personnel_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
ge=1,
|
||||||
|
description="负责营销人员ID(编辑端点忽略,仅 /ownership 生效)",
|
||||||
|
)
|
||||||
|
assistant_personnel_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
ge=1,
|
||||||
|
description="协助营销人员ID(编辑端点忽略)",
|
||||||
|
)
|
||||||
|
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
|
||||||
|
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
|
||||||
|
items: list[BusinessItemIn] = Field(
|
||||||
|
min_length=1, description="产品明细行,至少 1 行(全量替换)"
|
||||||
|
)
|
||||||
|
secondary_allocations: list[SecondaryAllocationIn] = Field(
|
||||||
|
default_factory=list, description="二次分配方案(全量替换;空数组=清空方案)"
|
||||||
|
)
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_special_note(self) -> "BusinessUpdateIn":
|
||||||
|
if self.is_special == 1 and not self.special_note:
|
||||||
|
raise ValueError("特殊业务必须填写说明")
|
||||||
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_allocations(self) -> "BusinessUpdateIn":
|
||||||
|
_ensure_allocations_unique(self.secondary_allocations)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessStatusIn(BaseModel):
|
||||||
|
"""状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
status: int = Field(ge=1, le=3, description="目标状态:2已完成 3已终止")
|
||||||
|
reason: str | None = Field(default=None, max_length=255, description="变更原因(终止时必填)")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_reason(self) -> "BusinessStatusIn":
|
||||||
|
if self.status == 3 and not self.reason:
|
||||||
|
raise ValueError("终止业务必须填写原因")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessOwnershipIn(BaseModel):
|
||||||
|
"""业务归属变更(负责/协助;前后值写入留痕)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
new_owner_personnel_id: int | None = Field(default=None, ge=1)
|
||||||
|
new_assistant_personnel_id: int | None = Field(default=None, ge=1)
|
||||||
|
reason: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessQuery(PageQuery):
|
||||||
|
"""业务列表查询条件(筛选参数驼峰 alias,customer.py 同款契约)。"""
|
||||||
|
|
||||||
|
keyword: str | None = Field(default=None, max_length=32, description="业务编号/公司名称,模糊")
|
||||||
|
status: int | None = Field(default=None, ge=1, le=3, description="状态筛选")
|
||||||
|
customer_id: int | None = Field(default=None, ge=1, description="客户筛选")
|
||||||
|
owner_personnel_id: int | None = Field(default=None, ge=1, description="负责营销人员筛选")
|
||||||
|
date_from: date | None = Field(default=None, description="业务日期起(含)")
|
||||||
|
date_to: date | None = Field(default=None, description="业务日期止(含)")
|
||||||
|
|
||||||
|
|
||||||
|
class GrossProfitRowOut(BaseModel):
|
||||||
|
"""毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。"""
|
||||||
|
|
||||||
|
item_id: int
|
||||||
|
product_category: str
|
||||||
|
product_name: str
|
||||||
|
spec_model: str | None = None
|
||||||
|
quantity: str
|
||||||
|
sale_amount: str
|
||||||
|
cost_amount: str | None = None
|
||||||
|
"""成本缺失(未录入)时为 None,派生列同为 None 并置 missing_reason"""
|
||||||
|
profit_amount: str | None = None
|
||||||
|
profit_rate: str | None = None
|
||||||
|
threshold: str
|
||||||
|
converted_profit: str | None = None
|
||||||
|
missing_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GrossProfitCategoryOut(BaseModel):
|
||||||
|
"""按产品类别的毛利润汇总(可计算行聚合;成本缺失行不计入)。"""
|
||||||
|
|
||||||
|
product_category: str
|
||||||
|
sale_amount: str
|
||||||
|
cost_amount: str
|
||||||
|
profit_amount: str
|
||||||
|
profit_rate: str
|
||||||
|
threshold: str
|
||||||
|
converted_profit: str
|
||||||
|
|
||||||
|
|
||||||
|
class GrossProfitOut(BaseModel):
|
||||||
|
"""毛利润数据视图(需求 2.2.4):行级 + 类别汇总。
|
||||||
|
|
||||||
|
口径注释(前端须展示):毛利润率 = 毛利润额 / 销售额,**不含运费**
|
||||||
|
(对照表 #2 作废需规含运费公式);阈值按 asOf 取 M1 参数快照;M3 核算
|
||||||
|
实际按回款到账日取参,本视图默认今日仅供录入期核对。
|
||||||
|
"""
|
||||||
|
|
||||||
|
business_id: int
|
||||||
|
as_of: date
|
||||||
|
rows: list[GrossProfitRowOut] = Field(default_factory=list)
|
||||||
|
categories: list[GrossProfitCategoryOut] = Field(default_factory=list)
|
||||||
|
missing_count: int = 0
|
||||||
|
note: str = "毛利润率与折算额不含运费;M3 核算按回款到账日取参数版本"
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipChangeLogOut(BaseModel):
|
||||||
|
"""业务归属变更留痕(人员删除后姓名为 None,固化 ID 兜底可追溯)。"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
business_id: int
|
||||||
|
old_owner_personnel_id: int | None = None
|
||||||
|
new_owner_personnel_id: int | None = None
|
||||||
|
old_assistant_personnel_id: int | None = None
|
||||||
|
new_assistant_personnel_id: int | None = None
|
||||||
|
old_owner_name: str | None = None
|
||||||
|
new_owner_name: str | None = None
|
||||||
|
change_reason: str | None = None
|
||||||
|
operator_id: int | None = None
|
||||||
|
operator_name: str
|
||||||
|
create_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessStatusLogOut(BaseModel):
|
||||||
|
"""业务状态流转记录(详情页时间线;创建首条 old=new=1)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
business_id: int
|
||||||
|
old_status: int
|
||||||
|
new_status: int
|
||||||
|
change_reason: str | None = None
|
||||||
|
operator_id: int | None = None
|
||||||
|
operator_name: str
|
||||||
|
create_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# 供 schema 外部引用的两类别常量(类别汇总展示用)
|
||||||
|
CATEGORY_LABELS = {
|
||||||
|
PRODUCT_CATEGORY_MUNICIPAL_WATER: "市政水利",
|
||||||
|
PRODUCT_CATEGORY_RAIL_TRANSIT: "轨道交通",
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""绩效核算的请求/响应模型(M3 ④)。"""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
|
|
||||||
|
class LedgerQuery(PageQuery):
|
||||||
|
"""台账列表查询条件。"""
|
||||||
|
|
||||||
|
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
|
||||||
|
personnel_id: int | None = Field(default=None, ge=1, description="归属人员筛选")
|
||||||
|
business_id: int | None = Field(default=None, ge=1, description="业务筛选")
|
||||||
|
is_active: int | None = Field(default=None, ge=0, le=1, description="生效标记筛选")
|
||||||
|
calc_type: int | None = Field(default=None, ge=1, le=2, description="核算类型筛选")
|
||||||
|
|
||||||
|
|
||||||
|
class LedgerOut(BaseModel):
|
||||||
|
"""台账行响应(金额/比例一律 str)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
receipt_id: int | None = None
|
||||||
|
calc_version: int
|
||||||
|
is_active: int
|
||||||
|
calc_type: int
|
||||||
|
business_id: int
|
||||||
|
business_no: str
|
||||||
|
personnel_id: int
|
||||||
|
personnel_name: str
|
||||||
|
role_code: str
|
||||||
|
customer_type: str
|
||||||
|
product_category: str
|
||||||
|
ship_date: date | None = None
|
||||||
|
receipt_date: date
|
||||||
|
payment_days: int | None = None
|
||||||
|
rate_text: str
|
||||||
|
converted_profit: Decimal | None = None
|
||||||
|
performance_amount: Decimal | None = None
|
||||||
|
barter_applied: int
|
||||||
|
weight_r: int | None = None
|
||||||
|
payment_weight: Decimal | None = None
|
||||||
|
payable_amount: Decimal | None = None
|
||||||
|
settlement_month: str
|
||||||
|
is_probation: int
|
||||||
|
missing_cost: int
|
||||||
|
snapshot_json: dict | None = None
|
||||||
|
settlement_id: int | None = None
|
||||||
|
calc_reason: str | None = None
|
||||||
|
create_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CalcReceiptOut(BaseModel):
|
||||||
|
"""单笔核算结果:台账行 + 重复计提标记(核算粒度 #25 一期口径)。"""
|
||||||
|
|
||||||
|
ledger: LedgerOut
|
||||||
|
#: True=该业务此前已核算过,本笔回款仅登记进度、未重复计提(#25 一期口径)
|
||||||
|
repeated: bool = False
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""客户管理的请求/响应模型(需求 2.1.3)。"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerOut(BaseModel):
|
||||||
|
"""客户响应(列表、详情与写操作回包共用)。
|
||||||
|
|
||||||
|
owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的
|
||||||
|
脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕
|
||||||
|
「ID 固化、姓名随联表」同一口径。
|
||||||
|
|
||||||
|
rating_total / rating_grade_name 由 LEFT JOIN customer_ratings 带出
|
||||||
|
(列表评级列免 N+1):未评级时均为 null;总分按分值红线以字符串出参,
|
||||||
|
等级为保存时点的快照固化值(见 models/customer_rating.py)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
company_name: str
|
||||||
|
owner_name: str
|
||||||
|
contact_person: str | None = None
|
||||||
|
contact_phone: str | None = None
|
||||||
|
address: str | None = None
|
||||||
|
industry: str | None = None
|
||||||
|
customer_type: str
|
||||||
|
owner_personnel_id: int | None = None
|
||||||
|
owner_personnel_name: str | None = None
|
||||||
|
remark: str | None = None
|
||||||
|
rating_total: str | None = None
|
||||||
|
rating_grade_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerCreateIn(BaseModel):
|
||||||
|
"""新增客户。
|
||||||
|
|
||||||
|
owner_personnel_id 为**初始归属**,可空(客户先建档、后派归属也合法);
|
||||||
|
归属的后续调整不走本模型,统一走 ``PUT /{id}/ownership``(见
|
||||||
|
``CustomerUpdateIn`` 的说明)。字段名蛇形直出(M1 契约:前端 api 封装
|
||||||
|
与 schema 同名对应,仅分页信封 list/pageNum 驼峰)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
company_name: str = Field(min_length=1, max_length=128, description="公司名称")
|
||||||
|
owner_name: str = Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=64,
|
||||||
|
description="客户负责人姓名(客户公司的负责人人名,非系统内营销人员)",
|
||||||
|
)
|
||||||
|
contact_person: str | None = Field(default=None, max_length=64, description="联系人")
|
||||||
|
contact_phone: str | None = Field(default=None, max_length=20, description="联系电话")
|
||||||
|
address: str | None = Field(default=None, max_length=255, description="地址")
|
||||||
|
industry: str | None = Field(default=None, max_length=64, description="所属行业")
|
||||||
|
customer_type: str = Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=32,
|
||||||
|
description="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户",
|
||||||
|
)
|
||||||
|
owner_personnel_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
ge=1,
|
||||||
|
description="归属负责营销人员ID(初始归属,可空)",
|
||||||
|
)
|
||||||
|
remark: str | None = Field(default=None, max_length=255, description="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerUpdateIn(BaseModel):
|
||||||
|
"""编辑客户(全量替换,前端表单全量提交)。
|
||||||
|
|
||||||
|
**不含 owner_personnel_id** —— 归属调整统一走 ``PUT /{id}/ownership``,
|
||||||
|
只有那条路径才写归属变更留痕。若在本端点放开该字段,会出现「改了归属
|
||||||
|
却无留痕」的旁路,破坏留痕的 append-only 完整性;编辑表单即使提交
|
||||||
|
ownerPersonnelId,pydantic 默认忽略未声明字段,归属以归属端点为准。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
company_name: str = Field(min_length=1, max_length=128, description="公司名称")
|
||||||
|
owner_name: str = Field(min_length=1, max_length=64, description="客户负责人姓名")
|
||||||
|
contact_person: str | None = Field(default=None, max_length=64, description="联系人")
|
||||||
|
contact_phone: str | None = Field(default=None, max_length=20, description="联系电话")
|
||||||
|
address: str | None = Field(default=None, max_length=255, description="地址")
|
||||||
|
industry: str | None = Field(default=None, max_length=64, description="所属行业")
|
||||||
|
customer_type: str = Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=32,
|
||||||
|
description="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户",
|
||||||
|
)
|
||||||
|
remark: str | None = Field(default=None, max_length=255, description="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerQuery(PageQuery):
|
||||||
|
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。"""
|
||||||
|
|
||||||
|
keyword: str | None = Field(
|
||||||
|
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
||||||
|
)
|
||||||
|
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
||||||
|
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
||||||
|
# 排序白名单(全项目首个列表排序参数):非法值由 Literal 拦下、经全局
|
||||||
|
# 异常处理器转 400 —— 不把排序字段透传给 SQL,避免注入面
|
||||||
|
order_field: Literal["rating"] | None = Field(
|
||||||
|
default=None, description="排序字段白名单:rating=客户评级总分(等级由总分判定、同向)"
|
||||||
|
)
|
||||||
|
order_type: Literal["asc", "desc"] | None = Field(
|
||||||
|
default=None, description="排序方向:asc 正序 / desc 倒序(未评级恒在末尾)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerOwnershipIn(BaseModel):
|
||||||
|
"""归属变更入参。
|
||||||
|
|
||||||
|
new_owner_personnel_id 可空:null 表示清空归属(客户暂无负责人也合法)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
new_owner_personnel_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
ge=1,
|
||||||
|
description="变更后归属人员ID,null=清空归属",
|
||||||
|
)
|
||||||
|
reason: str | None = Field(default=None, max_length=255, description="变更原因")
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipChangeLogOut(BaseModel):
|
||||||
|
"""归属变更留痕响应。
|
||||||
|
|
||||||
|
新旧归属人员姓名由 LEFT JOIN 带出,人员被删除后为 null;固化的
|
||||||
|
``*_personnel_id`` 始终原样返回 —— 留痕表对人员 ID 无外键固化
|
||||||
|
(见 `models/customer.py` 的论证),姓名可随人员消失,ID 不可。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
customer_id: int
|
||||||
|
old_owner_personnel_id: int | None = None
|
||||||
|
new_owner_personnel_id: int | None = None
|
||||||
|
old_owner_name: str | None = None
|
||||||
|
new_owner_name: str | None = None
|
||||||
|
change_reason: str | None = None
|
||||||
|
operator_id: int | None = None
|
||||||
|
operator_name: str
|
||||||
|
create_time: datetime
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""工作台的请求/响应模型(M4 ⑧)。
|
||||||
|
|
||||||
|
四区块一次返回(前端一次请求渲染整页,避免 6-10 次往返):
|
||||||
|
经营绩效总览 / 待办事项 / 风险预警 / 最近动态。金额一律 Decimal
|
||||||
|
(序列化为字符串),前端不做数值运算。
|
||||||
|
|
||||||
|
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
|
||||||
|
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
|
||||||
|
不返回伪造的 0 条。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
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 DashboardOut(BaseModel):
|
||||||
|
"""工作台四区块一次性响应。"""
|
||||||
|
|
||||||
|
month: str
|
||||||
|
overview: OverviewBlock
|
||||||
|
todos: TodoBlock
|
||||||
|
warnings: WarningBlock
|
||||||
|
activities: list[ActivityItem] = Field(default_factory=list)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""营销人员的请求/响应模型(需求 2.1.1)。"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
|
# salary_standard 列为 Numeric(12,2):整数部分最多 10 位。
|
||||||
|
# 工资系统金额红线——入参出参一律 str,服务层转 Decimal,全程不经 float
|
||||||
|
SALARY_MAX = Decimal("9999999999.99")
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelRegionOut(BaseModel):
|
||||||
|
"""辐射区域条目响应。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
region_name: str
|
||||||
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelPlanOut(BaseModel):
|
||||||
|
"""拓展计划条目响应。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
plan_period: str
|
||||||
|
plan_content: str
|
||||||
|
target_desc: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelOut(BaseModel):
|
||||||
|
"""人员响应(列表与写操作回包共用)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
personnel_name: str
|
||||||
|
business_role_id: int
|
||||||
|
role_name: str | None = None
|
||||||
|
manager_id: int | None = None
|
||||||
|
manager_name: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
hire_date: date | None = None
|
||||||
|
is_probation: int = 0
|
||||||
|
salary_standard: str | None = None
|
||||||
|
status: int = 1
|
||||||
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelDetailOut(PersonnelOut):
|
||||||
|
"""人员详情:附区域与拓展计划,供编辑抽屉一次回显。"""
|
||||||
|
|
||||||
|
regions: list[PersonnelRegionOut] = Field(default_factory=list)
|
||||||
|
plans: list[PersonnelPlanOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelCreateIn(BaseModel):
|
||||||
|
"""新增人员。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
personnel_name: str = Field(min_length=1, max_length=64, description="姓名")
|
||||||
|
business_role_id: int = Field(ge=1, description="岗位角色ID")
|
||||||
|
manager_id: int | None = Field(default=None, ge=1, description="直属营销经理ID,空=无")
|
||||||
|
phone: str | None = Field(default=None, max_length=20, description="联系电话")
|
||||||
|
hire_date: date | None = Field(default=None, description="入职日期,ISO 格式")
|
||||||
|
is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否")
|
||||||
|
salary_standard: str | None = Field(
|
||||||
|
default=None, description="月工资标准(元),字符串形式的数字"
|
||||||
|
)
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("salary_standard")
|
||||||
|
@classmethod
|
||||||
|
def _check_salary_standard(cls, value: str | None) -> str | None:
|
||||||
|
"""按 Numeric(12,2) 的容量校验字符串金额。
|
||||||
|
|
||||||
|
金额以字符串进出避免 float 精度损失(项目金额红线);
|
||||||
|
小数位超过两位直接拒绝而非静默舍入——舍入即算错钱。
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
amount = Decimal(value)
|
||||||
|
except InvalidOperation:
|
||||||
|
raise ValueError("工资标准须为有效数字") from None
|
||||||
|
if not amount.is_finite():
|
||||||
|
raise ValueError("工资标准须为有效数字")
|
||||||
|
if amount < 0:
|
||||||
|
raise ValueError("工资标准不能为负数")
|
||||||
|
if amount > SALARY_MAX:
|
||||||
|
raise ValueError("工资标准超出可录入上限")
|
||||||
|
if -amount.as_tuple().exponent > 2:
|
||||||
|
raise ValueError("工资标准最多两位小数")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelUpdateIn(PersonnelCreateIn):
|
||||||
|
"""编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。"""
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelStatusIn(BaseModel):
|
||||||
|
"""在职/离职切换。"""
|
||||||
|
|
||||||
|
status: int = Field(ge=0, le=1, description="1在职 0离职")
|
||||||
|
|
||||||
|
|
||||||
|
class RegionItemIn(BaseModel):
|
||||||
|
"""辐射区域条目。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
region_name: str = Field(min_length=1, max_length=128, description="区域名称")
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class RegionReplaceIn(BaseModel):
|
||||||
|
"""辐射区域全量替换。空列表即清空该人员的全部区域。"""
|
||||||
|
|
||||||
|
items: list[RegionItemIn] = Field(default_factory=list, description="全量条目")
|
||||||
|
|
||||||
|
|
||||||
|
class PlanItemIn(BaseModel):
|
||||||
|
"""拓展计划条目。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
plan_period: str = Field(min_length=1, max_length=32, description="计划期间,如 2026Q1")
|
||||||
|
plan_content: str = Field(min_length=1, max_length=500, description="计划内容")
|
||||||
|
target_desc: str | None = Field(default=None, max_length=255, description="指标说明")
|
||||||
|
|
||||||
|
|
||||||
|
class PlanReplaceIn(BaseModel):
|
||||||
|
"""拓展计划全量替换。空列表即清空该人员的全部计划。"""
|
||||||
|
|
||||||
|
items: list[PlanItemIn] = Field(default_factory=list, description="全量条目")
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelQuery(PageQuery):
|
||||||
|
"""人员列表查询条件。"""
|
||||||
|
|
||||||
|
keyword: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配")
|
||||||
|
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选:1在职 0离职")
|
||||||
|
business_role_id: int | None = Field(default=None, ge=1, description="岗位筛选")
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""产品管理的请求/响应模型。
|
||||||
|
|
||||||
|
数量与单价(stock_quantity/sale_price)入参出参一律用**字符串**承载:
|
||||||
|
前端 JS 的 number 无法精确表示全部十进制小数,字符串是金额红线的入口闸门,
|
||||||
|
Decimal 转换统一收敛在 service 层。字段命名与 user/role/param 一致采用
|
||||||
|
蛇形直出(前端 TS 接口同构)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount
|
||||||
|
|
||||||
|
|
||||||
|
class ProductOut(BaseModel):
|
||||||
|
"""产品响应(列表与详情共用)。数量/单价按红线以字符串出参。"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
product_name: str
|
||||||
|
spec_model: str
|
||||||
|
unit: str | None = None
|
||||||
|
stock_quantity: str | None = None
|
||||||
|
sale_price: str | None = None
|
||||||
|
product_category: str
|
||||||
|
status: int = 1
|
||||||
|
remark: str | None = None
|
||||||
|
create_time: datetime
|
||||||
|
update_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCreateIn(BaseModel):
|
||||||
|
"""新增产品。
|
||||||
|
|
||||||
|
spec_model 允许缺省/空:service 层归一为空串(组合唯一键
|
||||||
|
uk_products_name_spec 对 NULL 不去重,归 NULL 会放行重名同规格数据)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
product_name: str = Field(min_length=1, max_length=128, description="产品名称")
|
||||||
|
spec_model: str | None = Field(
|
||||||
|
default=None, max_length=128, description="规格型号,空归一为空串"
|
||||||
|
)
|
||||||
|
unit: str | None = Field(default=None, max_length=16, description="计量单位")
|
||||||
|
stock_quantity: str | None = Field(default=None, description="数量,非负数字字符串")
|
||||||
|
sale_price: str | None = Field(default=None, description="销售单价(元),非负数字字符串")
|
||||||
|
product_category: str = Field(min_length=1, max_length=32, description="产品类别")
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("stock_quantity")
|
||||||
|
@classmethod
|
||||||
|
def _check_stock_quantity(cls, value: str | None) -> str | None:
|
||||||
|
"""空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
|
||||||
|
|
||||||
|
复用 _check_amount:容量/小数位与 DECIMAL(14,2) 列口径一致
|
||||||
|
(此前私写的 _parse_non_negative 不查小数位与容量,属同语义
|
||||||
|
字段两处口径漂移)。
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return _check_amount(value, "数量", AMOUNT_MAX)
|
||||||
|
|
||||||
|
@field_validator("sale_price")
|
||||||
|
@classmethod
|
||||||
|
def _check_sale_price(cls, value: str | None) -> str | None:
|
||||||
|
"""空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
|
||||||
|
|
||||||
|
scale=4 与列口径 Numeric(14,4) 一致 —— 带精度的历史单价必须能
|
||||||
|
原样提交(「编辑任何带精度历史行都失败」的 fba8cfc 教训)。
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return _check_amount(value, "销售单价", PRICE_MAX, scale=4)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductUpdateIn(ProductCreateIn):
|
||||||
|
"""编辑产品。
|
||||||
|
|
||||||
|
前端表单全量提交,故为全量替换语义;**不含 status** —— 启用/停用是
|
||||||
|
独立操作(走 PUT /{id}/status),避免编辑表单误触停用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProductStatusIn(BaseModel):
|
||||||
|
"""启用/停用产品。"""
|
||||||
|
|
||||||
|
status: int = Field(ge=0, le=1, description="1启用 0停用")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductQuery(PageQuery):
|
||||||
|
"""产品列表查询条件。"""
|
||||||
|
|
||||||
|
keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配")
|
||||||
|
product_category: str | None = Field(default=None, max_length=32, description="按类别筛选")
|
||||||
|
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""客户评级等级配置的请求/响应模型。
|
||||||
|
|
||||||
|
分值(min_score/max_score)入参出参一律用**字符串**承载:前端 JS 的 number
|
||||||
|
无法精确表示全部十进制小数,字符串是分值红线的入口闸门,Decimal 转换统一
|
||||||
|
收敛在 service 层(与 schemas/product.py 同一口径)。
|
||||||
|
|
||||||
|
注意:schema 只拦**格式**(可解析、有限数);0≤min≤max≤100 与区间不重叠
|
||||||
|
是横跨两字段/多行的业务值域规则,在 service 层审计上下文内统一判定 ——
|
||||||
|
值域校验失败的 400 也要留痕(2026-10-13 定稿口径)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_score(value: str, *, label: str) -> str:
|
||||||
|
"""分值字符串格式校验:必须可被 Decimal 解析且为有限数。
|
||||||
|
|
||||||
|
``NaN`` 参与比较抛 InvalidOperation、``Infinity`` 落库即溢出,均须在
|
||||||
|
入口用 ``is_finite()`` 拦下(与 schemas/product.py 的 _parse_non_negative
|
||||||
|
同口径);非负与 ≤100 属业务值域,留给 service 层在审计上下文内判定。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
score = Decimal(value.strip())
|
||||||
|
except InvalidOperation as exc:
|
||||||
|
raise ValueError(f"{label}必须是有效数字:{value}") from exc
|
||||||
|
if not score.is_finite():
|
||||||
|
raise ValueError(f"{label}必须为有限数字:{value}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class RatingGradeOut(BaseModel):
|
||||||
|
"""评级等级响应(列表直出)。分值按红线以字符串出参。"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
grade_name: str
|
||||||
|
min_score: str
|
||||||
|
max_score: str
|
||||||
|
sort_order: int
|
||||||
|
remark: str | None = None
|
||||||
|
create_time: datetime
|
||||||
|
update_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RatingGradeCreateIn(BaseModel):
|
||||||
|
"""新增评级等级。
|
||||||
|
|
||||||
|
min_score/max_score 必填:区间是判定表的判定依据,缺省无从判定。
|
||||||
|
sort_order 缺省 0(列默认值同源),判定按其升序取首个命中等级。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
grade_name: str = Field(min_length=1, max_length=32, description="等级名称(如 A/优秀)")
|
||||||
|
min_score: str = Field(description="总分下限(含),0-100 数字字符串")
|
||||||
|
max_score: str = Field(description="总分上限(展示用),0-100 数字字符串")
|
||||||
|
sort_order: int = Field(default=0, ge=0, description="判定顺序,升序")
|
||||||
|
remark: str | None = Field(default=None, max_length=255, description="说明")
|
||||||
|
|
||||||
|
@field_validator("min_score")
|
||||||
|
@classmethod
|
||||||
|
def _check_min_score(cls, value: str) -> str:
|
||||||
|
"""总分下限须为有限数字(值域 0-100 由 service 层判定)。"""
|
||||||
|
return _parse_score(value, label="总分下限")
|
||||||
|
|
||||||
|
@field_validator("max_score")
|
||||||
|
@classmethod
|
||||||
|
def _check_max_score(cls, value: str) -> str:
|
||||||
|
"""总分上限须为有限数字(值域 0-100 由 service 层判定)。"""
|
||||||
|
return _parse_score(value, label="总分上限")
|
||||||
|
|
||||||
|
|
||||||
|
class RatingGradeUpdateIn(RatingGradeCreateIn):
|
||||||
|
"""编辑评级等级。
|
||||||
|
|
||||||
|
前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRatingOut(BaseModel):
|
||||||
|
"""客户评级响应(每客户一行;总分与分值按红线以字符串出参)。
|
||||||
|
|
||||||
|
grade_name 是保存时点按等级映射**快照固化**的值(无外键):等级表后续
|
||||||
|
增删改不影响已保存的评级结论;为 NULL 表示保存时未命中任何等级
|
||||||
|
(总分低于全部下限或等级表被清空)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
customer_id: int
|
||||||
|
score_ship_amount: str
|
||||||
|
score_receipt_rate: str
|
||||||
|
score_profit_rate: str
|
||||||
|
score_contract_cooperation: str
|
||||||
|
score_site_construction: str
|
||||||
|
total_score: str
|
||||||
|
grade_name: str | None = None
|
||||||
|
remark: str | None = None
|
||||||
|
scorer_name: str
|
||||||
|
create_time: datetime
|
||||||
|
update_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRatingSaveIn(BaseModel):
|
||||||
|
"""保存客户评级(每客户一行,整体替换语义)。
|
||||||
|
|
||||||
|
五项分值为 0-20 的数字字符串(schema 只拦格式;0≤分≤20 与总分等级
|
||||||
|
判定在 service 层审计上下文内完成 —— 失败的 400 同样留痕)。总分由
|
||||||
|
服务端按五项重算,**不采信前端回传的总分**。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
score_ship_amount: str = Field(description="发货额评分(0-20 数字字符串)")
|
||||||
|
score_receipt_rate: str = Field(description="回款率评分(0-20 数字字符串)")
|
||||||
|
score_profit_rate: str = Field(description="毛利率评分(0-20 数字字符串)")
|
||||||
|
score_contract_cooperation: str = Field(description="合同履约配合能力评分(0-20 数字字符串)")
|
||||||
|
score_site_construction: str = Field(description="现场规范施工能力评分(0-20 数字字符串)")
|
||||||
|
remark: str | None = Field(default=None, max_length=255, description="备注")
|
||||||
|
|
||||||
|
@field_validator(
|
||||||
|
"score_ship_amount",
|
||||||
|
"score_receipt_rate",
|
||||||
|
"score_profit_rate",
|
||||||
|
"score_contract_cooperation",
|
||||||
|
"score_site_construction",
|
||||||
|
)
|
||||||
|
@classmethod
|
||||||
|
def _check_score(cls, value: str) -> str:
|
||||||
|
"""单项分值须为有限数字(值域 0-20 由 service 层判定)。"""
|
||||||
|
return _parse_score(value, label="评分")
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""回款管理的请求/响应模型(需求 2.2.3)。
|
||||||
|
|
||||||
|
金额入参出参一律 str(服务层 Decimal,全程禁 float);字段契约**纯蛇形
|
||||||
|
直出**(2026-10-13 定稿,无驼峰 alias)。
|
||||||
|
|
||||||
|
payment_days 是派生值(发货日 → 回款日的天数,对照表 #1),不在写接口
|
||||||
|
入参内 —— 落库列只有 shipment_id 源数据,派生值实时计算,发货批次变更
|
||||||
|
后无需回写历史。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
|
# 金额校验是工资系统的红线口径(数字/非负/容量/两位小数拒绝),跨 schema
|
||||||
|
# 单点复用 T7 定稿工具,禁止各处复制导致口径漂移;AMOUNT_MAX 即
|
||||||
|
# DECIMAL(14,2) 的容量上限,与回款列容量同源
|
||||||
|
from app.schemas.business import AMOUNT_MAX, _check_amount
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptCreateIn(BaseModel):
|
||||||
|
"""新增回款:业务必选,发货批次可暂不指定(手动指定,对照表 #18)。
|
||||||
|
|
||||||
|
shipment_id 指向的发货单必须与本回款同属一个业务(服务层强校验,
|
||||||
|
数据完整性红线);shipment_id 可空 = 暂未指定,指定后回款天数方可派生。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
business_id: int = Field(ge=1, description="业务ID")
|
||||||
|
shipment_id: int | None = Field(default=None, ge=1, description="发货单ID,可空=暂未指定")
|
||||||
|
receipt_date: date = Field(description="回款到账日(M3 按此日匹配参数版本)")
|
||||||
|
amount: str = Field(description="回款金额(元),≥0,最多两位小数")
|
||||||
|
is_barter: int = Field(default=0, ge=0, le=1, description="以物抵顶标记:1是 0否")
|
||||||
|
remark: str | None = Field(default=None, max_length=255, description="备注")
|
||||||
|
|
||||||
|
@field_validator("amount")
|
||||||
|
@classmethod
|
||||||
|
def _check_amount_value(cls, value: str) -> str:
|
||||||
|
return _check_amount(value, "回款金额", AMOUNT_MAX) or value
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptUpdateIn(BaseModel):
|
||||||
|
"""编辑回款(全量替换语义)。
|
||||||
|
|
||||||
|
shipment_id 传 None = 清除指定(回到「暂未指定」);amount 是必填项,
|
||||||
|
全量替换下不存在「不传即不变」的歧义。已核算(settlement_status=1)
|
||||||
|
的回款在服务层禁改,schema 层不感知该状态。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
shipment_id: int | None = Field(
|
||||||
|
default=None, ge=1, description="发货单ID,可空=暂未指定(传空清除既有指定)"
|
||||||
|
)
|
||||||
|
receipt_date: date = Field(description="回款到账日")
|
||||||
|
amount: str = Field(description="回款金额(元),≥0,最多两位小数")
|
||||||
|
is_barter: int = Field(default=0, ge=0, le=1, description="以物抵顶标记:1是 0否")
|
||||||
|
remark: str | None = Field(default=None, max_length=255, description="备注")
|
||||||
|
|
||||||
|
@field_validator("amount")
|
||||||
|
@classmethod
|
||||||
|
def _check_amount_value(cls, value: str) -> str:
|
||||||
|
return _check_amount(value, "回款金额", AMOUNT_MAX) or value
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptOut(BaseModel):
|
||||||
|
"""回款响应(列表与写操作回包共用)。
|
||||||
|
|
||||||
|
business_no/shipment_no 为关联冗余展示;payment_days 为派生值:
|
||||||
|
未指定发货批次时为 None,指定后 = 发货日到回款日的天数(发货日首日
|
||||||
|
计第 1 天)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
receipt_batch_no: str
|
||||||
|
business_id: int
|
||||||
|
business_no: str
|
||||||
|
shipment_id: int | None = None
|
||||||
|
shipment_no: str | None = None
|
||||||
|
receipt_date: date
|
||||||
|
amount: str
|
||||||
|
is_barter: int
|
||||||
|
settlement_status: int
|
||||||
|
payment_days: int | None = None
|
||||||
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptQuery(PageQuery):
|
||||||
|
"""回款列表查询条件(筛选参数纯蛇形直出,同 BusinessQuery 契约)。"""
|
||||||
|
|
||||||
|
business_id: int | None = Field(default=None, ge=1, description="业务筛选")
|
||||||
|
date_from: date | None = Field(default=None, description="回款到账日起(含)")
|
||||||
|
date_to: date | None = Field(default=None, description="回款到账日止(含)")
|
||||||
|
is_barter: int | None = Field(default=None, ge=0, le=1, description="以物抵顶筛选:1是 0否")
|
||||||
|
settlement_status: int | None = Field(
|
||||||
|
default=None, ge=0, le=1, description="核算状态筛选:0未核算 1已核算"
|
||||||
|
)
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"""报表统计的请求/响应模型(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
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""系统设置(公司信息 + 站点信息)请求/响应模型。
|
||||||
|
|
||||||
|
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
|
||||||
|
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
|
||||||
|
顺着复用链泄漏到匿名端点。
|
||||||
|
|
||||||
|
2026-10-20 精简(用户确认):删除系统副标题与企业档案字段(信用代码/
|
||||||
|
地址/电话/邮箱);新增站点标题(浏览器标签页/侧边栏副标题)与站点图标
|
||||||
|
(浏览器标签页 favicon)。public 端点只需登录页/标签页展示字段,两者
|
||||||
|
恰好同集合,但仍保留独立 schema 作为白名单防线。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SettingOut(BaseModel):
|
||||||
|
"""公司信息与站点信息(管理端)。"""
|
||||||
|
|
||||||
|
company_name: str
|
||||||
|
logo_url: str | None = None
|
||||||
|
site_title: str | None = None
|
||||||
|
brand_slogan: str | None = None
|
||||||
|
footer_text: str | None = None
|
||||||
|
icp_no: str | None = None
|
||||||
|
favicon_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SettingPublicOut(BaseModel):
|
||||||
|
"""公司信息与站点信息(匿名端点白名单):登录页/浏览器标签页展示所需字段。"""
|
||||||
|
|
||||||
|
company_name: str
|
||||||
|
logo_url: str | None = None
|
||||||
|
site_title: str | None = None
|
||||||
|
brand_slogan: str | None = None
|
||||||
|
footer_text: str | None = None
|
||||||
|
icp_no: str | None = None
|
||||||
|
favicon_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SettingUpdateIn(BaseModel):
|
||||||
|
"""公司信息与站点信息更新(**部分更新**语义)。
|
||||||
|
|
||||||
|
str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1
|
||||||
|
后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。
|
||||||
|
|
||||||
|
仅提交的字段生效(``exclude_unset``),未提交或显式 null 的字段保持
|
||||||
|
不变;可选文本传空串等价清空(落库为 null、前端回退内置默认)。
|
||||||
|
company_name 若提交必须非空(DB 非空约束)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
company_name: str | None = Field(
|
||||||
|
default=None, min_length=1, max_length=64, description="公司名称(提交则须非空)"
|
||||||
|
)
|
||||||
|
site_title: str | None = Field(default=None, max_length=64, description="站点标题")
|
||||||
|
brand_slogan: str | None = Field(default=None, max_length=64, description="品牌标语")
|
||||||
|
footer_text: str | None = Field(default=None, max_length=64, description="页脚版权文本")
|
||||||
|
icp_no: str | None = Field(default=None, max_length=32, description="ICP 备案号")
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""月度结算的请求/响应模型(M3 ④)。"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.month import validate_month
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
|
|
||||||
|
def _check_month(value: str) -> str:
|
||||||
|
"""月份格式 YYYY-MM 且月份 01-12(settlement_month 全表唯一的维度)。"""
|
||||||
|
return validate_month(value)
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementGenerateIn(BaseModel):
|
||||||
|
"""月结生成入参。"""
|
||||||
|
|
||||||
|
month: str = Field(description="结算月份 YYYY-MM")
|
||||||
|
|
||||||
|
@field_validator("month")
|
||||||
|
@classmethod
|
||||||
|
def _v_month(cls, value: str) -> str:
|
||||||
|
return _check_month(value)
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementQuery(PageQuery):
|
||||||
|
"""月结列表查询。"""
|
||||||
|
|
||||||
|
status: int | None = Field(default=None, ge=1, le=4, description="状态筛选")
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementItemOut(BaseModel):
|
||||||
|
"""月结明细行。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
personnel_id: int
|
||||||
|
personnel_name: str
|
||||||
|
role_code: str
|
||||||
|
calc_type: int
|
||||||
|
performance_sum: Decimal
|
||||||
|
payable_sum: Decimal
|
||||||
|
basic_amount: Decimal | None = None
|
||||||
|
salary_mode: str | None = None
|
||||||
|
net_amount: Decimal | None = None
|
||||||
|
is_probation: int
|
||||||
|
probation_note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementOut(BaseModel):
|
||||||
|
"""月结头表响应。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
settlement_month: str
|
||||||
|
status: int
|
||||||
|
total_performance: Decimal | None = None
|
||||||
|
total_payable: Decimal | None = None
|
||||||
|
person_count: int
|
||||||
|
approval_order_no: str | None = None
|
||||||
|
submitter_name: str
|
||||||
|
locked_at: datetime | datetime | None = None
|
||||||
|
locked_by: str | None = None
|
||||||
|
create_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementDetailOut(SettlementOut):
|
||||||
|
"""月结详情:附明细。"""
|
||||||
|
|
||||||
|
items: list[SettlementItemOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AdjustmentCreateIn(BaseModel):
|
||||||
|
"""调整单创建入参(金额恒正,方向表达补发/扣减)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
settlement_month: str = Field(description="调整月份 YYYY-MM")
|
||||||
|
personnel_id: int = Field(ge=1, description="调整人员ID")
|
||||||
|
direction: int = Field(ge=1, le=2, description="1补发 2扣减")
|
||||||
|
# 正则先拦格式(非负两位小数、≤12 位整数防 NUMERIC(14,2) 溢出);
|
||||||
|
# 「> 0」由服务层判定(schema 只管形状,语义校验留在 service)
|
||||||
|
amount: str = Field(
|
||||||
|
pattern=r"^\d{1,12}(\.\d{1,2})?$",
|
||||||
|
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)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""发货管理的请求/响应模型(需求 2.2.2)。
|
||||||
|
|
||||||
|
金额/数量入参出参一律 str(服务层 Decimal 运算,全程禁 float),容量与
|
||||||
|
小数位校验复用 business.py 的 ``_check_amount``(同一套口径,防两处漂移);
|
||||||
|
字段契约全线蛇形直出(2026-10-13 定稿:无驼峰 alias)。
|
||||||
|
|
||||||
|
amount(行金额)与 total_amount(发货总金额)不在入参内 —— 由服务层按
|
||||||
|
数量×单价 计算固化,不信任前端传值;运费仅记录展示,不计入毛利润口径。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentItemIn(BaseModel):
|
||||||
|
"""发货产品明细行(product_id 空=自由录入;价格口径两可,可空人工补)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
product_id: int | None = Field(default=None, ge=1, description="产品ID;空=自由录入")
|
||||||
|
product_name: str = Field(min_length=1, max_length=128, description="产品名称")
|
||||||
|
spec_model: str | None = Field(
|
||||||
|
default=None, max_length=128, description="规格型号,空=空串归一"
|
||||||
|
)
|
||||||
|
unit: str | None = Field(default=None, max_length=16, description="计量单位")
|
||||||
|
quantity: str = Field(description="数量,>0")
|
||||||
|
sale_price: str | None = Field(
|
||||||
|
default=None, description="发货单价(元),可空=待人工补价,补价前行金额记 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("quantity")
|
||||||
|
@classmethod
|
||||||
|
def _check_quantity(cls, value: str) -> str:
|
||||||
|
return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value
|
||||||
|
|
||||||
|
@field_validator("sale_price")
|
||||||
|
@classmethod
|
||||||
|
def _check_price(cls, value: str | None) -> str | None:
|
||||||
|
"""发货单价列 DECIMAL(14,4):按 4 位小数口径(回显可原样提交)。"""
|
||||||
|
return _check_amount(value, "单价", PRICE_MAX, scale=4)
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentItemOut(BaseModel):
|
||||||
|
"""发货产品明细行响应。金额/数量一律 str。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
product_id: int | None = None
|
||||||
|
product_name: str
|
||||||
|
spec_model: str | None = None
|
||||||
|
unit: str | None = None
|
||||||
|
quantity: str
|
||||||
|
sale_price: str | None = None
|
||||||
|
amount: str
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentOut(BaseModel):
|
||||||
|
"""发货单响应(列表与写操作回包共用);business_no 为 join 附带列。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
shipment_no: str
|
||||||
|
business_id: int
|
||||||
|
business_no: str | None = None
|
||||||
|
ship_date: date
|
||||||
|
freight_amount: str
|
||||||
|
total_amount: str
|
||||||
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentDetailOut(ShipmentOut):
|
||||||
|
"""发货单详情:附产品明细行。"""
|
||||||
|
|
||||||
|
items: list[ShipmentItemOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentCreateIn(BaseModel):
|
||||||
|
"""创建发货单:业务维度 + 明细行数组(明细 ≥1 行)。
|
||||||
|
|
||||||
|
字段名全线蛇形直出(同 business.py 契约,仅分页信封 list/pageNum 驼峰)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
business_id: int = Field(ge=1, description="业务ID")
|
||||||
|
ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)")
|
||||||
|
freight_amount: str | None = Field(
|
||||||
|
default=None, description="运费金额(元),空记 0;仅记录展示,不计入毛利润口径"
|
||||||
|
)
|
||||||
|
items: list[ShipmentItemIn] = Field(min_length=1, description="发货明细行,至少 1 行")
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("freight_amount")
|
||||||
|
@classmethod
|
||||||
|
def _check_freight(cls, value: str | None) -> str | None:
|
||||||
|
"""运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。"""
|
||||||
|
return _check_amount(value, "运费金额", AMOUNT_MAX)
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentUpdateIn(BaseModel):
|
||||||
|
"""编辑发货单(明细全量替换)。
|
||||||
|
|
||||||
|
business_id 不在编辑入参内 —— 发货单挂靠的业务创建时定死,改挂会让
|
||||||
|
已指定本发货单的回款语义断裂。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)")
|
||||||
|
freight_amount: str | None = Field(default=None, description="运费金额(元),空记 0")
|
||||||
|
items: list[ShipmentItemIn] = Field(
|
||||||
|
min_length=1, description="发货明细行,至少 1 行(全量替换)"
|
||||||
|
)
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("freight_amount")
|
||||||
|
@classmethod
|
||||||
|
def _check_freight(cls, value: str | None) -> str | None:
|
||||||
|
"""运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。"""
|
||||||
|
return _check_amount(value, "运费金额", AMOUNT_MAX)
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentQuery(PageQuery):
|
||||||
|
"""发货单列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
|
||||||
|
|
||||||
|
business_id 可空=全量:发货单以业务维度查看为主(一期),但单号可
|
||||||
|
独立检索,不强制挂业务条件。
|
||||||
|
"""
|
||||||
|
|
||||||
|
business_id: int | None = Field(default=None, ge=1, description="业务筛选,空=全部")
|
||||||
|
ship_date_from: date | None = Field(default=None, description="发货日期起(含)")
|
||||||
|
ship_date_to: date | None = Field(default=None, description="发货日期止(含)")
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
"""审批流业务编排(2026-10-15 复核审批补充需求)。
|
||||||
|
|
||||||
|
三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数开关驱动
|
||||||
|
(`approval.secondary_allocation.enabled` / `approval.monthly_settlement.enabled`):
|
||||||
|
|
||||||
|
- **关闭(默认)**:提交即生成审批单并「自动通过」全节点留痕(action=3,
|
||||||
|
operator 记「系统」)——留痕不断链,与「关闭=直接生效」的需求口径一致;
|
||||||
|
- **开启**:status=1 待财务复核 → 2 待领导审核 → 3 已通过;任一节点驳回
|
||||||
|
即整体驳回(status=4),一期不做退回重走(简单状态机)。
|
||||||
|
|
||||||
|
红线:
|
||||||
|
- `submit_order` **不 commit、不嵌套 audit** —— 由业务写路径(business_service
|
||||||
|
的 create/update)既有 audit 上下文统一收口,违反嵌套禁令会让失败路径
|
||||||
|
回滚不完整(见 audit_service 模块 docstring);
|
||||||
|
- 节点准入**严格按角色编码**:status=1 仅财务、status=2 仅领导,admin 不
|
||||||
|
代办 —— 放行 admin 会让双人复核流退化成管理员单点审批;
|
||||||
|
- 同一引用对象的在途单(status 1/2)在重新提交前先撤回(status=5 +
|
||||||
|
node=0/action=4 留痕),保证「一个对象至多一张在途单」。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_APPROVE,
|
||||||
|
ACTION_REJECT,
|
||||||
|
MODULE_APPROVAL,
|
||||||
|
NO_PREFIX_APPROVAL,
|
||||||
|
ROLE_ADMIN,
|
||||||
|
ROLE_FINANCE,
|
||||||
|
ROLE_LEADER,
|
||||||
|
)
|
||||||
|
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||||
|
from app.core.response import PageResult
|
||||||
|
from app.models import ApprovalOrder, ApprovalStep, User
|
||||||
|
from app.models.approval import (
|
||||||
|
APPROVAL_ACTION_APPROVE,
|
||||||
|
APPROVAL_ACTION_AUTO_APPROVE,
|
||||||
|
APPROVAL_ACTION_REJECT,
|
||||||
|
APPROVAL_ACTION_WITHDRAW,
|
||||||
|
APPROVAL_NODE_FINANCE,
|
||||||
|
APPROVAL_NODE_LEADER,
|
||||||
|
APPROVAL_NODE_WITHDRAW,
|
||||||
|
APPROVAL_NODES,
|
||||||
|
APPROVAL_STATUS_APPROVED,
|
||||||
|
APPROVAL_STATUS_PENDING_FINANCE,
|
||||||
|
APPROVAL_STATUS_PENDING_LEADER,
|
||||||
|
APPROVAL_STATUS_REJECTED,
|
||||||
|
APPROVAL_STATUS_WITHDRAWN,
|
||||||
|
)
|
||||||
|
from app.repositories import user_repository
|
||||||
|
from app.schemas.approval import (
|
||||||
|
ApprovalActionIn,
|
||||||
|
ApprovalOrderDetailOut,
|
||||||
|
ApprovalOrderOut,
|
||||||
|
ApprovalQuery,
|
||||||
|
ApprovalStepOut,
|
||||||
|
)
|
||||||
|
from app.services import audit_service, param_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
from app.services.no_generator import next_no
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 开关参数编码(与 param_registry 种子同源;新增审批对象类型必须在此登记,
|
||||||
|
# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用)
|
||||||
|
ENABLE_CODE_BY_TYPE: dict[int, str] = {
|
||||||
|
1: "approval.secondary_allocation.enabled",
|
||||||
|
2: "approval.monthly_settlement.enabled",
|
||||||
|
3: "approval.adjustment.enabled",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 在途状态集合(同一引用对象至多一张在途单)
|
||||||
|
_PENDING_STATUSES = (APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
|
||||||
|
|
||||||
|
# 在途状态 → 当前节点应操作的角色(admin 不代办:单人审批绕过双人复核)
|
||||||
|
_NODE_ROLE_BY_STATUS = {
|
||||||
|
APPROVAL_STATUS_PENDING_FINANCE: ROLE_FINANCE,
|
||||||
|
APPROVAL_STATUS_PENDING_LEADER: 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:
|
||||||
|
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
|
||||||
|
code = ENABLE_CODE_BY_TYPE.get(order_type)
|
||||||
|
if code is None:
|
||||||
|
raise BadRequestError("未知的审批对象类型")
|
||||||
|
return param_service.resolve_snapshot(session, date.today()).boolean(code)
|
||||||
|
|
||||||
|
|
||||||
|
def submit_order(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
order_type: int,
|
||||||
|
ref_id: int,
|
||||||
|
refer_no: str,
|
||||||
|
submitter: User,
|
||||||
|
) -> ApprovalOrder:
|
||||||
|
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
|
||||||
|
|
||||||
|
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
|
||||||
|
(全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透:
|
||||||
|
create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单
|
||||||
|
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
|
||||||
|
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
|
||||||
|
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
|
||||||
|
"""
|
||||||
|
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
|
||||||
|
|
||||||
|
order = ApprovalOrder(
|
||||||
|
order_no=next_no(
|
||||||
|
session,
|
||||||
|
model=ApprovalOrder,
|
||||||
|
column_name="order_no",
|
||||||
|
prefix=NO_PREFIX_APPROVAL,
|
||||||
|
),
|
||||||
|
order_type=order_type,
|
||||||
|
ref_id=ref_id,
|
||||||
|
refer_no=refer_no,
|
||||||
|
submitter_id=submitter.id,
|
||||||
|
submitter_name=submitter.username,
|
||||||
|
)
|
||||||
|
session.add(order)
|
||||||
|
|
||||||
|
if is_enabled(session, order_type):
|
||||||
|
order.status = APPROVAL_STATUS_PENDING_FINANCE
|
||||||
|
session.flush()
|
||||||
|
logger.info("审批单待审 | order=%s", order.order_no)
|
||||||
|
return order
|
||||||
|
|
||||||
|
# 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链
|
||||||
|
order.status = APPROVAL_STATUS_APPROVED
|
||||||
|
session.flush()
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
ApprovalStep(
|
||||||
|
order_id=order.id,
|
||||||
|
node=node,
|
||||||
|
action=APPROVAL_ACTION_AUTO_APPROVE,
|
||||||
|
operator_name="系统",
|
||||||
|
)
|
||||||
|
for node in (APPROVAL_NODE_FINANCE, APPROVAL_NODE_LEADER)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
logger.info("审批单自动通过 | order=%s(开关关闭)", order.order_no)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def withdraw_pending_orders(
|
||||||
|
session: Session, *, order_type: int, ref_id: int, operator: User
|
||||||
|
) -> int:
|
||||||
|
"""撤回引用对象的全部在途单(供业务清空方案等场景显式调用;不 commit)。
|
||||||
|
|
||||||
|
返回撤回张数(0 = 本就无在途单)。
|
||||||
|
"""
|
||||||
|
before = len(
|
||||||
|
session.execute(
|
||||||
|
select(ApprovalOrder.id).where(
|
||||||
|
ApprovalOrder.order_type == order_type,
|
||||||
|
ApprovalOrder.ref_id == ref_id,
|
||||||
|
ApprovalOrder.status.in_(_PENDING_STATUSES),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if before:
|
||||||
|
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=operator)
|
||||||
|
return before
|
||||||
|
|
||||||
|
|
||||||
|
def act(
|
||||||
|
session: Session,
|
||||||
|
order_id: int,
|
||||||
|
payload: ApprovalActionIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ApprovalOrderDetailOut:
|
||||||
|
"""节点动作(财务复核/领导审核):状态机流转 + 节点留痕(审计上下文)。
|
||||||
|
|
||||||
|
通过:status=1 → 2(下一步领导);status=2 → 3(终态已通过)。
|
||||||
|
驳回:任一在途状态 → 4(整体驳回,一期不退回重走)。
|
||||||
|
节点留痕的 node 取**动作发生时的当前节点**(通过/驳回都记在操作节点上)。
|
||||||
|
"""
|
||||||
|
codes = set(user_repository.get_role_codes(operator))
|
||||||
|
verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_APPROVAL,
|
||||||
|
action=verb,
|
||||||
|
summary=f"审批单 #{order_id} {verb}",
|
||||||
|
request_data=payload.model_dump(),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
order = session.get(ApprovalOrder, order_id, with_for_update=True)
|
||||||
|
if order is None:
|
||||||
|
raise NotFoundError("审批单不存在")
|
||||||
|
if order.status not in _PENDING_STATUSES:
|
||||||
|
raise BadRequestError("审批单已结束,不可重复操作")
|
||||||
|
|
||||||
|
required_role = _NODE_ROLE_BY_STATUS[order.status]
|
||||||
|
if required_role not in codes:
|
||||||
|
raise ForbiddenError(f"当前节点须{_ROLE_LABEL[required_role]}角色操作")
|
||||||
|
|
||||||
|
node_now = APPROVAL_NODES[order.status]
|
||||||
|
rejected = payload.action == APPROVAL_ACTION_REJECT
|
||||||
|
if rejected:
|
||||||
|
order.status = APPROVAL_STATUS_REJECTED
|
||||||
|
else:
|
||||||
|
order.status = (
|
||||||
|
APPROVAL_STATUS_PENDING_LEADER
|
||||||
|
if order.status == APPROVAL_STATUS_PENDING_FINANCE
|
||||||
|
else APPROVAL_STATUS_APPROVED
|
||||||
|
)
|
||||||
|
# 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前
|
||||||
|
# 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。
|
||||||
|
# 驳回在任何节点都是终态,即时联动。
|
||||||
|
final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
ApprovalStep(
|
||||||
|
order_id=order.id,
|
||||||
|
node=node_now,
|
||||||
|
action=payload.action,
|
||||||
|
opinion=payload.opinion,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以
|
||||||
|
# 「当前生效行」身份留在表里、被 M3 核算快照算进工资;方案事实由
|
||||||
|
# 审批单留痕与操作日志 request_data 承载(分摊语义落定后如需保留
|
||||||
|
# 历史版本,在 ④ 设计时演进)。审批单的清理由调用方(act 内联)
|
||||||
|
# 委托 business_repository 完成 —— service 层互调、不触 commit。
|
||||||
|
if rejected and order.order_type == 1:
|
||||||
|
from app.repositories import business_repository
|
||||||
|
|
||||||
|
business_repository.delete_allocations(session, order.ref_id)
|
||||||
|
|
||||||
|
# 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿;
|
||||||
|
# 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。
|
||||||
|
if order.order_type == 2 or order.order_type == 3:
|
||||||
|
from app.models import AdjustmentOrder
|
||||||
|
from app.models.settlement import (
|
||||||
|
ADJUSTMENT_STATUS_EFFECTIVE,
|
||||||
|
)
|
||||||
|
from app.models.settlement import (
|
||||||
|
ADJUSTMENT_STATUS_REJECTED as ADJ_REJECTED,
|
||||||
|
)
|
||||||
|
from app.services import settlement_service
|
||||||
|
|
||||||
|
if order.order_type == 2:
|
||||||
|
from app.models.settlement import MonthlySettlement
|
||||||
|
|
||||||
|
target = session.get(MonthlySettlement, order.ref_id)
|
||||||
|
if target is not None:
|
||||||
|
if rejected:
|
||||||
|
settlement_service.mark_rejected(session, target, operator=operator)
|
||||||
|
elif final_approved:
|
||||||
|
settlement_service.mark_locked(session, target, operator=operator)
|
||||||
|
else:
|
||||||
|
adj = session.get(AdjustmentOrder, order.ref_id)
|
||||||
|
if adj is not None:
|
||||||
|
if rejected:
|
||||||
|
adj.status = ADJ_REJECTED
|
||||||
|
elif final_approved:
|
||||||
|
adj.status = ADJUSTMENT_STATUS_EFFECTIVE
|
||||||
|
|
||||||
|
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
|
||||||
|
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
|
||||||
|
# 成失败审计,与库中事实相反(全 services 层唯一例外不许再犯)
|
||||||
|
session.flush()
|
||||||
|
detail = _detail_of(session, order)
|
||||||
|
session.commit()
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
|
||||||
|
"""分页查询审批单(准入由 api 层依赖判定)。"""
|
||||||
|
filters = _order_filters(query)
|
||||||
|
rows = (
|
||||||
|
session.execute(
|
||||||
|
select(ApprovalOrder)
|
||||||
|
.where(*filters)
|
||||||
|
.order_by(ApprovalOrder.id.desc())
|
||||||
|
.offset(query.offset)
|
||||||
|
.limit(query.page_size)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total = session.execute(
|
||||||
|
select(func.count()).select_from(ApprovalOrder).where(*filters)
|
||||||
|
).scalar_one()
|
||||||
|
return PageResult[ApprovalOrderOut](
|
||||||
|
records=[ApprovalOrderOut.model_validate(r) for r in rows],
|
||||||
|
total=int(total),
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_order(session: Session, order_id: int) -> ApprovalOrderDetailOut:
|
||||||
|
"""审批单详情(附节点时间线)。"""
|
||||||
|
order = session.get(ApprovalOrder, order_id)
|
||||||
|
if order is None:
|
||||||
|
raise NotFoundError("审批单不存在")
|
||||||
|
return _detail_of(session, order)
|
||||||
|
|
||||||
|
|
||||||
|
def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operator: User) -> None:
|
||||||
|
"""撤回引用对象的在途单(重新提交前调用;不 commit)。"""
|
||||||
|
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:
|
||||||
|
order.status = APPROVAL_STATUS_WITHDRAWN
|
||||||
|
session.add(
|
||||||
|
ApprovalStep(
|
||||||
|
order_id=order.id,
|
||||||
|
node=APPROVAL_NODE_WITHDRAW,
|
||||||
|
action=APPROVAL_ACTION_WITHDRAW,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if pending:
|
||||||
|
session.flush()
|
||||||
|
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
|
||||||
|
"""出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。"""
|
||||||
|
steps = (
|
||||||
|
session.execute(
|
||||||
|
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
detail = ApprovalOrderDetailOut.model_validate(order)
|
||||||
|
detail.steps = [ApprovalStepOut.model_validate(s) for s in steps]
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
def _order_filters(query: ApprovalQuery) -> tuple:
|
||||||
|
filters = []
|
||||||
|
if query.keyword:
|
||||||
|
# autoescape:用户输入里的 %/_ 按 URL 片段字面匹配(全库统一口径,
|
||||||
|
# 见 customer_repository.contains 用法)——否则 "AP2026_10" 会把
|
||||||
|
# 下划线当单字符通配,搜出无关单据
|
||||||
|
filters.append(
|
||||||
|
ApprovalOrder.order_no.contains(query.keyword, autoescape=True)
|
||||||
|
| ApprovalOrder.refer_no.contains(query.keyword, autoescape=True)
|
||||||
|
)
|
||||||
|
if query.status is not None:
|
||||||
|
filters.append(ApprovalOrder.status == query.status)
|
||||||
|
if query.order_type is not None:
|
||||||
|
filters.append(ApprovalOrder.order_type == query.order_type)
|
||||||
|
return tuple(filters)
|
||||||
@@ -0,0 +1,929 @@
|
|||||||
|
"""业务管理业务编排(需求 2.2.1/2.2.4)。
|
||||||
|
|
||||||
|
事务边界在本层:repository 只 flush,commit/rollback 只写在这里。
|
||||||
|
|
||||||
|
- 业务编号自动生成(no_generator.next_no):MAX+FOR UPDATE 锁当日序号区间,
|
||||||
|
残余竞态以唯一键(1062)冲突重试一次兜底
|
||||||
|
- 五项汇总冗余(total_sale/cost/ship/received/freight)由 ``_recalc_totals``
|
||||||
|
单点重算 —— 明细/发货/回款任何写路径收口到此函数,禁止各处手写
|
||||||
|
- 状态机:1执行中 → 2已完成 / 3已终止;终态不可再变更(一期口径,开放问题 #8);
|
||||||
|
终止必填原因;每次流转写 business_status_logs(创建写首条 old=new=1)
|
||||||
|
- 归属变更(负责/协助)写 ownership_change_logs(owner_type=2),同事务一次
|
||||||
|
commit;只影响变更后核算,不回溯历史(对照表 #19)
|
||||||
|
- 毛利润视图为只读派生值(复用 formula.profit_rate/converted_profit),
|
||||||
|
阈值按 asOf 取 M1 参数快照 —— 落库即漂移,M3 台账才是固化点
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
from fractions import Fraction
|
||||||
|
|
||||||
|
from sqlalchemy import Row, func, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
ACTION_DELETE,
|
||||||
|
ACTION_UPDATE,
|
||||||
|
MODULE_BUSINESS,
|
||||||
|
NO_PREFIX_BUSINESS,
|
||||||
|
)
|
||||||
|
from app.core.db_errors import is_duplicate_entry
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.core.response import PageQuery, PageResult
|
||||||
|
from app.models import (
|
||||||
|
ApprovalOrder,
|
||||||
|
Business,
|
||||||
|
BusinessItem,
|
||||||
|
BusinessStatusLog,
|
||||||
|
Customer,
|
||||||
|
OwnershipChangeLog,
|
||||||
|
Personnel,
|
||||||
|
Receipt,
|
||||||
|
SecondaryAllocation,
|
||||||
|
Shipment,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from app.models.approval import APPROVAL_TYPE_SECONDARY_ALLOCATION
|
||||||
|
from app.models.business import (
|
||||||
|
BUSINESS_STATUS_ACTIVE,
|
||||||
|
BUSINESS_STATUS_DONE,
|
||||||
|
)
|
||||||
|
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||||
|
from app.repositories import business_repository, customer_repository, personnel_repository
|
||||||
|
from app.schemas.business import (
|
||||||
|
BusinessCreateIn,
|
||||||
|
BusinessDetailOut,
|
||||||
|
BusinessItemIn,
|
||||||
|
BusinessItemOut,
|
||||||
|
BusinessOut,
|
||||||
|
BusinessOwnershipIn,
|
||||||
|
BusinessQuery,
|
||||||
|
BusinessStatusIn,
|
||||||
|
BusinessStatusLogOut,
|
||||||
|
BusinessUpdateIn,
|
||||||
|
GrossProfitCategoryOut,
|
||||||
|
GrossProfitOut,
|
||||||
|
GrossProfitRowOut,
|
||||||
|
OwnershipChangeLogOut,
|
||||||
|
SecondaryAllocationIn,
|
||||||
|
SecondaryAllocationOut,
|
||||||
|
)
|
||||||
|
from app.services import approval_service, audit_service, param_service, personnel_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
from app.services.formula import converted_profit, profit_rate
|
||||||
|
from app.services.no_generator import next_no
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 编号唯一键冲突后的重试次数:MAX+FOR UPDATE 已覆盖绝大多数竞态,
|
||||||
|
# 重试是为极端场景(锁等待超时后重入)兜底 —— 无限重试反而掩盖死锁类缺陷
|
||||||
|
_NO_RETRY = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(row: tuple) -> BusinessOut:
|
||||||
|
"""(business, company_name, owner_name, assistant_name) → 响应模型。
|
||||||
|
|
||||||
|
金额冗余列出参 str;汇总列可能为 NULL(server_default 只对 INSERT 生效,
|
||||||
|
存量行可能为 NULL),统一转 "0" 保持契约稳定。
|
||||||
|
"""
|
||||||
|
business, company_name, owner_name, assistant_name = row
|
||||||
|
return BusinessOut(
|
||||||
|
id=business.id,
|
||||||
|
business_no=business.business_no,
|
||||||
|
customer_id=business.customer_id,
|
||||||
|
customer_name=company_name,
|
||||||
|
business_date=business.business_date,
|
||||||
|
status=business.status,
|
||||||
|
owner_personnel_id=business.owner_personnel_id,
|
||||||
|
owner_personnel_name=owner_name,
|
||||||
|
assistant_personnel_id=business.assistant_personnel_id,
|
||||||
|
assistant_personnel_name=assistant_name,
|
||||||
|
is_special=business.is_special,
|
||||||
|
special_note=business.special_note,
|
||||||
|
total_sale_amount=_str_or_zero(business.total_sale_amount),
|
||||||
|
total_cost_amount=_str_or_zero(business.total_cost_amount),
|
||||||
|
total_ship_amount=_str_or_zero(business.total_ship_amount),
|
||||||
|
total_received_amount=_str_or_zero(business.total_received_amount),
|
||||||
|
total_freight_amount=_str_or_zero(business.total_freight_amount),
|
||||||
|
remark=business.remark,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _str_or_zero(value: Decimal | None) -> str:
|
||||||
|
return str(value) if value is not None else "0"
|
||||||
|
|
||||||
|
|
||||||
|
def _item_to_out(item: BusinessItem) -> BusinessItemOut:
|
||||||
|
return BusinessItemOut(
|
||||||
|
id=item.id,
|
||||||
|
product_id=item.product_id,
|
||||||
|
product_name=item.product_name,
|
||||||
|
spec_model=item.spec_model,
|
||||||
|
unit=item.unit,
|
||||||
|
product_category=item.product_category,
|
||||||
|
quantity=str(item.quantity),
|
||||||
|
sale_price=str(item.sale_price) if item.sale_price is not None else None,
|
||||||
|
sale_amount=str(item.sale_amount),
|
||||||
|
cost_amount=str(item.cost_amount) if item.cost_amount is not None else None,
|
||||||
|
guided_price=str(item.guided_price) if item.guided_price is not None else None,
|
||||||
|
remark=item.remark,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_item_rows(business_id: int, items: list[BusinessItemIn]) -> list[BusinessItem]:
|
||||||
|
"""入参明细行转 ORM 行:sale_amount 服务层按 数量×单价 计算固化。
|
||||||
|
|
||||||
|
不信任前端传值(schema 亦无 sale_amount 入参)——单价可空时销售额记 0,
|
||||||
|
由用户在编辑中补价后重算(毛利润视图中销售额 0 的行全部为 0,防除零)。
|
||||||
|
乘积显式 quantize 两位固化:数量(2位)×单价(4位) 可产 6 位小数,直写
|
||||||
|
DECIMAL(14,2) 会被 MySQL 静默舍入且回显漂移(创建响应读内存值、复查
|
||||||
|
读库值前后不一致);HALF_UP 与 MySQL 的 DECIMAL 舍入方向一致。
|
||||||
|
"""
|
||||||
|
rows: list[BusinessItem] = []
|
||||||
|
for item in items:
|
||||||
|
quantity = Decimal(item.quantity)
|
||||||
|
sale_price = Decimal(item.sale_price) if item.sale_price is not None else None
|
||||||
|
sale_amount = (
|
||||||
|
(quantity * sale_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
if sale_price is not None
|
||||||
|
else Decimal("0.00")
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
BusinessItem(
|
||||||
|
business_id=business_id,
|
||||||
|
product_id=item.product_id,
|
||||||
|
product_name=item.product_name,
|
||||||
|
spec_model=item.spec_model or "",
|
||||||
|
unit=item.unit,
|
||||||
|
product_category=item.product_category,
|
||||||
|
quantity=quantity,
|
||||||
|
sale_price=sale_price,
|
||||||
|
sale_amount=sale_amount,
|
||||||
|
cost_amount=Decimal(item.cost_amount) if item.cost_amount is not None else None,
|
||||||
|
guided_price=Decimal(item.guided_price) if item.guided_price is not None else None,
|
||||||
|
remark=item.remark,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _recalc_totals(session: Session, business: Business) -> None:
|
||||||
|
"""重算业务的五项汇总冗余(明细/发货/回款写路径统一收口)。
|
||||||
|
|
||||||
|
五条独立聚合查询(各表单列 SUM,走 business_id 索引)——业务单据量级
|
||||||
|
为单业务数行,不值得为省查询做单大 JOIN。
|
||||||
|
"""
|
||||||
|
sale = session.execute(
|
||||||
|
_sum(BusinessItem.sale_amount, BusinessItem.business_id, business.id)
|
||||||
|
).scalar_one()
|
||||||
|
cost = session.execute(
|
||||||
|
_sum(BusinessItem.cost_amount, BusinessItem.business_id, business.id)
|
||||||
|
).scalar_one()
|
||||||
|
ship = session.execute(_sum_ship_items(business.id)).scalar_one()
|
||||||
|
freight = session.execute(
|
||||||
|
_sum(Shipment.freight_amount, Shipment.business_id, business.id)
|
||||||
|
).scalar_one()
|
||||||
|
received = session.execute(_sum(Receipt.amount, Receipt.business_id, business.id)).scalar_one()
|
||||||
|
business.total_sale_amount = sale or Decimal("0")
|
||||||
|
business.total_cost_amount = cost or Decimal("0")
|
||||||
|
business.total_ship_amount = ship or Decimal("0")
|
||||||
|
business.total_freight_amount = freight or Decimal("0")
|
||||||
|
business.total_received_amount = received or Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
def _sum(column, match_column, value) -> object:
|
||||||
|
return select(func.sum(column)).where(match_column == value)
|
||||||
|
|
||||||
|
|
||||||
|
def _sum_ship_items(business_id: int):
|
||||||
|
"""发货明细行金额合计(经发货单关联到业务)。"""
|
||||||
|
from app.models import ShipmentItem
|
||||||
|
|
||||||
|
return (
|
||||||
|
select(func.sum(ShipmentItem.amount))
|
||||||
|
.select_from(ShipmentItem)
|
||||||
|
.join(Shipment, Shipment.id == ShipmentItem.shipment_id)
|
||||||
|
.where(Shipment.business_id == business_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_no(session: Session) -> str:
|
||||||
|
"""生成业务编号(MAX+FOR UPDATE;1062 兜底重试一次,见模块 docstring)。"""
|
||||||
|
return next_no(session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_personnel_active(session: Session, personnel_id: int, label: str) -> Personnel:
|
||||||
|
"""归属人员须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
|
||||||
|
return personnel_service.ensure_active(session, personnel_id, label=label)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_customer(session: Session, customer_id: int) -> Customer:
|
||||||
|
customer = customer_repository.get_by_id(session, customer_id)
|
||||||
|
if customer is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||||
|
return is_duplicate_entry(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
|
||||||
|
"""分页查询业务(附客户名与归属人名)。"""
|
||||||
|
rows = business_repository.list_businesses(
|
||||||
|
session,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
keyword=query.keyword,
|
||||||
|
status=query.status,
|
||||||
|
customer_id=query.customer_id,
|
||||||
|
owner_personnel_id=query.owner_personnel_id,
|
||||||
|
date_from=query.date_from,
|
||||||
|
date_to=query.date_to,
|
||||||
|
)
|
||||||
|
total = business_repository.count_businesses(
|
||||||
|
session,
|
||||||
|
keyword=query.keyword,
|
||||||
|
status=query.status,
|
||||||
|
customer_id=query.customer_id,
|
||||||
|
owner_personnel_id=query.owner_personnel_id,
|
||||||
|
date_from=query.date_from,
|
||||||
|
date_to=query.date_to,
|
||||||
|
)
|
||||||
|
return PageResult[BusinessOut](
|
||||||
|
records=[_to_out(row) for row in rows],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
||||||
|
"""业务详情(附明细行与二次分配方案,供编辑抽屉回显)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
"""
|
||||||
|
row = business_repository.get_with_names(session, business_id)
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
out = BusinessDetailOut(**_to_out(row).model_dump())
|
||||||
|
out.items = [
|
||||||
|
_item_to_out(item) for item in business_repository.list_items(session, business_id)
|
||||||
|
]
|
||||||
|
allocations = business_repository.list_allocations(session, business_id)
|
||||||
|
out.secondary_allocations = [
|
||||||
|
SecondaryAllocationOut(
|
||||||
|
personnel_id=a.personnel_id, personnel_name=a.personnel_name, ratio=str(a.ratio)
|
||||||
|
)
|
||||||
|
for a in allocations
|
||||||
|
]
|
||||||
|
out.allocation_hint = _allocation_hint(allocations)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _build_allocation_rows(
|
||||||
|
session: Session,
|
||||||
|
business_id: int,
|
||||||
|
items: list[SecondaryAllocationIn],
|
||||||
|
*,
|
||||||
|
old_ids: set[int],
|
||||||
|
) -> list[SecondaryAllocation]:
|
||||||
|
"""构造二次分配明细行:人员校验 + 姓名冗余固化 + 比例转 Decimal。
|
||||||
|
|
||||||
|
:param old_ids: 编辑前已参与的人员 ID 集合(create 传空集)。这些人的
|
||||||
|
行是**历史事实**——personnel_name 冗余固化的设计本就允许离职后回显,
|
||||||
|
编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对
|
||||||
|
**新加入**的参与人做在职校验。
|
||||||
|
|
||||||
|
: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] = []
|
||||||
|
for item in items:
|
||||||
|
if item.personnel_id in old_ids:
|
||||||
|
person = personnel_repository.get_by_id(session, item.personnel_id)
|
||||||
|
if person is None:
|
||||||
|
raise BadRequestError("分摊参与人员不存在")
|
||||||
|
else:
|
||||||
|
person = _ensure_personnel_active(session, item.personnel_id, "分摊参与")
|
||||||
|
rows.append(
|
||||||
|
SecondaryAllocation(
|
||||||
|
business_id=business_id,
|
||||||
|
personnel_id=item.personnel_id,
|
||||||
|
personnel_name=person.personnel_name,
|
||||||
|
ratio=Decimal(item.ratio),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
|
||||||
|
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
|
||||||
|
|
||||||
|
无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。
|
||||||
|
"""
|
||||||
|
if not allocations:
|
||||||
|
return None
|
||||||
|
total = sum((a.ratio for a in allocations), Decimal("0"))
|
||||||
|
total_text = total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
if total == Decimal("100"):
|
||||||
|
return f"二次分配合计 {total_text}%"
|
||||||
|
rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
return f"二次分配合计 {total_text}%,余额 {rest}%(归合同负责营销人员)"
|
||||||
|
|
||||||
|
|
||||||
|
def create_business(
|
||||||
|
session: Session,
|
||||||
|
payload: BusinessCreateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> BusinessOut:
|
||||||
|
"""创建业务 + 明细行 + 首条状态留痕,一个事务。
|
||||||
|
|
||||||
|
创建时指定的归属不写归属留痕(留痕记「变更」,创建即初始事实)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
:raises BadRequestError: 归属人员不存在或已离职 / 明细校验不通过
|
||||||
|
"""
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_BUSINESS,
|
||||||
|
action=ACTION_CREATE,
|
||||||
|
summary=f"创建业务(客户ID={payload.customer_id},明细 {len(payload.items)} 行)",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
_ensure_customer(session, payload.customer_id)
|
||||||
|
if payload.owner_personnel_id is not None:
|
||||||
|
_ensure_personnel_active(session, payload.owner_personnel_id, "负责")
|
||||||
|
if payload.assistant_personnel_id is not None:
|
||||||
|
_ensure_personnel_active(session, payload.assistant_personnel_id, "协助")
|
||||||
|
|
||||||
|
# 编号唯一键冲突的重试整体收在审计上下文内:rollback 后同一事务重来,
|
||||||
|
# 成功/最终失败各只记一条审计(吞异常 return 会被记成假成功,重入
|
||||||
|
# 整个 create_* 则违反「不支持嵌套」约定);上限 _NO_RETRY 防无限重试
|
||||||
|
# 掩盖死锁类缺陷。审计摘要预取的是首次编号——重试成功时入库编号见
|
||||||
|
# 应用日志(AuditRecord frozen,不回填)。
|
||||||
|
for attempt in range(_NO_RETRY + 1):
|
||||||
|
try:
|
||||||
|
business = Business(
|
||||||
|
business_no=_generate_no(session),
|
||||||
|
customer_id=payload.customer_id,
|
||||||
|
business_date=payload.business_date,
|
||||||
|
status=BUSINESS_STATUS_ACTIVE,
|
||||||
|
owner_personnel_id=payload.owner_personnel_id,
|
||||||
|
assistant_personnel_id=payload.assistant_personnel_id,
|
||||||
|
is_special=payload.is_special,
|
||||||
|
special_note=payload.special_note,
|
||||||
|
remark=payload.remark,
|
||||||
|
)
|
||||||
|
business_repository.create(session, business)
|
||||||
|
business_repository.replace_items(
|
||||||
|
session, business.id, _build_item_rows(business.id, payload.items)
|
||||||
|
)
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
business_repository.add_status_log(
|
||||||
|
session,
|
||||||
|
BusinessStatusLog(
|
||||||
|
business_id=business.id,
|
||||||
|
old_status=BUSINESS_STATUS_ACTIVE,
|
||||||
|
new_status=BUSINESS_STATUS_ACTIVE,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 二次分配方案与业务同事务(需求:只出现在合同刚建立时);
|
||||||
|
# 放在重试循环内——rollback 后方案行随业务一并重来
|
||||||
|
business_repository.replace_allocations(
|
||||||
|
session,
|
||||||
|
business.id,
|
||||||
|
_build_allocation_rows(
|
||||||
|
session, business.id, payload.secondary_allocations, old_ids=set()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 方案非空才提单:开关关闭=自动通过留痕,开启=待财务复核。
|
||||||
|
# AP 编号撞键时 IntegrityError 穿透 → 外层编号重试循环把
|
||||||
|
# 业务/方案/审批单整体重来
|
||||||
|
if payload.secondary_allocations:
|
||||||
|
approval_service.submit_order(
|
||||||
|
session,
|
||||||
|
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||||
|
ref_id=business.id,
|
||||||
|
refer_no=business.business_no,
|
||||||
|
submitter=operator,
|
||||||
|
)
|
||||||
|
customer = customer_repository.get_by_id(session, business.customer_id)
|
||||||
|
out = _to_out((business, customer.company_name if customer else None, None, None))
|
||||||
|
new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM
|
||||||
|
session.commit()
|
||||||
|
break
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
if _is_duplicate_entry(exc) and attempt < _NO_RETRY:
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("创建业务 | business_id=%s | no=%s", new_id, new_no)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def update_business(
|
||||||
|
session: Session,
|
||||||
|
business_id: int,
|
||||||
|
payload: BusinessUpdateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> BusinessOut:
|
||||||
|
"""编辑业务:全量替换明细 + 重算汇总;归属不在此端点(防旁路)。
|
||||||
|
|
||||||
|
客户可改,但有发货或回款记录后禁改 —— 单据 FK 挂在业务上,换客户会让
|
||||||
|
发货/回款语义断裂(明细同理随业务走)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
:raises BadRequestError: 业务已完成或已终止 / 归属人员不合法 / 有发货或回款时换客户
|
||||||
|
"""
|
||||||
|
row = business_repository.get_with_names(session, business_id, for_update=True)
|
||||||
|
business = row[0] if row else None
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_BUSINESS,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"修改业务 {business.business_no if business else f'#{business_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
business, _, owner_name, assistant_name = row
|
||||||
|
|
||||||
|
# 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线,
|
||||||
|
# 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务)
|
||||||
|
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError("业务已完成或已终止,禁止编辑业务")
|
||||||
|
|
||||||
|
if payload.customer_id != business.customer_id and business_repository.has_docs(
|
||||||
|
session, business_id
|
||||||
|
):
|
||||||
|
raise BadRequestError("业务已有发货或回款记录,不能更换客户")
|
||||||
|
customer = _ensure_customer(session, payload.customer_id)
|
||||||
|
if payload.owner_personnel_id is not None:
|
||||||
|
_ensure_personnel_active(session, payload.owner_personnel_id, "负责")
|
||||||
|
if payload.assistant_personnel_id is not None:
|
||||||
|
_ensure_personnel_active(session, payload.assistant_personnel_id, "协助")
|
||||||
|
|
||||||
|
business.customer_id = payload.customer_id
|
||||||
|
business.business_date = payload.business_date
|
||||||
|
business.is_special = payload.is_special
|
||||||
|
business.special_note = payload.special_note
|
||||||
|
business.remark = payload.remark
|
||||||
|
# 归属刻意不赋值:留痕只由 /ownership 端点产生,编辑路径改归属构成
|
||||||
|
# 「改归属不留痕」的旁路(与 customers 编辑不触归属同一口径)
|
||||||
|
business_repository.replace_items(
|
||||||
|
session, business_id, _build_item_rows(business_id, payload.items)
|
||||||
|
)
|
||||||
|
# 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
|
||||||
|
# 前置场景(月结在 ④,快照固化后按同一哲学隔离)
|
||||||
|
old_ids = {
|
||||||
|
a.personnel_id for a in business_repository.list_allocations(session, business_id)
|
||||||
|
}
|
||||||
|
business_repository.replace_allocations(
|
||||||
|
session,
|
||||||
|
business_id,
|
||||||
|
_build_allocation_rows(
|
||||||
|
session, business_id, payload.secondary_allocations, old_ids=old_ids
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回
|
||||||
|
# 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批
|
||||||
|
if payload.secondary_allocations:
|
||||||
|
approval_service.submit_order(
|
||||||
|
session,
|
||||||
|
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||||
|
ref_id=business_id,
|
||||||
|
refer_no=business.business_no,
|
||||||
|
submitter=operator,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
approval_service.withdraw_pending_orders(
|
||||||
|
session,
|
||||||
|
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||||
|
ref_id=business_id,
|
||||||
|
operator=operator,
|
||||||
|
)
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
||||||
|
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
|
||||||
|
out = _to_out((business, customer.company_name, owner_name, assistant_name))
|
||||||
|
session.commit()
|
||||||
|
logger.info("修改业务 | business_id=%s", business_id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_business(
|
||||||
|
session: Session,
|
||||||
|
business_id: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""删除业务(物理删除):有发货/回款 → 400 引导走「已终止」。
|
||||||
|
|
||||||
|
无单据引用的业务(建错的空单)方可删除;删除时连带清理明细、状态留痕
|
||||||
|
与业务维度的归属留痕(无外键固化表,须手动清)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
:raises BadRequestError: 存在发货或回款记录 / 存在在途审批单
|
||||||
|
"""
|
||||||
|
business = business_repository.get_by_id(session, business_id, for_update=True)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_BUSINESS,
|
||||||
|
action=ACTION_DELETE,
|
||||||
|
summary=f"删除业务 {business.business_no if business else f'#{business_id}'}",
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if business is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
if business_repository.has_docs(session, business_id):
|
||||||
|
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
|
||||||
|
# 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批):
|
||||||
|
# 禁删引导先完成/撤回审批,而不是静默留下指向已删业务的活单
|
||||||
|
if _count_pending_approvals(session, business_id):
|
||||||
|
raise BadRequestError("该业务有在途审批单,请先完成或撤回审批后再删除")
|
||||||
|
|
||||||
|
business_no = business.business_no
|
||||||
|
_hard_delete(session, business_id)
|
||||||
|
session.commit()
|
||||||
|
logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no)
|
||||||
|
|
||||||
|
|
||||||
|
def _count_pending_approvals(session: Session, business_id: int) -> int:
|
||||||
|
"""该业务的在途审批单数(status 1/2;终态单随业务删除一并清理)。"""
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.models import ApprovalOrder
|
||||||
|
from app.models.approval import (
|
||||||
|
APPROVAL_STATUS_PENDING_FINANCE,
|
||||||
|
APPROVAL_STATUS_PENDING_LEADER,
|
||||||
|
)
|
||||||
|
|
||||||
|
return int(
|
||||||
|
session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(ApprovalOrder)
|
||||||
|
.where(
|
||||||
|
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||||
|
ApprovalOrder.ref_id == business_id,
|
||||||
|
ApprovalOrder.status.in_(
|
||||||
|
(APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hard_delete(session: Session, business_id: int) -> None:
|
||||||
|
"""按依赖序删除:明细 → 二次分配方案 → 审批单(steps 随 CASCADE)→
|
||||||
|
状态留痕 → 业务维度归属留痕 → 主表。"""
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
|
||||||
|
session.execute(
|
||||||
|
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||||
|
)
|
||||||
|
session.execute(
|
||||||
|
delete(ApprovalOrder).where(
|
||||||
|
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||||
|
ApprovalOrder.ref_id == business_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.execute(delete(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id))
|
||||||
|
session.execute(
|
||||||
|
delete(OwnershipChangeLog).where(
|
||||||
|
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
|
||||||
|
OwnershipChangeLog.business_id == business_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.execute(delete(Business).where(Business.id == business_id))
|
||||||
|
|
||||||
|
|
||||||
|
def change_status(
|
||||||
|
session: Session,
|
||||||
|
business_id: int,
|
||||||
|
payload: BusinessStatusIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> BusinessOut:
|
||||||
|
"""状态流转:1→2 / 1→3,终态不可再变更;每次流转写留痕。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
:raises BadRequestError: 非执行中状态流转 / 目标与当前相同
|
||||||
|
"""
|
||||||
|
row = business_repository.get_with_names(session, business_id, for_update=True)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_BUSINESS,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=(
|
||||||
|
f"业务状态变更 {row[0].business_no if row else f'#{business_id}'} → "
|
||||||
|
f"{'已完成' if payload.status == BUSINESS_STATUS_DONE else '已终止'}"
|
||||||
|
),
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
business, _, _, _ = row
|
||||||
|
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError("已完成或已终止的业务不可再变更状态")
|
||||||
|
if business.status == payload.status:
|
||||||
|
raise BadRequestError("状态未变化")
|
||||||
|
|
||||||
|
# 变更前状态须在赋值前固化:ORM 属性赋值后 old_status 就取不到旧值
|
||||||
|
# (留痕会记成 old==new 失真,时间线无法回溯)
|
||||||
|
old_status = business.status
|
||||||
|
business.status = payload.status
|
||||||
|
business_repository.add_status_log(
|
||||||
|
session,
|
||||||
|
BusinessStatusLog(
|
||||||
|
business_id=business_id,
|
||||||
|
old_status=old_status,
|
||||||
|
new_status=payload.status,
|
||||||
|
change_reason=payload.reason,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新状态
|
||||||
|
out = _to_out(row)
|
||||||
|
session.commit()
|
||||||
|
logger.info(
|
||||||
|
"业务状态变更 | business_id=%s | %s -> %s",
|
||||||
|
business_id,
|
||||||
|
old_status,
|
||||||
|
payload.status,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def change_ownership(
|
||||||
|
session: Session,
|
||||||
|
business_id: int,
|
||||||
|
payload: BusinessOwnershipIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> BusinessOut:
|
||||||
|
"""归属变更(负责/协助):同事务「更新主表 + 插留痕」,一次 commit。
|
||||||
|
|
||||||
|
负责与协助之一发生变化即记一条留痕(前后值都含两个字段);两者均未变化
|
||||||
|
→ 400「归属未变化」。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
:raises BadRequestError: 归属人员不存在或已离职 / 归属未变化
|
||||||
|
"""
|
||||||
|
row = business_repository.get_with_names(session, business_id, for_update=True)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_BUSINESS,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"调整业务归属 {row[0].business_no if row else f'#{business_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
business, _, old_owner_name, old_assistant_name = row
|
||||||
|
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
|
||||||
|
# 固化标量,新归属人名必须按新 ID 重取
|
||||||
|
new_owner = (
|
||||||
|
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
|
||||||
|
if payload.new_owner_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
new_assistant = (
|
||||||
|
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
|
||||||
|
if payload.new_assistant_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
|
||||||
|
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
|
||||||
|
if not owner_changed and not assistant_changed:
|
||||||
|
raise BadRequestError("归属未变化")
|
||||||
|
|
||||||
|
old_owner_id = business.owner_personnel_id
|
||||||
|
old_assistant_id = business.assistant_personnel_id
|
||||||
|
business.owner_personnel_id = payload.new_owner_personnel_id
|
||||||
|
business.assistant_personnel_id = payload.new_assistant_personnel_id
|
||||||
|
business_repository.add_ownership_log(
|
||||||
|
session,
|
||||||
|
OwnershipChangeLog(
|
||||||
|
owner_type=OWNER_TYPE_BUSINESS,
|
||||||
|
business_id=business_id,
|
||||||
|
old_owner_personnel_id=old_owner_id,
|
||||||
|
new_owner_personnel_id=payload.new_owner_personnel_id,
|
||||||
|
old_assistant_personnel_id=old_assistant_id,
|
||||||
|
new_assistant_personnel_id=payload.new_assistant_personnel_id,
|
||||||
|
change_reason=payload.reason,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
|
||||||
|
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
|
||||||
|
out = _to_out(
|
||||||
|
(
|
||||||
|
business,
|
||||||
|
row[1],
|
||||||
|
new_owner.personnel_name if new_owner is not None else old_owner_name,
|
||||||
|
new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
logger.info(
|
||||||
|
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
|
||||||
|
business_id,
|
||||||
|
old_owner_id,
|
||||||
|
payload.new_owner_personnel_id,
|
||||||
|
old_assistant_id,
|
||||||
|
payload.new_assistant_personnel_id,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def list_ownership_logs(
|
||||||
|
session: Session, business_id: int, query: PageQuery
|
||||||
|
) -> PageResult[OwnershipChangeLogOut]:
|
||||||
|
"""某业务的归属变更留痕(分页)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
"""
|
||||||
|
if business_repository.get_by_id(session, business_id) is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
rows = business_repository.list_ownership_logs(
|
||||||
|
session, business_id, offset=query.offset, limit=query.page_size
|
||||||
|
)
|
||||||
|
total = business_repository.count_ownership_logs(session, business_id=business_id)
|
||||||
|
return PageResult[OwnershipChangeLogOut](
|
||||||
|
records=[_log_to_out(row) for row in rows],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_to_out(row: Row) -> OwnershipChangeLogOut:
|
||||||
|
log, old_owner_name, new_owner_name = row
|
||||||
|
return OwnershipChangeLogOut(
|
||||||
|
id=log.id,
|
||||||
|
business_id=log.business_id,
|
||||||
|
old_owner_personnel_id=log.old_owner_personnel_id,
|
||||||
|
new_owner_personnel_id=log.new_owner_personnel_id,
|
||||||
|
old_assistant_personnel_id=log.old_assistant_personnel_id,
|
||||||
|
new_assistant_personnel_id=log.new_assistant_personnel_id,
|
||||||
|
old_owner_name=old_owner_name,
|
||||||
|
new_owner_name=new_owner_name,
|
||||||
|
change_reason=log.change_reason,
|
||||||
|
operator_id=log.operator_id,
|
||||||
|
operator_name=log.operator_name,
|
||||||
|
create_time=log.create_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_status_logs(
|
||||||
|
session: Session, business_id: int, query: PageQuery
|
||||||
|
) -> PageResult[BusinessStatusLogOut]:
|
||||||
|
"""某业务的状态流转时间线(分页,时间正序)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
"""
|
||||||
|
if business_repository.get_by_id(session, business_id) is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
rows = business_repository.list_status_logs(
|
||||||
|
session, business_id, offset=query.offset, limit=query.page_size
|
||||||
|
)
|
||||||
|
total = business_repository.count_status_logs(session, business_id=business_id)
|
||||||
|
return PageResult[BusinessStatusLogOut](
|
||||||
|
records=[BusinessStatusLogOut.model_validate(row) for row in rows],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut:
|
||||||
|
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
|
||||||
|
|
||||||
|
阈值按 asOf(默认今日)取 M1 参数快照(product.threshold,按行类别维度,
|
||||||
|
存储口径为 % 数如 30,直接进 converted_profit);毛利润率按 M1 预览同一
|
||||||
|
出参口径输出 Fraction 分数字符串(如 "97/10000");成本缺失的行派生列为
|
||||||
|
None 并标注原因;汇总只聚合可计算行。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
"""
|
||||||
|
if business_repository.get_by_id(session, business_id) is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
as_of = as_of or date.today()
|
||||||
|
snapshot = param_service.resolve_snapshot(session, as_of)
|
||||||
|
|
||||||
|
def threshold_of(category: str) -> Fraction:
|
||||||
|
return snapshot.fraction("product.threshold", product_category=category)
|
||||||
|
|
||||||
|
rows_out: list[GrossProfitRowOut] = []
|
||||||
|
missing_count = 0
|
||||||
|
# 类别聚合容器:{类别: [sale, cost]},成本缺失行不计入该类别聚合
|
||||||
|
agg: dict[str, list[Decimal]] = {}
|
||||||
|
for item in business_repository.list_items(session, business_id):
|
||||||
|
threshold = threshold_of(item.product_category)
|
||||||
|
row_kwargs: dict = {
|
||||||
|
"item_id": item.id,
|
||||||
|
"product_category": item.product_category,
|
||||||
|
"product_name": item.product_name,
|
||||||
|
"spec_model": item.spec_model,
|
||||||
|
"quantity": str(item.quantity),
|
||||||
|
"sale_amount": str(item.sale_amount),
|
||||||
|
"threshold": str(threshold),
|
||||||
|
}
|
||||||
|
if item.cost_amount is None:
|
||||||
|
missing_count += 1
|
||||||
|
row_kwargs.update(
|
||||||
|
cost_amount=None,
|
||||||
|
profit_amount=None,
|
||||||
|
profit_rate=None,
|
||||||
|
converted_profit=None,
|
||||||
|
missing_reason="成本缺失,无法计算毛利润",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
profit = item.sale_amount - item.cost_amount
|
||||||
|
rate = profit_rate(profit, item.sale_amount)
|
||||||
|
conv = converted_profit(item.sale_amount, rate, threshold)
|
||||||
|
row_kwargs.update(
|
||||||
|
cost_amount=str(item.cost_amount),
|
||||||
|
profit_amount=str(profit),
|
||||||
|
profit_rate=str(rate),
|
||||||
|
converted_profit=str(conv),
|
||||||
|
missing_reason=None,
|
||||||
|
)
|
||||||
|
bucket = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
|
||||||
|
bucket[0] += item.sale_amount
|
||||||
|
bucket[1] += item.cost_amount
|
||||||
|
rows_out.append(GrossProfitRowOut(**row_kwargs))
|
||||||
|
|
||||||
|
categories = [
|
||||||
|
GrossProfitCategoryOut(
|
||||||
|
product_category=category,
|
||||||
|
sale_amount=str(sale),
|
||||||
|
cost_amount=str(cost),
|
||||||
|
profit_amount=str(sale - cost),
|
||||||
|
profit_rate=str(profit_rate(sale - cost, sale)),
|
||||||
|
threshold=str(threshold_of(category)),
|
||||||
|
converted_profit=str(
|
||||||
|
converted_profit(sale, profit_rate(sale - cost, sale), threshold_of(category))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for category, [sale, cost] in agg.items()
|
||||||
|
]
|
||||||
|
return GrossProfitOut(
|
||||||
|
business_id=business_id,
|
||||||
|
as_of=as_of,
|
||||||
|
rows=rows_out,
|
||||||
|
categories=categories,
|
||||||
|
missing_count=missing_count,
|
||||||
|
)
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
"""绩效核算服务(M3 ④):回款 → 台账的固化流水线。
|
||||||
|
|
||||||
|
单笔核算(calc_receipt)与计算预览(param_service.preview)**共用同一
|
||||||
|
公式路径与取值防线**:resolve_snapshot 按回款到账日取参(裁决 #11)、按
|
||||||
|
岗位模板分派计提比例(‰ 存储口径 ÷1000 转纯比例)、行级折算(混合业务
|
||||||
|
按行汇总,对照表 #14)。两者的任何口径修改必须同步——预览给业务对数、
|
||||||
|
核算才是算钱的法定固化点。
|
||||||
|
|
||||||
|
策略点(业务答复 2026-10-19 已回填,后续调整仍集中在对应函数 + 按回款批量重算):
|
||||||
|
- ``_personal_rate``:#12 经理个人户单笔由参数 manager.personal.included
|
||||||
|
分流——0=单笔落 0(聚合口径在月结时由 manager_team_share 计算)、
|
||||||
|
1=按业务员递减公式计提;#20 在 ``_row_profit``(暂按不含税原样相减)
|
||||||
|
- 二次分摊(澄清 #1 答复「分配绩效」,算后分):``_load_allocations``
|
||||||
|
读生效方案,``_persist_ledger`` 按比例拆行(参与人份额 + 负责人余额,
|
||||||
|
行合计守恒);#2 余额归负责人、#3 二次分配即协助场景的核算载体
|
||||||
|
- 核算粒度 #25:业务级一次性计提——首个触发核算的回款计算全额应发,
|
||||||
|
后续回款仅置 settlement_status=1(repeated=True),不重复计提
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
from fractions import Fraction
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import update
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import MODULE_CALC
|
||||||
|
from app.core.exceptions import BadRequestError
|
||||||
|
from app.models import (
|
||||||
|
Business,
|
||||||
|
BusinessItem,
|
||||||
|
Customer,
|
||||||
|
PerformanceLedger,
|
||||||
|
Personnel,
|
||||||
|
Receipt,
|
||||||
|
Shipment,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from app.models.performance_ledger import CALC_TYPE_RECEIPT
|
||||||
|
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
|
||||||
|
from app.repositories import business_repository, performance_ledger_repository
|
||||||
|
from app.schemas.calc import CalcReceiptOut
|
||||||
|
from app.services import audit_service, param_service, personnel_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
from app.services.formula import (
|
||||||
|
apply_barter,
|
||||||
|
clamp_weight_r,
|
||||||
|
converted_profit,
|
||||||
|
payment_days,
|
||||||
|
payment_weight,
|
||||||
|
performance_amount,
|
||||||
|
profit_rate,
|
||||||
|
salesperson_rate,
|
||||||
|
start_payment_ok,
|
||||||
|
)
|
||||||
|
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
|
||||||
|
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _RateDecision:
|
||||||
|
"""单笔计提比例的分派结果。"""
|
||||||
|
|
||||||
|
rate: Fraction # 纯比例(str() 产出分数原文如 88/9000,直接落台账 rate_text)
|
||||||
|
rate_note: str # 人读说明(入 snapshot)
|
||||||
|
base_override: Decimal | None # 非 None 时绩效基数用它(信息员=销售额)
|
||||||
|
manager_pending: bool # True=经理聚合口径,单笔落 0(#12 策略点)
|
||||||
|
|
||||||
|
|
||||||
|
def calc_receipt(
|
||||||
|
session: Session,
|
||||||
|
receipt_id: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
recalc_reason: str | None = None,
|
||||||
|
) -> CalcReceiptOut:
|
||||||
|
"""单笔回款核算 / 重算(recalc_reason 非 None 时为重算模式)。
|
||||||
|
|
||||||
|
防重前两层在 ``_load_and_guard``(FOR UPDATE 锁行 + settlement_status
|
||||||
|
与月结锁定校验),第三层是落台账 uk(receipt_id, calc_version)。全程
|
||||||
|
一个 audit 事务,出参在 commit 前固化。
|
||||||
|
|
||||||
|
首算模式:回款必须未核算;业务级一次性计提(#25)——已有生效台账行时
|
||||||
|
仅登记进度(repeated=True,``_prepare_mode`` 内提前返回)。
|
||||||
|
重算模式:回款必须已核算过;作废旧版本行(is_active=0),按当前参数
|
||||||
|
落 calc_version+1 新行,calc_reason 留痕。
|
||||||
|
|
||||||
|
本函数只做编排:锁行校验 → 模式预处理 → 输入装载 → 逐行折算 →
|
||||||
|
计提组成 → 落库,各段独立私有函数(口径修改只动对应段)。
|
||||||
|
"""
|
||||||
|
is_recalc = recalc_reason is not None
|
||||||
|
action = "重算" if is_recalc else "核算"
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_CALC,
|
||||||
|
action=action,
|
||||||
|
summary=f"{action}回款 #{receipt_id}",
|
||||||
|
request_data={
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
**({"reason": recalc_reason} if is_recalc else {}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
receipt, business = _load_and_guard(session, receipt_id, is_recalc=is_recalc)
|
||||||
|
repeated_out = _prepare_mode(session, receipt, business, is_recalc=is_recalc)
|
||||||
|
if repeated_out is not None:
|
||||||
|
return repeated_out
|
||||||
|
|
||||||
|
shipment = _load_shipment(session, receipt)
|
||||||
|
person = _load_person(session, business)
|
||||||
|
snapshot = resolve_snapshot(session, receipt.receipt_date)
|
||||||
|
items = _load_items(session, business)
|
||||||
|
allocations = _load_allocations(session, business, person.id)
|
||||||
|
|
||||||
|
conversion = _convert_items(snapshot, person, items)
|
||||||
|
amounts = _compose_amounts(
|
||||||
|
session, snapshot, person, business, receipt, shipment, conversion
|
||||||
|
)
|
||||||
|
rows = _persist_ledger(
|
||||||
|
session,
|
||||||
|
receipt=receipt,
|
||||||
|
business=business,
|
||||||
|
person=person,
|
||||||
|
shipment=shipment,
|
||||||
|
items=items,
|
||||||
|
allocations=allocations,
|
||||||
|
conversion=conversion,
|
||||||
|
amounts=amounts,
|
||||||
|
recalc_reason=recalc_reason,
|
||||||
|
is_recalc=is_recalc,
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
out = CalcReceiptOut(ledger=_to_out(rows.owner_row), repeated=False)
|
||||||
|
session.commit()
|
||||||
|
logger.info(
|
||||||
|
"核算完成 | receipt=%s | ledger=%s | payable=%s | 分摊行=%s",
|
||||||
|
receipt_id,
|
||||||
|
rows.owner_row.id,
|
||||||
|
amounts.payable,
|
||||||
|
len(rows.share_rows),
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _load_and_guard(
|
||||||
|
session: Session, receipt_id: int, *, is_recalc: bool
|
||||||
|
) -> tuple[Receipt, Business]:
|
||||||
|
"""锁行 + 模式校验 + 月结锁定拦截 + 业务归属校验(核算/重算共用)。"""
|
||||||
|
receipt = session.get(Receipt, receipt_id, with_for_update=True)
|
||||||
|
if receipt is None:
|
||||||
|
raise BadRequestError("回款不存在")
|
||||||
|
if is_recalc:
|
||||||
|
if receipt.settlement_status == SETTLEMENT_PENDING:
|
||||||
|
raise BadRequestError("该回款尚未核算过(无旧版本可重算,走首次核算)")
|
||||||
|
elif receipt.settlement_status == SETTLEMENT_DONE:
|
||||||
|
raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)")
|
||||||
|
|
||||||
|
# 月结锁定拦截:已审定月份的回款不得再核算/重算——金额已固化,
|
||||||
|
# 变动唯一通道是调整单(否则重算会绕开审批改写已锁定月份的应付)
|
||||||
|
from app.services import settlement_service
|
||||||
|
|
||||||
|
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||||||
|
|
||||||
|
business = session.get(Business, receipt.business_id, with_for_update=True)
|
||||||
|
if business is None:
|
||||||
|
raise BadRequestError("回款关联的业务不存在")
|
||||||
|
if business.owner_personnel_id is None:
|
||||||
|
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
|
||||||
|
return receipt, business
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_mode(
|
||||||
|
session: Session, receipt: Receipt, business: Business, *, is_recalc: bool
|
||||||
|
) -> CalcReceiptOut | None:
|
||||||
|
"""模式预处理:重算作废旧版本;首算判业务级一次性计提。
|
||||||
|
|
||||||
|
返回非 None = 本笔仅登记进度(repeated=True 的出参,已 commit),
|
||||||
|
调用方直接返回。
|
||||||
|
"""
|
||||||
|
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算。
|
||||||
|
# 无生效行 = 该回款是「进度登记」(业务级一次性计提 #25 的第二笔及
|
||||||
|
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
|
||||||
|
# 两行生效行导致双重计提 → 一律 400
|
||||||
|
if is_recalc:
|
||||||
|
# 分摊拆行后旧版本可能多行(负责人+参与人)——全部作废再落新版本
|
||||||
|
olds = performance_ledger_repository.list_active_by_receipt(session, receipt.id)
|
||||||
|
if not olds:
|
||||||
|
raise BadRequestError("该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算")
|
||||||
|
for old in olds:
|
||||||
|
performance_ledger_repository.mark_inactive(session, old)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
|
||||||
|
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
|
||||||
|
if not performance_ledger_repository.has_active_for_business(
|
||||||
|
session, business.id, calc_type=CALC_TYPE_RECEIPT
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
_mark_settled(session, receipt)
|
||||||
|
row = performance_ledger_repository.get_active_by_receipt_of_business(session, business.id)
|
||||||
|
session.flush()
|
||||||
|
out = CalcReceiptOut(ledger=_to_out(row), repeated=True)
|
||||||
|
session.commit()
|
||||||
|
logger.info("回款登记进度(业务已核算)| receipt=%s | business=%s", receipt.id, business.id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _load_shipment(session: Session, receipt: Receipt) -> Shipment:
|
||||||
|
"""核销基准:发货批次存在且带发货日(回款天数公式的输入)。"""
|
||||||
|
if receipt.shipment_id is None:
|
||||||
|
raise BadRequestError("回款未指定发货批次,无法确定发货日期(先指定批次再核算)")
|
||||||
|
shipment = session.get(Shipment, receipt.shipment_id)
|
||||||
|
if shipment is None or shipment.ship_date is None:
|
||||||
|
raise BadRequestError("发货批次缺少发货日期,无法核算")
|
||||||
|
return shipment
|
||||||
|
|
||||||
|
|
||||||
|
def _load_person(session: Session, business: Business):
|
||||||
|
"""核算归属:在职 + 绑定岗位角色(计提公式锚点)。"""
|
||||||
|
person = personnel_service.ensure_active(session, business.owner_personnel_id, label="核算归属")
|
||||||
|
if person.business_role_id is None:
|
||||||
|
raise BadRequestError("归属人员未绑定岗位角色,无法确定计提公式")
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
def _load_items(session: Session, business: Business) -> list[BusinessItem]:
|
||||||
|
"""业务产品明细行(逐行折算的输入;空明细无法核算)。"""
|
||||||
|
items = business_repository.list_items(session, business.id)
|
||||||
|
if not items:
|
||||||
|
raise BadRequestError("业务无产品明细行,无法核算")
|
||||||
|
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)
|
||||||
|
class _Conversion:
|
||||||
|
"""逐行折算结果(行级明细入台账快照,汇总值驱动计提与起付判定)。"""
|
||||||
|
|
||||||
|
total_revenue: Decimal
|
||||||
|
total_profit: Decimal
|
||||||
|
converted_total: Decimal
|
||||||
|
missing_cost: bool
|
||||||
|
line_details: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_items(snapshot, person, items: list[BusinessItem]) -> _Conversion:
|
||||||
|
"""逐行折算(行级 product_category 阈值;成本缺失行毛利按 0 计并标记)。"""
|
||||||
|
total_revenue = Decimal("0")
|
||||||
|
total_profit = Decimal("0")
|
||||||
|
converted_total = Decimal("0")
|
||||||
|
missing_cost = False
|
||||||
|
line_details: list[dict[str, Any]] = []
|
||||||
|
for item in items:
|
||||||
|
row_revenue = item.sale_amount
|
||||||
|
if item.cost_amount is None:
|
||||||
|
missing_cost = True
|
||||||
|
row_profit = Decimal("0")
|
||||||
|
else:
|
||||||
|
row_profit = _row_profit(row_revenue, item.cost_amount)
|
||||||
|
total_revenue += row_revenue
|
||||||
|
total_profit += row_profit
|
||||||
|
threshold = parse_fraction(
|
||||||
|
snapshot.get(
|
||||||
|
"product.threshold",
|
||||||
|
role_code=role_code_of(person),
|
||||||
|
product_category=item.product_category,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row_converted = converted_profit(
|
||||||
|
row_revenue, profit_rate(row_profit, row_revenue), threshold
|
||||||
|
)
|
||||||
|
converted_total += row_converted
|
||||||
|
line_details.append(
|
||||||
|
{
|
||||||
|
"item_id": item.id,
|
||||||
|
"product_category": item.product_category,
|
||||||
|
"revenue": str(row_revenue),
|
||||||
|
"profit": str(row_profit),
|
||||||
|
# 行级折算同样固化为两位(to_decimal 对整数值无小数位,
|
||||||
|
# 快照/台账口径需与列一致)
|
||||||
|
"converted": str(_q2(row_converted)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return _Conversion(total_revenue, total_profit, converted_total, missing_cost, line_details)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _Amounts:
|
||||||
|
"""计提组成结果(比例分派 → 绩效 → 抵顶 → 支付权重 → 应付)。"""
|
||||||
|
|
||||||
|
days: int
|
||||||
|
decision: _RateDecision
|
||||||
|
converted: Decimal
|
||||||
|
performance: Decimal
|
||||||
|
barter_applied: bool
|
||||||
|
weight_r: int
|
||||||
|
weight: Fraction
|
||||||
|
payable: Decimal
|
||||||
|
start_ok: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_amounts(
|
||||||
|
session: Session,
|
||||||
|
snapshot,
|
||||||
|
person,
|
||||||
|
business: Business,
|
||||||
|
receipt: Receipt,
|
||||||
|
shipment: Shipment,
|
||||||
|
conversion: _Conversion,
|
||||||
|
) -> _Amounts:
|
||||||
|
"""按比例分派与支付权重组成应付(与 param_service.preview 同公式路径)。"""
|
||||||
|
# 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入)
|
||||||
|
days = payment_days(shipment.ship_date, receipt.receipt_date)
|
||||||
|
decision = _personal_rate(session, snapshot, person, business, days)
|
||||||
|
|
||||||
|
if decision.base_override is not None:
|
||||||
|
converted = decision.base_override # 信息员:基数=本人销售额
|
||||||
|
else:
|
||||||
|
converted = conversion.converted_total
|
||||||
|
|
||||||
|
performance = (
|
||||||
|
Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate)
|
||||||
|
)
|
||||||
|
barter_applied = receipt.is_barter == 1
|
||||||
|
base_amount = performance
|
||||||
|
if barter_applied:
|
||||||
|
discount = parse_fraction(snapshot.get("barter.discount", role_code=role_code_of(person)))
|
||||||
|
base_amount = apply_barter(performance, discount)
|
||||||
|
|
||||||
|
received_total = business.total_received_amount or Decimal("0")
|
||||||
|
start_ratio = parse_fraction(
|
||||||
|
snapshot.get("payment.start.ratio", role_code=role_code_of(person))
|
||||||
|
)
|
||||||
|
start_ok = start_payment_ok(received_total, conversion.total_revenue, start_ratio)
|
||||||
|
weight_r = clamp_weight_r(received_total, conversion.total_revenue)
|
||||||
|
weight = payment_weight(weight_r)
|
||||||
|
payable = param_service.to_decimal(Fraction(base_amount) * weight)
|
||||||
|
return _Amounts(
|
||||||
|
days=days,
|
||||||
|
decision=decision,
|
||||||
|
converted=converted,
|
||||||
|
performance=performance,
|
||||||
|
barter_applied=barter_applied,
|
||||||
|
weight_r=weight_r,
|
||||||
|
weight=weight,
|
||||||
|
payable=payable,
|
||||||
|
start_ok=start_ok,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _LedgerRows:
|
||||||
|
"""拆行落库结果:负责人行恒存在(出参与 CAS 锚点),参与人份额行可空。"""
|
||||||
|
|
||||||
|
owner_row: PerformanceLedger
|
||||||
|
share_rows: list[PerformanceLedger]
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_ledger(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
receipt: Receipt,
|
||||||
|
business: Business,
|
||||||
|
person,
|
||||||
|
shipment: Shipment,
|
||||||
|
items: list[BusinessItem],
|
||||||
|
allocations: list,
|
||||||
|
conversion: _Conversion,
|
||||||
|
amounts: _Amounts,
|
||||||
|
recalc_reason: str | None,
|
||||||
|
is_recalc: bool,
|
||||||
|
) -> _LedgerRows:
|
||||||
|
"""落台账行(分摊拆行;版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。
|
||||||
|
|
||||||
|
分摊(澄清 #1 答复 2026-10-19「分配绩效」,算后分):业务级全额(含
|
||||||
|
递减比例、抵顶、支付权重全环节)算完后按方案比例拆给参与人,余额归
|
||||||
|
合同负责人。行合计与业务级全额**守恒**(负责人行取 q2 全额减参与人
|
||||||
|
q2 份额的差额,防分漂);每行快照带完整方案快照,行级可独立回溯。
|
||||||
|
参与人离职照分(历史方案事实,方案调整是业务侧责任——入职校验只在
|
||||||
|
保存侧,核算侧只要求人员存在,FK RESTRICT 已保证)。
|
||||||
|
"""
|
||||||
|
receipt_date = receipt.receipt_date
|
||||||
|
base_snapshot: dict[str, Any] = {
|
||||||
|
"params_as_of": str(receipt_date),
|
||||||
|
"line_details": conversion.line_details,
|
||||||
|
"start_payment_ok": amounts.start_ok,
|
||||||
|
"rate_note": amounts.decision.rate_note,
|
||||||
|
"revenue_total": str(conversion.total_revenue),
|
||||||
|
"profit_total": str(conversion.total_profit),
|
||||||
|
}
|
||||||
|
if amounts.decision.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(
|
||||||
|
session,
|
||||||
|
PerformanceLedger(
|
||||||
|
receipt_id=receipt.id,
|
||||||
|
calc_version=version,
|
||||||
|
calc_type=CALC_TYPE_RECEIPT,
|
||||||
|
business_id=business.id,
|
||||||
|
business_no=business.business_no,
|
||||||
|
personnel_id=target.id,
|
||||||
|
personnel_name=target.personnel_name,
|
||||||
|
role_code=role_code_of(target),
|
||||||
|
customer_type=customer_type,
|
||||||
|
product_category=category,
|
||||||
|
ship_date=shipment.ship_date,
|
||||||
|
receipt_date=receipt_date,
|
||||||
|
payment_days=amounts.days,
|
||||||
|
rate_text=str(amounts.decision.rate),
|
||||||
|
converted_profit=conv,
|
||||||
|
performance_amount=perf,
|
||||||
|
barter_applied=1 if amounts.barter_applied else 0,
|
||||||
|
weight_r=amounts.weight_r,
|
||||||
|
payment_weight=param_service.to_decimal(amounts.weight),
|
||||||
|
payable_amount=pay,
|
||||||
|
settlement_month=receipt_date.strftime("%Y-%m"),
|
||||||
|
is_probation=1 if target.is_probation == 1 else 0,
|
||||||
|
missing_cost=1 if conversion.missing_cost else 0,
|
||||||
|
snapshot_json={
|
||||||
|
**base_snapshot,
|
||||||
|
"alloc_role": tag,
|
||||||
|
"alloc_ratio": owner_ratio_text if tag == "owner" else str(ratio),
|
||||||
|
},
|
||||||
|
calc_reason=recalc_reason,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if tag == "owner":
|
||||||
|
owner_row = row
|
||||||
|
else:
|
||||||
|
share_rows.append(row)
|
||||||
|
|
||||||
|
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
|
||||||
|
# 会 rowcount=0 误报并发撞车)
|
||||||
|
if not is_recalc:
|
||||||
|
_mark_settled(session, receipt)
|
||||||
|
return _LedgerRows(owner_row=owner_row, share_rows=share_rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _q2(value: Decimal | None) -> Decimal | None:
|
||||||
|
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。
|
||||||
|
|
||||||
|
显式传 rounding——quantize 缺省是上下文 ROUND_HALF_EVEN(银行家舍入),
|
||||||
|
x.xx5 会舍成偶数值,与业务四舍五入口径差 1 分。
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
|
def _personal_rate(
|
||||||
|
session: Session,
|
||||||
|
snapshot,
|
||||||
|
person,
|
||||||
|
business: Business,
|
||||||
|
days: int,
|
||||||
|
) -> _RateDecision:
|
||||||
|
"""按岗位模板分派单笔计提比例(与 param_service._preview_rate 同构)。
|
||||||
|
|
||||||
|
#12 策略点:经理个人户(owner 岗位绑 TPL_MANAGER_SHARE)单笔不计提
|
||||||
|
—— 12.5%+5% 是月度团队聚合口径,单笔落 rate=0/绩效 0 的台账行保事实。
|
||||||
|
"""
|
||||||
|
role = _role_of(session, person)
|
||||||
|
customer_type = _customer_type(session, business)
|
||||||
|
|
||||||
|
if customer_type == "enterprise":
|
||||||
|
# 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线)
|
||||||
|
rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000
|
||||||
|
return _RateDecision(rate, "企业户固定计提比例", None, False)
|
||||||
|
|
||||||
|
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
|
||||||
|
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(
|
||||||
|
Decimal("0"),
|
||||||
|
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
|
||||||
|
None,
|
||||||
|
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"经理个人户(基数开关=1):按业务员递减 {start}‰ 起,{days} 天"
|
||||||
|
return _RateDecision(rate, note, None, False)
|
||||||
|
if template == "TPL_INFO_SALES_FIXED":
|
||||||
|
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
|
||||||
|
rate = parse_fraction(snapshot.get("info.rate")) / 1000
|
||||||
|
return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False)
|
||||||
|
if template == "TPL_SALESPERSON_DECAY":
|
||||||
|
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
||||||
|
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
||||||
|
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
|
||||||
|
# int(参数原文) 对合法入库的 '360.0' 会抛 ValueError 使整月核算 500;
|
||||||
|
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
|
||||||
|
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||||
|
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||||
|
note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)"
|
||||||
|
return _RateDecision(rate, note, None, False)
|
||||||
|
raise BadRequestError(f"公式模板 {template} 暂不支持核算")
|
||||||
|
|
||||||
|
|
||||||
|
def _role_of(session: Session, person):
|
||||||
|
from app.repositories import business_role_repository
|
||||||
|
|
||||||
|
if person.business_role_id is None:
|
||||||
|
return None
|
||||||
|
return business_role_repository.get_by_id(session, person.business_role_id)
|
||||||
|
|
||||||
|
|
||||||
|
def role_code_of(person) -> str:
|
||||||
|
"""人员岗位编码(台账冗余固化用;无岗位为空串——前置校验已拦)。"""
|
||||||
|
from app.repositories import business_role_repository
|
||||||
|
|
||||||
|
if person.business_role_id is None:
|
||||||
|
return ""
|
||||||
|
session = Session.object_session(person)
|
||||||
|
role = business_role_repository.get_by_id(session, person.business_role_id)
|
||||||
|
return role.role_code if role else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _customer_type(session: Session, business: Business) -> str:
|
||||||
|
customer = session.get(Customer, business.customer_id)
|
||||||
|
return customer.customer_type if customer else "personal"
|
||||||
|
|
||||||
|
|
||||||
|
def _category_label(items: list[BusinessItem]) -> str:
|
||||||
|
"""单类别直出;混合业务记 mixed(行级明细在 snapshot_json)。"""
|
||||||
|
categories = {item.product_category for item in items}
|
||||||
|
return categories.pop() if len(categories) == 1 else "mixed"
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_settled(session: Session, receipt: Receipt) -> None:
|
||||||
|
"""CAS 置已核算:并发下另一事务先行置 1 时 rowcount=0 → 400。"""
|
||||||
|
result = session.execute(
|
||||||
|
update(Receipt)
|
||||||
|
.where(Receipt.id == receipt.id, Receipt.settlement_status == SETTLEMENT_PENDING)
|
||||||
|
.values(settlement_status=SETTLEMENT_DONE)
|
||||||
|
)
|
||||||
|
if result.rowcount == 0:
|
||||||
|
raise BadRequestError("该回款已被并发核算,请刷新后查看台账")
|
||||||
|
|
||||||
|
|
||||||
|
def _row_profit(revenue: Decimal, cost: Decimal) -> Decimal:
|
||||||
|
"""行毛利 = 行销售额 − 行成本。
|
||||||
|
|
||||||
|
#20 策略点(待业务答复,2026-10-16 暂定口径):金额暂按**不含税**原样
|
||||||
|
相减;答复若为含税口径,只改本函数(含税换算/取不含税金额列)+ 按回款
|
||||||
|
批量重算回填。
|
||||||
|
"""
|
||||||
|
return revenue - cost
|
||||||
|
|
||||||
|
|
||||||
|
def recalc_receipt(
|
||||||
|
session: Session,
|
||||||
|
receipt_id: int,
|
||||||
|
reason: str,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> CalcReceiptOut:
|
||||||
|
"""重算(授权动作,管理员/财务):作废旧版本行、按当前参数重算新版本。
|
||||||
|
|
||||||
|
薄委托 calc_receipt(recalc_reason=...)——折算体只有一份;所属月已审定
|
||||||
|
的拦截在 calc_receipt 内(核算与重算同口径)。
|
||||||
|
"""
|
||||||
|
if not reason or not reason.strip():
|
||||||
|
raise BadRequestError("重算必须填写原因")
|
||||||
|
return calc_receipt(
|
||||||
|
session, receipt_id, operator=operator, meta=meta, recalc_reason=reason.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def batch_calc(
|
||||||
|
session: Session,
|
||||||
|
receipt_ids: list[int],
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""批量核算:逐笔独立事务(各笔自带 audit),返回逐笔成败清单。
|
||||||
|
|
||||||
|
**不开外层 audit**——外层包 audit 会违反嵌套禁令,且单笔失败不应
|
||||||
|
回滚其它笔的核算结果。
|
||||||
|
"""
|
||||||
|
results: list[dict] = []
|
||||||
|
for rid in receipt_ids:
|
||||||
|
try:
|
||||||
|
out = calc_receipt(session, rid, operator=operator, meta=meta)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"receipt_id": rid,
|
||||||
|
"ok": True,
|
||||||
|
"repeated": out.repeated,
|
||||||
|
"ledger_id": out.ledger.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
results.append(
|
||||||
|
{"receipt_id": rid, "ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
||||||
|
)
|
||||||
|
ok_count = sum(1 for r in results if r["ok"])
|
||||||
|
logger.info("批量核算 | 总数=%s 成功=%s", len(results), ok_count)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def list_ledger_page(session: Session, query):
|
||||||
|
"""分页查询台账(api 层转发)。"""
|
||||||
|
rows = performance_ledger_repository.list_ledger(session, query)
|
||||||
|
total = performance_ledger_repository.count_ledger(session, query)
|
||||||
|
from app.core.response import PageResult
|
||||||
|
from app.schemas.calc import LedgerOut
|
||||||
|
|
||||||
|
return PageResult[LedgerOut](
|
||||||
|
records=[LedgerOut.model_validate(r) for r in rows],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_ledger(session: Session, ledger_id: int):
|
||||||
|
"""台账详情(api 层转发)。"""
|
||||||
|
row = performance_ledger_repository.get_by_id(session, ledger_id)
|
||||||
|
if row is None:
|
||||||
|
from app.core.exceptions import NotFoundError
|
||||||
|
|
||||||
|
raise NotFoundError("台账记录不存在")
|
||||||
|
from app.schemas.calc import LedgerOut
|
||||||
|
|
||||||
|
return LedgerOut.model_validate(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(row: PerformanceLedger):
|
||||||
|
from app.schemas.calc import LedgerOut
|
||||||
|
|
||||||
|
return LedgerOut.model_validate(row)
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
"""客户管理业务编排(需求 2.1.3)。
|
||||||
|
|
||||||
|
**事务边界在本层**:repository 只 flush,commit/rollback 只写在这里。
|
||||||
|
|
||||||
|
- 「公司名称 + 负责人」组合唯一(uk_customers_company_owner):先查后写不是
|
||||||
|
原子操作,并发提交撞唯一键时捕 IntegrityError 判 1062 翻译为 400
|
||||||
|
(同 M1 的并发兜底模式)
|
||||||
|
- **归属变更的裁决语义是「只影响变更后核算,不回溯历史」(对照表 #19)**:
|
||||||
|
实现为同一事务内「更新 customers.owner_personnel_id + 插入一条
|
||||||
|
ownership_change_logs」,一次 commit —— 两步拆开提交时,中途失败会出现
|
||||||
|
「主表已换人、留痕缺失」的静默不一致,事后追溯无据可查
|
||||||
|
- 归属调整只有 ``change_ownership`` 一条路径;编辑端点不触归属,
|
||||||
|
防止出现「改归属不留痕」的旁路
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import Row
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
ACTION_DELETE,
|
||||||
|
ACTION_UPDATE,
|
||||||
|
CUSTOMER_TYPES,
|
||||||
|
MODULE_CUSTOMER,
|
||||||
|
)
|
||||||
|
from app.core.db_errors import is_duplicate_entry
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.core.response import PageQuery, PageResult
|
||||||
|
from app.models import Customer, OwnershipChangeLog, Personnel, User
|
||||||
|
from app.models.customer import OWNER_TYPE_CUSTOMER
|
||||||
|
from app.repositories import customer_rating_repository, customer_repository, personnel_repository
|
||||||
|
from app.schemas.customer import (
|
||||||
|
CustomerCreateIn,
|
||||||
|
CustomerOut,
|
||||||
|
CustomerOwnershipIn,
|
||||||
|
CustomerQuery,
|
||||||
|
CustomerUpdateIn,
|
||||||
|
OwnershipChangeLogOut,
|
||||||
|
)
|
||||||
|
from app.services import audit_service, personnel_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(
|
||||||
|
customer: Customer,
|
||||||
|
owner_name: str | None,
|
||||||
|
rating_total: object | None = None,
|
||||||
|
rating_grade: str | None = None,
|
||||||
|
) -> CustomerOut:
|
||||||
|
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。
|
||||||
|
|
||||||
|
rating_total 由 LEFT JOIN 带出(Decimal 或 None),按分值红线转字符串
|
||||||
|
出参;新建客户/未评级客户不传,保持 null。
|
||||||
|
"""
|
||||||
|
return CustomerOut(
|
||||||
|
id=customer.id,
|
||||||
|
company_name=customer.company_name,
|
||||||
|
owner_name=customer.owner_name,
|
||||||
|
contact_person=customer.contact_person,
|
||||||
|
contact_phone=customer.contact_phone,
|
||||||
|
address=customer.address,
|
||||||
|
industry=customer.industry,
|
||||||
|
customer_type=customer.customer_type,
|
||||||
|
owner_personnel_id=customer.owner_personnel_id,
|
||||||
|
owner_personnel_name=owner_name,
|
||||||
|
remark=customer.remark,
|
||||||
|
rating_total=str(rating_total) if rating_total is not None else None,
|
||||||
|
rating_grade_name=rating_grade,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_log_out(row: Row) -> OwnershipChangeLogOut:
|
||||||
|
"""留痕行 (留痕, 旧归属人名, 新归属人名) 转响应模型。"""
|
||||||
|
log, old_name, new_name = row
|
||||||
|
return OwnershipChangeLogOut(
|
||||||
|
id=log.id,
|
||||||
|
customer_id=log.customer_id,
|
||||||
|
old_owner_personnel_id=log.old_owner_personnel_id,
|
||||||
|
new_owner_personnel_id=log.new_owner_personnel_id,
|
||||||
|
old_owner_name=old_name,
|
||||||
|
new_owner_name=new_name,
|
||||||
|
change_reason=log.change_reason,
|
||||||
|
operator_id=log.operator_id,
|
||||||
|
operator_name=log.operator_name,
|
||||||
|
create_time=log.create_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_customer_type(customer_type: str) -> None:
|
||||||
|
"""客户分类必须是注册表内的三个值之一(前端为受控下拉,后端仍须校验)。
|
||||||
|
|
||||||
|
customer_type 是 M3 计提规则匹配的锚点,脏值会让该客户在核算时匹配不到
|
||||||
|
任何计提规则而静默漏算。
|
||||||
|
"""
|
||||||
|
if customer_type not in CUSTOMER_TYPES:
|
||||||
|
raise BadRequestError("客户分类不合法")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_owner_personnel(session: Session, personnel_id: int) -> Personnel:
|
||||||
|
"""归属人员必须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
|
||||||
|
return personnel_service.ensure_active(session, personnel_id, label="归属")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||||
|
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(1062)。
|
||||||
|
|
||||||
|
customers 上还有归属人员外键(RESTRICT),并发窗口内删除人员等场景
|
||||||
|
会抛 1452 —— 不能一并翻译成「已存在」,否则用户会被错误提示带偏;
|
||||||
|
非 1062 原样上抛,由全局 IntegrityError handler 收敛 400(通用文案)。
|
||||||
|
"""
|
||||||
|
return is_duplicate_entry(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
|
||||||
|
"""分页查询客户(附归属人员姓名与评级快照;支持评级列排序)。"""
|
||||||
|
rows = customer_repository.list_customers(
|
||||||
|
session,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
keyword=query.keyword,
|
||||||
|
customer_type=query.customer_type,
|
||||||
|
owner_personnel_id=query.owner_personnel_id,
|
||||||
|
order_field=query.order_field,
|
||||||
|
order_type=query.order_type,
|
||||||
|
)
|
||||||
|
total = customer_repository.count_customers(
|
||||||
|
session,
|
||||||
|
keyword=query.keyword,
|
||||||
|
customer_type=query.customer_type,
|
||||||
|
owner_personnel_id=query.owner_personnel_id,
|
||||||
|
)
|
||||||
|
records = [
|
||||||
|
_to_out(customer, owner_name, rating_total, rating_grade)
|
||||||
|
for customer, owner_name, rating_total, rating_grade in rows
|
||||||
|
]
|
||||||
|
return PageResult[CustomerOut](
|
||||||
|
records=records,
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_customer(session: Session, customer_id: int) -> CustomerOut:
|
||||||
|
"""客户详情。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
"""
|
||||||
|
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
customer, owner_name, rating_total, rating_grade = row
|
||||||
|
return _to_out(customer, owner_name, rating_total, rating_grade)
|
||||||
|
|
||||||
|
|
||||||
|
def create_customer(
|
||||||
|
session: Session,
|
||||||
|
payload: CustomerCreateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> CustomerOut:
|
||||||
|
"""新增客户。
|
||||||
|
|
||||||
|
:raises BadRequestError: 客户分类不合法 / 归属人员不存在或已离职 /
|
||||||
|
「公司名称+负责人」已存在(含并发兜底)
|
||||||
|
"""
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_CUSTOMER,
|
||||||
|
action=ACTION_CREATE,
|
||||||
|
summary=f"新增客户 {payload.company_name}({payload.owner_name})",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 值域与归属校验在审计上下文内:失败(400)也要留痕 ——
|
||||||
|
# 与 personnel_service._validate_refs 移入上下文的口径一致
|
||||||
|
_ensure_customer_type(payload.customer_type)
|
||||||
|
owner_name: str | None = None
|
||||||
|
if payload.owner_personnel_id is not None:
|
||||||
|
owner_name = _ensure_owner_personnel(session, payload.owner_personnel_id).personnel_name
|
||||||
|
if (
|
||||||
|
customer_repository.get_by_company_owner(
|
||||||
|
session, payload.company_name, payload.owner_name
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
raise BadRequestError("该公司名称与负责人的客户已存在")
|
||||||
|
|
||||||
|
customer = Customer(
|
||||||
|
company_name=payload.company_name,
|
||||||
|
owner_name=payload.owner_name,
|
||||||
|
contact_person=payload.contact_person,
|
||||||
|
contact_phone=payload.contact_phone,
|
||||||
|
address=payload.address,
|
||||||
|
industry=payload.industry,
|
||||||
|
customer_type=payload.customer_type,
|
||||||
|
owner_personnel_id=payload.owner_personnel_id,
|
||||||
|
remark=payload.remark,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
customer_repository.create(session, customer)
|
||||||
|
# 响应在 commit 之前构造(同 user_service 顶部说明)
|
||||||
|
out = _to_out(customer, owner_name)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 先查后写不是原子操作:并发提交同一「公司名称+负责人」会撞
|
||||||
|
# uk_customers_company_owner,回滚并翻译为可读的 400,而非 500
|
||||||
|
session.rollback()
|
||||||
|
if _is_duplicate_entry(exc):
|
||||||
|
raise BadRequestError("该公司名称与负责人的客户已存在") from exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("新增客户 | customer_id=%s | company=%s", customer.id, payload.company_name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def update_customer(
|
||||||
|
session: Session,
|
||||||
|
customer_id: int,
|
||||||
|
payload: CustomerUpdateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> CustomerOut:
|
||||||
|
"""编辑客户档案(全量替换)。
|
||||||
|
|
||||||
|
**不触归属**:owner_personnel_id 保持原值,调整统一走
|
||||||
|
``PUT /{id}/ownership``(唯一写留痕的路径),见 ``CustomerUpdateIn`` 说明。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
:raises BadRequestError: 客户分类不合法 / 「公司名称+负责人」已存在(含并发兜底)
|
||||||
|
"""
|
||||||
|
# 只读预取在审计上下文之前:目标不存在(NotFound 失败)时也能留下可读记录,
|
||||||
|
# 摘要与回包都要用客户行;归属姓名本端点不变更,一并带出
|
||||||
|
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||||
|
customer = row[0] if row else None
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_CUSTOMER,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"修改客户 {customer.company_name if customer else f'#{customer_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 值域校验在审计上下文内:分类非法的 400 也要留痕(同 create 口径)
|
||||||
|
_ensure_customer_type(payload.customer_type)
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
customer, owner_name, rating_total, rating_grade = row
|
||||||
|
|
||||||
|
duplicate = customer_repository.get_by_company_owner(
|
||||||
|
session, payload.company_name, payload.owner_name
|
||||||
|
)
|
||||||
|
if duplicate is not None and duplicate.id != customer_id:
|
||||||
|
raise BadRequestError("该公司名称与负责人的客户已存在")
|
||||||
|
|
||||||
|
customer.company_name = payload.company_name
|
||||||
|
customer.owner_name = payload.owner_name
|
||||||
|
customer.contact_person = payload.contact_person
|
||||||
|
customer.contact_phone = payload.contact_phone
|
||||||
|
customer.address = payload.address
|
||||||
|
customer.industry = payload.industry
|
||||||
|
customer.customer_type = payload.customer_type
|
||||||
|
# owner_personnel_id 刻意不赋值 —— 归属调整只走 /ownership
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 响应在 commit 之前构造(同 user_service 顶部说明)
|
||||||
|
out = _to_out(customer, owner_name, rating_total, rating_grade)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写
|
||||||
|
# 的并发窗口会撞唯一键,兜底同 create_customer
|
||||||
|
session.rollback()
|
||||||
|
if _is_duplicate_entry(exc):
|
||||||
|
raise BadRequestError("该公司名称与负责人的客户已存在") from exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("修改客户 | customer_id=%s | company=%s", customer_id, payload.company_name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_customer(
|
||||||
|
session: Session,
|
||||||
|
customer_id: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""删除客户(物理删除):被业务引用 → 400 引导处理业务单据。
|
||||||
|
|
||||||
|
businesses.customer_id 为 RESTRICT 外键,引用检查必须先于删除——
|
||||||
|
否则并发窗口内的 FK 冲突会以 500 暴露(T4 时该检查占位、业务表就位
|
||||||
|
后由本处补齐,留痕表是无外键固化不参与拦截)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
:raises BadRequestError: 客户已被业务单据引用
|
||||||
|
"""
|
||||||
|
customer = customer_repository.get_by_id(session, customer_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_CUSTOMER,
|
||||||
|
action=ACTION_DELETE,
|
||||||
|
summary=f"删除客户 {customer.company_name if customer else f'#{customer_id}'}",
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if customer is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
if customer_repository.count_business_refs(session, customer_id):
|
||||||
|
raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据")
|
||||||
|
|
||||||
|
company_name = customer.company_name # 固化:对象随删除失效
|
||||||
|
try:
|
||||||
|
# 评级行 FK RESTRICT:先删评级行再删客户主行(同事务一次 commit;
|
||||||
|
# 评级是附属快照,不构成「有关联业务」的删除阻碍)
|
||||||
|
rating = customer_rating_repository.get_by_customer_id(session, customer_id)
|
||||||
|
if rating is not None:
|
||||||
|
customer_rating_repository.delete(session, rating)
|
||||||
|
customer_repository.delete(session, customer)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 先查后删的并发窗口:新业务挂上了该客户,FK RESTRICT 兜底为 400
|
||||||
|
session.rollback()
|
||||||
|
raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据") from exc
|
||||||
|
logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name)
|
||||||
|
|
||||||
|
|
||||||
|
def change_ownership(
|
||||||
|
session: Session,
|
||||||
|
customer_id: int,
|
||||||
|
payload: CustomerOwnershipIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> CustomerOut:
|
||||||
|
"""归属变更:同一事务内「改主表归属 + 插留痕」,一次 commit。
|
||||||
|
|
||||||
|
裁决口径为「只影响变更后核算,不回溯历史」(对照表 #19):历史核算结果
|
||||||
|
已由 M3 台账固化,留痕仅承担追溯职责,故无 effective_date 时点化设计。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
:raises BadRequestError: 归属人员不存在或已离职 / 归属未变化
|
||||||
|
"""
|
||||||
|
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
|
||||||
|
# 上下文内进行,失败同样要留下可追溯的记录
|
||||||
|
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||||
|
old_person = (
|
||||||
|
personnel_repository.get_by_id(session, row[0].owner_personnel_id)
|
||||||
|
if row is not None and row[0].owner_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
new_person = (
|
||||||
|
personnel_repository.get_by_id(session, payload.new_owner_personnel_id)
|
||||||
|
if payload.new_owner_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
old_label = old_person.personnel_name if old_person else "无"
|
||||||
|
new_label = new_person.personnel_name if new_person else "无"
|
||||||
|
customer_name = row[0].company_name if row else f"#{customer_id}"
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_CUSTOMER,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"调整客户归属 {customer_name}:{old_label} → {new_label}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
customer, _, rating_total, rating_grade = row
|
||||||
|
|
||||||
|
if payload.new_owner_personnel_id is not None:
|
||||||
|
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
|
||||||
|
old_owner_id = customer.owner_personnel_id
|
||||||
|
if old_owner_id == payload.new_owner_personnel_id:
|
||||||
|
raise BadRequestError("归属未变化")
|
||||||
|
|
||||||
|
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
|
||||||
|
customer.owner_personnel_id = payload.new_owner_personnel_id
|
||||||
|
customer_repository.add_ownership_log(
|
||||||
|
session,
|
||||||
|
OwnershipChangeLog(
|
||||||
|
owner_type=OWNER_TYPE_CUSTOMER,
|
||||||
|
customer_id=customer.id,
|
||||||
|
old_owner_personnel_id=old_owner_id,
|
||||||
|
new_owner_personnel_id=payload.new_owner_personnel_id,
|
||||||
|
change_reason=payload.reason,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
|
||||||
|
out = _to_out(
|
||||||
|
customer,
|
||||||
|
new_person.personnel_name if new_person else None,
|
||||||
|
rating_total,
|
||||||
|
rating_grade,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
logger.info(
|
||||||
|
"调整客户归属 | customer_id=%s | old=%s | new=%s",
|
||||||
|
customer_id,
|
||||||
|
old_owner_id,
|
||||||
|
payload.new_owner_personnel_id,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def list_ownership_logs(
|
||||||
|
session: Session,
|
||||||
|
customer_id: int,
|
||||||
|
query: PageQuery,
|
||||||
|
) -> PageResult[OwnershipChangeLogOut]:
|
||||||
|
"""某客户的归属变更留痕(分页,按变更时间倒序)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
"""
|
||||||
|
if customer_repository.get_by_id(session, customer_id) is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
rows = customer_repository.list_ownership_logs(
|
||||||
|
session,
|
||||||
|
customer_id=customer_id,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
)
|
||||||
|
total = customer_repository.count_ownership_logs(session, customer_id=customer_id)
|
||||||
|
return PageResult[OwnershipChangeLogOut](
|
||||||
|
records=[_to_log_out(row) for row in rows],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""工作台服务(M4 ⑧):四区块实时聚合编排。
|
||||||
|
|
||||||
|
设计决策(2026-10-19):
|
||||||
|
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
|
||||||
|
不复用报表的部门汇总(后者会加载整月台账,过重)
|
||||||
|
- **待办与预警实时派生**:不建待办/预警表、不引调度器;处置动作本身
|
||||||
|
已有操作日志可追溯。与 FR-035「预警产生/消除留痕」的差距记录在
|
||||||
|
计划开放项,待业务确认是否物化
|
||||||
|
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
|
||||||
|
``sync_available=false`` 显式降级,不返回伪造的 0 条
|
||||||
|
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.services import approval_service
|
||||||
|
|
||||||
|
# 工作台只展示摘要,完整列表跳转对应管理页
|
||||||
|
_TODO_LIMIT = 5
|
||||||
|
_WARNING_LIMIT = 20
|
||||||
|
_ACTIVITY_LIMIT = 20
|
||||||
|
_TREND_MONTHS = 6
|
||||||
|
|
||||||
|
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
|
||||||
|
"""四区块一次性出数(前端一次请求渲染整页)。
|
||||||
|
|
||||||
|
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
|
||||||
|
风险预警仍是全局经营视角,不按人裁剪。
|
||||||
|
"""
|
||||||
|
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),
|
||||||
|
"activities": _activities(session),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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": (
|
||||||
|
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月"
|
||||||
|
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 _activities(session: Session) -> list[dict]:
|
||||||
|
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。"""
|
||||||
|
logs = operation_log_repository.list_logs(
|
||||||
|
session,
|
||||||
|
offset=0,
|
||||||
|
limit=_ACTIVITY_LIMIT,
|
||||||
|
filters=LogFilters(),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": log.id,
|
||||||
|
"module": log.module,
|
||||||
|
"action": log.action,
|
||||||
|
"summary": log.summary,
|
||||||
|
"username": log.username,
|
||||||
|
"success": log.success,
|
||||||
|
"create_time": log.create_time,
|
||||||
|
}
|
||||||
|
for log in logs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
@@ -40,9 +40,20 @@ def salesperson_rate(
|
|||||||
start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。
|
start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。
|
||||||
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
|
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
|
||||||
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
|
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
|
||||||
|
|
||||||
|
首日取整(澄清 #14 业务答复 2026-10-19):第 1 天**不递减**、直接取
|
||||||
|
起始值(需规图示「第 1 天 10‰」覆盖公式的 9.9889‰)——第 2 天起按公式
|
||||||
|
递减,360 天=6‰ 锚点与 361 天=4‰ 分界均不受影响。
|
||||||
|
|
||||||
|
先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出
|
||||||
|
高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days
|
||||||
|
的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。
|
||||||
"""
|
"""
|
||||||
|
days = max(days, 0)
|
||||||
if days > boundary_days:
|
if days > boundary_days:
|
||||||
return floor / 1000
|
return floor / 1000
|
||||||
|
if days <= 1:
|
||||||
|
return start / 1000
|
||||||
return (start - days * slope) / 1000
|
return (start - days * slope) / 1000
|
||||||
|
|
||||||
|
|
||||||
@@ -90,3 +101,63 @@ def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bo
|
|||||||
if revenue == 0:
|
if revenue == 0:
|
||||||
return False
|
return False
|
||||||
return Fraction(received) / Fraction(revenue) >= ratio / 100
|
return Fraction(received) / Fraction(revenue) >= ratio / 100
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_weight_r(received: Decimal, revenue: Decimal) -> int:
|
||||||
|
"""支付权重 R:回款占销售额百分比,封顶封底 [0, 100](对照表 #22)。
|
||||||
|
|
||||||
|
received 非负时 int() 即向下取整;min/max 同时是红字冲销(负数回款)
|
||||||
|
接入时的方向防线。从计算预览的内联实现提取 —— ④ 核算与预览共用。
|
||||||
|
"""
|
||||||
|
if revenue <= 0:
|
||||||
|
return 0
|
||||||
|
return max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100)))
|
||||||
|
|
||||||
|
|
||||||
|
def monthly_salary(performance: Decimal, basic: Decimal | None, mode: str) -> Decimal | None:
|
||||||
|
"""月工资分岗口径(对照表 #8,salary.basic.mode 按角色维度取值)。
|
||||||
|
|
||||||
|
- INCLUDE:绩效 + 基本工资(业务员默认——绩效之外照发基本工资)
|
||||||
|
- DEDUCT:绩效 − 基本工资(经理默认——基本工资从绩效分成中扣除)
|
||||||
|
- EXCLUDE:仅绩效,基本工资不参与
|
||||||
|
basic 缺失时按 0 参与(INCLUDE/EXCLUDE 等价于纯绩效)。
|
||||||
|
"""
|
||||||
|
basic_value = basic if basic is not None else Decimal("0")
|
||||||
|
if mode == "INCLUDE":
|
||||||
|
return performance + basic_value
|
||||||
|
if mode == "DEDUCT":
|
||||||
|
return performance - basic_value
|
||||||
|
if mode == "EXCLUDE":
|
||||||
|
return performance
|
||||||
|
raise ValueError(f"未知的月工资口径:{mode}")
|
||||||
|
|
||||||
|
|
||||||
|
def probation_annual_note(year_total: Decimal, threshold: Decimal) -> str | None:
|
||||||
|
"""试用期年累计说明:超阈值部分年底一次性结算(对照表 #17)。
|
||||||
|
|
||||||
|
未超阈值返回 None(无需说明);阈值按元展示(Decimal 直出)。
|
||||||
|
"""
|
||||||
|
if year_total <= threshold:
|
||||||
|
return None
|
||||||
|
excess = (year_total - threshold).quantize(Decimal("0.01"), rounding="ROUND_HALF_UP")
|
||||||
|
return f"试用期绩效照算不发放;年累计超 {threshold} 元部分年底一次性结算(当前超 {excess} 元)"
|
||||||
|
|
||||||
|
|
||||||
|
def manager_team_share(
|
||||||
|
team_performance: Decimal,
|
||||||
|
company_performance: Decimal,
|
||||||
|
*,
|
||||||
|
team_pct: Fraction,
|
||||||
|
company_pct: Fraction,
|
||||||
|
) -> Decimal:
|
||||||
|
"""经理绩效 = 本团队业务员绩效 × 12.5% + 全公司业务员绩效 × 5%(裁决 #6/#15/#16)。
|
||||||
|
|
||||||
|
**#12 策略点(待业务答复,2026-10-16 暂定口径)**:基数**不含**经理本人
|
||||||
|
名下经理个人户业务的业绩——该部分按经理个人户规则另行处理(单笔落台账
|
||||||
|
rate=0/绩效 0,待答复后若需并入,只改调用方传入的基数集合)。
|
||||||
|
团队口径:manager_id=该经理且岗位非「营销经理」的业务员绩效行之和;
|
||||||
|
全公司口径:全部业务员(非经理岗)绩效行之和。
|
||||||
|
"""
|
||||||
|
return _to_decimal(Fraction(team_performance) * team_pct / 100) + _to_decimal(
|
||||||
|
Fraction(company_performance) * company_pct / 100
|
||||||
|
)
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""单据编号生成器(业务 YW / 发货 SH / 回款 HK 共用)。
|
||||||
|
|
||||||
|
规则:``{前缀}{YYYYMMDD}-{当日4位序号}``,序号按自然日重置。
|
||||||
|
并发安全:``SELECT MAX(no) ... FOR UPDATE`` 锁住当日序号区间 —— 后到的
|
||||||
|
事务会等待先到者提交后再读到新 MAX,序号单调不重;残余竞态(锁等待超时
|
||||||
|
等极端场景)由服务层捕唯一键冲突(1062)重试一次兜底。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
def today_prefix(prefix: str) -> str:
|
||||||
|
"""当日编号前缀(如 YW20261013-),LIKE 匹配与拼号共用同一来源防漂移。"""
|
||||||
|
return f"{prefix}{datetime.now().strftime('%Y%m%d')}-"
|
||||||
|
|
||||||
|
|
||||||
|
def next_no(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
model: type,
|
||||||
|
column_name: str,
|
||||||
|
prefix: str,
|
||||||
|
seq_width: int = 4,
|
||||||
|
) -> str:
|
||||||
|
"""生成下一个编号。
|
||||||
|
|
||||||
|
:param model: 编号所在的 ORM 模型(businesses/shipments/receipts)
|
||||||
|
:param column_name: 编号列名(business_no/shipment_no/receipt_batch_no)
|
||||||
|
:param prefix: 单据前缀(NO_PREFIX_* 常量)
|
||||||
|
:param seq_width: 序号位数(默认 4);超出宽度的序号按实际位数输出,
|
||||||
|
VARCHAR(32) 余量充足、唯一性不受影响
|
||||||
|
"""
|
||||||
|
like_prefix = today_prefix(prefix)
|
||||||
|
column = getattr(model, column_name)
|
||||||
|
# 排序按「长度优先、同级字典序」:同前缀下序号位数增多(9999→10000)后
|
||||||
|
# 纯字典序会反转("-10000" < "-9999",'1'<'9'),seq 将恒重算出同一号码、
|
||||||
|
# 每笔建单都撞唯一键 —— 长度大即数值大,同级同长时字典序与数值序一致
|
||||||
|
max_no = session.execute(
|
||||||
|
select(column)
|
||||||
|
.where(column.like(f"{like_prefix}%"))
|
||||||
|
.order_by(func.length(column).desc(), column.desc())
|
||||||
|
.limit(1)
|
||||||
|
.with_for_update()
|
||||||
|
).scalar()
|
||||||
|
seq = int(max_no.rsplit("-", 1)[1]) + 1 if max_no else 1
|
||||||
|
return f"{like_prefix}{seq:0{seq_width}d}"
|
||||||
@@ -243,18 +243,124 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
sort_order=15,
|
sort_order=15,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
code="probation.salary.basic",
|
code="approval.secondary_allocation.enabled",
|
||||||
name="试用期基本工资标准",
|
name="二次分配审批开关",
|
||||||
group="月工资口径",
|
group="审批开关",
|
||||||
value_type="DECIMAL",
|
value_type="BOOL",
|
||||||
unit="元",
|
unit=None,
|
||||||
dimension_type="NONE",
|
dimension_type="NONE",
|
||||||
options=None,
|
options=None,
|
||||||
default_value="3000",
|
default_value="1",
|
||||||
value_min="0",
|
value_min=None,
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
|
description=(
|
||||||
sort_order=16,
|
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
|
||||||
|
"1=提交后进入财务复核→领导审核审批流。"
|
||||||
|
"默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭"
|
||||||
|
),
|
||||||
|
sort_order=17,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="approval.monthly_settlement.enabled",
|
||||||
|
name="月度结算审批开关",
|
||||||
|
group="审批开关",
|
||||||
|
value_type="BOOL",
|
||||||
|
unit=None,
|
||||||
|
dimension_type="NONE",
|
||||||
|
options=None,
|
||||||
|
default_value="1",
|
||||||
|
value_min=None,
|
||||||
|
value_max=None,
|
||||||
|
description=(
|
||||||
|
"月度结算审批链开关(④ 月结提交时消费):"
|
||||||
|
"0=结算生成即生效,1=走财务复核→领导审核审批流。"
|
||||||
|
"默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭"
|
||||||
|
),
|
||||||
|
sort_order=18,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="approval.adjustment.enabled",
|
||||||
|
name="调整单审批开关",
|
||||||
|
group="审批开关",
|
||||||
|
value_type="BOOL",
|
||||||
|
unit=None,
|
||||||
|
dimension_type="NONE",
|
||||||
|
options=None,
|
||||||
|
default_value="0",
|
||||||
|
value_min=None,
|
||||||
|
value_max=None,
|
||||||
|
description=(
|
||||||
|
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
|
||||||
|
"调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,"
|
||||||
|
"默认值业务未明确,暂按直接生效,业务可开启)"
|
||||||
|
),
|
||||||
|
sort_order=19,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="manager.personal.included",
|
||||||
|
name="经理个人户计入聚合基数",
|
||||||
|
group="经理分成",
|
||||||
|
value_type="BOOL",
|
||||||
|
unit=None,
|
||||||
|
dimension_type="NONE",
|
||||||
|
options=None,
|
||||||
|
default_value="0",
|
||||||
|
value_min=None,
|
||||||
|
value_max=None,
|
||||||
|
description=(
|
||||||
|
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
|
||||||
|
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
|
||||||
|
"0=不计入(默认,暂口径) 1=计入"
|
||||||
|
),
|
||||||
|
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="工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
|
||||||
|
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=(
|
||||||
|
"发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 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=(
|
||||||
|
"待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"
|
||||||
|
),
|
||||||
|
sort_order=23,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from decimal import Decimal
|
|||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -21,6 +20,7 @@ from app.core.constants import (
|
|||||||
ACTION_DELETE,
|
ACTION_DELETE,
|
||||||
ACTION_UPDATE,
|
ACTION_UPDATE,
|
||||||
MODULE_PARAM,
|
MODULE_PARAM,
|
||||||
|
NO_PREFIX_PARAM,
|
||||||
PRODUCT_CATEGORIES,
|
PRODUCT_CATEGORIES,
|
||||||
)
|
)
|
||||||
from app.core.exceptions import BadRequestError, NotFoundError
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
@@ -35,6 +35,7 @@ from app.schemas.param import (
|
|||||||
ParamCatalogItemOut,
|
ParamCatalogItemOut,
|
||||||
ParamChangeBatchOut,
|
ParamChangeBatchOut,
|
||||||
ParamChangeIn,
|
ParamChangeIn,
|
||||||
|
ParamChangeItemIn,
|
||||||
ParamDiffOut,
|
ParamDiffOut,
|
||||||
ParamDimensionValueOut,
|
ParamDimensionValueOut,
|
||||||
ParamHistoryOut,
|
ParamHistoryOut,
|
||||||
@@ -46,6 +47,7 @@ from app.schemas.param import (
|
|||||||
from app.services import audit_service, formula
|
from app.services import audit_service, formula
|
||||||
from app.services.audit_service import AuditRecord, RequestMeta
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
from app.services.formula import to_decimal
|
from app.services.formula import to_decimal
|
||||||
|
from app.services.no_generator import next_no
|
||||||
from app.services.param_registry import get_spec, parse_fraction, validate_and_normalize
|
from app.services.param_registry import get_spec, parse_fraction, validate_and_normalize
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -187,19 +189,65 @@ def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCat
|
|||||||
# ==================== 变更与版本管理 ====================
|
# ==================== 变更与版本管理 ====================
|
||||||
|
|
||||||
|
|
||||||
def _next_version_no(session: Session) -> str:
|
def _normalize_items(
|
||||||
"""生成批次版本号 P{YYYYMMDD}-{当日序号}(当日自增,两位补零)。"""
|
session: Session, items: list[ParamChangeItemIn]
|
||||||
today = date.today()
|
) -> list[tuple[ParamDefinition, str, str]]:
|
||||||
prefix = f"P{today:%Y%m%d}"
|
"""全量校验条目并归一为 (定义, 规范维度值, 规范值),任何一项不合法整批拒绝。
|
||||||
seq = 0
|
|
||||||
for (version_no,) in session.execute(
|
定义与岗位各一条 IN 批量查询预载(逐条查询在批次扩大时是写侧 N+1);
|
||||||
select(ParamChangeBatch.version_no).where(ParamChangeBatch.version_no.like(f"{prefix}-%"))
|
岗位预载收集全部非空维度值 —— 集合可能略大于实际所需,但省去按维度
|
||||||
):
|
类型先分类的第二轮遍历,无害多取由校验逻辑自行过滤。
|
||||||
try:
|
|
||||||
seq = max(seq, int(version_no.rsplit("-", 1)[1]))
|
:raises BadRequestError: 参数未定义 / 维度不合法 / 值校验失败 / 同批次重复条目
|
||||||
except (IndexError, ValueError): # 手工造数容错:跳过不合规格的版本号
|
"""
|
||||||
continue
|
codes = list({item.param_code for item in items})
|
||||||
return f"{prefix}-{seq + 1:02d}"
|
definitions = param_repository.list_definitions_by_codes(session, codes)
|
||||||
|
dimension_values = list({item.dimension_value for item in items if item.dimension_value})
|
||||||
|
# 预载字典键归一为 casefold:单条版 get_by_code 走 SQL ci 校对(大小写
|
||||||
|
# 变形可命中),批量版改字典查找后须自行等价归一,否则变形大小写会
|
||||||
|
# 被误判「不是已存在的岗位」;岗位编码均为 ascii 小写蛇形,casefold 足够
|
||||||
|
roles = {
|
||||||
|
code.casefold(): role
|
||||||
|
for code, role in business_role_repository.list_by_codes(session, dimension_values).items()
|
||||||
|
}
|
||||||
|
normalized: list[tuple[ParamDefinition, str, str]] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for item in items:
|
||||||
|
definition = definitions.get(item.param_code)
|
||||||
|
if definition is None:
|
||||||
|
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
|
||||||
|
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
|
||||||
|
# ci 校对规则下大小写变形也能命中库,此后一律用库里的规范值:
|
||||||
|
# 注册表 get_spec 与快照字典均按精确键匹配,原始大小写入库会导致
|
||||||
|
# 取值链静默不命中(按错误参数算钱且无报错)
|
||||||
|
spec = get_spec(definition.param_code)
|
||||||
|
dimension = item.dimension_value
|
||||||
|
if spec.dimension_type == "NONE" and dimension != "":
|
||||||
|
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
|
||||||
|
if spec.dimension_type == "BUSINESS_ROLE" and dimension:
|
||||||
|
role = roles.get(dimension.casefold())
|
||||||
|
if role is None:
|
||||||
|
raise BadRequestError(
|
||||||
|
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
|
||||||
|
)
|
||||||
|
dimension = role.role_code
|
||||||
|
if (
|
||||||
|
spec.dimension_type == "PRODUCT_CATEGORY"
|
||||||
|
and dimension
|
||||||
|
and dimension not in PRODUCT_CATEGORIES
|
||||||
|
):
|
||||||
|
raise BadRequestError(
|
||||||
|
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
|
||||||
|
)
|
||||||
|
value = validate_and_normalize(spec, item.value)
|
||||||
|
key = (item.param_code, dimension)
|
||||||
|
if key in seen:
|
||||||
|
raise BadRequestError(
|
||||||
|
f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})"
|
||||||
|
)
|
||||||
|
seen.add(key)
|
||||||
|
normalized.append((definition, dimension, value))
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def submit_change(
|
def submit_change(
|
||||||
@@ -233,47 +281,21 @@ def submit_change(
|
|||||||
raise BadRequestError("指定生效日期不得早于今日")
|
raise BadRequestError("指定生效日期不得早于今日")
|
||||||
|
|
||||||
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
|
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
|
||||||
normalized: list[tuple[ParamDefinition, str, str]] = []
|
normalized = _normalize_items(session, payload.items)
|
||||||
seen: set[tuple[str, str]] = set()
|
|
||||||
for item in payload.items:
|
|
||||||
definition = param_repository.get_definition_by_code(session, item.param_code)
|
|
||||||
if definition is None:
|
|
||||||
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
|
|
||||||
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
|
|
||||||
spec = get_spec(item.param_code)
|
|
||||||
dimension = item.dimension_value
|
|
||||||
if spec.dimension_type == "NONE" and dimension != "":
|
|
||||||
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
|
|
||||||
if (
|
|
||||||
spec.dimension_type == "BUSINESS_ROLE"
|
|
||||||
and dimension
|
|
||||||
and business_role_repository.get_by_code(session, dimension) is None
|
|
||||||
):
|
|
||||||
raise BadRequestError(
|
|
||||||
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
spec.dimension_type == "PRODUCT_CATEGORY"
|
|
||||||
and dimension
|
|
||||||
and dimension not in PRODUCT_CATEGORIES
|
|
||||||
):
|
|
||||||
raise BadRequestError(
|
|
||||||
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
|
|
||||||
)
|
|
||||||
value = validate_and_normalize(spec, item.value)
|
|
||||||
key = (item.param_code, dimension)
|
|
||||||
if key in seen:
|
|
||||||
raise BadRequestError(
|
|
||||||
f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})"
|
|
||||||
)
|
|
||||||
seen.add(key)
|
|
||||||
normalized.append((definition, dimension, value))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
batch = param_repository.create_batch(
|
batch = param_repository.create_batch(
|
||||||
session,
|
session,
|
||||||
ParamChangeBatch(
|
ParamChangeBatch(
|
||||||
version_no=_next_version_no(session),
|
# 版本号与三单据同口径:MAX FOR UPDATE 锁当日序号区间;
|
||||||
|
# 残余并发冲突由下方 IntegrityError 分支翻译为可重试 400
|
||||||
|
version_no=next_no(
|
||||||
|
session,
|
||||||
|
model=ParamChangeBatch,
|
||||||
|
column_name="version_no",
|
||||||
|
prefix=NO_PREFIX_PARAM,
|
||||||
|
seq_width=2,
|
||||||
|
),
|
||||||
effective_date=effective,
|
effective_date=effective,
|
||||||
change_type=payload.change_type,
|
change_type=payload.change_type,
|
||||||
remark=payload.remark,
|
remark=payload.remark,
|
||||||
@@ -299,10 +321,8 @@ def submit_change(
|
|||||||
batch_id=batch.id,
|
batch_id=batch.id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
session.commit()
|
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
# version_no 唯一键的并发冲突:当日序号是无锁查询后 +1,两名管理员
|
# version_no 唯一键的并发冲突:回滚并翻译为可重试的 400
|
||||||
# 并发提交会生成相同版本号 —— 回滚并翻译为可重试的 400
|
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
|
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -503,7 +523,9 @@ def _preview_rate(
|
|||||||
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
||||||
"""
|
"""
|
||||||
if payload.customer_type == "enterprise":
|
if payload.customer_type == "enterprise":
|
||||||
rate = take("enterprise.rate")
|
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
|
||||||
|
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
|
||||||
|
rate = take("enterprise.rate") / 1000
|
||||||
return rate, "企业户固定计提比例", None
|
return rate, "企业户固定计提比例", None
|
||||||
|
|
||||||
role_code = payload.business_role
|
role_code = payload.business_role
|
||||||
@@ -519,7 +541,8 @@ def _preview_rate(
|
|||||||
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
|
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
|
||||||
)
|
)
|
||||||
if template == "TPL_INFO_SALES_FIXED":
|
if template == "TPL_INFO_SALES_FIXED":
|
||||||
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":
|
||||||
@@ -591,7 +614,8 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
|||||||
|
|
||||||
start_ratio = take("payment.start.ratio")
|
start_ratio = take("payment.start.ratio")
|
||||||
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
||||||
weight_r = min(100, int(Fraction(received) / Fraction(revenue) * 100)) if revenue > 0 else 0
|
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
|
||||||
|
weight_r = formula.clamp_weight_r(received, revenue)
|
||||||
weight = formula.payment_weight(weight_r)
|
weight = formula.payment_weight(weight_r)
|
||||||
base_amount = after_barter if barter_applied else performance
|
base_amount = after_barter if barter_applied else performance
|
||||||
payable = to_decimal(Fraction(base_amount) * weight)
|
payable = to_decimal(Fraction(base_amount) * weight)
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
"""营销人员管理业务编排(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。
|
||||||
|
|
||||||
|
业务规则(前端会做提示,**后端校验是唯一防线**):
|
||||||
|
|
||||||
|
- 岗位必须存在且启用 —— 岗位决定计提公式模板,挂到停用/不存在的岗位
|
||||||
|
会让 M3 核算找不到模板
|
||||||
|
- 直属经理不能是自己、目标须存在且在职 —— 自引用关系是 M3 经理 12.5%
|
||||||
|
聚合的展开锚点,环引用或悬空引用会让聚合死循环/漏算
|
||||||
|
- 删除保护:被团队成员(personnel.manager_id)或客户归属
|
||||||
|
(customers.owner_personnel_id)引用的人员**禁删**,应改为离职 ——
|
||||||
|
历史核算与客户归属都要能回溯到人,物理删除会留下悬空引用
|
||||||
|
- 金额红线:salary_standard 以字符串进出、服务层转 Decimal,全程不经 float
|
||||||
|
|
||||||
|
辐射区域与拓展计划为整表替换维护:先删、立即 flush、再插入
|
||||||
|
(防御同一 flush 先 INSERT 后 DELETE 撞唯一键,见 personnel_repository)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
ACTION_DELETE,
|
||||||
|
ACTION_UPDATE,
|
||||||
|
MODULE_PERSONNEL,
|
||||||
|
)
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.core.response import PageResult
|
||||||
|
from app.models import BusinessRole, Personnel, PersonnelPlan, PersonnelRegion, User
|
||||||
|
from app.models.personnel import PERSONNEL_STATUS_ACTIVE
|
||||||
|
from app.repositories import business_role_repository, personnel_repository
|
||||||
|
from app.schemas.personnel import (
|
||||||
|
PersonnelCreateIn,
|
||||||
|
PersonnelDetailOut,
|
||||||
|
PersonnelOut,
|
||||||
|
PersonnelPlanOut,
|
||||||
|
PersonnelQuery,
|
||||||
|
PersonnelRegionOut,
|
||||||
|
PlanReplaceIn,
|
||||||
|
RegionReplaceIn,
|
||||||
|
)
|
||||||
|
from app.services import audit_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 删除保护的统一文案:三种引用(团队成员/客户归属/业务归属)共享同一提示,
|
||||||
|
# 引导管理员走「离职」而非删除
|
||||||
|
_DELETE_BLOCKED = "存在团队成员、客户归属或业务归属引用,请改为离职而非删除"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personnel:
|
||||||
|
"""归属人员必须存在且在职,返回人员行(业务/客户两侧归属校验共用)。
|
||||||
|
|
||||||
|
「不存在」与「已离职」合并为同一文案:人员 ID 是否有效不构成需要向
|
||||||
|
调用方区分的信息(同登录防枚举的思路);label 落文案(负责/协助/归属)。
|
||||||
|
收口单点:在职状态值取 models.personnel.PERSONNEL_STATUS_ACTIVE,
|
||||||
|
此前 business/customer 两 service 各持常量与校验,仅靠注释维持同步。
|
||||||
|
|
||||||
|
:raises BadRequestError: {label}人员不存在或已离职
|
||||||
|
"""
|
||||||
|
person = personnel_repository.get_by_id(session, personnel_id)
|
||||||
|
if person is None or person.status != PERSONNEL_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError(f"{label}人员不存在或已离职")
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
|
||||||
|
"""分页查询人员(含离职)。"""
|
||||||
|
rows = personnel_repository.list_personnel(
|
||||||
|
session,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
keyword=query.keyword,
|
||||||
|
status=query.status,
|
||||||
|
business_role_id=query.business_role_id,
|
||||||
|
)
|
||||||
|
total = personnel_repository.count_personnel(
|
||||||
|
session,
|
||||||
|
keyword=query.keyword,
|
||||||
|
status=query.status,
|
||||||
|
business_role_id=query.business_role_id,
|
||||||
|
)
|
||||||
|
return PageResult[PersonnelOut](
|
||||||
|
records=[
|
||||||
|
_to_out(personnel, role_name, manager_name)
|
||||||
|
for personnel, role_name, manager_name in rows
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_detail(session: Session, personnel_id: int) -> PersonnelDetailOut:
|
||||||
|
"""人员详情(含区域与拓展计划,供编辑抽屉回显)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 人员不存在
|
||||||
|
"""
|
||||||
|
row = personnel_repository.get_with_names(session, personnel_id)
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("人员不存在")
|
||||||
|
personnel, role_name, manager_name = row
|
||||||
|
return PersonnelDetailOut(
|
||||||
|
**_to_out(personnel, role_name, manager_name).model_dump(),
|
||||||
|
regions=[
|
||||||
|
PersonnelRegionOut.model_validate(item)
|
||||||
|
for item in personnel_repository.list_regions(session, personnel_id)
|
||||||
|
],
|
||||||
|
plans=[
|
||||||
|
PersonnelPlanOut.model_validate(item)
|
||||||
|
for item in personnel_repository.list_plans(session, personnel_id)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_personnel(
|
||||||
|
session: Session,
|
||||||
|
payload: PersonnelCreateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> PersonnelOut:
|
||||||
|
"""新增人员,默认在职。
|
||||||
|
|
||||||
|
:raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法
|
||||||
|
"""
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PERSONNEL,
|
||||||
|
action=ACTION_CREATE,
|
||||||
|
summary=f"新增人员 {payload.personnel_name}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 引用校验放在审计上下文内:失败同样要留痕(与 update_personnel 口径
|
||||||
|
# 一致)——「谁在何时试图把人挂到停用岗位/离职经理上」是审计对象,
|
||||||
|
# 校验留在上下文外会让这类失败操作无据可查
|
||||||
|
role, manager_name = _validate_refs(
|
||||||
|
session, payload.business_role_id, payload.manager_id, None
|
||||||
|
)
|
||||||
|
personnel = Personnel(
|
||||||
|
personnel_name=payload.personnel_name,
|
||||||
|
business_role_id=payload.business_role_id,
|
||||||
|
manager_id=payload.manager_id,
|
||||||
|
phone=payload.phone,
|
||||||
|
hire_date=payload.hire_date,
|
||||||
|
is_probation=payload.is_probation,
|
||||||
|
salary_standard=_parse_salary(payload.salary_standard),
|
||||||
|
status=1,
|
||||||
|
remark=payload.remark,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
personnel_repository.create(session, personnel)
|
||||||
|
out = _to_out(personnel, role.role_name, manager_name)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 先查后写不是原子操作:岗位/经理行被并发删除时 FK RESTRICT 令写入失败
|
||||||
|
session.rollback()
|
||||||
|
raise BadRequestError("岗位或直属经理引用已失效,请刷新后重试") from exc
|
||||||
|
|
||||||
|
logger.info("新增人员 | personnel_id=%s | name=%s", personnel.id, payload.personnel_name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def update_personnel(
|
||||||
|
session: Session,
|
||||||
|
personnel_id: int,
|
||||||
|
payload: PersonnelCreateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> PersonnelOut:
|
||||||
|
"""编辑人员(全量替换,不含状态)。
|
||||||
|
|
||||||
|
经理自引用校验以**被编辑人员的 id** 为准:把经理改成自己是环引用。
|
||||||
|
|
||||||
|
:raises NotFoundError: 人员不存在
|
||||||
|
:raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法
|
||||||
|
"""
|
||||||
|
row = personnel_repository.get_with_names(session, personnel_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PERSONNEL,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"修改人员 {row[0].personnel_name if row else f'#{personnel_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("人员不存在")
|
||||||
|
personnel = row[0]
|
||||||
|
role, manager_name = _validate_refs(
|
||||||
|
session, payload.business_role_id, payload.manager_id, personnel.id
|
||||||
|
)
|
||||||
|
personnel.personnel_name = payload.personnel_name
|
||||||
|
personnel.business_role_id = payload.business_role_id
|
||||||
|
personnel.manager_id = payload.manager_id
|
||||||
|
personnel.phone = payload.phone
|
||||||
|
personnel.hire_date = payload.hire_date
|
||||||
|
personnel.is_probation = payload.is_probation
|
||||||
|
personnel.salary_standard = _parse_salary(payload.salary_standard)
|
||||||
|
personnel.remark = payload.remark
|
||||||
|
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
|
||||||
|
out = _to_out(personnel, role.role_name, manager_name)
|
||||||
|
session.commit()
|
||||||
|
logger.info("修改人员 | personnel_id=%s", personnel_id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_personnel(
|
||||||
|
session: Session,
|
||||||
|
personnel_id: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""删除人员。有团队成员或客户归属引用时禁删,引导走「离职」。
|
||||||
|
|
||||||
|
查询走 ``FOR UPDATE`` 锁行:校验与删除之间若发生并发引用,锁行使
|
||||||
|
并发外键写入在删除时失败(可见的 400)而非引用被静默悬空。
|
||||||
|
|
||||||
|
:raises NotFoundError: 人员不存在
|
||||||
|
:raises BadRequestError: 存在团队成员或客户归属引用
|
||||||
|
"""
|
||||||
|
personnel = personnel_repository.get_by_id(session, personnel_id, for_update=True)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PERSONNEL,
|
||||||
|
action=ACTION_DELETE,
|
||||||
|
summary=f"删除人员 {personnel.personnel_name if personnel else f'#{personnel_id}'}",
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if personnel is None:
|
||||||
|
raise NotFoundError("人员不存在")
|
||||||
|
|
||||||
|
ids = [personnel_id]
|
||||||
|
if (
|
||||||
|
personnel_repository.count_team_members(session, ids)
|
||||||
|
or personnel_repository.count_customer_ownerships(session, ids)
|
||||||
|
or personnel_repository.count_business_roles_refs(session, ids)
|
||||||
|
):
|
||||||
|
# 业务归属引用(负责/协助)随业务表就位一并纳入删除保护——
|
||||||
|
# 漏检会让删除以 FK RESTRICT 的 500 暴露而非可读的 400
|
||||||
|
raise BadRequestError(_DELETE_BLOCKED)
|
||||||
|
|
||||||
|
name = personnel.personnel_name # 固化:对象随删除失效
|
||||||
|
try:
|
||||||
|
personnel_repository.delete(session, personnel)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 校验与删除之间并发新增了团队成员/客户归属,FK RESTRICT 令删除失败
|
||||||
|
session.rollback()
|
||||||
|
raise BadRequestError(_DELETE_BLOCKED) from exc
|
||||||
|
logger.info("删除人员 | personnel_id=%s | name=%s", personnel_id, name)
|
||||||
|
|
||||||
|
|
||||||
|
def set_status(
|
||||||
|
session: Session,
|
||||||
|
personnel_id: int,
|
||||||
|
status: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> PersonnelOut:
|
||||||
|
"""在职/离职切换。
|
||||||
|
|
||||||
|
人员语义不用「启用/禁用」词表,action 记 ACTION_UPDATE、摘要写明去向。
|
||||||
|
|
||||||
|
:raises NotFoundError: 人员不存在
|
||||||
|
"""
|
||||||
|
row = personnel_repository.get_with_names(session, personnel_id)
|
||||||
|
verb = "设为在职" if status == 1 else "设为离职"
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PERSONNEL,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"{verb} {row[0].personnel_name if row else f'#{personnel_id}'}",
|
||||||
|
request_data={"status": status},
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("人员不存在")
|
||||||
|
personnel, role_name, manager_name = row
|
||||||
|
personnel.status = status
|
||||||
|
out = _to_out(personnel, role_name, manager_name)
|
||||||
|
session.commit()
|
||||||
|
logger.info("变更人员状态 | personnel_id=%s | status=%s", personnel_id, status)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def replace_regions(
|
||||||
|
session: Session,
|
||||||
|
personnel_id: int,
|
||||||
|
payload: RegionReplaceIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""全量替换辐射区域,空列表即清空。
|
||||||
|
|
||||||
|
:raises NotFoundError: 人员不存在
|
||||||
|
"""
|
||||||
|
row = personnel_repository.get_with_names(session, personnel_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PERSONNEL,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"维护辐射区域 {row[0].personnel_name if row else f'#{personnel_id}'}",
|
||||||
|
request_data=payload.model_dump(),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("人员不存在")
|
||||||
|
rows = [
|
||||||
|
PersonnelRegion(
|
||||||
|
personnel_id=personnel_id, region_name=item.region_name, remark=item.remark
|
||||||
|
)
|
||||||
|
for item in payload.items
|
||||||
|
]
|
||||||
|
personnel_repository.replace_regions(session, personnel_id, rows)
|
||||||
|
session.commit()
|
||||||
|
logger.info("维护辐射区域 | personnel_id=%s | 条目数=%s", personnel_id, len(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def replace_plans(
|
||||||
|
session: Session,
|
||||||
|
personnel_id: int,
|
||||||
|
payload: PlanReplaceIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""全量替换拓展计划,空列表即清空。
|
||||||
|
|
||||||
|
:raises NotFoundError: 人员不存在
|
||||||
|
"""
|
||||||
|
row = personnel_repository.get_with_names(session, personnel_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PERSONNEL,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"维护拓展计划 {row[0].personnel_name if row else f'#{personnel_id}'}",
|
||||||
|
request_data=payload.model_dump(),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("人员不存在")
|
||||||
|
rows = [
|
||||||
|
PersonnelPlan(
|
||||||
|
personnel_id=personnel_id,
|
||||||
|
plan_period=item.plan_period,
|
||||||
|
plan_content=item.plan_content,
|
||||||
|
target_desc=item.target_desc,
|
||||||
|
)
|
||||||
|
for item in payload.items
|
||||||
|
]
|
||||||
|
personnel_repository.replace_plans(session, personnel_id, rows)
|
||||||
|
session.commit()
|
||||||
|
logger.info("维护拓展计划 | personnel_id=%s | 条目数=%s", personnel_id, len(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(personnel: Personnel, role_name: str | None, manager_name: str | None) -> PersonnelOut:
|
||||||
|
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
|
||||||
|
return PersonnelOut(
|
||||||
|
id=personnel.id,
|
||||||
|
personnel_name=personnel.personnel_name,
|
||||||
|
business_role_id=personnel.business_role_id,
|
||||||
|
role_name=role_name,
|
||||||
|
manager_id=personnel.manager_id,
|
||||||
|
manager_name=manager_name,
|
||||||
|
phone=personnel.phone,
|
||||||
|
hire_date=personnel.hire_date,
|
||||||
|
is_probation=personnel.is_probation,
|
||||||
|
salary_standard=(
|
||||||
|
str(personnel.salary_standard) if personnel.salary_standard is not None else None
|
||||||
|
),
|
||||||
|
status=personnel.status,
|
||||||
|
remark=personnel.remark,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_refs(
|
||||||
|
session: Session,
|
||||||
|
business_role_id: int,
|
||||||
|
manager_id: int | None,
|
||||||
|
self_id: int | None,
|
||||||
|
) -> tuple[BusinessRole, str | None]:
|
||||||
|
"""新增/编辑共用的引用校验:岗位须存在且启用;经理不能是自己且须在职。
|
||||||
|
|
||||||
|
``self_id`` 编辑时传被编辑人员的 id、新增时传 None(新增不存在「自己」)。
|
||||||
|
返回岗位行与经理姓名供响应 DTO 复用,避免提交后再查一次。
|
||||||
|
|
||||||
|
:raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法
|
||||||
|
"""
|
||||||
|
role = business_role_repository.get_by_id(session, business_role_id)
|
||||||
|
if role is None or role.status != 1:
|
||||||
|
raise BadRequestError("岗位不存在或已停用")
|
||||||
|
|
||||||
|
manager_name: str | None = None
|
||||||
|
if manager_id is not None:
|
||||||
|
if manager_id == self_id:
|
||||||
|
raise BadRequestError("直属经理不能是自己")
|
||||||
|
manager = personnel_repository.get_by_id(session, manager_id)
|
||||||
|
if manager is None or manager.status != 1:
|
||||||
|
raise BadRequestError("直属经理不存在或已离职")
|
||||||
|
manager_name = manager.personnel_name
|
||||||
|
return role, manager_name
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_salary(value: str | None) -> Decimal | None:
|
||||||
|
"""入参字符串转 Decimal 存库。
|
||||||
|
|
||||||
|
格式、非负与容量已在 schema 层校验,此处只做类型转换 ——
|
||||||
|
金额红线:全程 Decimal,不经 float。
|
||||||
|
"""
|
||||||
|
return Decimal(value) if value is not None else None
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"""产品管理业务编排。
|
||||||
|
|
||||||
|
产品主数据的两条硬约束(违反会算错钱):
|
||||||
|
|
||||||
|
- ``product_category`` 是**折算阈值的行级锚点**:混合业务按产品逐条计算
|
||||||
|
(对照表 #14),阈值由 M1 参数 ``product.threshold`` 按 PRODUCT_CATEGORY
|
||||||
|
维度承担。类别游离于合法值域之外时,核算按类别回溯参数会取不到阈值,
|
||||||
|
故值域校验是业务规则而非格式校验
|
||||||
|
- 数量/单价全程 ``Decimal``:schema 层已拦格式与非负(金额红线入口),
|
||||||
|
service 层负责字符串到 Decimal 的转换与落库
|
||||||
|
|
||||||
|
另:``spec_model`` 归一为**空串而非 NULL** —— 它参与 uk_products_name_spec
|
||||||
|
组合唯一键,MySQL 组合索引对 NULL 不去重,归 NULL 会放行重名同规格数据。
|
||||||
|
删除为物理删除,被业务明细引用的保护检查待业务数据表(V5)就位后补充。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
ACTION_DELETE,
|
||||||
|
ACTION_DISABLE,
|
||||||
|
ACTION_ENABLE,
|
||||||
|
ACTION_UPDATE,
|
||||||
|
MODULE_PRODUCT,
|
||||||
|
PRODUCT_CATEGORIES,
|
||||||
|
)
|
||||||
|
from app.core.db_errors import is_duplicate_entry
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.core.response import PageResult
|
||||||
|
from app.models import Product, User
|
||||||
|
from app.repositories import product_repository
|
||||||
|
from app.schemas.product import (
|
||||||
|
ProductCreateIn,
|
||||||
|
ProductOut,
|
||||||
|
ProductQuery,
|
||||||
|
ProductUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import audit_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(product: Product) -> ProductOut:
|
||||||
|
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
|
||||||
|
return ProductOut(
|
||||||
|
id=product.id,
|
||||||
|
product_name=product.product_name,
|
||||||
|
spec_model=product.spec_model,
|
||||||
|
unit=product.unit,
|
||||||
|
stock_quantity=str(product.stock_quantity) if product.stock_quantity is not None else None,
|
||||||
|
sale_price=str(product.sale_price) if product.sale_price is not None else None,
|
||||||
|
product_category=product.product_category,
|
||||||
|
status=product.status,
|
||||||
|
remark=product.remark,
|
||||||
|
create_time=product.create_time,
|
||||||
|
update_time=product.update_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_decimal(value: str | None) -> Decimal | None:
|
||||||
|
"""数字字符串转 Decimal(可空列的空串/None 透传 None)。
|
||||||
|
|
||||||
|
入参已 str 化再进 Decimal:即使上游口径变化传入数字类型,也不经 float
|
||||||
|
二进制近似,金额红线在最后一道转换处仍然成立。schema 已保证可解析,
|
||||||
|
此处不再重复校验。
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return Decimal(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_category(product_category: str) -> None:
|
||||||
|
"""类别必须 ∈ PRODUCT_CATEGORIES(前端下拉仅是展示约束,后端校验是唯一防线)。"""
|
||||||
|
if product_category not in PRODUCT_CATEGORIES:
|
||||||
|
raise BadRequestError("产品类别不合法")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||||
|
"""判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。
|
||||||
|
|
||||||
|
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、
|
||||||
|
数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。
|
||||||
|
"""
|
||||||
|
return is_duplicate_entry(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_if_duplicate(exc: IntegrityError) -> None:
|
||||||
|
"""唯一键冲突统一转业务提示;其余完整性错误原样重抛。"""
|
||||||
|
if _is_duplicate_entry(exc):
|
||||||
|
raise BadRequestError("已存在同名称同规格的产品") from exc
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
|
||||||
|
"""分页查询产品(含停用)。"""
|
||||||
|
products = product_repository.list_products(
|
||||||
|
session,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
keyword=query.keyword,
|
||||||
|
product_category=query.product_category,
|
||||||
|
status=query.status,
|
||||||
|
)
|
||||||
|
total = product_repository.count_products(
|
||||||
|
session,
|
||||||
|
keyword=query.keyword,
|
||||||
|
product_category=query.product_category,
|
||||||
|
status=query.status,
|
||||||
|
)
|
||||||
|
return PageResult[ProductOut](
|
||||||
|
records=[_to_out(product) for product in products],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_product(session: Session, product_id: int) -> ProductOut:
|
||||||
|
"""产品详情。
|
||||||
|
|
||||||
|
:raises NotFoundError: 产品不存在
|
||||||
|
"""
|
||||||
|
product = product_repository.get_by_id(session, product_id)
|
||||||
|
if product is None:
|
||||||
|
raise NotFoundError("产品不存在")
|
||||||
|
return _to_out(product)
|
||||||
|
|
||||||
|
|
||||||
|
def create_product(
|
||||||
|
session: Session,
|
||||||
|
payload: ProductCreateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ProductOut:
|
||||||
|
"""新增产品。类别须在合法值域内,新产品固定启用。
|
||||||
|
|
||||||
|
:raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品
|
||||||
|
"""
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PRODUCT,
|
||||||
|
action=ACTION_CREATE,
|
||||||
|
summary=f"新增产品 {payload.product_name}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 值域校验在审计上下文内:类别非法的 400 也要留痕(成功与失败都记)
|
||||||
|
_ensure_category(payload.product_category)
|
||||||
|
spec_model = payload.spec_model or "" # 归一空串:组合唯一键对 NULL 不去重
|
||||||
|
if (
|
||||||
|
product_repository.get_by_name_spec(session, payload.product_name, spec_model)
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
raise BadRequestError("已存在同名称同规格的产品")
|
||||||
|
|
||||||
|
product = Product(
|
||||||
|
product_name=payload.product_name,
|
||||||
|
spec_model=spec_model,
|
||||||
|
unit=payload.unit,
|
||||||
|
stock_quantity=_to_decimal(payload.stock_quantity),
|
||||||
|
sale_price=_to_decimal(payload.sale_price),
|
||||||
|
product_category=payload.product_category,
|
||||||
|
status=1,
|
||||||
|
remark=payload.remark,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
product_repository.create(session, product)
|
||||||
|
# 响应与后置动作全部在 commit 之前完成(见 business_role_service 同处说明)
|
||||||
|
out = _to_out(product)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 先查后写不是原子操作:并发提交同名称同规格会撞 uk_products_name_spec
|
||||||
|
session.rollback()
|
||||||
|
_raise_if_duplicate(exc)
|
||||||
|
|
||||||
|
logger.info("新增产品 | product_id=%s | category=%s", product.id, payload.product_category)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def update_product(
|
||||||
|
session: Session,
|
||||||
|
product_id: int,
|
||||||
|
payload: ProductUpdateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ProductOut:
|
||||||
|
"""编辑产品基本信息(全量替换,不含状态;启停走独立端点)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 产品不存在
|
||||||
|
:raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品
|
||||||
|
"""
|
||||||
|
product = product_repository.get_by_id(session, product_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PRODUCT,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"修改产品 {product.product_name if product else f'#{product_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 值域校验在审计上下文内:类别非法的 400 也要留痕(同 create 口径)
|
||||||
|
_ensure_category(payload.product_category)
|
||||||
|
if product is None:
|
||||||
|
raise NotFoundError("产品不存在")
|
||||||
|
|
||||||
|
product.product_name = payload.product_name
|
||||||
|
product.spec_model = payload.spec_model or ""
|
||||||
|
product.unit = payload.unit
|
||||||
|
product.stock_quantity = _to_decimal(payload.stock_quantity)
|
||||||
|
product.sale_price = _to_decimal(payload.sale_price)
|
||||||
|
product.product_category = payload.product_category
|
||||||
|
product.remark = payload.remark
|
||||||
|
out = _to_out(product)
|
||||||
|
try:
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 改名/改规格可能撞 uk_products_name_spec(本行改值与他人既有数据重合)
|
||||||
|
session.rollback()
|
||||||
|
_raise_if_duplicate(exc)
|
||||||
|
|
||||||
|
logger.info("修改产品 | product_id=%s | category=%s", product_id, payload.product_category)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def set_status(
|
||||||
|
session: Session,
|
||||||
|
product_id: int,
|
||||||
|
status: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ProductOut:
|
||||||
|
"""启用或停用产品。停用为主(模型约定),物理删除仅限误录数据。
|
||||||
|
|
||||||
|
:raises NotFoundError: 产品不存在
|
||||||
|
"""
|
||||||
|
product = product_repository.get_by_id(session, product_id)
|
||||||
|
verb = "启用" if status == 1 else "停用"
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PRODUCT,
|
||||||
|
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
|
||||||
|
summary=f"{verb}产品 {product.product_name if product else f'#{product_id}'}",
|
||||||
|
request_data={"status": status},
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if product is None:
|
||||||
|
raise NotFoundError("产品不存在")
|
||||||
|
|
||||||
|
product.status = status
|
||||||
|
out = _to_out(product)
|
||||||
|
session.commit()
|
||||||
|
logger.info("变更产品状态 | product_id=%s | status=%s", product_id, status)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_product(
|
||||||
|
session: Session,
|
||||||
|
product_id: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""物理删除产品(仅限误录数据,常规下架用停用)。
|
||||||
|
|
||||||
|
被业务明细/发货明细引用(RESTRICT 外键)的产品禁删:先 count_refs
|
||||||
|
给出可读 400(引导改走停用),校验与删除之间的并发引用由锁行 +
|
||||||
|
IntegrityError 兜底(customer_service.delete_customer 同款结构)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 产品不存在
|
||||||
|
:raises BadRequestError: 产品已被业务或发货明细引用
|
||||||
|
"""
|
||||||
|
product = product_repository.get_by_id(session, product_id, for_update=True)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PRODUCT,
|
||||||
|
action=ACTION_DELETE,
|
||||||
|
summary=f"删除产品 {product.product_name if product else f'#{product_id}'}",
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if product is None:
|
||||||
|
raise NotFoundError("产品不存在")
|
||||||
|
if product_repository.count_refs(session, product_id):
|
||||||
|
raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)")
|
||||||
|
|
||||||
|
product_name = product.product_name # 固化:对象随删除失效
|
||||||
|
try:
|
||||||
|
product_repository.delete(session, product)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 先查后删的并发窗口:新明细挂上了该产品,FK RESTRICT 兜底为 400
|
||||||
|
session.rollback()
|
||||||
|
raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)") from exc
|
||||||
|
logger.info("删除产品 | product_name=%s", product_name)
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
"""客户评级业务编排(等级映射配置 + 每客户评级落库)。
|
||||||
|
|
||||||
|
评级定位(对照表 #25):**不影响绩效金额**。自 2026-10-19 起评分每客户
|
||||||
|
落库(customer_ratings,一行一客户快照),等级映射仍是本服务承载的
|
||||||
|
**判定表**,判定规则为按 sort_order 升序取首个 min_score ≤ 总分的等级,
|
||||||
|
max_score 仅展示。两条硬约束(违反会算错等级):
|
||||||
|
|
||||||
|
- 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串
|
||||||
|
到 Decimal 的转换与落库;值域校验(等级区间 0≤min≤max≤100、单项评分
|
||||||
|
0≤分≤20)在**审计上下文内**完成,失败的 400 同样留痕(2026-10-13 定稿
|
||||||
|
口径)
|
||||||
|
- 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表
|
||||||
|
失效。判定为**闭区间两两相交**:任一其他等级满足
|
||||||
|
``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。
|
||||||
|
边界相邻不算重叠(前段 max=89、后段 min=90)—— 种子 A/B/C/D 即按
|
||||||
|
相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order
|
||||||
|
靠前的等级,相邻无歧义
|
||||||
|
|
||||||
|
删除允许清空:等级表对客户评级**无外键引用**(评级快照固化等级名、不
|
||||||
|
回指本表),物理删除无悬空风险,也不伤及已评级客户的历史结论;全部删空
|
||||||
|
后新评级只记总分、等级为 NULL(前端已对应提示),故不强制保留任何一条。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
ACTION_DELETE,
|
||||||
|
ACTION_UPDATE,
|
||||||
|
MODULE_RATING,
|
||||||
|
)
|
||||||
|
from app.core.db_errors import is_duplicate_entry
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.models import CustomerRating, CustomerRatingGrade, User
|
||||||
|
from app.repositories import customer_rating_repository, customer_repository, rating_repository
|
||||||
|
from app.schemas.rating import (
|
||||||
|
CustomerRatingOut,
|
||||||
|
CustomerRatingSaveIn,
|
||||||
|
RatingGradeCreateIn,
|
||||||
|
RatingGradeOut,
|
||||||
|
RatingGradeUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import audit_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 分值域上下界(Decimal 常量,禁用 float 参与比较)
|
||||||
|
_SCORE_FLOOR = Decimal("0")
|
||||||
|
_SCORE_CEIL = Decimal("100")
|
||||||
|
# 单项评分上界(五项各 0-20,总分 0-100)
|
||||||
|
_ITEM_SCORE_CEIL = Decimal("20")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut:
|
||||||
|
"""ORM 对象转响应模型。分值 Decimal → str(分值红线出参口径)。"""
|
||||||
|
return RatingGradeOut(
|
||||||
|
id=grade.id,
|
||||||
|
grade_name=grade.grade_name,
|
||||||
|
min_score=str(grade.min_score),
|
||||||
|
max_score=str(grade.max_score),
|
||||||
|
sort_order=grade.sort_order,
|
||||||
|
remark=grade.remark,
|
||||||
|
create_time=grade.create_time,
|
||||||
|
update_time=grade.update_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_decimal(value: str) -> Decimal:
|
||||||
|
"""数字字符串转 Decimal。
|
||||||
|
|
||||||
|
入参已 str 化再进 Decimal:即使上游口径变化传入数字类型,也不经 float
|
||||||
|
二进制近似,分值红线在最后一道转换处仍然成立。schema 已保证可解析,
|
||||||
|
此处不再重复校验。
|
||||||
|
"""
|
||||||
|
return Decimal(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_score_range(min_score: Decimal, max_score: Decimal) -> None:
|
||||||
|
"""区间合法性:0 ≤ min_score ≤ max_score ≤ 100。
|
||||||
|
|
||||||
|
超界/倒挂任一不满足即「分值区间不合法」——统一一条消息不区分具体
|
||||||
|
越界方式,前端据此整体提示。须在审计上下文内调用,失败也要留痕。
|
||||||
|
"""
|
||||||
|
if not (_SCORE_FLOOR <= min_score <= max_score <= _SCORE_CEIL):
|
||||||
|
raise BadRequestError("分值区间不合法")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_no_overlap(
|
||||||
|
grades: list[CustomerRatingGrade],
|
||||||
|
new_min: Decimal,
|
||||||
|
new_max: Decimal,
|
||||||
|
*,
|
||||||
|
exclude_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""新区间与既有等级**不得重叠**(闭区间两两相交判定)。
|
||||||
|
|
||||||
|
任一其他等级满足 ``other.min_score <= new_max 且 new_min <= other.max_score``
|
||||||
|
即重叠,400 并点名冲突等级,便于前端定位到具体行。边界相邻不算重叠
|
||||||
|
(见模块 docstring)。exclude_id 供编辑时排除自身。
|
||||||
|
|
||||||
|
:raises BadRequestError: 分值区间与等级 X 重叠
|
||||||
|
"""
|
||||||
|
for other in grades:
|
||||||
|
if exclude_id is not None and other.id == exclude_id:
|
||||||
|
continue
|
||||||
|
if other.min_score <= new_max and new_min <= other.max_score:
|
||||||
|
raise BadRequestError(f"分值区间与等级 {other.grade_name} 重叠")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||||
|
"""判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。
|
||||||
|
|
||||||
|
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误语义未知,
|
||||||
|
原样上抛 —— 由全局 IntegrityError handler 收敛为 400(通用文案),
|
||||||
|
500 只留给程序缺陷。
|
||||||
|
"""
|
||||||
|
return is_duplicate_entry(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_if_duplicate(exc: IntegrityError) -> None:
|
||||||
|
"""唯一键冲突统一转业务提示;其余完整性错误原样重抛。"""
|
||||||
|
if _is_duplicate_entry(exc):
|
||||||
|
raise BadRequestError("该等级名称已存在") from exc
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
def list_grades(session: Session) -> list[RatingGradeOut]:
|
||||||
|
"""全量等级列表,按判定顺序(sort_order 升序)直出。
|
||||||
|
|
||||||
|
不分页:等级映射是判定表,数量个位数,分页无意义。
|
||||||
|
"""
|
||||||
|
return [_to_out(grade) for grade in rating_repository.list_all(session)]
|
||||||
|
|
||||||
|
|
||||||
|
def create_grade(
|
||||||
|
session: Session,
|
||||||
|
payload: RatingGradeCreateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> RatingGradeOut:
|
||||||
|
"""新增评级等级。
|
||||||
|
|
||||||
|
区间须 0≤min≤max≤100 且与既有等级不重叠(新增同样判重叠:判定表
|
||||||
|
语义下重叠即脏配置,没有「先停用旧区间」之类的两阶段配置手段)。
|
||||||
|
|
||||||
|
:raises BadRequestError: 分值区间不合法 / 分值区间与等级 X 重叠 / 该等级名称已存在
|
||||||
|
"""
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_RATING,
|
||||||
|
action=ACTION_CREATE,
|
||||||
|
summary=f"新增评级等级 {payload.grade_name}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 值域校验在审计上下文内:分值非法的 400 也要留痕(成功与失败都记)
|
||||||
|
new_min = _to_decimal(payload.min_score)
|
||||||
|
new_max = _to_decimal(payload.max_score)
|
||||||
|
_ensure_score_range(new_min, new_max)
|
||||||
|
_ensure_no_overlap(rating_repository.list_all(session, for_update=True), new_min, new_max)
|
||||||
|
if rating_repository.get_by_name(session, payload.grade_name) is not None:
|
||||||
|
raise BadRequestError("该等级名称已存在")
|
||||||
|
|
||||||
|
grade = CustomerRatingGrade(
|
||||||
|
grade_name=payload.grade_name,
|
||||||
|
min_score=new_min,
|
||||||
|
max_score=new_max,
|
||||||
|
sort_order=payload.sort_order,
|
||||||
|
remark=payload.remark,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
rating_repository.create(session, grade)
|
||||||
|
# 响应与后置动作全部在 commit 之前完成(见 product_service 同处说明)
|
||||||
|
out = _to_out(grade)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 先查后写不是原子操作:并发提交同名等级会撞 uk_customer_rating_grades_grade_name
|
||||||
|
session.rollback()
|
||||||
|
_raise_if_duplicate(exc)
|
||||||
|
|
||||||
|
logger.info("新增评级等级 | grade_id=%s | name=%s", grade.id, payload.grade_name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def update_grade(
|
||||||
|
session: Session,
|
||||||
|
grade_id: int,
|
||||||
|
payload: RatingGradeUpdateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> RatingGradeOut:
|
||||||
|
"""编辑评级等级(全量替换)。
|
||||||
|
|
||||||
|
区间与其他等级不得重叠 —— 重叠判定以**修改后的区间**对全部既有等级
|
||||||
|
(排除自身)两两相交,命中即 400 并点名冲突等级。
|
||||||
|
|
||||||
|
:raises NotFoundError: 评级等级不存在
|
||||||
|
:raises BadRequestError: 分值区间不合法 / 分值区间与等级 X 重叠 / 该等级名称已存在
|
||||||
|
"""
|
||||||
|
grade = rating_repository.get_by_id(session, grade_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_RATING,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"修改评级等级 {grade.grade_name if grade else f'#{grade_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 值域校验在审计上下文内(同 create 口径)
|
||||||
|
new_min = _to_decimal(payload.min_score)
|
||||||
|
new_max = _to_decimal(payload.max_score)
|
||||||
|
_ensure_score_range(new_min, new_max)
|
||||||
|
if grade is None:
|
||||||
|
raise NotFoundError("评级等级不存在")
|
||||||
|
|
||||||
|
_ensure_no_overlap(
|
||||||
|
rating_repository.list_all(session, for_update=True),
|
||||||
|
new_min,
|
||||||
|
new_max,
|
||||||
|
exclude_id=grade_id,
|
||||||
|
)
|
||||||
|
existed = rating_repository.get_by_name(session, payload.grade_name)
|
||||||
|
if existed is not None and existed.id != grade_id:
|
||||||
|
raise BadRequestError("该等级名称已存在")
|
||||||
|
|
||||||
|
grade.grade_name = payload.grade_name
|
||||||
|
grade.min_score = new_min
|
||||||
|
grade.max_score = new_max
|
||||||
|
grade.sort_order = payload.sort_order
|
||||||
|
grade.remark = payload.remark
|
||||||
|
out = _to_out(grade)
|
||||||
|
try:
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 改名可能撞 uk_customer_rating_grades_grade_name(本行改值与他人既有数据重合)
|
||||||
|
session.rollback()
|
||||||
|
_raise_if_duplicate(exc)
|
||||||
|
|
||||||
|
logger.info("修改评级等级 | grade_id=%s | name=%s", grade_id, payload.grade_name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_grade(
|
||||||
|
session: Session,
|
||||||
|
grade_id: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""物理删除评级等级。
|
||||||
|
|
||||||
|
等级表对客户评级**无外键引用** —— 评级行快照固化等级名(见
|
||||||
|
``models/customer_rating.py`` 的论证),判定只读本表现值,故可物理删除
|
||||||
|
且无需锁行防并发引用;删除等级不改变已评级客户的历史结论。
|
||||||
|
|
||||||
|
**允许清空**:全部删空后新评级只记总分、等级为 NULL(前端已对应提示),
|
||||||
|
不强制保留任何一条。
|
||||||
|
|
||||||
|
:raises NotFoundError: 评级等级不存在
|
||||||
|
"""
|
||||||
|
grade = rating_repository.get_by_id(session, grade_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_RATING,
|
||||||
|
action=ACTION_DELETE,
|
||||||
|
summary=f"删除评级等级 {grade.grade_name if grade else f'#{grade_id}'}",
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if grade is None:
|
||||||
|
raise NotFoundError("评级等级不存在")
|
||||||
|
|
||||||
|
grade_name = grade.grade_name # 固化:对象随删除失效
|
||||||
|
rating_repository.delete(session, grade)
|
||||||
|
session.commit()
|
||||||
|
logger.info("删除评级等级 | grade_name=%s", grade_name)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 客户评级(每客户一行,落库) ====================
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_grade(
|
||||||
|
grades: list[CustomerRatingGrade], total_score: Decimal
|
||||||
|
) -> CustomerRatingGrade | None:
|
||||||
|
"""等级判定纯函数:按 sort_order 升序取首个 min_score ≤ 总分的等级。
|
||||||
|
|
||||||
|
与前端打分卡的实时判定同规则同序(前端按数值比较,此处 Decimal 比较
|
||||||
|
——同一规则的两端实现,测试锁定边界行为一致)。无命中(总分低于全部
|
||||||
|
下限或等级表为空)返回 None,由调用方决定展示与落库(快照记 NULL)。
|
||||||
|
"""
|
||||||
|
# id 兜底让同序等级次序稳定(纯函数也接受未持久化对象:id 可能为 None)
|
||||||
|
for grade in sorted(grades, key=lambda g: (g.sort_order, g.id or 0)):
|
||||||
|
if grade.min_score <= total_score:
|
||||||
|
return grade
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_rating_out(rating: CustomerRating) -> CustomerRatingOut:
|
||||||
|
"""客户评级行转响应模型。分值 Decimal → str(分值红线出参口径)。"""
|
||||||
|
return CustomerRatingOut(
|
||||||
|
customer_id=rating.customer_id,
|
||||||
|
score_ship_amount=str(rating.score_ship_amount),
|
||||||
|
score_receipt_rate=str(rating.score_receipt_rate),
|
||||||
|
score_profit_rate=str(rating.score_profit_rate),
|
||||||
|
score_contract_cooperation=str(rating.score_contract_cooperation),
|
||||||
|
score_site_construction=str(rating.score_site_construction),
|
||||||
|
total_score=str(rating.total_score),
|
||||||
|
grade_name=rating.grade_name,
|
||||||
|
remark=rating.remark,
|
||||||
|
scorer_name=rating.scorer_name,
|
||||||
|
create_time=rating.create_time,
|
||||||
|
update_time=rating.update_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_item_scores(scores: list[Decimal]) -> None:
|
||||||
|
"""单项评分值域:0 ≤ 分 ≤ 20(总分由五项求和自然落在 0-100)。
|
||||||
|
|
||||||
|
须在审计上下文内调用,失败也要留痕(同 _ensure_score_range 口径)。
|
||||||
|
"""
|
||||||
|
for score in scores:
|
||||||
|
if not (_SCORE_FLOOR <= score <= _ITEM_SCORE_CEIL):
|
||||||
|
raise BadRequestError("单项评分须在 0~20 之间")
|
||||||
|
|
||||||
|
|
||||||
|
def get_customer_rating(session: Session, customer_id: int) -> CustomerRatingOut | None:
|
||||||
|
"""查某客户当前评级;未评级返回 None(接口信封 data 为 null)。
|
||||||
|
|
||||||
|
「未评级」是正常态而非错误,不用 404 表达;客户不存在才是 NotFound。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
"""
|
||||||
|
customer = customer_repository.get_by_id(session, customer_id)
|
||||||
|
if customer is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
rating = customer_rating_repository.get_by_customer_id(session, customer_id)
|
||||||
|
return _to_rating_out(rating) if rating is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def save_customer_rating(
|
||||||
|
session: Session,
|
||||||
|
customer_id: int,
|
||||||
|
payload: CustomerRatingSaveIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> CustomerRatingOut:
|
||||||
|
"""保存客户评级(upsert:首次创建,再次保存整体覆盖)。
|
||||||
|
|
||||||
|
总分与等级**服务端重算**:总分按五项求和(不采信前端回传),等级按
|
||||||
|
保存时点的等级映射判定后**快照固化**进评级行 —— 等级表后续增删改
|
||||||
|
不影响该行(见 ``models/customer_rating.py``)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
:raises BadRequestError: 单项评分须在 0~20 之间(审计上下文内校验,留痕)
|
||||||
|
"""
|
||||||
|
# 只读预取在审计上下文之前:客户不存在(NotFound 失败)也能留下可读记录,
|
||||||
|
# 摘要需要客户名
|
||||||
|
customer = customer_repository.get_by_id(session, customer_id)
|
||||||
|
existing = customer_rating_repository.get_by_customer_id(session, customer_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_RATING,
|
||||||
|
action=ACTION_CREATE if existing is None else ACTION_UPDATE,
|
||||||
|
summary=f"评定客户「{customer.company_name if customer else f'#{customer_id}'}」评级",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 值域校验在审计上下文内:分值非法的 400 也要留痕(同等级配置口径)
|
||||||
|
item_scores = [
|
||||||
|
_to_decimal(payload.score_ship_amount),
|
||||||
|
_to_decimal(payload.score_receipt_rate),
|
||||||
|
_to_decimal(payload.score_profit_rate),
|
||||||
|
_to_decimal(payload.score_contract_cooperation),
|
||||||
|
_to_decimal(payload.score_site_construction),
|
||||||
|
]
|
||||||
|
_ensure_item_scores(item_scores)
|
||||||
|
if customer is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
|
||||||
|
total_score = sum(item_scores, _SCORE_FLOOR)
|
||||||
|
grade = resolve_grade(rating_repository.list_all(session), total_score)
|
||||||
|
|
||||||
|
def _apply(row: CustomerRating) -> None:
|
||||||
|
"""把五项分值/总分/等级快照写入评级行(创建与并发兜底共用)。"""
|
||||||
|
(
|
||||||
|
row.score_ship_amount,
|
||||||
|
row.score_receipt_rate,
|
||||||
|
row.score_profit_rate,
|
||||||
|
row.score_contract_cooperation,
|
||||||
|
row.score_site_construction,
|
||||||
|
) = item_scores
|
||||||
|
row.total_score = total_score
|
||||||
|
row.grade_name = grade.grade_name if grade is not None else None
|
||||||
|
row.remark = payload.remark
|
||||||
|
row.scorer_id = operator.id
|
||||||
|
row.scorer_name = operator.username
|
||||||
|
|
||||||
|
try:
|
||||||
|
if existing is None:
|
||||||
|
rating = CustomerRating(
|
||||||
|
customer_id=customer_id,
|
||||||
|
scorer_id=operator.id,
|
||||||
|
scorer_name=operator.username,
|
||||||
|
)
|
||||||
|
_apply(rating)
|
||||||
|
customer_rating_repository.create(session, rating)
|
||||||
|
else:
|
||||||
|
_apply(existing)
|
||||||
|
rating = existing
|
||||||
|
out = _to_rating_out(rating)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 先查后写不是原子操作:并发为同一客户首次评级会撞
|
||||||
|
# uk_customer_ratings_customer_id,回滚后转为更新兜底
|
||||||
|
session.rollback()
|
||||||
|
if not is_duplicate_entry(exc):
|
||||||
|
raise
|
||||||
|
raced = customer_rating_repository.get_by_customer_id(session, customer_id)
|
||||||
|
if raced is None:
|
||||||
|
# 1062 但行不在:数据态异常,交全局 IntegrityError 兜底
|
||||||
|
raise
|
||||||
|
_apply(raced)
|
||||||
|
rating = raced
|
||||||
|
out = _to_rating_out(rating)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"保存客户评级 | customer_id=%s | total=%s | grade=%s",
|
||||||
|
customer_id,
|
||||||
|
out.total_score,
|
||||||
|
out.grade_name,
|
||||||
|
)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
"""回款管理业务编排(需求 2.2.3)。
|
||||||
|
|
||||||
|
事务边界在本层:repository 只 flush,commit/rollback 只写在这里。
|
||||||
|
|
||||||
|
- **写操作前 FOR UPDATE 锁业务行**(delete_business 先例):同业务的
|
||||||
|
发货/回款/编辑相互串行化,防止并发写后 ``_recalc_totals`` 重算
|
||||||
|
total_received_amount 互相覆盖(丢失更新);批次号生成(MAX+FOR
|
||||||
|
UPDATE 锁当日序号区间)与唯一键(1062)冲突重试一次兜底,模式同
|
||||||
|
business_service
|
||||||
|
- **shipment_id 同业务强校验**(数据完整性红线):指定的发货单必须存在
|
||||||
|
且与本回款同属一个业务 —— 跨业务指定会让回款天数与台账归属漂移;
|
||||||
|
存在性与归属两类失败统一同一文案(防通过响应差异探测发货单存在性,
|
||||||
|
与登录判定的防枚举同哲学),FK RESTRICT 是最终防线
|
||||||
|
- **settlement_status 是 M3 核算防重锚点**(需规 2.3.3「每笔回款仅核算
|
||||||
|
一次」):已核算回款禁改禁删 —— 改金额/删行会让 M3 台账与源数据漂移;
|
||||||
|
M2 只写入 0(未核算),置 1 由 M3 核算服务负责
|
||||||
|
- 回款合计回写统一收口 ``business_service._recalc_totals``(增删改后
|
||||||
|
都重算,禁各处手写 SUM)
|
||||||
|
- payment_days 为**只读派生值**:shipment_id 非空且发货单在库时按
|
||||||
|
``formula.payment_days(ship_date, receipt_date)`` 实时计算(发货日首日
|
||||||
|
计第 1 天),不落库;未指定批次为 None
|
||||||
|
- 一期禁负数金额(≥0):schema 拒绝;红字/退款冲销需求出现时先补设计
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
ACTION_DELETE,
|
||||||
|
ACTION_UPDATE,
|
||||||
|
MODULE_RECEIPT,
|
||||||
|
NO_PREFIX_RECEIPT,
|
||||||
|
)
|
||||||
|
from app.core.db_errors import is_duplicate_entry
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.core.response import PageResult
|
||||||
|
from app.models import Receipt
|
||||||
|
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||||
|
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
|
||||||
|
from app.repositories import business_repository, receipt_repository
|
||||||
|
from app.schemas.receipt import (
|
||||||
|
ReceiptCreateIn,
|
||||||
|
ReceiptOut,
|
||||||
|
ReceiptQuery,
|
||||||
|
ReceiptUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import audit_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
from app.services.business_service import _recalc_totals
|
||||||
|
from app.services.formula import payment_days
|
||||||
|
from app.services.no_generator import next_no
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY)
|
||||||
|
_NO_RETRY = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(row: tuple) -> ReceiptOut:
|
||||||
|
"""(receipt, business_no, shipment_no, ship_date) → 响应模型。
|
||||||
|
|
||||||
|
金额出参 str;payment_days 为派生值 —— ship_date 为 None(未指定批次)
|
||||||
|
时输出 None;回款日早于发货日时按公式照实输出负值,忠实反映源数据
|
||||||
|
(钳制会掩盖录入异常,M3 台账同口径)。
|
||||||
|
"""
|
||||||
|
receipt, business_no, shipment_no, ship_date = row
|
||||||
|
return ReceiptOut(
|
||||||
|
id=receipt.id,
|
||||||
|
receipt_batch_no=receipt.receipt_batch_no,
|
||||||
|
business_id=receipt.business_id,
|
||||||
|
business_no=business_no,
|
||||||
|
shipment_id=receipt.shipment_id,
|
||||||
|
shipment_no=shipment_no,
|
||||||
|
receipt_date=receipt.receipt_date,
|
||||||
|
amount=str(receipt.amount),
|
||||||
|
is_barter=receipt.is_barter,
|
||||||
|
settlement_status=receipt.settlement_status,
|
||||||
|
payment_days=(
|
||||||
|
payment_days(ship_date, receipt.receipt_date) if ship_date is not None else None
|
||||||
|
),
|
||||||
|
remark=receipt.remark,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_no(session: Session) -> str:
|
||||||
|
"""生成回款批次号(HK 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。"""
|
||||||
|
return next_no(session, model=Receipt, column_name="receipt_batch_no", prefix=NO_PREFIX_RECEIPT)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_shipment_of_business(
|
||||||
|
session: Session, shipment_id: int | None, business_id: int
|
||||||
|
) -> tuple[str | None, object | None]:
|
||||||
|
"""发货批次同业务强校验,返回 (shipment_no, ship_date) 供 DTO 组装。
|
||||||
|
|
||||||
|
「必须存在且 business_id 相同」两类失败统一同一文案(防探测发货单
|
||||||
|
存在性);shipment_id 为 None = 暂未指定,直接放行。
|
||||||
|
|
||||||
|
:raises BadRequestError: 发货批次不存在或不属于该业务
|
||||||
|
"""
|
||||||
|
if shipment_id is None:
|
||||||
|
return None, None
|
||||||
|
shipment = receipt_repository.get_shipment(session, shipment_id)
|
||||||
|
if shipment is None or shipment.business_id != business_id:
|
||||||
|
raise BadRequestError("发货批次不属于该业务,无法指定")
|
||||||
|
return shipment.shipment_no, shipment.ship_date
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_business_writable(session: Session, business_id: int, action: str):
|
||||||
|
"""锁业务行并要求状态=1 执行中(新增/编辑/删除共用的写准入)。
|
||||||
|
|
||||||
|
锁在判定之前取:判定结果基于锁定后的行,配合 _recalc_totals 的重算
|
||||||
|
同事务提交,并发写不会丢失更新。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
:raises BadRequestError: 业务已完成或已终止
|
||||||
|
"""
|
||||||
|
business = business_repository.get_by_id(session, business_id, for_update=True)
|
||||||
|
if business is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError(f"业务已完成或已终止,禁止{action}回款")
|
||||||
|
return business
|
||||||
|
|
||||||
|
|
||||||
|
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
|
||||||
|
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
|
||||||
|
rows = receipt_repository.list_receipts(
|
||||||
|
session,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
business_id=query.business_id,
|
||||||
|
date_from=query.date_from,
|
||||||
|
date_to=query.date_to,
|
||||||
|
is_barter=query.is_barter,
|
||||||
|
settlement_status=query.settlement_status,
|
||||||
|
)
|
||||||
|
total = receipt_repository.count_receipts(
|
||||||
|
session,
|
||||||
|
business_id=query.business_id,
|
||||||
|
date_from=query.date_from,
|
||||||
|
date_to=query.date_to,
|
||||||
|
is_barter=query.is_barter,
|
||||||
|
settlement_status=query.settlement_status,
|
||||||
|
)
|
||||||
|
return PageResult[ReceiptOut](
|
||||||
|
records=[_to_out(row) for row in rows],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
|
||||||
|
"""回款详情(同列表 DTO,含 payment_days 派生值)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 回款不存在
|
||||||
|
"""
|
||||||
|
row = receipt_repository.get_with_refs(session, receipt_id)
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("回款不存在")
|
||||||
|
return _to_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
def create_receipt(
|
||||||
|
session: Session,
|
||||||
|
payload: ReceiptCreateIn,
|
||||||
|
*,
|
||||||
|
operator,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ReceiptOut:
|
||||||
|
"""新增回款:锁业务行 → 校验 → 建行 + 重算合计,一个事务。
|
||||||
|
|
||||||
|
摘要三要素(批次号/业务号/金额)在进入审计前就绪:业务号取自锁行
|
||||||
|
结果、批次号先行生成(SELECT MAX FOR UPDATE,不产生未提交变更)、
|
||||||
|
金额来自入参;引用校验(业务状态/发货批次同业务)在审计上下文内,
|
||||||
|
失败也留痕 —— 失败路径回滚释放已取序号,当日序号出现空洞无害
|
||||||
|
(唯一性不依赖连续)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
:raises BadRequestError: 业务已完成或已终止 / 发货批次不属于该业务
|
||||||
|
"""
|
||||||
|
business = business_repository.get_by_id(session, payload.business_id, for_update=True)
|
||||||
|
batch_no = _generate_no(session)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_RECEIPT,
|
||||||
|
action=ACTION_CREATE,
|
||||||
|
summary=(
|
||||||
|
f"新增回款 {batch_no}"
|
||||||
|
f"(业务 {business.business_no if business else f'#{payload.business_id}'},"
|
||||||
|
f"金额 {payload.amount})"
|
||||||
|
),
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 批次号唯一键冲突的重试整体收在审计上下文内(模式同
|
||||||
|
# business_service.create_business):rollback 释放旧锁,重试前重新
|
||||||
|
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
|
||||||
|
for attempt in range(_NO_RETRY + 1):
|
||||||
|
try:
|
||||||
|
if business is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError("业务已完成或已终止,禁止新增回款")
|
||||||
|
# 月结锁定拦截(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()
|
||||||
|
break
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
if _is_duplicate_entry(exc) and attempt < _NO_RETRY:
|
||||||
|
business = business_repository.get_by_id(
|
||||||
|
session, payload.business_id, for_update=True
|
||||||
|
)
|
||||||
|
batch_no = _generate_no(session)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
|
||||||
|
new_id,
|
||||||
|
new_no,
|
||||||
|
payload.business_id,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def update_receipt(
|
||||||
|
session: Session,
|
||||||
|
receipt_id: int,
|
||||||
|
payload: ReceiptUpdateIn,
|
||||||
|
*,
|
||||||
|
operator,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ReceiptOut:
|
||||||
|
"""编辑回款(全量替换)+ 重算合计,一个事务。
|
||||||
|
|
||||||
|
**已核算回款禁止修改**(M3 防重锚点):M3 台账已按该行固化核算,
|
||||||
|
改金额/到账日/批次指定会让台账与源数据漂移。shipment_id 变更后
|
||||||
|
重新做同业务强校验(全量替换语义下每次编辑都校验,无「未变即免检」
|
||||||
|
的旁路)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 回款不存在 / 业务不存在
|
||||||
|
:raises BadRequestError: 已核算禁改 / 业务已完成或已终止 / 发货批次不属于该业务
|
||||||
|
"""
|
||||||
|
receipt = receipt_repository.get_by_id(session, receipt_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_RECEIPT,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"修改回款 {receipt.receipt_batch_no if receipt else f'#{receipt_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if receipt is None:
|
||||||
|
raise NotFoundError("回款不存在")
|
||||||
|
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||||
|
raise BadRequestError("已核算回款禁止修改")
|
||||||
|
business = _ensure_business_writable(session, receipt.business_id, "修改")
|
||||||
|
# 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数,
|
||||||
|
# 只查新月会让「从锁定月改到未锁定月」绕过拦截)
|
||||||
|
from app.services import settlement_service
|
||||||
|
|
||||||
|
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||||||
|
settlement_service.ensure_month_not_locked(session, payload.receipt_date.strftime("%Y-%m"))
|
||||||
|
# 校验返回的 (shipment_no, ship_date) 即新批次 DTO 所需 —— shipment_id
|
||||||
|
# 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join
|
||||||
|
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||||
|
session, payload.shipment_id, receipt.business_id
|
||||||
|
)
|
||||||
|
|
||||||
|
receipt.receipt_date = payload.receipt_date
|
||||||
|
receipt.shipment_id = payload.shipment_id
|
||||||
|
receipt.amount = Decimal(payload.amount)
|
||||||
|
receipt.is_barter = payload.is_barter
|
||||||
|
receipt.remark = payload.remark
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
||||||
|
batch_no = receipt.receipt_batch_no
|
||||||
|
session.commit()
|
||||||
|
# commit 后不再触碰 ORM 属性(audit_service 约定):批次号已固化
|
||||||
|
logger.info("修改回款 | receipt_id=%s | no=%s", receipt_id, batch_no)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_receipt(
|
||||||
|
session: Session,
|
||||||
|
receipt_id: int,
|
||||||
|
*,
|
||||||
|
operator,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""删除回款(物理删除)+ 重算合计,一个事务。
|
||||||
|
|
||||||
|
**已核算回款禁止删除**(M3 防重锚点,理由同编辑);业务须状态=1
|
||||||
|
执行中 —— 终态业务的回款是 M3 核算与报表的既成事实,不得删改。
|
||||||
|
|
||||||
|
:raises NotFoundError: 回款不存在 / 业务不存在
|
||||||
|
:raises BadRequestError: 已核算禁删 / 业务已完成或已终止
|
||||||
|
"""
|
||||||
|
receipt = receipt_repository.get_by_id(session, receipt_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_RECEIPT,
|
||||||
|
action=ACTION_DELETE,
|
||||||
|
summary=f"删除回款 {receipt.receipt_batch_no if receipt else f'#{receipt_id}'}",
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if receipt is None:
|
||||||
|
raise NotFoundError("回款不存在")
|
||||||
|
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||||
|
raise BadRequestError("已核算回款禁止删除")
|
||||||
|
business = _ensure_business_writable(session, receipt.business_id, "删除")
|
||||||
|
# 月结锁定拦截:已锁定月份的回款是月结快照的源数据,不得删除
|
||||||
|
from app.services import settlement_service
|
||||||
|
|
||||||
|
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||||||
|
|
||||||
|
batch_no = receipt.receipt_batch_no
|
||||||
|
receipt_repository.delete(session, receipt)
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
session.commit()
|
||||||
|
# commit 后不再触碰 ORM 属性(audit_service 约定):批次号已固化
|
||||||
|
logger.info("删除回款 | receipt_id=%s | no=%s", receipt_id, batch_no)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||||
|
"""识别 MySQL 唯一键冲突(1062),与 business_service 同口径。"""
|
||||||
|
return is_duplicate_entry(exc)
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
"""报表 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
@@ -1,17 +1,20 @@
|
|||||||
"""角色管理业务编排。
|
"""角色管理业务编排。
|
||||||
|
|
||||||
需求 2.5.1 只定义 admin / user 两个角色,本模块属用户确认的**自主完善**
|
需求 2.5.1 定义 admin / user 两个登录角色;2026-10-15 复核审批补充需求
|
||||||
(轻量版:不含权限点分配 —— `permissions` / `role_permissions` 表继续闲置,
|
新增营销后勤/财务/领导三个职能登录角色(营销后勤与财务/领导分区于审批
|
||||||
待业务方给出权限清单后再启用)。
|
节点准入)。本模块仍为轻量版(不含权限点分配 —— `permissions` /
|
||||||
|
`role_permissions` 表继续闲置,待业务方给出权限清单后再启用)。
|
||||||
|
|
||||||
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
|
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
|
||||||
|
|
||||||
- `admin` 角色**禁删禁停** —— `cli` 建管理员与 `require_admin` 判定都依赖
|
- **内置角色禁删禁停**(`BUILTIN_ROLE_CODES`:admin / marketing_ops /
|
||||||
该编码存在,删除会使系统无法自举
|
finance / leader)—— `admin` 被删除会使系统无法自举(cli 建管理员与
|
||||||
|
`require_admin` 判定依赖);财务/领导是审批节点准入锚点,被删会让
|
||||||
|
在途审批流失去可操作人
|
||||||
- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默
|
- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默
|
||||||
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
|
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
|
||||||
锁行,堵住「校验与删除之间并发绑定」的窗口
|
锁行,堵住「校验与删除之间并发绑定」的窗口
|
||||||
- `role_code` 创建后不可改 —— 它是授权判定(`ROLE_ADMIN` 常量按编码比对)的锚点
|
- `role_code` 创建后不可改 —— 它是授权判定(`BUILTIN_ROLE_CODES` 白名单按编码比对)的锚点
|
||||||
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
|
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
|
||||||
但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
|
但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
|
||||||
"""
|
"""
|
||||||
@@ -27,8 +30,8 @@ from app.core.constants import (
|
|||||||
ACTION_DISABLE,
|
ACTION_DISABLE,
|
||||||
ACTION_ENABLE,
|
ACTION_ENABLE,
|
||||||
ACTION_UPDATE,
|
ACTION_UPDATE,
|
||||||
|
BUILTIN_ROLE_CODES,
|
||||||
MODULE_ROLE,
|
MODULE_ROLE,
|
||||||
ROLE_ADMIN,
|
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -177,10 +180,10 @@ def set_status(
|
|||||||
|
|
||||||
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
|
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
|
||||||
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
|
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
|
||||||
内置管理员角色不可停用。
|
内置角色不可停用。
|
||||||
|
|
||||||
:raises NotFoundError: 角色不存在
|
:raises NotFoundError: 角色不存在
|
||||||
:raises ForbiddenError: 试图停用内置管理员角色
|
:raises ForbiddenError: 试图停用内置角色
|
||||||
"""
|
"""
|
||||||
role = role_repository.get_by_id(session, role_id)
|
role = role_repository.get_by_id(session, role_id)
|
||||||
verb = "启用" if status == 1 else "禁用"
|
verb = "启用" if status == 1 else "禁用"
|
||||||
@@ -198,8 +201,8 @@ def set_status(
|
|||||||
if role is None:
|
if role is None:
|
||||||
raise NotFoundError("角色不存在")
|
raise NotFoundError("角色不存在")
|
||||||
|
|
||||||
if role.role_code == ROLE_ADMIN and status == 0:
|
if role.role_code in BUILTIN_ROLE_CODES and status == 0:
|
||||||
raise ForbiddenError("内置管理员角色不可停用")
|
raise ForbiddenError("内置角色不可停用")
|
||||||
|
|
||||||
role.status = status
|
role.status = status
|
||||||
out = _to_out(role, _user_count(session, role_id))
|
out = _to_out(role, _user_count(session, role_id))
|
||||||
@@ -217,12 +220,12 @@ def delete_role(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""删除角色。
|
"""删除角色。
|
||||||
|
|
||||||
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)。
|
内置角色不可删除(admin 与三个审批职能角色,见 BUILTIN_ROLE_CODES);仍有关联用户的角色不可删除(提示先解除绑定)。
|
||||||
查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把
|
查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把
|
||||||
新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。
|
新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。
|
||||||
|
|
||||||
:raises NotFoundError: 角色不存在
|
:raises NotFoundError: 角色不存在
|
||||||
:raises ForbiddenError: 试图删除内置管理员角色
|
:raises ForbiddenError: 试图删除内置角色
|
||||||
:raises BadRequestError: 角色下仍有绑定的用户
|
:raises BadRequestError: 角色下仍有绑定的用户
|
||||||
"""
|
"""
|
||||||
role = role_repository.get_by_id(session, role_id, for_update=True)
|
role = role_repository.get_by_id(session, role_id, for_update=True)
|
||||||
@@ -239,8 +242,8 @@ def delete_role(
|
|||||||
if role is None:
|
if role is None:
|
||||||
raise NotFoundError("角色不存在")
|
raise NotFoundError("角色不存在")
|
||||||
|
|
||||||
if role.role_code == ROLE_ADMIN:
|
if role.role_code in BUILTIN_ROLE_CODES:
|
||||||
raise ForbiddenError("内置管理员角色不可删除")
|
raise ForbiddenError("内置角色不可删除")
|
||||||
|
|
||||||
# user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限
|
# user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限
|
||||||
user_count = _user_count(session, role_id)
|
user_count = _user_count(session, role_id)
|
||||||
|
|||||||
@@ -0,0 +1,622 @@
|
|||||||
|
"""月度结算服务(M3 ④):生成快照 → 提交审批 → 通过即锁定。
|
||||||
|
|
||||||
|
确认回款口径(澄清 #8 策略点,2026-10-16 暂定):settlement_status=1 的
|
||||||
|
**有效台账行**(is_active=1、settlement_id IS NULL=未被任何月结固化),
|
||||||
|
按 settlement_month(回款日所在月)归集。答复后只改 ``_confirm_scope``。
|
||||||
|
|
||||||
|
通过即锁定(status=3 终态):锁定后源数据(回款/发货)写路径由
|
||||||
|
``ensure_month_not_locked`` 拦截;调整走调整单(T6)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
MODULE_SETTLEMENT,
|
||||||
|
)
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.models import (
|
||||||
|
MonthlySettlement,
|
||||||
|
Personnel,
|
||||||
|
SettlementItem,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from app.models.approval import APPROVAL_TYPE_MONTHLY_SETTLEMENT
|
||||||
|
from app.models.performance_ledger import (
|
||||||
|
CALC_TYPE_MANAGER_AGGREGATE,
|
||||||
|
CALC_TYPE_RECEIPT,
|
||||||
|
)
|
||||||
|
from app.models.settlement import (
|
||||||
|
SETTLEMENT_STATUS_APPROVED,
|
||||||
|
SETTLEMENT_STATUS_DRAFT,
|
||||||
|
SETTLEMENT_STATUS_PENDING,
|
||||||
|
SETTLEMENT_STATUS_REJECTED,
|
||||||
|
)
|
||||||
|
from app.repositories import performance_ledger_repository, settlement_repository
|
||||||
|
from app.schemas.settlement import (
|
||||||
|
SettlementDetailOut,
|
||||||
|
SettlementOut,
|
||||||
|
SettlementQuery,
|
||||||
|
)
|
||||||
|
from app.services import approval_service, audit_service, manager_share
|
||||||
|
from app.services.approval_service import AuditRecord, RequestMeta
|
||||||
|
from app.services.formula import manager_team_share, monthly_salary
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 审批对象类型引用(order_type=2 月结、3 调整单)
|
||||||
|
_SETTLEMENT_ORDER_TYPE = APPROVAL_TYPE_MONTHLY_SETTLEMENT
|
||||||
|
_SETTLEMENT_NOTE = "确认回款口径(#8 策略点):settlement_status=1 的有效台账行按回款日归月"
|
||||||
|
|
||||||
|
|
||||||
|
def generate(
|
||||||
|
session: Session,
|
||||||
|
month: str,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> SettlementOut:
|
||||||
|
"""生成月度结算快照(草稿):按确认回款口径取有效台账行,按人汇总。
|
||||||
|
|
||||||
|
- 同月已有月结行(任意状态)时 400——一行一月,重生成走「驳回/草稿态
|
||||||
|
重新生成」(_release_previous_round 解绑回填后清明细重算)
|
||||||
|
- 经理聚合(calc_type=2)在生成时计算并落库(#12 参数化见
|
||||||
|
``_build_manager_aggregates``)
|
||||||
|
|
||||||
|
本函数只做编排,各段独立私有函数(口径修改只动对应段)。
|
||||||
|
"""
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_SETTLEMENT,
|
||||||
|
action="生成月结",
|
||||||
|
summary=f"生成 {month} 月度结算快照",
|
||||||
|
request_data={"month": month},
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
existing = settlement_repository.get_by_month(session, month)
|
||||||
|
_guard_regeneration(existing, month)
|
||||||
|
_release_previous_round(session, existing, month)
|
||||||
|
|
||||||
|
ledger_rows = _confirm_scope(session, month)
|
||||||
|
if not ledger_rows:
|
||||||
|
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
|
||||||
|
|
||||||
|
aggregate_rows = _build_manager_aggregates(session, ledger_rows, month)
|
||||||
|
settlement = _upsert_draft(session, existing, operator, month)
|
||||||
|
items, ledger_ids = _build_items(session, ledger_rows, aggregate_rows, month)
|
||||||
|
|
||||||
|
settlement_repository.replace_items(session, settlement.id, items)
|
||||||
|
_apply_totals(settlement, items)
|
||||||
|
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
|
||||||
|
# 入结回填:台账行 settlement_id 是「未入结」防重判据(_confirm_scope),
|
||||||
|
# 不回填会让重生成把已固化行反复纳入;聚合行此刻已落库、id 可用
|
||||||
|
settlement_repository.update_ledger_settlement_id(session, ledger_ids, settlement.id)
|
||||||
|
|
||||||
|
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结
|
||||||
|
# 记成失败审计,与库中事实相反)
|
||||||
|
session.flush()
|
||||||
|
detail = _detail_of(session, settlement)
|
||||||
|
session.commit()
|
||||||
|
logger.info("月结生成 | month=%s | items=%s", month, len(items))
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_regeneration(existing: MonthlySettlement | None, month: str) -> None:
|
||||||
|
"""重生成准入:已审定 / 待审批不可重生成(草稿、已驳回放行)。"""
|
||||||
|
if existing is None:
|
||||||
|
return
|
||||||
|
if existing.status == SETTLEMENT_STATUS_APPROVED:
|
||||||
|
raise BadRequestError(f"{month} 已审定锁定,不可重新生成(调整走调整单)")
|
||||||
|
if existing.status == SETTLEMENT_STATUS_PENDING:
|
||||||
|
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成")
|
||||||
|
|
||||||
|
|
||||||
|
def _release_previous_round(
|
||||||
|
session: Session, existing: MonthlySettlement | None, month: str
|
||||||
|
) -> None:
|
||||||
|
"""释放上一轮固化标记(必须早于 _confirm_scope)。
|
||||||
|
|
||||||
|
台账行 settlement_id 回填后,「未入结」过滤会把它们永久排除在候选集外;
|
||||||
|
旧聚合行也要作废,否则本轮重建的聚合行与旧行重复计入。
|
||||||
|
"""
|
||||||
|
if existing is None:
|
||||||
|
return
|
||||||
|
settlement_repository.clear_ledger_settlement_id(session, existing.id)
|
||||||
|
performance_ledger_repository.deactivate_month_aggregates(session, month)
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_draft(
|
||||||
|
session: Session, existing: MonthlySettlement | None, operator: User, month: str
|
||||||
|
) -> MonthlySettlement:
|
||||||
|
"""新建或重置草稿头表(重生成时刷新提交人与状态)。"""
|
||||||
|
if existing is None:
|
||||||
|
settlement = MonthlySettlement(
|
||||||
|
settlement_month=month,
|
||||||
|
submitter_id=operator.id,
|
||||||
|
submitter_name=operator.username,
|
||||||
|
status=SETTLEMENT_STATUS_DRAFT,
|
||||||
|
)
|
||||||
|
return settlement_repository.add(session, settlement)
|
||||||
|
existing.submitter_id = operator.id
|
||||||
|
existing.submitter_name = operator.username
|
||||||
|
existing.status = SETTLEMENT_STATUS_DRAFT
|
||||||
|
return existing
|
||||||
|
|
||||||
|
|
||||||
|
def _build_items(
|
||||||
|
session: Session, ledger_rows: list, aggregate_rows: list, month: str
|
||||||
|
) -> tuple[list[SettlementItem], list[int]]:
|
||||||
|
"""按人汇总生成明细行,返回 (明细, 本次入结的台账行 id 列表)。"""
|
||||||
|
by_person = _bucket_rows(ledger_rows + aggregate_rows)
|
||||||
|
items = [_item_of(session, bucket, month) for _, bucket in sorted(by_person.items())]
|
||||||
|
ledger_ids = [lid for bucket in by_person.values() for lid in bucket["ledger_ids"]]
|
||||||
|
return items, ledger_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_rows(rows: list) -> dict[tuple[int, int], dict]:
|
||||||
|
"""台账行按 (人员, 核算类型) 维度分桶(冗余人读字段取首行)。"""
|
||||||
|
by_person: dict[tuple[int, int], dict] = {}
|
||||||
|
for row in rows:
|
||||||
|
key = (row.personnel_id, row.calc_type)
|
||||||
|
bucket = by_person.setdefault(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
"personnel_id": row.personnel_id,
|
||||||
|
"personnel_name": row.personnel_name,
|
||||||
|
"role_code": row.role_code,
|
||||||
|
"calc_type": row.calc_type,
|
||||||
|
"performance_sum": Decimal("0"),
|
||||||
|
"payable_sum": Decimal("0"),
|
||||||
|
"ledger_ids": [],
|
||||||
|
"is_probation": row.is_probation,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bucket["performance_sum"] += row.performance_amount or Decimal("0")
|
||||||
|
bucket["payable_sum"] += row.payable_amount or Decimal("0")
|
||||||
|
bucket["ledger_ids"].append(row.id)
|
||||||
|
return by_person
|
||||||
|
|
||||||
|
|
||||||
|
def _item_of(session: Session, bucket: dict, month: str) -> SettlementItem:
|
||||||
|
"""单条结算明细:月工资三口径净额、试用期说明与台账行引用列表。"""
|
||||||
|
person = session.get(Personnel, bucket["personnel_id"])
|
||||||
|
basic = person.salary_standard if person else None
|
||||||
|
mode = _salary_mode_of(session, bucket["role_code"], month)
|
||||||
|
net = (
|
||||||
|
monthly_salary(bucket["payable_sum"], basic, mode)
|
||||||
|
if person and person.is_probation != 1
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
note = None
|
||||||
|
if person and person.is_probation == 1:
|
||||||
|
note = (
|
||||||
|
f"试用期绩效照算不发放;年累计超 {_probation_threshold(session, month)} 元部分"
|
||||||
|
"年底一次性结算"
|
||||||
|
)
|
||||||
|
return SettlementItem(
|
||||||
|
personnel_id=bucket["personnel_id"],
|
||||||
|
personnel_name=bucket["personnel_name"],
|
||||||
|
role_code=bucket["role_code"],
|
||||||
|
calc_type=bucket["calc_type"],
|
||||||
|
performance_sum=bucket["performance_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||||
|
payable_sum=bucket["payable_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||||
|
basic_amount=basic,
|
||||||
|
salary_mode=mode,
|
||||||
|
net_amount=(
|
||||||
|
net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) if net is not None else None
|
||||||
|
),
|
||||||
|
is_probation=bucket["is_probation"],
|
||||||
|
probation_note=note,
|
||||||
|
snapshot_json={"ledger_ids": bucket["ledger_ids"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_totals(settlement: MonthlySettlement, items: list[SettlementItem]) -> None:
|
||||||
|
"""头表三合计固化(明细已两位小数,合计再 HALF_UP 收口)。"""
|
||||||
|
settlement.total_performance = sum((i.performance_sum for i in items), Decimal("0")).quantize(
|
||||||
|
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||||
|
)
|
||||||
|
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
|
||||||
|
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||||
|
)
|
||||||
|
settlement.person_count = len({i.personnel_id for i in items})
|
||||||
|
|
||||||
|
|
||||||
|
def submit(
|
||||||
|
session: Session,
|
||||||
|
settlement_id: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> SettlementDetailOut:
|
||||||
|
"""提交审批:开关分流(关=自动通过并锁定;开=待财务复核)。"""
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_SETTLEMENT,
|
||||||
|
action="提交月结",
|
||||||
|
summary=f"提交月结 #{settlement_id}",
|
||||||
|
request_data={"settlement_id": settlement_id},
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
settlement = settlement_repository.get_by_id(session, settlement_id)
|
||||||
|
if settlement is None:
|
||||||
|
raise NotFoundError("月结不存在")
|
||||||
|
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
|
||||||
|
raise BadRequestError("仅草稿或已驳回状态可提交")
|
||||||
|
|
||||||
|
order = approval_service.submit_order(
|
||||||
|
session,
|
||||||
|
order_type=_SETTLEMENT_ORDER_TYPE,
|
||||||
|
ref_id=settlement.id,
|
||||||
|
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
|
||||||
|
submitter=operator,
|
||||||
|
)
|
||||||
|
settlement.approval_order_no = order.order_no
|
||||||
|
if order.status == SETTLEMENT_STATUS_APPROVED:
|
||||||
|
mark_locked(session, settlement, operator=operator)
|
||||||
|
logger.info("月结自动通过并锁定 | %s", settlement.settlement_month)
|
||||||
|
else:
|
||||||
|
settlement.status = SETTLEMENT_STATUS_PENDING
|
||||||
|
|
||||||
|
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已提交的
|
||||||
|
# 月结记成失败审计,与库中事实相反)
|
||||||
|
session.flush()
|
||||||
|
detail = _detail_of(session, settlement)
|
||||||
|
session.commit()
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
|
||||||
|
"""审批通过钩子(由 approval_service.act 回调):置已审定+锁定信息。"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
settlement.status = SETTLEMENT_STATUS_APPROVED
|
||||||
|
settlement.locked_at = datetime.now()
|
||||||
|
settlement.locked_by = operator.username
|
||||||
|
|
||||||
|
|
||||||
|
def mark_rejected(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
|
||||||
|
"""审批驳回钩子:回草稿(可修改后重提)。
|
||||||
|
|
||||||
|
锁定信息一并清空:草稿态残留 locked_at/locked_by 会让详情页展示
|
||||||
|
「已锁定」的假象(历史缺陷路径下驳回前可能已被提前锁定)。
|
||||||
|
"""
|
||||||
|
settlement.status = SETTLEMENT_STATUS_DRAFT
|
||||||
|
settlement.locked_at = None
|
||||||
|
settlement.locked_by = None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_month_not_locked(session: Session, month: str) -> None:
|
||||||
|
"""锁定拦截(回款/发货写路径调用):单据日期所在月已审定 → 400。"""
|
||||||
|
settlement = settlement_repository.get_by_month(session, month)
|
||||||
|
if settlement is not None and settlement.status == SETTLEMENT_STATUS_APPROVED:
|
||||||
|
raise BadRequestError(f"{month} 已月结锁定,源数据不可修改(调整走调整单)")
|
||||||
|
|
||||||
|
|
||||||
|
def get_detail(session: Session, settlement_id: int) -> SettlementDetailOut:
|
||||||
|
settlement = settlement_repository.get_by_id(session, settlement_id)
|
||||||
|
if settlement is None:
|
||||||
|
raise NotFoundError("月结不存在")
|
||||||
|
return _detail_of(session, settlement)
|
||||||
|
|
||||||
|
|
||||||
|
def list_page(session: Session, query: SettlementQuery):
|
||||||
|
from app.core.response import PageResult
|
||||||
|
from app.schemas.settlement import SettlementOut
|
||||||
|
|
||||||
|
rows = settlement_repository.list_settlements(session, query)
|
||||||
|
total = settlement_repository.count_settlements(session, query)
|
||||||
|
return PageResult[SettlementOut](
|
||||||
|
records=[SettlementOut.model_validate(r) for r in rows],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 内部 ----------
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm_scope(session: Session, month: str) -> list:
|
||||||
|
"""确认回款口径(#8 策略点):本月**单笔回款**的有效台账行(未入结)。
|
||||||
|
|
||||||
|
calc_type 必须限定单笔(1):聚合行(2)也落同月台账、也会被回填
|
||||||
|
settlement_id,不排除会在重生成时与新聚合行重复计入。
|
||||||
|
"""
|
||||||
|
from app.models import PerformanceLedger
|
||||||
|
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(PerformanceLedger)
|
||||||
|
.where(
|
||||||
|
PerformanceLedger.settlement_month == month,
|
||||||
|
PerformanceLedger.calc_type == CALC_TYPE_RECEIPT,
|
||||||
|
PerformanceLedger.is_active == 1,
|
||||||
|
PerformanceLedger.settlement_id.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(PerformanceLedger.id)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _manager_share_params(session: Session, month: str) -> manager_share.ManagerShareParams:
|
||||||
|
"""薄委托 manager_share 共享模块:月结聚合与报表部门汇总同源取参。
|
||||||
|
|
||||||
|
迁移说明(2026-10-19):实现原样迁至 manager_share,行为零变化;
|
||||||
|
参数按百分数存储(12.5 表示 12.5%),调用方不得再乘 100。
|
||||||
|
"""
|
||||||
|
return manager_share.manager_share_params(session, month)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_team_buckets(
|
||||||
|
session: Session, ledger_rows: list, include_manager_personal: bool
|
||||||
|
) -> dict[int, dict]:
|
||||||
|
"""薄委托 manager_share 共享模块:按经理采集团队业绩(行为零变化)。"""
|
||||||
|
return manager_share.collect_team_buckets(session, ledger_rows, include_manager_personal)
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_row(
|
||||||
|
session: Session,
|
||||||
|
manager,
|
||||||
|
bucket: dict,
|
||||||
|
company_base: Decimal,
|
||||||
|
share: manager_share.ManagerShareParams,
|
||||||
|
):
|
||||||
|
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
|
||||||
|
from app.models import PerformanceLedger
|
||||||
|
|
||||||
|
amount = manager_team_share(
|
||||||
|
bucket["performance"],
|
||||||
|
company_base,
|
||||||
|
team_pct=share.team_pct,
|
||||||
|
company_pct=share.company_pct,
|
||||||
|
)
|
||||||
|
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
|
||||||
|
return performance_ledger_repository.add(
|
||||||
|
session,
|
||||||
|
PerformanceLedger(
|
||||||
|
receipt_id=None,
|
||||||
|
calc_version=1, # 聚合行无回款维度,版本恒 1(uk 含 NULL 不去重)
|
||||||
|
calc_type=CALC_TYPE_MANAGER_AGGREGATE,
|
||||||
|
business_id=bucket["business_id"],
|
||||||
|
business_no=bucket["business_no"],
|
||||||
|
personnel_id=manager.id,
|
||||||
|
personnel_name=manager.personnel_name,
|
||||||
|
role_code="sales_manager",
|
||||||
|
customer_type="personal",
|
||||||
|
product_category="mixed",
|
||||||
|
receipt_date=share.param_date,
|
||||||
|
# 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真)
|
||||||
|
rate_text=f"聚合:{share.team_text}%+{share.company_text}%",
|
||||||
|
converted_profit=None,
|
||||||
|
performance_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||||
|
barter_applied=0,
|
||||||
|
payable_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||||
|
settlement_month=share.month,
|
||||||
|
is_probation=1 if manager.is_probation == 1 else 0,
|
||||||
|
snapshot_json={
|
||||||
|
"team_base": str(bucket["performance"]),
|
||||||
|
"company_base": str(company_base),
|
||||||
|
"members": member_names,
|
||||||
|
"params_as_of": str(share.param_date),
|
||||||
|
"manager_personal_included": 1 if share.include_manager_personal else 0,
|
||||||
|
"note": (
|
||||||
|
f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}"
|
||||||
|
"经理个人户业绩;9.8 方案公式"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list:
|
||||||
|
"""按经理生成团队聚合行(calc_type=2)并落库。
|
||||||
|
|
||||||
|
#12 答复(2026-10-19)参数化:经理个人户业绩是否计入聚合基数由参数
|
||||||
|
``manager.personal.included`` 决定(0=不计入默认,1=计入);
|
||||||
|
经理 = 台账行归属人员经 manager_id 关联的 sales_manager 岗人员。
|
||||||
|
"""
|
||||||
|
from app.repositories import business_role_repository
|
||||||
|
|
||||||
|
share = _manager_share_params(session, month)
|
||||||
|
managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal)
|
||||||
|
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);共享口径单点
|
||||||
|
company_base = manager_share.company_base_of(ledger_rows, share.include_manager_personal)
|
||||||
|
aggregates: list = []
|
||||||
|
for manager_id, bucket in managers.items():
|
||||||
|
manager = session.get(Personnel, manager_id)
|
||||||
|
if manager is None:
|
||||||
|
continue
|
||||||
|
role = (
|
||||||
|
business_role_repository.get_by_id(session, manager.business_role_id)
|
||||||
|
if manager.business_role_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if role is None or role.role_code != "sales_manager":
|
||||||
|
continue # 非营销经理的上级不参与聚合
|
||||||
|
aggregates.append(_aggregate_row(session, manager, bucket, company_base, share))
|
||||||
|
return aggregates
|
||||||
|
|
||||||
|
|
||||||
|
def _month_end(month: str) -> date:
|
||||||
|
"""薄委托 manager_share 共享模块:YYYY-MM → 所属月月末(取参锚点)。
|
||||||
|
|
||||||
|
保留包装:tests/unit/test_settlement_helpers.py 直接 import 本名字;
|
||||||
|
「补生成历史月份结果不随操作日漂移」的原因说明见 manager_share.month_end。
|
||||||
|
"""
|
||||||
|
return manager_share.month_end(month)
|
||||||
|
|
||||||
|
|
||||||
|
def _salary_mode_of(session: Session, role_code: str, month: str) -> str | None:
|
||||||
|
from app.services.param_service import resolve_snapshot
|
||||||
|
|
||||||
|
snapshot = resolve_snapshot(session, _month_end(month))
|
||||||
|
return snapshot.get("salary.basic.mode", role_code=role_code) or None
|
||||||
|
|
||||||
|
|
||||||
|
def _probation_threshold(session: Session, month: str) -> Decimal:
|
||||||
|
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||||
|
|
||||||
|
snapshot = resolve_snapshot(session, _month_end(month))
|
||||||
|
from app.services.formula import to_decimal
|
||||||
|
|
||||||
|
return to_decimal(parse_fraction(snapshot.get("probation.annual.threshold")))
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_of(session: Session, settlement: MonthlySettlement) -> SettlementDetailOut:
|
||||||
|
items = settlement_repository.list_items(session, settlement.id)
|
||||||
|
detail = SettlementDetailOut.model_validate(settlement)
|
||||||
|
from app.schemas.settlement import SettlementItemOut
|
||||||
|
|
||||||
|
detail.items = [SettlementItemOut.model_validate(i) for i in items]
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
def create_adjustment(
|
||||||
|
session: Session,
|
||||||
|
payload,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
):
|
||||||
|
"""新建调整单:开关分流(关=提交即生效留痕;开=待审批)。"""
|
||||||
|
from app.core.constants import NO_PREFIX_ADJUSTMENT
|
||||||
|
from app.models import AdjustmentOrder
|
||||||
|
from app.services.no_generator import next_no
|
||||||
|
|
||||||
|
amount = _adjustment_amount(payload)
|
||||||
|
|
||||||
|
person = session.get(Personnel, payload.personnel_id)
|
||||||
|
if person is None:
|
||||||
|
raise BadRequestError("调整人员不存在")
|
||||||
|
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_SETTLEMENT,
|
||||||
|
action="新建调整单",
|
||||||
|
summary=f"调整单 {payload.direction} {payload.amount} 元({person.personnel_name})",
|
||||||
|
request_data=payload.model_dump(),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
order = AdjustmentOrder(
|
||||||
|
order_no=next_no(
|
||||||
|
session,
|
||||||
|
model=AdjustmentOrder,
|
||||||
|
column_name="order_no",
|
||||||
|
prefix=NO_PREFIX_ADJUSTMENT,
|
||||||
|
),
|
||||||
|
settlement_month=payload.settlement_month,
|
||||||
|
personnel_id=payload.personnel_id,
|
||||||
|
personnel_name=person.personnel_name,
|
||||||
|
direction=payload.direction,
|
||||||
|
amount=amount,
|
||||||
|
reason=payload.reason.strip(),
|
||||||
|
submitter_id=operator.id,
|
||||||
|
submitter_name=operator.username,
|
||||||
|
)
|
||||||
|
session.add(order)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
_wire_adjustment_approval(session, order, operator)
|
||||||
|
out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定)
|
||||||
|
session.commit()
|
||||||
|
logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _adjustment_amount(payload) -> Decimal:
|
||||||
|
"""金额恒正校验(方向表达补发/扣减)。
|
||||||
|
|
||||||
|
列注释即「恒正」约定,负数会让方向语义反转、审批/报表按 direction
|
||||||
|
汇总出错;格式已由 schema 正则拦截,此处兜底转换 + 语义判定。
|
||||||
|
"""
|
||||||
|
from decimal import InvalidOperation
|
||||||
|
|
||||||
|
try:
|
||||||
|
amount = Decimal(payload.amount)
|
||||||
|
except InvalidOperation as exc:
|
||||||
|
raise BadRequestError("调整金额格式非法(非负两位小数)") from exc
|
||||||
|
if amount <= 0:
|
||||||
|
raise BadRequestError("调整金额须大于 0(方向表达补发/扣减)")
|
||||||
|
return amount
|
||||||
|
|
||||||
|
|
||||||
|
def _wire_adjustment_approval(session: Session, order, operator: User) -> None:
|
||||||
|
"""开关分流与留痕统一交给 submit_order(关=自动通过留痕;开=待财务复核)。
|
||||||
|
|
||||||
|
**不得手工插 ApprovalStep**——``approval_steps.order_id`` 外键指向
|
||||||
|
approval_orders(不是 adjustment_orders),用调整单 id 落步必然外键
|
||||||
|
失败(历史缺陷:关开关时新建调整单不可用)。
|
||||||
|
"""
|
||||||
|
from app.models.approval import APPROVAL_STATUS_APPROVED, APPROVAL_TYPE_ADJUSTMENT
|
||||||
|
from app.models.settlement import (
|
||||||
|
ADJUSTMENT_STATUS_EFFECTIVE,
|
||||||
|
ADJUSTMENT_STATUS_PENDING,
|
||||||
|
)
|
||||||
|
|
||||||
|
approval_order = approval_service.submit_order(
|
||||||
|
session,
|
||||||
|
order_type=APPROVAL_TYPE_ADJUSTMENT,
|
||||||
|
ref_id=order.id,
|
||||||
|
refer_no=order.order_no,
|
||||||
|
submitter=operator,
|
||||||
|
)
|
||||||
|
order.status = (
|
||||||
|
ADJUSTMENT_STATUS_EFFECTIVE
|
||||||
|
if approval_order.status == APPROVAL_STATUS_APPROVED
|
||||||
|
else ADJUSTMENT_STATUS_PENDING
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _adjustment_out(order) -> dict:
|
||||||
|
"""调整单出参(commit 前固化;expire_on_commit 关闭下也不依赖后置读取)。"""
|
||||||
|
return {
|
||||||
|
"id": order.id,
|
||||||
|
"order_no": order.order_no,
|
||||||
|
"status": order.status,
|
||||||
|
"personnel_name": order.personnel_name,
|
||||||
|
"amount": str(order.amount),
|
||||||
|
"direction": order.direction,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_adjustments(session: Session) -> list[dict]:
|
||||||
|
"""调整单列表(月份倒序)。"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models import AdjustmentOrder
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
session.execute(select(AdjustmentOrder).order_by(AdjustmentOrder.id.desc()).limit(200))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
from app.models.settlement import adjustment_label # noqa: F401
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"order_no": r.order_no,
|
||||||
|
"settlement_month": r.settlement_month,
|
||||||
|
"personnel_name": r.personnel_name,
|
||||||
|
"direction": r.direction,
|
||||||
|
"amount": str(r.amount),
|
||||||
|
"reason": r.reason,
|
||||||
|
"status": r.status,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""发货管理业务编排(需求 2.2.2)。
|
||||||
|
|
||||||
|
事务边界在本层:repository 只 flush,commit/rollback 只写在这里。
|
||||||
|
|
||||||
|
- 行金额(amount)与发货单总金额(total_amount)服务层按 数量×单价 计算
|
||||||
|
固化,不信任前端传值;单价可空时行金额记 0(对齐业务明细口径)
|
||||||
|
- 业务级汇总(发货/运费合计)回写统一收口 ``business_service._recalc_totals``,
|
||||||
|
本模块不另写聚合逻辑;发货单 total_amount 由 repository 行 SUM 重算
|
||||||
|
- **写操作前 FOR UPDATE 锁业务行**(receipt_service 同款写准入):发货与
|
||||||
|
回款并发写同一业务时相互串行化,防 ``_recalc_totals`` 重算互相覆盖
|
||||||
|
(丢失更新);锁序统一「业务行 → 单据编号区间」,与业务/回款同构
|
||||||
|
- 业务状态门槛:仅执行中(1)业务可写发货单 —— 终态业务的发货单禁改禁删
|
||||||
|
(一期保守口径:编辑不影响已核算回款、核算未发生,但会改业务级汇总
|
||||||
|
基线,设计未定项待业务确认后可放开终态编辑)
|
||||||
|
- 删除保护:被回款批次指定(receipts.shipment_id)的发货单禁删,先解除
|
||||||
|
回款的批次指定;删除连带明细行并重算业务级汇总
|
||||||
|
- 编号 SH 生成复用 no_generator.next_no;残余竞态以唯一键(1062)冲突
|
||||||
|
重试一次兜底,模式同 business_service.create_business
|
||||||
|
- 审计:锁行与编号生成放在进入审计前(不产生未提交变更),状态校验在
|
||||||
|
审计上下文内,失败也留痕 —— 失败回滚释放已取序号,当日序号空洞无害
|
||||||
|
(唯一性不依赖连续);ship_date 是回款天数基准日,编辑即时生效(M3 按
|
||||||
|
编辑后基准日实时派生,无固化快照可漂移)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
ACTION_DELETE,
|
||||||
|
ACTION_UPDATE,
|
||||||
|
MODULE_SHIPMENT,
|
||||||
|
NO_PREFIX_SHIPMENT,
|
||||||
|
)
|
||||||
|
from app.core.db_errors import is_duplicate_entry
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.core.response import PageResult
|
||||||
|
from app.models import Business, Shipment, ShipmentItem, User
|
||||||
|
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||||
|
from app.repositories import business_repository, shipment_repository
|
||||||
|
from app.schemas.shipment import (
|
||||||
|
ShipmentCreateIn,
|
||||||
|
ShipmentDetailOut,
|
||||||
|
ShipmentItemIn,
|
||||||
|
ShipmentItemOut,
|
||||||
|
ShipmentOut,
|
||||||
|
ShipmentQuery,
|
||||||
|
ShipmentUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import audit_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
from app.services.business_service import _recalc_totals
|
||||||
|
from app.services.no_generator import next_no
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY)
|
||||||
|
_NO_RETRY = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(row: tuple[Shipment, str]) -> ShipmentOut:
|
||||||
|
"""(shipment, business_no) → 响应模型;金额出参 str。"""
|
||||||
|
shipment, business_no = row
|
||||||
|
return ShipmentOut(
|
||||||
|
id=shipment.id,
|
||||||
|
shipment_no=shipment.shipment_no,
|
||||||
|
business_id=shipment.business_id,
|
||||||
|
business_no=business_no,
|
||||||
|
ship_date=shipment.ship_date,
|
||||||
|
freight_amount=str(shipment.freight_amount),
|
||||||
|
total_amount=str(shipment.total_amount),
|
||||||
|
remark=shipment.remark,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _item_to_out(item: ShipmentItem) -> ShipmentItemOut:
|
||||||
|
return ShipmentItemOut(
|
||||||
|
id=item.id,
|
||||||
|
product_id=item.product_id,
|
||||||
|
product_name=item.product_name,
|
||||||
|
spec_model=item.spec_model,
|
||||||
|
unit=item.unit,
|
||||||
|
quantity=str(item.quantity),
|
||||||
|
sale_price=str(item.sale_price) if item.sale_price is not None else None,
|
||||||
|
amount=str(item.amount),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _amount_or_zero(value: str | None) -> Decimal:
|
||||||
|
"""可空金额入参转 Decimal,空记 0(运费可缺省的统一归一)。"""
|
||||||
|
return Decimal(value) if value is not None else Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_item_rows(items: list[ShipmentItemIn]) -> list[ShipmentItem]:
|
||||||
|
"""入参明细行转 ORM 行:amount 服务层按 数量×单价 计算固化。
|
||||||
|
|
||||||
|
不信任前端传值(schema 亦无 amount 入参)——单价可空时行金额记 0,
|
||||||
|
由用户在编辑中补价后重算;shipment_id 由 replace_items 统一回填。
|
||||||
|
乘积显式 quantize 两位固化(模式同 business_service._build_item_rows):
|
||||||
|
数量(2位)×单价(4位) 可产 6 位小数,直写 DECIMAL(14,2) 会被 MySQL
|
||||||
|
静默舍入且回显漂移。
|
||||||
|
"""
|
||||||
|
rows: list[ShipmentItem] = []
|
||||||
|
for item in items:
|
||||||
|
quantity = Decimal(item.quantity)
|
||||||
|
sale_price = Decimal(item.sale_price) if item.sale_price is not None else None
|
||||||
|
amount = (
|
||||||
|
(quantity * sale_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
if sale_price is not None
|
||||||
|
else Decimal("0.00")
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
ShipmentItem(
|
||||||
|
product_id=item.product_id,
|
||||||
|
product_name=item.product_name,
|
||||||
|
spec_model=item.spec_model or "",
|
||||||
|
unit=item.unit,
|
||||||
|
quantity=quantity,
|
||||||
|
sale_price=sale_price,
|
||||||
|
amount=amount,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_no(session: Session) -> str:
|
||||||
|
"""生成发货单号(SH 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。"""
|
||||||
|
return next_no(session, model=Shipment, column_name="shipment_no", prefix=NO_PREFIX_SHIPMENT)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_business_writable(session: Session, business_id: int, action: str) -> Business:
|
||||||
|
"""锁业务行并要求状态=1 执行中(新增/编辑/删除共用的写准入)。
|
||||||
|
|
||||||
|
锁在判定之前取:判定结果基于锁定后的行,配合 _recalc_totals 的重算
|
||||||
|
同事务提交,并发写不会丢失更新。与 receipt_service._ensure_business_writable
|
||||||
|
同构 —— 文案单据名落到发货单(私有函数各自保留,不为一个字符串上提
|
||||||
|
公共层)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
:raises BadRequestError: 业务已完成或已终止
|
||||||
|
"""
|
||||||
|
business = business_repository.get_by_id(session, business_id, for_update=True)
|
||||||
|
if business is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError(f"业务已完成或已终止,禁止{action}")
|
||||||
|
return business
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||||
|
"""识别 MySQL 唯一键冲突(1062),与 business/receipt_service 同口径。"""
|
||||||
|
return is_duplicate_entry(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
|
||||||
|
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
|
||||||
|
rows = shipment_repository.list_shipments(
|
||||||
|
session,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
business_id=query.business_id,
|
||||||
|
ship_date_from=query.ship_date_from,
|
||||||
|
ship_date_to=query.ship_date_to,
|
||||||
|
)
|
||||||
|
total = shipment_repository.count_shipments(
|
||||||
|
session,
|
||||||
|
business_id=query.business_id,
|
||||||
|
ship_date_from=query.ship_date_from,
|
||||||
|
ship_date_to=query.ship_date_to,
|
||||||
|
)
|
||||||
|
return PageResult[ShipmentOut](
|
||||||
|
records=[_to_out(row) for row in rows],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_detail(session: Session, shipment_id: int) -> ShipmentDetailOut:
|
||||||
|
"""发货单详情(附明细行,供编辑抽屉回显)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 发货单不存在
|
||||||
|
"""
|
||||||
|
row = shipment_repository.get_with_business_no(session, shipment_id)
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("发货单不存在")
|
||||||
|
out = ShipmentDetailOut(**_to_out(row).model_dump())
|
||||||
|
out.items = [
|
||||||
|
_item_to_out(item) for item in shipment_repository.list_items(session, shipment_id)
|
||||||
|
]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def create_shipment(
|
||||||
|
session: Session,
|
||||||
|
payload: ShipmentCreateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ShipmentOut:
|
||||||
|
"""新增发货单:锁业务行 → 校验 → 建单 + 明细 + 双重汇总回写,一个事务。
|
||||||
|
|
||||||
|
摘要两号(发货单号/业务号)在进入审计前就绪:业务号取自锁行结果、
|
||||||
|
发货单号先行生成(SELECT MAX FOR UPDATE,不产生未提交变更);状态
|
||||||
|
校验在审计上下文内,失败也留痕 —— 失败路径回滚释放已取序号,当日
|
||||||
|
序号出现空洞无害(唯一性不依赖连续)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
:raises BadRequestError: 业务已完成或已终止,禁止新发货
|
||||||
|
"""
|
||||||
|
business = business_repository.get_by_id(session, payload.business_id, for_update=True)
|
||||||
|
shipment_no = _generate_no(session)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_SHIPMENT,
|
||||||
|
action=ACTION_CREATE,
|
||||||
|
summary=(
|
||||||
|
f"新增发货单 {shipment_no}"
|
||||||
|
f"(业务 {business.business_no if business else f'#{payload.business_id}'},"
|
||||||
|
f"明细 {len(payload.items)} 行)"
|
||||||
|
),
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 单号唯一键冲突的重试整体收在审计上下文内(模式同
|
||||||
|
# business_service.create_business):rollback 释放旧锁,重试前重新
|
||||||
|
# 锁业务行与生成编号——成功/最终失败各只记一条审计,无递归
|
||||||
|
for attempt in range(_NO_RETRY + 1):
|
||||||
|
try:
|
||||||
|
if business is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError("业务已完成或已终止,禁止新发货")
|
||||||
|
# 月结锁定拦截:发货日所在月已审定 → 源数据不可改(调整走调整单)
|
||||||
|
from app.services import settlement_service
|
||||||
|
|
||||||
|
settlement_service.ensure_month_not_locked(
|
||||||
|
session, payload.ship_date.strftime("%Y-%m")
|
||||||
|
)
|
||||||
|
|
||||||
|
shipment = Shipment(
|
||||||
|
shipment_no=shipment_no,
|
||||||
|
business_id=payload.business_id,
|
||||||
|
ship_date=payload.ship_date,
|
||||||
|
freight_amount=_amount_or_zero(payload.freight_amount),
|
||||||
|
total_amount=Decimal("0"),
|
||||||
|
remark=payload.remark,
|
||||||
|
)
|
||||||
|
shipment_repository.create(session, shipment)
|
||||||
|
shipment_repository.replace_items(
|
||||||
|
session, shipment.id, _build_item_rows(payload.items)
|
||||||
|
)
|
||||||
|
total = shipment_repository.sum_item_amount(session, shipment.id)
|
||||||
|
shipment.total_amount = total if total is not None else Decimal("0")
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
out = _to_out((shipment, business.business_no))
|
||||||
|
new_id, new_no = shipment.id, shipment_no # 固化:commit 后不碰 ORM
|
||||||
|
session.commit()
|
||||||
|
break
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
if _is_duplicate_entry(exc) and attempt < _NO_RETRY:
|
||||||
|
business = business_repository.get_by_id(
|
||||||
|
session, payload.business_id, for_update=True
|
||||||
|
)
|
||||||
|
shipment_no = _generate_no(session)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"新增发货单 | shipment_id=%s | no=%s | business_id=%s",
|
||||||
|
new_id,
|
||||||
|
new_no,
|
||||||
|
payload.business_id,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def update_shipment(
|
||||||
|
session: Session,
|
||||||
|
shipment_id: int,
|
||||||
|
payload: ShipmentUpdateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ShipmentOut:
|
||||||
|
"""编辑发货单(明细全量替换)+ 双重汇总回写,一个事务。
|
||||||
|
|
||||||
|
business_id 不在编辑入参内 —— 发货单挂靠的业务创建时定死,改挂会让
|
||||||
|
已指定本发货单的回款语义断裂。终态业务的发货单禁改(与回款写准入同
|
||||||
|
口径;一期保守处理,设计未定项待业务确认后可放开——发货编辑不影响
|
||||||
|
已核算回款,核算未发生,无固化快照可漂移)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 发货单不存在 / 业务不存在
|
||||||
|
:raises BadRequestError: 业务已完成或已终止
|
||||||
|
"""
|
||||||
|
shipment = shipment_repository.get_by_id(session, shipment_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_SHIPMENT,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"修改发货单 {shipment.shipment_no if shipment else f'#{shipment_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if shipment is None:
|
||||||
|
raise NotFoundError("发货单不存在")
|
||||||
|
business = _ensure_business_writable(session, shipment.business_id, "编辑发货单")
|
||||||
|
# 月结锁定拦截:原发货日与新月都不得落入已锁定月份(改日期=跨月搬数)
|
||||||
|
from app.services import settlement_service
|
||||||
|
|
||||||
|
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
|
||||||
|
settlement_service.ensure_month_not_locked(session, payload.ship_date.strftime("%Y-%m"))
|
||||||
|
|
||||||
|
shipment.ship_date = payload.ship_date
|
||||||
|
shipment.freight_amount = _amount_or_zero(payload.freight_amount)
|
||||||
|
shipment.remark = payload.remark
|
||||||
|
shipment_repository.replace_items(session, shipment_id, _build_item_rows(payload.items))
|
||||||
|
total = shipment_repository.sum_item_amount(session, shipment_id)
|
||||||
|
shipment.total_amount = total if total is not None else Decimal("0")
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
# business_id 创建时定死不可改挂,锁定的 business.business_no 与重查同源
|
||||||
|
out = _to_out((shipment, business.business_no))
|
||||||
|
shipment_no = shipment.shipment_no
|
||||||
|
session.commit()
|
||||||
|
# commit 后不再触碰 ORM 属性(audit_service 约定):单号已固化
|
||||||
|
logger.info("修改发货单 | shipment_id=%s | no=%s", shipment_id, shipment_no)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_shipment(
|
||||||
|
session: Session,
|
||||||
|
shipment_id: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""删除发货单(物理删除,连带明细行)+ 业务级汇总回写,一个事务。
|
||||||
|
|
||||||
|
被回款批次指定(receipts.shipment_id 引用)→ 400 引导先解除回款的
|
||||||
|
批次指定;业务须状态=1 执行中(终态业务的发货单禁删,与回款写准入
|
||||||
|
同口径)—— 发货单的运费/金额是业务级汇总与 M3 核算的源数据,终态后
|
||||||
|
不得变动。
|
||||||
|
|
||||||
|
:raises NotFoundError: 发货单不存在 / 业务不存在
|
||||||
|
:raises BadRequestError: 业务已完成或已终止 / 已被回款批次指定
|
||||||
|
"""
|
||||||
|
shipment = shipment_repository.get_by_id(session, shipment_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_SHIPMENT,
|
||||||
|
action=ACTION_DELETE,
|
||||||
|
summary=f"删除发货单 {shipment.shipment_no if shipment else f'#{shipment_id}'}",
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if shipment is None:
|
||||||
|
raise NotFoundError("发货单不存在")
|
||||||
|
business = _ensure_business_writable(session, shipment.business_id, "删除发货单")
|
||||||
|
# 月结锁定拦截:已锁定月份的发货单是月结快照的源数据,不得删除
|
||||||
|
from app.services import settlement_service
|
||||||
|
|
||||||
|
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
|
||||||
|
if shipment_repository.count_receipt_refs(session, shipment_id):
|
||||||
|
raise BadRequestError("已被回款批次指定,请先解除回款的批次指定")
|
||||||
|
|
||||||
|
shipment_no = shipment.shipment_no
|
||||||
|
shipment_repository.delete_with_items(session, shipment_id)
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
session.commit()
|
||||||
|
# commit 后不再触碰 ORM 属性(audit_service 约定):单号已固化
|
||||||
|
logger.info("删除发货单 | shipment_id=%s | no=%s", shipment_id, shipment_no)
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""系统设置(公司信息 + 站点信息)业务编排。
|
||||||
|
|
||||||
|
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo/favicon 只存**相对
|
||||||
|
路径**(uploads/logo、uploads/favicon 下固定名覆盖),文件不入库的理由:
|
||||||
|
图片进数据库会让每次读取与备份放大传输,且无法独立替换;DB 只持有指针。
|
||||||
|
|
||||||
|
图像安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
|
||||||
|
后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与
|
||||||
|
覆盖任意文件。
|
||||||
|
|
||||||
|
logo 与 favicon 相互独立(2026-10-20):
|
||||||
|
- logo 用于登录页品牌位;
|
||||||
|
- favicon 用于浏览器标签页图标,未上传时前端使用内置 favicon.svg
|
||||||
|
(不再跟随 logo——公司 logo 是横版,作标签页图标常被裁切)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.constants import ACTION_UPDATE, MODULE_SYSTEM_SETTINGS
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.models import SystemSetting, User
|
||||||
|
from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn
|
||||||
|
from app.services import audit_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# magic bytes:PNG 8 字节签名;JPEG 以 SOI 起始;ICO 以 00 00 01 00 起始
|
||||||
|
_MAGIC = {
|
||||||
|
"png": b"\x89PNG\r\n\x1a\n",
|
||||||
|
"jpg": b"\xff\xd8\xff",
|
||||||
|
"ico": b"\x00\x00\x01\x00",
|
||||||
|
}
|
||||||
|
# 允许的后缀集合:logo 沿用 png/jpg(页眉/登录页展示);favicon 额外接受 ico
|
||||||
|
LOGO_EXTS = ("png", "jpg")
|
||||||
|
FAVICON_EXTS = ("png", "jpg", "ico")
|
||||||
|
ASSET_MAX_BYTES = 2 * 1024 * 1024 # 2MB:展示尺寸上限,防上传滥用
|
||||||
|
|
||||||
|
SETTINGS_ID = 1 # 全系统单行主键
|
||||||
|
|
||||||
|
# 公司档案中的可选文本字段:提交空串等价清空(落库 null,前端回退内置默认)
|
||||||
|
_OPTIONAL_TEXT_FIELDS = ("site_title", "brand_slogan", "footer_text", "icp_no")
|
||||||
|
|
||||||
|
# 资源种类 → 中文名(错误提示与审计摘要用)
|
||||||
|
_ASSET_LABELS = {"logo": "logo", "favicon": "站点图标"}
|
||||||
|
_ASSET_EXTS = {"logo": LOGO_EXTS, "favicon": FAVICON_EXTS}
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting(session: Session) -> SystemSetting:
|
||||||
|
"""取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。"""
|
||||||
|
setting = session.get(SystemSetting, SETTINGS_ID)
|
||||||
|
if setting is None:
|
||||||
|
setting = SystemSetting(id=SETTINGS_ID, company_name="衍水集团")
|
||||||
|
session.add(setting)
|
||||||
|
session.flush()
|
||||||
|
return setting
|
||||||
|
|
||||||
|
|
||||||
|
def to_out(setting: SystemSetting) -> SettingOut:
|
||||||
|
"""管理端出参(图片地址带更新时间版本参数,替换后浏览器不再用旧缓存)。"""
|
||||||
|
return SettingOut(
|
||||||
|
company_name=setting.company_name,
|
||||||
|
logo_url=_asset_url(setting, "logo"),
|
||||||
|
site_title=setting.site_title,
|
||||||
|
brand_slogan=setting.brand_slogan,
|
||||||
|
footer_text=setting.footer_text,
|
||||||
|
icp_no=setting.icp_no,
|
||||||
|
favicon_url=_asset_url(setting, "favicon"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def to_public_out(setting: SystemSetting) -> SettingPublicOut:
|
||||||
|
"""匿名端点出参(白名单字段,独立 schema 见模块说明)。"""
|
||||||
|
return SettingPublicOut(
|
||||||
|
company_name=setting.company_name,
|
||||||
|
logo_url=_asset_url(setting, "logo"),
|
||||||
|
site_title=setting.site_title,
|
||||||
|
brand_slogan=setting.brand_slogan,
|
||||||
|
footer_text=setting.footer_text,
|
||||||
|
icp_no=setting.icp_no,
|
||||||
|
favicon_url=_asset_url(setting, "favicon"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_url(setting: SystemSetting, kind: str) -> str | None:
|
||||||
|
"""资源 URL(kind=logo/favicon);未上传返回 None。
|
||||||
|
|
||||||
|
版本号取**上传计数器**而非 update_time:同扩展名替换时固定文件名不变
|
||||||
|
(logo_path 值不变),MySQL 的 ON UPDATE 不会刷新 update_time,用它做
|
||||||
|
?v= 会导致浏览器一直用旧图(2026-10-20 用户反馈的换图不刷新问题)。
|
||||||
|
"""
|
||||||
|
if not getattr(setting, f"{kind}_path"):
|
||||||
|
return None
|
||||||
|
version = getattr(setting, f"{kind}_version") or 0
|
||||||
|
return f"/api/v1/system/settings/{kind}/public?v={version}"
|
||||||
|
|
||||||
|
|
||||||
|
def update_company(
|
||||||
|
session: Session,
|
||||||
|
payload: SettingUpdateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> SettingOut:
|
||||||
|
"""更新公司信息与站点信息(部分更新 + 审计留痕)。
|
||||||
|
|
||||||
|
仅提交的字段生效(``exclude_unset``:未提交/显式 null 保持原值),
|
||||||
|
便于「只改名称」「只改站点标题」等场景不覆盖其他字段;可选文本传
|
||||||
|
空串等价清空(落库 null)。
|
||||||
|
"""
|
||||||
|
changes = payload.model_dump(exclude_unset=True, exclude_none=True)
|
||||||
|
summary = "修改公司信息"
|
||||||
|
if "company_name" in changes:
|
||||||
|
summary = f"修改公司信息(公司名称「{changes['company_name']}」)"
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_SYSTEM_SETTINGS,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=summary,
|
||||||
|
request_data=changes or None,
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
setting = get_setting(session)
|
||||||
|
if "company_name" in changes:
|
||||||
|
# schema 已保证提交值非空(min_length=1)
|
||||||
|
setting.company_name = changes["company_name"]
|
||||||
|
for field in _OPTIONAL_TEXT_FIELDS:
|
||||||
|
if field in changes:
|
||||||
|
setattr(setting, field, changes[field].strip() or None)
|
||||||
|
session.commit()
|
||||||
|
return to_out(setting)
|
||||||
|
|
||||||
|
|
||||||
|
def save_logo(
|
||||||
|
session: Session,
|
||||||
|
content: bytes,
|
||||||
|
ext: str,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> SettingOut:
|
||||||
|
"""上传公司 logo(登录页品牌位;png/jpg ≤2MB)。"""
|
||||||
|
return _save_asset(session, content, ext, kind="logo", operator=operator, meta=meta)
|
||||||
|
|
||||||
|
|
||||||
|
def save_favicon(
|
||||||
|
session: Session,
|
||||||
|
content: bytes,
|
||||||
|
ext: str,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> SettingOut:
|
||||||
|
"""上传站点图标(浏览器标签页;png/jpg/ico ≤2MB)。"""
|
||||||
|
return _save_asset(session, content, ext, kind="favicon", operator=operator, meta=meta)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_asset(
|
||||||
|
session: Session,
|
||||||
|
content: bytes,
|
||||||
|
ext: str,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None,
|
||||||
|
) -> SettingOut:
|
||||||
|
"""校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
|
||||||
|
|
||||||
|
旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。
|
||||||
|
"""
|
||||||
|
label = _ASSET_LABELS[kind]
|
||||||
|
allowed = _ASSET_EXTS[kind]
|
||||||
|
ext = ext.lower()
|
||||||
|
_validate_upload(content, ext, allowed=allowed, label=label)
|
||||||
|
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_SYSTEM_SETTINGS,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"更新{label}",
|
||||||
|
request_data={"ext": ext, "size": len(content)},
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
setting = get_setting(session)
|
||||||
|
old_path = getattr(setting, f"{kind}_path")
|
||||||
|
|
||||||
|
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
|
||||||
|
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
|
||||||
|
# 由写入覆盖,无需也不会误删新文件
|
||||||
|
target = _asset_dir(kind) / f"{kind}.{ext}"
|
||||||
|
target.write_bytes(content)
|
||||||
|
|
||||||
|
setattr(setting, f"{kind}_path", f"{kind}/{kind}.{ext}")
|
||||||
|
# 版本号每次上传必增:即使同扩展名覆盖(路径不变)也能破浏览器缓存
|
||||||
|
setattr(setting, f"{kind}_version", (getattr(setting, f"{kind}_version") or 0) + 1)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
|
||||||
|
if old_ext and old_ext != ext and old_ext in allowed:
|
||||||
|
try:
|
||||||
|
_cleanup_old_asset(kind, old_ext)
|
||||||
|
except OSError:
|
||||||
|
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
|
||||||
|
# 成功的更新会被记成失败审计并 500 —— 审计与事实相反
|
||||||
|
logger.warning(
|
||||||
|
"旧%s清理失败(不影响本次更新)| ext=%s", label, old_ext, exc_info=True
|
||||||
|
)
|
||||||
|
logger.info("更新%s | size=%s ext=%s", label, len(content), ext)
|
||||||
|
return to_out(setting)
|
||||||
|
|
||||||
|
|
||||||
|
def read_logo(session: Session) -> tuple[Path, str]:
|
||||||
|
"""按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。"""
|
||||||
|
return _read_asset(session, "logo")
|
||||||
|
|
||||||
|
|
||||||
|
def read_favicon(session: Session) -> tuple[Path, str]:
|
||||||
|
"""按库中记录定位站点图标文件,返回 (路径, 扩展名)。未上传抛 404。"""
|
||||||
|
return _read_asset(session, "favicon")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_asset(session: Session, kind: str) -> tuple[Path, str]:
|
||||||
|
label = _ASSET_LABELS[kind]
|
||||||
|
setting = get_setting(session)
|
||||||
|
path_str = getattr(setting, f"{kind}_path")
|
||||||
|
if not path_str:
|
||||||
|
raise NotFoundError(f"尚未上传{label}")
|
||||||
|
path = get_settings().upload_dir / path_str
|
||||||
|
if not path.is_file():
|
||||||
|
raise NotFoundError(f"{label}文件不存在,请重新上传")
|
||||||
|
return path, path.suffix.lstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_upload(content: bytes, ext: str, *, allowed: tuple[str, ...], label: str) -> None:
|
||||||
|
"""后缀白名单 + magic bytes + 大小三重校验(同口径供 logo/favicon 复用)。"""
|
||||||
|
if ext not in allowed:
|
||||||
|
raise BadRequestError(f"{label}仅支持 {'/'.join(allowed)} 格式")
|
||||||
|
if len(content) > ASSET_MAX_BYTES:
|
||||||
|
raise BadRequestError(f"{label}文件不能超过 2MB")
|
||||||
|
if not content.startswith(_MAGIC[ext]):
|
||||||
|
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_dir(kind: str) -> Path:
|
||||||
|
directory = get_settings().upload_dir / kind
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
return directory
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_old_asset(kind: str, old_ext: str) -> None:
|
||||||
|
if old_ext in _ASSET_EXTS[kind]:
|
||||||
|
old_file = _asset_dir(kind) / f"{kind}.{old_ext}"
|
||||||
|
if old_file.exists():
|
||||||
|
old_file.unlink(missing_ok=True)
|
||||||
@@ -35,6 +35,7 @@ dependencies = [
|
|||||||
"captcha>=0.6",
|
"captcha>=0.6",
|
||||||
"pillow>=11.0",
|
"pillow>=11.0",
|
||||||
"openpyxl>=3.1.5",
|
"openpyxl>=3.1.5",
|
||||||
|
"python-multipart>=0.0.32",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
+132
-5
@@ -1,6 +1,6 @@
|
|||||||
# 后端测试
|
# 后端测试
|
||||||
|
|
||||||
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 301 个用例全绿(单元 223 / 集成 78)。
|
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 697 个用例全绿(2026-10-16,M3 增量批次后)。
|
||||||
|
|
||||||
## 一、测试约定
|
## 一、测试约定
|
||||||
|
|
||||||
@@ -92,6 +92,73 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
|
|
||||||
运行:`uv run pytest -q` → **406 passed**(2026-10-13 全量含回归;T5 冒烟另验证提交/生效/撤回/对比/导出/预览全链路)。
|
运行:`uv run pytest -q` → **406 passed**(2026-10-13 全量含回归;T5 冒烟另验证提交/生效/撤回/对比/导出/预览全链路)。
|
||||||
|
|
||||||
|
### 基础数据管理(2026-10-13 完成)
|
||||||
|
|
||||||
|
| 文件 | 守什么 | 用例数 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
|
||||||
|
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 |
|
||||||
|
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕 | 14 |
|
||||||
|
|
||||||
|
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。
|
||||||
|
|
||||||
|
### 业务数据管理(2026-10-13 完成)
|
||||||
|
|
||||||
|
| 文件 | 守什么 | 用例数 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tests/integration/test_businesses.py` | 未登录 401;编号 `YW{YYYYMMDD}-{4位}` 与当日序号递增;空明细/数量 0/非法类别/单价非数字 400;**销售额服务层固化**(schema 无 sale_amount 入参,塞了忽略);特殊业务必填说明;编辑全量替换后**三处冗余一致**(明细 SUM == 业务冗余列 == DB SUM);状态机(终止无原因 400 → 留痕 old=1/new=3 → 终态再变更 400);归属变更主表+留痕同生(old/new owner 与 assistant、owner_type=2)、归属未变化 400、创建时归属人员已离职 400;有发货/回款禁换客户禁删;空业务删除连带清理明细/状态留痕/业务归属留痕;毛利润视图金标准(销售 10000/成本 30 → 率 997/1000、市政 30% → 折算 6970;**销售额 0 全 0 防除零**;**成本缺失行**派生为 null 不计类别汇总;**asOf 按业务日期回溯旧阈值**);审计成功/失败双记录 | 25 |
|
||||||
|
| `tests/integration/test_shipments.py` | 未登录 401;编号 `SH{YYYYMMDD}-{4位}`;业务不存在 404;终态业务禁新发货 400;空明细/数量 0/运费负数/运费三位小数 400;**行金额与发货单总额服务层固化**;业务 total_ship/total_freight 回写一致;编辑后两处汇总同步;被回款指定禁删(解除后可删);删除后业务级汇总回退;终态业务发货单禁改禁删;审计双记录 | 15 |
|
||||||
|
| `tests/integration/test_receipts.py` | 未登录 401;批次 `HK{YYYYMMDD}-{4位}`;未指定批次可登记且 payment_days 为 null;**指定批次派生「发货日首日计第 1 天」**(2026-10-05 → 2026-11-02 = 29 天,编辑清指定后回 null);业务不存在 404 / 终态禁新增 400;金额负数/三位小数 400;**跨业务指定批次 400**(「发货批次不属于该业务,无法指定」,编辑路同校验);合计回写与删除回退;**已核算(改库 settlement_status=1)禁改禁删**;settlement_status 筛选 0/1 两路;审计双记录 | 16 |
|
||||||
|
| `tests/integration/test_ratings.py` | 未登录 401;种子四等级全量按 sort_order 升序(A 90-100/B 75-89/C 60-74/D 0-59);区间倒挂/超上限/负下限 → 400「分值区间不合法」;名称唯一 → 400「该等级名称已存在」;**新增与编辑两路重叠 400 点名冲突等级**;**边界相邻不算重叠正例**(删 C 后新增 60-74 / 把 D 编辑为 0-74,74 与 75 相邻放行);编辑改名改区间回显;删除后判定表更新、二次删除 404;审计双记录。种子删改由 conftest 兜底恢复 | 14 |
|
||||||
|
|
||||||
|
运行:`uv run pytest tests/integration/test_{businesses,shipments,receipts,ratings}.py -q` → **70 passed**(2026-10-13,连跑两次幂等);全量回归 `uv run pytest -q` → **603 passed**(单元 381 / 集成 222)。
|
||||||
|
|
||||||
|
### M3-core ④ 核算引擎 + 月度结算(2026-10-16 完成)
|
||||||
|
|
||||||
|
| 文件 | 守什么 | 用例数 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tests/unit/test_formula.py`(扩) | R 钳制(向下取整/[0,100] 封顶封底/销售额 0 防御)、月工资三口径(INCLUDE 加/DEDUCT 减可负/EXCLUDE 仅绩效/基本缺失按 0/未知口径报错)、试用期年累计说明(未超无 note/超额精确差额)、经理 12.5%+5% 聚合(#12 暂口径) | +12 |
|
||||||
|
| `tests/integration/test_calc.py` | **金标准双类别复算到分**(市政 4000 + 轨交 0 = 折算 4000;88/9000 约分原文落 rate_text;绩效 39.11;R=100 应付 39.11,DB 锚定);**业务级一次性计提 #25**(同业务第二笔回款 repeated=True 仅登记进度不新增行);已核算再核算 400;未指定批次 400;**成本缺失行毛利按 0 计**(折算保守 −3000、missing_cost=1);负绩效照算不归零(折算 −2000);准入(normal 403/marketing_ops 可用);列表筛选;重算(finance 授权、v2 生效 v1 保留、reason 留痕、原因缺失 400、未核算重算 400、越权 403);批量逐笔成败清单 | 13 |
|
||||||
|
| `tests/integration/test_settlements.py` | 生成按人汇总+台账回填 settlement_id;无核算记录 400;**已审定月份重生成 400**;开关关闭提交即自动通过锁定(AP 单号/locked_at 回填);**锁定后回款禁改 400**(M2 已核算禁改防线先于锁定拦截);开关开启两节点流转(finance→leader)至锁定;#12 暂口径聚合不实现(items 按人汇总不聚合验证) | 6 |
|
||||||
|
|
||||||
|
运行:`uv run pytest tests/integration/test_{calc,settlements}.py tests/unit/test_formula.py -q` → **32 passed**(2026-10-16);全量回归见运行记录。
|
||||||
|
|
||||||
|
### M3 增量批次(2026-10-16 完成:公司信息 / 二次分配录入 / 审批框架)
|
||||||
|
|
||||||
|
| 文件 | 守什么 | 用例数 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tests/integration/test_settings.py` | 公司信息单行语义(PUT 只改名称不增行);**public 端点无令牌 200 且字段白名单仅 2 项**(复用 admin schema 即泄漏面);名称空 400;非 admin 403;logo 三重校验(伪造扩展名被 magic bytes 拦 / gif 拒 / 超 2MB 拒);合法 png 上传回读 content-type;匿名上传 401;修改留审计日志 | 11 |
|
||||||
|
| `tests/unit/test_secondary_allocation_schema.py` | 比例边界矩阵(0 拒 / 0.01-100 过 / 100.01 拒 / 负数拒 / **三位小数拒绝不静默舍入** / 非数字拒);人员 ID 非正拒;**创建/编辑两入参的参与人去重**(uk 前置友好提示);不同参与人合法 | 13 |
|
||||||
|
| `tests/integration/test_secondary_allocation.py` | 带方案创建详情回显(**personnel_name 冗余固化**:离职后回显有名);不建方案详情空且无 hint;合计满额 hint 只报合计无「余额」噪音;**离职人员参与 400**「分摊参与人员不存在或已离职」;终态业务禁改方案 400;编辑全量替换与空数组清空;**被方案引用人员禁删(RESTRICT→400)且方案回显有名**;删除业务后方案 CASCADE 随删、人员可删 | 9 |
|
||||||
|
| `tests/integration/test_approval_flow.py` | **开关关闭(种子默认 0):提单即两级「自动通过」留痕**(status=3、steps 两条 action=3、operator 记「系统」——留痕不断链);方案为空不提单;开关开启两节点流转(1→财务通过→2→领导通过→3);财务驳回整体 status=4 且意见留痕;驳回缺意见 400;**节点准入:领导/admin 不可代审财务节点(403)**;终态单重复操作 400;再编辑撤旧建新(旧单 status=5 + node=0/action=4 撤回留痕);模块准入(normal/marketing_ops 403,finance/leader 200)。开关切换经参数变更批次端到端,不直改库;用后 conftest 恢复种子基线 | 11 |
|
||||||
|
| `tests/unit/test_param_registry.py`(改) | 注册表 16→**18 项**(V6 首例 BOOL 两个审批开关);**BOOL 校验边界专项**(0/1 过、true/2 拒) | +1 |
|
||||||
|
| `tests/integration/test_schema_sync.py`(改) | 角色种子断言 2→**5 角色**(marketing_ops/finance/leader 入列) | 参数化 |
|
||||||
|
|
||||||
|
运行:`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)。
|
||||||
|
|
||||||
## 三、运行记录
|
## 三、运行记录
|
||||||
|
|
||||||
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
||||||
@@ -101,11 +168,39 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 |
|
| 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 |
|
||||||
| 2026-10-10 | 全部 | 282 | ✅ 通过(103.81s) | **系统管理模块(用户/角色/日志)完成**后统一编写该模块套件(新增 91 例:单元 48 / 集成 43)。含操作日志审计机制专项;**e2e 发现并修复 `replace_roles` 唯一键缺陷**(见四、缺陷表);集成用例需容器在跑 |
|
| 2026-10-10 | 全部 | 282 | ✅ 通过(103.81s) | **系统管理模块(用户/角色/日志)完成**后统一编写该模块套件(新增 91 例:单元 48 / 集成 43)。含操作日志审计机制专项;**e2e 发现并修复 `replace_roles` 唯一键缺陷**(见四、缺陷表);集成用例需容器在跑 |
|
||||||
| 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) |
|
| 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) |
|
||||||
|
| 2026-10-13 | 基础数据集成三文件 | 48 | ✅ 通过(27.60s) | **基础数据管理模块完成**后统一编写(人员 20/产品 14/客户 14)。审计上下文内外的校验位置差异为本次新发现的缺陷类别(见下) |
|
||||||
|
| 2026-10-13 | 业务数据集成四文件 | 70 | ✅ 通过(54.73s,连跑两次幂等) | **业务数据管理(后端接口)完成**后统一编写(业务 25/发货 15/回款 16/评级 14)。全量回归 603 例通过 |
|
||||||
|
| 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 |
|
||||||
|
| 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 |
|
||||||
|
| 2026-10-15 | 全量回归 + e2e | 639 | ✅ 全部通过 | **code-review 32 条修复后全量回归**(新增 34 例:跨位宽 3 / 参数预览与维度 3 / 审计重试 2 / 终态编辑与 quantize 2 / 产品保护 2+6 / 评级锁 1 / formula 钳制 1 等)。e2e 冒烟:登录 8 项 + 系统管理 13 项 + 基础数据 13 步 + 业务全链路 16 步全过(拦截器/正则收口/params 分页改造后复跑) |
|
||||||
|
| 2026-10-17 | M3-core 定点 | 32 | ✅ 通过 | **④ 核算引擎+月结完成**(calc 13 / settlements 6 / formula 扩 12 / schemas 等)。金标准复算到分;重算版本链;月结锁定与审批联动 |
|
||||||
|
| 2026-10-17 | 全量回归 | 701+N | ✅ 全部通过 | **M3-core 收尾回归**(T1-T6:V7 四表+核算+月结+调整单;前端台账/月结两页 order17/18)。e2e 补 calc_settlement_flow 待下批 |
|
||||||
|
| 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 | 全量回归 | 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-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 |
|
||||||
|
| 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
|
||||||
|
|
||||||
## 四、测试发现并修复的缺陷
|
## 四、测试发现并修复的缺陷
|
||||||
|
|
||||||
| 日期 | 缺陷 | 影响 | 处理 |
|
| 日期 | 缺陷 | 影响 | 处理 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
| 2026-10-19 | 部门汇总导出/打印只展平成员行 | 团队分成、全公司 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-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")` |
|
||||||
@@ -120,14 +215,46 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 2026-10-10 | `.gitignore` 的 `logs/` 规则吞掉日志查询页目录 | 规则无前导斜杠、匹配任意层级:`frontend/src/views/system/logs/` 整体被忽略,文件**进不了版本库**——干净克隆构建失败(本地因文件已在磁盘上而一切"正常",隐蔽性极强) | 收窄为 `/backend/logs/`(该规则本意即后端运行日志);以 `git check-ignore` 实证不再命中 |
|
| 2026-10-10 | `.gitignore` 的 `logs/` 规则吞掉日志查询页目录 | 规则无前导斜杠、匹配任意层级:`frontend/src/views/system/logs/` 整体被忽略,文件**进不了版本库**——干净克隆构建失败(本地因文件已在磁盘上而一切"正常",隐蔽性极强) | 收窄为 `/backend/logs/`(该规则本意即后端运行日志);以 `git check-ignore` 实证不再命中 |
|
||||||
| 2026-10-10 | 时间筛选未做时区归一 | 混用 aware/naive 抛 TypeError 穿透成 **500**;带时区输入被驱动静默丢弃偏移(查询窗口偏 8 小时且无提示) | `LogQueryBase` 加 field_validator:aware → `astimezone()` 本地 naive、naive 原样;补 4 例专项 |
|
| 2026-10-10 | 时间筛选未做时区归一 | 混用 aware/naive 抛 TypeError 穿透成 **500**;带时区输入被驱动静默丢弃偏移(查询窗口偏 8 小时且无提示) | `LogQueryBase` 加 field_validator:aware → `astimezone()` 本地 naive、naive 原样;补 4 例专项 |
|
||||||
| 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 |
|
| 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 |
|
||||||
|
| 2026-10-13 | 基础数据三 service 的**前置校验在审计上下文之外** | 校验失败(岗位停用/经理自引用/分类不合法等 400)完全不留操作日志,违背「成功与失败都记」的既定原则——与 2026-10-10 审计时序缺陷同源,新写的 service 又犯了(各自独立实现时口径漂移) | personnel/product/customer 三 service 的值域与引用校验统一移入 `audit_service.audit` 上下文内;集成用例锁定(审计成功与失败双记录) |
|
||||||
|
| 2026-10-13 | `change_status` 状态留痕的 old_status 取到**赋值后**的 ORM 属性 | 时间线记成 `old==new`(如 1→3 的变更记成 3→3)——「变更前状态」无法回溯,状态流转追溯失效。由集成用例断言 `(old,new)==(1,3)` 抓住 | 赋值前固化 `old_status = business.status` 再流转;应用日志一并修正;回归用例锁定 |
|
||||||
|
| 2026-10-13 | **客户/人员删除保护漏检业务引用**(T4 占位注释「待业务表就位后补充」在 T7 交付后无人补齐) | 被业务引用的客户、被业务归属(负责/协助)引用的人员删除 → FK RESTRICT 穿透成 **500**。**由 e2e 清理步骤暴露**(集成测试只覆盖了 T2/T4 当时的引用面,业务表就位后引用面扩大未回看) | customer 删除加 `count_business_refs` 检查 + IntegrityError 兜底 400;personnel 删除加 `count_business_roles_refs`(负责/协助合并统计);补 2 个集成用例锁定;文案更新「客户归属或业务归属引用」 |
|
||||||
|
| 2026-10-13 | **单价列(DECIMAL(14,4))被两位小数校验拒收**(前后端同源) | 编辑任何带精度的历史单价(回显 "5000.0000")被前端 `AMOUNT_PATTERN` 与后端 `_check_amount` 双双拒绝——**创建后单价带小数位的记录永远无法编辑**。**由 e2e「编辑业务」步骤暴露**(创建用整数、编辑回显带 4 位小数,恰好只在这条路径相遇) | 后端 `_check_amount` 加 `scale` 参数(金额/数量 2 位、单价类 4 位);前端业务/发货明细行单价类放行 4 位;集成用例锁定 |
|
||||||
|
| 2026-10-13 | 发货 `freight_amount` 漏做金额校验 | 负数/三位小数/超容量的运费直接落库(schema 对 quantity/sale_price 都复用了 `_check_amount`,唯独运费没套用),业务运费合计与报表随之错乱 | shipments.py schema 的 Create/Update 加 `field_validator("freight_amount")`(同套口径,文案「运费金额不能为负数 / 最多两位小数」);集成用例锁定 |
|
||||||
|
| 2026-10-15 | **计算预览把 ‰ 存储值当纯比例相乘**(企业户/信息员分支) | `enterprise.rate`=3(3‰)、`info.rate`=1(1‰)未经 ÷1000 直接乘——绩效金额放大 1000 倍(15000×3=45000)。**既有测试只覆盖业务员路径**(`salesperson_rate` 内部有 ÷1000),两分支零覆盖使缺陷存活;该预览是 M3 核算共用契约 | 两分支显式 `/1000` 并注释声明换算;补企业户/信息员精确数值用例锁定 |
|
||||||
|
| 2026-10-15 | **编号撞唯一键的重试写在 audit 上下文内 `return _retry_create`**(业务/发货/回款三处同构) | 吞异常后正常退出被记 `success=True`(假成功审计,业务实际已回滚);重入整个 `create_*` 违反「不支持嵌套」约定;`_NO_RETRY=1` 死常量致持续冲突时**无界递归**至 RecursionError | 改为 audit 上下文内 for 循环(上限 `_NO_RETRY`),rollback 后重新锁行与取号;新增集成用例锁定「成功/最终失败各恰好一条日志」 |
|
||||||
|
| 2026-10-15 | **单号 MAX 检索用 VARCHAR 字典序**(`no_generator`) | 序号破 4 位宽度(9999→10000)后字典序反转("-10000"<"-9999"),MAX 恒取旧值 → seq 恒重算同一号码 → 当日该前缀所有建单持续撞唯一键 | 排序改「长度优先、同级字典序」(同前缀下长度大即数值大);跨位宽/同宽/空表三用例锁定;param 版本号一并复用 |
|
||||||
|
| 2026-10-15 | **编辑业务全程无锁读 + 无终态门槛** | ① 「有发货禁换客户」走无锁快照读,与发货/回款写路径(FOR UPDATE)不互斥——并发新增发货可旁路保护;② 已完成/已终止业务仍可全量改明细并重算 M3 核算基线(发货/回款服务禁写终态,父单据反而放行) | `get_with_names` 加 `for_update`;update 补终态门槛;前端编辑按钮终态禁用;`has_docs` 单点收口(update/delete 共用) |
|
||||||
|
| 2026-10-15 | **删除产品无引用保护**(docstring「待 V5 补充」在 V5 就位后未兑现) | 被业务/发货明细引用的产品删除 → FK RESTRICT 穿透成 500(与 2026-10-13 客户/人员删除保护漏检同源——「占位注释交付后无人回看」第二次发生) | `count_refs`(两明细表)+ IntegrityError 兜底 400;**全局注册 IntegrityError handler** 兜底未来遗漏(漏网的引用冲突收敛 400 而非 500) |
|
||||||
|
| 2026-10-15 | **产品 schema 私写校验不查小数位与容量** | `sale_price` 用 `_parse_non_negative`(非负/有限),5 位小数被 Numeric(14,4) **静默截断**(舍入即算错钱)、超容量 500——与 fba8cfc 修掉的「schema 小数位口径」同源残留 | 复用 `_check_amount`(PRICE_MAX+scale=4 / AMOUNT_MAX);6 个秒级 schema 单测锁定 |
|
||||||
|
| 2026-10-15 | 行金额乘积(数量2位×单价4位=6位小数)直写 DECIMAL(14,2) | MySQL 静默舍入且回显漂移:创建响应读内存值 "5.166615"、复查读库值 "5.17"——同一行金额前后显示不一致 | 显式 `quantize(HALF_UP)` 固化(与 MySQL 舍入方向一致);回显/库值一致性用例锁定 |
|
||||||
|
| 2026-10-15 | 先款后货(payment_days 为负)计提比例线性外插 | 比例 = 起始 + \|天数\|×斜率,**高于起始值甚至超 100%**——M3 接入真实回款后直接多算绩效工资(预览被 schema 拦住、回款登记不校验日期先后,缺陷在 M3 前不可见) | `salesperson_rate` 钳制 days≥0(按起始比例计提;payment_days 出参仍照实输出负值,口径经用户 2026-10-15 裁决) |
|
||||||
|
| 2026-10-15 | 评级区间重叠先查后写无锁、无唯一键兜底 | 两管理员并发提交重叠区间互判不重叠而双双入库——同一总分命中多等级且结果取决于插入顺序 | 判重叠的 `list_all` 加 FOR UPDATE 锁全表行串行化;只读直出不加锁 |
|
||||||
|
| 2026-10-15 | 岗位维度值大小写校验(ci)与快照匹配(精确)不对称 | API 提交 "SalesPerson" 校验通过入库,快照字典按精确键取值不命中——**静默按错误参数算钱且无报错**;param_code 变形大写则 get_spec 抛 ValueError 成 500 | 校验命中后一律以库里规范值落库(role_code/definition.param_code);批量预载字典键 casefold 归一保持等价命中 |
|
||||||
|
| 2026-10-16 | **logo 上传同扩展名覆盖时先写后删** | `save_logo` 在写入新文件**之后**清理旧扩展名——同扩展名重传时把刚落盘的新文件一并删掉,回读 404(首次单跑通过、集成连跑即暴露,依赖「库里已有同扩展名旧记录」的前置状态) | 清理旧扩展名移到写新文件**之前**且仅异扩展名执行;集成连跑两遍幂等锁定 |
|
||||||
|
| 2026-10-16 | **public 端点 300s 响应缓存让登录页拿旧公司名** | 公司名保存后退出登录,登录页命中浏览器缓存副本仍显示旧品牌名(设置页「保存后生效」承诺失效)——**由 settings_flow e2e 首跑暴露**(新标签页直接打开正常、退出回跳的登录页异常) | 登录页改用带时间戳的原生 `fetch` + `cache: no-store` 绕过磁盘缓存(axios 对响应 max-age 无能为力);手动解包统一信封 |
|
||||||
|
| 2026-10-16 | V6 迁移 downgrade 保留 autogenerate 的逐个 `drop_index` 且漏删行级种子 | 外键唯一支撑索引单独删除报 MySQL 1553 回滚中断;roles/param_definitions/param_values 的行级种子(表由 V1/V3 建)drop_table 不会带走——重放 upgrade 时唯一键冲突 | downgrade 只保留 `drop_table`(V1/V3 先例)+ 三条 DELETE 反向清理行级种子;downgrade→upgrade 往返实测通过 |
|
||||||
|
| 2026-10-19 | **经理团队聚合分成放大 100 倍**(settlement_service) | 参数按百分数存储(12.5=12.5%),`parse_fraction` 后调用方又 ×100、`manager_team_share` 内部再 ÷100——净效应「绩效×12.5」;单元测试直传 Fraction 未覆盖调用方换算使缺陷存活 | 去掉调用方 ×100;`rate_text` 改用实际参数原文(原硬编码默认值失真);集成用例两档金标准(6.84/9.78 vs 错误值 684.43)锁定 |
|
||||||
|
| 2026-10-19 | **审批联动不判终审**(approval_service.act) | 财务节点通过(1→2)即 mark_locked 月结/调整单置生效——领导驳回后 locked_at/locked_by 残留、两级审批形同虚设(锁定后源数据写路径立即被拦) | 生效类联动收敛到 `final_approved`(终态才触发);驳回任意节点即时;mark_rejected 补清锁定字段;用例断言财务通过后仍 status=2 且无锁定信息 |
|
||||||
|
| 2026-10-19 | **调整单创建完全不可用**(两处叠加) | ① `ENABLE_CODE_BY_TYPE` 漏登记 type=3 → is_enabled 恒 400「未知的审批对象类型」;② 修复①后暴露:自动生效分支手工插 `ApprovalStep(order_id=调整单id)`,而该外键指向 approval_orders,必然 errno 1452 | 补登记 `approval.adjustment.enabled`;自动通过留痕统一交 `submit_order`(不再手工插步);新建调整单集成用例锁定 |
|
||||||
|
| 2026-10-19 | **重算绕过业务级一次性计提防线** | 「进度登记」回款(#25 第二笔及以后)无独立台账行,重算会凭空落一行生效行——同业务两行生效行、绩效按两倍计入工资 | 无旧版本行一律 400「进度登记无可重算」;用例锁定「重算后同业务仍只有一行生效行」 |
|
||||||
|
| 2026-10-19 | **前端重算传错 ID**(台账页) | 把台账行主键当 receipt_id 传给重算接口——会作废并重算**无关回款**的台账(若目标回款已核算),或报错无法重算目标行 | 改传 `row.receipt_id`(聚合行 receipt_id 空值防御);`LedgerRow` 本含该字段 |
|
||||||
|
| 2026-10-19 | **quantize 缺省银行家舍入**(9 处金额固化) | docstring 声称 HALF_UP,实际未传 rounding 走 ROUND_HALF_EVEN——x.xx5 舍成偶数尾数,与业务四舍五入口径差 1 分(台账固化金额比应发少 1 分) | 9 处显式 `rounding=ROUND_HALF_UP`;`_q2` 提为模块级并单测锁定(2.345→2.35) |
|
||||||
|
| 2026-10-19 | **月结锁定拦截只覆盖 1/4 写路径** | 仅新增回款被拦;回款编辑(可跨月搬数)/删除、发货增/改/删、核算与重算全部放行——锁定月份快照与源数据脱钩且无留痕 | 五处写路径补齐 `ensure_month_not_locked`(编辑按原月+新月双查);拦截先于「被引用/已核算」判定;三用例锁定 |
|
||||||
|
| 2026-10-19 | **台账 settlement_id 回填从未发生**(死代码) | `update_ledger_settlement_id` 全库零调用——「未入结」判据恒真,重生成会把已固化行反复纳入(或补上回填却不先解绑导致漏算整月) | generate 回填 + 重生成前解绑(新增 `clear_ledger_settlement_id`);用例断言回填与重生成金额不翻倍 |
|
||||||
|
| 2026-10-19 | **经理聚合行从不落库** | `_build_manager_aggregates` 只构造内存对象参与 items 汇总——台账表永远无 calc_type=2 行,报表下钻(business_id 锚点)落空 | 聚合行经 repository 落库并回填 settlement_id;重生成先作废旧聚合行(`deactivate_month_aggregates`);`_confirm_scope` 限定 calc_type=1 防聚合行混入单笔池 |
|
||||||
|
| 2026-10-19 | **月结取参用生成日**(date.today) | 补生成历史月份时经理分成/月工资口径/试用期阈值按生成日参数计算——同一月份两次生成金额不同,违背「参数按生效日期时点化」红线 | 新增 `_month_end`(所属月月末)为聚合与月工资口径取参锚点;单测 5 例 + 集成用例(10-25 预约变更影响 10 月聚合) |
|
||||||
|
| 2026-10-19 | **迁移双轨三处漂移** | ① V6 就地修改加入 uk 致旧库补不上(V8 也未补)→ V9 条件式补齐(幂等裸 SQL,临时库验证两分支);② V7 留存 SQL 约束/索引名单数(`fk_performance_ledger_*`)与 alembic op.f/init-dev 漂移;③ V8 迁移 revision 缺类型注解,`test_schema_sync` 的 head 推断**静默漏检 V8**、init-dev 版本号落后未被发现 | V9 迁移 + V7 SQL 名修正 + V8 注解修正 + init-dev 版本推进;schema 同步测试恢复全量断言 |
|
||||||
|
| 2026-10-19 | 运维脚本两处(restore_drill/health_check) | ① 行数核对把查询失败回显 `'?'` 当数据比较(`'?'=='?'` 判 ✓)且 TABLES 缺 V7 四表等 16 张——备份漏表时演练全绿误报「可恢复」;② `redis-cli -a "$REDIS_PASSWORD"` 密码进宿主进程命令行 | TABLES 补全 32 张业务表 + `'?'` 一律判 ✗;Redis 探活改容器内环境变量展开(compose 补 REDIS_PASSWORD),实测 PONG |
|
||||||
|
| 2026-10-19 | 调整单金额无校验 / INT 参数原文 `int()` / logo 清理裸抛 | ① amount 负数反转补发-扣减语义、'abc' 直落库 500;② `int('360.0')` 使整月核算 500(INT 参数合法入库原文);③ 旧 logo 清理 OSError 裸抛把成功更新记成失败审计并 500 | schema 正则 + 服务层 Decimal>0;`int(parse_fraction(...))`;清理包 try 仅 warning;各补回归用例 |
|
||||||
|
|
||||||
## 五、待补用例
|
## 五、待补用例
|
||||||
|
|
||||||
| 模块 | 状态 | 说明 |
|
| 模块 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 基础数据管理 | 未开始 | 需求已澄清(2026-10-12);依赖 T+ 对接方式确认 |
|
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
||||||
| 业务数据管理 | 未开始 | 需求已澄清(28 项裁决见 `docs/问题与解决方案对照表_v2.xlsx`) |
|
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
||||||
| 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) |
|
| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 |
|
||||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
| 绩效工资核算(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 完成并补齐用例,见上表。
|
||||||
Loaded 100 of 238 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user