Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b074576fe | ||
|
|
013d6e7a74 | ||
|
|
0ca1734523 | ||
|
|
b0c599e513 | ||
|
|
2fda2d118b | ||
|
|
37eb51513f | ||
|
|
ee801652e3 | ||
|
|
8a171a572c | ||
|
|
651341b36c | ||
|
|
0d63359acb | ||
|
|
ea41dadb64 | ||
|
|
8242aae454 | ||
|
|
87a0884953 | ||
|
|
ea989ce9ad | ||
|
|
4fe5916c2c | ||
|
|
78bef43c06 | ||
|
|
823d9f02ec | ||
|
|
678db0499d | ||
|
|
a00fca942d | ||
|
|
61965e0b60 | ||
|
|
111ac5425a | ||
|
|
61fe5cca98 | ||
|
|
32b7940067 | ||
|
|
b9781f7d3f | ||
|
|
8e8bcaa5fb | ||
|
|
1f65913002 | ||
|
|
e61800dcdc | ||
|
|
f88857e026 | ||
|
|
5ece9f6dab | ||
|
|
c48d24ab6d | ||
|
|
d0726487e9 | ||
|
|
a84fe9b316 | ||
|
|
94e2957359 | ||
|
|
ebc9595de2 | ||
|
|
9964281c14 | ||
|
|
694d21d1fd | ||
|
|
4c32a556de | ||
|
|
4c10475228 | ||
|
|
2e0c64849b | ||
|
|
33b9b00bb0 | ||
|
|
d03ddadd1a | ||
|
|
c319a6a428 | ||
|
|
36114745db | ||
|
|
b86eaf2b00 | ||
|
|
71df9a16ed | ||
|
|
980628bb8f | ||
|
|
c8131588bf | ||
|
|
b6d84033ce | ||
|
|
698df6d31a | ||
|
|
39a3fa7796 | ||
|
|
ebb3a92f25 | ||
|
|
c91d1db78a | ||
|
|
294d99a0d3 | ||
|
|
6ff202d637 | ||
|
|
4e54d09ccb | ||
|
|
b7cfdfa50a | ||
|
|
04a1a0d307 | ||
|
|
2a1939d2e7 | ||
|
|
cfd2a196f2 | ||
|
|
cc4c51359c | ||
|
|
036834a1d0 | ||
|
|
8903a194c9 | ||
|
|
a2bd3960e1 | ||
|
|
fb398c3835 | ||
|
|
c5f219423b | ||
|
|
a536608b53 | ||
|
|
ccfd20651b | ||
|
|
48c0a86279 | ||
|
|
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 |
No files matched your search
+20
-3
@@ -1,5 +1,5 @@
|
||||
# AI辅助目录(整个目录忽略)
|
||||
ai-aux/
|
||||
.ai/
|
||||
|
||||
# 依赖
|
||||
node_modules/
|
||||
@@ -45,8 +45,9 @@ htmlcov/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.tox/
|
||||
# 端到端测试产出的截图
|
||||
frontend/e2e/*.png
|
||||
# 端到端测试产出的截图(脚本以相对路径 e2e/failure.png 落盘,在不同 CWD
|
||||
# 下会落到 frontend/e2e/ 或 frontend/e2e/e2e/,用 ** 一并覆盖)
|
||||
frontend/e2e/**/*.png
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
@@ -59,3 +60,19 @@ frontend/e2e/*.png
|
||||
backup/
|
||||
.claude
|
||||
CLAUDE.md
|
||||
|
||||
# 运行期上传物(公司 logo 等):与构建产物隔离,不入版本库
|
||||
backend/uploads/
|
||||
# 数据库备份导出目录(⑨ 备份脚本产物)
|
||||
db/backups/
|
||||
# 客户发来的原始账套备份压缩包。
|
||||
/db/*.zip
|
||||
|
||||
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
|
||||
docs/**/*.rar
|
||||
docs/T_12.3数据字典/
|
||||
# macOS 压缩包残留元数据
|
||||
__MACOSX/
|
||||
|
||||
# AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库
|
||||
.tmpdocx/
|
||||
+20
-3
@@ -26,6 +26,23 @@ LOGIN_FAIL_LIMIT=5
|
||||
LOGIN_LOCK_MINUTES=15
|
||||
|
||||
# ===== T+ 对接(待畅捷通确认后填写)=====
|
||||
T_PLUS_BASE_URL=
|
||||
T_PLUS_APP_KEY=
|
||||
T_PLUS_APP_SECRET=
|
||||
T_PLUS_ENABLED=false
|
||||
T_PLUS_HOST=127.0.0.1
|
||||
T_PLUS_PORT=1433
|
||||
T_PLUS_DATABASE=
|
||||
T_PLUS_USER=
|
||||
T_PLUS_PASSWORD=
|
||||
# 可显式指定项目外的只读连接 JSON;不从页面接收该路径。
|
||||
T_PLUS_CONNECTION_FILE=
|
||||
# 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。
|
||||
# 本地 PoC 可复用已安装的 SQL Server 驱动;不自动安装或升级客户驱动。
|
||||
T_PLUS_ODBC_DRIVER=
|
||||
# 默认启用传输加密。旧实例 TLS 不兼容时,仅隔离开发环境可显式设为 false,
|
||||
# 记录原因并重新验收;程序不会自动降级。真实环境值后续按验收结果填写。
|
||||
T_PLUS_ENCRYPT=true
|
||||
# 内网自签证书可暂用 true;它不校验证书身份。部署可信证书后改为 false。
|
||||
T_PLUS_TRUST_SERVER_CERTIFICATE=true
|
||||
T_PLUS_CONNECT_TIMEOUT=10
|
||||
T_PLUS_QUERY_TIMEOUT=120
|
||||
# 可选:留空时使用仓库根目录下的 db/queries/tplus/
|
||||
T_PLUS_QUERY_DIR=
|
||||
+93
-1
@@ -83,8 +83,9 @@ app/
|
||||
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
|
||||
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
|
||||
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
|
||||
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
|
||||
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选;`order_by=rating_score` 按评分排序,未评分垫底)/ 新增(分类值域;公司名+负责人唯一) |
|
||||
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
|
||||
| PUT | `/api/v1/base/customers/{id}/rating` | 保存评级(评级计算器「保存评分」;只写 `rating_score`/`rating_time`,五项明细不落库 #25) |
|
||||
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
|
||||
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
|
||||
|
||||
@@ -114,6 +115,44 @@ app/
|
||||
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
|
||||
若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
|
||||
|
||||
### 报表统计接口(M4 ⑤,限管理员/财务/领导)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/reports/personal-detail` | 个人绩效工资明细表(FR-029:人员/月份/客户类型/产品类别/岗位筛选;响应带 `summary` 全筛选合计) |
|
||||
| GET | `/api/v1/reports/department-summary` | 部门绩效工资汇总表(FR-030:月份必填;团队基数与月结聚合行同源;12.5% 与 5% 分列;含成员内联明细、未分配桶、月结冻结对照额) |
|
||||
| GET | `/api/v1/reports/business-stats` | 业务统计表(FR-031:发货/回款/绩效为业务累计值,不随业务日期筛选变化) |
|
||||
| GET | `/api/v1/reports/payment-stats` | 回款情况统计表(FR-031:8 档天数区间恒在,空桶为 0) |
|
||||
| GET | `/api/v1/reports/payment-detail` | 回款区间下钻(`bucket` 必填,未注册编码 400) |
|
||||
| GET | `/api/v1/reports/payment-day-buckets` | 区间档位元数据(前端下拉唯一来源,禁止前端硬编码) |
|
||||
| GET | `/api/v1/reports/{code}/export` | Excel 导出(文件流非统一信封;含公司页眉/财务复核栏/领导签字栏;超 2 万行 400;行为入操作日志) |
|
||||
|
||||
准入 `require_roles(admin, finance, leader)`(FR-030/031;FR-029 的「营销后勤授权范围内」
|
||||
待业务权限清单 #6)。前端菜单同层过滤(路由 `allowedRoles`)。
|
||||
|
||||
### 工作台接口(M4 ⑧,限管理员/财务/领导)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/dashboard?month=YYYY-MM` | 四区块一次性返回:经营绩效总览(当月绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步降级)、风险预警(回款逾期/审批超时 + 同步降级)、最近动态(操作日志) |
|
||||
|
||||
待办与预警**实时派生**(不落表、不引调度器);预警阈值走参数体系
|
||||
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
|
||||
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
|
||||
|
||||
### T+ 本地只读同步与管理
|
||||
|
||||
T+ 数据库是 SQL Server 只读源库,业务库仍为 MySQL。Docker 恢复 PoC、备份检查和 D 盘目录约定见 `docs/T+本地恢复与同步开发手册.md`。
|
||||
|
||||
- 连接配置在 `backend/.env` 的 `T_PLUS_*` 变量中,默认关闭,不配置时不影响主业务启动。
|
||||
- `uv run python -m app.cli tplus-stage --entity personnel` 按主键分页,将源行写入 `tplus_sync_records` staging,并记录批次和失败信息。水位表已预留,时间增量未启用,`--watermark` 会明确拒绝。
|
||||
- T+ 驱动通过 `uv sync --extra tplus` 安装到应用环境,ODBC 驱动名称必须显式配置;交付兼容目标为 SQL Server 2008 R2,2022 副本不能代替同版本验收。
|
||||
- 查询目录 `db/queries/tplus/` 随部署交付,或使用 `T_PLUS_QUERY_DIR` 指定受控位置;支持六类主表和三类明细。
|
||||
- T+ 字典中的候选字段在实际备份核验和客户确认前,不得直接覆盖岗位、工资标准、归属、业务状态或核算状态。
|
||||
- 管理 API `/api/v1/system/tplus` 全部限管理员:概况、批次/异常分页、按需报告、创建与失败重试。HTTP 只创建 QUEUED 任务;`uv run python -m app.cli tplus-worker --watch --connection-file <项目外JSON路径>` 独立处理。
|
||||
- 核心文件:`services/tplus/task_service.py`(幂等/重试)、`worker.py`(执行)、`relationship_audit.py`(字典与关联)、`query_service.py`(安全输出)、`core/tplus_lock.py`(跨提交执行锁)。原 CLI 读取与 worker 共用执行锁。
|
||||
- V15 / Alembic `e5f607a8b9c0` 增加范围、跳过数、请求键、父批次、请求人和核验结果。未启用时间增量与工资业务导入;完整运行步骤和异常处理见本地恢复手册。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
@@ -196,6 +235,59 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
|
||||
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
|
||||
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
|
||||
|
||||
### 审批快照与方案版本化(2026-10-20 定稿,改动前先读)
|
||||
|
||||
- **审批单带版本号与内容快照**:`approval_orders.version_no` 按 `(order_type, ref_id)` 从 1 递增,重提先撤回旧在途单、但**旧单快照不覆盖**;`snapshot_json` 统一信封 `{schema_version, order_type, captured_at, data}`,`data` 由 `approval_service.{allocation,settlement,adjustment}_content` 组装(Decimal 转 str、日期转 ISO),开关关闭的自动通过路径同样写快照。审批期间业务再改,复核者看到的仍是送审版本。
|
||||
- **两个 `version_no` 语义不同,别混用**:`business_secondary_allocations.version_no` = 方案内容第几版,**仅方案内容(参与人+比例)变化时 +1**;`approval_orders.version_no` = 同一对象第几次送审,**仅送审内容变化时 +1**。改备注不动金额 → 两者都不动;改金额 → 只有审批单版本号动;改方案比例 → 两者都动。
|
||||
- **送审内容未变不重提**:编辑业务时若**存在在途审批单**且其快照 `data` 与本次送审内容逐键相等(`approval_service.pending_matches_snapshot`,只比 `data` 不比 `captured_at`),则保留原单不撤回不重建;否则撤旧提新。方案内容未变时**不新增方案版本行**(`business_service._plan_changed` 按「人员+比例」集合比较,与行序无关)。
|
||||
- **快照的覆盖范围是「方案 + 钱 + 归属」**(业务编号/日期、客户、负责与协助人、销售额、成本额、方案参与人与比例、合计与余额),**不含产品明细行**。因此只改产品构成而金额不变时不会重提——分摊依据(谁分、分多少、基于多少钱)未变;产品明细由复核者按业务编号回业务页查看。
|
||||
- **列表不加载快照列**:快照可达上百 KB,审批单列表、工作台待办、工作台审批超时预警三处查询一律 `defer(ApprovalOrder.snapshot_json)`(与操作日志列表排除 `request_data` 同一理由,参考 `operation_log_repository.list_logs`);详情走 `get_order` 按需读取。
|
||||
- **详情内容来源三态**:`content_source=snapshot`(送审快照)/ `live`(V11 前的历史单体无快照,只读回退当前内容并显式告警)/ `missing`(引用对象不存在且无快照)。**禁止回填历史单快照**——回填等于伪造提交时内容。
|
||||
- **二次分配方案 append-only**:`business_secondary_allocations` 行不物理删除,编辑全量替换与驳回清空只把旧行 `is_active=0`;唯一键为 `(business_id, version_no, personnel_id)`,同一版本内参与人唯一、跨版本可重复。`business_repository.list_allocations(active_only=True)` 是核算与详情回显的唯一取数口径;业务详情 `allocation_history` 返回全部版本供回溯。
|
||||
- **月结提交要锁行**:`settlement_service.submit` 取月结行必须 `for_update=True`——并发提交会让两次 `submit_order` 算出同一个审批单 `version_no`,送审次序失真。
|
||||
- **审批单 `version_no` 有唯一键兜底(V12)**:`uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`——同一对象的第 N 次送审在库里有唯一一行。ORM 侧约束名必须与迁移 DDL 严格一致,否则 autogenerate 误判。
|
||||
- **取版本号必须是「当前读」(`approval_service._next_version`)**,两个禁忌:(a) 不能用普通 `MAX` 查询——请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻,父行锁只串行化写入时刻、**不刷新快照**,MAX 会读到旧值;(b) 不能写成 `select(func.max(...)).with_for_update()`——MySQL 官方明确「Locking clauses are not permitted in queries with aggregate functions or GROUP BY」,聚合上的 FOR UPDATE 语义无保证。正解:锁读该 ref 的现有版本行(`select(version_no)...with_for_update()`),最大值 Python 侧算(同 `no_generator` 套路)。
|
||||
- **版本号冲突的收敛口径**:`submit_order` 内不 catch、不 rollback(红线)。create 路径由外层编号重试循环整体重来(`business_service._NO_RETRY`);编辑/月结提交路径由审计上下文统一回滚,再把 1062 翻译为 400「提交冲突(版本号生成重复),请重试」(同 `param_service` 版本号冲突模板)。唯一键是最后一道防线,不是取号正确性的前提。
|
||||
- **迁移前先修存量**:V11 把存量行 `version_no` 一律置 1,而 V11 前每次重提都新建行——同一 `(order_type, ref_id)` 存在多行 version_no=1,直接加唯一键必报 1062。V12 先重排「组内有重复」的分组(按 id 升序 1..n),其余分组不动(全量重排会改掉前端已展示过的历史版本号)。
|
||||
- **新增审批对象类型**须同时提供快照组装函数并接入 `_content_of` 的 live 回退分支,不要在各业务服务内联 JSON 结构。
|
||||
|
||||
### 工作台口径(M4 ⑧,改动前先读)
|
||||
|
||||
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
|
||||
- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定,**已终止业务(status=3)排除**;回款未指定批次 = `receipts.shipment_id IS NULL`。
|
||||
- **待办按角色过滤**:`pending_statuses_for_roles`(approval_service)把当前用户角色映射到在途节点——财务只看待财务复核(status=1)、领导只看待领导审核(status=2)、admin 可看全部;多角色取并集。风险预警仍是全局经营视角,不按人裁剪。
|
||||
- **预警展示上限**:`items` 最多 20 条,`total` 是全部命中数(前端徽标用它,不能用 `items.length`)。
|
||||
- **趋势窗口**:回款趋势先按 `receipt_date` 范围过滤再按 `DATE_FORMAT` 分组,函数列不参与 WHERE,保证索引可用。
|
||||
- **同步降级**:⑥ 未建时 `sync_available=false`,前端显示「模块未启用」——不要返回 0 条冒充「无失败」。
|
||||
- **参数快照一次解析**:`warning.*` 三个阈值在每次请求只 `resolve_snapshot` 一次,多条规则复用。
|
||||
- **导出 SQL 的编码坑**:Windows 下 `alembic upgrade --sql > V*.sql` 默认按控制台 GBK 输出,`test_schema_sync` 按 UTF-8 读取会报 `UnicodeDecodeError`——必须 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1` 前缀。
|
||||
|
||||
### 报表口径(M4 ⑤,改动前先读)
|
||||
|
||||
- **数据源固定单笔生效行**:四张报表的明细一律 `performance_ledgers.is_active=1 AND calc_type=1`;经理聚合行(`calc_type=2`)**只在部门汇总作冻结对照**(`frozen_amount`)单读。聚合行 `receipt_id` 为 NULL、`payment_days` 为 NULL,混入明细会让合同编号/天数列失真,业务统计的绩效也会被整团队聚合额污染。
|
||||
- **调整单并入**:仅 `status=2`(生效),按 `(settlement_month, personnel_id)` 并入,**不要求该人当月有台账行**(只有调整单也要出零绩效成员行);`direction=1` 补发记 +、`2` 扣减记 −(金额恒正)。待审批/驳回/撤回的金额绝不能进报表——`report_repository.list_month_adjustments` 与调整单管理页的 `list_adjustments` 是两个口径。调整单归属跟随该人普通行的实时 `manager_id`;只有经理个人户的经理跟随本人团队。
|
||||
- **回款金额按回款单去重**:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 `DISTINCT receipt_id` 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。
|
||||
- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块)。
|
||||
- **全公司 5% 的发放口径**:月结只为有台账 bucket 的营销经理各生成一条聚合行(12.5%+5%),**每位经理各计一次 5%**;零成员团队无聚合行、不发 5%。报表的 `aggregate_amount` 优先取冻结聚合行、未生成月结时才用 `manager_team_share` 一次舍入预览;`team_subtotal`/`net_total` = 聚合额 + 调整净额。`team_share_total` 与 `company_share_total` 仅分列展示,两者之和与聚合额的差记入 `aggregate_rounding_diff`(分列各自 q2 会有 0.01~0.02 尾差)。
|
||||
- **对拍边界**:报表应发 = 月结 `total_payable` + 调整单净额(调整单不进月结 items);筛选 `manager_id` 时只汇总展示团队,隐藏金额进 `other_*`,不能与全公司月结总额对拍。
|
||||
- **人员归属按行类型**:普通台账行跟随 `manager_id`;经理个人户行在 `manager.personal.included=1` 时归经理本人团队,且经理成员行**只取个人户金额**(其普通行业绩留在上级团队或未分配),保证团队基数 = 成员绩效合计。岗位判定范围必须覆盖所有成员引用的上级,否则其他经理的下属会被误判为「无经理」。按 `manager_id` 筛选时 `unassigned` 为空(语义是「仅看该经理团队」)。
|
||||
- **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。
|
||||
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。部门汇总是**单 sheet 分块签字稿**(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);第二块展示单经理 5%、计入次数与合计,对账块展示团队分成/全公司分成(分列)、聚合行舍入差、调整净额,应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。
|
||||
- **导出审计**:`audit` 上下文覆盖取数与构建全过程,超限 400 和取数异常都会写失败操作日志(`success=0`);成功摘要为静态报表标题。
|
||||
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
|
||||
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
|
||||
|
||||
### 面向业务人员的文案(2026-10-23 定稿,改文案前先读)
|
||||
|
||||
系统由财务与营销人员日常使用,**不是给开发者看的**。凡是会渲染到页面的文本(参数说明、错误提示、口径注释)一律按业务语言写:
|
||||
|
||||
- **`param_registry.py` 的 `description` 是唯一显示源**,会直出到「计提参数」页参数名下方。写清「这个参数管什么、钱怎么变、什么时候该调它」,不写英文枚举名(`FULL`/`BALANCE`)、需求编号(`澄清 #10`)、内部阶段号(`M3`/`M4 ⑧`)、实现层精度约束(「禁止按 0.011111 截断存储」留在代码注释与测试里)。
|
||||
- **枚举值在页面上必须翻译**:前端 `params/index.vue` 的 `ENUM_VALUE_LABELS`(`FULL` 当月全额 / `BALANCE` 余额 / `INCLUDE` 含基本工资 / `DEDUCT` 扣除基本工资 / `EXCLUDE` 不计基本工资 / `1` 开 / `0` 关)与 `DIMENSION_LABELS`(`municipal_water` 市政水利 / `rail_transit` 轨道交通)。后端报错也不许把原始枚举值甩给用户 —— `param_service._preview_rate` 的产品类别校验用 `「市政水利、轨道交通」` 而不是 `municipal_water / rail_transit`。
|
||||
- **公式模板报错用中文模板名**:`calc_templates.TEMPLATES[code].name`,取不到才回退 code;文案要给出下一步(「请在岗位角色里改用业务员递减或信息员固定计提」),而不是只说「不支持」。
|
||||
- **`db/init/init-dev.sql` 的种子 description 必须与 registry 同源**:它只在数据卷为空时首次启动执行,已有库要跑 `ai-aux/preview/sync_param_desc.py`(读 `PARAM_REGISTRY` 逐条 UPDATE)才看得到新文案。`db/migration/V*.sql` 里的旧描述是历史记录,**不要回头改** —— 改了会让已部署库与脚本不一致。
|
||||
- **前端模板层禁止出现开发者语汇**:`澄清 / 待业务 / M3 / M4 / 一期 / 二阶段 / 暂口径 / 待确认 / 待接 / 暂不 / 待补` 只允许出现在代码注释里。改完用 grep 扫一遍 `frontend/src/views`。
|
||||
- **台账与预览里落库的说明文字(`rate_text` / `rate_note` / 聚合行 `snapshot_json.note`)同样受管**:这些会出现在绩效台账、个人明细、打印稿与计算预览上。写成「这笔为什么是这个比例、什么时候不再降」(如「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」),不写 `基数开关=1`、`#12 参数化`、`9.8 方案公式` 这类需求编号。**分数原文(`11/1125`)与百分比拼装(`聚合:20%+5%`)不算说明文字**,那是金额口径的可复核原文,不要为了「口语化」把它改成四舍五入的小数。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
|
||||
|
||||
@@ -0,0 +1,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,71 @@
|
||||
"""v12 审批单版本号唯一键
|
||||
|
||||
Revision ID: 7f4f7b918bc4
|
||||
Revises: f3a8c2d61b47
|
||||
Create Date: 2026-10-21 10:15:00.000000
|
||||
|
||||
给 ``approval_orders`` 加 ``(order_type, ref_id, version_no)`` 唯一键,兜住
|
||||
「同一引用对象的第几次送审」在并发下算重。
|
||||
|
||||
**加键前必须先重排存量**:V11 迁移用 ``ADD COLUMN version_no INT NOT NULL
|
||||
DEFAULT 1`` 把存量行一律置 1,而 V11 之前的每次重提都是**新建行**(撤回
|
||||
只改旧单 status,行不删)。于是任何有 V11 前审批历史的库里,同一
|
||||
``(order_type, ref_id)`` 存在多行且 ``version_no`` 全为 1——直接
|
||||
``ADD CONSTRAINT`` 必报 1062,迁移会停在半途。
|
||||
|
||||
重排只针对「组内有重复」的分组(``COUNT(*) > COUNT(DISTINCT version_no)``),
|
||||
按 id 升序重排为 1..n;其余分组一个字节都不动——全量重排会把前端已展示
|
||||
过的历史版本号(``V{{ version_no }}``)改掉,属于用户可见的历史数据篡改。
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "7f4f7b918bc4"
|
||||
down_revision: str | Sequence[str] | None = "f3a8c2d61b47"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_UK = "uk_approval_orders_order_type_ref_version"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""先重排存量重复的版本号,再加唯一键。"""
|
||||
# 重排:只动「组内有重复版本号」的分组,按 id 升序编 1..n。
|
||||
# 用派生表内联而非临时表——同一张临时表被 JOIN 两侧引用会报 1137
|
||||
# "Can't reopen table"。
|
||||
op.execute(
|
||||
"UPDATE approval_orders ao "
|
||||
"JOIN (SELECT id, ROW_NUMBER() OVER ("
|
||||
" PARTITION BY order_type, ref_id ORDER BY id) AS rn "
|
||||
" FROM approval_orders "
|
||||
" WHERE (order_type, ref_id) IN ("
|
||||
" SELECT order_type, ref_id FROM approval_orders "
|
||||
" GROUP BY order_type, ref_id "
|
||||
" HAVING COUNT(*) > COUNT(DISTINCT version_no))) t "
|
||||
"ON ao.id = t.id "
|
||||
"SET ao.version_no = t.rn"
|
||||
)
|
||||
# 幂等 guard:MySQL DDL 隐式提交,若加键成功而版本号未落盘,
|
||||
# 重跑会报 1091(键已存在)。写法沿用 V9 先例。
|
||||
op.execute(
|
||||
"SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
|
||||
"WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'approval_orders' "
|
||||
f"AND CONSTRAINT_NAME = '{_UK}')"
|
||||
)
|
||||
op.execute(
|
||||
"SET @ddl := IF(@uk_exists = 0, "
|
||||
f"'ALTER TABLE approval_orders ADD CONSTRAINT {_UK} "
|
||||
"UNIQUE (order_type, ref_id, version_no)', 'DO 0')"
|
||||
)
|
||||
op.execute("PREPARE stmt FROM @ddl")
|
||||
op.execute("EXECUTE stmt")
|
||||
op.execute("DEALLOCATE PREPARE stmt")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删唯一键。唯一键本质是索引,DROP INDEX 不校验数据,存量是否已有
|
||||
重复 version_no 与删索引无关;重排结果也不回滚(版本号保持 1..n 连续)。"""
|
||||
op.execute(f"ALTER TABLE approval_orders DROP INDEX {_UK}")
|
||||
@@ -0,0 +1,144 @@
|
||||
"""v10 工作台预警规则参数
|
||||
|
||||
Revision ID: 90653814a421
|
||||
Revises: ba0217f5091b
|
||||
Create Date: 2026-10-19 17:55:48.892998
|
||||
|
||||
新增风险预警三条参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」):
|
||||
- warning.enabled:总开关(默认 1)
|
||||
- warning.receipt.overdue.days:发货后逾期天数(默认 90)
|
||||
- warning.approval.timeout.days:审批超时天数(默认 3)
|
||||
|
||||
「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import date
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "90653814a421"
|
||||
down_revision: str | Sequence[str] | None = "ba0217f5091b"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_EFFECTIVE_DATE = date(2026, 10, 19)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""种入三个预警参数定义与生效值(append-only 时点化)。"""
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"param_definitions",
|
||||
sa.column("id", sa.BigInteger),
|
||||
sa.column("param_code", sa.String),
|
||||
sa.column("param_name", sa.String),
|
||||
sa.column("param_group", sa.String),
|
||||
sa.column("value_type", sa.String),
|
||||
sa.column("unit", sa.String),
|
||||
sa.column("dimension_type", sa.String),
|
||||
sa.column("options_text", sa.String),
|
||||
sa.column("default_value", sa.String),
|
||||
sa.column("value_min", sa.String),
|
||||
sa.column("value_max", sa.String),
|
||||
sa.column("description", sa.String),
|
||||
sa.column("sort_order", sa.Integer),
|
||||
sa.column("status", sa.Integer),
|
||||
),
|
||||
[
|
||||
{
|
||||
"id": 21,
|
||||
"param_code": "warning.enabled",
|
||||
"param_name": "风险预警总开关",
|
||||
"param_group": "预警规则",
|
||||
"value_type": "BOOL",
|
||||
"unit": None,
|
||||
"dimension_type": "NONE",
|
||||
"options_text": None,
|
||||
"default_value": "1",
|
||||
"value_min": None,
|
||||
"value_max": None,
|
||||
"description": "工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
|
||||
"sort_order": 21,
|
||||
"status": 1,
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"param_code": "warning.receipt.overdue.days",
|
||||
"param_name": "回款逾期天数阈值",
|
||||
"param_group": "预警规则",
|
||||
"value_type": "INT",
|
||||
"unit": "天",
|
||||
"dimension_type": "NONE",
|
||||
"options_text": None,
|
||||
"default_value": "90",
|
||||
"value_min": "1",
|
||||
"value_max": None,
|
||||
"description": "发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)",
|
||||
"sort_order": 22,
|
||||
"status": 1,
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"param_code": "warning.approval.timeout.days",
|
||||
"param_name": "审批超时天数阈值",
|
||||
"param_group": "预警规则",
|
||||
"value_type": "INT",
|
||||
"unit": "天",
|
||||
"dimension_type": "NONE",
|
||||
"options_text": None,
|
||||
"default_value": "3",
|
||||
"value_min": "1",
|
||||
"value_max": None,
|
||||
"description": "待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)",
|
||||
"sort_order": 23,
|
||||
"status": 1,
|
||||
},
|
||||
],
|
||||
)
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"param_values",
|
||||
sa.column("id", sa.BigInteger),
|
||||
sa.column("param_id", sa.BigInteger),
|
||||
sa.column("dimension_value", sa.String),
|
||||
sa.column("effective_date", sa.Date),
|
||||
sa.column("value_text", sa.String),
|
||||
sa.column("batch_id", sa.BigInteger),
|
||||
),
|
||||
[
|
||||
{
|
||||
"id": 26,
|
||||
"param_id": 21,
|
||||
"dimension_value": "",
|
||||
"effective_date": _EFFECTIVE_DATE,
|
||||
"value_text": "1",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"param_id": 22,
|
||||
"dimension_value": "",
|
||||
"effective_date": _EFFECTIVE_DATE,
|
||||
"value_text": "90",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"param_id": 23,
|
||||
"dimension_value": "",
|
||||
"effective_date": _EFFECTIVE_DATE,
|
||||
"value_text": "3",
|
||||
"batch_id": 1,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚种子行(行级种子不会被 drop_table 带走,必须显式删除)。"""
|
||||
op.execute("DELETE FROM param_values WHERE id IN (26, 27, 28)")
|
||||
op.execute("DELETE FROM param_definitions WHERE id IN (21, 22, 23)")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""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,51 @@
|
||||
"""v13 客户评级落库
|
||||
|
||||
Revision ID: c3a9e1d7b2f4
|
||||
Revises: 7f4f7b918bc4
|
||||
Create Date: 2026-10-23 10:30:00.000000
|
||||
|
||||
评级计算器从独立工具页并入客户管理(行内弹窗),评分由「不落库」改为
|
||||
保存为客户档案属性:``rating_score`` 总分 + ``rating_time`` 评级时间。
|
||||
两列均可空(null = 未评分,历史客户与新客户无需回填),人工维护字段,
|
||||
T+ 同步守则不覆盖(见 TPlusSyncMixin 的同步守则说明)。
|
||||
|
||||
五项评分明细与命中等级都不落库:前者是打分过程的中间量,没有下游消费方;
|
||||
后者由分数按当前等级映射实时推导(等级映射是可变判定表,改了配置全部客户
|
||||
的等级随之更新),因此本迁移只落总分与评级时间两列。
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c3a9e1d7b2f4"
|
||||
down_revision: str | Sequence[str] | None = "7f4f7b918bc4"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""customers 加评级两列,紧跟 remark 之后保持与 ORM 声明同序。
|
||||
|
||||
用 execute 手写而非 op.add_column:后者不带 AFTER 子句,两列会落到
|
||||
表尾(T+ 预留列之后),与 ORM/init-dev 的声明顺序不一致。
|
||||
"""
|
||||
op.execute(
|
||||
"ALTER TABLE customers "
|
||||
"ADD COLUMN rating_score TINYINT COMMENT "
|
||||
"'评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' "
|
||||
"AFTER remark"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE customers "
|
||||
"ADD COLUMN rating_time DATETIME COMMENT "
|
||||
"'最近评级时间(评级计算器保存评分时刷新;人工维护字段)' "
|
||||
"AFTER rating_score"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除评级两列。评分数据随列删除,评级无下游引用、无悬空风险。"""
|
||||
op.drop_column("customers", "rating_time")
|
||||
op.drop_column("customers", "rating_score")
|
||||
@@ -0,0 +1,188 @@
|
||||
"""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,122 @@
|
||||
"""T+ 只读同步批次、错误、staging 和增量水位。
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: c3a9e1d7b2f4
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.mysql import BIGINT
|
||||
|
||||
revision: str = "d4e5f6a7b8c9"
|
||||
down_revision: str | Sequence[str] | None = "c3a9e1d7b2f4"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""增加 T+ 源更新时间和同步审计/staging 表。"""
|
||||
for table in ("personnel", "products", "customers", "businesses", "shipments", "receipts"):
|
||||
op.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN source_updated_at DATETIME "
|
||||
"COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at"
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
|
||||
sa.Column("sync_mode", sa.String(length=16), nullable=False, comment="同步模式:FULL全量 INCREMENTAL增量"),
|
||||
sa.Column("status", sa.String(length=16), nullable=False, comment="状态:RUNNING/SUCCEEDED/PARTIAL/FAILED"),
|
||||
sa.Column("source_database", sa.String(length=128), nullable=True, comment="T+ 源数据库名"),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=False, comment="开始读取时间"),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True, comment="结束时间"),
|
||||
sa.Column("watermark_from", sa.DateTime(), nullable=True, comment="增量起始水位"),
|
||||
sa.Column("watermark_to", sa.DateTime(), nullable=True, comment="本批次结束水位"),
|
||||
sa.Column("source_record_count", sa.Integer(), nullable=False, server_default="0", comment="源记录数"),
|
||||
sa.Column("staged_record_count", sa.Integer(), nullable=False, server_default="0", comment="写入 staging 的记录数"),
|
||||
sa.Column("failed_record_count", sa.Integer(), nullable=False, server_default="0", comment="失败记录数"),
|
||||
sa.Column("error_message", sa.Text(), nullable=True, comment="批次错误摘要"),
|
||||
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_batches"),
|
||||
sa.UniqueConstraint("batch_no", name="uk_tplus_sync_batches_batch_no"),
|
||||
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 同步批次表(记录读取范围、结果和对账统计)",
|
||||
)
|
||||
op.create_index("idx_tplus_sync_batches_status", "tplus_sync_batches", ["status"])
|
||||
op.create_index("idx_tplus_sync_batches_started_at", "tplus_sync_batches", ["started_at"])
|
||||
|
||||
op.create_table(
|
||||
"tplus_sync_errors",
|
||||
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
|
||||
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
|
||||
sa.Column("source_table", sa.String(length=128), nullable=False, comment="T+ 源表"),
|
||||
sa.Column("source_id", sa.String(length=64), nullable=True, comment="T+ 源主键"),
|
||||
sa.Column("source_line_no", sa.String(length=64), nullable=True, comment="T+ 明细行号或明细主键"),
|
||||
sa.Column("error_code", sa.String(length=32), nullable=False, comment="错误编码"),
|
||||
sa.Column("error_message", sa.Text(), nullable=False, comment="错误说明"),
|
||||
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0", comment="已重试次数"),
|
||||
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_errors"),
|
||||
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 同步错误表(记录失败行和重试信息)",
|
||||
)
|
||||
op.create_index("idx_tplus_sync_errors_batch_no", "tplus_sync_errors", ["batch_no"])
|
||||
op.create_index("idx_tplus_sync_errors_entity_type", "tplus_sync_errors", ["entity_type"])
|
||||
|
||||
op.create_table(
|
||||
"tplus_sync_records",
|
||||
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
|
||||
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
|
||||
sa.Column("source_table", sa.String(length=128), nullable=False, comment="T+ 源表"),
|
||||
sa.Column("source_id", sa.String(length=64), nullable=False, comment="T+ 源主键"),
|
||||
sa.Column("source_no", sa.String(length=64), nullable=True, comment="T+ 原始编号"),
|
||||
sa.Column("source_line_no", sa.String(length=64), nullable=True, comment="T+ 明细行号或明细主键"),
|
||||
sa.Column("source_updated_at", sa.DateTime(), nullable=True, comment="T+ 源数据更新时间"),
|
||||
sa.Column("row_hash", sa.String(length=64), nullable=False, comment="原始行 SHA-256"),
|
||||
sa.Column("state", sa.String(length=16), nullable=False, server_default="PENDING", comment="状态:PENDING/APPLIED/SKIPPED/ERROR"),
|
||||
sa.Column("payload_json", sa.JSON(), nullable=False, comment="原始行 JSON"),
|
||||
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_records"),
|
||||
sa.UniqueConstraint("entity_type", "source_id", "row_hash", name="uk_tplus_sync_records_entity_source_hash"),
|
||||
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 原始同步 staging 表(映射确认前只保存原始行,不直接改业务表)",
|
||||
)
|
||||
op.create_index("idx_tplus_sync_records_batch_no", "tplus_sync_records", ["batch_no"])
|
||||
op.create_index("idx_tplus_sync_records_source_no", "tplus_sync_records", ["source_no"])
|
||||
op.create_index("idx_tplus_sync_records_state", "tplus_sync_records", ["state"])
|
||||
|
||||
op.create_table(
|
||||
"tplus_sync_watermarks",
|
||||
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
|
||||
sa.Column("last_source_updated_at", sa.DateTime(), nullable=True, comment="最近源更新时间"),
|
||||
sa.Column("last_source_id", sa.String(length=64), nullable=True, comment="同一时间点的最后源主键"),
|
||||
sa.Column("status", sa.String(length=16), nullable=False, server_default="READY", comment="状态:READY/BLOCKED"),
|
||||
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_watermarks"),
|
||||
sa.UniqueConstraint("entity_type", name="uk_tplus_sync_watermarks_entity_type"),
|
||||
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 增量同步水位表",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除 T+ 同步支撑表和源更新时间列。"""
|
||||
op.drop_table("tplus_sync_watermarks")
|
||||
op.drop_index("idx_tplus_sync_records_state", table_name="tplus_sync_records")
|
||||
op.drop_index("idx_tplus_sync_records_source_no", table_name="tplus_sync_records")
|
||||
op.drop_index("idx_tplus_sync_records_batch_no", table_name="tplus_sync_records")
|
||||
op.drop_table("tplus_sync_records")
|
||||
op.drop_index("idx_tplus_sync_errors_entity_type", table_name="tplus_sync_errors")
|
||||
op.drop_index("idx_tplus_sync_errors_batch_no", table_name="tplus_sync_errors")
|
||||
op.drop_table("tplus_sync_errors")
|
||||
op.drop_index("idx_tplus_sync_batches_started_at", table_name="tplus_sync_batches")
|
||||
op.drop_index("idx_tplus_sync_batches_status", table_name="tplus_sync_batches")
|
||||
op.drop_table("tplus_sync_batches")
|
||||
for table in ("receipts", "shipments", "businesses", "customers", "products", "personnel"):
|
||||
op.drop_column(table, "source_updated_at")
|
||||
@@ -0,0 +1,118 @@
|
||||
"""V15 T+ 持久化任务、幂等和重试元数据。"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.mysql import BIGINT
|
||||
|
||||
revision: str = "e5f607a8b9c0"
|
||||
down_revision: str | Sequence[str] | None = "d4e5f6a7b8c9"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""仅扩展管理元数据,不改原始暂存行或工资业务表。"""
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("entity_types_json", sa.JSON(), comment="请求读取的实体类型;历史批次可为空"),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column(
|
||||
"skipped_record_count",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="重复快照跳过条数",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("parent_batch_no", sa.String(32), comment="重试来源批次号,保留原批次记录"),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("request_key", sa.String(36), comment="请求幂等键,空值用于历史和直接CLI任务"),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column(
|
||||
"requested_by", BIGINT(unsigned=True), comment="请求用户ID,CLI任务为空,无物理外键"
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("audit_result_json", sa.JSON(), comment="关联核验结果,只含系统字典和汇总"),
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"started_at",
|
||||
existing_type=sa.DateTime(),
|
||||
nullable=True,
|
||||
comment="开始执行时间(排队时为空)",
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"sync_mode",
|
||||
existing_type=sa.String(16),
|
||||
nullable=False,
|
||||
comment="任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留",
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"status",
|
||||
existing_type=sa.String(16),
|
||||
nullable=False,
|
||||
comment="状态:QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED",
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uk_tplus_sync_batches_request_key", "tplus_sync_batches", ["request_key"]
|
||||
)
|
||||
op.create_index(
|
||||
"idx_tplus_sync_batches_parent_status", "tplus_sync_batches", ["parent_batch_no", "status"]
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE tplus_sync_batches SET skipped_record_count=GREATEST(0, source_record_count-staged_record_count-failed_record_count) WHERE status='SUCCEEDED' AND sync_mode='FULL'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚前关闭 worker;原始暂存数据和历史批次仍保留。"""
|
||||
op.execute(
|
||||
"UPDATE tplus_sync_batches SET started_at=create_time, status='FAILED', finished_at=CURRENT_TIMESTAMP, error_message='任务管理版本已回滚' WHERE status='QUEUED'"
|
||||
)
|
||||
op.execute("UPDATE tplus_sync_batches SET started_at=create_time WHERE started_at IS NULL")
|
||||
op.drop_index("idx_tplus_sync_batches_parent_status", table_name="tplus_sync_batches")
|
||||
op.drop_constraint("uk_tplus_sync_batches_request_key", "tplus_sync_batches", type_="unique")
|
||||
for name in (
|
||||
"audit_result_json",
|
||||
"requested_by",
|
||||
"request_key",
|
||||
"parent_batch_no",
|
||||
"skipped_record_count",
|
||||
"entity_types_json",
|
||||
):
|
||||
op.drop_column("tplus_sync_batches", name)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"started_at",
|
||||
existing_type=sa.DateTime(),
|
||||
nullable=False,
|
||||
comment="开始读取时间",
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"sync_mode",
|
||||
existing_type=sa.String(16),
|
||||
nullable=False,
|
||||
comment="同步模式:FULL全量 INCREMENTAL增量",
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"status",
|
||||
existing_type=sa.String(16),
|
||||
nullable=False,
|
||||
comment="状态:RUNNING/SUCCEEDED/PARTIAL/FAILED",
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""v11 审批快照与方案版本化
|
||||
|
||||
Revision ID: f3a8c2d61b47
|
||||
Revises: 90653814a421
|
||||
Create Date: 2026-10-20 10:30:00.000000
|
||||
|
||||
补齐 code-review 遗留 2 项:
|
||||
|
||||
- 审批单加 ``version_no`` 与 ``snapshot_json``:重提产生新版本,送审内容
|
||||
在提交时固化,审批期间业务再改不影响复核依据;
|
||||
- 二次分配方案行改 append-only 版本化:加 ``version_no`` / ``is_active``,
|
||||
唯一键由 (business_id, personnel_id) 扩为
|
||||
(business_id, version_no, personnel_id),编辑替换与驳回清空只置历史、
|
||||
不再物理删除。
|
||||
|
||||
存量数据均落 ``version_no=1`` / ``is_active=1``:升级前的行就是当前生效
|
||||
版本,语义不变。审批单存量快照为空,详情接口按「当前内容回退 + 显式
|
||||
标注」处理,不做历史回填(回填会伪造提交时内容)。
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f3a8c2d61b47"
|
||||
down_revision: str | Sequence[str] | None = "90653814a421"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""加审批快照列/索引与方案版本列,替换唯一键。"""
|
||||
op.execute(
|
||||
"ALTER TABLE approval_orders "
|
||||
"ADD COLUMN version_no INT NOT NULL DEFAULT 1 "
|
||||
"COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' "
|
||||
"AFTER refer_no"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE approval_orders "
|
||||
"ADD COLUMN snapshot_json JSON NULL "
|
||||
"COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' "
|
||||
"AFTER version_no"
|
||||
)
|
||||
op.execute("CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)")
|
||||
|
||||
op.execute(
|
||||
"ALTER TABLE business_secondary_allocations "
|
||||
"ADD COLUMN version_no INT NOT NULL DEFAULT 1 "
|
||||
"COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' "
|
||||
"AFTER business_id"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE business_secondary_allocations "
|
||||
"ADD COLUMN is_active TINYINT NOT NULL DEFAULT 1 "
|
||||
"COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' "
|
||||
"AFTER ratio"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE business_secondary_allocations "
|
||||
"DROP INDEX uk_business_secondary_allocations_business_personnel, "
|
||||
"ADD CONSTRAINT uk_business_secondary_allocations_business_version_personnel "
|
||||
"UNIQUE (business_id, version_no, personnel_id)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX idx_business_secondary_allocations_business_active "
|
||||
"ON business_secondary_allocations (business_id, is_active)"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE business_secondary_allocations COMMENT = "
|
||||
"'业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回退结构(降级前须确认同业务同一参与人只存在于一个版本)。"""
|
||||
op.execute(
|
||||
"DROP INDEX idx_business_secondary_allocations_business_active "
|
||||
"ON business_secondary_allocations"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE business_secondary_allocations "
|
||||
"DROP INDEX uk_business_secondary_allocations_business_version_personnel, "
|
||||
"ADD CONSTRAINT uk_business_secondary_allocations_business_personnel "
|
||||
"UNIQUE (business_id, personnel_id)"
|
||||
)
|
||||
op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN is_active")
|
||||
op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN version_no")
|
||||
|
||||
op.execute("DROP INDEX idx_approval_orders_type_ref ON approval_orders")
|
||||
op.execute("ALTER TABLE approval_orders DROP COLUMN snapshot_json")
|
||||
op.execute("ALTER TABLE approval_orders DROP COLUMN version_no")
|
||||
@@ -113,3 +113,29 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
|
||||
|
||||
|
||||
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,6 +8,7 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api import health
|
||||
from app.api.v1 import auth
|
||||
from app.api.v1.base import business_roles as base_business_roles
|
||||
from app.api.v1.base import customers as base_customers
|
||||
from app.api.v1.base import personnel as base_personnel
|
||||
from app.api.v1.base import products as base_products
|
||||
@@ -15,10 +16,17 @@ 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 logs as system_logs
|
||||
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 settings as system_settings
|
||||
from app.api.v1.system import tplus as system_tplus
|
||||
from app.api.v1.system import users as system_users
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -36,8 +44,11 @@ system_router.include_router(system_users.router)
|
||||
system_router.include_router(system_roles.router)
|
||||
system_router.include_router(system_business_roles.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.login_router)
|
||||
system_router.include_router(system_tplus.router)
|
||||
v1_router.include_router(system_router)
|
||||
|
||||
# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问
|
||||
@@ -45,6 +56,7 @@ base_router = APIRouter(prefix="/base")
|
||||
base_router.include_router(base_personnel.router)
|
||||
base_router.include_router(base_products.router)
|
||||
base_router.include_router(base_customers.router)
|
||||
base_router.include_router(base_business_roles.router)
|
||||
v1_router.include_router(base_router)
|
||||
|
||||
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
|
||||
@@ -55,4 +67,13 @@ 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)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""岗位下拉选项(基础数据与报表筛选共用)。
|
||||
|
||||
岗位管理本身仅管理员可用(FR-006),但报表的「岗位」筛选对财务/领导
|
||||
开放(FR-029)。为避免把管理接口整体放开,这里提供只读、登录即可的
|
||||
启用岗位选项;写操作仍走 `/system/business-roles` 的管理员端点。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.deps import DbSession, get_current_user
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.schemas.business_role import BusinessRoleOptionOut
|
||||
from app.services import business_role_service
|
||||
|
||||
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
|
||||
|
||||
# 基础数据是业务操作:登录即可,不要求管理员
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/options",
|
||||
response_model=ApiResponse[list[BusinessRoleOptionOut]],
|
||||
summary="岗位下拉选项",
|
||||
)
|
||||
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[BusinessRoleOptionOut]]:
|
||||
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
|
||||
|
||||
岗位规模有限(内置 + 自定义),一次取全量,不做分页。
|
||||
"""
|
||||
return ok(business_role_service.list_options(session))
|
||||
@@ -21,6 +21,7 @@ from app.schemas.customer import (
|
||||
CustomerOut,
|
||||
CustomerOwnershipIn,
|
||||
CustomerQuery,
|
||||
CustomerRatingIn,
|
||||
CustomerUpdateIn,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
@@ -102,6 +103,31 @@ def delete_customer(
|
||||
return ok(message="客户已删除")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{customer_id}/rating",
|
||||
response_model=ApiResponse[CustomerOut],
|
||||
summary="保存客户评级",
|
||||
)
|
||||
def rate_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
payload: CustomerRatingIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CustomerOut]:
|
||||
"""保存评级总分(评级计算器「保存评分」)。
|
||||
|
||||
评分是客户档案属性:存总分与评级时间,五项明细不落库(对照表 #25);
|
||||
编辑端点不含评分 —— 评分只走本端点,由计算器统一入口写入。
|
||||
"""
|
||||
return ok(
|
||||
customer_service.rate_customer(
|
||||
session, customer_id, payload, operator=current_user, meta=meta
|
||||
),
|
||||
message="评级已保存",
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{customer_id}/ownership",
|
||||
response_model=ApiResponse[CustomerOut],
|
||||
|
||||
@@ -0,0 +1,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,52 @@
|
||||
"""工作台接口(M4 ⑧):三区块聚合 + 最近动态分页。
|
||||
|
||||
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
|
||||
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.api.deps import DbSession, require_roles
|
||||
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.schemas.dashboard import ActivityPageOut, ActivityQuery, DashboardOut
|
||||
from app.services import dashboard_service
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["工作台"])
|
||||
|
||||
_DashboardUser = Annotated[
|
||||
User,
|
||||
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台")),
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台三区块")
|
||||
def get_dashboard(
|
||||
session: DbSession,
|
||||
current_user: _DashboardUser,
|
||||
month: Annotated[
|
||||
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
|
||||
] = None,
|
||||
) -> ApiResponse[DashboardOut]:
|
||||
"""一次返回经营总览/待办/风险预警(待办与预警实时派生)。
|
||||
|
||||
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
|
||||
最近动态是分页列表,走 `GET /dashboard/activities`。
|
||||
"""
|
||||
return ok(dashboard_service.get_dashboard(session, month, current_user=current_user))
|
||||
|
||||
|
||||
@router.get("/activities", response_model=ApiResponse[ActivityPageOut], summary="最近动态(分页)")
|
||||
def list_dashboard_activities(
|
||||
session: DbSession,
|
||||
current_user: _DashboardUser,
|
||||
query: Annotated[ActivityQuery, Query()],
|
||||
) -> ApiResponse[ActivityPageOut]:
|
||||
"""分页查询最近动态,可按模块筛选。
|
||||
|
||||
与聚合端点拆开:翻页只重取这张列表,不必重算总览/待办/预警。
|
||||
"""
|
||||
return ok(dashboard_service.list_activities(session, query))
|
||||
@@ -0,0 +1,191 @@
|
||||
"""报表统计接口(M4 ⑤):四张报表只读查询 + Excel 导出。
|
||||
|
||||
准入:管理员/财务/领导(FR-030/031 明确;FR-029 的营销后勤「授权范围内」
|
||||
待权限清单 #6,暂不开放)。前后端双层:前端 allowedRoles 控菜单,本层
|
||||
require_roles 兜底。
|
||||
|
||||
导出返回文件流(非统一信封),沿用参数导出先例:服务端文件名为 ASCII
|
||||
(HTTP 头按 latin-1 编码,中文名会 UnicodeEncodeError),中文名由前端
|
||||
``link.download`` 指定。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.schemas.report import (
|
||||
BusinessStatsQuery,
|
||||
BusinessStatsRow,
|
||||
DepartmentSummaryOut,
|
||||
DepartmentSummaryQuery,
|
||||
PaymentDetailQuery,
|
||||
PaymentDetailRow,
|
||||
PaymentStatsOut,
|
||||
PaymentStatsQuery,
|
||||
PersonalDetailQuery,
|
||||
PersonalDetailRow,
|
||||
ReportPage,
|
||||
)
|
||||
from app.services import report_service
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["绩效工资 · 报表统计"])
|
||||
|
||||
_ReportViewer = Annotated[
|
||||
User,
|
||||
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问报表统计")),
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/personal-detail",
|
||||
response_model=ApiResponse[ReportPage[PersonalDetailRow]],
|
||||
summary="个人绩效工资明细表",
|
||||
)
|
||||
def personal_detail(
|
||||
session: DbSession,
|
||||
_: _ReportViewer,
|
||||
query: Annotated[PersonalDetailQuery, Query()],
|
||||
) -> ApiResponse[ReportPage[PersonalDetailRow]]:
|
||||
"""按人员/月份/客户类型/产品类别/岗位查明细(口径与台账一致)。"""
|
||||
return ok(report_service.list_personal_detail(session, query))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/department-summary",
|
||||
response_model=ApiResponse[DepartmentSummaryOut],
|
||||
summary="部门绩效工资汇总表",
|
||||
)
|
||||
def department_summary(
|
||||
session: DbSession,
|
||||
_: _ReportViewer,
|
||||
query: Annotated[DepartmentSummaryQuery, Query()],
|
||||
) -> ApiResponse[DepartmentSummaryOut]:
|
||||
"""按经理团队/月份汇总(团队基数与月结聚合行同源)。"""
|
||||
return ok(report_service.department_summary(session, query))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/business-stats",
|
||||
response_model=ApiResponse[ReportPage[BusinessStatsRow]],
|
||||
summary="业务统计表",
|
||||
)
|
||||
def business_stats(
|
||||
session: DbSession,
|
||||
_: _ReportViewer,
|
||||
query: Annotated[BusinessStatsQuery, Query()],
|
||||
) -> ApiResponse[ReportPage[BusinessStatsRow]]:
|
||||
"""按业务查询发货、回款、绩效(回款/绩效为业务累计值)。"""
|
||||
return ok(report_service.business_stats(session, query))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/payment-stats",
|
||||
response_model=ApiResponse[PaymentStatsOut],
|
||||
summary="回款情况统计表",
|
||||
)
|
||||
def payment_stats(
|
||||
session: DbSession,
|
||||
_: _ReportViewer,
|
||||
query: Annotated[PaymentStatsQuery, Query()],
|
||||
) -> ApiResponse[PaymentStatsOut]:
|
||||
"""按回款天数区间/客户类型/产品类别统计(空桶恒在且为 0)。"""
|
||||
return ok(report_service.payment_stats(session, query))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/payment-detail",
|
||||
response_model=ApiResponse[ReportPage[PaymentDetailRow]],
|
||||
summary="回款区间下钻",
|
||||
)
|
||||
def payment_detail(
|
||||
session: DbSession,
|
||||
_: _ReportViewer,
|
||||
query: Annotated[PaymentDetailQuery, Query()],
|
||||
) -> ApiResponse[ReportPage[PaymentDetailRow]]:
|
||||
"""区间桶下钻到单笔台账行(含回款金额与批次号)。"""
|
||||
return ok(report_service.payment_detail(session, query))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/payment-day-buckets", response_model=ApiResponse[list[dict]], summary="回款天数区间档位"
|
||||
)
|
||||
def payment_day_buckets(_: _ReportViewer) -> ApiResponse[list[dict]]:
|
||||
"""区间档位元数据(前端渲染下拉/表头,禁止前端硬编码档位)。"""
|
||||
return ok(report_service.get_payment_day_buckets())
|
||||
|
||||
|
||||
class ExportQuery(BaseModel):
|
||||
"""导出通用筛选参数(按报表编码取用相关字段,未用字段忽略)。"""
|
||||
|
||||
settlement_month: str | None = None
|
||||
personnel_id: int | None = None
|
||||
customer_type: str | None = None
|
||||
product_category: str | None = None
|
||||
role_code: str | None = None
|
||||
manager_id: int | None = None
|
||||
keyword: str | None = None
|
||||
customer_id: int | None = None
|
||||
owner_personnel_id: int | None = None
|
||||
status: int | None = None
|
||||
date_from: date | None = None
|
||||
date_to: date | None = None
|
||||
|
||||
|
||||
@router.get("/{code}/export", summary="导出报表 Excel")
|
||||
def export_report(
|
||||
code: Annotated[
|
||||
Literal["personal-detail", "department-summary", "business-stats", "payment-stats"],
|
||||
Path(description="报表编码"),
|
||||
],
|
||||
session: DbSession,
|
||||
current_user: _ReportViewer,
|
||||
meta: RequestMetaDep,
|
||||
query: Annotated[ExportQuery, Query()],
|
||||
) -> StreamingResponse:
|
||||
"""导出 xlsx(含公司页眉/财务复核栏/领导签字栏),行为入操作日志。"""
|
||||
content, filename = report_service.export_report(
|
||||
session,
|
||||
code,
|
||||
_build_export_query(code, query),
|
||||
operator=current_user,
|
||||
meta=meta,
|
||||
)
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": (f'attachment; filename="{filename}_{date.today():%Y%m%d}.xlsx"')
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _build_export_query(code: str, query: ExportQuery):
|
||||
"""导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。
|
||||
|
||||
手工构造模型抛出的是 `pydantic.ValidationError`,不是 FastAPI 的
|
||||
`RequestValidationError`——不在这里转换就会落到全局 500。导出参数
|
||||
非法与查询接口一样应当是 400。
|
||||
"""
|
||||
data = query.model_dump()
|
||||
model = {
|
||||
"personal-detail": PersonalDetailQuery,
|
||||
"department-summary": DepartmentSummaryQuery,
|
||||
"business-stats": BusinessStatsQuery,
|
||||
"payment-stats": PaymentStatsQuery,
|
||||
}[code]
|
||||
try:
|
||||
return model(**data)
|
||||
except ValidationError as exc:
|
||||
first = exc.errors()[0]
|
||||
location = ".".join(str(part) for part in first.get("loc", ()))
|
||||
message = first.get("msg", "参数错误")
|
||||
detail = f"{location} {message}" if location else message
|
||||
raise BadRequestError(f"参数错误:{detail}") from exc
|
||||
@@ -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:参数配置化 + 版本管理 + 留痕)。
|
||||
|
||||
全部要求管理员权限(开放问题 #5 的当前默认,后续可放宽只读);
|
||||
写操作(提交/撤回)经 `RequestMetaDep` 带入请求元信息写入操作日志,
|
||||
变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
|
||||
权限分层(澄清 #17 业务答复 2026-10-19「查看可、修改仅管理员」):只读
|
||||
端点(目录/变更记录/批次明细/对比/导出/预览/历史)要求登录即可;写操作
|
||||
(提交/撤回)仅管理员。写操作经 `RequestMetaDep` 带入请求元信息写入操作
|
||||
日志,变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
|
||||
导出为文件流(非统一信封),前端以 blob 接收。
|
||||
"""
|
||||
|
||||
@@ -12,7 +13,7 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Path, Query
|
||||
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.schemas.param import (
|
||||
ParamBatchDetailOut,
|
||||
@@ -37,7 +38,7 @@ router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"])
|
||||
)
|
||||
def get_catalog(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: LoginUser,
|
||||
as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None,
|
||||
) -> ApiResponse[list[ParamCatalogItemOut]]:
|
||||
"""全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。"""
|
||||
@@ -63,7 +64,7 @@ def submit_change(
|
||||
)
|
||||
def list_changes(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: LoginUser,
|
||||
query: Annotated[ParamBatchQuery, Query()],
|
||||
) -> ApiResponse[PageResult[ParamBatchOut]]:
|
||||
"""按生效日期区间分页查询变更批次(创建时间倒序)。"""
|
||||
@@ -75,7 +76,7 @@ def list_changes(
|
||||
)
|
||||
def get_batch(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: LoginUser,
|
||||
batch_id: Annotated[int, Path(ge=1)],
|
||||
) -> ApiResponse[ParamBatchDetailOut]:
|
||||
"""批次头 + 逐项旧值 → 新值。"""
|
||||
@@ -97,7 +98,7 @@ def withdraw_batch(
|
||||
@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比")
|
||||
def compare(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: LoginUser,
|
||||
from_date: Annotated[date, Query(description="起始业务日期(含)")],
|
||||
to_date: Annotated[date, Query(description="截止业务日期(含)")],
|
||||
) -> ApiResponse[list[ParamDiffOut]]:
|
||||
@@ -108,7 +109,7 @@ def compare(
|
||||
@router.get("/export", summary="导出变更记录 Excel")
|
||||
def export_changes(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: LoginUser,
|
||||
start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None,
|
||||
end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None,
|
||||
) -> StreamingResponse:
|
||||
@@ -125,7 +126,7 @@ def export_changes(
|
||||
@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览")
|
||||
def preview(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: LoginUser,
|
||||
payload: ParamPreviewIn,
|
||||
) -> ApiResponse[ParamPreviewOut]:
|
||||
"""输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。"""
|
||||
@@ -139,7 +140,7 @@ def preview(
|
||||
)
|
||||
def get_history(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: LoginUser,
|
||||
param_code: Annotated[str, Path(max_length=64)],
|
||||
query: Annotated[ParamHistoryQuery, Query()],
|
||||
) -> ApiResponse[PageResult[ParamHistoryOut]]:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""系统设置接口(公司信息)。
|
||||
|
||||
`GET /public` 与 `GET /logo/public` **无鉴权**:登录页渲染公司名/logo 时
|
||||
尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py),
|
||||
仅暴露两个非敏感展示字段。
|
||||
|
||||
`/logo/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"}
|
||||
|
||||
|
||||
@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.LOGO_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.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"},
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""管理员管理 T+ 只读任务;长任务交给独立 worker 执行。"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Path, Query
|
||||
|
||||
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.schemas.tplus import (
|
||||
TPlusBatchDetailOut,
|
||||
TPlusBatchOut,
|
||||
TPlusBatchQuery,
|
||||
TPlusCreateIn,
|
||||
TPlusErrorOut,
|
||||
TPlusErrorQuery,
|
||||
TPlusOverviewOut,
|
||||
TPlusRetryIn,
|
||||
)
|
||||
from app.services.tplus import query_service, task_service
|
||||
|
||||
router = APIRouter(prefix="/tplus", tags=["系统管理 · T+ 数据同步"])
|
||||
BatchNumber = Annotated[str, Path(min_length=1, max_length=32, pattern=r"^[A-Za-z0-9_-]+$")]
|
||||
|
||||
|
||||
@router.get("/overview", response_model=ApiResponse[TPlusOverviewOut])
|
||||
def overview(session: DbSession, _: AdminUser):
|
||||
"""查询配置状态、任务统计和最近核验。"""
|
||||
return ok(query_service.overview(session))
|
||||
|
||||
|
||||
@router.get("/batches", response_model=ApiResponse[PageResult[TPlusBatchOut]])
|
||||
def batches(session: DbSession, _: AdminUser, query: Annotated[TPlusBatchQuery, Query()]):
|
||||
"""分页查询批次元数据。"""
|
||||
return ok(query_service.list_batches(session, query))
|
||||
|
||||
|
||||
@router.get("/batches/{batch_no}", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||
def detail(session: DbSession, _: AdminUser, batch_no: BatchNumber):
|
||||
"""查询批次详情与受控的核验报告。"""
|
||||
return ok(query_service.get_batch(session, batch_no))
|
||||
|
||||
|
||||
@router.get("/batches/{batch_no}/errors", response_model=ApiResponse[PageResult[TPlusErrorOut]])
|
||||
def errors(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
batch_no: BatchNumber,
|
||||
query: Annotated[TPlusErrorQuery, Query()],
|
||||
):
|
||||
"""分页查询异常定位和固定错误说明。"""
|
||||
return ok(query_service.list_errors(session, batch_no, query))
|
||||
|
||||
|
||||
@router.post("/batches", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||
def create(session: DbSession, admin: AdminUser, meta: RequestMetaDep, payload: TPlusCreateIn):
|
||||
"""将读取或核验任务持久化排队。"""
|
||||
batch = task_service.create_task(session, payload, task_service.TaskActor(admin, meta))
|
||||
return ok(query_service.get_batch(session, batch.batch_no))
|
||||
|
||||
|
||||
@router.post("/batches/{batch_no}/retry", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||
def retry(
|
||||
session: DbSession,
|
||||
admin: AdminUser,
|
||||
meta: RequestMetaDep,
|
||||
batch_no: BatchNumber,
|
||||
payload: TPlusRetryIn,
|
||||
):
|
||||
"""新建重试批次,保留原批次与异常历史。"""
|
||||
batch = task_service.retry_task(session, batch_no, payload, task_service.TaskActor(admin, meta))
|
||||
return ok(query_service.get_batch(session, batch.batch_no))
|
||||
+93
-1
@@ -11,13 +11,20 @@
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
|
||||
from app.core.db import dispose_engine, get_session_factory
|
||||
from app.core.security import hash_password
|
||||
from app.core.tplus_db import dispose_tplus_engine, get_tplus_session_factory
|
||||
from app.models import Role, User, UserRole
|
||||
from app.repositories.tplus_repository import get_batch
|
||||
from app.services.tplus.sync_service import TPlusSyncService
|
||||
from app.services.tplus.worker import run_once
|
||||
|
||||
|
||||
def _prompt_password() -> str:
|
||||
@@ -80,6 +87,54 @@ def _create_admin(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _stage_tplus(args: argparse.Namespace) -> int:
|
||||
"""读取 T+ 源库并写入 MySQL staging。"""
|
||||
target_factory = get_session_factory()
|
||||
source_factory = get_tplus_session_factory()
|
||||
watermark = datetime.fromisoformat(args.watermark) if args.watermark else None
|
||||
with source_factory() as source_session, target_factory() as target_session:
|
||||
batch = TPlusSyncService(source_session, target_session).run(
|
||||
entity_types=args.entity,
|
||||
watermark=watermark,
|
||||
batch_size=args.batch_size,
|
||||
)
|
||||
print(
|
||||
f"T+ staging 完成:批次={batch.batch_no} 状态={batch.status} "
|
||||
f"源记录={batch.source_record_count} 新增={batch.staged_record_count} "
|
||||
f"失败={batch.failed_record_count}"
|
||||
)
|
||||
return 0 if batch.status == "SUCCEEDED" else 1
|
||||
|
||||
|
||||
def _tplus_worker(args: argparse.Namespace) -> int:
|
||||
"""独立消费持久化任务;HTTP 进程不承担长时间读取。"""
|
||||
if args.connection_file:
|
||||
settings = get_settings()
|
||||
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
|
||||
if not 2 <= args.poll_seconds <= 60:
|
||||
print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr)
|
||||
return 1
|
||||
factory = get_session_factory()
|
||||
try:
|
||||
while True:
|
||||
with factory() as session:
|
||||
batch_no = run_once(session)
|
||||
batch = get_batch(session, batch_no) if batch_no else None
|
||||
status = batch.status if batch else None
|
||||
if batch_no:
|
||||
print(f"任务完成:{batch_no} 状态={status}", flush=True)
|
||||
if not args.watch:
|
||||
return 0 if status in (None, "SUCCEEDED") else 1
|
||||
if batch_no is None or status != "SUCCEEDED":
|
||||
time.sleep(args.poll_seconds)
|
||||
except KeyboardInterrupt:
|
||||
print("T+ 读取服务已停止")
|
||||
return 130
|
||||
except Exception as exc:
|
||||
print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""命令行入口。"""
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -96,12 +151,49 @@ def main() -> int:
|
||||
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
|
||||
)
|
||||
|
||||
p_tplus = sub.add_parser("tplus-stage", help="读取 T+ 源库并写入原始 staging")
|
||||
p_tplus.add_argument(
|
||||
"--entity",
|
||||
action="append",
|
||||
choices=[
|
||||
"personnel",
|
||||
"products",
|
||||
"customers",
|
||||
"businesses",
|
||||
"shipments",
|
||||
"receipts",
|
||||
"business_items",
|
||||
"shipment_items",
|
||||
"receipt_items",
|
||||
],
|
||||
required=True,
|
||||
help="实体类型,可重复指定",
|
||||
)
|
||||
p_tplus.add_argument(
|
||||
"--watermark",
|
||||
default=None,
|
||||
help="增量起始时间,ISO 8601 格式;不传表示全量读取",
|
||||
)
|
||||
p_tplus.add_argument("--batch-size", type=int, default=500, help="每次从源库读取的行数")
|
||||
|
||||
p_worker = sub.add_parser("tplus-worker", help="执行已排队的 T+ 读取或关联核验")
|
||||
modes = p_worker.add_mutually_exclusive_group()
|
||||
modes.add_argument("--once", action="store_true", help="执行一个任务后退出(默认)")
|
||||
modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务")
|
||||
p_worker.add_argument("--connection-file", default=None, help="显式指定项目外只读连接 JSON")
|
||||
p_worker.add_argument("--poll-seconds", type=int, default=3, help="空闲轮询间隔,2–60 秒")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
return _create_admin(args)
|
||||
if args.command == "create-admin":
|
||||
return _create_admin(args)
|
||||
if args.command == "tplus-worker":
|
||||
return _tplus_worker(args)
|
||||
return _stage_tplus(args)
|
||||
finally:
|
||||
dispose_engine()
|
||||
dispose_tplus_engine()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -27,11 +27,12 @@ _WIDTH, _HEIGHT = 120, 40
|
||||
_FONT_SIZE = 25
|
||||
_PADDING = 8
|
||||
|
||||
# 与前端配色呼应:墨色字符、青灰干扰、素纸底
|
||||
_INK = (15, 54, 70)
|
||||
_INTERFERENCE = (126, 156, 170)
|
||||
_SPECKLE = (168, 183, 192)
|
||||
_PAPER = (247, 246, 242)
|
||||
# 与前端 v3 配色呼应(--ys-ink / --ys-ink-2 / --ys-ink-3 / --ys-paper):
|
||||
# 墨色字符、次级墨干扰、辅助墨噪点、纸白底
|
||||
_INK = (28, 35, 51) # #1c2333
|
||||
_INTERFERENCE = (91, 101, 119) # #5b6577
|
||||
_SPECKLE = (154, 163, 178) # #9aa3b2
|
||||
_PAPER = (255, 255, 255) # #ffffff
|
||||
|
||||
_SPECKLE_COUNT = 46
|
||||
_INTERFERENCE_LINES = 2
|
||||
|
||||
@@ -58,6 +58,23 @@ class Settings(BaseSettings):
|
||||
redis_password: str = ""
|
||||
redis_db: int = 0
|
||||
|
||||
# ===== T+ 只读 SQL Server =====
|
||||
# T+ 数据库默认关闭,避免开发者未配置驱动时影响主业务启动。
|
||||
t_plus_enabled: bool = False
|
||||
t_plus_host: str = "127.0.0.1"
|
||||
t_plus_port: int = 1433
|
||||
t_plus_database: str = ""
|
||||
t_plus_user: str = ""
|
||||
t_plus_password: str = Field(default="", repr=False)
|
||||
t_plus_connection_file: str = ""
|
||||
# 部署前必须指定已在 SQL Server 2008 R2 实测的驱动,不自动选择最新版。
|
||||
t_plus_odbc_driver: str = ""
|
||||
t_plus_encrypt: bool = True
|
||||
t_plus_trust_server_certificate: bool = True
|
||||
t_plus_connect_timeout: int = 10
|
||||
t_plus_query_timeout: int = Field(default=120, ge=1, le=600)
|
||||
t_plus_query_dir: str = ""
|
||||
|
||||
@field_validator("app_secret_key")
|
||||
@classmethod
|
||||
def _check_secret_key(cls, v: str) -> str:
|
||||
@@ -92,6 +109,41 @@ class Settings(BaseSettings):
|
||||
auth = f":{self.redis_password}@" if self.redis_password else ""
|
||||
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
|
||||
@property
|
||||
def t_plus_database_url(self) -> URL:
|
||||
"""构造 T+ 只读 SQL Server 连接 URL。
|
||||
|
||||
连接 URL 只在启用 T+ 适配器时使用;密码由 SQLAlchemy URL 对象保存,
|
||||
日志中调用 ``render_as_string()`` 时仍会默认隐藏密码。
|
||||
ODBC 的分号和花括号由方言统一转义,调用方不得再手工拼接连接串。
|
||||
"""
|
||||
query = {
|
||||
"driver": self.t_plus_odbc_driver,
|
||||
"Encrypt": "yes" if self.t_plus_encrypt else "no",
|
||||
"TrustServerCertificate": "yes"
|
||||
if self.t_plus_trust_server_certificate
|
||||
else "no",
|
||||
"Connection Timeout": str(self.t_plus_connect_timeout),
|
||||
}
|
||||
return URL.create(
|
||||
drivername="mssql+pyodbc",
|
||||
username=self.t_plus_user,
|
||||
password=self.t_plus_password,
|
||||
host=self.t_plus_host,
|
||||
port=self.t_plus_port,
|
||||
database=self.t_plus_database,
|
||||
query=query,
|
||||
)
|
||||
|
||||
@property
|
||||
def upload_dir(self) -> Path:
|
||||
"""上传文件根目录(公司 logo 等):backend/uploads/。
|
||||
|
||||
不叫 static/ 是刻意的:静态目录将来可能整体交给前端构建产物,
|
||||
运行期上传物必须与之隔离;该目录不入版本库(.gitignore)。
|
||||
"""
|
||||
return BACKEND_DIR / "uploads"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -25,6 +25,19 @@ TOKEN_TYPE_BEARER = "Bearer"
|
||||
# ===== 角色编码(与 db/migration 初始化数据保持一致)=====
|
||||
ROLE_ADMIN = "admin"
|
||||
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」,
|
||||
@@ -70,6 +83,11 @@ MODULE_BUSINESS = "业务管理"
|
||||
MODULE_SHIPMENT = "发货管理"
|
||||
MODULE_RECEIPT = "回款管理"
|
||||
MODULE_RATING = "客户评级"
|
||||
MODULE_SYSTEM_SETTINGS = "系统设置"
|
||||
MODULE_APPROVAL = "审批管理"
|
||||
MODULE_CALC = "绩效核算"
|
||||
MODULE_SETTLEMENT = "月度结算"
|
||||
MODULE_REPORT = "报表统计"
|
||||
|
||||
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
|
||||
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
|
||||
@@ -77,6 +95,8 @@ 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_UPDATE = "修改"
|
||||
@@ -84,3 +104,6 @@ ACTION_DELETE = "删除"
|
||||
ACTION_RESET_PASSWORD = "重置密码"
|
||||
ACTION_ENABLE = "启用"
|
||||
ACTION_DISABLE = "禁用"
|
||||
ACTION_APPROVE = "审批通过"
|
||||
ACTION_REJECT = "审批驳回"
|
||||
ACTION_EXPORT = "导出"
|
||||
@@ -0,0 +1,26 @@
|
||||
"""月份参数校验(YYYY-MM)——叶子模块。
|
||||
|
||||
报表、月结与调整单共用同一套校验:只校验长度会放过 `2026-13` 这类
|
||||
非法月份,一路传到 `calendar.monthrange` 才抛 ValueError,最终被全局
|
||||
处理器当成服务端异常(500)。月份范围必须在入口处拦住,才能返回 400。
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
|
||||
|
||||
|
||||
def validate_month(value: str) -> str:
|
||||
"""校验 YYYY-MM 且月份 01-12;非法抛 ValueError(Pydantic 友好)。
|
||||
|
||||
年份 0000 也拒绝:`date(0, ...)` 同样会抛 ValueError,且业务上不存在。
|
||||
"""
|
||||
if not _MONTH_RE.match(value) or int(value[:4]) == 0:
|
||||
raise ValueError("月份格式须为 YYYY-MM(01-12)")
|
||||
return value
|
||||
|
||||
|
||||
def parse_month(value: str) -> tuple[int, int]:
|
||||
"""校验并返回 (year, month),供服务层计算月末等使用。"""
|
||||
validate_month(value)
|
||||
return int(value[:4]), int(value[5:7])
|
||||
@@ -0,0 +1,79 @@
|
||||
"""报表统计元数据(区间桶、中文标签、导出上限)——叶子模块。
|
||||
|
||||
刻意不导入 app 内任何模块:report_service 与 report_export 都要消费这里的
|
||||
定义,若它反向依赖 service 层会形成循环导入;也不走参数体系——FR-031
|
||||
「区间可配置」一期以代码常量落地(参数化需注册表 + 迁移 + init-dev +
|
||||
registry 测试四处同改,业务未提诉求前不做),配置缝收在
|
||||
get_payment_day_buckets 单函数,未来在线可配时只改该函数。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DayBucket:
|
||||
"""回款天数区间档位:min/max 均为 None 表示「无回款天数」兜底桶。"""
|
||||
|
||||
code: str
|
||||
label: str
|
||||
min_days: int | None
|
||||
max_days: int | None
|
||||
|
||||
|
||||
# 8 档区间(催办清单 R2 默认口径)。两个兜底桶必须保留:payment_days 可空
|
||||
# (回款未指定发货批次)且可为负(先款后货照实负值),缺了它们这些行会
|
||||
# 静默消失、金额对不上。
|
||||
PAYMENT_DAY_BUCKETS: tuple[DayBucket, ...] = (
|
||||
DayBucket("le0", "≤0 天(先款后货)", None, 0),
|
||||
DayBucket("1-30", "1-30 天", 1, 30),
|
||||
DayBucket("31-60", "31-60 天", 31, 60),
|
||||
DayBucket("61-90", "61-90 天", 61, 90),
|
||||
DayBucket("91-180", "91-180 天", 91, 180),
|
||||
DayBucket("181-360", "181-360 天", 181, 360),
|
||||
DayBucket("361+", "361 天以上", 361, None),
|
||||
DayBucket("none", "无回款天数", None, None),
|
||||
)
|
||||
|
||||
CUSTOMER_TYPE_LABELS: dict[str, str] = {
|
||||
"personal": "个人户",
|
||||
"manager_personal": "经理个人户",
|
||||
"enterprise": "企业户",
|
||||
}
|
||||
|
||||
# mixed 不在 core.constants.PRODUCT_CATEGORIES(那只是可维护的档案类别),
|
||||
# 但台账行可以是 mixed(混合业务行级明细入 snapshot_json),报表必须能标它
|
||||
PRODUCT_CATEGORY_LABELS: dict[str, str] = {
|
||||
"municipal_water": "市政水利",
|
||||
"rail_transit": "轨道交通",
|
||||
"mixed": "混合业务",
|
||||
}
|
||||
|
||||
# 业务状态中文标签:导出列、筛选页眉与前端标签共用同一份映射
|
||||
BUSINESS_STATUS_LABELS: dict[int, str] = {1: "进行中", 2: "已完成", 3: "已终止"}
|
||||
|
||||
REPORT_TEMPLATE_VERSION = "V1.2"
|
||||
# 导出超限即拒绝(需求「大数据量分批/超限提示」二选一取后者):普通
|
||||
# Workbook 全量载入内存,2 万行约数十 MB,超过该量级必须引导按月导出
|
||||
REPORT_EXPORT_MAX_ROWS = 20000
|
||||
|
||||
|
||||
def get_payment_day_buckets() -> tuple[DayBucket, ...]:
|
||||
"""区间档位唯一出口:前端下拉与分桶都以本函数返回为准,禁止硬编码。
|
||||
|
||||
未来「区间在线可配」时改此处读参数即可,调用方与前端零改动。
|
||||
"""
|
||||
return PAYMENT_DAY_BUCKETS
|
||||
|
||||
|
||||
def bucket_of(payment_days: int | None) -> DayBucket:
|
||||
"""把单笔回款天数落入对应区间桶;未匹配任何区间按「无回款天数」兜底。"""
|
||||
if payment_days is None:
|
||||
return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none")
|
||||
for bucket in PAYMENT_DAY_BUCKETS:
|
||||
if bucket.code == "none":
|
||||
continue
|
||||
lower_ok = bucket.min_days is None or payment_days >= bucket.min_days
|
||||
upper_ok = bucket.max_days is None or payment_days <= bucket.max_days
|
||||
if lower_ok and upper_ok:
|
||||
return bucket
|
||||
return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none")
|
||||
@@ -0,0 +1,115 @@
|
||||
"""T+ 只读 SQL Server 连接。
|
||||
|
||||
T+ 是外部源库,业务接口不直接依赖它。连接采用延迟初始化,未配置或未启用
|
||||
时不会影响主业务服务启动;适配器执行查询时才建立连接池。
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
|
||||
from sqlalchemy import Engine, create_engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
|
||||
_engine: Engine | None = None
|
||||
_session_factory: sessionmaker[Session] | None = None
|
||||
|
||||
|
||||
class _ConnectionFile(BaseModel):
|
||||
"""连接文件仅允许源库字段,密码不会出现在模型表示中。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
host: str = Field(min_length=1, max_length=255)
|
||||
port: int = Field(ge=1, le=65535)
|
||||
database: str = Field(min_length=1, max_length=128)
|
||||
user: str = Field(min_length=1, max_length=128)
|
||||
password: SecretStr
|
||||
driver: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
def resolve_tplus_settings(settings: Settings) -> Settings:
|
||||
"""解析显式配置的只读连接,返回副本,不修改应用全局配置。"""
|
||||
if not settings.t_plus_enabled:
|
||||
raise RuntimeError("T+ 数据源未启用,请设置 T_PLUS_ENABLED=true")
|
||||
resolved = settings
|
||||
if settings.t_plus_connection_file:
|
||||
try:
|
||||
path = Path(settings.t_plus_connection_file)
|
||||
if path.stat().st_size > 16384:
|
||||
raise ValueError("连接文件过大")
|
||||
config = _ConnectionFile.model_validate(
|
||||
json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
)
|
||||
except OSError:
|
||||
# 消息只区分故障类别,不回显路径内容;路径本身可在应用配置中核对。
|
||||
raise RuntimeError("T+ 只读连接文件不存在或不可读") from None
|
||||
except (ValueError, ValidationError):
|
||||
raise RuntimeError("T+ 只读连接文件字段不正确") from None
|
||||
values = {
|
||||
"t_plus_" + key: getattr(config, key) for key in ("host", "port", "database", "user")
|
||||
}
|
||||
values.update(
|
||||
t_plus_password=config.password.get_secret_value(), t_plus_odbc_driver=config.driver
|
||||
)
|
||||
resolved = settings.model_copy(update=values)
|
||||
if resolved.t_plus_user.strip().casefold() == "sa":
|
||||
raise RuntimeError("应用必须使用 T+ 专用只读账号,不能使用 sa")
|
||||
if not resolved.t_plus_database or not resolved.t_plus_user or not resolved.t_plus_odbc_driver:
|
||||
raise RuntimeError("T+ 只读连接缺少数据库、账号或已验证的 ODBC 驱动")
|
||||
return resolved
|
||||
|
||||
|
||||
def get_tplus_engine() -> Engine:
|
||||
"""获取 T+ 只读引擎;未启用或缺少 ODBC 驱动时给出可读错误。"""
|
||||
global _engine, _session_factory
|
||||
if _engine is not None:
|
||||
return _engine
|
||||
|
||||
settings = resolve_tplus_settings(get_settings())
|
||||
|
||||
try:
|
||||
_engine = create_engine(
|
||||
settings.t_plus_database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=1800,
|
||||
pool_timeout=settings.t_plus_connect_timeout,
|
||||
echo=False,
|
||||
hide_parameters=True,
|
||||
)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"T+ 连接需要 pyodbc(uv sync --extra tplus)和已验证的 SQL Server ODBC 驱动"
|
||||
) from exc
|
||||
_session_factory = sessionmaker(_engine, expire_on_commit=False)
|
||||
|
||||
def configure_timeout(connection, _record):
|
||||
connection.timeout = settings.t_plus_query_timeout
|
||||
|
||||
event.listen(_engine, "connect", configure_timeout)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_tplus_session_factory() -> sessionmaker[Session]:
|
||||
"""获取 T+ SQLAlchemy 会话工厂。"""
|
||||
get_tplus_engine()
|
||||
assert _session_factory is not None
|
||||
return _session_factory
|
||||
|
||||
|
||||
def get_tplus_db() -> Generator[Session, None, None]:
|
||||
"""提供只读 T+ 会话;调用方不得 commit 或执行写操作。"""
|
||||
factory = get_tplus_session_factory()
|
||||
with factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def dispose_tplus_engine() -> None:
|
||||
"""释放 T+ 连接池。"""
|
||||
global _engine, _session_factory
|
||||
if _engine is not None:
|
||||
_engine.dispose()
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
@@ -0,0 +1,48 @@
|
||||
"""同账套任务串行锁;独立持有 MySQL 连接,跨分页 commit 仍保持有效。"""
|
||||
|
||||
import hashlib
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
class TPlusBusyError(RuntimeError):
|
||||
"""已有任务持有执行锁。"""
|
||||
|
||||
|
||||
def lock_name(session: Session) -> str:
|
||||
"""生成不含地址、账号或密码的固定长度锁名。"""
|
||||
database = session.get_bind().url.database or "default"
|
||||
return "ys:tplus:read:" + hashlib.sha256(database.encode()).hexdigest()[:24]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def sync_lock(session: Session):
|
||||
"""只在取得锁后执行;释放失败时使连接失效,防止带锁回池。"""
|
||||
engine = session.get_bind()
|
||||
if engine.dialect.name != "mysql":
|
||||
raise RuntimeError("T+ 执行协调需要 MySQL 命名锁")
|
||||
connection = engine.connect()
|
||||
acquired = False
|
||||
try:
|
||||
acquired = (
|
||||
connection.execute(
|
||||
text("SELECT GET_LOCK(:name, 0)"), {"name": lock_name(session)}
|
||||
).scalar_one()
|
||||
== 1
|
||||
)
|
||||
if not acquired:
|
||||
raise TPlusBusyError("已有 T+ 任务正在执行,请稍后重试")
|
||||
yield
|
||||
finally:
|
||||
if acquired:
|
||||
try:
|
||||
released = connection.execute(
|
||||
text("SELECT RELEASE_LOCK(:name)"), {"name": lock_name(session)}
|
||||
).scalar_one()
|
||||
if released != 1:
|
||||
connection.invalidate()
|
||||
except Exception:
|
||||
connection.invalidate()
|
||||
connection.close()
|
||||
@@ -15,6 +15,7 @@ from app.core.db import dispose_engine
|
||||
from app.core.exception_handlers import register_exception_handlers
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.redis_client import close_redis
|
||||
from app.core.tplus_db import dispose_tplus_engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,6 +36,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
)
|
||||
yield
|
||||
dispose_engine()
|
||||
dispose_tplus_engine()
|
||||
close_redis()
|
||||
logger.info("应用已停止,连接池已释放")
|
||||
|
||||
|
||||
@@ -9,22 +9,35 @@
|
||||
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
|
||||
"""
|
||||
|
||||
from app.models.approval import ApprovalOrder, ApprovalStep
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.business import Business, BusinessItem, BusinessStatusLog
|
||||
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
|
||||
from app.models.business_role import BusinessRole
|
||||
from app.models.customer import Customer, OwnershipChangeLog
|
||||
from app.models.login_log import LoginLog
|
||||
from app.models.operation_log import OperationLog
|
||||
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.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
|
||||
from app.models.shipment import Shipment, ShipmentItem
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.tplus_sync import (
|
||||
TPlusSyncBatch,
|
||||
TPlusSyncError,
|
||||
TPlusSyncRecord,
|
||||
TPlusSyncWatermark,
|
||||
)
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
__all__ = [
|
||||
"AdjustmentOrder",
|
||||
"ApprovalOrder",
|
||||
"ApprovalStep",
|
||||
"Base",
|
||||
"Business",
|
||||
"BusinessItem",
|
||||
@@ -33,11 +46,13 @@ __all__ = [
|
||||
"Customer",
|
||||
"CustomerRatingGrade",
|
||||
"LoginLog",
|
||||
"MonthlySettlement",
|
||||
"OperationLog",
|
||||
"OwnershipChangeLog",
|
||||
"ParamChangeBatch",
|
||||
"ParamDefinition",
|
||||
"ParamValue",
|
||||
"PerformanceLedger",
|
||||
"Permission",
|
||||
"Personnel",
|
||||
"PersonnelPlan",
|
||||
@@ -46,8 +61,15 @@ __all__ = [
|
||||
"Receipt",
|
||||
"Role",
|
||||
"RolePermission",
|
||||
"SecondaryAllocation",
|
||||
"SettlementItem",
|
||||
"Shipment",
|
||||
"ShipmentItem",
|
||||
"SystemSetting",
|
||||
"TPlusSyncBatch",
|
||||
"TPlusSyncError",
|
||||
"TPlusSyncRecord",
|
||||
"TPlusSyncWatermark",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"UserRole",
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""审批模型:审批单与节点记录(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 (
|
||||
JSON,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
# 审批单状态(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(
|
||||
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
|
||||
# 同一引用对象的第 N 次送审在库里有唯一一行:并发提单算重版本号时
|
||||
# 由 DB 兜住(应用层取号走锁读,见 approval_service._next_version)
|
||||
UniqueConstraint(
|
||||
"order_type",
|
||||
"ref_id",
|
||||
"version_no",
|
||||
name="uk_approval_orders_order_type_ref_version",
|
||||
),
|
||||
Index("idx_approval_orders_type_status", "order_type", "status"),
|
||||
# 版本号按 (order_type, ref_id) 递增,须有对应索引避免全表 max 扫描
|
||||
Index("idx_approval_orders_type_ref", "order_type", "ref_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
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="引用对象单号(冗余固化,列表页免联查)"
|
||||
)
|
||||
version_no: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖;"
|
||||
"同一对象内唯一,由uk_approval_orders_order_type_ref_version强制)",
|
||||
)
|
||||
snapshot_json: Mapped[dict | None] = mapped_column(
|
||||
JSON,
|
||||
comment="提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)",
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
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="创建时间"
|
||||
)
|
||||
@@ -91,6 +91,9 @@ class TPlusSyncMixin:
|
||||
String(32), comment="同步批次号(同步模块写入)"
|
||||
)
|
||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间")
|
||||
source_updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, comment="T+ 源数据更新时间(未确认时可为空)"
|
||||
)
|
||||
|
||||
|
||||
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
|
||||
|
||||
@@ -15,7 +15,17 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, text
|
||||
from sqlalchemy import (
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import (
|
||||
@@ -33,6 +43,82 @@ 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 回馈):参与人全为系统内人工维护
|
||||
- **版本行 append-only**:编辑全量替换与驳回清空都把旧行置
|
||||
``is_active=0`` 而非物理删除,历史方案可回溯;当前生效行由
|
||||
``is_active=1`` 唯一标识。核算仍按「当前生效行」取快照
|
||||
(``performance_ledgers.snapshot_json.alloc_plan`` 固化),版本化只
|
||||
补齐「方案事实留存」,不改变核算口径
|
||||
|
||||
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
|
||||
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
|
||||
冗余固化,人离职后方案回显仍有名。uk 锁
|
||||
(business_id, version_no, personnel_id),同一版本内同一参与人仅一行,
|
||||
重复提交在服务层聚合;跨版本允许同一人再次出现。
|
||||
"""
|
||||
|
||||
__tablename__ = "business_secondary_allocations"
|
||||
__table_args__ = mysql_table_args(
|
||||
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
|
||||
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
|
||||
"业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)",
|
||||
UniqueConstraint(
|
||||
"business_id",
|
||||
"version_no",
|
||||
"personnel_id",
|
||||
name="uk_business_secondary_allocations_business_version_personnel",
|
||||
),
|
||||
Index("idx_business_secondary_allocations_business_id", "business_id"),
|
||||
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
|
||||
# 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1
|
||||
Index("idx_business_secondary_allocations_business_active", "business_id", "is_active"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
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;业务删除随删)",
|
||||
)
|
||||
version_no: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)",
|
||||
)
|
||||
personnel_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||
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)",
|
||||
)
|
||||
is_active: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)",
|
||||
)
|
||||
|
||||
|
||||
class Business(Base, TimestampMixin, TPlusSyncMixin):
|
||||
"""业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。"""
|
||||
|
||||
|
||||
@@ -63,6 +63,14 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
|
||||
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||
rating_score: Mapped[int | None] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
comment="评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)",
|
||||
)
|
||||
rating_time: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
comment="最近评级时间(评级计算器保存评分时刷新;人工维护字段)",
|
||||
)
|
||||
|
||||
|
||||
class OwnershipChangeLog(Base):
|
||||
|
||||
@@ -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,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,31 @@
|
||||
"""系统设置模型:公司信息单例行。
|
||||
|
||||
与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期
|
||||
时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列
|
||||
value_text 为 String(64) 装不下;故独立单行表。logo 只存相对路径——文件在
|
||||
`backend/uploads/logo/`,独立于数据库备份体积且可单独替换(见
|
||||
system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底,upsert 维护)。
|
||||
"""
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class SystemSetting(Base, TimestampMixin):
|
||||
"""系统设置表:本期仅公司信息(公司名称 / logo 路径),全系统单行 id=1。"""
|
||||
|
||||
__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=未上传使用默认)",
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""T+ 同步批次、错误和原始 staging 记录。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class TPlusSyncBatch(Base, TimestampMixin):
|
||||
"""一次 T+ 全量或增量同步的审计记录。"""
|
||||
|
||||
__tablename__ = "tplus_sync_batches"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 同步批次表(记录读取范围、结果和对账统计)",
|
||||
UniqueConstraint("batch_no", name="uk_tplus_sync_batches_batch_no"),
|
||||
UniqueConstraint("request_key", name="uk_tplus_sync_batches_request_key"),
|
||||
Index("idx_tplus_sync_batches_status", "status"),
|
||||
Index("idx_tplus_sync_batches_started_at", "started_at"),
|
||||
Index("idx_tplus_sync_batches_parent_status", "parent_batch_no", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
batch_no: Mapped[str] = mapped_column(String(32), nullable=False, comment="同步批次号")
|
||||
sync_mode: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, comment="任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, comment="状态:QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED"
|
||||
)
|
||||
source_database: Mapped[str | None] = mapped_column(String(128), comment="T+ 源数据库名")
|
||||
started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, comment="开始执行时间(排队时为空)"
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, comment="结束时间")
|
||||
watermark_from: Mapped[datetime | None] = mapped_column(DateTime, comment="增量起始水位")
|
||||
watermark_to: Mapped[datetime | None] = mapped_column(DateTime, comment="本批次结束水位")
|
||||
source_record_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="源记录数"
|
||||
)
|
||||
staged_record_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="写入 staging 的记录数"
|
||||
)
|
||||
failed_record_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="失败记录数"
|
||||
)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, comment="批次错误摘要")
|
||||
entity_types_json: Mapped[list[str] | None] = mapped_column(
|
||||
JSON, comment="请求读取的实体类型;历史批次可为空"
|
||||
)
|
||||
skipped_record_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="重复快照跳过条数"
|
||||
)
|
||||
parent_batch_no: Mapped[str | None] = mapped_column(
|
||||
String(32), comment="重试来源批次号,保留原批次记录"
|
||||
)
|
||||
request_key: Mapped[str | None] = mapped_column(
|
||||
String(36), comment="请求幂等键,空值用于历史和直接CLI任务"
|
||||
)
|
||||
requested_by: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED, comment="请求用户ID,CLI任务为空,无物理外键"
|
||||
)
|
||||
audit_result_json: Mapped[dict | None] = mapped_column(
|
||||
JSON, comment="关联核验结果,只含系统字典和汇总"
|
||||
)
|
||||
|
||||
|
||||
class TPlusSyncError(Base, TimestampMixin):
|
||||
"""单条 T+ 数据读取、清洗或 staging 失败记录。"""
|
||||
|
||||
__tablename__ = "tplus_sync_errors"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 同步错误表(记录失败行和重试信息)",
|
||||
Index("idx_tplus_sync_errors_batch_no", "batch_no"),
|
||||
Index("idx_tplus_sync_errors_entity_type", "entity_type"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
batch_no: Mapped[str] = mapped_column(String(32), nullable=False, comment="同步批次号")
|
||||
entity_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="业务实体类型")
|
||||
source_table: Mapped[str] = mapped_column(String(128), nullable=False, comment="T+ 源表")
|
||||
source_id: Mapped[str | None] = mapped_column(String(64), comment="T+ 源主键")
|
||||
source_line_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 明细行号或明细主键")
|
||||
error_code: Mapped[str] = mapped_column(String(32), nullable=False, comment="错误编码")
|
||||
error_message: Mapped[str] = mapped_column(Text, nullable=False, comment="错误说明")
|
||||
retry_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="已重试次数"
|
||||
)
|
||||
|
||||
|
||||
class TPlusSyncRecord(Base, TimestampMixin):
|
||||
"""T+ 原始行 staging,保存映射未定前的可追溯数据。"""
|
||||
|
||||
__tablename__ = "tplus_sync_records"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 原始同步 staging 表(映射确认前只保存原始行,不直接改业务表)",
|
||||
UniqueConstraint(
|
||||
"entity_type", "source_id", "row_hash", name="uk_tplus_sync_records_entity_source_hash"
|
||||
),
|
||||
Index("idx_tplus_sync_records_batch_no", "batch_no"),
|
||||
Index("idx_tplus_sync_records_source_no", "source_no"),
|
||||
Index("idx_tplus_sync_records_state", "state"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
batch_no: Mapped[str] = mapped_column(String(32), nullable=False, comment="同步批次号")
|
||||
entity_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="业务实体类型")
|
||||
source_table: Mapped[str] = mapped_column(String(128), nullable=False, comment="T+ 源表")
|
||||
source_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="T+ 源主键")
|
||||
source_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 原始编号")
|
||||
source_line_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 明细行号或明细主键")
|
||||
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime, comment="T+ 源更新时间")
|
||||
row_hash: Mapped[str] = mapped_column(String(64), nullable=False, comment="原始行 SHA-256")
|
||||
state: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
server_default="PENDING",
|
||||
comment="状态:PENDING/APPLIED/SKIPPED/ERROR",
|
||||
)
|
||||
payload_json: Mapped[dict] = mapped_column(JSON, nullable=False, comment="原始行 JSON")
|
||||
|
||||
|
||||
class TPlusSyncWatermark(Base, TimestampMixin):
|
||||
"""按实体保存增量同步水位。"""
|
||||
|
||||
__tablename__ = "tplus_sync_watermarks"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 增量同步水位表",
|
||||
UniqueConstraint("entity_type", name="uk_tplus_sync_watermarks_entity_type"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
entity_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="业务实体类型")
|
||||
last_source_updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, comment="最近源更新时间"
|
||||
)
|
||||
last_source_id: Mapped[str | None] = mapped_column(String(64), comment="同一时间点的最后源主键")
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, server_default="READY", comment="状态:READY/BLOCKED"
|
||||
)
|
||||
@@ -5,9 +5,7 @@
|
||||
models/customer.py 定义)。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Row, Select, delete, exists, func, or_, select
|
||||
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import (
|
||||
@@ -18,10 +16,13 @@ from app.models import (
|
||||
OwnershipChangeLog,
|
||||
Personnel,
|
||||
Receipt,
|
||||
SecondaryAllocation,
|
||||
Shipment,
|
||||
)
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
from app.repositories import ownership_log_repository
|
||||
from app.repositories.ownership_log_repository import OwnershipLogRef
|
||||
from app.schemas.business import BusinessQuery
|
||||
|
||||
|
||||
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
|
||||
@@ -76,50 +77,32 @@ def get_with_names(
|
||||
return session.execute(stmt).first()
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
keyword: str | None,
|
||||
status: int | None,
|
||||
customer_id: int | None,
|
||||
owner_personnel_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
|
||||
"""业务列表的筛选条件(调用方须已 join Customer)。"""
|
||||
if keyword:
|
||||
if query.keyword:
|
||||
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
|
||||
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Business.business_no.contains(keyword, autoescape=True),
|
||||
Customer.company_name.contains(keyword, autoescape=True),
|
||||
Business.business_no.contains(query.keyword, autoescape=True),
|
||||
Customer.company_name.contains(query.keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Business.status == status)
|
||||
if customer_id is not None:
|
||||
stmt = stmt.where(Business.customer_id == customer_id)
|
||||
if owner_personnel_id is not None:
|
||||
stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(Business.business_date >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(Business.business_date <= date_to)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(Business.status == query.status)
|
||||
if query.customer_id is not None:
|
||||
stmt = stmt.where(Business.customer_id == query.customer_id)
|
||||
if query.owner_personnel_id is not None:
|
||||
stmt = stmt.where(Business.owner_personnel_id == query.owner_personnel_id)
|
||||
if query.date_from is not None:
|
||||
stmt = stmt.where(Business.business_date >= query.date_from)
|
||||
if query.date_to is not None:
|
||||
stmt = stmt.where(Business.business_date <= query.date_to)
|
||||
return stmt
|
||||
|
||||
|
||||
def list_businesses(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None,
|
||||
status: int | None,
|
||||
customer_id: int | None,
|
||||
owner_personnel_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
session: Session, query: BusinessQuery
|
||||
) -> list[tuple[Business, str, str | None, str | None]]:
|
||||
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
|
||||
owner = aliased(Personnel)
|
||||
@@ -127,49 +110,24 @@ def list_businesses(
|
||||
stmt = select(
|
||||
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
|
||||
).join(Customer, Customer.id == Business.customer_id)
|
||||
stmt = _apply_filters(
|
||||
stmt,
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
customer_id=customer_id,
|
||||
owner_personnel_id=owner_personnel_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
stmt = _apply_filters(stmt, query)
|
||||
stmt = (
|
||||
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
||||
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
|
||||
.order_by(Business.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_businesses(
|
||||
session: Session,
|
||||
*,
|
||||
keyword: str | None,
|
||||
status: int | None,
|
||||
customer_id: int | None,
|
||||
owner_personnel_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
) -> int:
|
||||
def count_businesses(session: Session, query: BusinessQuery) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(Business)
|
||||
.join(Customer, Customer.id == Business.customer_id)
|
||||
)
|
||||
stmt = _apply_filters(
|
||||
stmt,
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
customer_id=customer_id,
|
||||
owner_personnel_id=owner_personnel_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
stmt = _apply_filters(stmt, query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@@ -198,6 +156,79 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem])
|
||||
session.flush()
|
||||
|
||||
|
||||
def list_allocations(
|
||||
session: Session, business_id: int, *, active_only: bool = True
|
||||
) -> list[SecondaryAllocation]:
|
||||
"""二次分配方案(详情回显与核算取数;默认仅当前生效版本)。"""
|
||||
stmt = select(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||
if active_only:
|
||||
stmt = stmt.where(SecondaryAllocation.is_active == 1)
|
||||
return list(
|
||||
session.execute(
|
||||
stmt.order_by(SecondaryAllocation.version_no, SecondaryAllocation.id)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def list_allocation_versions(session: Session, business_id: int) -> list[SecondaryAllocation]:
|
||||
"""方案全部版本行(含历史;业务详情「方案历史」用,按版本正序)。"""
|
||||
return list_allocations(session, business_id, active_only=False)
|
||||
|
||||
|
||||
def replace_allocations(
|
||||
session: Session, business_id: int, rows: list[SecondaryAllocation]
|
||||
) -> None:
|
||||
"""全量替换二次分配方案:旧生效行置历史,新行按下一版本号插入。
|
||||
|
||||
不 commit(事务边界在服务层)。版本行 append-only——编辑与清空都不
|
||||
物理删除,驳回后仍可回溯被否决方案的参与人与比例。空 ``rows`` 表示
|
||||
只清空生效态(不产生新版本)。
|
||||
"""
|
||||
deactivate_allocations(session, business_id)
|
||||
if not rows:
|
||||
return
|
||||
next_version = (
|
||||
session.execute(
|
||||
select(func.coalesce(func.max(SecondaryAllocation.version_no), 0)).where(
|
||||
SecondaryAllocation.business_id == business_id
|
||||
)
|
||||
).scalar_one()
|
||||
+ 1
|
||||
)
|
||||
for row in rows:
|
||||
row.business_id = business_id
|
||||
row.version_no = next_version
|
||||
row.is_active = 1
|
||||
session.add(row)
|
||||
session.flush()
|
||||
|
||||
|
||||
def deactivate_allocations(session: Session, business_id: int) -> None:
|
||||
"""把业务的当前生效方案置为历史(审批驳回/编辑替换时调用;行保留)。
|
||||
|
||||
被否决的方案不得以「当前生效行」身份继续被核算取到;``is_active=0``
|
||||
后核算侧自然读不到,而历史版本仍可在业务详情回溯。不 commit。
|
||||
|
||||
显式声明 ``synchronize_session="fetch"``:Core UPDATE 默认走 auto 策略
|
||||
(优先 evaluate,本例的条件恰好能在 Python 侧求值,故实测也会刷新在途
|
||||
对象)。显式写 fetch 是为了不把「在途对象被刷新」这件事押在 evaluate
|
||||
能否解析条件上——条件一旦复杂化,auto 会退化为不刷新,同一会话里已
|
||||
加载的方案行就会停留在 ``is_active=1`` 的过期内存值(查询虽有
|
||||
is_active 过滤,但行对象本身仍是「生效态」,被序列化或参与判断就是
|
||||
错的)。
|
||||
"""
|
||||
session.execute(
|
||||
update(SecondaryAllocation)
|
||||
.where(
|
||||
SecondaryAllocation.business_id == business_id,
|
||||
SecondaryAllocation.is_active == 1,
|
||||
)
|
||||
.values(is_active=0)
|
||||
.execution_options(synchronize_session="fetch")
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def add_status_log(session: Session, log: BusinessStatusLog) -> None:
|
||||
"""追加状态留痕(不 commit)。"""
|
||||
session.add(log)
|
||||
@@ -239,18 +270,17 @@ def list_ownership_logs(
|
||||
) -> list[Row]:
|
||||
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
|
||||
return ownership_log_repository.list_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
ref_column=OwnershipChangeLog.business_id,
|
||||
ref_id=business_id,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
session, _business_log_ref(business_id), offset=offset, limit=limit
|
||||
)
|
||||
|
||||
|
||||
def count_ownership_logs(session: Session, business_id: int) -> int:
|
||||
return ownership_log_repository.count_by_ref(
|
||||
session,
|
||||
return ownership_log_repository.count_by_ref(session, _business_log_ref(business_id))
|
||||
|
||||
|
||||
def _business_log_ref(business_id: int) -> OwnershipLogRef:
|
||||
"""业务维度的归属留痕锚点。"""
|
||||
return OwnershipLogRef(
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
ref_column=OwnershipChangeLog.business_id,
|
||||
ref_id=business_id,
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import BusinessRole
|
||||
from app.schemas.business_role import BusinessRoleQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@@ -39,41 +40,48 @@ def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, Business
|
||||
return {role.role_code: role for role in rows}
|
||||
|
||||
|
||||
def list_roles(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> list[BusinessRole]:
|
||||
def list_roles(session: Session, query: BusinessRoleQuery) -> list[BusinessRole]:
|
||||
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
|
||||
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
|
||||
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(select(BusinessRole), query)
|
||||
stmt = (
|
||||
stmt.order_by(BusinessRole.sort_order, BusinessRole.id)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
|
||||
def count_roles(session: Session, query: BusinessRoleQuery) -> int:
|
||||
"""统计符合条件的岗位总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status)
|
||||
stmt = _apply_filters(select(func.count(BusinessRole.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
|
||||
def list_all(session: Session) -> list[BusinessRole]:
|
||||
"""全部岗位(含停用,不分页)——岗位下拉选项的取数口径。
|
||||
|
||||
岗位规模有限(内置 + 自定义),一次取全量,不受分页上限约束;
|
||||
排序与 list_roles 一致(sort_order, id),下拉与列表相对次序相同。
|
||||
"""
|
||||
stmt = select(BusinessRole).order_by(BusinessRole.sort_order, BusinessRole.id)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, query: BusinessRoleQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
|
||||
不转义时 ``_`` 会当单字符通配执行。
|
||||
"""
|
||||
if keyword:
|
||||
if query.keyword:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
BusinessRole.role_code.contains(keyword, autoescape=True),
|
||||
BusinessRole.role_name.contains(keyword, autoescape=True),
|
||||
BusinessRole.role_code.contains(query.keyword, autoescape=True),
|
||||
BusinessRole.role_name.contains(query.keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(BusinessRole.status == status)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(BusinessRole.status == query.status)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ from sqlalchemy.orm import Session
|
||||
from app.models import Customer, OwnershipChangeLog, Personnel
|
||||
from app.models.customer import OWNER_TYPE_CUSTOMER
|
||||
from app.repositories import ownership_log_repository
|
||||
from app.repositories.ownership_log_repository import OwnershipLogRef
|
||||
from app.repositories.query_utils import fuzzy
|
||||
from app.schemas.customer import CustomerQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@@ -45,49 +47,27 @@ def get_by_company_owner(session: Session, company_name: str, owner_name: str) -
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_customers(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
customer_type: str | None = None,
|
||||
owner_personnel_id: int | None = None,
|
||||
) -> list[Row]:
|
||||
def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
|
||||
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
|
||||
|
||||
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
|
||||
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
|
||||
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
|
||||
排序键由查询白名单决定(见 ``CustomerQuery.order_by``):
|
||||
id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
_base_select(),
|
||||
keyword=keyword,
|
||||
customer_type=customer_type,
|
||||
owner_personnel_id=owner_personnel_id,
|
||||
)
|
||||
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(_base_select(), query)
|
||||
stmt = _apply_order(stmt, query)
|
||||
stmt = stmt.offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_customers(
|
||||
session: Session,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
customer_type: str | None = None,
|
||||
owner_personnel_id: int | None = None,
|
||||
) -> int:
|
||||
def count_customers(session: Session, query: CustomerQuery) -> int:
|
||||
"""统计符合条件的客户总数,供分页使用。
|
||||
|
||||
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||||
``_apply_filters`` 单点保证)。
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(Customer.id)),
|
||||
keyword=keyword,
|
||||
customer_type=customer_type,
|
||||
owner_personnel_id=owner_personnel_id,
|
||||
)
|
||||
stmt = _apply_filters(select(func.count(Customer.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@@ -101,9 +81,7 @@ def list_ownership_logs(
|
||||
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
|
||||
return ownership_log_repository.list_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
ref_column=OwnershipChangeLog.customer_id,
|
||||
ref_id=customer_id,
|
||||
_customer_log_ref(customer_id),
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
@@ -111,8 +89,12 @@ def list_ownership_logs(
|
||||
|
||||
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
|
||||
"""统计某客户的归属留痕总数,供分页使用。"""
|
||||
return ownership_log_repository.count_by_ref(
|
||||
session,
|
||||
return ownership_log_repository.count_by_ref(session, _customer_log_ref(customer_id))
|
||||
|
||||
|
||||
def _customer_log_ref(customer_id: int) -> OwnershipLogRef:
|
||||
"""客户维度的归属留痕锚点。"""
|
||||
return OwnershipLogRef(
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
ref_column=OwnershipChangeLog.customer_id,
|
||||
ref_id=customer_id,
|
||||
@@ -126,20 +108,14 @@ def _base_select() -> Select:
|
||||
)
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
keyword: str | None,
|
||||
customer_type: str | None,
|
||||
owner_personnel_id: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||
if keyword:
|
||||
stmt = stmt.where(_keyword_clause(keyword))
|
||||
if customer_type:
|
||||
stmt = stmt.where(Customer.customer_type == customer_type)
|
||||
if owner_personnel_id is not None:
|
||||
stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id)
|
||||
if query.keyword:
|
||||
stmt = stmt.where(_keyword_clause(query.keyword))
|
||||
if query.customer_type:
|
||||
stmt = stmt.where(Customer.customer_type == query.customer_type)
|
||||
if query.owner_personnel_id is not None:
|
||||
stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id)
|
||||
return stmt
|
||||
|
||||
|
||||
@@ -157,6 +133,23 @@ def _keyword_clause(keyword: str) -> ColumnElement[bool]:
|
||||
)
|
||||
|
||||
|
||||
def _apply_order(stmt: Select, query: CustomerQuery) -> Select:
|
||||
"""应用排序。排序键已由 schema 白名单收口,此处只做键 → 列的映射。
|
||||
|
||||
MySQL 把 NULL 视为最小值:ASC 时未评分(null)会排最前,但「没打分」
|
||||
不该压在「打了低分」之上 —— ASC 分支用 ``is_(None)`` 布尔键把 null
|
||||
挪到末尾;DESC 分支 null 天然在最后,无需处理。尾部再按 id 倒序兜底,
|
||||
保证同分行的次序稳定(分页不跳行)。
|
||||
"""
|
||||
if query.order_by == "rating_score":
|
||||
score = Customer.rating_score
|
||||
if query.order == "asc":
|
||||
stmt = stmt.order_by(score.is_(None), score.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(score.desc())
|
||||
return stmt.order_by(Customer.id.desc())
|
||||
|
||||
|
||||
# ==================== 写入 ====================
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""工作台仓储:四区块的只读聚合查询;不 commit、不抛业务异常。
|
||||
|
||||
总览只做 SQL 聚合(SUM/COUNT DISTINCT),不复用报表的
|
||||
``department_summary``——后者会加载整月台账并逐团队查人员,适合报表页,
|
||||
不适合每次打开工作台。
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import exists, func, or_, select
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from app.models import (
|
||||
ApprovalOrder,
|
||||
Business,
|
||||
BusinessRole,
|
||||
MonthlySettlement,
|
||||
PerformanceLedger,
|
||||
Personnel,
|
||||
Receipt,
|
||||
Shipment,
|
||||
)
|
||||
from app.models.approval import (
|
||||
APPROVAL_STATUS_PENDING_FINANCE,
|
||||
APPROVAL_STATUS_PENDING_LEADER,
|
||||
)
|
||||
|
||||
_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1)
|
||||
_PENDING_STATUSES = (
|
||||
APPROVAL_STATUS_PENDING_FINANCE,
|
||||
APPROVAL_STATUS_PENDING_LEADER,
|
||||
)
|
||||
|
||||
|
||||
# ==== 经营绩效总览 ====
|
||||
|
||||
|
||||
def sum_month_performance(session: Session, month: str) -> dict:
|
||||
"""当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。"""
|
||||
return (
|
||||
session.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
|
||||
"performance_total"
|
||||
),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
|
||||
func.count(func.distinct(PerformanceLedger.personnel_id)).label("person_count"),
|
||||
).where(
|
||||
PerformanceLedger.settlement_month == month,
|
||||
*_ACTIVE_SINGLE,
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
|
||||
|
||||
def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
|
||||
"""近 N 个月绩效合计(按台账归属月)。"""
|
||||
rows = session.execute(
|
||||
select(
|
||||
PerformanceLedger.settlement_month,
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0),
|
||||
)
|
||||
.where(
|
||||
PerformanceLedger.settlement_month.in_(months),
|
||||
*_ACTIVE_SINGLE,
|
||||
)
|
||||
.group_by(PerformanceLedger.settlement_month)
|
||||
).all()
|
||||
return dict(rows)
|
||||
|
||||
|
||||
def receipt_trend(session: Session, *, start_date: date, end_date: date) -> dict[str, object]:
|
||||
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
|
||||
|
||||
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
|
||||
分组,不参与 WHERE,避免函数列导致全表扫描。
|
||||
"""
|
||||
month_expr = func.date_format(Receipt.receipt_date, "%Y-%m")
|
||||
rows = session.execute(
|
||||
select(month_expr, func.coalesce(func.sum(Receipt.amount), 0))
|
||||
.where(Receipt.receipt_date >= start_date, Receipt.receipt_date < end_date)
|
||||
.group_by(month_expr)
|
||||
).all()
|
||||
return dict(rows)
|
||||
|
||||
|
||||
def count_active_managers(session: Session) -> int:
|
||||
"""在职营销经理数(团队数口径,与部门汇总的团队枚举一致)。"""
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(Personnel)
|
||||
.join(BusinessRole, Personnel.business_role_id == BusinessRole.id)
|
||||
.where(
|
||||
BusinessRole.role_code == "sales_manager",
|
||||
Personnel.status == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def get_month_settlement(session: Session, month: str) -> MonthlySettlement | None:
|
||||
"""当月月结头(状态与锁定时间角标)。"""
|
||||
return session.execute(
|
||||
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
# ==== 待办事项 ====
|
||||
|
||||
|
||||
def list_pending_approvals(
|
||||
session: Session, *, statuses: tuple[int, ...], limit: int
|
||||
) -> list[ApprovalOrder]:
|
||||
"""待处理审批单(按提交时间升序——最久的排前面)。
|
||||
|
||||
``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。
|
||||
|
||||
排除 snapshot_json 列(快照可达上百 KB,待办只取单号/类型/提交人/时间)。
|
||||
"""
|
||||
if not statuses:
|
||||
return []
|
||||
return list(
|
||||
session.execute(
|
||||
select(ApprovalOrder)
|
||||
.options(defer(ApprovalOrder.snapshot_json))
|
||||
.where(ApprovalOrder.status.in_(statuses))
|
||||
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
|
||||
.limit(limit)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def count_pending_approvals(session: Session, *, statuses: tuple[int, ...]) -> int:
|
||||
if not statuses:
|
||||
return 0
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(ApprovalOrder)
|
||||
.where(ApprovalOrder.status.in_(statuses))
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def list_unassigned_receipts(session: Session, *, limit: int) -> list:
|
||||
"""回款未指定发货批次(shipment_id IS NULL,核算前置缺失)。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(
|
||||
Receipt,
|
||||
Business.business_no.label("business_no"),
|
||||
)
|
||||
.outerjoin(Business, Business.id == Receipt.business_id)
|
||||
.where(Receipt.shipment_id.is_(None))
|
||||
.order_by(Receipt.receipt_date.asc(), Receipt.id.asc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def count_unassigned_receipts(session: Session) -> int:
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count()).select_from(Receipt).where(Receipt.shipment_id.is_(None))
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
# ==== 风险预警 ====
|
||||
|
||||
|
||||
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
|
||||
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
|
||||
|
||||
已终止业务(status=3)不再催收,排除在预警之外;业务行缺失时仍保留,
|
||||
避免历史脏数据让发货单从预警中静默消失。
|
||||
"""
|
||||
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
|
||||
return list(
|
||||
session.execute(
|
||||
select(
|
||||
Shipment,
|
||||
Business.business_no.label("business_no"),
|
||||
)
|
||||
.outerjoin(Business, Business.id == Shipment.business_id)
|
||||
.where(
|
||||
Shipment.ship_date <= cutoff,
|
||||
~has_receipt,
|
||||
or_(Business.status.is_(None), Business.status != 3),
|
||||
)
|
||||
.order_by(Shipment.ship_date.asc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def count_overdue_shipments(session: Session, *, cutoff: date) -> int:
|
||||
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(Shipment)
|
||||
.outerjoin(Business, Business.id == Shipment.business_id)
|
||||
.where(
|
||||
Shipment.ship_date <= cutoff,
|
||||
~has_receipt,
|
||||
or_(Business.status.is_(None), Business.status != 3),
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def list_overdue_approvals(
|
||||
session: Session, *, cutoff: datetime, limit: int
|
||||
) -> list[ApprovalOrder]:
|
||||
"""提交后超过阈值仍未处理的审批单(审批超时规则)。
|
||||
|
||||
排除 snapshot_json 列(同待办:预警只用单号/类型/提交人/时间)。
|
||||
"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(ApprovalOrder)
|
||||
.options(defer(ApprovalOrder.snapshot_json))
|
||||
.where(
|
||||
ApprovalOrder.status.in_(_PENDING_STATUSES),
|
||||
ApprovalOrder.create_time < cutoff,
|
||||
)
|
||||
.order_by(ApprovalOrder.create_time.asc())
|
||||
.limit(limit)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def count_overdue_approvals(session: Session, *, cutoff: datetime) -> int:
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(ApprovalOrder)
|
||||
.where(
|
||||
ApprovalOrder.status.in_(_PENDING_STATUSES),
|
||||
ApprovalOrder.create_time < cutoff,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
"""登录日志数据访问。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -10,68 +12,73 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
|
||||
_USER_AGENT_MAX_LEN = 255
|
||||
|
||||
|
||||
def add(
|
||||
session: Session,
|
||||
*,
|
||||
user_id: int | None,
|
||||
username: str,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
success: bool,
|
||||
fail_reason: str | None = None,
|
||||
) -> None:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LoginLogEntry:
|
||||
"""一条待写入的登录日志(全字段 DTO)。
|
||||
|
||||
与 ``OperationLogEntry`` 同款做法:写入字段平铺传递时,任何一处漏传
|
||||
都只表现为日志字段缺失、不报错(success 默认 0 的语义尤其隐蔽)。
|
||||
"""
|
||||
|
||||
user_id: int | None
|
||||
username: str
|
||||
success: bool
|
||||
ip: str | None = None
|
||||
user_agent: str | None = None
|
||||
fail_reason: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LoginLogListQuery:
|
||||
"""登录日志列表的查询条件(筛选 + 分页)。"""
|
||||
|
||||
filters: LogFilters
|
||||
offset: int = 0
|
||||
limit: int = 20
|
||||
ip: str | None = None
|
||||
|
||||
|
||||
def add(session: Session, entry: LoginLogEntry) -> None:
|
||||
"""写入一条登录日志。只 flush 不 commit。
|
||||
|
||||
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
|
||||
|
||||
:param user_id: 账号不存在时传 None(列本身可空)
|
||||
:param username: 用户名原样记录,便于账号不存在时也能追溯
|
||||
:param entry.user_id: 账号不存在时为 None(列本身可空)
|
||||
:param entry.username: 用户名原样记录,便于账号不存在时也能追溯
|
||||
"""
|
||||
session.add(
|
||||
LoginLog(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent[:_USER_AGENT_MAX_LEN] if user_agent else None,
|
||||
success=1 if success else 0,
|
||||
fail_reason=fail_reason,
|
||||
user_id=entry.user_id,
|
||||
username=entry.username,
|
||||
ip=entry.ip,
|
||||
user_agent=entry.user_agent[:_USER_AGENT_MAX_LEN] if entry.user_agent else None,
|
||||
success=1 if entry.success else 0,
|
||||
fail_reason=entry.fail_reason,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def list_logs(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
filters: LogFilters,
|
||||
ip: str | None = None,
|
||||
) -> list[LoginLog]:
|
||||
def list_logs(session: Session, query: LoginLogListQuery) -> list[LoginLog]:
|
||||
"""分页查询登录日志。
|
||||
|
||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
||||
"""
|
||||
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
|
||||
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(select(LoginLog), query)
|
||||
stmt = stmt.order_by(LoginLog.id.desc()).offset(query.offset).limit(query.limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_logs(
|
||||
session: Session,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
ip: str | None = None,
|
||||
) -> int:
|
||||
def count_logs(session: Session, query: LoginLogListQuery) -> int:
|
||||
"""统计符合条件的登录日志总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
|
||||
stmt = _apply_filters(select(func.count(LoginLog.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
|
||||
def _apply_filters(stmt: Select, query: LoginLogListQuery) -> Select:
|
||||
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
|
||||
stmt = apply_log_filters(stmt, LoginLog, filters)
|
||||
if ip:
|
||||
stmt = stmt.where(LoginLog.ip == ip)
|
||||
stmt = apply_log_filters(stmt, LoginLog, query.filters)
|
||||
if query.ip:
|
||||
stmt = stmt.where(LoginLog.ip == query.ip)
|
||||
return stmt
|
||||
@@ -107,15 +107,18 @@ def get_by_id(session: Session, log_id: int) -> OperationLog | None:
|
||||
return session.get(OperationLog, log_id)
|
||||
|
||||
|
||||
def list_logs(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
filters: LogFilters,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
) -> list[OperationLog]:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperationLogListQuery:
|
||||
"""操作日志列表的查询条件(筛选 + 分页)。"""
|
||||
|
||||
filters: LogFilters
|
||||
offset: int = 0
|
||||
limit: int = 20
|
||||
module: str | None = None
|
||||
action: str | None = None
|
||||
|
||||
|
||||
def list_logs(session: Session, query: OperationLogListQuery) -> list[OperationLog]:
|
||||
"""分页查询操作日志。
|
||||
|
||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||
@@ -127,41 +130,34 @@ def list_logs(
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
select(OperationLog).options(defer(OperationLog.request_data)),
|
||||
filters=filters,
|
||||
module=module,
|
||||
action=action,
|
||||
query,
|
||||
)
|
||||
stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit)
|
||||
stmt = stmt.order_by(OperationLog.id.desc()).offset(query.offset).limit(query.limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_logs(
|
||||
session: Session,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
) -> int:
|
||||
def count_logs(session: Session, query: OperationLogListQuery) -> int:
|
||||
"""统计符合条件的日志总数,供分页使用。"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
|
||||
)
|
||||
stmt = _apply_filters(select(func.count(OperationLog.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
module: str | None,
|
||||
action: str | None,
|
||||
) -> Select:
|
||||
def distinct_modules(session: Session) -> list[str]:
|
||||
"""全部模块名(去重、按名称升序):工作台「最近动态」的模块筛选项。
|
||||
|
||||
交给 SQL 的 DISTINCT,不在应用层聚合 —— 应用层去重要把整列拉进内存。
|
||||
"""
|
||||
stmt = select(OperationLog.module).distinct().order_by(OperationLog.module)
|
||||
return [module for module in session.execute(stmt).scalars() if module]
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
|
||||
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
|
||||
stmt = apply_log_filters(stmt, OperationLog, filters)
|
||||
if module:
|
||||
stmt = stmt.where(OperationLog.module == module)
|
||||
if action:
|
||||
stmt = stmt.where(OperationLog.action == action)
|
||||
stmt = apply_log_filters(stmt, OperationLog, query.filters)
|
||||
if query.module:
|
||||
stmt = stmt.where(OperationLog.module == query.module)
|
||||
if query.action:
|
||||
stmt = stmt.where(OperationLog.action == query.action)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -5,18 +5,32 @@ owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧
|
||||
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Row, func, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import OwnershipChangeLog, Personnel
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnershipLogRef:
|
||||
"""归属留痕的引用锚点(维度类型 + 引用列 + 引用 ID)。
|
||||
|
||||
引用列是 SQLAlchemy 列表达式(如 ``OwnershipChangeLog.customer_id``),
|
||||
不是可序列化值 —— 故用冻结 dataclass 而非 Pydantic 模型。
|
||||
"""
|
||||
|
||||
owner_type: int
|
||||
ref_column: Any
|
||||
ref_id: int
|
||||
|
||||
|
||||
def list_by_ref(
|
||||
session: Session,
|
||||
ref: OwnershipLogRef,
|
||||
*,
|
||||
owner_type: int,
|
||||
ref_column,
|
||||
ref_id: int,
|
||||
offset: int,
|
||||
limit: int,
|
||||
) -> list[Row]:
|
||||
@@ -32,7 +46,7 @@ def list_by_ref(
|
||||
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
|
||||
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
|
||||
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
|
||||
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
|
||||
.where(OwnershipChangeLog.owner_type == ref.owner_type, ref.ref_column == ref.ref_id)
|
||||
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -40,13 +54,11 @@ def list_by_ref(
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_by_ref(
|
||||
session: Session, *, owner_type: int, ref_column, ref_id: int
|
||||
) -> int:
|
||||
def count_by_ref(session: Session, ref: OwnershipLogRef) -> int:
|
||||
"""统计某引用对象的归属留痕总数,供分页使用。"""
|
||||
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
||||
OwnershipChangeLog.owner_type == owner_type,
|
||||
ref_column == ref_id,
|
||||
OwnershipChangeLog.owner_type == ref.owner_type,
|
||||
ref.ref_column == ref.ref_id,
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import Select, delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import ParamChangeBatch, ParamDefinition, ParamValue
|
||||
from app.schemas.param import ParamBatchQuery, ParamHistoryQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@@ -97,27 +98,27 @@ def list_definitions_by_codes(
|
||||
|
||||
|
||||
def list_history(
|
||||
session: Session,
|
||||
param_id: int,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
dimension_value: str | None = None,
|
||||
session: Session, param_id: int, query: ParamHistoryQuery
|
||||
) -> list[tuple[ParamValue, ParamChangeBatch]]:
|
||||
"""单参数的版本历史(含批次信息),最新在前。"""
|
||||
stmt = _history_stmt(session, param_id, dimension_value=dimension_value)
|
||||
return [(value, batch) for value, batch in session.execute(stmt.offset(offset).limit(limit))]
|
||||
"""单参数的版本历史(含批次信息),最新在前。
|
||||
|
||||
``param_id`` 是「查哪个参数」的主锚点(由路径参数 param_code 解析而来),
|
||||
不属于列表筛选项,故仍以位置参数传入;筛选与分页走 ``query``。
|
||||
"""
|
||||
stmt = _history_stmt(param_id, query)
|
||||
return [
|
||||
(value, batch)
|
||||
for value, batch in session.execute(stmt.offset(query.offset).limit(query.page_size))
|
||||
]
|
||||
|
||||
|
||||
def count_history(session: Session, param_id: int, *, dimension_value: str | None = None) -> int:
|
||||
def count_history(session: Session, param_id: int, query: ParamHistoryQuery) -> int:
|
||||
"""统计单参数的历史条目数。"""
|
||||
stmt = select(func.count()).select_from(
|
||||
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
|
||||
)
|
||||
stmt = select(func.count()).select_from(_history_stmt(param_id, query).subquery())
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select:
|
||||
def _history_stmt(param_id: int, query: ParamHistoryQuery) -> Select:
|
||||
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
|
||||
stmt = (
|
||||
select(ParamValue, ParamChangeBatch)
|
||||
@@ -125,8 +126,8 @@ def _history_stmt(session: Session, param_id: int, *, dimension_value: str | Non
|
||||
.where(ParamValue.param_id == param_id)
|
||||
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
|
||||
)
|
||||
if dimension_value is not None:
|
||||
stmt = stmt.where(ParamValue.dimension_value == dimension_value)
|
||||
if query.dimension_value is not None:
|
||||
stmt = stmt.where(ParamValue.dimension_value == query.dimension_value)
|
||||
return stmt
|
||||
|
||||
|
||||
@@ -150,41 +151,30 @@ def get_batch_by_id(
|
||||
return session.get(ParamChangeBatch, batch_id)
|
||||
|
||||
|
||||
def list_batches(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
) -> list[ParamChangeBatch]:
|
||||
def list_batches(session: Session, query: ParamBatchQuery) -> list[ParamChangeBatch]:
|
||||
"""分页查询变更批次(按创建时间倒序)。"""
|
||||
stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date)
|
||||
stmt = _batch_filters(select(ParamChangeBatch), query)
|
||||
return list(
|
||||
session.execute(
|
||||
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def count_batches(
|
||||
session: Session, *, start_date: date | None = None, end_date: date | None = None
|
||||
) -> int:
|
||||
def count_batches(session: Session, query: ParamBatchQuery) -> int:
|
||||
"""统计符合条件的批次总数。"""
|
||||
stmt = _batch_filters(
|
||||
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
|
||||
)
|
||||
stmt = _batch_filters(select(func.count(ParamChangeBatch.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select:
|
||||
def _batch_filters(stmt: Select, query: ParamBatchQuery) -> Select:
|
||||
"""批次筛选(生效日期区间,含边界)。列表与计数共用。"""
|
||||
if start_date is not None:
|
||||
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
|
||||
if end_date is not None:
|
||||
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
|
||||
if query.start_date is not None:
|
||||
stmt = stmt.where(ParamChangeBatch.effective_date >= query.start_date)
|
||||
if query.end_date is not None:
|
||||
stmt = stmt.where(ParamChangeBatch.effective_date <= query.end_date)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
|
||||
from app.repositories.query_utils import fuzzy
|
||||
from app.schemas.personnel import PersonnelQuery
|
||||
|
||||
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
|
||||
_manager = aliased(Personnel)
|
||||
@@ -30,44 +31,23 @@ def get_with_names(session: Session, personnel_id: int) -> Row | None:
|
||||
return session.execute(stmt).first()
|
||||
|
||||
|
||||
def list_personnel(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
business_role_id: int | None = None,
|
||||
) -> list[Row]:
|
||||
def list_personnel(session: Session, query: PersonnelQuery) -> list[Row]:
|
||||
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
|
||||
|
||||
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
_base_select(), keyword=keyword, status=status, business_role_id=business_role_id
|
||||
)
|
||||
stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(_base_select(), query)
|
||||
stmt = stmt.order_by(Personnel.id.desc()).offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_personnel(
|
||||
session: Session,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
business_role_id: int | None = None,
|
||||
) -> int:
|
||||
def count_personnel(session: Session, query: PersonnelQuery) -> int:
|
||||
"""统计符合条件的人员总数,供分页使用。
|
||||
|
||||
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||||
``_apply_filters`` 单点保证)。
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(Personnel.id)),
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
business_role_id=business_role_id,
|
||||
)
|
||||
stmt = _apply_filters(select(func.count(Personnel.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@@ -154,20 +134,14 @@ def _base_select() -> Select:
|
||||
)
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
keyword: str | None,
|
||||
status: int | None,
|
||||
business_role_id: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: PersonnelQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||
if keyword:
|
||||
stmt = fuzzy(stmt, Personnel.personnel_name, keyword)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Personnel.status == status)
|
||||
if business_role_id is not None:
|
||||
stmt = stmt.where(Personnel.business_role_id == business_role_id)
|
||||
if query.keyword:
|
||||
stmt = fuzzy(stmt, Personnel.personnel_name, query.keyword)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(Personnel.status == query.status)
|
||||
if query.business_role_id is not None:
|
||||
stmt = stmt.where(Personnel.business_role_id == query.business_role_id)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import BusinessItem, Product, ShipmentItem
|
||||
from app.schemas.product import ProductQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@@ -34,50 +35,20 @@ def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Pr
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_products(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
product_category: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> list[Product]:
|
||||
def list_products(session: Session, query: ProductQuery) -> list[Product]:
|
||||
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
|
||||
stmt = _apply_filters(
|
||||
select(Product),
|
||||
keyword=keyword,
|
||||
product_category=product_category,
|
||||
status=status,
|
||||
)
|
||||
stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(select(Product), query)
|
||||
stmt = stmt.order_by(Product.id).offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_products(
|
||||
session: Session,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
product_category: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> int:
|
||||
def count_products(session: Session, query: ProductQuery) -> int:
|
||||
"""统计符合条件的产品总数,供分页使用。"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(Product.id)),
|
||||
keyword=keyword,
|
||||
product_category=product_category,
|
||||
status=status,
|
||||
)
|
||||
stmt = _apply_filters(select(func.count(Product.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
keyword: str | None,
|
||||
product_category: str | None,
|
||||
status: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
|
||||
@@ -86,17 +57,17 @@ def _apply_filters(
|
||||
写 contains —— M1 business_role_repository 的双列关键词同款写法。
|
||||
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
|
||||
"""
|
||||
if keyword:
|
||||
if query.keyword:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Product.product_name.contains(keyword, autoescape=True),
|
||||
Product.spec_model.contains(keyword, autoescape=True),
|
||||
Product.product_name.contains(query.keyword, autoescape=True),
|
||||
Product.spec_model.contains(query.keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if product_category:
|
||||
stmt = stmt.where(Product.product_category == product_category)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Product.status == status)
|
||||
if query.product_category:
|
||||
stmt = stmt.where(Product.product_category == query.product_category)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(Product.status == query.status)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -32,8 +32,9 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati
|
||||
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
|
||||
"""按主键查询。
|
||||
|
||||
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
|
||||
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
|
||||
等级表无外键引用(客户档案只存总分,等级由分数按当前映射实时推导,
|
||||
不存等级 id),无需 ``for_update`` 锁行 —— product_repository 锁行防的
|
||||
是并发引用悬空,此处不存在该风险:删掉一个等级,只是以后不再判到它。
|
||||
"""
|
||||
return session.get(CustomerRatingGrade, grade_id)
|
||||
|
||||
@@ -60,6 +61,10 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
|
||||
|
||||
|
||||
def delete(session: Session, grade: CustomerRatingGrade) -> None:
|
||||
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
|
||||
"""物理删除等级。
|
||||
|
||||
等级表无外键引用(客户档案只存总分,不存等级 id),调用方无需引用保护
|
||||
校验:删掉等级只是「以后不再判到它」,已存的总分不受影响。
|
||||
"""
|
||||
session.delete(grade)
|
||||
session.flush()
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Business, Receipt, Shipment
|
||||
from app.schemas.receipt import ReceiptQuery
|
||||
|
||||
|
||||
def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
|
||||
@@ -43,72 +44,42 @@ def get_shipment(session: Session, shipment_id: int) -> Shipment | None:
|
||||
return session.get(Shipment, shipment_id)
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
business_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
is_barter: int | None,
|
||||
settlement_status: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select:
|
||||
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
|
||||
if business_id is not None:
|
||||
stmt = stmt.where(Receipt.business_id == business_id)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(Receipt.receipt_date >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(Receipt.receipt_date <= date_to)
|
||||
if is_barter is not None:
|
||||
stmt = stmt.where(Receipt.is_barter == is_barter)
|
||||
if settlement_status is not None:
|
||||
stmt = stmt.where(Receipt.settlement_status == settlement_status)
|
||||
if query.business_id is not None:
|
||||
stmt = stmt.where(Receipt.business_id == query.business_id)
|
||||
if query.date_from is not None:
|
||||
stmt = stmt.where(Receipt.receipt_date >= query.date_from)
|
||||
if query.date_to is not None:
|
||||
stmt = stmt.where(Receipt.receipt_date <= query.date_to)
|
||||
if query.is_barter is not None:
|
||||
stmt = stmt.where(Receipt.is_barter == query.is_barter)
|
||||
# settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段
|
||||
if query.settlement_status is not None:
|
||||
stmt = stmt.where(Receipt.settlement_status == query.settlement_status)
|
||||
return stmt
|
||||
|
||||
|
||||
def list_receipts(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
business_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
is_barter: int | None,
|
||||
settlement_status: int | None,
|
||||
session: Session, query: ReceiptQuery
|
||||
) -> list[tuple[Receipt, str, str | None, date | None]]:
|
||||
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
|
||||
stmt = _apply_filters(
|
||||
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
|
||||
.join(Business, Business.id == Receipt.business_id)
|
||||
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
|
||||
business_id=business_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
is_barter=is_barter,
|
||||
settlement_status=settlement_status,
|
||||
query,
|
||||
)
|
||||
return list(
|
||||
session.execute(
|
||||
stmt.order_by(Receipt.id.desc()).offset(query.offset).limit(query.page_size)
|
||||
).all()
|
||||
)
|
||||
return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all())
|
||||
|
||||
|
||||
def count_receipts(
|
||||
session: Session,
|
||||
*,
|
||||
business_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
is_barter: int | None,
|
||||
settlement_status: int | None,
|
||||
) -> int:
|
||||
def count_receipts(session: Session, query: ReceiptQuery) -> int:
|
||||
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count()).select_from(Receipt),
|
||||
business_id=business_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
is_barter=is_barter,
|
||||
settlement_status=settlement_status,
|
||||
)
|
||||
stmt = _apply_filters(select(func.count()).select_from(Receipt), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
"""报表统计仓储:只读聚合查询;不 commit、不抛业务异常。
|
||||
|
||||
报表与台账分页查询刻意分开而非复用 ``performance_ledger_repository``:
|
||||
- 台账查询允许聚合行(calc_type=2),报表明细必须显式排除——经理聚合
|
||||
行 receipt_id 为 NULL,混入明细会让合同编号/天数列失真、业务统计的
|
||||
绩效被整团队聚合额污染;
|
||||
- 月结的 ``_confirm_scope`` 带 ``settlement_id IS NULL``(可入结口径),
|
||||
报表要看的是同月**全量**生效单笔行,口径不同;
|
||||
- 聚合需求(SUM/GROUP BY)台账仓储完全没有,报表在此统一实现。
|
||||
|
||||
发货额/回款额直接读 businesses 的汇总冗余列(服务层事务内单点维护),
|
||||
不逐业务子查询发货/回款表;绩效额 businesses 无冗余列,用子查询联台账。
|
||||
"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.report_meta import DayBucket
|
||||
from app.models import (
|
||||
AdjustmentOrder,
|
||||
Business,
|
||||
Customer,
|
||||
MonthlySettlement,
|
||||
PerformanceLedger,
|
||||
Personnel,
|
||||
)
|
||||
from app.schemas.report import (
|
||||
BusinessStatsQuery,
|
||||
PaymentDetailQuery,
|
||||
PaymentStatsQuery,
|
||||
PersonalDetailQuery,
|
||||
)
|
||||
|
||||
# 单笔生效行基础条件:报表四表共用(聚合行与已替代版本一律排除)
|
||||
_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1)
|
||||
|
||||
|
||||
# ==== 个人绩效明细(FR-029)====
|
||||
|
||||
|
||||
def _personal_filters(query: PersonalDetailQuery) -> tuple:
|
||||
filters = [*_ACTIVE_SINGLE]
|
||||
if query.settlement_month:
|
||||
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
|
||||
if query.personnel_id is not None:
|
||||
filters.append(PerformanceLedger.personnel_id == query.personnel_id)
|
||||
if query.customer_type:
|
||||
filters.append(PerformanceLedger.customer_type == query.customer_type)
|
||||
if query.product_category:
|
||||
filters.append(PerformanceLedger.product_category == query.product_category)
|
||||
if query.role_code:
|
||||
filters.append(PerformanceLedger.role_code == query.role_code)
|
||||
return tuple(filters)
|
||||
|
||||
|
||||
def list_personal_detail(
|
||||
session: Session, query: PersonalDetailQuery, *, offset: int, limit: int
|
||||
) -> list[PerformanceLedger]:
|
||||
"""个人绩效明细分页(回款日倒序,同人按版本可追溯)。"""
|
||||
stmt = select(PerformanceLedger).where(*_personal_filters(query))
|
||||
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_personal_detail(session: Session, query: PersonalDetailQuery) -> int:
|
||||
"""个人绩效明细计数(与 list_personal_detail 同筛选)。"""
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count()).select_from(PerformanceLedger).where(*_personal_filters(query))
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def sum_personal_detail(session: Session, query: PersonalDetailQuery) -> dict:
|
||||
"""全筛选口径合计(分页与合计同条件,避免「页脚合计≠全部结果」)。"""
|
||||
return (
|
||||
session.execute(
|
||||
select(
|
||||
func.count().label("row_count"),
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
|
||||
"performance_total"
|
||||
),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
|
||||
func.coalesce(func.sum(PerformanceLedger.converted_profit), 0).label(
|
||||
"converted_profit_total"
|
||||
),
|
||||
).where(*_personal_filters(query))
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
|
||||
|
||||
# ==== 部门汇总取数(FR-030)====
|
||||
|
||||
|
||||
def list_month_receipt_rows(session: Session, month: str) -> list[PerformanceLedger]:
|
||||
"""同月全量生效单笔台账行(部门汇总基数与成员明细的取数)。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.settlement_month == month, *_ACTIVE_SINGLE
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def list_month_manager_aggregates(session: Session, month: str) -> list[PerformanceLedger]:
|
||||
"""同月经理聚合行(calc_type=2):已生成月结时的冻结对照值。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.settlement_month == month,
|
||||
PerformanceLedger.calc_type == 2,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def list_month_adjustments(session: Session, month: str) -> list[AdjustmentOrder]:
|
||||
"""同月已生效调整单(status=2)——报表汇总并入的口径。
|
||||
|
||||
与 list_adjustments(调整单管理页)不同:必须按月筛选且只取生效行,
|
||||
待审批/已驳回/已撤回的金额绝不能混进报表。
|
||||
"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(AdjustmentOrder).where(
|
||||
AdjustmentOrder.settlement_month == month,
|
||||
AdjustmentOrder.status == 2,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def list_month_settlement(session: Session, month: str) -> MonthlySettlement | None:
|
||||
"""同月月结头(部门汇总展示锁定状态用;无则 None)。"""
|
||||
return session.execute(
|
||||
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
# ==== 业务统计(FR-031 之一)====
|
||||
|
||||
|
||||
def _business_perf_subquery():
|
||||
"""业务维度绩效子查询(排除聚合行,防 12.5%/5% 聚合额污染单业务绩效)。"""
|
||||
return (
|
||||
select(
|
||||
PerformanceLedger.business_id.label("business_id"),
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label("perf"),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable"),
|
||||
)
|
||||
.where(*_ACTIVE_SINGLE)
|
||||
.group_by(PerformanceLedger.business_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
|
||||
def _business_filters(query: BusinessStatsQuery) -> tuple:
|
||||
filters = []
|
||||
if query.keyword:
|
||||
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 转义通配符,
|
||||
# 与 business_repository._apply_filters 同口径(双列 OR 用法为该处既定)
|
||||
filters.append(
|
||||
Business.business_no.contains(query.keyword, autoescape=True)
|
||||
| Customer.company_name.contains(query.keyword, autoescape=True)
|
||||
)
|
||||
if query.customer_id is not None:
|
||||
filters.append(Business.customer_id == query.customer_id)
|
||||
if query.owner_personnel_id is not None:
|
||||
filters.append(Business.owner_personnel_id == query.owner_personnel_id)
|
||||
if query.status is not None:
|
||||
filters.append(Business.status == query.status)
|
||||
if query.date_from is not None:
|
||||
filters.append(Business.business_date >= query.date_from)
|
||||
if query.date_to is not None:
|
||||
filters.append(Business.business_date <= query.date_to)
|
||||
return tuple(filters)
|
||||
|
||||
|
||||
def _business_select(query: BusinessStatsQuery):
|
||||
perf_sq = _business_perf_subquery()
|
||||
return (
|
||||
select(
|
||||
Business,
|
||||
Customer.company_name.label("customer_name"),
|
||||
Personnel.personnel_name.label("owner_personnel_name"),
|
||||
perf_sq.c.perf.label("performance_amount"),
|
||||
perf_sq.c.payable.label("payable_amount"),
|
||||
)
|
||||
.outerjoin(Customer, Customer.id == Business.customer_id)
|
||||
.outerjoin(Personnel, Personnel.id == Business.owner_personnel_id)
|
||||
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
|
||||
.where(*_business_filters(query))
|
||||
)
|
||||
|
||||
|
||||
def list_business_stats(
|
||||
session: Session, query: BusinessStatsQuery, *, offset: int, limit: int
|
||||
) -> list:
|
||||
"""业务统计分页(业务日期倒序)。"""
|
||||
stmt = _business_select(query).order_by(Business.id.desc()).offset(offset).limit(limit)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_business_stats(session: Session, query: BusinessStatsQuery) -> int:
|
||||
"""业务统计计数(联客户表因关键字筛选涉及客户名)。"""
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(Business)
|
||||
.outerjoin(Customer, Customer.id == Business.customer_id)
|
||||
.where(*_business_filters(query))
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict:
|
||||
"""业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。"""
|
||||
perf_sq = _business_perf_subquery()
|
||||
return (
|
||||
session.execute(
|
||||
select(
|
||||
func.count().label("row_count"),
|
||||
func.coalesce(func.sum(Business.total_ship_amount), 0).label("ship_total"),
|
||||
func.coalesce(func.sum(Business.total_received_amount), 0).label("received_total"),
|
||||
func.coalesce(func.sum(Business.total_sale_amount), 0).label("sale_total"),
|
||||
func.coalesce(func.sum(perf_sq.c.perf), 0).label("performance_total"),
|
||||
func.coalesce(func.sum(perf_sq.c.payable), 0).label("payable_total"),
|
||||
)
|
||||
.outerjoin(Customer, Customer.id == Business.customer_id)
|
||||
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
|
||||
.where(*_business_filters(query))
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
|
||||
|
||||
# ==== 回款统计(FR-031 之二)====
|
||||
|
||||
|
||||
def _payment_filters(query: PaymentStatsQuery | PaymentDetailQuery) -> tuple:
|
||||
filters = [*_ACTIVE_SINGLE]
|
||||
if query.settlement_month:
|
||||
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
|
||||
if query.date_from is not None:
|
||||
filters.append(PerformanceLedger.receipt_date >= query.date_from)
|
||||
if query.date_to is not None:
|
||||
filters.append(PerformanceLedger.receipt_date <= query.date_to)
|
||||
if query.customer_type:
|
||||
filters.append(PerformanceLedger.customer_type == query.customer_type)
|
||||
if query.product_category:
|
||||
filters.append(PerformanceLedger.product_category == query.product_category)
|
||||
return tuple(filters)
|
||||
|
||||
|
||||
def group_payment_by_days(session: Session, query: PaymentStatsQuery) -> list:
|
||||
"""按(回款天数精确值, 客户类型, 产品类别)聚合绩效与应付。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(
|
||||
PerformanceLedger.payment_days,
|
||||
PerformanceLedger.customer_type,
|
||||
PerformanceLedger.product_category,
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
|
||||
"performance_total"
|
||||
),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
|
||||
func.count().label("ledger_count"),
|
||||
)
|
||||
.where(*_payment_filters(query))
|
||||
.group_by(
|
||||
PerformanceLedger.payment_days,
|
||||
PerformanceLedger.customer_type,
|
||||
PerformanceLedger.product_category,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def group_payment_receipt_by_days(session: Session, query: PaymentStatsQuery) -> list:
|
||||
"""按维度聚合回款金额与笔数——金额侧必须按 receipt_id 去重。
|
||||
|
||||
二次分摊拆行使同一回款在台账出现多行,直接 SUM(receipts.amount) 会
|
||||
按拆行数翻倍;台账拆行只改归属人,不改维度值(payment_days/客户类型/
|
||||
产品类别),因此先对台账行 DISTINCT 出回款维度,再联回款表求和。
|
||||
"""
|
||||
distinct_receipts = (
|
||||
select(
|
||||
PerformanceLedger.receipt_id.label("receipt_id"),
|
||||
PerformanceLedger.payment_days.label("payment_days"),
|
||||
PerformanceLedger.customer_type.label("customer_type"),
|
||||
PerformanceLedger.product_category.label("product_category"),
|
||||
)
|
||||
.where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None))
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
from app.models import Receipt
|
||||
|
||||
return list(
|
||||
session.execute(
|
||||
select(
|
||||
distinct_receipts.c.payment_days,
|
||||
distinct_receipts.c.customer_type,
|
||||
distinct_receipts.c.product_category,
|
||||
func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"),
|
||||
func.count(distinct_receipts.c.receipt_id).label("receipt_count"),
|
||||
)
|
||||
.outerjoin(Receipt, Receipt.id == distinct_receipts.c.receipt_id)
|
||||
.group_by(
|
||||
distinct_receipts.c.payment_days,
|
||||
distinct_receipts.c.customer_type,
|
||||
distinct_receipts.c.product_category,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def group_payment_by_customer_type(session: Session, query: PaymentStatsQuery) -> list:
|
||||
"""按客户类型聚合(绩效 + 回款金额)。"""
|
||||
return _group_by_dimension(session, query, PerformanceLedger.customer_type)
|
||||
|
||||
|
||||
def group_payment_by_product_category(session: Session, query: PaymentStatsQuery) -> list:
|
||||
"""按产品类别聚合(绩效 + 回款金额)。"""
|
||||
return _group_by_dimension(session, query, PerformanceLedger.product_category)
|
||||
|
||||
|
||||
def _group_by_dimension(session: Session, query: PaymentStatsQuery, dim_column) -> list:
|
||||
"""维度聚合共用实现(绩效侧 GROUP BY + 金额侧 DISTINCT 去重联回款)。"""
|
||||
from app.models import Receipt
|
||||
|
||||
dim_perf = (
|
||||
select(
|
||||
dim_column.label("dim"),
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
|
||||
"performance_total"
|
||||
),
|
||||
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
|
||||
func.count().label("ledger_count"),
|
||||
)
|
||||
.where(*_payment_filters(query))
|
||||
.group_by(dim_column)
|
||||
)
|
||||
dim_receipts = (
|
||||
select(
|
||||
dim_column.label("dim"),
|
||||
PerformanceLedger.receipt_id.label("receipt_id"),
|
||||
)
|
||||
.where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None))
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
perf_rows = {row.dim: row for row in session.execute(dim_perf).all()}
|
||||
receipt_rows = session.execute(
|
||||
select(
|
||||
dim_receipts.c.dim,
|
||||
func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"),
|
||||
)
|
||||
.outerjoin(Receipt, Receipt.id == dim_receipts.c.receipt_id)
|
||||
.group_by(dim_receipts.c.dim)
|
||||
).all()
|
||||
receipt_amounts = {row.dim: row.receipt_amount for row in receipt_rows}
|
||||
return [
|
||||
(
|
||||
row.dim,
|
||||
receipt_amounts.get(row.dim),
|
||||
row.performance_total,
|
||||
row.payable_total,
|
||||
row.ledger_count,
|
||||
)
|
||||
for row in perf_rows.values()
|
||||
]
|
||||
|
||||
|
||||
def _bucket_condition(bucket: DayBucket) -> tuple:
|
||||
"""区间桶的 days 过滤条件:none 桶 IS NULL,其余按 min/max 闭区间。"""
|
||||
if bucket.code == "none":
|
||||
return (PerformanceLedger.payment_days.is_(None),)
|
||||
conds = []
|
||||
if bucket.min_days is not None:
|
||||
conds.append(PerformanceLedger.payment_days >= bucket.min_days)
|
||||
if bucket.max_days is not None:
|
||||
conds.append(PerformanceLedger.payment_days <= bucket.max_days)
|
||||
return tuple(conds)
|
||||
|
||||
|
||||
def list_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> list:
|
||||
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
|
||||
|
||||
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
|
||||
"""
|
||||
from app.models import Receipt
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
PerformanceLedger,
|
||||
Receipt.amount.label("receipt_amount"),
|
||||
Receipt.receipt_batch_no.label("receipt_batch_no"),
|
||||
)
|
||||
.outerjoin(Receipt, Receipt.id == PerformanceLedger.receipt_id)
|
||||
.where(*_payment_filters(query), *_bucket_condition(bucket))
|
||||
)
|
||||
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
|
||||
stmt = stmt.offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> int:
|
||||
"""区间下钻计数(与 list_payment_detail 同筛选)。"""
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(PerformanceLedger)
|
||||
.where(*_payment_filters(query), *_bucket_condition(bucket))
|
||||
).scalar_one()
|
||||
)
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Role, UserRole
|
||||
from app.schemas.role import RoleQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@@ -47,44 +48,37 @@ def get_all_roles(session: Session) -> list[Role]:
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def list_roles(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> list[Role]:
|
||||
def list_roles(session: Session, query: RoleQuery) -> list[Role]:
|
||||
"""分页查询角色(管理列表:含停用角色)。
|
||||
|
||||
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
|
||||
"""
|
||||
stmt = _apply_filters(select(Role), keyword=keyword, status=status)
|
||||
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(select(Role), query)
|
||||
stmt = stmt.order_by(Role.sort_order, Role.id).offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
|
||||
def count_roles(session: Session, query: RoleQuery) -> int:
|
||||
"""统计符合条件的角色总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
|
||||
stmt = _apply_filters(select(func.count(Role.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
|
||||
def _apply_filters(stmt: Select, query: RoleQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
|
||||
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
|
||||
"""
|
||||
if keyword:
|
||||
if query.keyword:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Role.role_code.contains(keyword, autoescape=True),
|
||||
Role.role_name.contains(keyword, autoescape=True),
|
||||
Role.role_code.contains(query.keyword, autoescape=True),
|
||||
Role.role_name.contains(query.keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Role.status == status)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(Role.status == query.status)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""月度结算仓储:只读写数据并 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, *, for_update: bool = False
|
||||
) -> MonthlySettlement | None:
|
||||
"""月结行;``for_update`` 供提交审批路径加行锁用。
|
||||
|
||||
提交要算审批单版本号(同一月结的第几次送审),并发提交会让两次
|
||||
算出同一个版本号;锁住月结行使并发提交串行化。
|
||||
"""
|
||||
if for_update:
|
||||
return session.get(MonthlySettlement, settlement_id, with_for_update=True)
|
||||
return session.get(MonthlySettlement, settlement_id)
|
||||
|
||||
|
||||
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()
|
||||
@@ -8,13 +8,13 @@
|
||||
业务级汇总仍收口 business_service._recalc_totals
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Select, delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Business, Receipt, Shipment, ShipmentItem
|
||||
from app.schemas.shipment import ShipmentQuery
|
||||
|
||||
|
||||
def get_by_id(session: Session, shipment_id: int) -> Shipment | None:
|
||||
@@ -31,59 +31,37 @@ def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment,
|
||||
).first()
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
business_id: int | None,
|
||||
ship_date_from: date | None,
|
||||
ship_date_to: date | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: ShipmentQuery) -> Select:
|
||||
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
|
||||
if business_id is not None:
|
||||
stmt = stmt.where(Shipment.business_id == business_id)
|
||||
if ship_date_from is not None:
|
||||
stmt = stmt.where(Shipment.ship_date >= ship_date_from)
|
||||
if ship_date_to is not None:
|
||||
stmt = stmt.where(Shipment.ship_date <= ship_date_to)
|
||||
if query.business_id is not None:
|
||||
stmt = stmt.where(Shipment.business_id == query.business_id)
|
||||
if query.ship_date_from is not None:
|
||||
stmt = stmt.where(Shipment.ship_date >= query.ship_date_from)
|
||||
if query.ship_date_to is not None:
|
||||
stmt = stmt.where(Shipment.ship_date <= query.ship_date_to)
|
||||
return stmt
|
||||
|
||||
|
||||
def list_shipments(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
business_id: int | None,
|
||||
ship_date_from: date | None,
|
||||
ship_date_to: date | None,
|
||||
) -> list[tuple[Shipment, str]]:
|
||||
def list_shipments(session: Session, query: ShipmentQuery) -> list[tuple[Shipment, str]]:
|
||||
"""分页查询发货单(附业务号),按 id 倒序。"""
|
||||
stmt = _apply_filters(
|
||||
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
|
||||
business_id=business_id,
|
||||
ship_date_from=ship_date_from,
|
||||
ship_date_to=ship_date_to,
|
||||
query,
|
||||
)
|
||||
return list(
|
||||
session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all()
|
||||
session.execute(
|
||||
stmt.order_by(Shipment.id.desc()).offset(query.offset).limit(query.page_size)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def count_shipments(
|
||||
session: Session,
|
||||
*,
|
||||
business_id: int | None,
|
||||
ship_date_from: date | None,
|
||||
ship_date_to: date | None,
|
||||
) -> int:
|
||||
def count_shipments(session: Session, query: ShipmentQuery) -> int:
|
||||
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count())
|
||||
.select_from(Shipment)
|
||||
.join(Business, Business.id == Shipment.business_id),
|
||||
business_id=business_id,
|
||||
ship_date_from=ship_date_from,
|
||||
ship_date_to=ship_date_to,
|
||||
query,
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""T+ 管理库访问;只 flush,事务由 service 管理。"""
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from app.models import TPlusSyncBatch, TPlusSyncError
|
||||
|
||||
|
||||
def get_batch(
|
||||
session: Session, batch_no: str, *, for_update: bool = False, with_result: bool = False
|
||||
):
|
||||
"""按批次号查询;普通查询不加载核验大字段。"""
|
||||
statement = select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == batch_no)
|
||||
if not with_result:
|
||||
statement = statement.options(defer(TPlusSyncBatch.audit_result_json))
|
||||
if for_update:
|
||||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||
return session.scalar(statement)
|
||||
|
||||
|
||||
def get_request(session: Session, key: str, *, for_update: bool = False):
|
||||
"""锁读用于唯一约束竞争后的当前读,避免旧事务快照看不到新任务。"""
|
||||
statement = select(TPlusSyncBatch).where(TPlusSyncBatch.request_key == key)
|
||||
if for_update:
|
||||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||
return session.scalar(statement)
|
||||
|
||||
|
||||
def active_retry(session: Session, parent_no: str):
|
||||
"""调用方先锁父批次,再用当前读判断是否已有未完成重试。"""
|
||||
return session.scalar(
|
||||
select(TPlusSyncBatch)
|
||||
.where(
|
||||
TPlusSyncBatch.parent_batch_no == parent_no,
|
||||
TPlusSyncBatch.status.in_(("QUEUED", "RUNNING")),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
|
||||
def increment_retry_errors(session: Session, parent_no: str) -> None:
|
||||
"""创建新重试时对原异常累计重试次数。"""
|
||||
session.execute(
|
||||
update(TPlusSyncError)
|
||||
.where(TPlusSyncError.batch_no == parent_no)
|
||||
.values(retry_count=TPlusSyncError.retry_count + 1)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def next_queued(session: Session):
|
||||
"""调用方持有账套执行锁后领取最早排队任务。"""
|
||||
return session.scalar(
|
||||
select(TPlusSyncBatch)
|
||||
.where(TPlusSyncBatch.status == "QUEUED")
|
||||
.order_by(TPlusSyncBatch.id)
|
||||
.limit(1)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
|
||||
def running_batches(session: Session) -> list[TPlusSyncBatch]:
|
||||
"""只在持有执行锁时使用,不能依据时间戳判断任务已经停止。"""
|
||||
return list(
|
||||
session.scalars(
|
||||
select(TPlusSyncBatch).where(TPlusSyncBatch.status == "RUNNING").with_for_update()
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def list_batches(session: Session, query):
|
||||
"""分页只读元数据,核验报告和原异常不随列表加载。"""
|
||||
filters = []
|
||||
if query.status:
|
||||
filters.append(TPlusSyncBatch.status == query.status)
|
||||
if query.task_type:
|
||||
filters.append(
|
||||
TPlusSyncBatch.sync_mode == ("AUDIT" if query.task_type == "AUDIT" else "FULL")
|
||||
)
|
||||
total = session.scalar(select(func.count()).select_from(TPlusSyncBatch).where(*filters))
|
||||
rows = session.scalars(
|
||||
select(TPlusSyncBatch)
|
||||
.where(*filters)
|
||||
.options(defer(TPlusSyncBatch.audit_result_json), defer(TPlusSyncBatch.error_message))
|
||||
.order_by(TPlusSyncBatch.id.desc())
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
).all()
|
||||
return rows, total or 0
|
||||
|
||||
|
||||
def active_retry_parents(session: Session, numbers: list[str]) -> set[str]:
|
||||
"""整页一次获取未完成的重试,避免逐行查询。"""
|
||||
if not numbers:
|
||||
return set()
|
||||
return set(
|
||||
session.scalars(
|
||||
select(TPlusSyncBatch.parent_batch_no).where(
|
||||
TPlusSyncBatch.parent_batch_no.in_(numbers),
|
||||
TPlusSyncBatch.status.in_(("QUEUED", "RUNNING")),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def list_errors(session: Session, number: str, query):
|
||||
"""只读取当前批次的异常定位元数据。"""
|
||||
filters = [TPlusSyncError.batch_no == number]
|
||||
if query.entity_type:
|
||||
filters.append(TPlusSyncError.entity_type == query.entity_type)
|
||||
total = session.scalar(select(func.count()).select_from(TPlusSyncError).where(*filters))
|
||||
rows = session.scalars(
|
||||
select(TPlusSyncError)
|
||||
.where(*filters)
|
||||
.options(defer(TPlusSyncError.error_message))
|
||||
.order_by(TPlusSyncError.id.desc())
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
).all()
|
||||
return rows, total or 0
|
||||
|
||||
|
||||
def status_counts(session: Session) -> dict[str, int]:
|
||||
"""汇总任务表,不扫描原始快照。"""
|
||||
return dict(
|
||||
session.execute(
|
||||
select(TPlusSyncBatch.status, func.count()).group_by(TPlusSyncBatch.status)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def latest_audit(session: Session):
|
||||
"""最近完成的核验;返回批次供查询层过滤报告。"""
|
||||
return session.scalar(
|
||||
select(TPlusSyncBatch)
|
||||
.where(
|
||||
TPlusSyncBatch.sync_mode == "AUDIT",
|
||||
TPlusSyncBatch.finished_at.is_not(None),
|
||||
)
|
||||
.order_by(TPlusSyncBatch.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
|
||||
def total_staged_records(session: Session) -> int:
|
||||
"""从批次元数据汇总累计新增数,避免扫描原始快照。"""
|
||||
return (
|
||||
session.scalar(select(func.coalesce(func.sum(TPlusSyncBatch.staged_record_count), 0))) or 0
|
||||
)
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models import Role, User, UserRole
|
||||
from app.repositories.query_utils import fuzzy
|
||||
from app.schemas.user import UserQuery
|
||||
|
||||
|
||||
def _base_query() -> Select[tuple[User]]:
|
||||
@@ -48,16 +49,7 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None:
|
||||
# ==================== 列表查询 ====================
|
||||
|
||||
|
||||
def list_users(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
username: str | None = None,
|
||||
real_name: str | None = None,
|
||||
status: int | None = None,
|
||||
role_code: str | None = None,
|
||||
) -> list[User]:
|
||||
def list_users(session: Session, query: UserQuery) -> list[User]:
|
||||
"""分页查询用户列表。
|
||||
|
||||
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
|
||||
@@ -65,48 +57,35 @@ def list_users(
|
||||
id 是自增主键,天然唯一且与创建顺序一致。
|
||||
"""
|
||||
stmt = _base_query()
|
||||
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
|
||||
if role_code:
|
||||
stmt = _apply_filters(stmt, query)
|
||||
if query.role_code:
|
||||
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
|
||||
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
|
||||
stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit)
|
||||
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
|
||||
stmt = stmt.order_by(User.id.desc()).offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).scalars().unique())
|
||||
|
||||
|
||||
def count_users(
|
||||
session: Session,
|
||||
*,
|
||||
username: str | None = None,
|
||||
real_name: str | None = None,
|
||||
status: int | None = None,
|
||||
role_code: str | None = None,
|
||||
) -> int:
|
||||
def count_users(session: Session, query: UserQuery) -> int:
|
||||
"""统计符合条件的用户总数,供分页使用。"""
|
||||
stmt = select(func.count(User.id))
|
||||
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
|
||||
if role_code:
|
||||
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
|
||||
stmt = _apply_filters(stmt, query)
|
||||
if query.role_code:
|
||||
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
username: str | None,
|
||||
real_name: str | None,
|
||||
status: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: UserQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
|
||||
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
|
||||
"""
|
||||
if username:
|
||||
stmt = fuzzy(stmt, User.username, username)
|
||||
if real_name:
|
||||
stmt = fuzzy(stmt, User.real_name, real_name)
|
||||
if status is not None:
|
||||
stmt = stmt.where(User.status == status)
|
||||
if query.username:
|
||||
stmt = fuzzy(stmt, User.username, query.username)
|
||||
if query.real_name:
|
||||
stmt = fuzzy(stmt, User.real_name, query.real_name)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(User.status == query.status)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
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
|
||||
version_no: int
|
||||
status: int
|
||||
submitter_id: int | None = None
|
||||
submitter_name: str
|
||||
create_time: datetime
|
||||
|
||||
|
||||
class ApprovalOrderDetailOut(ApprovalOrderOut):
|
||||
"""审批单详情:附节点时间线与审批内容。
|
||||
|
||||
``content_source`` 语义:``snapshot``=提交时快照(可信);
|
||||
``live``=历史单无快照、回退读当前业务内容(仅供参考);
|
||||
``missing``=引用对象已不存在且无快照。
|
||||
"""
|
||||
|
||||
steps: list[ApprovalStepOut] = Field(default_factory=list)
|
||||
snapshot: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="提交时内容快照(信封:schema_version/order_type/captured_at/data)",
|
||||
)
|
||||
content_source: Literal["snapshot", "live", "missing"] = Field(
|
||||
default="missing", description="审批内容来源:提交时快照/当前内容回退/缺失"
|
||||
)
|
||||
|
||||
|
||||
class ApprovalQuery(PageQuery):
|
||||
"""审批单列表查询条件。"""
|
||||
|
||||
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=3, 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
|
||||
@@ -151,10 +151,64 @@ class BusinessOut(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
class AllocationVersionOut(SecondaryAllocationOut):
|
||||
"""方案版本行(含历史;业务详情抽屉回溯用)。"""
|
||||
|
||||
version_no: int
|
||||
is_active: int = Field(description="1当前生效 0历史版本")
|
||||
create_time: datetime
|
||||
|
||||
|
||||
def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
|
||||
"""同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示
|
||||
(让 400 文案说「重复参与人」而不是裸唯一键冲突)。"""
|
||||
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,仅展示)"
|
||||
)
|
||||
allocation_history: list[AllocationVersionOut] = Field(
|
||||
default_factory=list, description="方案全部版本(含历史;被驳回方案亦保留)"
|
||||
)
|
||||
|
||||
|
||||
class BusinessCreateIn(BaseModel):
|
||||
@@ -175,6 +229,9 @@ class BusinessCreateIn(BaseModel):
|
||||
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")
|
||||
@@ -184,9 +241,14 @@ class BusinessCreateIn(BaseModel):
|
||||
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)。"""
|
||||
"""编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
@@ -207,6 +269,9 @@ class BusinessUpdateIn(BaseModel):
|
||||
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")
|
||||
@@ -215,6 +280,11 @@ class BusinessUpdateIn(BaseModel):
|
||||
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已终止(终态不可再变更)。"""
|
||||
|
||||
@@ -27,6 +27,19 @@ class BusinessRoleOut(BaseModel):
|
||||
is_builtin: int = 0
|
||||
|
||||
|
||||
class BusinessRoleOptionOut(BaseModel):
|
||||
"""岗位下拉选项(人员档案与报表筛选共用)。
|
||||
|
||||
与 ``BusinessRoleOut`` 分开:下拉只需渲染用的最小字段集,不必顺带
|
||||
暴露计提模板等管理字段。字段名直出蛇形(前端按同名取值)。
|
||||
"""
|
||||
|
||||
id: int = Field(description="岗位ID")
|
||||
role_code: str = Field(description="岗位编码")
|
||||
role_name: str = Field(description="岗位名称")
|
||||
status: int = Field(description="状态:1启用 0禁用")
|
||||
|
||||
|
||||
class TemplateParamOut(BaseModel):
|
||||
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,7 @@
|
||||
"""客户管理的请求/响应模型(需求 2.1.3)。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -28,6 +29,8 @@ class CustomerOut(BaseModel):
|
||||
owner_personnel_id: int | None = None
|
||||
owner_personnel_name: str | None = None
|
||||
remark: str | None = None
|
||||
rating_score: int | None = None
|
||||
rating_time: datetime | None = None
|
||||
|
||||
|
||||
class CustomerCreateIn(BaseModel):
|
||||
@@ -90,13 +93,32 @@ class CustomerUpdateIn(BaseModel):
|
||||
|
||||
|
||||
class CustomerQuery(PageQuery):
|
||||
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。"""
|
||||
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
|
||||
|
||||
排序用 ``Literal`` 白名单而不是自由字符串:``order_by`` 直接拼进
|
||||
``ORDER BY``,自由值等于把注入面开到排序键上;客户列表只有两个
|
||||
有意义的排序键,白名单足够。
|
||||
"""
|
||||
|
||||
keyword: str | None = Field(
|
||||
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
||||
)
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
||||
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
||||
order_by: Literal["id", "rating_score"] = Field(
|
||||
default="id", description="排序键:id 建档序 / rating_score 评级总分"
|
||||
)
|
||||
order: Literal["asc", "desc"] = Field(default="desc", description="排序方向")
|
||||
|
||||
|
||||
class CustomerRatingIn(BaseModel):
|
||||
"""保存客户评级入参(评级计算器「保存评分」)。
|
||||
|
||||
score 是五项之和的整数(0-100);五项明细不落库(评级记录只存总分,
|
||||
见对照表 #25 的定位:评分是档案属性而非审计事件)。
|
||||
"""
|
||||
|
||||
score: int = Field(ge=0, le=100, description="评级总分(0-100 整数)")
|
||||
|
||||
|
||||
class CustomerOwnershipIn(BaseModel):
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""工作台的请求/响应模型(M4 ⑧)。
|
||||
|
||||
聚合端点一次返回三区块(前端一次请求渲染整页,避免多次往返):
|
||||
经营绩效总览 / 待办事项 / 风险预警。金额一律 Decimal
|
||||
(序列化为字符串),前端不做数值运算。
|
||||
|
||||
**最近动态独立成分页端点**:它可能有很长(操作日志全量),塞进聚合
|
||||
响应既撑爆页面又只能截断前 20 条;拆开后翻页只重取这张列表,不必
|
||||
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
|
||||
|
||||
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
|
||||
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
|
||||
不返回伪造的 0 条。
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
class TrendPoint(BaseModel):
|
||||
"""近 6 个月趋势点(绩效按台账归属月,回款按到账月)。"""
|
||||
|
||||
month: str
|
||||
performance_total: Decimal | None = None
|
||||
receipt_total: Decimal | None = None
|
||||
|
||||
|
||||
class SettlementBrief(BaseModel):
|
||||
"""当月月结状态(工作台总览角标)。"""
|
||||
|
||||
exists: bool = False
|
||||
status: int | None = Field(default=None, description="1草稿 2待审批 3已审定 4已驳回")
|
||||
total_performance: Decimal | None = None
|
||||
total_payable: Decimal | None = None
|
||||
locked_at: datetime | None = None
|
||||
|
||||
|
||||
class OverviewBlock(BaseModel):
|
||||
"""经营绩效总览(FR-033):口径与台账/月结一致。"""
|
||||
|
||||
month: str
|
||||
performance_total: Decimal | None = Field(default=None, description="当月单笔绩效合计")
|
||||
payable_total: Decimal | None = Field(default=None, description="当月应付绩效合计")
|
||||
person_count: int = Field(default=0, description="当月有台账的人员数(拆行去重)")
|
||||
team_count: int = Field(default=0, description="在职营销经理数(团队数)")
|
||||
settlement: SettlementBrief
|
||||
trend: list[TrendPoint] = Field(default_factory=list, description="近 6 个月")
|
||||
note: str = Field(default="", description="口径说明(页面展示)")
|
||||
|
||||
|
||||
class ApprovalTodoItem(BaseModel):
|
||||
"""待审批条目。"""
|
||||
|
||||
order_id: int
|
||||
order_no: str
|
||||
order_type: int
|
||||
submitter_name: str | None = None
|
||||
create_time: datetime
|
||||
|
||||
|
||||
class ReceiptTodoItem(BaseModel):
|
||||
"""回款未指定发货批次条目。"""
|
||||
|
||||
receipt_id: int
|
||||
receipt_batch_no: str
|
||||
business_id: int
|
||||
business_no: str | None = None
|
||||
amount: Decimal
|
||||
receipt_date: date
|
||||
|
||||
|
||||
class TodoBlock(BaseModel):
|
||||
"""待办事项(FR-034,澄清 #11 起步范围)。"""
|
||||
|
||||
pending_approval_total: int = 0
|
||||
pending_approvals: list[ApprovalTodoItem] = Field(default_factory=list)
|
||||
unassigned_receipt_total: int = 0
|
||||
unassigned_receipts: list[ReceiptTodoItem] = Field(default_factory=list)
|
||||
sync_available: bool = Field(default=False, description="⑥ 同步模块是否已启用")
|
||||
sync_failure_total: int = 0
|
||||
|
||||
|
||||
class WarningItem(BaseModel):
|
||||
"""风险预警条目(实时派生,不改变业务数据)。"""
|
||||
|
||||
code: str = Field(description="规则编码:receipt_overdue / approval_timeout")
|
||||
level: str = Field(description="级别:warning / danger")
|
||||
object_type: str
|
||||
object_id: int
|
||||
title: str
|
||||
detail: str
|
||||
suggest: str
|
||||
link: str | None = Field(default=None, description="前端跳转路径")
|
||||
|
||||
|
||||
class WarningBlock(BaseModel):
|
||||
"""风险预警(FR-035,首批两条可算规则;同步规则待 ⑥)。"""
|
||||
|
||||
enabled: bool = True
|
||||
receipt_overdue_days: int = 90
|
||||
approval_timeout_days: int = 3
|
||||
items: list[WarningItem] = Field(default_factory=list)
|
||||
total: int = Field(default=0, description="全部规则命中总数(items 仅为展示上限内的子集)")
|
||||
receipt_overdue_total: int = 0
|
||||
approval_timeout_total: int = 0
|
||||
sync_available: bool = False
|
||||
|
||||
|
||||
class ActivityItem(BaseModel):
|
||||
"""最近动态条目(来源 operation_logs,只读聚合)。"""
|
||||
|
||||
id: int
|
||||
module: str
|
||||
action: str
|
||||
summary: str
|
||||
username: str | None = None
|
||||
success: int
|
||||
create_time: datetime
|
||||
|
||||
|
||||
class ActivityQuery(PageQuery):
|
||||
"""最近动态查询条件:分页 + 模块筛选。
|
||||
|
||||
只支持模块筛选(工作台只需要这一个维度);其余筛选项见日志查询页。
|
||||
"""
|
||||
|
||||
module: str | None = Field(default=None, max_length=64, description="模块,精确匹配")
|
||||
|
||||
|
||||
class ActivityPageOut(BaseModel):
|
||||
"""最近动态分页结果:当前页 + 总数 + 模块筛选项。
|
||||
|
||||
分页字段名沿用全站规范(list/total/pageNum/pageSize),与
|
||||
`PageResult` 同形;额外带 `modules`(全量模块名),
|
||||
供前端模块下拉使用 —— 不随当前页变化,避免翻页后选项增减。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
records: list[ActivityItem] = Field(
|
||||
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
|
||||
)
|
||||
total: int = Field(default=0, description="总记录数")
|
||||
page_num: int = Field(
|
||||
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
|
||||
)
|
||||
page_size: int = Field(
|
||||
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
|
||||
)
|
||||
modules: list[str] = Field(default_factory=list, description="全部模块名(筛选项)")
|
||||
|
||||
|
||||
class DashboardOut(BaseModel):
|
||||
"""工作台三区块一次性响应(最近动态走独立分页端点)。"""
|
||||
|
||||
month: str
|
||||
overview: OverviewBlock
|
||||
todos: TodoBlock
|
||||
warnings: WarningBlock
|
||||
@@ -0,0 +1,371 @@
|
||||
"""报表统计的请求/响应模型(M4 ⑤)。
|
||||
|
||||
金额与比例一律 `Decimal`(pydantic v2 序列化为字符串,零精度损失);
|
||||
比例原样输出 `rate_text`(如 88/9000),禁止转 float 或截断展示。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.month import validate_month
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
def _required_month(value: str) -> str:
|
||||
"""必填月份:非法直接抛 ValueError,由 FastAPI 压成 400。"""
|
||||
return validate_month(value)
|
||||
|
||||
|
||||
def _optional_month(value: str | None) -> str | None:
|
||||
"""可选月份:空串规范为 None(axios 未剔除时也不会误报格式错误)。"""
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return validate_month(value)
|
||||
|
||||
|
||||
# ==== 查询入参 ====
|
||||
|
||||
|
||||
class PersonalDetailQuery(PageQuery):
|
||||
"""个人绩效明细查询条件(FR-029:人员/月份/客户类型/产品类别/岗位)。"""
|
||||
|
||||
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
|
||||
personnel_id: int | None = Field(default=None, ge=1, description="人员筛选")
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
||||
role_code: str | None = Field(default=None, max_length=32, description="岗位筛选")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str | None) -> str | None:
|
||||
return _optional_month(value)
|
||||
|
||||
|
||||
class DepartmentSummaryQuery(BaseModel):
|
||||
"""部门汇总查询条件(月份必填——部门表按单月聚合,无跨月视角)。"""
|
||||
|
||||
settlement_month: str = Field(min_length=7, max_length=7, description="归属月 YYYY-MM")
|
||||
manager_id: int | None = Field(default=None, ge=1, description="仅看某经理团队")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str) -> str:
|
||||
return _required_month(value)
|
||||
|
||||
|
||||
class BusinessStatsQuery(PageQuery):
|
||||
"""业务统计查询条件;回款/绩效为该业务累计值,不随业务日期筛选而变。"""
|
||||
|
||||
keyword: str | None = Field(default=None, max_length=64, description="业务编号/客户名称")
|
||||
customer_id: int | None = Field(default=None, ge=1, description="客户筛选")
|
||||
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人筛选")
|
||||
status: int | None = Field(default=None, description="业务状态筛选")
|
||||
date_from: date | None = Field(default=None, description="业务日期起")
|
||||
date_to: date | None = Field(default=None, description="业务日期止")
|
||||
|
||||
|
||||
class PaymentStatsQuery(BaseModel):
|
||||
"""回款统计查询条件(按回款天数区间/客户类型/产品类别统计,FR-031)。"""
|
||||
|
||||
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
|
||||
date_from: date | None = Field(default=None, description="回款日期起")
|
||||
date_to: date | None = Field(default=None, description="回款日期止")
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str | None) -> str | None:
|
||||
return _optional_month(value)
|
||||
|
||||
|
||||
class PaymentDetailQuery(PageQuery):
|
||||
"""回款区间下钻(桶编码必填,入参由 payment-stats 页面展开行携带)。"""
|
||||
|
||||
bucket: str = Field(min_length=1, max_length=16, description="区间桶编码")
|
||||
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
|
||||
date_from: date | None = Field(default=None, description="回款日期起")
|
||||
date_to: date | None = Field(default=None, description="回款日期止")
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str | None) -> str | None:
|
||||
return _optional_month(value)
|
||||
|
||||
|
||||
# ==== 响应出参 ====
|
||||
|
||||
|
||||
class ReportSummary(BaseModel):
|
||||
"""全筛选口径合计(与「当前页合计」区分,前端页脚需注明)。
|
||||
|
||||
字段按报表取用:个人明细用绩效/应付/折算毛利;业务统计用发货/回款/
|
||||
销售额;不涉及的留 None。
|
||||
"""
|
||||
|
||||
row_count: int = Field(default=0, description="筛选结果行数")
|
||||
performance_total: Decimal | None = Field(default=None, description="绩效金额合计")
|
||||
payable_total: Decimal | None = Field(default=None, description="应付绩效合计")
|
||||
converted_profit_total: Decimal | None = Field(default=None, description="折算毛利润合计")
|
||||
ship_total: Decimal | None = Field(default=None, description="发货额合计")
|
||||
received_total: Decimal | None = Field(default=None, description="回款额合计")
|
||||
sale_total: Decimal | None = Field(default=None, description="销售额合计")
|
||||
|
||||
|
||||
class ReportPage[T](BaseModel):
|
||||
"""报表分页:标准分页四字段 + 全筛选合计。
|
||||
|
||||
序列化字段与 PageResult 一致(list/total/pageNum/pageSize),前端
|
||||
usePagedList 可直接消费;追加的 summary 由各页页脚单独读取。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
records: list[T] = Field(
|
||||
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
|
||||
)
|
||||
total: int = Field(default=0, description="总记录数")
|
||||
page_num: int = Field(
|
||||
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
|
||||
)
|
||||
page_size: int = Field(
|
||||
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
|
||||
)
|
||||
summary: ReportSummary = Field(default_factory=ReportSummary, description="全筛选合计")
|
||||
|
||||
|
||||
class PersonalDetailRow(BaseModel):
|
||||
"""个人绩效明细行(需规 2.4.1 列全覆盖)。
|
||||
|
||||
二次分摊拆行后同一回款按人多行(负责人余额行 + 参与人份额行),行内
|
||||
不放「关联回款金额」列——同回款金额重复展示会让合计无法自洽,金额
|
||||
下沉到快照下钻。
|
||||
"""
|
||||
|
||||
ledger_id: int
|
||||
business_id: int
|
||||
business_no: str
|
||||
personnel_id: int
|
||||
personnel_name: str
|
||||
role_code: str
|
||||
role_name: str
|
||||
customer_type: str
|
||||
product_category: str
|
||||
ship_date: date | None = None
|
||||
receipt_date: date
|
||||
payment_days: int | None = None
|
||||
rate_text: str
|
||||
weight_r: int | None = None
|
||||
payment_weight: Decimal | None = None
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
converted_profit: Decimal | None = None
|
||||
is_probation: int
|
||||
missing_cost: int
|
||||
calc_version: int
|
||||
settlement_id: int | None = None
|
||||
|
||||
|
||||
class DepartmentMemberRow(BaseModel):
|
||||
"""部门汇总 · 团队成员明细(内联于团队行)。"""
|
||||
|
||||
personnel_id: int
|
||||
personnel_name: str
|
||||
role_code: str
|
||||
is_probation: int
|
||||
member_kind: str = Field(
|
||||
default="regular",
|
||||
description="行来源:regular 普通台账 / manager_personal 经理个人户 / adjustment 仅调整单",
|
||||
)
|
||||
unassigned_reason: str | None = Field(
|
||||
default=None, description="未纳入团队基数的原因(仅未分配块)"
|
||||
)
|
||||
performance_sum: Decimal | None = None
|
||||
payable_sum: Decimal | None = None
|
||||
adjust_extra: Decimal = Field(default=Decimal("0"), description="调整单生效补发合计")
|
||||
adjust_deduct: Decimal = Field(default=Decimal("0"), description="调整单生效扣减合计")
|
||||
adjust_net: Decimal = Field(default=Decimal("0"), description="调整净额(补发为正)")
|
||||
net_total: Decimal | None = None
|
||||
ledger_ids: list[int] = Field(default_factory=list, description="台账行 id 列表(下钻锚点)")
|
||||
|
||||
|
||||
class DepartmentTeamRow(BaseModel):
|
||||
"""部门汇总 · 经理团队行。"""
|
||||
|
||||
manager_id: int
|
||||
manager_name: str
|
||||
manager_status: int = Field(description="经理在职状态(1 在职 0 离职)")
|
||||
member_count: int
|
||||
team_base: Decimal = Field(description="团队基数(12.5% 的底,等于成员绩效合计)")
|
||||
team_share_amount: Decimal = Field(description="12.5% 团队分成(分列展示)")
|
||||
company_share_amount: Decimal = Field(
|
||||
description="单经理 5% 名义值;该团队无月结聚合行时为 0(不发放)"
|
||||
)
|
||||
has_aggregate: bool = Field(
|
||||
default=False, description="本月是否有月结聚合行(决定是否发放 5%)"
|
||||
)
|
||||
aggregate_amount: Decimal = Field(
|
||||
default=Decimal("0"),
|
||||
description="月结聚合行应发(12.5%+5% 一次舍入);无聚合行时 0",
|
||||
)
|
||||
team_subtotal: Decimal = Field(
|
||||
default=Decimal("0"),
|
||||
description="团队应发小计 = 聚合行金额 + 调整净额",
|
||||
)
|
||||
company_base_ratio: Decimal = Field(description="团队基数占全公司基数比例(除零为 0)")
|
||||
adjust_extra: Decimal = Decimal("0")
|
||||
adjust_deduct: Decimal = Decimal("0")
|
||||
adjust_net: Decimal = Decimal("0")
|
||||
net_total: Decimal = Field(description="团队净额 = 团队应发小计")
|
||||
frozen_amount: Decimal | None = Field(default=None, description="月结聚合行冻结额(对照)")
|
||||
aggregate_ledger_id: int | None = None
|
||||
members: list[DepartmentMemberRow] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DepartmentCompanyBlock(BaseModel):
|
||||
"""部门汇总 · 全公司口径块(FR-030「全公司 5% 部分单列」)。"""
|
||||
|
||||
team_base_total: Decimal = Field(description="各团队基数之和(= 全部单笔绩效合计)")
|
||||
company_base: Decimal = Field(description="全公司基数(5% 的底)")
|
||||
company_share_pct: str = Field(description="分成比例参数原文")
|
||||
company_share_amount: Decimal = Field(description="单经理 5% 名义值")
|
||||
company_share_count: int = Field(
|
||||
default=0, description="计入应发的经理数(展示团队中有月结聚合行的数量)"
|
||||
)
|
||||
company_share_total: Decimal = Field(
|
||||
default=Decimal("0"), description="5% 合计 = 单份 × 计入次数"
|
||||
)
|
||||
|
||||
|
||||
class DepartmentReconciliation(BaseModel):
|
||||
"""部门汇总 · 对账块(导出/打印签字稿的合计口径,前端不自行求和)。
|
||||
|
||||
应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额。
|
||||
聚合行金额与月结 ``total_payable`` 同口径(12.5%+5% 一次舍入);
|
||||
``team_share_total`` 与 ``company_share_total`` 仅作分列展示,两者
|
||||
之和与聚合额的差记入 ``aggregate_rounding_diff``。
|
||||
"""
|
||||
|
||||
team_member_payable_total: Decimal = Field(description="展示团队成员的应付绩效合计")
|
||||
unassigned_payable_total: Decimal = Field(description="展示未分配人员的应付绩效合计")
|
||||
team_share_total: Decimal = Field(description="展示团队的 12.5% 分成合计(分列)")
|
||||
company_share_total: Decimal = Field(
|
||||
description="展示团队的全公司分成合计(每位有聚合行的经理各计一次,分列)"
|
||||
)
|
||||
aggregate_payable_total: Decimal = Field(
|
||||
description="展示团队的经理聚合行应发合计(12.5%+5% 一次舍入)"
|
||||
)
|
||||
aggregate_rounding_diff: Decimal = Field(
|
||||
default=Decimal("0"),
|
||||
description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)",
|
||||
)
|
||||
adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)")
|
||||
payable_total: Decimal = Field(
|
||||
description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额"
|
||||
)
|
||||
team_base_total: Decimal = Field(description="展示团队基数合计")
|
||||
unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计")
|
||||
other_team_base: Decimal = Field(
|
||||
default=Decimal("0"), description="未展示的基数(其他团队 + 全公司未分配)"
|
||||
)
|
||||
other_team_member_payable_total: Decimal = Field(default=Decimal("0"))
|
||||
other_team_share_total: Decimal = Field(default=Decimal("0"))
|
||||
other_team_company_share_total: Decimal = Field(default=Decimal("0"))
|
||||
other_team_aggregate_total: Decimal = Field(default=Decimal("0"))
|
||||
other_adjust_net_total: Decimal = Field(default=Decimal("0"))
|
||||
company_base: Decimal = Field(description="全公司基数(5% 的底)")
|
||||
|
||||
|
||||
class DepartmentSummaryOut(BaseModel):
|
||||
"""部门绩效工资汇总响应(FR-030)。"""
|
||||
|
||||
settlement_month: str
|
||||
params: dict | None = Field(default=None, description="分成参数原文与取值时点(对账用)")
|
||||
teams: list[DepartmentTeamRow] = Field(default_factory=list)
|
||||
unassigned: list[DepartmentMemberRow] = Field(
|
||||
default_factory=list, description="无经理人员的绩效(不参与分成,仅展示)"
|
||||
)
|
||||
company: DepartmentCompanyBlock | None = None
|
||||
reconciliation: DepartmentReconciliation | None = Field(
|
||||
default=None, description="签字稿对账块(应发公式各项)"
|
||||
)
|
||||
totals: dict = Field(default_factory=dict, description="全表合计(成员/团队对账自证)")
|
||||
|
||||
|
||||
class BusinessStatsRow(BaseModel):
|
||||
"""业务统计行(发货/回款/绩效为该业务累计值)。"""
|
||||
|
||||
business_id: int
|
||||
business_no: str
|
||||
business_date: date | None = None
|
||||
status: int
|
||||
customer_name: str | None = None
|
||||
owner_personnel_name: str | None = None
|
||||
total_sale_amount: Decimal | None = None
|
||||
total_ship_amount: Decimal | None = None
|
||||
total_received_amount: Decimal | None = None
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
|
||||
|
||||
class BucketRow(BaseModel):
|
||||
"""回款统计 · 区间桶行(空桶恒在且为 0,防金额静默消失)。"""
|
||||
|
||||
code: str
|
||||
label: str
|
||||
min_days: int | None = None
|
||||
max_days: int | None = None
|
||||
receipt_amount: Decimal | None = None
|
||||
receipt_count: int = 0
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
ledger_count: int = 0
|
||||
|
||||
|
||||
class GroupRow(BaseModel):
|
||||
"""回款统计 · 维度对比行(客户类型 / 产品类别)。"""
|
||||
|
||||
code: str
|
||||
label: str
|
||||
receipt_amount: Decimal | None = None
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
ledger_count: int = 0
|
||||
|
||||
|
||||
class PaymentStatsOut(BaseModel):
|
||||
"""回款情况统计响应(FR-031)。
|
||||
|
||||
口径:已计提回款(有台账行)的金额与绩效;进度登记回款无台账行,
|
||||
不在本表(页面需注明)。
|
||||
"""
|
||||
|
||||
settlement_month: str | None = None
|
||||
buckets: list[BucketRow] = Field(default_factory=list)
|
||||
by_customer_type: list[GroupRow] = Field(default_factory=list)
|
||||
by_product_category: list[GroupRow] = Field(default_factory=list)
|
||||
totals: dict = Field(default_factory=dict, description="全表合计")
|
||||
|
||||
|
||||
class PaymentDetailRow(BaseModel):
|
||||
"""回款区间下钻行(行级展示回款金额与批次号)。"""
|
||||
|
||||
ledger_id: int
|
||||
receipt_id: int | None = None
|
||||
receipt_batch_no: str | None = None
|
||||
amount: Decimal | None = Field(default=None, description="回款金额(同回款拆行重复展示)")
|
||||
business_id: int
|
||||
business_no: str
|
||||
personnel_id: int
|
||||
personnel_name: str
|
||||
customer_type: str
|
||||
product_category: str
|
||||
ship_date: date | None = None
|
||||
receipt_date: date
|
||||
payment_days: int | None = None
|
||||
performance_amount: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
@@ -0,0 +1,34 @@
|
||||
"""系统设置(公司信息)请求/响应模型。
|
||||
|
||||
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
|
||||
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
|
||||
顺着复用链泄漏到匿名端点。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SettingOut(BaseModel):
|
||||
"""公司信息(管理端)。"""
|
||||
|
||||
company_name: str
|
||||
logo_url: str | None = None
|
||||
|
||||
|
||||
class SettingPublicOut(BaseModel):
|
||||
"""公司信息(匿名端点白名单):登录页展示所需的两个字段。"""
|
||||
|
||||
company_name: str
|
||||
logo_url: str | None = None
|
||||
|
||||
|
||||
class SettingUpdateIn(BaseModel):
|
||||
"""公司信息更新。
|
||||
|
||||
str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1
|
||||
后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
company_name: str = Field(min_length=1, max_length=64, description="公司名称")
|
||||
@@ -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,109 @@
|
||||
"""T+ 管理接口合同;只暴露范围、数量和受控核验结果。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
EntityType = Literal[
|
||||
"personnel",
|
||||
"products",
|
||||
"customers",
|
||||
"businesses",
|
||||
"shipments",
|
||||
"receipts",
|
||||
"business_items",
|
||||
"shipment_items",
|
||||
"receipt_items",
|
||||
]
|
||||
TaskType = Literal["READ", "AUDIT"]
|
||||
TaskStatus = Literal["QUEUED", "RUNNING", "SUCCEEDED", "PARTIAL", "FAILED"]
|
||||
|
||||
|
||||
class TPlusCreateIn(BaseModel):
|
||||
"""创建长任务;幂等键是调用意图的一部分,必须由调用方保留。"""
|
||||
|
||||
task_type: TaskType = "READ"
|
||||
entity_types: list[EntityType] = Field(default_factory=list, max_length=9)
|
||||
request_key: UUID
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_scope(self):
|
||||
if self.task_type == "READ" and not self.entity_types:
|
||||
raise ValueError("请选择至少一种读取数据")
|
||||
if self.task_type == "AUDIT" and self.entity_types:
|
||||
raise ValueError("关联核验不接受自定义读取范围")
|
||||
if len(set(self.entity_types)) != len(self.entity_types):
|
||||
raise ValueError("读取类型不能重复")
|
||||
return self
|
||||
|
||||
|
||||
class TPlusRetryIn(BaseModel):
|
||||
"""失败批次重试不接受任意新范围。"""
|
||||
|
||||
request_key: UUID
|
||||
|
||||
|
||||
class TPlusBatchQuery(PageQuery):
|
||||
"""批次筛选,与读取状态和任务类型保持一致。"""
|
||||
|
||||
status: TaskStatus | None = None
|
||||
task_type: TaskType | None = None
|
||||
|
||||
|
||||
class TPlusErrorQuery(PageQuery):
|
||||
"""异常只能在指定批次内查询。"""
|
||||
|
||||
entity_type: EntityType | None = None
|
||||
|
||||
|
||||
class TPlusBatchOut(BaseModel):
|
||||
"""批次列表不包含原始数据、源配置或数据库异常。"""
|
||||
|
||||
batch_no: str
|
||||
task_type: TaskType
|
||||
status: str
|
||||
entity_types: list[str] | None
|
||||
source_record_count: int
|
||||
staged_record_count: int
|
||||
skipped_record_count: int
|
||||
failed_record_count: int
|
||||
parent_batch_no: str | None
|
||||
create_time: datetime
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
can_retry: bool
|
||||
error_summary: str | None = None
|
||||
|
||||
|
||||
class TPlusBatchDetailOut(TPlusBatchOut):
|
||||
"""详情按需携带受控的关联核验结果。"""
|
||||
|
||||
audit_result: dict | None = None
|
||||
|
||||
|
||||
class TPlusErrorOut(BaseModel):
|
||||
"""异常使用固定说明,源定位仅开放整数 ID。"""
|
||||
|
||||
id: int
|
||||
entity_type: str
|
||||
source_id: str | None
|
||||
error_code: str
|
||||
error_message: str
|
||||
retry_count: int
|
||||
create_time: datetime
|
||||
|
||||
|
||||
class TPlusOverviewOut(BaseModel):
|
||||
"""配置就绪不等同于源库连通或兼容验收通过。"""
|
||||
|
||||
is_configured: bool
|
||||
configuration_message: str
|
||||
status_counts: dict[str, int]
|
||||
total_staged_records: int = 0
|
||||
compatibility_status: str = "PENDING"
|
||||
business_import_enabled: bool = False
|
||||
latest_audit: dict | None = None
|
||||
@@ -0,0 +1,589 @@
|
||||
"""审批流业务编排(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, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from app.core.constants import (
|
||||
ACTION_APPROVE,
|
||||
ACTION_REJECT,
|
||||
MODULE_APPROVAL,
|
||||
NO_PREFIX_APPROVAL,
|
||||
ROLE_ADMIN,
|
||||
ROLE_FINANCE,
|
||||
ROLE_LEADER,
|
||||
)
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import ApprovalOrder, ApprovalStep, Business, 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,
|
||||
snapshot: dict | None = None,
|
||||
) -> ApprovalOrder:
|
||||
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
|
||||
|
||||
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
|
||||
(全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透:
|
||||
create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单
|
||||
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
|
||||
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
|
||||
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
|
||||
|
||||
``snapshot`` 是「提交时」的业务内容(由调用方组装,Decimal 已转 str);
|
||||
落库时包一层信封并固化采集时间——审批期间业务再改,复核者看到的仍是
|
||||
送审内容。开关关闭的自动通过路径同样写快照,留痕不走样。
|
||||
"""
|
||||
_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,
|
||||
version_no=_next_version(session, order_type, ref_id),
|
||||
snapshot_json=(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"order_type": order_type,
|
||||
"captured_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"data": snapshot,
|
||||
}
|
||||
if snapshot is not None
|
||||
else None
|
||||
),
|
||||
submitter_id=submitter.id,
|
||||
submitter_name=submitter.username,
|
||||
)
|
||||
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 pending_matches_snapshot(
|
||||
session: Session, *, order_type: int, ref_id: int, snapshot: dict | None
|
||||
) -> bool:
|
||||
"""在途审批单的送审内容是否与给定快照一致(用于「内容未变不重提」)。
|
||||
|
||||
只比较快照信封里的 ``data`` 部分——``captured_at`` 每次采集都不同,
|
||||
参与比较会让判断恒为不一致。V11 之前的历史单没有快照,一律视为不一致
|
||||
(保守重提:宁可多提一次,不能漏审)。
|
||||
"""
|
||||
if snapshot is None:
|
||||
return False
|
||||
pending = (
|
||||
session.execute(
|
||||
select(ApprovalOrder).where(
|
||||
ApprovalOrder.order_type == order_type,
|
||||
ApprovalOrder.ref_id == ref_id,
|
||||
ApprovalOrder.status.in_(_PENDING_STATUSES),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for order in pending:
|
||||
stored = order.snapshot_json or {}
|
||||
if stored.get("data") == snapshot:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def withdraw_pending_orders(
|
||||
session: Session, *, order_type: int, ref_id: int, operator: User
|
||||
) -> int:
|
||||
"""撤回引用对象的全部在途单(供业务清空方案等场景显式调用;不 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,
|
||||
)
|
||||
)
|
||||
# 驳回=方案未获批准:把该业务的生效方案行置历史(is_active=0,
|
||||
# **不删行**——V11 起 append-only 版本化),防止被否决的方案以
|
||||
# 「当前生效行」身份被 M3 核算快照算进工资;历史方案仍可在业务
|
||||
# 详情 allocation_history 回溯。委托 business_repository 完成
|
||||
# —— service 层互调、不触 commit。
|
||||
if rejected and order.order_type == 1:
|
||||
from app.repositories import business_repository
|
||||
|
||||
business_repository.deactivate_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 层依赖判定)。
|
||||
|
||||
排除 snapshot_json 列(月结快照含按人明细,可达数十~上百 KB):列表出参
|
||||
本就不含快照,整列加载会让翻页负担随快照体积线性放大;详情走
|
||||
`get_order` 按需读取(与操作日志列表排除 request_data 同一理由,
|
||||
见 operation_log_repository.list_logs)。
|
||||
"""
|
||||
filters = _order_filters(query)
|
||||
rows = (
|
||||
session.execute(
|
||||
select(ApprovalOrder)
|
||||
.options(defer(ApprovalOrder.snapshot_json))
|
||||
.where(*filters)
|
||||
.order_by(ApprovalOrder.id.desc())
|
||||
.offset(query.offset)
|
||||
.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 _next_version(session: Session, order_type: int, ref_id: int) -> int:
|
||||
"""同一对象类型+引用对象的下一个提交版本号(从 1 递增)。
|
||||
|
||||
**必须用锁读(当前读),不能用普通 MAX 查询**:普通 SELECT 在
|
||||
REPEATABLE READ 下是快照读,读到的是本事务首次一致读的快照;请求级
|
||||
鉴权(deps.get_current_user 与业务共用同一 DbSession)那次 SELECT 已把
|
||||
快照锚定在请求开始时刻,此后即使父行锁等到并发事务提交,MAX 仍停在
|
||||
旧快照上——两个请求会算出同一个 version_no。锁读是当前读,永远读最新
|
||||
已提交数据。
|
||||
|
||||
**不能写成 select(func.max(...)).with_for_update()**:MySQL 官方明确
|
||||
「Locking clauses are not permitted in queries with aggregate functions
|
||||
or GROUP BY」,聚合结果上的 FOR UPDATE 语义无保证。改锁「该 ref 的现有
|
||||
版本行」,最大值在 Python 侧算——同 no_generator 的既有套路。只取
|
||||
version_no 单列,不把 snapshot_json 拉进内存。
|
||||
"""
|
||||
versions = (
|
||||
session.execute(
|
||||
select(ApprovalOrder.version_no)
|
||||
.where(
|
||||
ApprovalOrder.order_type == order_type,
|
||||
ApprovalOrder.ref_id == ref_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return (max(versions) if versions else 0) + 1
|
||||
|
||||
|
||||
def allocation_content(session: Session, business: Business) -> dict:
|
||||
"""二次分配方案的审批内容(提单快照与历史单回退共用同一结构)。
|
||||
|
||||
业务主行已存在但客户联查落空(客户被删等异常)时返回空字典——调用方
|
||||
以 ``or None`` 归一,避免生成「有快照但内容为空」的信封。
|
||||
"""
|
||||
from app.repositories import business_repository
|
||||
|
||||
row = business_repository.get_with_names(session, business.id)
|
||||
if row is None:
|
||||
return {}
|
||||
_, customer_name, owner_name, assistant_name = row
|
||||
allocations = business_repository.list_allocations(session, business.id)
|
||||
total = sum((a.ratio for a in allocations), Decimal("0"))
|
||||
return {
|
||||
"business_no": business.business_no,
|
||||
"business_date": business.business_date.isoformat() if business.business_date else None,
|
||||
"customer_name": customer_name,
|
||||
"owner_personnel_name": owner_name,
|
||||
"assistant_personnel_name": assistant_name,
|
||||
"sale_amount": _amount_text(business.total_sale_amount),
|
||||
"cost_amount": _amount_text(business.total_cost_amount),
|
||||
"allocations": [
|
||||
{
|
||||
"personnel_id": a.personnel_id,
|
||||
"personnel_name": a.personnel_name,
|
||||
"ratio": str(a.ratio),
|
||||
}
|
||||
for a in allocations
|
||||
],
|
||||
"allocation_total": str(total.quantize(Decimal("0.01"))),
|
||||
"allocation_balance": str((Decimal("100") - total).quantize(Decimal("0.01"))),
|
||||
}
|
||||
|
||||
|
||||
def _amount_text(value: Decimal | None) -> str | None:
|
||||
"""金额文本化(Decimal 全程不转 float;None 保留为空,不伪造 0.00)。"""
|
||||
return str(value) if value is not None else None
|
||||
|
||||
|
||||
def settlement_content(session: Session, settlement_id: int) -> dict | None:
|
||||
"""月结审批内容(汇总 + 按人明细;提单快照与历史单回退共用)。"""
|
||||
from app.models import MonthlySettlement
|
||||
from app.repositories import settlement_repository
|
||||
|
||||
settlement = session.get(MonthlySettlement, settlement_id)
|
||||
if settlement is None:
|
||||
return None
|
||||
items = settlement_repository.list_items(session, settlement_id)
|
||||
return {
|
||||
"settlement_month": settlement.settlement_month,
|
||||
"total_performance": _amount_text(settlement.total_performance),
|
||||
"total_payable": _amount_text(settlement.total_payable),
|
||||
"person_count": settlement.person_count,
|
||||
"items": [
|
||||
{
|
||||
"personnel_name": item.personnel_name,
|
||||
"role_code": item.role_code,
|
||||
"calc_type": item.calc_type,
|
||||
"performance_sum": str(item.performance_sum),
|
||||
"payable_sum": str(item.payable_sum),
|
||||
"basic_amount": _amount_text(item.basic_amount),
|
||||
"salary_mode": item.salary_mode,
|
||||
"net_amount": _amount_text(item.net_amount),
|
||||
"is_probation": item.is_probation,
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def adjustment_content(session: Session, adjustment_id: int) -> dict | None:
|
||||
"""调整单审批内容(提单快照与历史单回退共用)。"""
|
||||
from app.models import AdjustmentOrder
|
||||
|
||||
adjustment = session.get(AdjustmentOrder, adjustment_id)
|
||||
if adjustment is None:
|
||||
return None
|
||||
return {
|
||||
"order_no": adjustment.order_no,
|
||||
"settlement_month": adjustment.settlement_month,
|
||||
"personnel_name": adjustment.personnel_name,
|
||||
"direction": adjustment.direction,
|
||||
"amount": str(adjustment.amount),
|
||||
"reason": adjustment.reason,
|
||||
}
|
||||
|
||||
|
||||
def _content_of(session: Session, order: ApprovalOrder) -> tuple[dict | None, str]:
|
||||
"""审批内容出参:优先提交时快照,历史单回退当前内容并显式标注来源。"""
|
||||
if order.snapshot_json:
|
||||
return order.snapshot_json, "snapshot"
|
||||
|
||||
if order.order_type == 1:
|
||||
business = session.get(Business, order.ref_id)
|
||||
data = allocation_content(session, business) if business is not None else None
|
||||
elif order.order_type == 2:
|
||||
data = settlement_content(session, order.ref_id)
|
||||
elif order.order_type == 3:
|
||||
data = adjustment_content(session, order.ref_id)
|
||||
else:
|
||||
data = None
|
||||
|
||||
if not data:
|
||||
return None, "missing"
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"order_type": order.order_type,
|
||||
"captured_at": None,
|
||||
"data": data,
|
||||
}, "live"
|
||||
|
||||
|
||||
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
|
||||
"""出参组装(含 steps 时间线与审批内容)。调用方须保证处于 commit 之前。"""
|
||||
steps = (
|
||||
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]
|
||||
detail.snapshot, detail.content_source = _content_of(session, order)
|
||||
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)
|
||||
@@ -37,12 +37,14 @@ def _record(
|
||||
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
|
||||
login_log_repository.add(
|
||||
session,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=success,
|
||||
fail_reason=fail_reason,
|
||||
login_log_repository.LoginLogEntry(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=success,
|
||||
fail_reason=fail_reason,
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from app.models import BusinessRole, User
|
||||
from app.repositories import business_role_repository
|
||||
from app.schemas.business_role import (
|
||||
BusinessRoleCreateIn,
|
||||
BusinessRoleOptionOut,
|
||||
BusinessRoleOut,
|
||||
BusinessRoleQuery,
|
||||
BusinessRoleUpdateIn,
|
||||
@@ -67,16 +68,8 @@ def _ensure_template(calc_template: str) -> None:
|
||||
|
||||
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
|
||||
"""分页查询岗位(含停用)。"""
|
||||
roles = business_role_repository.list_roles(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
)
|
||||
total = business_role_repository.count_roles(
|
||||
session, keyword=query.keyword, status=query.status
|
||||
)
|
||||
roles = business_role_repository.list_roles(session, query)
|
||||
total = business_role_repository.count_roles(session, query)
|
||||
return PageResult[BusinessRoleOut](
|
||||
records=[_to_out(role) for role in roles],
|
||||
total=total,
|
||||
@@ -85,6 +78,23 @@ def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[Busines
|
||||
)
|
||||
|
||||
|
||||
def list_options(session: Session) -> list[BusinessRoleOptionOut]:
|
||||
"""岗位下拉选项(含停用,不分页)。
|
||||
|
||||
含停用是因为人员档案可能仍引用停用岗位(停用只是禁止新增引用,
|
||||
不改历史归属);岗位规模有限(内置 + 自定义),一次取全量。
|
||||
"""
|
||||
return [
|
||||
BusinessRoleOptionOut(
|
||||
id=role.id,
|
||||
role_code=role.role_code,
|
||||
role_name=role.role_name,
|
||||
status=role.status,
|
||||
)
|
||||
for role in business_role_repository.list_all(session)
|
||||
]
|
||||
|
||||
|
||||
def get_role(session: Session, role_id: int) -> BusinessRoleOut:
|
||||
"""岗位详情。
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
@@ -34,6 +36,7 @@ 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,
|
||||
@@ -41,16 +44,19 @@ from app.models import (
|
||||
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
|
||||
from app.repositories import business_repository, customer_repository, personnel_repository
|
||||
from app.schemas.business import (
|
||||
AllocationVersionOut,
|
||||
BusinessCreateIn,
|
||||
BusinessDetailOut,
|
||||
BusinessItemIn,
|
||||
@@ -65,8 +71,10 @@ from app.schemas.business import (
|
||||
GrossProfitOut,
|
||||
GrossProfitRowOut,
|
||||
OwnershipChangeLogOut,
|
||||
SecondaryAllocationIn,
|
||||
SecondaryAllocationOut,
|
||||
)
|
||||
from app.services import audit_service, param_service, personnel_service
|
||||
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
|
||||
@@ -228,26 +236,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
|
||||
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
|
||||
"""分页查询业务(附客户名与归属人名)。"""
|
||||
rows = business_repository.list_businesses(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
customer_id=query.customer_id,
|
||||
owner_personnel_id=query.owner_personnel_id,
|
||||
date_from=query.date_from,
|
||||
date_to=query.date_to,
|
||||
)
|
||||
total = business_repository.count_businesses(
|
||||
session,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
customer_id=query.customer_id,
|
||||
owner_personnel_id=query.owner_personnel_id,
|
||||
date_from=query.date_from,
|
||||
date_to=query.date_to,
|
||||
)
|
||||
rows = business_repository.list_businesses(session, query)
|
||||
total = business_repository.count_businesses(session, query)
|
||||
return PageResult[BusinessOut](
|
||||
records=[_to_out(row) for row in rows],
|
||||
total=total,
|
||||
@@ -257,7 +247,7 @@ def list_businesses(session: Session, query: BusinessQuery) -> PageResult[Busine
|
||||
|
||||
|
||||
def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
||||
"""业务详情(附明细行,供编辑抽屉回显)。
|
||||
"""业务详情(附明细行与二次分配方案,供编辑抽屉回显)。
|
||||
|
||||
:raises NotFoundError: 业务不存在
|
||||
"""
|
||||
@@ -268,9 +258,108 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
||||
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)
|
||||
out.allocation_history = [
|
||||
AllocationVersionOut(
|
||||
personnel_id=a.personnel_id,
|
||||
personnel_name=a.personnel_name,
|
||||
ratio=str(a.ratio),
|
||||
version_no=a.version_no,
|
||||
is_active=a.is_active,
|
||||
create_time=a.create_time,
|
||||
)
|
||||
for a in business_repository.list_allocation_versions(session, business_id)
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
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 _plan_changed(old_rows: list[SecondaryAllocation], new_rows: list[SecondaryAllocation]) -> bool:
|
||||
"""方案内容(参与人 + 比例)是否真的变了。
|
||||
|
||||
比较口径是**集合**(人员 ID 与其比例的组合),与提交顺序无关——送审
|
||||
快照同样按库内生效行的顺序生成,若按顺序比较,用户只是把行拖了个
|
||||
序就会被判成「变了」,进而无谓地新增版本与重提审批单。
|
||||
"""
|
||||
|
||||
def _key(row: SecondaryAllocation) -> tuple[int, Decimal]:
|
||||
return (row.personnel_id, Decimal(row.ratio).quantize(Decimal("0.01")))
|
||||
|
||||
return {_key(r) for r in old_rows} != {_key(r) for r in new_rows}
|
||||
|
||||
|
||||
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
|
||||
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
|
||||
|
||||
无方案返回 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,
|
||||
@@ -335,6 +424,27 @@ def create_business(
|
||||
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,
|
||||
snapshot=approval_service.allocation_content(session, business) or None,
|
||||
)
|
||||
customer = customer_repository.get_by_id(session, business.customer_id)
|
||||
out = _to_out((business, customer.company_name if customer else None, None, None))
|
||||
new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM
|
||||
@@ -408,7 +518,58 @@ def update_business(
|
||||
business_repository.replace_items(
|
||||
session, business_id, _build_item_rows(business_id, payload.items)
|
||||
)
|
||||
# 方案:内容真的变了才动版本行(内容未变不新增版本,避免「只改备注」
|
||||
# 也堆出一个方案版本);空数组=清空=把生效行置历史
|
||||
old_allocations = business_repository.list_allocations(session, business_id)
|
||||
new_rows = _build_allocation_rows(
|
||||
session,
|
||||
business_id,
|
||||
payload.secondary_allocations,
|
||||
old_ids={a.personnel_id for a in old_allocations},
|
||||
)
|
||||
if not payload.secondary_allocations:
|
||||
business_repository.replace_allocations(session, business_id, [])
|
||||
elif _plan_changed(old_allocations, new_rows):
|
||||
business_repository.replace_allocations(session, business_id, new_rows)
|
||||
# 汇总先重算:送审快照里的销售额/成本额必须反映本次编辑后的值,
|
||||
# 放到提单之后会固化成上一版本的金额
|
||||
_recalc_totals(session, business)
|
||||
# 提单/撤单与方案同步生命周期:
|
||||
# - 方案为空 → 仅撤回在途单(旧在途单挂着已不存在的方案继续走审批)
|
||||
# - 送审内容与在途单一致 → 保留原单,不撤回不重建(避免反复保存
|
||||
# 刷出「撤回+新建」的审批留痕噪音)
|
||||
# - 其余 → 提新单(内含撤旧建新)
|
||||
if payload.secondary_allocations:
|
||||
snapshot = approval_service.allocation_content(session, business) or None
|
||||
if not approval_service.pending_matches_snapshot(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
ref_id=business_id,
|
||||
snapshot=snapshot,
|
||||
):
|
||||
try:
|
||||
approval_service.submit_order(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
ref_id=business_id,
|
||||
refer_no=business.business_no,
|
||||
submitter=operator,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
# version_no 唯一键的并发冲突(取号是锁读后此项理论不可达,
|
||||
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062
|
||||
# 翻译成可重试的业务提示,其余完整性错误原样上抛
|
||||
if _is_duplicate_entry(exc):
|
||||
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
|
||||
raise
|
||||
else:
|
||||
approval_service.withdraw_pending_orders(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
ref_id=business_id,
|
||||
operator=operator,
|
||||
)
|
||||
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
||||
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
|
||||
out = _to_out((business, customer.company_name, owner_name, assistant_name))
|
||||
@@ -430,7 +591,7 @@ def delete_business(
|
||||
与业务维度的归属留痕(无外键固化表,须手动清)。
|
||||
|
||||
:raises NotFoundError: 业务不存在
|
||||
:raises BadRequestError: 存在发货或回款记录
|
||||
:raises BadRequestError: 存在发货或回款记录 / 存在在途审批单
|
||||
"""
|
||||
business = business_repository.get_by_id(session, business_id, for_update=True)
|
||||
with audit_service.audit(
|
||||
@@ -447,6 +608,10 @@ def delete_business(
|
||||
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)
|
||||
@@ -454,11 +619,46 @@ def delete_business(
|
||||
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(
|
||||
@@ -532,6 +732,67 @@ def change_status(
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _BusinessOwnershipResolution:
|
||||
"""业务归属变更的解析结果(新归属人行 + 变更前固化 ID)。"""
|
||||
|
||||
owner: Personnel | None
|
||||
assistant: Personnel | None
|
||||
old_owner_id: int | None
|
||||
old_assistant_id: int | None
|
||||
|
||||
|
||||
def _resolve_business_ownership(
|
||||
session: Session, business: Business, payload: BusinessOwnershipIn
|
||||
) -> _BusinessOwnershipResolution:
|
||||
"""校验新归属人(须在职)并判定归属是否真的变化。
|
||||
|
||||
校验在审计上下文内执行 —— 失败也留痕。两者均未变化 → 400「归属未变化」。
|
||||
"""
|
||||
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
|
||||
# 固化标量,新归属人名必须按新 ID 重取
|
||||
owner = (
|
||||
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
|
||||
if payload.new_owner_personnel_id is not None
|
||||
else None
|
||||
)
|
||||
assistant = (
|
||||
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
|
||||
if payload.new_assistant_personnel_id is not None
|
||||
else None
|
||||
)
|
||||
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
|
||||
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
|
||||
if not owner_changed and not assistant_changed:
|
||||
raise BadRequestError("归属未变化")
|
||||
return _BusinessOwnershipResolution(
|
||||
owner=owner,
|
||||
assistant=assistant,
|
||||
old_owner_id=business.owner_personnel_id,
|
||||
old_assistant_id=business.assistant_personnel_id,
|
||||
)
|
||||
|
||||
|
||||
def _business_ownership_log(
|
||||
business: Business,
|
||||
payload: BusinessOwnershipIn,
|
||||
resolution: _BusinessOwnershipResolution,
|
||||
operator: User,
|
||||
) -> OwnershipChangeLog:
|
||||
"""归属变更留痕行(负责/协助两个字段的前后值都记)。"""
|
||||
return OwnershipChangeLog(
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
business_id=business.id,
|
||||
old_owner_personnel_id=resolution.old_owner_id,
|
||||
new_owner_personnel_id=payload.new_owner_personnel_id,
|
||||
old_assistant_personnel_id=resolution.old_assistant_id,
|
||||
new_assistant_personnel_id=payload.new_assistant_personnel_id,
|
||||
change_reason=payload.reason,
|
||||
operator_id=operator.id,
|
||||
operator_name=operator.username,
|
||||
)
|
||||
|
||||
|
||||
def change_ownership(
|
||||
session: Session,
|
||||
business_id: int,
|
||||
@@ -563,41 +824,12 @@ def change_ownership(
|
||||
if row is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
business, _, old_owner_name, old_assistant_name = row
|
||||
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
|
||||
# 固化标量,新归属人名必须按新 ID 重取
|
||||
new_owner = (
|
||||
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
|
||||
if payload.new_owner_personnel_id is not None
|
||||
else None
|
||||
)
|
||||
new_assistant = (
|
||||
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
|
||||
if payload.new_assistant_personnel_id is not None
|
||||
else None
|
||||
)
|
||||
resolution = _resolve_business_ownership(session, business, payload)
|
||||
|
||||
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
|
||||
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
|
||||
if not owner_changed and not assistant_changed:
|
||||
raise BadRequestError("归属未变化")
|
||||
|
||||
old_owner_id = business.owner_personnel_id
|
||||
old_assistant_id = business.assistant_personnel_id
|
||||
business.owner_personnel_id = payload.new_owner_personnel_id
|
||||
business.assistant_personnel_id = payload.new_assistant_personnel_id
|
||||
business_repository.add_ownership_log(
|
||||
session,
|
||||
OwnershipChangeLog(
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
business_id=business_id,
|
||||
old_owner_personnel_id=old_owner_id,
|
||||
new_owner_personnel_id=payload.new_owner_personnel_id,
|
||||
old_assistant_personnel_id=old_assistant_id,
|
||||
new_assistant_personnel_id=payload.new_assistant_personnel_id,
|
||||
change_reason=payload.reason,
|
||||
operator_id=operator.id,
|
||||
operator_name=operator.username,
|
||||
),
|
||||
session, _business_ownership_log(business, payload, resolution, operator)
|
||||
)
|
||||
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
|
||||
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
|
||||
@@ -605,17 +837,19 @@ def change_ownership(
|
||||
(
|
||||
business,
|
||||
row[1],
|
||||
new_owner.personnel_name if new_owner is not None else old_owner_name,
|
||||
new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
|
||||
resolution.owner.personnel_name if resolution.owner is not None else old_owner_name,
|
||||
resolution.assistant.personnel_name
|
||||
if resolution.assistant is not None
|
||||
else old_assistant_name,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
|
||||
business_id,
|
||||
old_owner_id,
|
||||
resolution.old_owner_id,
|
||||
payload.new_owner_personnel_id,
|
||||
old_assistant_id,
|
||||
resolution.old_assistant_id,
|
||||
payload.new_assistant_personnel_id,
|
||||
)
|
||||
return out
|
||||
@@ -681,6 +915,94 @@ def list_status_logs(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _GrossProfitScan:
|
||||
"""毛利润逐行扫描结果(行 DTO、类别聚合桶、成本缺失计数)。"""
|
||||
|
||||
rows: list[GrossProfitRowOut]
|
||||
category_agg: dict[str, list[Decimal]]
|
||||
missing_count: int
|
||||
|
||||
|
||||
def _gross_profit_row(
|
||||
item: BusinessItem, threshold: Fraction
|
||||
) -> tuple[GrossProfitRowOut, tuple[Decimal, Decimal] | None]:
|
||||
"""单行毛利润派生值(行 DTO + 类别聚合桶)。
|
||||
|
||||
聚合桶为 None 表示成本缺失行 —— 这类行不计入类别汇总,但仍要输出
|
||||
派生列为 None 的行与缺失原因。阈值对两类行都要取(成本缺失也展示阈值)。
|
||||
"""
|
||||
row_kwargs: dict = {
|
||||
"item_id": item.id,
|
||||
"product_category": item.product_category,
|
||||
"product_name": item.product_name,
|
||||
"spec_model": item.spec_model,
|
||||
"quantity": str(item.quantity),
|
||||
"sale_amount": str(item.sale_amount),
|
||||
"threshold": str(threshold),
|
||||
}
|
||||
if item.cost_amount is None:
|
||||
row_kwargs.update(
|
||||
cost_amount=None,
|
||||
profit_amount=None,
|
||||
profit_rate=None,
|
||||
converted_profit=None,
|
||||
missing_reason="成本缺失,无法计算毛利润",
|
||||
)
|
||||
return GrossProfitRowOut(**row_kwargs), None
|
||||
|
||||
profit = item.sale_amount - item.cost_amount
|
||||
rate = profit_rate(profit, item.sale_amount)
|
||||
conv = converted_profit(item.sale_amount, rate, threshold)
|
||||
row_kwargs.update(
|
||||
cost_amount=str(item.cost_amount),
|
||||
profit_amount=str(profit),
|
||||
profit_rate=str(rate),
|
||||
converted_profit=str(conv),
|
||||
missing_reason=None,
|
||||
)
|
||||
return GrossProfitRowOut(**row_kwargs), (item.sale_amount, item.cost_amount)
|
||||
|
||||
|
||||
def _scan_gross_profit_rows(
|
||||
session: Session, business_id: int, threshold_of: Callable[[str], Fraction]
|
||||
) -> _GrossProfitScan:
|
||||
"""逐行扫描业务明细:出行 DTO、类别聚合桶与成本缺失计数。
|
||||
|
||||
类别顺序沿用明细行的首次出现顺序(不排序)—— 前端汇总展示依赖该顺序。
|
||||
"""
|
||||
rows: list[GrossProfitRowOut] = []
|
||||
agg: dict[str, list[Decimal]] = {}
|
||||
missing_count = 0
|
||||
for item in business_repository.list_items(session, business_id):
|
||||
row, bucket = _gross_profit_row(item, threshold_of(item.product_category))
|
||||
rows.append(row)
|
||||
if bucket is None:
|
||||
missing_count += 1
|
||||
continue
|
||||
current = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
|
||||
current[0] += bucket[0]
|
||||
current[1] += bucket[1]
|
||||
return _GrossProfitScan(rows=rows, category_agg=agg, missing_count=missing_count)
|
||||
|
||||
|
||||
def _gross_profit_category(
|
||||
category: str, sale: Decimal, cost: Decimal, threshold: Fraction
|
||||
) -> GrossProfitCategoryOut:
|
||||
"""类别汇总行(派生口径与行级一致:利润率/折算复用 formula 纯函数)。"""
|
||||
profit = sale - cost
|
||||
rate = profit_rate(profit, sale)
|
||||
return GrossProfitCategoryOut(
|
||||
product_category=category,
|
||||
sale_amount=str(sale),
|
||||
cost_amount=str(cost),
|
||||
profit_amount=str(profit),
|
||||
profit_rate=str(rate),
|
||||
threshold=str(threshold),
|
||||
converted_profit=str(converted_profit(sale, rate, threshold)),
|
||||
)
|
||||
|
||||
|
||||
def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut:
|
||||
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
|
||||
|
||||
@@ -699,64 +1021,15 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
|
||||
def threshold_of(category: str) -> Fraction:
|
||||
return snapshot.fraction("product.threshold", product_category=category)
|
||||
|
||||
rows_out: list[GrossProfitRowOut] = []
|
||||
missing_count = 0
|
||||
# 类别聚合容器:{类别: [sale, cost]},成本缺失行不计入该类别聚合
|
||||
agg: dict[str, list[Decimal]] = {}
|
||||
for item in business_repository.list_items(session, business_id):
|
||||
threshold = threshold_of(item.product_category)
|
||||
row_kwargs: dict = {
|
||||
"item_id": item.id,
|
||||
"product_category": item.product_category,
|
||||
"product_name": item.product_name,
|
||||
"spec_model": item.spec_model,
|
||||
"quantity": str(item.quantity),
|
||||
"sale_amount": str(item.sale_amount),
|
||||
"threshold": str(threshold),
|
||||
}
|
||||
if item.cost_amount is None:
|
||||
missing_count += 1
|
||||
row_kwargs.update(
|
||||
cost_amount=None,
|
||||
profit_amount=None,
|
||||
profit_rate=None,
|
||||
converted_profit=None,
|
||||
missing_reason="成本缺失,无法计算毛利润",
|
||||
)
|
||||
else:
|
||||
profit = item.sale_amount - item.cost_amount
|
||||
rate = profit_rate(profit, item.sale_amount)
|
||||
conv = converted_profit(item.sale_amount, rate, threshold)
|
||||
row_kwargs.update(
|
||||
cost_amount=str(item.cost_amount),
|
||||
profit_amount=str(profit),
|
||||
profit_rate=str(rate),
|
||||
converted_profit=str(conv),
|
||||
missing_reason=None,
|
||||
)
|
||||
bucket = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
|
||||
bucket[0] += item.sale_amount
|
||||
bucket[1] += item.cost_amount
|
||||
rows_out.append(GrossProfitRowOut(**row_kwargs))
|
||||
|
||||
scan = _scan_gross_profit_rows(session, business_id, threshold_of)
|
||||
categories = [
|
||||
GrossProfitCategoryOut(
|
||||
product_category=category,
|
||||
sale_amount=str(sale),
|
||||
cost_amount=str(cost),
|
||||
profit_amount=str(sale - cost),
|
||||
profit_rate=str(profit_rate(sale - cost, sale)),
|
||||
threshold=str(threshold_of(category)),
|
||||
converted_profit=str(
|
||||
converted_profit(sale, profit_rate(sale - cost, sale), threshold_of(category))
|
||||
),
|
||||
)
|
||||
for category, [sale, cost] in agg.items()
|
||||
_gross_profit_category(category, sale, cost, threshold_of(category))
|
||||
for category, [sale, cost] in scan.category_agg.items()
|
||||
]
|
||||
return GrossProfitOut(
|
||||
business_id=business_id,
|
||||
as_of=as_of,
|
||||
rows=rows_out,
|
||||
rows=scan.rows,
|
||||
categories=categories,
|
||||
missing_count=missing_count,
|
||||
missing_count=scan.missing_count,
|
||||
)
|
||||
@@ -0,0 +1,726 @@
|
||||
"""绩效核算服务(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 = "经理个人户单笔不计提(未计入分成基数):绩效在月结时按团队与全公司分成计算"
|
||||
|
||||
|
||||
@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"),
|
||||
"经理个人户:绩效按团队与全公司分成在月结时计算,单笔不计提",
|
||||
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"经理个人户(已开启计入分成基数):按业务员递减,回款 {days} 天,起始 {start}‰"
|
||||
return _RateDecision(rate, note, None, False)
|
||||
if template == "TPL_INFO_SALES_FIXED":
|
||||
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
|
||||
rate = parse_fraction(snapshot.get("info.rate")) / 1000
|
||||
return _RateDecision(rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", None, False)
|
||||
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"回款 {days} 天,从起始 {start}‰ 按日递减"
|
||||
f"(满 {boundary} 天后固定按 {floor}‰ 计算)"
|
||||
)
|
||||
return _RateDecision(rate, note, None, False)
|
||||
# 提示给模板中文名而非编码:业务人员在岗位角色页看到的是「营销经理·团队与全公司分成」
|
||||
from app.services.calc_templates import TEMPLATES
|
||||
|
||||
tpl_name = TEMPLATES[template].name if template in TEMPLATES else template
|
||||
raise BadRequestError(f"「{tpl_name}」不支持单笔核算,请检查该人员的岗位设置")
|
||||
|
||||
|
||||
def _role_of(session: Session, person):
|
||||
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)
|
||||
@@ -14,6 +14,8 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Row
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -37,6 +39,7 @@ from app.schemas.customer import (
|
||||
CustomerOut,
|
||||
CustomerOwnershipIn,
|
||||
CustomerQuery,
|
||||
CustomerRatingIn,
|
||||
CustomerUpdateIn,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
@@ -60,6 +63,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
|
||||
owner_personnel_id=customer.owner_personnel_id,
|
||||
owner_personnel_name=owner_name,
|
||||
remark=customer.remark,
|
||||
rating_score=customer.rating_score,
|
||||
rating_time=customer.rating_time,
|
||||
)
|
||||
|
||||
|
||||
@@ -107,20 +112,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
|
||||
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
|
||||
"""分页查询客户(附归属人员姓名)。"""
|
||||
rows = customer_repository.list_customers(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
keyword=query.keyword,
|
||||
customer_type=query.customer_type,
|
||||
owner_personnel_id=query.owner_personnel_id,
|
||||
)
|
||||
total = customer_repository.count_customers(
|
||||
session,
|
||||
keyword=query.keyword,
|
||||
customer_type=query.customer_type,
|
||||
owner_personnel_id=query.owner_personnel_id,
|
||||
)
|
||||
rows = customer_repository.list_customers(session, query)
|
||||
total = customer_repository.count_customers(session, query)
|
||||
return PageResult[CustomerOut](
|
||||
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
|
||||
total=total,
|
||||
@@ -274,6 +267,53 @@ def update_customer(
|
||||
return out
|
||||
|
||||
|
||||
def rate_customer(
|
||||
session: Session,
|
||||
customer_id: int,
|
||||
payload: CustomerRatingIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> CustomerOut:
|
||||
"""保存客户评级(评级计算器「保存评分」)。
|
||||
|
||||
评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用
|
||||
等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯
|
||||
「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够
|
||||
回答「这个分是什么时候打的」。等级由前端按当前映射实时算并展示。
|
||||
|
||||
:raises NotFoundError: 客户不存在
|
||||
"""
|
||||
# 客户名进审计摘要,预取放在审计上下文之前(同 change_ownership 口径)
|
||||
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||
customer_name = row[0].company_name if row else f"#{customer_id}"
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_CUSTOMER,
|
||||
action=ACTION_UPDATE,
|
||||
summary=f"保存客户评级 {customer_name}:{payload.score} 分",
|
||||
request_data=payload.model_dump(),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if row is None:
|
||||
raise NotFoundError("客户不存在")
|
||||
customer, owner_name = row
|
||||
|
||||
customer.rating_score = payload.score
|
||||
# 评级时间取应用层本地时间(库列 TZ=Asia/Shanghai,与 TimestampMixin
|
||||
# 的服务器默认值同一时区口径),不用数据库 NOW():行已在本会话
|
||||
customer.rating_time = datetime.now()
|
||||
|
||||
# 响应在 commit 之前构造(同 user_service 顶部说明)
|
||||
out = _to_out(customer, owner_name)
|
||||
session.commit()
|
||||
logger.info("保存客户评级 | customer_id=%s | score=%s", customer_id, payload.score)
|
||||
return out
|
||||
|
||||
|
||||
def delete_customer(
|
||||
session: Session,
|
||||
customer_id: int,
|
||||
@@ -317,6 +357,71 @@ def delete_customer(
|
||||
logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CustomerOwnershipPrefetch:
|
||||
"""客户归属变更的审计前只读预取(摘要要客户名与新旧归属人名)。"""
|
||||
|
||||
row: Row | None
|
||||
new_person: Personnel | None
|
||||
old_label: str
|
||||
new_label: str
|
||||
customer_name: str
|
||||
|
||||
|
||||
def _prefetch_customer_ownership(
|
||||
session: Session, customer_id: int, payload: CustomerOwnershipIn
|
||||
) -> _CustomerOwnershipPrefetch:
|
||||
"""审计上下文之前的只读预取(预取不写库,失败路径也要有可读摘要)。"""
|
||||
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||
old_person = (
|
||||
personnel_repository.get_by_id(session, row[0].owner_personnel_id)
|
||||
if row is not None and row[0].owner_personnel_id is not None
|
||||
else None
|
||||
)
|
||||
new_person = (
|
||||
personnel_repository.get_by_id(session, payload.new_owner_personnel_id)
|
||||
if payload.new_owner_personnel_id is not None
|
||||
else None
|
||||
)
|
||||
return _CustomerOwnershipPrefetch(
|
||||
row=row,
|
||||
new_person=new_person,
|
||||
old_label=old_person.personnel_name if old_person else "无",
|
||||
new_label=new_person.personnel_name if new_person else "无",
|
||||
customer_name=row[0].company_name if row else f"#{customer_id}",
|
||||
)
|
||||
|
||||
|
||||
def _validate_customer_ownership(
|
||||
session: Session, customer: Customer, payload: CustomerOwnershipIn
|
||||
) -> int | None:
|
||||
"""校验新归属人(须在职)并判定归属是否变化,返回变更前归属人 ID。"""
|
||||
if payload.new_owner_personnel_id is not None:
|
||||
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
|
||||
old_owner_id = customer.owner_personnel_id
|
||||
if old_owner_id == payload.new_owner_personnel_id:
|
||||
raise BadRequestError("归属未变化")
|
||||
return old_owner_id
|
||||
|
||||
|
||||
def _customer_ownership_log(
|
||||
customer: Customer,
|
||||
payload: CustomerOwnershipIn,
|
||||
old_owner_id: int | None,
|
||||
operator: User,
|
||||
) -> OwnershipChangeLog:
|
||||
"""客户归属变更留痕行。"""
|
||||
return OwnershipChangeLog(
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
customer_id=customer.id,
|
||||
old_owner_personnel_id=old_owner_id,
|
||||
new_owner_personnel_id=payload.new_owner_personnel_id,
|
||||
change_reason=payload.reason,
|
||||
operator_id=operator.id,
|
||||
operator_name=operator.username,
|
||||
)
|
||||
|
||||
|
||||
def change_ownership(
|
||||
session: Session,
|
||||
customer_id: int,
|
||||
@@ -335,57 +440,34 @@ def change_ownership(
|
||||
"""
|
||||
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
|
||||
# 上下文内进行,失败同样要留下可追溯的记录
|
||||
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||
old_person = (
|
||||
personnel_repository.get_by_id(session, row[0].owner_personnel_id)
|
||||
if row is not None and row[0].owner_personnel_id is not None
|
||||
else None
|
||||
)
|
||||
new_person = (
|
||||
personnel_repository.get_by_id(session, payload.new_owner_personnel_id)
|
||||
if payload.new_owner_personnel_id is not None
|
||||
else None
|
||||
)
|
||||
old_label = old_person.personnel_name if old_person else "无"
|
||||
new_label = new_person.personnel_name if new_person else "无"
|
||||
customer_name = row[0].company_name if row else f"#{customer_id}"
|
||||
prefetch = _prefetch_customer_ownership(session, customer_id, payload)
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_CUSTOMER,
|
||||
action=ACTION_UPDATE,
|
||||
summary=f"调整客户归属 {customer_name}:{old_label} → {new_label}",
|
||||
summary=(
|
||||
f"调整客户归属 {prefetch.customer_name}:"
|
||||
f"{prefetch.old_label} → {prefetch.new_label}"
|
||||
),
|
||||
request_data=payload.model_dump(exclude_none=True),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if row is None:
|
||||
if prefetch.row is None:
|
||||
raise NotFoundError("客户不存在")
|
||||
customer, _ = row
|
||||
customer, _ = prefetch.row
|
||||
|
||||
if payload.new_owner_personnel_id is not None:
|
||||
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
|
||||
old_owner_id = customer.owner_personnel_id
|
||||
if old_owner_id == payload.new_owner_personnel_id:
|
||||
raise BadRequestError("归属未变化")
|
||||
old_owner_id = _validate_customer_ownership(session, customer, payload)
|
||||
|
||||
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
|
||||
customer.owner_personnel_id = payload.new_owner_personnel_id
|
||||
customer_repository.add_ownership_log(
|
||||
session,
|
||||
OwnershipChangeLog(
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
customer_id=customer.id,
|
||||
old_owner_personnel_id=old_owner_id,
|
||||
new_owner_personnel_id=payload.new_owner_personnel_id,
|
||||
change_reason=payload.reason,
|
||||
operator_id=operator.id,
|
||||
operator_name=operator.username,
|
||||
),
|
||||
session, _customer_ownership_log(customer, payload, old_owner_id, operator)
|
||||
)
|
||||
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
|
||||
out = _to_out(customer, new_person.personnel_name if new_person else None)
|
||||
out = _to_out(customer, prefetch.new_person.personnel_name if prefetch.new_person else None)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"调整客户归属 | customer_id=%s | old=%s | new=%s",
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""工作台服务(M4 ⑧):区块实时聚合编排 + 最近动态分页。
|
||||
|
||||
设计决策(2026-10-19):
|
||||
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
|
||||
不复用报表的部门汇总(后者会加载整月台账,过重)
|
||||
- **待办与预警实时派生**:不建待办/预警表、不引调度器;处置动作本身
|
||||
已有操作日志可追溯。与 FR-035「预警产生/消除留痕」的差距记录在
|
||||
计划开放项,待业务确认是否物化
|
||||
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
|
||||
``sync_available=false`` 显式降级,不返回伪造的 0 条
|
||||
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
|
||||
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
|
||||
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.month import validate_month
|
||||
from app.repositories import dashboard_repository, operation_log_repository, user_repository
|
||||
from app.repositories.query_utils import LogFilters
|
||||
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
|
||||
from app.services import approval_service
|
||||
|
||||
# 工作台只展示摘要,完整列表跳转对应管理页
|
||||
_TODO_LIMIT = 5
|
||||
_WARNING_LIMIT = 20
|
||||
_TREND_MONTHS = 6
|
||||
|
||||
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
|
||||
|
||||
|
||||
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
|
||||
"""三区块一次性出数(前端一次请求渲染整页)。
|
||||
|
||||
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
|
||||
风险预警仍是全局经营视角,不按人裁剪。
|
||||
最近动态不在此返回 —— 它是分页列表,走 ``list_activities``。
|
||||
"""
|
||||
resolved = _normalize_month(month)
|
||||
warning_params = _warning_params(session)
|
||||
pending_statuses = approval_service.pending_statuses_for_roles(
|
||||
user_repository.get_role_codes(current_user)
|
||||
)
|
||||
return {
|
||||
"month": resolved,
|
||||
"overview": _overview(session, resolved),
|
||||
"todos": _todos(session, pending_statuses),
|
||||
"warnings": _warnings(session, warning_params),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_month(month: str | None) -> str:
|
||||
"""未传月份按当月;非法月份在入口拦成 400(避免 500)。"""
|
||||
if not month:
|
||||
today = date.today()
|
||||
return f"{today.year:04d}-{today.month:02d}"
|
||||
try:
|
||||
return validate_month(month)
|
||||
except ValueError as exc:
|
||||
raise BadRequestError(str(exc)) from exc
|
||||
|
||||
|
||||
def _shift_month(month: str, delta: int) -> str:
|
||||
"""YYYY-MM 平移 delta 个月(用于近 N 个月窗口)。"""
|
||||
year, mon = int(month[:4]), int(month[5:7])
|
||||
index = year * 12 + (mon - 1) + delta
|
||||
return f"{index // 12:04d}-{index % 12 + 1:02d}"
|
||||
|
||||
|
||||
def _month_start(month: str) -> date:
|
||||
"""YYYY-MM → 该月首日(范围查询边界,避免函数列过滤)。"""
|
||||
return date(int(month[:4]), int(month[5:7]), 1)
|
||||
|
||||
|
||||
def _trend_months(month: str) -> list[str]:
|
||||
"""窗口为「当月及之前共 6 个月」,升序返回便于前端画图。"""
|
||||
return [_shift_month(month, offset) for offset in range(-(_TREND_MONTHS - 1), 1)]
|
||||
|
||||
|
||||
def _overview(session: Session, month: str) -> dict:
|
||||
"""经营绩效总览(FR-033):口径与台账/月结一致。"""
|
||||
sums = dashboard_repository.sum_month_performance(session, month)
|
||||
settlement = dashboard_repository.get_month_settlement(session, month)
|
||||
months = _trend_months(month)
|
||||
performance = dashboard_repository.performance_trend(session, months)
|
||||
receipts = dashboard_repository.receipt_trend(
|
||||
session,
|
||||
start_date=_month_start(months[0]),
|
||||
end_date=_month_start(_shift_month(months[-1], 1)),
|
||||
)
|
||||
return {
|
||||
"month": month,
|
||||
"performance_total": _dec(sums["performance_total"]),
|
||||
"payable_total": _dec(sums["payable_total"]),
|
||||
"person_count": int(sums["person_count"]),
|
||||
"team_count": dashboard_repository.count_active_managers(session),
|
||||
"settlement": {
|
||||
"exists": settlement is not None,
|
||||
"status": settlement.status if settlement else None,
|
||||
"total_performance": settlement.total_performance if settlement else None,
|
||||
"total_payable": settlement.total_payable if settlement else None,
|
||||
"locked_at": settlement.locked_at if settlement else None,
|
||||
},
|
||||
"trend": [
|
||||
{
|
||||
"month": item,
|
||||
"performance_total": _dec(performance.get(item)),
|
||||
"receipt_total": _dec(receipts.get(item)),
|
||||
}
|
||||
for item in months
|
||||
],
|
||||
"note": (
|
||||
"绩效按台账归属月统计(二次分配按参与人拆分后求和,不重复不计漏);"
|
||||
"回款按到账月统计(含尚未核算的回款);不含经理团队分成与调整单"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _todos(session: Session, pending_statuses: tuple[int, ...]) -> dict:
|
||||
"""待办事项(FR-034,澄清 #11 起步范围)。
|
||||
|
||||
``pending_statuses`` 已按当前用户角色解析:财务只看待财务复核、
|
||||
领导只看待领导审核;admin 查看全部在途单。
|
||||
"""
|
||||
orders = dashboard_repository.list_pending_approvals(
|
||||
session, statuses=pending_statuses, limit=_TODO_LIMIT
|
||||
)
|
||||
receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT)
|
||||
return {
|
||||
"pending_approval_total": dashboard_repository.count_pending_approvals(
|
||||
session, statuses=pending_statuses
|
||||
),
|
||||
"pending_approvals": [
|
||||
{
|
||||
"order_id": order.id,
|
||||
"order_no": order.order_no,
|
||||
"order_type": order.order_type,
|
||||
"submitter_name": order.submitter_name,
|
||||
"create_time": order.create_time,
|
||||
}
|
||||
for order in orders
|
||||
],
|
||||
"unassigned_receipt_total": dashboard_repository.count_unassigned_receipts(session),
|
||||
"unassigned_receipts": [
|
||||
{
|
||||
"receipt_id": row[0].id,
|
||||
"receipt_batch_no": row[0].receipt_batch_no,
|
||||
"business_id": row[0].business_id,
|
||||
"business_no": row.business_no,
|
||||
"amount": row[0].amount,
|
||||
"receipt_date": row[0].receipt_date,
|
||||
}
|
||||
for row in receipts
|
||||
],
|
||||
# ⑥ T+ 同步未建:显式降级,不伪造「无失败」
|
||||
"sync_available": False,
|
||||
"sync_failure_total": 0,
|
||||
}
|
||||
|
||||
|
||||
def _warning_params(session: Session) -> dict:
|
||||
"""预警参数一次解析(阈值参数化,澄清 #10)。"""
|
||||
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
snapshot = resolve_snapshot(session, date.today())
|
||||
return {
|
||||
"enabled": snapshot.get("warning.enabled") == "1",
|
||||
"receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))),
|
||||
"approval_timeout_days": int(parse_fraction(snapshot.get("warning.approval.timeout.days"))),
|
||||
}
|
||||
|
||||
|
||||
def _warnings(session: Session, params: dict) -> dict:
|
||||
"""风险预警(FR-035):首批两条可算规则;同步规则待 ⑥。"""
|
||||
if not params["enabled"]:
|
||||
return {
|
||||
"enabled": False,
|
||||
"receipt_overdue_days": params["receipt_overdue_days"],
|
||||
"approval_timeout_days": params["approval_timeout_days"],
|
||||
"items": [],
|
||||
"total": 0,
|
||||
"receipt_overdue_total": 0,
|
||||
"approval_timeout_total": 0,
|
||||
"sync_available": False,
|
||||
}
|
||||
|
||||
receipt_cutoff = date.today() - timedelta(days=params["receipt_overdue_days"])
|
||||
approval_cutoff = datetime.now() - timedelta(days=params["approval_timeout_days"])
|
||||
shipments = dashboard_repository.list_overdue_shipments(
|
||||
session, cutoff=receipt_cutoff, limit=_WARNING_LIMIT
|
||||
)
|
||||
orders = dashboard_repository.list_overdue_approvals(
|
||||
session, cutoff=approval_cutoff, limit=_WARNING_LIMIT
|
||||
)
|
||||
|
||||
items = [
|
||||
{
|
||||
"code": "receipt_overdue",
|
||||
"level": "danger",
|
||||
"object_type": "shipment",
|
||||
"object_id": row[0].id,
|
||||
"title": f"发货批次 {row[0].shipment_no} 逾期未回款",
|
||||
"detail": (
|
||||
f"业务 {row.business_no or row[0].business_id} 发货日 {row[0].ship_date},"
|
||||
f"超过 {params['receipt_overdue_days']} 天无回款记录"
|
||||
),
|
||||
"suggest": "联系客户催收,或核对回款批次指定关系",
|
||||
"link": "/business/shipments",
|
||||
}
|
||||
for row in shipments
|
||||
]
|
||||
items += [
|
||||
{
|
||||
"code": "approval_timeout",
|
||||
"level": "warning",
|
||||
"object_type": "approval_order",
|
||||
"object_id": order.id,
|
||||
"title": f"审批单 {order.order_no} 超时未处理",
|
||||
"detail": (
|
||||
f"{_ORDER_TYPE_LABELS.get(order.order_type, '审批')}由 "
|
||||
f"{order.submitter_name or '—'} 提交于 {order.create_time:%Y-%m-%d %H:%M},"
|
||||
f"已超过 {params['approval_timeout_days']} 天"
|
||||
),
|
||||
"suggest": "在审批管理中复核或退回",
|
||||
"link": "/system/approvals",
|
||||
}
|
||||
for order in orders
|
||||
]
|
||||
receipt_overdue_total = dashboard_repository.count_overdue_shipments(
|
||||
session, cutoff=receipt_cutoff
|
||||
)
|
||||
approval_timeout_total = dashboard_repository.count_overdue_approvals(
|
||||
session, cutoff=approval_cutoff
|
||||
)
|
||||
return {
|
||||
"enabled": True,
|
||||
"receipt_overdue_days": params["receipt_overdue_days"],
|
||||
"approval_timeout_days": params["approval_timeout_days"],
|
||||
"items": items,
|
||||
# items 有展示上限(_WARNING_LIMIT),徽标计数必须用全量命中数
|
||||
"total": receipt_overdue_total + approval_timeout_total,
|
||||
"receipt_overdue_total": receipt_overdue_total,
|
||||
"approval_timeout_total": approval_timeout_total,
|
||||
"sync_available": False,
|
||||
}
|
||||
|
||||
|
||||
def list_activities(session: Session, query: ActivityQuery) -> ActivityPageOut:
|
||||
"""最近动态分页(FR-036):以操作日志为唯一来源(写操作即留痕)。
|
||||
|
||||
模块筛选项与列表同源返回(全量模块名,不随当前页变化),
|
||||
前端模块下拉不必另取一次全量。
|
||||
"""
|
||||
repo_query = operation_log_repository.OperationLogListQuery(
|
||||
filters=LogFilters(),
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
module=query.module,
|
||||
)
|
||||
logs = operation_log_repository.list_logs(session, repo_query)
|
||||
total = operation_log_repository.count_logs(session, repo_query)
|
||||
return ActivityPageOut(
|
||||
records=[
|
||||
ActivityItem(
|
||||
id=log.id,
|
||||
module=log.module,
|
||||
action=log.action,
|
||||
summary=log.summary,
|
||||
username=log.username,
|
||||
success=log.success,
|
||||
create_time=log.create_time,
|
||||
)
|
||||
for log in logs
|
||||
],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
modules=operation_log_repository.distinct_modules(session),
|
||||
)
|
||||
|
||||
|
||||
def _dec(value) -> Decimal | None:
|
||||
"""SQL 聚合结果转 Decimal(SUM 可能返回 int,None 原样返回)。"""
|
||||
if value is None:
|
||||
return None
|
||||
return value if isinstance(value, Decimal) else Decimal(str(value))
|
||||
@@ -41,6 +41,10 @@ def salesperson_rate(
|
||||
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
|
||||
(对照表 #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
|
||||
的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。
|
||||
@@ -48,6 +52,8 @@ def salesperson_rate(
|
||||
days = max(days, 0)
|
||||
if days > boundary_days:
|
||||
return floor / 1000
|
||||
if days <= 1:
|
||||
return start / 1000
|
||||
return (start - days * slope) / 1000
|
||||
|
||||
|
||||
@@ -95,3 +101,63 @@ def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bo
|
||||
if revenue == 0:
|
||||
return False
|
||||
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
|
||||
)
|
||||
@@ -22,18 +22,15 @@ from app.schemas.log import (
|
||||
|
||||
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
|
||||
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
|
||||
filters = _filters_from(query)
|
||||
logs = operation_log_repository.list_logs(
|
||||
session,
|
||||
repo_query = operation_log_repository.OperationLogListQuery(
|
||||
filters=_filters_from(query),
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
filters=filters,
|
||||
module=query.module,
|
||||
action=query.action,
|
||||
)
|
||||
total = operation_log_repository.count_logs(
|
||||
session, filters=filters, module=query.module, action=query.action
|
||||
)
|
||||
logs = operation_log_repository.list_logs(session, repo_query)
|
||||
total = operation_log_repository.count_logs(session, repo_query)
|
||||
return PageResult[OperationLogOut](
|
||||
records=[OperationLogOut.model_validate(log) for log in logs],
|
||||
total=total,
|
||||
@@ -55,15 +52,14 @@ def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut:
|
||||
|
||||
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
|
||||
"""分页查询登录日志。"""
|
||||
filters = _filters_from(query)
|
||||
logs = login_log_repository.list_logs(
|
||||
session,
|
||||
repo_query = login_log_repository.LoginLogListQuery(
|
||||
filters=_filters_from(query),
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
filters=filters,
|
||||
ip=query.ip,
|
||||
)
|
||||
total = login_log_repository.count_logs(session, filters=filters, ip=query.ip)
|
||||
logs = login_log_repository.list_logs(session, repo_query)
|
||||
total = login_log_repository.count_logs(session, repo_query)
|
||||
return PageResult[LoginLogOut](
|
||||
records=[LoginLogOut.model_validate(log) for log in logs],
|
||||
total=total,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""经理分成共享口径(月结聚合与报表部门汇总的唯一实现)。
|
||||
|
||||
从 settlement_service 迁出(行为零变化):经理 12.5%/5% 的取参、团队
|
||||
分桶、公司基数计算,月结生成与部门汇总报表都要用——抽为单一实现后
|
||||
「报表汇总 == 月结聚合行」不靠两处代码各自对齐,而是同源函数的直接
|
||||
结果。分成金额仍走 formula.manager_team_share 纯函数。
|
||||
"""
|
||||
|
||||
from calendar import monthrange
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.month import parse_month
|
||||
from app.models import Personnel
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagerShareParams:
|
||||
"""经理分成取参与参数原文(同一次计算共用一份,随月月末时点化)。"""
|
||||
|
||||
month: str
|
||||
param_date: date
|
||||
team_text: str
|
||||
company_text: str
|
||||
team_pct: Fraction
|
||||
company_pct: Fraction
|
||||
include_manager_personal: bool
|
||||
|
||||
|
||||
def month_end(month: str) -> date:
|
||||
"""YYYY-MM → 所属月月末(月结与报表取参锚点)。
|
||||
|
||||
月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today()
|
||||
会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」
|
||||
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
|
||||
更细的业务日期,取该月最后可确定的生效时点。
|
||||
"""
|
||||
try:
|
||||
year, mon = parse_month(month)
|
||||
except ValueError as exc:
|
||||
# schema 已拦住非法月份;内部调用绕过 schema 时也不能变成 500
|
||||
raise BadRequestError(str(exc)) from exc
|
||||
return date(year, mon, monthrange(year, mon)[1])
|
||||
|
||||
|
||||
def manager_share_params(session: Session, month: str) -> ManagerShareParams:
|
||||
"""解析经理分成参数(锚定所属月月末,取参与台账行口径同源)。
|
||||
|
||||
参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
|
||||
manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)。
|
||||
#12 开关(manager.personal.included)与分成比例同日取参,答复后调整可追溯。
|
||||
"""
|
||||
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
param_date = month_end(month)
|
||||
snapshot = resolve_snapshot(session, param_date)
|
||||
team_text = snapshot.get("manager.share.team")
|
||||
company_text = snapshot.get("manager.share.company")
|
||||
return ManagerShareParams(
|
||||
month=month,
|
||||
param_date=param_date,
|
||||
team_text=team_text,
|
||||
company_text=company_text,
|
||||
team_pct=parse_fraction(team_text),
|
||||
company_pct=parse_fraction(company_text),
|
||||
include_manager_personal=snapshot.get("manager.personal.included") == "1",
|
||||
)
|
||||
|
||||
|
||||
def add_team_performance(managers: dict[int, dict], manager_id: int, row) -> None:
|
||||
"""把一行业绩并入经理的团队桶(首行固化业务锚点,成员名去重收集)。"""
|
||||
bucket = managers.setdefault(
|
||||
manager_id,
|
||||
{
|
||||
"performance": Decimal("0"),
|
||||
"business_id": row.business_id,
|
||||
"business_no": row.business_no,
|
||||
"names": set(),
|
||||
},
|
||||
)
|
||||
bucket["performance"] += row.performance_amount or Decimal("0")
|
||||
bucket["names"].add(row.personnel_name)
|
||||
|
||||
|
||||
def collect_team_buckets(
|
||||
session: Session, ledger_rows: list, include_manager_personal: bool
|
||||
) -> dict[int, dict]:
|
||||
"""按经理采集团队业绩。
|
||||
|
||||
业务员行经 manager_id 上溯到其经理;经理个人户行(#12 开关计入时)
|
||||
归属经理本人——行 personnel_id 即经理,不走上溯(经理没有上级,
|
||||
按上溯会被跳过漏计)。
|
||||
"""
|
||||
managers: dict[int, dict] = {}
|
||||
for row in ledger_rows:
|
||||
if row.customer_type == "manager_personal":
|
||||
if include_manager_personal:
|
||||
add_team_performance(managers, row.personnel_id, row)
|
||||
continue # 默认不计入聚合基数(#12 参数化)
|
||||
person = session.get(Personnel, row.personnel_id)
|
||||
manager_id = person.manager_id if person else None
|
||||
if manager_id is not None:
|
||||
add_team_performance(managers, manager_id, row)
|
||||
return managers
|
||||
|
||||
|
||||
def company_base_of(ledger_rows: list, include_manager_personal: bool) -> Decimal:
|
||||
"""全公司基数(5% 的底):默认排除经理个人户行(#12 开关计入时不过滤)。
|
||||
|
||||
口径逐行固定,调用方在循环外算一次。
|
||||
"""
|
||||
return sum(
|
||||
(
|
||||
r.performance_amount or Decimal("0")
|
||||
for r in ledger_rows
|
||||
if include_manager_personal or r.customer_type != "manager_personal"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
@@ -3,6 +3,10 @@
|
||||
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
|
||||
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
|
||||
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
|
||||
|
||||
`description` 会直接显示在「计提参数」页的参数名下方,**面向业务人员撰写**:
|
||||
说清这个参数管什么、钱怎么变、什么时候该调它。不写英文枚举名、需求编号、
|
||||
内部阶段号,也不写实现层面的精度约束(那些留在代码注释与测试里)。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -43,7 +47,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="10",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
|
||||
description="回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。",
|
||||
sort_order=1,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -57,7 +61,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="4/360",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
|
||||
description="回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。",
|
||||
sort_order=2,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -71,7 +75,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="4",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
|
||||
description="回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。",
|
||||
sort_order=3,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -85,7 +89,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="360",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
|
||||
description="回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。",
|
||||
sort_order=4,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -99,7 +103,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="12.5",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="∑本人带领业务员绩效 × 此比例(限本团队)",
|
||||
description="经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。",
|
||||
sort_order=5,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -113,7 +117,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="5",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="∑全部业务员绩效 × 此比例(全公司)",
|
||||
description="经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。",
|
||||
sort_order=6,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -127,7 +131,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="3",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
|
||||
description="企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。",
|
||||
sort_order=7,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -141,7 +145,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="1",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="信息员绩效 = 本人销售额 × 此比例",
|
||||
description="信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。",
|
||||
sort_order=8,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -155,7 +159,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="10",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="预付款统一按此比例计提",
|
||||
description="预付款到账即按这个比例计提绩效,不必等到全额回款。",
|
||||
sort_order=9,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -169,7 +173,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="80",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="以物抵顶回款绩效按应发绩效的此比例折算",
|
||||
description="以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。",
|
||||
sort_order=10,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -183,7 +187,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="60",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
|
||||
description="单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。",
|
||||
sort_order=11,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -197,7 +201,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="FULL",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
|
||||
description="选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。",
|
||||
sort_order=12,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -211,7 +215,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="30",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
|
||||
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。",
|
||||
sort_order=13,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -225,7 +229,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="INCLUDE",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
|
||||
description="算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。",
|
||||
sort_order=14,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -239,22 +243,108 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="18000",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="试用期年累计绩效超过此值部分年底一次性结算",
|
||||
description="试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。",
|
||||
sort_order=15,
|
||||
),
|
||||
ParamSpec(
|
||||
code="probation.salary.basic",
|
||||
name="试用期基本工资标准",
|
||||
group="月工资口径",
|
||||
value_type="DECIMAL",
|
||||
unit="元",
|
||||
code="approval.secondary_allocation.enabled",
|
||||
name="二次分配审批开关",
|
||||
group="审批开关",
|
||||
value_type="BOOL",
|
||||
unit=None,
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="3000",
|
||||
value_min="0",
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
|
||||
sort_order=16,
|
||||
description="二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。",
|
||||
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="月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。",
|
||||
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="调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。",
|
||||
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="经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。",
|
||||
sort_order=20,
|
||||
),
|
||||
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
|
||||
# 首批三条中「同步连续失败」依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目
|
||||
ParamSpec(
|
||||
code="warning.enabled",
|
||||
name="风险预警总开关",
|
||||
group="预警规则",
|
||||
value_type="BOOL",
|
||||
unit=None,
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。",
|
||||
sort_order=21,
|
||||
),
|
||||
ParamSpec(
|
||||
code="warning.receipt.overdue.days",
|
||||
name="回款逾期天数阈值",
|
||||
group="预警规则",
|
||||
value_type="INT",
|
||||
unit="天",
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="90",
|
||||
value_min="1",
|
||||
value_max=None,
|
||||
description="发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。",
|
||||
sort_order=22,
|
||||
),
|
||||
ParamSpec(
|
||||
code="warning.approval.timeout.days",
|
||||
name="审批超时天数阈值",
|
||||
group="预警规则",
|
||||
value_type="INT",
|
||||
unit="天",
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="3",
|
||||
value_min="1",
|
||||
value_max=None,
|
||||
description="审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。",
|
||||
sort_order=23,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
@@ -44,7 +44,7 @@ from app.schemas.param import (
|
||||
ParamPreviewOut,
|
||||
ParamUsedOut,
|
||||
)
|
||||
from app.services import audit_service, formula
|
||||
from app.services import audit_service, calc_templates, formula
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.formula import to_decimal
|
||||
from app.services.no_generator import next_no
|
||||
@@ -379,16 +379,8 @@ def get_history(
|
||||
definition = param_repository.get_definition_by_code(session, param_code)
|
||||
if definition is None:
|
||||
raise NotFoundError("参数不存在")
|
||||
rows = param_repository.list_history(
|
||||
session,
|
||||
definition.id,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
dimension_value=query.dimension_value,
|
||||
)
|
||||
total = param_repository.count_history(
|
||||
session, definition.id, dimension_value=query.dimension_value
|
||||
)
|
||||
rows = param_repository.list_history(session, definition.id, query)
|
||||
total = param_repository.count_history(session, definition.id, query)
|
||||
return PageResult[ParamHistoryOut](
|
||||
records=[
|
||||
ParamHistoryOut(
|
||||
@@ -410,16 +402,8 @@ def get_history(
|
||||
|
||||
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
|
||||
"""变更批次分页(按创建时间倒序)。"""
|
||||
batches = param_repository.list_batches(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
start_date=query.start_date,
|
||||
end_date=query.end_date,
|
||||
)
|
||||
total = param_repository.count_batches(
|
||||
session, start_date=query.start_date, end_date=query.end_date
|
||||
)
|
||||
batches = param_repository.list_batches(session, query)
|
||||
total = param_repository.count_batches(session, query)
|
||||
return PageResult[ParamBatchOut](
|
||||
records=[
|
||||
ParamBatchOut(
|
||||
@@ -509,42 +493,89 @@ def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDi
|
||||
return out
|
||||
|
||||
|
||||
def _preview_rate(
|
||||
session: Session,
|
||||
payload: ParamPreviewIn,
|
||||
snapshot: ParamSnapshot,
|
||||
days: int,
|
||||
take,
|
||||
) -> tuple[Fraction, str, Decimal | None]:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PreviewContext:
|
||||
"""预览上下文(会话、入参、参数取值闭包)。
|
||||
|
||||
``take`` 闭包同时负责取数与记录「用了哪些参数」——**调用顺序即
|
||||
``params_used`` 的展示顺序**,拆分时不得调换各段的先后。
|
||||
"""
|
||||
|
||||
session: Session
|
||||
payload: ParamPreviewIn
|
||||
take: Callable[[str], Fraction]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PreviewRateDecision:
|
||||
"""计提比例判定(回款天数、纯比例、人读说明、绩效基数覆盖值)。"""
|
||||
|
||||
days: int
|
||||
rate: Fraction
|
||||
rate_text: str
|
||||
base_override: Decimal | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PreviewEarning:
|
||||
"""折算与绩效(毛利润率、折算额、绩效额)。"""
|
||||
|
||||
profit_rate: Fraction
|
||||
converted: Decimal
|
||||
performance: Decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PreviewBarter:
|
||||
"""以物抵顶(是否适用、折算后绩效、计发基数)。"""
|
||||
|
||||
applied: bool
|
||||
after_barter: Decimal | None
|
||||
base_amount: Decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PreviewPayment:
|
||||
"""支付环节(起付判定、R 权重、权重、应付金额)。"""
|
||||
|
||||
start_ok: bool
|
||||
weight_r: int
|
||||
weight: Fraction
|
||||
payable: Decimal
|
||||
|
||||
|
||||
def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decimal | None]:
|
||||
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
|
||||
|
||||
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
|
||||
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
|
||||
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
||||
"""
|
||||
payload = ctx.payload
|
||||
take = ctx.take
|
||||
if payload.customer_type == "enterprise":
|
||||
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
|
||||
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
|
||||
rate = take("enterprise.rate") / 1000
|
||||
return rate, "企业户固定计提比例", None
|
||||
return rate, "企业户:不按回款天数,统一按固定比例计提", None
|
||||
|
||||
role_code = payload.business_role
|
||||
template = "TPL_SALESPERSON_DECAY"
|
||||
if role_code:
|
||||
role = business_role_repository.get_by_code(session, role_code)
|
||||
role = business_role_repository.get_by_code(ctx.session, role_code)
|
||||
if role is None:
|
||||
raise BadRequestError(f"岗位 {role_code!r} 不存在")
|
||||
raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认")
|
||||
template = role.calc_template
|
||||
|
||||
if template == "TPL_MANAGER_SHARE":
|
||||
raise BadRequestError(
|
||||
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
|
||||
"营销经理的绩效按团队与全公司分成计算,不单笔试算;请到月度结算里查看"
|
||||
)
|
||||
if template == "TPL_INFO_SALES_FIXED":
|
||||
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
|
||||
rate = take("info.rate") / 1000
|
||||
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
|
||||
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
|
||||
return rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", Decimal(payload.revenue)
|
||||
if template == "TPL_SALESPERSON_DECAY":
|
||||
start = take("salesperson.rate.start")
|
||||
slope = take("salesperson.rate.slope")
|
||||
@@ -553,12 +584,84 @@ def _preview_rate(
|
||||
rate = formula.salesperson_rate(
|
||||
days, start=start, slope=slope, floor=floor, boundary_days=boundary
|
||||
)
|
||||
# 说明写成业务话:说清这笔为什么是这个比例、什么时候不再降
|
||||
return (
|
||||
rate,
|
||||
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
|
||||
f"回款 {days} 天,从起始 {to_decimal(start)}‰ 按日递减"
|
||||
f"(满 {boundary} 天后固定按 {to_decimal(floor)}‰ 计算)",
|
||||
None,
|
||||
)
|
||||
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
|
||||
# 提示里给模板中文名而不是编码:编码是内部标识,业务人员在岗位角色页看不到它
|
||||
tpl_name = calc_templates.TEMPLATES[template].name if template in calc_templates.TEMPLATES else template
|
||||
raise BadRequestError(
|
||||
f"「{tpl_name}」不支持单笔试算,请在岗位角色里改用业务员递减或信息员固定计提"
|
||||
)
|
||||
|
||||
|
||||
def _preview_rate_decision(ctx: _PreviewContext) -> _PreviewRateDecision:
|
||||
"""产品类别校验 + 回款天数 + 计提比例判定。"""
|
||||
payload = ctx.payload
|
||||
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
|
||||
# 提示里用中文类别名:前端下拉显示的是「市政水利」,报错却回英文编码会让人对不上
|
||||
raise BadRequestError(
|
||||
f"产品类别「{payload.product_category}」不存在,可选:市政水利、轨道交通"
|
||||
)
|
||||
days = formula.payment_days(payload.ship_date, payload.receipt_date)
|
||||
rate, rate_text, base_override = _preview_rate(ctx, days)
|
||||
return _PreviewRateDecision(
|
||||
days=days, rate=rate, rate_text=rate_text, base_override=base_override
|
||||
)
|
||||
|
||||
|
||||
def _preview_earning(ctx: _PreviewContext, decision: _PreviewRateDecision) -> _PreviewEarning:
|
||||
"""毛利润率 → 折算额(阈值)→ 绩效额(比例)。"""
|
||||
payload = ctx.payload
|
||||
revenue = Decimal(payload.revenue)
|
||||
profit = Decimal(payload.profit)
|
||||
profit_rate = formula.profit_rate(profit, revenue)
|
||||
if decision.base_override is not None:
|
||||
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
|
||||
converted = decision.base_override
|
||||
else:
|
||||
threshold = ctx.take("product.threshold")
|
||||
converted = formula.converted_profit(revenue, profit_rate, threshold)
|
||||
performance = formula.performance_amount(converted, decision.rate)
|
||||
return _PreviewEarning(profit_rate=profit_rate, converted=converted, performance=performance)
|
||||
|
||||
|
||||
def _preview_barter(ctx: _PreviewContext, performance: Decimal) -> _PreviewBarter:
|
||||
"""以物抵顶折算(按应发绩效的固定比例折算,比例来自参数)。"""
|
||||
applied = ctx.payload.barter
|
||||
after_barter: Decimal | None = None
|
||||
if applied:
|
||||
discount = ctx.take("barter.discount")
|
||||
after_barter = formula.apply_barter(performance, discount)
|
||||
base_amount = after_barter if applied else performance
|
||||
return _PreviewBarter(applied=applied, after_barter=after_barter, base_amount=base_amount)
|
||||
|
||||
|
||||
def _preview_payment(
|
||||
ctx: _PreviewContext, received: Decimal, revenue: Decimal, base_amount: Decimal
|
||||
) -> _PreviewPayment:
|
||||
"""起付比例判定 + R 权重 + 应付金额。"""
|
||||
start_ratio = ctx.take("payment.start.ratio")
|
||||
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
||||
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
|
||||
weight_r = formula.clamp_weight_r(received, revenue)
|
||||
weight = formula.payment_weight(weight_r)
|
||||
payable = to_decimal(Fraction(base_amount) * weight)
|
||||
return _PreviewPayment(start_ok=start_ok, weight_r=weight_r, weight=weight, payable=payable)
|
||||
|
||||
|
||||
def _preview_probation_note(ctx: _PreviewContext) -> str | None:
|
||||
"""试用期说明(绩效照算不发放,年累计超阈值的部分年底一次性结算)。"""
|
||||
if not ctx.payload.probation:
|
||||
return None
|
||||
annual = ctx.take("probation.annual.threshold")
|
||||
return (
|
||||
f"试用期人员:绩效照常计算但不发放;年内累计超过 {to_decimal(annual)} 元的部分,"
|
||||
"年底一次性结清。"
|
||||
)
|
||||
|
||||
|
||||
def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
||||
@@ -566,7 +669,6 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
||||
# 参数按回款到账日取值(对照表 #11)
|
||||
snapshot = resolve_snapshot(session, payload.receipt_date)
|
||||
revenue = Decimal(payload.revenue)
|
||||
profit = Decimal(payload.profit)
|
||||
received = Decimal(payload.received_amount)
|
||||
used: list[ParamUsedOut] = []
|
||||
|
||||
@@ -589,58 +691,29 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
||||
)
|
||||
return parse_fraction(raw)
|
||||
|
||||
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
|
||||
raise BadRequestError(
|
||||
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit"
|
||||
)
|
||||
|
||||
days = formula.payment_days(payload.ship_date, payload.receipt_date)
|
||||
rate, rate_text, base_override = _preview_rate(session, payload, snapshot, days, take)
|
||||
|
||||
profit_rate = formula.profit_rate(profit, revenue)
|
||||
if base_override is not None:
|
||||
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
|
||||
converted = base_override
|
||||
else:
|
||||
threshold = take("product.threshold")
|
||||
converted = formula.converted_profit(revenue, profit_rate, threshold)
|
||||
performance = formula.performance_amount(converted, rate)
|
||||
|
||||
barter_applied = payload.barter
|
||||
after_barter: Decimal | None = None
|
||||
if barter_applied:
|
||||
discount = take("barter.discount")
|
||||
after_barter = formula.apply_barter(performance, discount)
|
||||
|
||||
start_ratio = take("payment.start.ratio")
|
||||
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
||||
# R 按「回款占销售额百分比向下取整」口径封顶封底:received 非负时 int()
|
||||
# 即向下取整;min/max 同时是红字冲销(负数回款)接入时的方向防线
|
||||
weight_r = (
|
||||
max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100))) if revenue > 0 else 0
|
||||
)
|
||||
weight = formula.payment_weight(weight_r)
|
||||
base_amount = after_barter if barter_applied else performance
|
||||
payable = to_decimal(Fraction(base_amount) * weight)
|
||||
|
||||
probation_note: str | None = None
|
||||
if payload.probation:
|
||||
annual = take("probation.annual.threshold")
|
||||
probation_note = f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算"
|
||||
ctx = _PreviewContext(session=session, payload=payload, take=take)
|
||||
# 各段顺序固定 —— take 的调用顺序即 params_used 的展示顺序
|
||||
decision = _preview_rate_decision(ctx)
|
||||
earning = _preview_earning(ctx, decision)
|
||||
barter = _preview_barter(ctx, earning.performance)
|
||||
payment = _preview_payment(ctx, received, revenue, barter.base_amount)
|
||||
probation_note = _preview_probation_note(ctx)
|
||||
|
||||
return ParamPreviewOut(
|
||||
payment_days=days,
|
||||
rate=str(rate),
|
||||
rate_text=rate_text,
|
||||
profit_rate=str(profit_rate),
|
||||
converted_profit=str(converted),
|
||||
performance_amount=str(performance),
|
||||
barter_applied=barter_applied,
|
||||
performance_after_barter=str(after_barter) if after_barter is not None else None,
|
||||
start_payment_ok=start_ok,
|
||||
payment_weight_r=weight_r,
|
||||
payment_weight=str(weight),
|
||||
payable_amount=str(payable),
|
||||
payment_days=decision.days,
|
||||
rate=str(decision.rate),
|
||||
rate_text=decision.rate_text,
|
||||
profit_rate=str(earning.profit_rate),
|
||||
converted_profit=str(earning.converted),
|
||||
performance_amount=str(earning.performance),
|
||||
barter_applied=barter.applied,
|
||||
performance_after_barter=(
|
||||
str(barter.after_barter) if barter.after_barter is not None else None
|
||||
),
|
||||
start_payment_ok=payment.start_ok,
|
||||
payment_weight_r=payment.weight_r,
|
||||
payment_weight=str(payment.weight),
|
||||
payable_amount=str(payment.payable),
|
||||
probation_note=probation_note,
|
||||
params_used=used,
|
||||
)
|
||||
|
||||
@@ -70,20 +70,8 @@ def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personn
|
||||
|
||||
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
|
||||
"""分页查询人员(含离职)。"""
|
||||
rows = personnel_repository.list_personnel(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
business_role_id=query.business_role_id,
|
||||
)
|
||||
total = personnel_repository.count_personnel(
|
||||
session,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
business_role_id=query.business_role_id,
|
||||
)
|
||||
rows = personnel_repository.list_personnel(session, query)
|
||||
total = personnel_repository.count_personnel(session, query)
|
||||
return PageResult[PersonnelOut](
|
||||
records=[
|
||||
_to_out(personnel, role_name, manager_name)
|
||||
|
||||
@@ -99,20 +99,8 @@ def _raise_if_duplicate(exc: IntegrityError) -> None:
|
||||
|
||||
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
|
||||
"""分页查询产品(含停用)。"""
|
||||
products = product_repository.list_products(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
keyword=query.keyword,
|
||||
product_category=query.product_category,
|
||||
status=query.status,
|
||||
)
|
||||
total = product_repository.count_products(
|
||||
session,
|
||||
keyword=query.keyword,
|
||||
product_category=query.product_category,
|
||||
status=query.status,
|
||||
)
|
||||
products = product_repository.list_products(session, query)
|
||||
total = product_repository.count_products(session, query)
|
||||
return PageResult[ProductOut](
|
||||
records=[_to_out(product) for product in products],
|
||||
total=total,
|
||||
|
||||
@@ -217,7 +217,10 @@ def update_grade(
|
||||
raise NotFoundError("评级等级不存在")
|
||||
|
||||
_ensure_no_overlap(
|
||||
rating_repository.list_all(session, for_update=True), new_min, new_max, exclude_id=grade_id
|
||||
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:
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -38,7 +40,7 @@ from app.core.constants import (
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import Receipt
|
||||
from app.models import Business, Receipt
|
||||
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
|
||||
from app.repositories import business_repository, receipt_repository
|
||||
@@ -128,24 +130,8 @@ def _ensure_business_writable(session: Session, business_id: int, action: str):
|
||||
|
||||
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
|
||||
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
|
||||
rows = receipt_repository.list_receipts(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
business_id=query.business_id,
|
||||
date_from=query.date_from,
|
||||
date_to=query.date_to,
|
||||
is_barter=query.is_barter,
|
||||
settlement_status=query.settlement_status,
|
||||
)
|
||||
total = receipt_repository.count_receipts(
|
||||
session,
|
||||
business_id=query.business_id,
|
||||
date_from=query.date_from,
|
||||
date_to=query.date_to,
|
||||
is_barter=query.is_barter,
|
||||
settlement_status=query.settlement_status,
|
||||
)
|
||||
rows = receipt_repository.list_receipts(session, query)
|
||||
total = receipt_repository.count_receipts(session, query)
|
||||
return PageResult[ReceiptOut](
|
||||
records=[_to_out(row) for row in rows],
|
||||
total=total,
|
||||
@@ -165,6 +151,57 @@ def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CreatedReceipt:
|
||||
"""建行结果(出参 + 固化的 ID/批次号,commit 后不再触碰 ORM 属性)。"""
|
||||
|
||||
out: ReceiptOut
|
||||
receipt_id: int
|
||||
batch_no: str
|
||||
|
||||
|
||||
def _create_receipt_once(
|
||||
session: Session,
|
||||
payload: ReceiptCreateIn,
|
||||
business: Business | None,
|
||||
batch_no: str,
|
||||
) -> _CreatedReceipt:
|
||||
"""一次建行尝试:准入校验 → 建行 → 重算合计 → 出参。
|
||||
|
||||
**不 commit**:提交与回滚由调用方(重试循环)统一控制,保证「一次尝试
|
||||
= 一个事务边界」。
|
||||
"""
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
raise BadRequestError("业务已完成或已终止,禁止新增回款")
|
||||
# 月结锁定拦截(2026-10-15 补充需求):比需求原文「提示」取硬,
|
||||
# 一期安全侧(策略点注释);未指定日期时以当天月近似
|
||||
from app.services import settlement_service
|
||||
|
||||
_lock_month = (payload.receipt_date or date.today()).strftime("%Y-%m")
|
||||
settlement_service.ensure_month_not_locked(session, _lock_month)
|
||||
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||
session, payload.shipment_id, payload.business_id
|
||||
)
|
||||
receipt = receipt_repository.create(
|
||||
session,
|
||||
Receipt(
|
||||
receipt_batch_no=batch_no,
|
||||
business_id=payload.business_id,
|
||||
shipment_id=payload.shipment_id,
|
||||
receipt_date=payload.receipt_date,
|
||||
amount=Decimal(payload.amount),
|
||||
is_barter=payload.is_barter,
|
||||
settlement_status=SETTLEMENT_PENDING,
|
||||
remark=payload.remark,
|
||||
),
|
||||
)
|
||||
_recalc_totals(session, business)
|
||||
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
||||
return _CreatedReceipt(out=out, receipt_id=receipt.id, batch_no=batch_no)
|
||||
|
||||
|
||||
def create_receipt(
|
||||
session: Session,
|
||||
payload: ReceiptCreateIn,
|
||||
@@ -205,29 +242,7 @@ def create_receipt(
|
||||
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
|
||||
for attempt in range(_NO_RETRY + 1):
|
||||
try:
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
raise BadRequestError("业务已完成或已终止,禁止新增回款")
|
||||
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||
session, payload.shipment_id, payload.business_id
|
||||
)
|
||||
receipt = receipt_repository.create(
|
||||
session,
|
||||
Receipt(
|
||||
receipt_batch_no=batch_no,
|
||||
business_id=payload.business_id,
|
||||
shipment_id=payload.shipment_id,
|
||||
receipt_date=payload.receipt_date,
|
||||
amount=Decimal(payload.amount),
|
||||
is_barter=payload.is_barter,
|
||||
settlement_status=SETTLEMENT_PENDING,
|
||||
remark=payload.remark,
|
||||
),
|
||||
)
|
||||
_recalc_totals(session, business)
|
||||
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
||||
new_id, new_no = receipt.id, batch_no # 固化:commit 后不碰 ORM
|
||||
created = _create_receipt_once(session, payload, business, batch_no)
|
||||
session.commit()
|
||||
break
|
||||
except IntegrityError as exc:
|
||||
@@ -242,11 +257,11 @@ def create_receipt(
|
||||
|
||||
logger.info(
|
||||
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
|
||||
new_id,
|
||||
new_no,
|
||||
created.receipt_id,
|
||||
created.batch_no,
|
||||
payload.business_id,
|
||||
)
|
||||
return out
|
||||
return created.out
|
||||
|
||||
|
||||
def update_receipt(
|
||||
@@ -284,6 +299,12 @@ def update_receipt(
|
||||
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(
|
||||
@@ -335,6 +356,10 @@ def delete_receipt(
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
"""报表 Excel 模板构建(纯函数,可单测)。
|
||||
|
||||
版式(FR-032「导出模板围绕财务复核和领导签字设计」):
|
||||
公司信息页眉 → 报表标题 → 筛选条件 → 导出时间/人/模板版本 → 表头 → 数据行
|
||||
→ 合计行 → 财务复核栏 / 领导签字栏 / 日期栏。
|
||||
|
||||
精度红线:金额写 ``Decimal``(openpyxl 原生支持,不经 float)+
|
||||
``number_format='#,##0.00'``;合计在 Python 侧求和后两位收口,**不写
|
||||
Excel 公式**(避免打开时重算漂移与公式注入面)。文本列以 ``= + - @``
|
||||
开头时前置单引号,防公式注入。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
from app.core.report_meta import (
|
||||
BUSINESS_STATUS_LABELS,
|
||||
CUSTOMER_TYPE_LABELS,
|
||||
PRODUCT_CATEGORY_LABELS,
|
||||
)
|
||||
|
||||
_HEADER_FILL = PatternFill("solid", fgColor="DDE3F0")
|
||||
_THIN = Side(style="thin", color="B0B7C3")
|
||||
_BORDER = Border(left=_THIN, right=_THIN, top=_THIN, bottom=_THIN)
|
||||
_TITLE_FONT = Font(size=14, bold=True)
|
||||
_COMPANY_FONT = Font(size=16, bold=True)
|
||||
_HEADER_FONT = Font(bold=True)
|
||||
_AMOUNT_FORMAT = "#,##0.00"
|
||||
_DATE_FORMAT = "YYYY-MM-DD"
|
||||
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Column:
|
||||
"""列定义:key 取行 dict 字段,kind 决定单元格类型与格式。"""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
kind: str # text / amount / int / date / customer / category / status
|
||||
width: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Block:
|
||||
"""签字稿中的一个区块:独立表头、数据行与可选合计行。"""
|
||||
|
||||
title: str
|
||||
columns: tuple[_Column, ...]
|
||||
rows: list[dict]
|
||||
total: dict | None = None
|
||||
empty_text: str = "(无)"
|
||||
|
||||
|
||||
_COLUMNS: dict[str, tuple[_Column, ...]] = {
|
||||
"personal-detail": (
|
||||
_Column("business_no", "合同编号", "text", 20),
|
||||
_Column("ship_date", "发货日", "date", 12),
|
||||
_Column("receipt_date", "回款日", "date", 12),
|
||||
_Column("payment_days", "回款天数", "int", 10),
|
||||
_Column("product_category", "产品类别", "category", 14),
|
||||
_Column("customer_type", "客户类型", "customer", 14),
|
||||
_Column("role_name", "岗位", "text", 12),
|
||||
_Column("rate_text", "计提比例", "text", 14),
|
||||
_Column("weight_r", "支付权重R", "int", 10),
|
||||
_Column("performance_amount", "绩效金额", "amount", 14),
|
||||
_Column("payable_amount", "应付绩效", "amount", 14),
|
||||
),
|
||||
"department-summary": (
|
||||
_Column("manager_name", "经理团队", "text", 16),
|
||||
_Column("member_name", "成员", "text", 14),
|
||||
_Column("role_code", "岗位编码", "text", 12),
|
||||
_Column("performance_sum", "绩效金额", "amount", 14),
|
||||
_Column("payable_sum", "应付绩效", "amount", 14),
|
||||
_Column("adjust_net", "调整净额", "amount", 14),
|
||||
_Column("net_total", "净合计", "amount", 14),
|
||||
),
|
||||
"business-stats": (
|
||||
_Column("business_no", "业务编号", "text", 20),
|
||||
_Column("business_date", "业务日期", "date", 12),
|
||||
_Column("customer_name", "客户", "text", 22),
|
||||
_Column("owner_personnel_name", "归属人", "text", 12),
|
||||
_Column("status", "状态", "status", 10),
|
||||
_Column("total_sale_amount", "销售额", "amount", 14),
|
||||
_Column("total_ship_amount", "发货额", "amount", 14),
|
||||
_Column("total_received_amount", "回款额", "amount", 14),
|
||||
_Column("performance_amount", "绩效金额", "amount", 14),
|
||||
_Column("payable_amount", "应付绩效", "amount", 14),
|
||||
),
|
||||
"payment-stats": (
|
||||
_Column("label", "回款天数区间", "text", 20),
|
||||
_Column("receipt_amount", "回款金额", "amount", 16),
|
||||
_Column("receipt_count", "回款笔数", "int", 10),
|
||||
_Column("performance_amount", "绩效金额", "amount", 14),
|
||||
_Column("payable_amount", "应付绩效", "amount", 14),
|
||||
_Column("ledger_count", "台账行数", "int", 10),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_workbook(
|
||||
*,
|
||||
code: str,
|
||||
title: str,
|
||||
filters: str,
|
||||
rows: list[dict],
|
||||
summary: dict | None,
|
||||
buckets: dict | None,
|
||||
template_version: str,
|
||||
operator_name: str,
|
||||
company_name: str,
|
||||
department: dict | None = None,
|
||||
) -> bytes:
|
||||
"""构建并返回 xlsx 字节流(业务数据只读,不触碰数据库)。
|
||||
|
||||
部门汇总是签字对账文件,走单 sheet 分块版式;其余报表仍是单表 +
|
||||
合计行。`department` 为 `department_summary` 的完整出参。
|
||||
"""
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = _sheet_name(code)
|
||||
if code == "department-summary" and department is not None:
|
||||
_write_department_document(
|
||||
sheet,
|
||||
department,
|
||||
company_name=company_name,
|
||||
title=title,
|
||||
filters=filters,
|
||||
template_version=template_version,
|
||||
operator_name=operator_name,
|
||||
)
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
columns = _COLUMNS[code]
|
||||
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
|
||||
if rows:
|
||||
for row in rows:
|
||||
_write_row(sheet, columns, row)
|
||||
else:
|
||||
sheet.append(["本筛选条件无数据"])
|
||||
_merge_row(sheet, len(columns))
|
||||
_write_total(sheet, columns, rows)
|
||||
_write_signature(sheet, len(columns))
|
||||
_autofit(sheet, columns)
|
||||
|
||||
# 回款统计附维度对比 sheet(客户类型/产品类别),与区间表共用页眉格式
|
||||
if code == "payment-stats" and buckets:
|
||||
for dim_key, dim_title in (
|
||||
("by_customer_type", "客户类型对比"),
|
||||
("by_product_category", "产品类别对比"),
|
||||
):
|
||||
extra = workbook.create_sheet(dim_title)
|
||||
_write_dimension_sheet(
|
||||
extra,
|
||||
dim_title,
|
||||
company_name,
|
||||
filters,
|
||||
buckets.get(dim_key) or [],
|
||||
template_version,
|
||||
operator_name,
|
||||
)
|
||||
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _sheet_name(code: str) -> str:
|
||||
return {
|
||||
"personal-detail": "个人绩效明细",
|
||||
"department-summary": "部门绩效汇总",
|
||||
"business-stats": "业务统计",
|
||||
"payment-stats": "回款统计",
|
||||
}[code]
|
||||
|
||||
|
||||
def _write_header(
|
||||
sheet,
|
||||
columns: tuple[_Column, ...],
|
||||
company_name: str,
|
||||
title: str,
|
||||
filters: str,
|
||||
template_version: str,
|
||||
operator_name: str,
|
||||
) -> None:
|
||||
"""页眉四行 + 表头行;返回后 sheet.max_row 即数据行起点。"""
|
||||
_write_document_header(
|
||||
sheet,
|
||||
len(columns),
|
||||
company_name,
|
||||
title,
|
||||
filters,
|
||||
template_version,
|
||||
operator_name,
|
||||
)
|
||||
|
||||
sheet.append([column.title for column in columns])
|
||||
header_row = sheet.max_row
|
||||
for cell in sheet[header_row]:
|
||||
cell.font = _HEADER_FONT
|
||||
cell.fill = _HEADER_FILL
|
||||
cell.border = _BORDER
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
sheet.freeze_panes = sheet.cell(row=header_row + 1, column=1)
|
||||
|
||||
|
||||
def _write_document_header(
|
||||
sheet,
|
||||
column_count: int,
|
||||
company_name: str,
|
||||
title: str,
|
||||
filters: str,
|
||||
template_version: str,
|
||||
operator_name: str,
|
||||
) -> None:
|
||||
"""公司名/标题/筛选条件/导出信息四行(分块文档与单表共用)。"""
|
||||
sheet.append([company_name])
|
||||
_merge_row(sheet, column_count)
|
||||
sheet.cell(row=1, column=1).font = _COMPANY_FONT
|
||||
sheet.cell(row=1, column=1).alignment = Alignment(horizontal="left", vertical="center")
|
||||
|
||||
sheet.append([title])
|
||||
_merge_row(sheet, column_count)
|
||||
sheet.cell(row=2, column=1).font = _TITLE_FONT
|
||||
sheet.cell(row=2, column=1).alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
sheet.append([f"筛选条件:{filters}"])
|
||||
_merge_row(sheet, column_count)
|
||||
sheet.append(
|
||||
[
|
||||
f"导出时间:{datetime.now():%Y-%m-%d %H:%M:%S} | 导出人:{operator_name} | "
|
||||
f"模板版本:{template_version}"
|
||||
]
|
||||
)
|
||||
_merge_row(sheet, column_count)
|
||||
sheet.append([])
|
||||
|
||||
|
||||
def _merge_row(sheet, column_count: int) -> None:
|
||||
"""合并当前行(公司名/标题/筛选行跨全表宽)。"""
|
||||
row = sheet.max_row
|
||||
if column_count > 1:
|
||||
sheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=column_count)
|
||||
|
||||
|
||||
def _write_row(sheet, columns: tuple[_Column, ...], row: dict) -> None:
|
||||
"""数据行:按列类型写单元格(金额 Decimal、日期 date、文本防注入)。"""
|
||||
values = []
|
||||
for column in columns:
|
||||
raw = row.get(column.key)
|
||||
values.append(_cell_value(column, raw))
|
||||
sheet.append(values)
|
||||
for index, column in enumerate(columns, start=1):
|
||||
cell = sheet.cell(row=sheet.max_row, column=index)
|
||||
cell.border = _BORDER
|
||||
if column.kind == "amount":
|
||||
cell.number_format = _AMOUNT_FORMAT
|
||||
elif column.kind == "date":
|
||||
cell.number_format = _DATE_FORMAT
|
||||
|
||||
|
||||
def _cell_value(column: _Column, raw):
|
||||
"""单值转换:标签列转中文,文本列防公式注入。"""
|
||||
if raw is None:
|
||||
return None
|
||||
if column.kind == "amount":
|
||||
return raw if isinstance(raw, Decimal) else Decimal(str(raw))
|
||||
if column.kind == "date":
|
||||
return raw
|
||||
if column.kind == "int":
|
||||
return int(raw)
|
||||
if column.kind == "customer":
|
||||
return CUSTOMER_TYPE_LABELS.get(str(raw), str(raw))
|
||||
if column.kind == "category":
|
||||
return PRODUCT_CATEGORY_LABELS.get(str(raw), str(raw))
|
||||
if column.kind == "status":
|
||||
return BUSINESS_STATUS_LABELS.get(int(raw), str(raw))
|
||||
return _safe_text(raw)
|
||||
|
||||
|
||||
def _safe_text(value) -> str:
|
||||
"""文本防公式注入:以 = + - @ 或制表符开头时前置单引号。"""
|
||||
text = str(value)
|
||||
return "'" + text if text.startswith(_FORMULA_PREFIXES) else text
|
||||
|
||||
|
||||
def _write_total(sheet, columns: tuple[_Column, ...], rows: list[dict]) -> None:
|
||||
"""合计行:金额列按行求和后两位收口(不写 Excel 公式)。
|
||||
|
||||
「合计」标签只写在第一个非金额列;其余非金额列留空——否则每遇到
|
||||
一个文本列都会重复写一次标签,合计行会满屏「合计」。
|
||||
"""
|
||||
if not rows:
|
||||
return
|
||||
totals = []
|
||||
label_written = False
|
||||
for column in columns:
|
||||
if column.kind != "amount":
|
||||
totals.append("合计" if not label_written else None)
|
||||
label_written = True
|
||||
continue
|
||||
total = sum(
|
||||
(
|
||||
row.get(column.key)
|
||||
for row in rows
|
||||
if isinstance(row.get(column.key), (Decimal, int))
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
totals.append(total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
||||
sheet.append(totals)
|
||||
for index, column in enumerate(columns, start=1):
|
||||
cell = sheet.cell(row=sheet.max_row, column=index)
|
||||
cell.font = _HEADER_FONT
|
||||
cell.border = _BORDER
|
||||
if column.kind == "amount":
|
||||
cell.number_format = _AMOUNT_FORMAT
|
||||
|
||||
|
||||
def _write_signature(sheet, column_count: int) -> None:
|
||||
"""财务复核栏 / 领导签字栏 / 日期栏(FR-032 模板要素)。"""
|
||||
sheet.append([])
|
||||
sheet.append(["财务复核:________________", "", "日期:______年__月__日"])
|
||||
sheet.append(["领导签字:________________", "", "日期:______年__月__日"])
|
||||
for row_index in (sheet.max_row - 1, sheet.max_row):
|
||||
sheet.cell(row=row_index, column=1).alignment = Alignment(horizontal="left")
|
||||
|
||||
|
||||
def _autofit(sheet, columns: tuple[_Column, ...]) -> None:
|
||||
"""列宽按定义表设置(CJK 按 2 字符宽,不用默认 8.43)。"""
|
||||
for index, column in enumerate(columns, start=1):
|
||||
sheet.column_dimensions[get_column_letter(index)].width = column.width
|
||||
|
||||
|
||||
def _write_dimension_sheet(
|
||||
sheet,
|
||||
title: str,
|
||||
company_name: str,
|
||||
filters: str,
|
||||
rows: list[dict],
|
||||
template_version: str,
|
||||
operator_name: str,
|
||||
) -> None:
|
||||
"""维度对比 sheet(客户类型/产品类别),页眉筛选条件与主表一致。"""
|
||||
columns = (
|
||||
_Column("label", "维度", "text", 16),
|
||||
_Column("receipt_amount", "回款金额", "amount", 16),
|
||||
_Column("performance_amount", "绩效金额", "amount", 14),
|
||||
_Column("payable_amount", "应付绩效", "amount", 14),
|
||||
_Column("ledger_count", "台账行数", "int", 10),
|
||||
)
|
||||
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
|
||||
for row in rows:
|
||||
_write_row(sheet, columns, row)
|
||||
_write_total(sheet, columns, rows)
|
||||
_write_signature(sheet, len(columns))
|
||||
_autofit(sheet, columns)
|
||||
|
||||
|
||||
# ==== 部门汇总签字稿(FR-030 / FR-032)====
|
||||
|
||||
_MEMBER_KIND_LABELS = {
|
||||
"regular": "普通绩效",
|
||||
"manager_personal": "经理个人户",
|
||||
"adjustment": "仅调整单",
|
||||
}
|
||||
|
||||
# 各块列数不同,统一按列号取最大宽度(签字稿打印时不挤在一起)
|
||||
_DEPARTMENT_WIDTHS = (30, 16, 22, 13, 13, 15, 18, 14, 14, 13)
|
||||
|
||||
|
||||
def _write_department_document(
|
||||
sheet,
|
||||
data: dict,
|
||||
*,
|
||||
company_name: str,
|
||||
title: str,
|
||||
filters: str,
|
||||
template_version: str,
|
||||
operator_name: str,
|
||||
) -> None:
|
||||
"""部门汇总签字稿:团队分成 → 全公司 5% → 对账 → 成员 → 未分配 → 签字栏。
|
||||
|
||||
全公司 5% 每位有月结聚合行的经理各计一次,合计在第二块展示;
|
||||
团队块的调整净额仅供成员汇总核对。应发合计由后端 `reconciliation`
|
||||
按聚合行口径算好(含分列舍入差),本函数只渲染。
|
||||
"""
|
||||
params = data.get("params") or {}
|
||||
team_text = params.get("team_text", "")
|
||||
company_text = params.get("company_text", "")
|
||||
teams = data.get("teams") or []
|
||||
unassigned = data.get("unassigned") or []
|
||||
company = data.get("company") or {}
|
||||
reconciliation = data.get("reconciliation") or {}
|
||||
company_share_count = int(company.get("company_share_count") or 0)
|
||||
|
||||
_write_document_header(
|
||||
sheet,
|
||||
len(_DEPARTMENT_WIDTHS),
|
||||
company_name,
|
||||
title,
|
||||
filters,
|
||||
template_version,
|
||||
operator_name,
|
||||
)
|
||||
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block(
|
||||
"一、团队分成汇总",
|
||||
(
|
||||
_Column("seq", "序号", "int", 6),
|
||||
_Column("manager_name", "经理团队", "text", 16),
|
||||
_Column("manager_status_text", "状态", "text", 8),
|
||||
_Column("member_count", "团队人数", "int", 9),
|
||||
_Column("team_base", "团队基数", "amount", 13),
|
||||
_Column("team_share_amount", f"团队分成({team_text}%)", "amount", 15),
|
||||
_Column("adjust_net", "调整净额(仅核对)", "amount", 16),
|
||||
_Column("team_subtotal", "团队应发小计(含聚合分成)", "amount", 18),
|
||||
_Column("frozen_amount", "月结冻结额", "amount", 14),
|
||||
),
|
||||
[
|
||||
{
|
||||
"seq": index,
|
||||
"manager_name": team["manager_name"],
|
||||
"manager_status_text": "在职" if team["manager_status"] == 1 else "离职",
|
||||
"member_count": team["member_count"],
|
||||
"team_base": team["team_base"],
|
||||
"team_share_amount": team["team_share_amount"],
|
||||
"adjust_net": team["adjust_net"],
|
||||
"team_subtotal": team["team_subtotal"],
|
||||
"frozen_amount": team["frozen_amount"],
|
||||
}
|
||||
for index, team in enumerate(teams, start=1)
|
||||
],
|
||||
{
|
||||
"manager_name": "合计",
|
||||
"member_count": sum(team["member_count"] for team in teams),
|
||||
"team_base": _sum_amount(team["team_base"] for team in teams),
|
||||
"team_share_amount": _sum_amount(team["team_share_amount"] for team in teams),
|
||||
"team_subtotal": _sum_amount(team["team_subtotal"] for team in teams),
|
||||
# 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导)
|
||||
"frozen_amount": (
|
||||
_sum_amount(
|
||||
team["frozen_amount"] for team in teams if team["frozen_amount"] is not None
|
||||
)
|
||||
if any(team["frozen_amount"] is not None for team in teams)
|
||||
else None
|
||||
),
|
||||
},
|
||||
"(无团队)",
|
||||
),
|
||||
)
|
||||
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block(
|
||||
f"二、全公司分成(每位有聚合行的经理各计一次,共 {company_share_count} 次)",
|
||||
(
|
||||
_Column("company_base", "全公司基数", "amount", 16),
|
||||
_Column("company_share_pct", "分成比例", "text", 12),
|
||||
_Column("company_share_amount", "单经理分成", "amount", 13),
|
||||
_Column("company_share_count", "计入次数", "int", 10),
|
||||
_Column("company_share_total", "分成合计", "amount", 14),
|
||||
),
|
||||
[
|
||||
{
|
||||
"company_base": company.get("company_base"),
|
||||
"company_share_pct": f"{company_text}%",
|
||||
"company_share_amount": company.get("company_share_amount"),
|
||||
"company_share_count": company_share_count,
|
||||
"company_share_total": company.get("company_share_total"),
|
||||
}
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block(
|
||||
"三、对账汇总",
|
||||
(
|
||||
_Column("item", "项目", "text", 30),
|
||||
_Column("amount", "金额", "amount", 16),
|
||||
_Column("note", "说明", "text", 40),
|
||||
),
|
||||
[
|
||||
{
|
||||
"item": "团队成员应付合计",
|
||||
"amount": reconciliation.get("team_member_payable_total"),
|
||||
},
|
||||
{
|
||||
"item": "未分配人员应付合计",
|
||||
"amount": reconciliation.get("unassigned_payable_total"),
|
||||
},
|
||||
{"item": "团队分成合计(分列)", "amount": reconciliation.get("team_share_total")},
|
||||
{
|
||||
"item": f"全公司分成合计({company_share_count} 次,分列)",
|
||||
"amount": reconciliation.get("company_share_total"),
|
||||
"note": "每位有聚合行的经理各计一次",
|
||||
},
|
||||
{
|
||||
"item": "聚合行舍入差",
|
||||
"amount": reconciliation.get("aggregate_rounding_diff"),
|
||||
"note": "分列舍入与聚合行一次舍入的差",
|
||||
},
|
||||
{
|
||||
"item": "调整净额合计(明细层)",
|
||||
"amount": reconciliation.get("adjust_net_total"),
|
||||
},
|
||||
{
|
||||
"item": "应发合计",
|
||||
"amount": reconciliation.get("payable_total"),
|
||||
"note": "上列各项之和",
|
||||
},
|
||||
{"item": "团队基数合计", "amount": reconciliation.get("team_base_total")},
|
||||
{
|
||||
"item": "未分配绩效合计",
|
||||
"amount": reconciliation.get("unassigned_performance_total"),
|
||||
},
|
||||
{
|
||||
"item": "其他未展示基数",
|
||||
"amount": reconciliation.get("other_team_base"),
|
||||
"note": "筛选时含其他团队与全公司未分配",
|
||||
},
|
||||
{"item": "全公司基数", "amount": reconciliation.get("company_base")},
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
member_columns = (
|
||||
_Column("manager_name", "经理团队", "text", 15),
|
||||
_Column("personnel_name", "成员", "text", 12),
|
||||
_Column("role_code", "岗位", "text", 10),
|
||||
_Column("member_kind_text", "来源", "text", 13),
|
||||
_Column("performance_sum", "绩效金额", "amount", 13),
|
||||
_Column("payable_sum", "应付绩效", "amount", 13),
|
||||
_Column("adjust_extra", "调整补发", "amount", 12),
|
||||
_Column("adjust_deduct", "调整扣减", "amount", 12),
|
||||
_Column("adjust_net", "调整净额", "amount", 12),
|
||||
_Column("net_total", "净合计", "amount", 13),
|
||||
)
|
||||
member_rows = [
|
||||
{
|
||||
"manager_name": team["manager_name"],
|
||||
"personnel_name": member["personnel_name"],
|
||||
"role_code": member["role_code"],
|
||||
"member_kind_text": _MEMBER_KIND_LABELS.get(
|
||||
member["member_kind"], member["member_kind"]
|
||||
),
|
||||
"performance_sum": member["performance_sum"],
|
||||
"payable_sum": member["payable_sum"],
|
||||
"adjust_extra": member["adjust_extra"],
|
||||
"adjust_deduct": member["adjust_deduct"],
|
||||
"adjust_net": member["adjust_net"],
|
||||
"net_total": member["net_total"],
|
||||
}
|
||||
for team in teams
|
||||
for member in team["members"]
|
||||
]
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block("四、团队成员明细", member_columns, member_rows, empty_text="(无成员)"),
|
||||
sum_amounts=True,
|
||||
)
|
||||
|
||||
unassigned_columns = (
|
||||
_Column("personnel_name", "人员", "text", 14),
|
||||
_Column("role_code", "岗位", "text", 10),
|
||||
_Column("unassigned_reason", "未纳入原因", "text", 24),
|
||||
_Column("performance_sum", "绩效金额", "amount", 13),
|
||||
_Column("payable_sum", "应付绩效", "amount", 13),
|
||||
_Column("adjust_extra", "调整补发", "amount", 12),
|
||||
_Column("adjust_deduct", "调整扣减", "amount", 12),
|
||||
_Column("adjust_net", "调整净额", "amount", 12),
|
||||
_Column("net_total", "净合计", "amount", 13),
|
||||
)
|
||||
unassigned_rows = [
|
||||
{
|
||||
"personnel_name": row["personnel_name"],
|
||||
"role_code": row["role_code"],
|
||||
"unassigned_reason": row.get("unassigned_reason") or "",
|
||||
"performance_sum": row["performance_sum"],
|
||||
"payable_sum": row["payable_sum"],
|
||||
"adjust_extra": row["adjust_extra"],
|
||||
"adjust_deduct": row["adjust_deduct"],
|
||||
"adjust_net": row["adjust_net"],
|
||||
"net_total": row["net_total"],
|
||||
}
|
||||
for row in unassigned
|
||||
]
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block("五、未分配人员明细", unassigned_columns, unassigned_rows),
|
||||
sum_amounts=True,
|
||||
)
|
||||
|
||||
_write_signature(sheet, len(_DEPARTMENT_WIDTHS))
|
||||
for index, width in enumerate(_DEPARTMENT_WIDTHS, start=1):
|
||||
sheet.column_dimensions[get_column_letter(index)].width = width
|
||||
|
||||
|
||||
def _write_block(sheet, block: _Block, *, sum_amounts: bool = False) -> None:
|
||||
"""写一个区块:标题、表头、数据/空提示,以及自定义或自动合计。"""
|
||||
sheet.append([block.title])
|
||||
title_row = sheet.max_row
|
||||
_merge_row(sheet, len(block.columns))
|
||||
sheet.cell(row=title_row, column=1).font = _HEADER_FONT
|
||||
|
||||
sheet.append([column.title for column in block.columns])
|
||||
header_row = sheet.max_row
|
||||
for cell in sheet[header_row]:
|
||||
cell.font = _HEADER_FONT
|
||||
cell.fill = _HEADER_FILL
|
||||
cell.border = _BORDER
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
if block.rows:
|
||||
for row in block.rows:
|
||||
_write_row(sheet, block.columns, row)
|
||||
else:
|
||||
sheet.append([block.empty_text])
|
||||
_merge_row(sheet, len(block.columns))
|
||||
|
||||
if block.total is not None:
|
||||
_write_custom_total(sheet, block.columns, block.total)
|
||||
elif sum_amounts:
|
||||
_write_total(sheet, block.columns, block.rows)
|
||||
sheet.append([])
|
||||
|
||||
|
||||
def _write_custom_total(sheet, columns: tuple[_Column, ...], total: dict) -> None:
|
||||
"""写后端预计算的合计行(不自动求和,避免重复计入共享基数)。"""
|
||||
values = []
|
||||
for column in columns:
|
||||
raw = total.get(column.key)
|
||||
values.append(_cell_value(column, raw) if raw is not None else None)
|
||||
sheet.append(values)
|
||||
for index, column in enumerate(columns, start=1):
|
||||
cell = sheet.cell(row=sheet.max_row, column=index)
|
||||
cell.font = _HEADER_FONT
|
||||
cell.border = _BORDER
|
||||
if column.kind == "amount":
|
||||
cell.number_format = _AMOUNT_FORMAT
|
||||
|
||||
|
||||
def _sum_amount(values) -> Decimal:
|
||||
"""金额列求和后两位收口(None 跳过)。"""
|
||||
return sum((value for value in values if value is not None), Decimal("0")).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,17 +1,20 @@
|
||||
"""角色管理业务编排。
|
||||
|
||||
需求 2.5.1 只定义 admin / user 两个角色,本模块属用户确认的**自主完善**
|
||||
(轻量版:不含权限点分配 —— `permissions` / `role_permissions` 表继续闲置,
|
||||
待业务方给出权限清单后再启用)。
|
||||
需求 2.5.1 定义 admin / user 两个登录角色;2026-10-15 复核审批补充需求
|
||||
新增营销后勤/财务/领导三个职能登录角色(营销后勤与财务/领导分区于审批
|
||||
节点准入)。本模块仍为轻量版(不含权限点分配 —— `permissions` /
|
||||
`role_permissions` 表继续闲置,待业务方给出权限清单后再启用)。
|
||||
|
||||
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
|
||||
|
||||
- `admin` 角色**禁删禁停** —— `cli` 建管理员与 `require_admin` 判定都依赖
|
||||
该编码存在,删除会使系统无法自举
|
||||
- **内置角色禁删禁停**(`BUILTIN_ROLE_CODES`:admin / marketing_ops /
|
||||
finance / leader)—— `admin` 被删除会使系统无法自举(cli 建管理员与
|
||||
`require_admin` 判定依赖);财务/领导是审批节点准入锚点,被删会让
|
||||
在途审批流失去可操作人
|
||||
- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默
|
||||
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
|
||||
锁行,堵住「校验与删除之间并发绑定」的窗口
|
||||
- `role_code` 创建后不可改 —— 它是授权判定(`ROLE_ADMIN` 常量按编码比对)的锚点
|
||||
- `role_code` 创建后不可改 —— 它是授权判定(`BUILTIN_ROLE_CODES` 白名单按编码比对)的锚点
|
||||
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
|
||||
但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
|
||||
"""
|
||||
@@ -27,8 +30,8 @@ from app.core.constants import (
|
||||
ACTION_DISABLE,
|
||||
ACTION_ENABLE,
|
||||
ACTION_UPDATE,
|
||||
BUILTIN_ROLE_CODES,
|
||||
MODULE_ROLE,
|
||||
ROLE_ADMIN,
|
||||
)
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
@@ -65,14 +68,8 @@ def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
|
||||
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
|
||||
下拉只给启用项。
|
||||
"""
|
||||
roles = role_repository.list_roles(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
)
|
||||
total = role_repository.count_roles(session, keyword=query.keyword, status=query.status)
|
||||
roles = role_repository.list_roles(session, query)
|
||||
total = role_repository.count_roles(session, query)
|
||||
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
|
||||
return PageResult[RoleManageOut](
|
||||
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
|
||||
@@ -177,10 +174,10 @@ def set_status(
|
||||
|
||||
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
|
||||
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
|
||||
内置管理员角色不可停用。
|
||||
内置角色不可停用。
|
||||
|
||||
:raises NotFoundError: 角色不存在
|
||||
:raises ForbiddenError: 试图停用内置管理员角色
|
||||
:raises ForbiddenError: 试图停用内置角色
|
||||
"""
|
||||
role = role_repository.get_by_id(session, role_id)
|
||||
verb = "启用" if status == 1 else "禁用"
|
||||
@@ -198,8 +195,8 @@ def set_status(
|
||||
if role is None:
|
||||
raise NotFoundError("角色不存在")
|
||||
|
||||
if role.role_code == ROLE_ADMIN and status == 0:
|
||||
raise ForbiddenError("内置管理员角色不可停用")
|
||||
if role.role_code in BUILTIN_ROLE_CODES and status == 0:
|
||||
raise ForbiddenError("内置角色不可停用")
|
||||
|
||||
role.status = status
|
||||
out = _to_out(role, _user_count(session, role_id))
|
||||
@@ -217,12 +214,12 @@ def delete_role(
|
||||
) -> None:
|
||||
"""删除角色。
|
||||
|
||||
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)。
|
||||
内置角色不可删除(admin 与三个审批职能角色,见 BUILTIN_ROLE_CODES);仍有关联用户的角色不可删除(提示先解除绑定)。
|
||||
查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把
|
||||
新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。
|
||||
|
||||
:raises NotFoundError: 角色不存在
|
||||
:raises ForbiddenError: 试图删除内置管理员角色
|
||||
:raises ForbiddenError: 试图删除内置角色
|
||||
:raises BadRequestError: 角色下仍有绑定的用户
|
||||
"""
|
||||
role = role_repository.get_by_id(session, role_id, for_update=True)
|
||||
@@ -239,8 +236,8 @@ def delete_role(
|
||||
if role is None:
|
||||
raise NotFoundError("角色不存在")
|
||||
|
||||
if role.role_code == ROLE_ADMIN:
|
||||
raise ForbiddenError("内置管理员角色不可删除")
|
||||
if role.role_code in BUILTIN_ROLE_CODES:
|
||||
raise ForbiddenError("内置角色不可删除")
|
||||
|
||||
# user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限
|
||||
user_count = _user_count(session, role_id)
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
"""月度结算服务(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.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import (
|
||||
MODULE_SETTLEMENT,
|
||||
)
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.models import (
|
||||
MonthlySettlement,
|
||||
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, for_update=True)
|
||||
if settlement is None:
|
||||
raise NotFoundError("月结不存在")
|
||||
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
|
||||
raise BadRequestError("仅草稿或已驳回状态可提交")
|
||||
|
||||
try:
|
||||
order = approval_service.submit_order(
|
||||
session,
|
||||
order_type=_SETTLEMENT_ORDER_TYPE,
|
||||
ref_id=settlement.id,
|
||||
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
|
||||
submitter=operator,
|
||||
# 月结明细在生成时已固化;提交时快照让复核者看到的是送审版本,
|
||||
# 而不是审批期间若发生重生成后的内容
|
||||
snapshot=approval_service.settlement_content(session, settlement.id),
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
# version_no 唯一键的并发冲突(提交前已锁月结行,此项理论不可达,
|
||||
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062 翻译成
|
||||
# 可重试的业务提示,其余完整性错误原样上抛
|
||||
if is_duplicate_entry(exc):
|
||||
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
|
||||
raise
|
||||
settlement.approval_order_no = order.order_no
|
||||
if order.status == SETTLEMENT_STATUS_APPROVED:
|
||||
mark_locked(session, settlement, operator=operator)
|
||||
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"团队分成:基数{'含' if share.include_manager_personal else '不含'}"
|
||||
"经理本人名下业绩,按本团队与全公司两级比例合计计提"
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
snapshot=approval_service.adjustment_content(session, order.id),
|
||||
)
|
||||
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
|
||||
]
|
||||
@@ -157,20 +157,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
|
||||
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
|
||||
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
|
||||
rows = shipment_repository.list_shipments(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
business_id=query.business_id,
|
||||
ship_date_from=query.ship_date_from,
|
||||
ship_date_to=query.ship_date_to,
|
||||
)
|
||||
total = shipment_repository.count_shipments(
|
||||
session,
|
||||
business_id=query.business_id,
|
||||
ship_date_from=query.ship_date_from,
|
||||
ship_date_to=query.ship_date_to,
|
||||
)
|
||||
rows = shipment_repository.list_shipments(session, query)
|
||||
total = shipment_repository.count_shipments(session, query)
|
||||
return PageResult[ShipmentOut](
|
||||
records=[_to_out(row) for row in rows],
|
||||
total=total,
|
||||
@@ -237,6 +225,12 @@ def create_shipment(
|
||||
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,
|
||||
@@ -309,6 +303,11 @@ def update_shipment(
|
||||
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)
|
||||
@@ -357,6 +356,10 @@ def delete_shipment(
|
||||
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("已被回款批次指定,请先解除回款的批次指定")
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""系统设置(公司信息)业务编排。
|
||||
|
||||
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo 只存**相对路径**
|
||||
(uploads/logo/ 下固定名覆盖),文件不入库的理由:图片进数据库会让每次
|
||||
读取与备份放大传输,且无法独立替换;DB 只持有一行指针。
|
||||
|
||||
logo 安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
|
||||
后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与
|
||||
覆盖任意文件。
|
||||
"""
|
||||
|
||||
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 + APP0/JFIF 起始
|
||||
_MAGIC = {
|
||||
"png": b"\x89PNG\r\n\x1a\n",
|
||||
"jpg": b"\xff\xd8\xff",
|
||||
}
|
||||
LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2MB:页眉/登录页展示尺寸上限,防上传滥用
|
||||
|
||||
SETTINGS_ID = 1 # 全系统单行主键
|
||||
|
||||
|
||||
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:
|
||||
"""管理端出参(logo_url 带更新时间版本参数,替换图片后浏览器不再用旧缓存)。"""
|
||||
return SettingOut(
|
||||
company_name=setting.company_name,
|
||||
logo_url=_logo_url(setting),
|
||||
)
|
||||
|
||||
|
||||
def to_public_out(setting: SystemSetting) -> SettingPublicOut:
|
||||
"""匿名端点出参(白名单字段,独立 schema 见模块说明)。"""
|
||||
return SettingPublicOut(
|
||||
company_name=setting.company_name,
|
||||
logo_url=_logo_url(setting),
|
||||
)
|
||||
|
||||
|
||||
def _logo_url(setting: SystemSetting) -> str | None:
|
||||
if not setting.logo_path:
|
||||
return None
|
||||
version = int(setting.update_time.timestamp()) if setting.update_time else 0
|
||||
return f"/api/v1/system/settings/logo/public?v={version}"
|
||||
|
||||
|
||||
def update_company(
|
||||
session: Session,
|
||||
payload: SettingUpdateIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> SettingOut:
|
||||
"""更新公司名称(审计留痕)。"""
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_SYSTEM_SETTINGS,
|
||||
action=ACTION_UPDATE,
|
||||
summary=f"修改公司名称为「{payload.company_name}」",
|
||||
request_data=payload.model_dump(),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
setting = get_setting(session)
|
||||
setting.company_name = payload.company_name.strip()
|
||||
session.commit()
|
||||
return to_out(setting)
|
||||
|
||||
|
||||
def save_logo(
|
||||
session: Session,
|
||||
content: bytes,
|
||||
ext: str,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> SettingOut:
|
||||
"""上传 logo:校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
|
||||
|
||||
旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。
|
||||
"""
|
||||
ext = ext.lower()
|
||||
if ext not in _MAGIC:
|
||||
raise BadRequestError("logo 仅支持 png/jpg 格式")
|
||||
if len(content) > LOGO_MAX_BYTES:
|
||||
raise BadRequestError("logo 文件不能超过 2MB")
|
||||
if not content.startswith(_MAGIC[ext]):
|
||||
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
|
||||
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_SYSTEM_SETTINGS,
|
||||
action=ACTION_UPDATE,
|
||||
summary="更新公司 logo",
|
||||
request_data={"ext": ext, "size": len(content)},
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
setting = get_setting(session)
|
||||
old_path = setting.logo_path
|
||||
|
||||
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
|
||||
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
|
||||
# 由写入覆盖,无需也不会误删新文件
|
||||
target = _logo_dir() / f"logo.{ext}"
|
||||
target.write_bytes(content)
|
||||
|
||||
setting.logo_path = f"logo/logo.{ext}"
|
||||
session.commit()
|
||||
|
||||
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
|
||||
if old_ext and old_ext != ext:
|
||||
try:
|
||||
_cleanup_old_logo(old_ext)
|
||||
except OSError:
|
||||
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
|
||||
# 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反
|
||||
logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True)
|
||||
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
|
||||
return to_out(setting)
|
||||
|
||||
|
||||
def read_logo(session: Session) -> tuple[Path, str]:
|
||||
"""按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。"""
|
||||
setting = get_setting(session)
|
||||
if not setting.logo_path:
|
||||
raise NotFoundError("尚未上传 logo")
|
||||
path = get_settings().upload_dir / setting.logo_path
|
||||
if not path.is_file():
|
||||
raise NotFoundError("logo 文件不存在,请重新上传")
|
||||
return path, path.suffix.lstrip(".")
|
||||
|
||||
|
||||
def _logo_dir() -> Path:
|
||||
directory = get_settings().upload_dir / "logo"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory
|
||||
|
||||
|
||||
def _cleanup_old_logo(old_ext: str) -> None:
|
||||
if old_ext in _MAGIC:
|
||||
old_file = _logo_dir() / f"logo.{old_ext}"
|
||||
if old_file.exists():
|
||||
old_file.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,23 @@
|
||||
# T+ 只读同步
|
||||
|
||||
> AI 摘要:读取外部 SQL Server 的原始快照,并管理持久化任务。关键文件为 task_service、worker、sync_service、relationship_audit 和 query_service;管理员 API 为 `/api/v1/system/tplus`。
|
||||
|
||||
## 职责与边界
|
||||
|
||||
- `task_service` 创建/重试任务,控制请求幂等和事务,保留原批次与异常;HTTP 不执行源库长查询。
|
||||
- `worker.run_once` 取得 MySQL 执行锁后恢复中断任务、领取一个任务;读取和关联核验共用锁。
|
||||
- `sync_service` 主键分页读取白名单实体,每页保存原始快照,相同源 ID 和行哈希去重。
|
||||
- `relationship_audit` 执行 `db/queries/tplus/audit/queries.sql`,只返回系统字典和数量;按收款方向与单据类型核对关联。
|
||||
- `query_service` 分页元数据,按需加载白名单报告;不返回原始 payload、驱动异常或源配置。
|
||||
|
||||
## 主要入口
|
||||
|
||||
管理员 API 提供概况、批次/异常分页、详情、创建与失败重试。CLI 为 `python -m app.cli tplus-worker --once/--watch`;原 `tplus-stage --entity personnel` 可继续直接读取。
|
||||
|
||||
连接配置、启动命令和异常处理参见项目 `docs/T+本地恢复与同步开发手册.md`。后端与 worker 必须指向同一 MySQL 业务库和 T+ 账套,查询文件随部署交付。
|
||||
|
||||
应用与兼容性脚本共用 `Settings.t_plus_database_url` 和连接文件校验;分号、花括号由 SQLAlchemy ODBC 方言转义。默认启用加密,失败不自动降级;既有私有配置需要按运行手册核对。管理页面不返回原始数据,但暂存 JSON 的生产敏感字段保护仍待 S1 决策。
|
||||
|
||||
## 验收限制
|
||||
|
||||
当前只写 MySQL 暂存与管理表,不写工资业务表、不推进时间水位。关联、成本、日期和负向事件的业务规则待客户确认。开发验收先以备份源 10.50.1600 为基准,生产完整版本后补;2022 副本结果不能替代该基准的实测。
|
||||
@@ -0,0 +1,6 @@
|
||||
"""T+ 只读读取、字段映射和同步 staging。"""
|
||||
|
||||
from app.services.tplus.reader import TPlusReader
|
||||
from app.services.tplus.sync_service import TPlusSyncService
|
||||
|
||||
__all__ = ["TPlusReader", "TPlusSyncService"]
|
||||
@@ -0,0 +1,136 @@
|
||||
"""T+ 字段映射元数据。
|
||||
|
||||
这里记录候选字段和确认状态,不把字典中的候选直接当成业务契约。
|
||||
高风险口径在客户确认前保持 ``PENDING``,同步器只允许写入原始 staging。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class MappingStatus(StrEnum):
|
||||
"""字段映射的证据状态。"""
|
||||
|
||||
CONFIRMED = "CONFIRMED"
|
||||
CANDIDATE = "CANDIDATE"
|
||||
MISSING = "MISSING"
|
||||
PENDING = "PENDING"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TPlusMapping:
|
||||
"""一个实体的源表、候选字段和目标表说明。"""
|
||||
|
||||
entity_type: str
|
||||
source_table: str
|
||||
source_fields: tuple[str, ...]
|
||||
target_table: str
|
||||
status: MappingStatus
|
||||
unresolved: tuple[str, ...] = ()
|
||||
version: str = "0.2"
|
||||
schema_evidence: str = "ceshi20261026.bak,2026-10-27 恢复副本结构核验"
|
||||
|
||||
|
||||
TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
TPlusMapping(
|
||||
"personnel",
|
||||
"AA_Person",
|
||||
("id", "code", "name", "disabled", "mobilePhoneNo", "officePhoneNo", "iddepartment"),
|
||||
"personnel",
|
||||
MappingStatus.CANDIDATE,
|
||||
("工号是否等于 code", "经理隶属关系", "入职日期", "试用期"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"products",
|
||||
"AA_Inventory",
|
||||
("id", "code", "name", "specification", "idunit", "idinventoryclass"),
|
||||
"products",
|
||||
MappingStatus.CANDIDATE,
|
||||
("库存数量语义", "参考价来源", "系统产品分类"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"customers",
|
||||
"AA_Partner",
|
||||
("id", "code", "name", "Contact", "representative", "disabled", "updated"),
|
||||
"customers",
|
||||
MappingStatus.CANDIDATE,
|
||||
("客户唯一键", "改名/合并规则", "系统客户分类"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"businesses",
|
||||
"SA_SaleOrder",
|
||||
("id", "code", "voucherdate", "idcustomer", "idclerk", "idbusinesstype", "updated"),
|
||||
"businesses",
|
||||
MappingStatus.PENDING,
|
||||
("成本口径", "业务负责人覆盖规则", "含税口径"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"shipments",
|
||||
"SA_SaleDelivery",
|
||||
(
|
||||
"id",
|
||||
"code",
|
||||
"voucherdate",
|
||||
"SendDate",
|
||||
"deliveryDate",
|
||||
"auditeddate",
|
||||
"SourceVoucherCode",
|
||||
"updated",
|
||||
),
|
||||
"shipments",
|
||||
MappingStatus.PENDING,
|
||||
("发货日期", "订单关联路径", "退货/红冲规则", "运费来源"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"receipts",
|
||||
"ARAP_ReceivePayment",
|
||||
(
|
||||
"id",
|
||||
"code",
|
||||
"voucherDate",
|
||||
"auditedDate",
|
||||
"createdTime",
|
||||
"SaleOrderCode",
|
||||
"SaleOrderID",
|
||||
"ts",
|
||||
),
|
||||
"receipts",
|
||||
MappingStatus.PENDING,
|
||||
("到账日期", "收款方式", "核销状态", "回款与发货关联"),
|
||||
),
|
||||
)
|
||||
|
||||
DETAIL_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
TPlusMapping(
|
||||
"business_items",
|
||||
"SA_SaleOrder_b",
|
||||
("id", "idSaleOrderDTO", "LatestCost"),
|
||||
"business_items",
|
||||
MappingStatus.PENDING,
|
||||
("成本口径", "含税口径"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"shipment_items",
|
||||
"SA_SaleDelivery_b",
|
||||
("ID", "idSaleDeliveryDTO", "saleOrderId"),
|
||||
"shipment_items",
|
||||
MappingStatus.PENDING,
|
||||
("发货归属", "红冲和退货"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"receipt_items",
|
||||
"ARAP_ReceivePayment_b",
|
||||
("id", "idArapReceivePaymentDTO", "saleorderid"),
|
||||
"receipts",
|
||||
MappingStatus.PENDING,
|
||||
("回款拆分", "核销语义", "增量水位"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_mapping(entity_type: str) -> TPlusMapping:
|
||||
"""按实体类型取得映射;未知实体直接拒绝。"""
|
||||
for mapping in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS):
|
||||
if mapping.entity_type == entity_type:
|
||||
return mapping
|
||||
raise KeyError(f"未知 T+ 实体类型:{entity_type}")
|
||||
@@ -0,0 +1,122 @@
|
||||
"""同步管理只读查询;只公开受控元数据,不公开源数据和原异常。"""
|
||||
|
||||
from app.core.exceptions import NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.core.tplus_db import resolve_tplus_settings
|
||||
from app.repositories import tplus_repository as repository
|
||||
from app.schemas.tplus import (
|
||||
TPlusBatchDetailOut,
|
||||
TPlusBatchOut,
|
||||
TPlusErrorOut,
|
||||
TPlusOverviewOut,
|
||||
)
|
||||
from app.services.tplus import task_service
|
||||
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
||||
from app.services.tplus.relationship_audit import public_report
|
||||
|
||||
ERROR_MESSAGES = {
|
||||
"READ_ERROR": "源数据读取失败,请检查只读连接和字段映射",
|
||||
"STAGE_ERROR": "原始数据保存失败,请核对字段类型后重试",
|
||||
"INTERRUPTED": "执行进程中断,可重新读取,已保存数据会自动去重",
|
||||
"EXECUTION_ERROR": "任务执行失败,请检查只读连接和字段映射",
|
||||
"TYPE_UNRESOLVED": "关联核验所需的单据类型缺失或不唯一",
|
||||
}
|
||||
_ENTITY_TYPES = {item.entity_type for item in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS)}
|
||||
|
||||
|
||||
def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
|
||||
"""只使用元数据字段组装输出,历史批次范围保持未知。"""
|
||||
entities = batch.entity_types_json
|
||||
scope_valid = (
|
||||
isinstance(entities, list)
|
||||
and bool(entities)
|
||||
and all(isinstance(item, str) and item in _ENTITY_TYPES for item in entities)
|
||||
)
|
||||
return TPlusBatchOut(
|
||||
batch_no=batch.batch_no,
|
||||
task_type="AUDIT" if batch.sync_mode == "AUDIT" else "READ",
|
||||
status=batch.status,
|
||||
entity_types=entities if scope_valid else None,
|
||||
source_record_count=batch.source_record_count,
|
||||
staged_record_count=batch.staged_record_count,
|
||||
skipped_record_count=batch.skipped_record_count,
|
||||
failed_record_count=batch.failed_record_count,
|
||||
parent_batch_no=batch.parent_batch_no,
|
||||
create_time=batch.create_time,
|
||||
started_at=batch.started_at,
|
||||
finished_at=batch.finished_at,
|
||||
can_retry=batch.status in ("FAILED", "PARTIAL")
|
||||
and (batch.sync_mode == "AUDIT" or (batch.sync_mode == "FULL" and scope_valid))
|
||||
and batch.batch_no not in active_parents,
|
||||
error_summary="任务有异常,请查看异常明细"
|
||||
if batch.status in ("FAILED", "PARTIAL")
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
def get_batch(session, number: str) -> TPlusBatchDetailOut:
|
||||
"""按需加载报告,未找到时统一返回 404。"""
|
||||
batch = repository.get_batch(session, number, with_result=True)
|
||||
if batch is None:
|
||||
raise NotFoundError("同步批次不存在")
|
||||
out = batch_out(batch, repository.active_retry_parents(session, [number]))
|
||||
return TPlusBatchDetailOut(
|
||||
**out.model_dump(), audit_result=public_report(batch.audit_result_json)
|
||||
)
|
||||
|
||||
|
||||
def list_batches(session, query):
|
||||
"""分页批次并批量查询重试状态。"""
|
||||
rows, total = repository.list_batches(session, query)
|
||||
active = repository.active_retry_parents(session, [row.batch_no for row in rows])
|
||||
return PageResult[TPlusBatchOut](
|
||||
records=[batch_out(row, active) for row in rows],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
def _safe_id(value) -> str | None:
|
||||
if isinstance(value, str) and value.isascii() and value.isdigit() and len(value) <= 10:
|
||||
return value if int(value) <= 2147483647 else None
|
||||
return None
|
||||
|
||||
|
||||
def list_errors(session, number, query):
|
||||
"""错误按编码生成固定说明,避免驱动异常泄漏连接参数。"""
|
||||
if repository.get_batch(session, number) is None:
|
||||
raise NotFoundError("同步批次不存在")
|
||||
rows, total = repository.list_errors(session, number, query)
|
||||
records = [
|
||||
TPlusErrorOut(
|
||||
id=row.id,
|
||||
entity_type=row.entity_type if row.entity_type in _ENTITY_TYPES else "system",
|
||||
source_id=_safe_id(row.source_id),
|
||||
error_code=row.error_code if row.error_code in ERROR_MESSAGES else "UNKNOWN",
|
||||
error_message=ERROR_MESSAGES.get(row.error_code, "任务未完成,请核对配置后重试"),
|
||||
retry_count=row.retry_count,
|
||||
create_time=row.create_time,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return PageResult[TPlusErrorOut](
|
||||
records=records, total=total, page_num=query.page_num, page_size=query.page_size
|
||||
)
|
||||
|
||||
|
||||
def overview(session) -> TPlusOverviewOut:
|
||||
"""只校验配置,不在页面查询期间建立 T+ 连接。"""
|
||||
try:
|
||||
resolve_tplus_settings(task_service.get_settings())
|
||||
configured, message = True, "只读配置已就绪,实际连接结果以任务执行为准"
|
||||
except RuntimeError:
|
||||
configured, message = False, "T+ 只读配置未就绪,请联系管理员配置"
|
||||
latest = repository.latest_audit(session)
|
||||
return TPlusOverviewOut(
|
||||
is_configured=configured,
|
||||
configuration_message=message,
|
||||
status_counts=repository.status_counts(session),
|
||||
total_staged_records=repository.total_staged_records(session),
|
||||
latest_audit=public_report(latest.audit_result_json) if latest else None,
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""T+ 六类源表的只读读取器。"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import BigInteger, Integer, bindparam, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.tplus.mapping import TPLUS_MAPPINGS, get_mapping
|
||||
|
||||
_ENTITY_QUERY_FILES = {
|
||||
"personnel": "personnel.sql",
|
||||
"products": "products.sql",
|
||||
"customers": "customers.sql",
|
||||
"businesses": "businesses.sql",
|
||||
"shipments": "shipments.sql",
|
||||
"receipts": "receipts.sql",
|
||||
"business_items": "business_items.sql",
|
||||
"shipment_items": "shipment_items.sql",
|
||||
"receipt_items": "receipt_items.sql",
|
||||
}
|
||||
|
||||
|
||||
class TPlusReader:
|
||||
"""执行白名单 SQL 文件,禁止调用方传入任意表名或 SQL。"""
|
||||
|
||||
def __init__(self, session: Session, query_dir: Path | None = None) -> None:
|
||||
self.session = session
|
||||
self.query_dir = query_dir or self._default_query_dir()
|
||||
|
||||
@staticmethod
|
||||
def _default_query_dir() -> Path:
|
||||
return Path(__file__).resolve().parents[4] / "db" / "queries" / "tplus"
|
||||
|
||||
def iter_entity(
|
||||
self,
|
||||
entity_type: str,
|
||||
*,
|
||||
watermark: datetime | None = None,
|
||||
batch_size: int = 500,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""按实体流式读取,水位只通过绑定参数传入。"""
|
||||
if entity_type not in _ENTITY_QUERY_FILES:
|
||||
raise KeyError(f"不支持的 T+ 实体类型:{entity_type}")
|
||||
if batch_size < 1 or batch_size > 5000:
|
||||
raise ValueError("batch_size 必须在 1 到 5000 之间")
|
||||
if watermark is not None:
|
||||
raise ValueError("增量水位语义尚未确认,当前只支持全量读取和哈希去重")
|
||||
get_mapping(entity_type)
|
||||
sql_path = self.query_dir / _ENTITY_QUERY_FILES[entity_type]
|
||||
if not sql_path.is_file():
|
||||
raise FileNotFoundError(f"缺少 T+ 查询文件:{sql_path}")
|
||||
|
||||
statement = text(sql_path.read_text(encoding="utf-8")).bindparams(
|
||||
bindparam("batch_size", type_=Integer()), bindparam("after_id", type_=BigInteger())
|
||||
)
|
||||
after_id = -2147483649 # 所有源表主键均已核验为 SQL Server int,包含其最小值。
|
||||
while True:
|
||||
result = self.session.execute(
|
||||
statement, {"batch_size": batch_size, "after_id": after_id}
|
||||
)
|
||||
try:
|
||||
rows = [dict(row) for row in result.mappings().all()]
|
||||
finally:
|
||||
result.close()
|
||||
if not rows:
|
||||
return
|
||||
next_id = int(rows[-1]["id"])
|
||||
if next_id <= after_id:
|
||||
raise ValueError("T+ 源主键未递增,停止读取以避免重复循环")
|
||||
yield from rows
|
||||
after_id = next_id
|
||||
|
||||
def fetch_entity(
|
||||
self,
|
||||
entity_type: str,
|
||||
*,
|
||||
watermark: datetime | None = None,
|
||||
max_rows: int = 5000,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""读取有限数量的实体行,供核验命令和单元测试使用。"""
|
||||
if max_rows < 1:
|
||||
raise ValueError("max_rows 必须大于 0")
|
||||
rows: list[dict[str, Any]] = []
|
||||
for row in self.iter_entity(entity_type, watermark=watermark):
|
||||
rows.append(row)
|
||||
if len(rows) >= max_rows:
|
||||
break
|
||||
return rows
|
||||
|
||||
def inventory(self) -> tuple[str, ...]:
|
||||
"""返回当前适配器支持的实体类型。"""
|
||||
return tuple(mapping.entity_type for mapping in TPLUS_MAPPINGS)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""读取实际系统字典并统计带单据类型的收款关联,不推断计提口径。"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logging import scrub
|
||||
from app.services.tplus.reader import TPlusReader
|
||||
|
||||
_COLUMNS = {
|
||||
"source_version": ("source_version",),
|
||||
"voucher_types": ("id", "code", "name"),
|
||||
"state_enums": ("id", "code", "name", "enum_name"),
|
||||
"receipt_kinds": ("voucher_type_code", "voucher_type_name", "receive_flag", "row_count"),
|
||||
"receipt_line_kinds": ("voucher_type_id", "voucher_type_name", "receive_flag", "row_count"),
|
||||
"sales_links": (
|
||||
"row_count",
|
||||
"delivery_matches",
|
||||
"detail_matches",
|
||||
"direct_order_matches",
|
||||
"detail_order_matches",
|
||||
"unambiguous_order_matches",
|
||||
"invalid_order_ids",
|
||||
"conflicting_order_ids",
|
||||
),
|
||||
"order_coverage": ("row_count", "without_order", "single_order", "multiple_orders"),
|
||||
}
|
||||
PENDING_CONFIRMATIONS = (
|
||||
("cost_basis", "绩效成本取值", "订单最新成本或出库成本的类型、时点和含税口径需业务确认"),
|
||||
("receipt_date", "到账日", "单据日、审核日和明细 arrivalDate 的业务含义需确认"),
|
||||
("ship_date", "发货日", "回款天数应使用哪个发货日期需确认"),
|
||||
("allocation", "多单分摊", "一笔收款涉及多张销货单或订单时,不能自动把整笔金额记给一单"),
|
||||
("negative_events", "退货与红冲", "负数、冲销及已结算业务的调整方式需确认"),
|
||||
)
|
||||
|
||||
|
||||
def public_report(raw: dict | None) -> dict | None:
|
||||
"""历史报告也按当前字段白名单输出,禁止透传未知嵌套数据。"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
result = {
|
||||
key: TPlusRelationshipAudit._safe_value(raw.get(key))
|
||||
for key in (
|
||||
"schema_version",
|
||||
"captured_at",
|
||||
"source_version",
|
||||
"is_target_engine",
|
||||
"relationship_status",
|
||||
)
|
||||
}
|
||||
result["compatibility_status"] = "PENDING"
|
||||
for section, columns in _COLUMNS.items():
|
||||
if section in ("source_version", "voucher_types"):
|
||||
continue
|
||||
value = raw.get(section)
|
||||
if isinstance(value, list):
|
||||
result[section] = [
|
||||
{key: TPlusRelationshipAudit._safe_value(row.get(key)) for key in columns}
|
||||
for row in value[:200]
|
||||
if isinstance(row, dict)
|
||||
]
|
||||
elif isinstance(value, dict):
|
||||
result[section] = {
|
||||
key: TPlusRelationshipAudit._safe_value(value.get(key)) for key in columns
|
||||
}
|
||||
else:
|
||||
result[section] = None
|
||||
result["pending_confirmations"] = [
|
||||
{"key": k, "title": t, "reason": r} for k, t, r in PENDING_CONFIRMATIONS
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
class TPlusRelationshipAudit:
|
||||
"""执行固定核验查询;结果只包含系统字典和数量,不包含客户记录。"""
|
||||
|
||||
def __init__(self, session: Session, query_dir: Path | None = None) -> None:
|
||||
self.session = session
|
||||
root = query_dir or TPlusReader._default_query_dir()
|
||||
source = (root / "audit" / "queries.sql").read_text(encoding="utf-8")
|
||||
sections = re.split(r"(?m)^-- @query (\w+)\s*$", source)
|
||||
self.queries = dict(zip(sections[1::2], sections[2::2], strict=True))
|
||||
if set(self.queries) != set(_COLUMNS):
|
||||
raise ValueError("关联核验查询清单不完整")
|
||||
|
||||
def fetch(self, name: str, **params) -> list[dict]:
|
||||
"""只接受登记的查询名与绑定参数,过滤非合同字段。"""
|
||||
if name not in self.queries:
|
||||
raise ValueError("不支持的核验查询")
|
||||
result = self.session.execute(text(self.queries[name]), params)
|
||||
try:
|
||||
return [
|
||||
{key: self._safe_value(row.get(key)) for key in _COLUMNS[name]}
|
||||
for row in result.mappings().all()
|
||||
]
|
||||
finally:
|
||||
result.close()
|
||||
|
||||
@staticmethod
|
||||
def _safe_value(value):
|
||||
if value is None or isinstance(value, (int, bool)):
|
||||
return value
|
||||
return scrub(re.sub(r"[\x00-\x1f]", " ", str(value)))[:200]
|
||||
|
||||
def run(self) -> dict:
|
||||
"""生成事实报告;类型缺失或有歧义时不执行该关联链路。"""
|
||||
version = self.fetch("source_version")[0]["source_version"]
|
||||
types = self.fetch("voucher_types")
|
||||
report = {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now().isoformat(),
|
||||
"source_version": version,
|
||||
"is_target_engine": str(version).startswith("10.50."),
|
||||
"compatibility_status": "PENDING",
|
||||
"state_enums": self.fetch("state_enums"),
|
||||
"receipt_kinds": self.fetch("receipt_kinds"),
|
||||
"receipt_line_kinds": self.fetch("receipt_line_kinds"),
|
||||
"sales_links": None,
|
||||
"order_coverage": None,
|
||||
"relationship_status": "UNRESOLVED",
|
||||
"relationship_basis": "主表按 isReceiveFlag=1(是否收款)筛选;明细按 SM_VoucherType 的 SA04 类型关联。主表 idvouchertype 是来源类型,不能据此筛本单类型。",
|
||||
"pending_confirmations": [
|
||||
{"key": k, "title": t, "reason": r} for k, t, r in PENDING_CONFIRMATIONS
|
||||
],
|
||||
}
|
||||
sale = [r["id"] for r in types if r["code"] == "SA04"]
|
||||
if len(sale) == 1:
|
||||
params = {"sale_delivery_type_id": sale[0], "receive_flag": 1}
|
||||
report["sales_links"] = self.fetch("sales_links", **params)[0]
|
||||
report["order_coverage"] = self.fetch("order_coverage", **params)[0]
|
||||
report["relationship_status"] = "OBSERVED"
|
||||
return report
|
||||
Loaded 100 of 276 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user