Merge feat/m3-increments into main

M2 ����/ҵ������ + M3 ����/�½�/���� + M4 ����ͳ��
This commit is contained in:
liuwenxin 2026-10-19 04:59:56 -04:00
commit 4e54d09ccb
266 changed files with 52396 additions and 474 deletions

12
.gitignore vendored
View File

@ -26,7 +26,10 @@ out/
# 日志
*.log
logs/
# 后端运行日志目录(core/logging.py 创建)。
# 不要写成裸 `logs/` —— 那会匹配任意层级的同名目录,曾把
# frontend/src/views/system/logs 整个吞掉(文件进不了版本库)
/backend/logs/
# IDE
.idea/
@ -54,3 +57,10 @@ frontend/e2e/*.png
# 备份
backup/
.claude
CLAUDE.md
# 运行期上传物(公司 logo 等):与构建产物隔离,不入版本库
backend/uploads/
# 数据库备份导出目录(⑨ 备份脚本产物)
db/backups/

View File

@ -2,7 +2,7 @@
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。
> 当前进度:登录鉴权链路已跑通(验证码 / JWT / 限流 / 登出黑名单 / 登录日志)。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。
> 当前进度:登录鉴权与系统管理(用户/角色/操作日志/日志查询)已完成,前端具备应用布局框架。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。
## 快速开始
@ -81,7 +81,8 @@ pnpm type-check # 类型检查
pnpm build # 类型检查 + 生产构建
# 浏览器端到端(需容器、后端、前端都在运行)
python e2e/login_flow.py
python e2e/login_flow.py # 登录链路 8 项
python e2e/system_management.py # 系统管理 13 项
```
## 目录结构

View File

@ -1,11 +1,13 @@
# 后端
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。当前实现登录鉴权与健康检查,业务模块待需求澄清后开发。
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。已实现登录鉴权、系统管理(用户/角色/操作日志/日志查询)与 M1 参数体系(岗位角色管理、计提参数版本化/留痕/对比/预览/导出,2026-10-13);业务模块(基础/业务数据、核算、报表)按路线图推进。
## 职责
- 提供 REST 接口(统一前缀 `/api`)
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
- 系统管理(需求 2.5.1):用户管理、角色管理、岗位角色(#28 可扩展+公式模板绑定)、操作日志(自动审计)、操作与登录日志查询
- 计提参数体系(需求 #28):16 项参数目录、按生效日期版本化(回款到账日取值)、变更批次留痕/对比/导出、计算预览
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
## 技术栈与版本
@ -53,12 +55,80 @@ app/
| POST | `/api/v1/auth/logout` | 是 | 令牌入黑名单,立即失效 |
| GET | `/api/v1/auth/me` | 是 | 当前用户与角色 |
### 系统管理接口(需求 2.5.1,全部要求管理员权限)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET / POST | `/api/v1/system/users` | 用户列表(分页/筛选)/ 新增用户 |
| GET / PUT / DELETE | `/api/v1/system/users/{id}` | 用户详情 / 编辑(含角色分配)/ 删除(物理删除) |
| PUT | `/api/v1/system/users/{id}/password` | 重置密码(提升令牌版本,旧令牌立即失效) |
| PUT | `/api/v1/system/users/{id}/status` | 启用 / 禁用(禁用同样提升令牌版本) |
| GET | `/api/v1/system/users/meta/roles` | 角色下拉选项(仅启用角色) |
| GET / POST | `/api/v1/system/roles` | 角色列表(含停用与绑定用户数)/ 新增角色 |
| PUT / DELETE | `/api/v1/system/roles/{id}` | 编辑(不含编码)/ 删除(内置与有绑定的角色受保护) |
| PUT | `/api/v1/system/roles/{id}/status` | 启用 / 禁用(内置 admin 禁停) |
| GET | `/api/v1/system/operation-logs` | 操作日志查询(操作人/模块/操作/结果/时间范围;**不含 request_data** 大字段) |
| GET | `/api/v1/system/operation-logs/{id}` | 操作日志详情(含已脱敏的请求参数,展开行懒加载) |
| GET | `/api/v1/system/login-logs` | 登录日志查询(用户名/IP/结果/时间范围) |
### 基础数据接口(需求 2.1,登录即可 —— 业务操作不要求管理员)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET / POST | `/api/v1/base/personnel` | 人员列表(姓名模糊/状态/岗位筛选,附岗位与经理名)/ 新增(岗位须启用;经理非自己且在职) |
| GET / PUT / DELETE | `/api/v1/base/personnel/{id}` | 详情(附区域与计划)/ 编辑 / 删除(被团队成员或客户归属引用 → 400 引导改离职) |
| PUT | `/api/v1/base/personnel/{id}/status` | 在职 / 离职切换 |
| PUT | `/api/v1/base/personnel/{id}/regions` | 辐射区域全量替换(空列表即清空) |
| PUT | `/api/v1/base/personnel/{id}/plans` | 市场拓展计划全量替换 |
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
### 业务数据接口(需求 2.2,登录即可)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET / POST | `/api/v1/business/businesses` | 业务列表(编号/公司名模糊、状态/客户/归属/日期筛选)/ 创建(编号 `YW{日期}-{序号}` 自动生成;明细 ≥1 行;销售额服务层固化) |
| GET / PUT / DELETE | `/api/v1/business/businesses/{id}` | 详情(附明细)/ 编辑(明细全量替换 + 五项汇总重算;**归属不在此端点**)/ 删除(有发货或回款 → 400 引导改已终止) |
| PUT | `/api/v1/business/businesses/{id}/status` | 状态流转(1→2/3;终态不可再变更;终止必填原因;写状态留痕) |
| PUT | `/api/v1/business/businesses/{id}/ownership` | 负责/协助归属变更(同事务写留痕,owner_type=2) |
| GET | `/api/v1/business/businesses/{id}/ownership-logs` | 业务归属变更留痕分页 |
| GET | `/api/v1/business/businesses/{id}/status-logs` | 状态流转时间线(创建写首条 old=new=1) |
| GET | `/api/v1/business/businesses/{id}/gross-profit` | **毛利润数据视图**(只读实时计算,不落库:行级率/折算额 + 类别汇总;不含运费 #2;阈值按 `as_of` 取参数快照;成本缺失行标注无法计算) |
| GET / POST | `/api/v1/business/shipments` | 发货单列表 / 创建(编号 `SH…`;业务须执行中;行金额服务层固化;业务级发货/运费汇总回写) |
| GET / PUT / DELETE | `/api/v1/business/shipments/{id}` | 详情 / 编辑(终态业务禁改)/ 删除(被回款批次指定 → 400) |
| GET / POST | `/api/v1/business/receipts` | 回款列表(含 **payment_days 派生**:发货日首日=第 1 天 #1)/ 新增(编号 `HK…`;**shipment_id 同业务强校验**;业务须执行中) |
| GET / PUT / DELETE | `/api/v1/business/receipts/{id}` | 详情 / 编辑 / 删除(**已核算回款禁改禁删**——M3 防重锚点) |
| GET / POST | `/api/v1/business/rating-grades` | 评级等级全量列表(sort_order 升序)/ 新增(区间值域 + **与既有等级重叠 → 400**) |
| PUT / DELETE | `/api/v1/business/rating-grades/{id}` | 编辑(重叠判定)/ 删除(无下游引用,允许清空) |
全部写操作自动写入操作日志(成功与失败都记,机制见下)。
统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
状态码:400 参数错误 / 401 未登录 / 403 无权限 / 404 不存在 / 500 服务端异常。
**登录失败一律 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`)。
## 常用命令
```bash
@ -118,6 +188,39 @@ Redis 黑名单只负责「用户主动登出」。
bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是静默截断)。
中文一个字符占 3 字节,24 个汉字即到顶——`max_length=20` 这类字符数校验拦不住。
### 操作日志:审计上下文 + 独立提交
写操作统一经 `services/audit_service.py` 的 `audit()` 上下文管理器留痕(用法见 `api/v1/system/users.py`):
- 调用形态为 DTO:`audit(session, operator=..., record=AuditRecord(module, action, summary, request_data), meta=...)`;落库条目为 `OperationLogEntry`
- 正常退出记成功;抛异常**先 `session.rollback()` 再**记失败(success=0 + 中文错误原因),随后原异常继续传播
- 日志**独立 commit**,整体兜底不外抛 —— 方向是「审计可丢,业务不可断」;反向理解:业务失败回滚不会带走失败记录,而那正是审计最需要的
- **操作人标量在进入上下文时固化**:失败路径先 rollback 会使 ORM 对象过期,之后再读 id/username 会触发额外查询(账号并发被删时 ObjectDeletedError 还会被兜底吞掉)
- 请求元信息(ip / user-agent / method / path)经路由层的 `RequestMetaDep` 传入 service
- **commit 之后的任何查询/ORM 读取都要前置**(提到 commit 前或用已固化的局部变量):业务已提交却因后置查询抛错会被记成「失败」,产生与事实相反的审计
- 文本列按 `models/operation_log.py` 的长度常量截断;`request_data` 先经**结构化脱敏**(递归按键名整值抹除 password/token 等,不依赖文本模式匹配)再经 `core.logging.scrub` 文本兜底,最后按 **UTF-8 字节边界**截断(encode 带 `errors="replace"` 兜底孤立代理字符)—— 超长/编码异常都会在 MySQL 或 encode 处抛出,被兜底吞掉后表现为「日志莫名消失」
- **日志列表与详情两段式**:`operation-logs` 列表排除 `request_data`(TEXT 大字段,随列表返回会让翻页负担随表增长),展开详情走 `GET /operation-logs/{id}` 懒加载
- 模块/操作词表在 `core/constants.py`(`MODULE_*` / `ACTION_*`),前端 `utils/constants.ts` 与其对齐
**角色管理保护规则**(后端是唯一防线,前端按钮禁用只是体验):admin 角色禁删禁停;有用户绑定的角色禁删(`user_roles` 外键是 CASCADE;删除前走 `SELECT ... FOR UPDATE` 锁行,堵住并发绑定窗口);`role_code` 创建后不可改;**停用角色不可再分配**(`_resolve_roles` 校验 status,停用只影响新分配、不影响既有授权)。
### M2 业务数据的四个关键约定(2026-10-13 定稿,改动前先读)
- **单据编号**(`services/no_generator.py`):`{前缀}{YYYYMMDD}-{当日4位序号}`,业务 YW / 发货 SH / 回款 HK(`core/constants.py` 的 `NO_PREFIX_*`)。`SELECT MAX ... FOR UPDATE` 锁当日区间防并发重号;残余竞态以唯一键 1062 冲突**重试一次**兜底(无限重试会掩盖死锁)。序号超 4 位按实际位数输出(VARCHAR(32) 余量足够)。
- **五项汇总冗余**(`businesses.total_*`):明细/发货/回款的任何写路径统一收口到 `business_service._recalc_totals` 单点重算,禁止各处手写 SUM 回写;发货与回款服务写操作前**必须 `get_by_id(for_update=True)` 锁业务行**(防两模块并发写同一业务的丢失更新)。毛利润率/折算额**不落库**——它们是明细金额 + 时点化阈值的派生值,落库会在成本调整或参数改版后漂移;视图接口(`/gross-profit`)实时算,M3 核算台账才是法定固化点。
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
### 报表口径(M4 ⑤,改动前先读)
- **数据源固定单笔生效行**:四张报表的明细一律 `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` 是两个口径。
- **回款金额按回款单去重**:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 `DISTINCT receipt_id` 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。
- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块),**12.5% 与 5% 分列**——两项之和才是聚合行金额。
- **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;集成测试逐项断言。
## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。

View File

@ -5,7 +5,7 @@ Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
@ -13,16 +13,22 @@ ${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
down_revision: str | Sequence[str] | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
"""应用迁移。"""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
"""回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
${downgrades if downgrades else "pass"}

View File

@ -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")

View File

@ -337,17 +337,19 @@ def upgrade() -> None:
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("idx_user_roles_role_id", table_name="user_roles")
"""回滚迁移。
只保留 drop_table —— DROP TABLE 会连带删除索引与外键。
原 autogenerate 输出里逐个 drop_index 会失败:idx_user_roles_role_id 与
idx_role_permissions_permission_id 分别是外键的唯一支撑索引,
MySQL 拒绝单独删除(errno 1553),回滚会中断在半途。
删除顺序遵循外键依赖:先子表(user_roles / role_permissions / login_logs),
再父表(users / roles / permissions)。
"""
op.drop_table("user_roles")
op.drop_index("idx_role_permissions_permission_id", table_name="role_permissions")
op.drop_table("role_permissions")
op.drop_index("idx_login_logs_username", table_name="login_logs")
op.drop_index("idx_login_logs_create_time", table_name="login_logs")
op.drop_table("login_logs")
op.drop_index(op.f("idx_users_phone"), table_name="users")
op.drop_table("users")
op.drop_table("roles")
op.drop_table("permissions")
# ### end Alembic commands ###

View File

@ -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')")

View File

@ -0,0 +1,696 @@
"""业务数据管理七表:业务/业务明细/状态留痕/发货单/发货明细/回款/评级等级配置(M2)
- businesses:业务编号自动生成(报表 2.4.1「合同编号」),五项汇总冗余列
由服务层事务内维护;毛利润相关值不落库(派生值,M3 台账才是固化点)
- business_items:行级 product_category 为折算锚点(对照表 #14),指导价
人工录入、T+ 同步不覆盖
- business_status_logs:append-only,创建时写首条(old=new=1)
- shipments/shipment_items:发货日为回款天数基准日(对照表 #1)
- receipts:手动指定发货批次(#18);settlement_status 为 M3 核算防重锚点
- customer_rating_grades:等级映射可配置,种子四条随迁移初始化
Revision ID: 8efc189b131f
Revises: e1133a7c670a
Create Date: 2026-10-13 11:14:17.545320
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "8efc189b131f"
down_revision: str | Sequence[str] | None = "e1133a7c670a"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# 评级等级种子(与 init-dev.sql 双写一致;口径见模型 docstring)
_GRADE_SEED = [
{"grade_name": "A", "min_score": 90, "max_score": 100, "sort_order": 1, "remark": "优秀"},
{"grade_name": "B", "min_score": 75, "max_score": 89, "sort_order": 2, "remark": "良好"},
{"grade_name": "C", "min_score": 60, "max_score": 74, "sort_order": 3, "remark": "合格"},
{"grade_name": "D", "min_score": 0, "max_score": 59, "sort_order": 4, "remark": "待改进"},
]
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"customer_rating_grades",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"grade_name", sa.String(length=32), nullable=False, comment="等级名称(如 A/优秀)"
),
sa.Column(
"min_score",
sa.Numeric(precision=5, scale=1),
nullable=False,
comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分",
),
sa.Column(
"max_score",
sa.Numeric(precision=5, scale=1),
nullable=False,
comment="总分上限(展示用)",
),
sa.Column(
"sort_order",
sa.Integer(),
server_default="0",
nullable=False,
comment="排序(判定顺序,升序)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="说明"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_rating_grades")),
sa.UniqueConstraint("grade_name", name=op.f("uk_customer_rating_grades_grade_name")),
comment="客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"business_status_logs",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(固化,无外键;删除保护由服务层承担)",
),
sa.Column(
"old_status", mysql.TINYINT(), nullable=False, comment="变更前状态(创建首条为 1)"
),
sa.Column("new_status", mysql.TINYINT(), nullable=False, comment="变更后状态"),
sa.Column(
"change_reason", sa.String(length=255), nullable=True, comment="变更原因(终止时必填)"
),
sa.Column(
"operator_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="操作人ID(账号删除后置空)",
),
sa.Column(
"operator_name",
sa.String(length=64),
nullable=False,
comment="操作人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["operator_id"],
["users.id"],
name=op.f("fk_business_status_logs_operator_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_status_logs")),
comment="业务状态变更记录表(append-only)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_business_status_logs_business_id",
"business_status_logs",
["business_id"],
unique=False,
)
op.create_index(
"idx_business_status_logs_create_time",
"business_status_logs",
["create_time"],
unique=False,
)
op.create_table(
"businesses",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"business_no",
sa.String(length=32),
nullable=False,
comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)",
),
sa.Column(
"customer_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="客户ID(customers.id)",
),
sa.Column("business_date", sa.Date(), nullable=True, comment="业务日期(T+ 同步口径)"),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)",
),
sa.Column(
"owner_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)",
),
sa.Column(
"assistant_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)",
),
sa.Column(
"is_special",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="特殊业务标记:1是 0否(公司参与等)",
),
sa.Column("special_note", sa.String(length=255), nullable=True, comment="特殊业务说明"),
sa.Column(
"total_sale_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)",
),
sa.Column(
"total_cost_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)",
),
sa.Column(
"total_ship_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)",
),
sa.Column(
"total_received_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)",
),
sa.Column(
"total_freight_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=True,
comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["assistant_personnel_id"],
["personnel.id"],
name=op.f("fk_businesses_assistant_personnel_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["customer_id"],
["customers.id"],
name=op.f("fk_businesses_customer_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["owner_personnel_id"],
["personnel.id"],
name=op.f("fk_businesses_owner_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_businesses")),
sa.UniqueConstraint("business_no", name=op.f("uk_businesses_business_no")),
comment="业务表(业务编号自动生成;T+同步不覆盖业务状态)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_businesses_business_date", "businesses", ["business_date"], unique=False)
op.create_index("idx_businesses_customer_id", "businesses", ["customer_id"], unique=False)
op.create_index(
"idx_businesses_owner_personnel_id", "businesses", ["owner_personnel_id"], unique=False
)
op.create_index(op.f("idx_businesses_source_no"), "businesses", ["source_no"], unique=False)
op.create_index("idx_businesses_status", "businesses", ["status"], unique=False)
op.create_table(
"business_items",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(businesses.id)",
),
sa.Column(
"product_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="产品ID(products.id;NULL=自由录入,未入产品库)",
),
sa.Column(
"product_name",
sa.String(length=128),
nullable=False,
comment="产品名称(引用产品时冗余固化)",
),
sa.Column(
"spec_model",
sa.String(length=128),
server_default="",
nullable=False,
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
),
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
sa.Column(
"product_category",
sa.String(length=32),
nullable=False,
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
),
sa.Column(
"quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="数量(>0)"
),
sa.Column(
"sale_price",
sa.Numeric(precision=14, scale=4),
nullable=True,
comment="销售单价(元;T+ 同步或人工)",
),
sa.Column(
"sale_amount",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)",
),
sa.Column(
"cost_amount",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)",
),
sa.Column(
"guided_price",
sa.Numeric(precision=14, scale=4),
nullable=True,
comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["business_id"],
["businesses.id"],
name=op.f("fk_business_items_business_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["product_id"],
["products.id"],
name=op.f("fk_business_items_product_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_items")),
comment="业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_business_items_business_id", "business_items", ["business_id"], unique=False
)
op.create_index(
"idx_business_items_product_category", "business_items", ["product_category"], unique=False
)
op.create_index("idx_business_items_product_id", "business_items", ["product_id"], unique=False)
op.create_table(
"shipments",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"shipment_no",
sa.String(length=32),
nullable=False,
comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)",
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(businesses.id;终态业务禁止新发货)",
),
sa.Column(
"ship_date",
sa.Date(),
nullable=False,
comment="发货日期(回款天数基准日,发货日首日=第 1 天)",
),
sa.Column(
"freight_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=False,
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
),
sa.Column(
"total_amount",
sa.Numeric(precision=14, scale=2),
server_default="0",
nullable=False,
comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["business_id"],
["businesses.id"],
name=op.f("fk_shipments_business_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_shipments")),
sa.UniqueConstraint("shipment_no", name=op.f("uk_shipments_shipment_no")),
comment="发货单表(发货日为回款天数基准日,发货日首日计第 1 天)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_shipments_business_id", "shipments", ["business_id"], unique=False)
op.create_index("idx_shipments_ship_date", "shipments", ["ship_date"], unique=False)
op.create_index(op.f("idx_shipments_source_no"), "shipments", ["source_no"], unique=False)
op.create_table(
"receipts",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"receipt_batch_no",
sa.String(length=32),
nullable=False,
comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)",
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="业务ID(businesses.id)",
),
sa.Column(
"shipment_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)",
),
sa.Column(
"receipt_date",
sa.Date(),
nullable=False,
comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)",
),
sa.Column(
"amount",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="回款金额(≥0;一期禁负数/红字)",
),
sa.Column(
"is_barter",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)",
),
sa.Column(
"settlement_status",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["business_id"],
["businesses.id"],
name=op.f("fk_receipts_business_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["shipment_id"],
["shipments.id"],
name=op.f("fk_receipts_shipment_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_receipts")),
sa.UniqueConstraint("receipt_batch_no", name=op.f("uk_receipts_receipt_batch_no")),
comment="回款表(手动指定发货批次;已核算回款禁改禁删)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_receipts_business_id", "receipts", ["business_id"], unique=False)
op.create_index("idx_receipts_receipt_date", "receipts", ["receipt_date"], unique=False)
op.create_index(
"idx_receipts_settlement_status", "receipts", ["settlement_status"], unique=False
)
op.create_index("idx_receipts_shipment_id", "receipts", ["shipment_id"], unique=False)
op.create_index(op.f("idx_receipts_source_no"), "receipts", ["source_no"], unique=False)
op.create_table(
"shipment_items",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"shipment_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="发货单ID(shipments.id)",
),
sa.Column(
"product_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="产品ID(products.id;NULL=自由录入)",
),
sa.Column(
"product_name",
sa.String(length=128),
nullable=False,
comment="产品名称(引用产品时冗余固化)",
),
sa.Column(
"spec_model",
sa.String(length=128),
server_default="",
nullable=False,
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
),
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
sa.Column(
"quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="发货数量(>0)"
),
sa.Column(
"sale_price",
sa.Numeric(precision=14, scale=4),
nullable=True,
comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)",
),
sa.Column(
"amount",
sa.Numeric(precision=14, scale=2),
nullable=False,
comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["product_id"],
["products.id"],
name=op.f("fk_shipment_items_product_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["shipment_id"],
["shipments.id"],
name=op.f("fk_shipment_items_shipment_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_shipment_items")),
comment="发货产品明细表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_shipment_items_product_id", "shipment_items", ["product_id"], unique=False)
op.create_index(
"idx_shipment_items_shipment_id", "shipment_items", ["shipment_id"], unique=False
)
# ### end Alembic commands ###
# 评级等级种子(等级映射可配置;双轨:init-dev.sql 同步写入)
op.bulk_insert(
sa.table(
"customer_rating_grades",
sa.column("grade_name", sa.String),
sa.column("min_score", sa.Numeric),
sa.column("max_score", sa.Numeric),
sa.column("sort_order", sa.Integer),
sa.column("remark", sa.String),
),
_GRADE_SEED,
)
def downgrade() -> None:
"""回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
# 整表回滚只保留 drop_table(DROP TABLE 连带删除索引与外键);
# 逐条 drop_index 会先于 drop_table 摘掉外键支撑索引而报 MySQL 1553
op.drop_table("shipment_items")
op.drop_table("receipts")
op.drop_table("shipments")
op.drop_table("business_items")
op.drop_table("businesses")
op.drop_table("business_status_logs")
op.drop_table("customer_rating_grades")

View File

@ -0,0 +1,109 @@
"""新增操作日志表
Revision ID: a45a8087831b
Revises: 6165691326d4
Create Date: 2026-10-10 10:06:17.435697
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "a45a8087831b"
down_revision: str | Sequence[str] | None = "6165691326d4"
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(
"operation_logs",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"user_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="操作人ID(账号删除后置空)",
),
sa.Column(
"username",
sa.String(length=64),
nullable=False,
comment="操作人用户名(冗余存储便于追溯)",
),
sa.Column("module", sa.String(length=64), nullable=False, comment="所属模块,如 用户管理"),
sa.Column(
"action", sa.String(length=32), nullable=False, comment="操作类型:新增/修改/删除/导出"
),
sa.Column(
"summary", sa.String(length=255), nullable=False, comment="操作摘要(中文,供人阅读)"
),
sa.Column("method", sa.String(length=8), nullable=True, comment="HTTP 方法"),
sa.Column("path", sa.String(length=255), nullable=True, comment="请求路径"),
sa.Column("ip", sa.String(length=45), nullable=True, comment="客户端IP(45位兼容IPv6)"),
sa.Column(
"user_agent",
sa.String(length=255),
nullable=True,
comment="客户端User-Agent(截断存储)",
),
sa.Column("request_data", sa.Text(), nullable=True, comment="请求参数(已脱敏)"),
sa.Column(
"success",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="是否成功:1成功 0失败",
),
sa.Column(
"error_message",
sa.String(length=500),
nullable=True,
comment="失败原因(写入方须截断)",
),
sa.Column("duration_ms", sa.Integer(), nullable=True, comment="处理耗时(毫秒)"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["user_id"], ["users.id"], name=op.f("fk_operation_logs_user_id"), ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_operation_logs")),
comment="操作日志表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_operation_logs_create_time", "operation_logs", ["create_time"], unique=False
)
op.create_index("idx_operation_logs_module", "operation_logs", ["module"], unique=False)
op.create_index("idx_operation_logs_user_id", "operation_logs", ["user_id"], unique=False)
op.create_index("idx_operation_logs_username", "operation_logs", ["username"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
# 整表回滚只保留 drop_table —— DROP TABLE 会连带删除索引与外键。
# 逐个 drop_index 会失败:idx_operation_logs_user_id 是外键
# fk_operation_logs_user_id 的唯一支撑索引,MySQL 拒绝删除(errno 1553)。
op.drop_table("operation_logs")

View File

@ -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。
"""

View File

@ -0,0 +1,810 @@
"""新增岗位角色与计提参数四表
Revision ID: bfbda9c3191d
Revises: a45a8087831b
Create Date: 2026-10-12 16:01:48.760620
"""
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 = "bfbda9c3191d"
down_revision: str | Sequence[str] | None = "a45a8087831b"
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(
"business_roles",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"role_code",
sa.String(length=32),
nullable=False,
comment="岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改",
),
sa.Column("role_name", sa.String(length=64), nullable=False, comment="岗位名称"),
sa.Column(
"calc_template",
sa.String(length=32),
nullable=False,
comment="计提公式模板编码(以代码内模板注册表为准)",
),
sa.Column("description", sa.String(length=255), nullable=True, comment="岗位说明"),
sa.Column(
"sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序"
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1启用 0禁用",
),
sa.Column(
"is_builtin",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="是否内置岗位:1内置(禁删禁停) 0自定义",
),
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_business_roles")),
sa.UniqueConstraint("role_code", name=op.f("uk_business_roles_role_code")),
comment="业务岗位角色表(与登录角色roles区分,决定计提公式与参数)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"param_definitions",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"param_code",
sa.String(length=64),
nullable=False,
comment="参数编码,如 salesperson.rate.slope",
),
sa.Column("param_name", sa.String(length=64), nullable=False, comment="参数名称"),
sa.Column(
"param_group",
sa.String(length=32),
nullable=False,
comment="参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径",
),
sa.Column(
"value_type",
sa.String(length=16),
nullable=False,
comment="值类型:DECIMAL/INT/BOOL/ENUM/TEXT",
),
sa.Column(
"unit", sa.String(length=16), nullable=True, comment="展示单位:‰/%/元/天,无单位为空"
),
sa.Column(
"dimension_type",
sa.String(length=32),
server_default="NONE",
nullable=False,
comment="维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE",
),
sa.Column(
"options_text",
sa.String(length=255),
nullable=True,
comment="枚举选项(竖线分隔),仅 ENUM 使用",
),
sa.Column(
"default_value",
sa.String(length=64),
nullable=True,
comment="系统默认值(Fraction 可解析字符串)",
),
sa.Column(
"value_min", sa.String(length=64), nullable=True, comment="取值下限(含),校验用"
),
sa.Column(
"value_max", sa.String(length=64), nullable=True, comment="取值上限(含),校验用"
),
sa.Column(
"description", sa.String(length=255), nullable=True, comment="口径说明(配置页展示)"
),
sa.Column(
"sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序"
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1启用 0停用",
),
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_param_definitions")),
sa.UniqueConstraint("param_code", name=op.f("uk_param_definitions_param_code")),
comment="计提参数定义表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"param_change_batches",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"version_no", sa.String(length=32), nullable=False, comment="版本号,如 P20261012-01"
),
sa.Column("effective_date", sa.Date(), nullable=False, comment="生效日期(业务日期口径)"),
sa.Column(
"change_type",
mysql.TINYINT(),
nullable=False,
comment="生效方式:1立即生效 2指定日期生效",
),
sa.Column("remark", 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(
"item_count", sa.Integer(), server_default="0", 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_param_change_batches_operator_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_param_change_batches")),
sa.UniqueConstraint("version_no", name=op.f("uk_param_change_batches_version_no")),
comment="参数变更批次表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"param_values",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"param_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="参数定义ID(param_definitions.id)",
),
sa.Column(
"dimension_value",
sa.String(length=32),
server_default=sa.text("''"),
nullable=False,
comment="维度取值:NONE维度为空串;产品类别/岗位编码为具体值",
),
sa.Column(
"effective_date", sa.Date(), nullable=False, comment="生效日期(按业务日期时点化取值)"
),
sa.Column(
"value_text",
sa.String(length=64),
nullable=False,
comment="参数值(Fraction 可解析字符串/枚举码/开关)",
),
sa.Column(
"old_value_text", sa.String(length=64), nullable=True, comment="变更前值(留痕)"
),
sa.Column(
"batch_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="变更批次ID(param_change_batches.id)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["batch_id"],
["param_change_batches.id"],
name=op.f("fk_param_values_batch_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["param_id"],
["param_definitions.id"],
name=op.f("fk_param_values_param_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_param_values")),
comment="参数版本条目表(append-only,按生效日期时点化取值)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_param_values_batch_id", "param_values", ["batch_id"], unique=False)
op.create_index(
"idx_param_values_effective_date", "param_values", ["effective_date"], unique=False
)
op.create_index(
"idx_param_values_param_id_dimension_value_effective_date",
"param_values",
["param_id", "dimension_value", "effective_date"],
unique=False,
)
# ### end Alembic commands ###
# ---- 种子数据(生产与开发同源;口径依据 2026-10-12 业务裁决对照表)----
# 显式指定 id:四张表均为本迁移新建、应用时必为空表,id 稳定可控;
# param_values 按 param_id 引用定义,无法依赖自增(bulk_insert 取不回自增值)。
op.bulk_insert(
sa.table(
"business_roles",
sa.column("id", sa.BigInteger),
sa.column("role_code", sa.String),
sa.column("role_name", sa.String),
sa.column("calc_template", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("is_builtin", sa.Integer),
),
[
{
"id": 1,
"role_code": "salesperson",
"role_name": "营销员",
"calc_template": "TPL_SALESPERSON_DECAY",
"description": "业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)",
"sort_order": 1,
"is_builtin": 1,
},
{
"id": 2,
"role_code": "sales_manager",
"role_name": "营销经理",
"calc_template": "TPL_MANAGER_SHARE",
"description": "经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%",
"sort_order": 2,
"is_builtin": 1,
},
{
"id": 3,
"role_code": "info_officer",
"role_name": "信息员",
"calc_template": "TPL_INFO_SALES_FIXED",
"description": "信息员绩效:本人销售额×1‰",
"sort_order": 3,
"is_builtin": 1,
},
],
)
op.bulk_insert(
sa.table(
"param_change_batches",
sa.column("id", sa.BigInteger),
sa.column("version_no", sa.String),
sa.column("effective_date", sa.Date),
sa.column("change_type", sa.Integer),
sa.column("remark", sa.String),
sa.column("operator_name", sa.String),
sa.column("item_count", sa.Integer),
),
[
{
"id": 1,
"version_no": "P20260101-00",
"effective_date": date(2026, 1, 1),
"change_type": 1,
"remark": "初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)",
"operator_name": "system",
"item_count": 19,
}
],
)
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),
),
[
{
"id": 1,
"param_code": "salesperson.rate.start",
"param_name": "业务员起始计提比例",
"param_group": "业务员个人户",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "10",
"value_min": "0",
"value_max": None,
"description": "线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
"sort_order": 1,
},
{
"id": 2,
"param_code": "salesperson.rate.slope",
"param_name": "业务员日递减斜率",
"param_group": "业务员个人户",
"value_type": "DECIMAL",
"unit": "‰/天",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "4/360",
"value_min": "0",
"value_max": None,
"description": "精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
"sort_order": 2,
},
{
"id": 3,
"param_code": "salesperson.rate.floor",
"param_name": "业务员计提下限",
"param_group": "业务员个人户",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "4",
"value_min": "0",
"value_max": None,
"description": "回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
"sort_order": 3,
},
{
"id": 4,
"param_code": "salesperson.days.boundary",
"param_name": "线性递减分界天数",
"param_group": "业务员个人户",
"value_type": "INT",
"unit": "天",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "360",
"value_min": "0",
"value_max": None,
"description": "≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
"sort_order": 4,
},
{
"id": 5,
"param_code": "manager.share.team",
"param_name": "经理本团队分成比例",
"param_group": "经理分成",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "12.5",
"value_min": "0",
"value_max": "100",
"description": "∑本人带领业务员绩效 × 此比例(限本团队)",
"sort_order": 5,
},
{
"id": 6,
"param_code": "manager.share.company",
"param_name": "经理全公司分成比例",
"param_group": "经理分成",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "5",
"value_min": "0",
"value_max": "100",
"description": "∑全部业务员绩效 × 此比例(全公司)",
"sort_order": 6,
},
{
"id": 7,
"param_code": "enterprise.rate",
"param_name": "企业户计提比例",
"param_group": "企业户",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "PRODUCT_CATEGORY",
"options_text": None,
"default_value": "3",
"value_min": "0",
"value_max": None,
"description": "所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
"sort_order": 7,
},
{
"id": 8,
"param_code": "info.rate",
"param_name": "信息员计提比例",
"param_group": "信息员",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "1",
"value_min": "0",
"value_max": None,
"description": "信息员绩效 = 本人销售额 × 此比例",
"sort_order": 8,
},
{
"id": 9,
"param_code": "prepay.rate",
"param_name": "预付款计提比例",
"param_group": "支付环节",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "NONE",
"options_text": None,
"default_value": "10",
"value_min": "0",
"value_max": None,
"description": "预付款统一按此比例计提",
"sort_order": 9,
},
{
"id": 10,
"param_code": "barter.discount",
"param_name": "以物抵顶折算比例",
"param_group": "支付环节",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "NONE",
"options_text": None,
"default_value": "80",
"value_min": "0",
"value_max": "100",
"description": "以物抵顶回款绩效按应发绩效的此比例折算",
"sort_order": 10,
},
{
"id": 11,
"param_code": "payment.start.ratio",
"param_name": "支付起付比例",
"param_group": "支付环节",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "NONE",
"options_text": None,
"default_value": "60",
"value_min": "0",
"value_max": "100",
"description": "单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
"sort_order": 11,
},
{
"id": 12,
"param_code": "payment.weight.mode",
"param_name": "支付权重应用方式",
"param_group": "支付环节",
"value_type": "ENUM",
"unit": None,
"dimension_type": "NONE",
"options_text": "FULL|BALANCE",
"default_value": "FULL",
"value_min": None,
"value_max": None,
"description": "FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
"sort_order": 12,
},
{
"id": 13,
"param_code": "product.threshold",
"param_name": "折算毛利润额阈值",
"param_group": "产品阈值",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "PRODUCT_CATEGORY",
"options_text": None,
"default_value": "30",
"value_min": "0",
"value_max": "100",
"description": "折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
"sort_order": 13,
},
{
"id": 14,
"param_code": "salary.basic.mode",
"param_name": "月工资基本工资口径",
"param_group": "月工资口径",
"value_type": "ENUM",
"unit": None,
"dimension_type": "BUSINESS_ROLE",
"options_text": "INCLUDE|DEDUCT|EXCLUDE",
"default_value": "INCLUDE",
"value_min": None,
"value_max": None,
"description": "INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
"sort_order": 14,
},
{
"id": 15,
"param_code": "probation.annual.threshold",
"param_name": "试用期年累计结算阈值",
"param_group": "月工资口径",
"value_type": "DECIMAL",
"unit": "元",
"dimension_type": "NONE",
"options_text": None,
"default_value": "18000",
"value_min": "0",
"value_max": None,
"description": "试用期年累计绩效超过此值部分年底一次性结算",
"sort_order": 15,
},
{
"id": 16,
"param_code": "probation.salary.basic",
"param_name": "试用期基本工资标准",
"param_group": "月工资口径",
"value_type": "DECIMAL",
"unit": "元",
"dimension_type": "NONE",
"options_text": None,
"default_value": "3000",
"value_min": "0",
"value_max": None,
"description": "试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
"sort_order": 16,
},
],
)
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),
),
[
# 业务员个人户(2026-02-17 折算公式执行日起)
{
"id": 1,
"param_id": 1,
"dimension_value": "salesperson",
"effective_date": date(2026, 2, 17),
"value_text": "10",
"batch_id": 1,
},
{
"id": 2,
"param_id": 2,
"dimension_value": "salesperson",
"effective_date": date(2026, 2, 17),
"value_text": "4/360",
"batch_id": 1,
},
{
"id": 3,
"param_id": 3,
"dimension_value": "salesperson",
"effective_date": date(2026, 2, 17),
"value_text": "4",
"batch_id": 1,
},
{
"id": 4,
"param_id": 4,
"dimension_value": "salesperson",
"effective_date": date(2026, 2, 17),
"value_text": "360",
"batch_id": 1,
},
# 经理与信息员(2026-08-13 执行日起)
{
"id": 5,
"param_id": 5,
"dimension_value": "sales_manager",
"effective_date": date(2026, 8, 13),
"value_text": "12.5",
"batch_id": 1,
},
{
"id": 6,
"param_id": 6,
"dimension_value": "sales_manager",
"effective_date": date(2026, 8, 13),
"value_text": "5",
"batch_id": 1,
},
{
"id": 7,
"param_id": 7,
"dimension_value": "",
"effective_date": date(2026, 2, 17),
"value_text": "2",
"batch_id": 1,
},
{
"id": 8,
"param_id": 7,
"dimension_value": "",
"effective_date": date(2026, 8, 13),
"value_text": "3",
"batch_id": 1,
},
{
"id": 9,
"param_id": 8,
"dimension_value": "info_officer",
"effective_date": date(2026, 8, 13),
"value_text": "1",
"batch_id": 1,
},
# 支付环节与通用口径(需规默认值,无历史冲突)
{
"id": 10,
"param_id": 9,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "10",
"batch_id": 1,
},
{
"id": 11,
"param_id": 10,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "80",
"batch_id": 1,
},
{
"id": 12,
"param_id": 11,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "60",
"batch_id": 1,
},
{
"id": 13,
"param_id": 12,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "FULL",
"batch_id": 1,
},
# 产品阈值(按产品类别维度)
{
"id": 14,
"param_id": 13,
"dimension_value": "municipal_water",
"effective_date": date(2026, 2, 17),
"value_text": "30",
"batch_id": 1,
},
{
"id": 15,
"param_id": 13,
"dimension_value": "rail_transit",
"effective_date": date(2026, 2, 17),
"value_text": "20",
"batch_id": 1,
},
# 月工资口径(按岗位维度)
{
"id": 16,
"param_id": 14,
"dimension_value": "salesperson",
"effective_date": date(2026, 1, 1),
"value_text": "INCLUDE",
"batch_id": 1,
},
{
"id": 17,
"param_id": 14,
"dimension_value": "sales_manager",
"effective_date": date(2026, 1, 1),
"value_text": "DEDUCT",
"batch_id": 1,
},
{
"id": 18,
"param_id": 15,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "18000",
"batch_id": 1,
},
{
"id": 19,
"param_id": 16,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "3000",
"batch_id": 1,
},
],
)
def downgrade() -> None:
"""回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
# 整表回滚只保留 drop_table —— DROP TABLE 会连带删除索引与外键。
# 原 autogenerate 输出的逐个 drop_index 会失败:组合索引最左列 param_id 与
# idx_param_values_batch_id 分别是两个外键的唯一支撑索引(MySQL errno 1553)。
# 删除顺序:先删引用方 param_values,再删被引用方。
op.drop_table("param_values")
op.drop_table("param_change_batches")
op.drop_table("param_definitions")
op.drop_table("business_roles")

View File

@ -0,0 +1,193 @@
"""V8:业务答复口径落实(2026-10-19 回执)。
四件事,全部源于《待澄清问题清单》2026-10-19 答复:
1. 审批开关默认值翻转(#5/#7 答复「默认开启」):param_definitions 的
default_value 更新为 '1';param_values **插入生效日 2026-10-19 的新行**
而非改历史行——参数体系时点化(按业务日期回溯),直接改种子行会篡改
10-19 之前的口径历史。
2. 撤销试用期统一工资参数(#16 答复「按人员档案逐人维护」):删除
probation.salary.basic 的定义与版本行;月结基本工资已直接读
personnel.salary_standard(该参数自 M3 ④ 实现起即无业务消费点)。
3. 新增经理个人户聚合基数开关(#12 答复「作为参数配置,手动调整是否
纳入」):manager.personal.included,默认 0=不计入(维持暂口径)。
4. 绩效台账唯一键加 personnel_id(二次分配分摊铺路,澄清 #1 答复
「分配绩效」):分摊后同一回款同版本按人各落一行(负责人余额行 +
参与人比例行),uk(receipt_id, calc_version) 会撞;扩为三列后聚合行
(receipt_id NULL)依旧不受 MySQL 唯一索引约束。
迁移 downgrade 只保留 drop_table/行级种子反向 DELETE(V6 教训:逐个
drop_index 会撞外键支撑索引 errno 1553)。
"""
from datetime import date
import sqlalchemy as sa
from alembic import op
revision: str = "c7d2e5a81f30"
down_revision: str | None = "3bca400421c6"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
op.execute(
"UPDATE param_definitions SET default_value = '1' "
"WHERE id IN (17, 18)"
)
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 23,
"param_id": 17,
"dimension_value": "",
"effective_date": date(2026, 10, 19),
"value_text": "1",
"batch_id": 1,
},
{
"id": 24,
"param_id": 18,
"dimension_value": "",
"effective_date": date(2026, 10, 19),
"value_text": "1",
"batch_id": 1,
},
],
)
# 2. 撤销试用期统一工资参数(#16:按人员档案 personnel.salary_standard 逐人维护)
op.execute("DELETE FROM param_values WHERE id = 19")
op.execute("DELETE FROM param_definitions WHERE id = 16")
# 3. 经理个人户聚合基数开关(#12:参数化,默认不计入)
op.bulk_insert(
sa.table(
"param_definitions",
sa.column("id", sa.BigInteger),
sa.column("param_code", sa.String),
sa.column("param_name", sa.String),
sa.column("param_group", sa.String),
sa.column("value_type", sa.String),
sa.column("unit", sa.String),
sa.column("dimension_type", sa.String),
sa.column("options_text", sa.String),
sa.column("default_value", sa.String),
sa.column("value_min", sa.String),
sa.column("value_max", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("status", sa.Integer),
),
[
{
"id": 20,
"param_code": "manager.personal.included",
"param_name": "经理个人户计入聚合基数",
"param_group": "经理分成",
"value_type": "BOOL",
"unit": None,
"dimension_type": "NONE",
"options_text": None,
"default_value": "0",
"value_min": None,
"value_max": None,
"description": (
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
"0=不计入(默认,暂口径) 1=计入"
),
"sort_order": 20,
"status": 1,
}
],
)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
{
"id": 25,
"param_id": 20,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "0",
"batch_id": 1,
}
],
)
# 4. 台账唯一键扩为三列(分摊拆行:同回款同版本按人一行)
# 顺序要紧:旧 uk 是 fk_performance_ledgers_receipt_id 的唯一支撑索引,
# 先删会撞 errno 1553(外键无支撑索引);先建新键(receipt_id 仍居最左)
# 再做替换,两键短暂并存无冲突。
op.create_unique_constraint(
"uk_performance_ledgers_receipt_id_calc_version_personnel",
"performance_ledgers",
["receipt_id", "calc_version", "personnel_id"],
)
op.drop_constraint(
"uk_performance_ledgers_receipt_id_calc_version",
"performance_ledgers",
type_="unique",
)
def downgrade() -> None:
"""反向:唯一键复原 + 种子行级回退(顺序与 upgrade 相反)。
试用期参数(definitions 16 / values 19)的原始种子在 V3,按 V6 downgrade
的行级反向惯例显式重插,保证可重放。
"""
# 与 upgrade 同因的顺序:先建回旧键再删新键(旧键支撑 receipts 外键)
op.create_unique_constraint(
"uk_performance_ledgers_receipt_id_calc_version",
"performance_ledgers",
["receipt_id", "calc_version"],
)
op.drop_constraint(
"uk_performance_ledgers_receipt_id_calc_version_personnel",
"performance_ledgers",
type_="unique",
)
op.execute("DELETE FROM param_values WHERE id = 25")
op.execute("DELETE FROM param_definitions WHERE id = 20")
op.execute(
"INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, "
"unit, dimension_type, options_text, default_value, value_min, value_max, "
"description, sort_order, status) VALUES "
"(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', "
"'元', 'NONE', NULL, '3000', '0', NULL, "
"'试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16, 1)"
)
op.execute(
"INSERT INTO param_values (id, param_id, dimension_value, effective_date, "
"value_text, batch_id) VALUES (19, 16, '', '2026-01-01', '3000', 1)"
)
op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
op.execute(
"UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)"
)

View File

@ -0,0 +1,490 @@
"""基础数据管理六表:人员/辐射区域/拓展计划/产品/客户/归属变更留痕(M2)
- personnel / personnel_regions / personnel_plans:人员档案及其 1:N 子表
- products:产品主数据(类别锚定折算阈值参数)
- customers:客户档案(公司名称+负责人为业务唯一标识)
- ownership_change_logs:归属变更留痕(append-only,不回溯历史)
- 六张表的 T+ 同步预留列(source_no 等)全部可空、不设唯一键(对照表 #26)
Revision ID: e1133a7c670a
Revises: bfbda9c3191d
Create Date: 2026-10-13 09:36:02.219248
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "e1133a7c670a"
down_revision: str | Sequence[str] | None = "bfbda9c3191d"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"products",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column("product_name", sa.String(length=128), nullable=False, comment="产品名称"),
sa.Column(
"spec_model",
sa.String(length=128),
server_default="",
nullable=False,
comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)",
),
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
sa.Column(
"stock_quantity",
sa.Numeric(precision=14, scale=2),
nullable=True,
comment="数量(T+ 同步原样存储,语义待 IT 确认)",
),
sa.Column(
"sale_price",
sa.Numeric(precision=14, scale=4),
nullable=True,
comment="销售单价(元,T+ 同步)",
),
sa.Column(
"product_category",
sa.String(length=32),
nullable=False,
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1启用 0停用",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_products")),
sa.UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"),
comment="产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(op.f("idx_products_source_no"), "products", ["source_no"], unique=False)
op.create_table(
"ownership_change_logs",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"owner_type", mysql.TINYINT(), nullable=False, comment="归属类型:1客户归属 2业务归属"
),
sa.Column(
"customer_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="客户ID(固化,owner_type=1 时非空)",
),
sa.Column(
"business_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="业务ID(固化,owner_type=2 时非空)",
),
sa.Column(
"old_owner_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="变更前负责营销人员ID(固化)",
),
sa.Column(
"new_owner_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="变更后负责营销人员ID(固化)",
),
sa.Column(
"old_assistant_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="变更前协助营销人员ID(固化,仅业务归属使用)",
),
sa.Column(
"new_assistant_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="变更后协助营销人员ID(固化,仅业务归属使用)",
),
sa.Column("change_reason", sa.String(length=255), nullable=True, comment="变更原因"),
sa.Column(
"operator_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="操作人ID(账号删除后置空)",
),
sa.Column(
"operator_name",
sa.String(length=64),
nullable=False,
comment="操作人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["operator_id"],
["users.id"],
name=op.f("fk_ownership_change_logs_operator_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_ownership_change_logs")),
comment="归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_ownership_change_logs_customer_id"),
"ownership_change_logs",
["customer_id"],
unique=False,
)
op.create_index(
op.f("idx_ownership_change_logs_business_id"),
"ownership_change_logs",
["business_id"],
unique=False,
)
op.create_index(
op.f("idx_ownership_change_logs_create_time"),
"ownership_change_logs",
["create_time"],
unique=False,
)
op.create_table(
"personnel",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column("personnel_name", sa.String(length=64), nullable=False, comment="姓名"),
sa.Column(
"business_role_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)",
),
sa.Column(
"manager_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)",
),
sa.Column("phone", sa.String(length=20), nullable=True, comment="联系电话"),
sa.Column("hire_date", sa.Date(), nullable=True, comment="入职日期"),
sa.Column(
"is_probation",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="是否试用期:1是 0否(试用期绩效照算不发放)",
),
sa.Column(
"salary_standard",
sa.Numeric(precision=12, scale=2),
nullable=True,
comment="月工资标准(元;T+同步不覆盖)",
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["business_role_id"],
["business_roles.id"],
name=op.f("fk_personnel_business_role_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["manager_id"],
["personnel.id"],
name=op.f("fk_personnel_manager_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel")),
comment="营销人员表(T+同步不覆盖岗位与工资标准)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_personnel_business_role_id"), "personnel", ["business_role_id"], unique=False
)
op.create_index(op.f("idx_personnel_manager_id"), "personnel", ["manager_id"], unique=False)
op.create_index(op.f("idx_personnel_source_no"), "personnel", ["source_no"], unique=False)
op.create_table(
"customers",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column("company_name", sa.String(length=128), nullable=False, comment="公司名称"),
sa.Column(
"owner_name",
sa.String(length=64),
nullable=False,
comment="客户负责人姓名(客户公司负责人的人名,非系统人员)",
),
sa.Column("contact_person", sa.String(length=64), nullable=True, comment="联系人"),
sa.Column("contact_phone", sa.String(length=20), nullable=True, comment="联系电话"),
sa.Column("address", sa.String(length=255), nullable=True, comment="地址"),
sa.Column("industry", sa.String(length=64), nullable=True, comment="所属行业(自由文本)"),
sa.Column(
"customer_type",
sa.String(length=32),
server_default="personal",
nullable=False,
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
),
sa.Column(
"owner_personnel_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.Column(
"source_no",
sa.String(length=64),
nullable=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
),
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
sa.Column(
"sync_batch_no",
sa.String(length=32),
nullable=True,
comment="同步批次号(同步模块写入)",
),
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
sa.ForeignKeyConstraint(
["owner_personnel_id"],
["personnel.id"],
name=op.f("fk_customers_owner_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_customers")),
sa.UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"),
comment="客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_customers_customer_type"), "customers", ["customer_type"], unique=False
)
op.create_index(
op.f("idx_customers_owner_personnel_id"), "customers", ["owner_personnel_id"], unique=False
)
op.create_index(op.f("idx_customers_source_no"), "customers", ["source_no"], unique=False)
op.create_table(
"personnel_plans",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="人员ID(personnel.id)",
),
sa.Column(
"plan_period",
sa.String(length=32),
nullable=False,
comment="计划期间(如 2026Q1、2026-05)",
),
sa.Column("plan_content", sa.String(length=500), nullable=False, comment="计划内容"),
sa.Column(
"target_desc", sa.String(length=255), nullable=True, comment="指标说明(文字描述)"
),
sa.Column(
"source_no", sa.String(length=64), nullable=True, comment="T+ 原始条目号(预留)"
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_personnel_plans_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_plans")),
comment="市场拓展计划表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_personnel_plans_personnel_id"), "personnel_plans", ["personnel_id"], unique=False
)
op.create_table(
"personnel_regions",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"personnel_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="人员ID(personnel.id)",
),
sa.Column(
"region_name",
sa.String(length=128),
nullable=False,
comment="区域名称(产品辐射地理范围)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["personnel_id"],
["personnel.id"],
name=op.f("fk_personnel_regions_personnel_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_regions")),
comment="产品辐射区域表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
op.f("idx_personnel_regions_personnel_id"),
"personnel_regions",
["personnel_id"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
"""回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
op.drop_table("personnel_regions")
op.drop_table("personnel_plans")
op.drop_table("customers")
op.drop_table("personnel")
op.drop_table("ownership_change_logs")
op.drop_table("products")

View File

@ -2,16 +2,18 @@
from typing import Annotated
from fastapi import Depends
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.constants import ROLE_ADMIN
from app.core.db import get_db
from app.core.exceptions import AuthError
from app.core.exceptions import AuthError, ForbiddenError
from app.core.security import decode_token
from app.models import User
from app.repositories import user_repository
from app.services import token_blacklist
from app.services.audit_service import RequestMeta
# auto_error=False:令牌缺失时由我们自己抛 AuthError,
# 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"}
@ -21,6 +23,29 @@ BearerCredentials = Annotated[HTTPAuthorizationCredentials | None, Depends(_bear
DbSession = Annotated[Session, Depends(get_db)]
def client_ip(request: Request) -> str | None:
"""取客户端 IP。
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
"""
return request.client.host if request.client else None
def get_request_meta(request: Request) -> RequestMeta:
"""提取 HTTP 请求元信息,供操作日志审计使用。"""
return RequestMeta(
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
method=request.method,
path=request.url.path,
)
RequestMetaDep = Annotated[RequestMeta, Depends(get_request_meta)]
def get_raw_token(credentials: BearerCredentials) -> str:
"""取原始令牌字符串。登出时需要它来解析 jti。"""
if credentials is None or not credentials.credentials:
@ -71,3 +96,46 @@ def _is_digits(value: object) -> bool:
令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。
"""
return isinstance(value, str) and value.isdigit()
def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> User:
"""要求当前用户具备管理员角色。
需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个
角色,故权限判定用**角色粗粒度**即可,不必引入权限码表 —— `permissions`
与 `role_permissions` 表已建好,等业务方给出权限清单后再启用。
:raises ForbiddenError: 已登录但不具备管理员角色(403)
"""
if ROLE_ADMIN not in user_repository.get_role_codes(current_user):
raise ForbiddenError("需要管理员权限")
return current_user
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

View File

@ -8,6 +8,23 @@ from fastapi import APIRouter
from app.api import health
from app.api.v1 import auth
from app.api.v1.base import customers as base_customers
from app.api.v1.base import personnel as base_personnel
from app.api.v1.base import products as base_products
from app.api.v1.business import businesses as business_businesses
from app.api.v1.business import ratings as business_ratings
from app.api.v1.business import receipts as business_receipts
from app.api.v1.business import shipments as business_shipments
from app.api.v1.calc import router as calc_router
from app.api.v1.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 users as system_users
api_router = APIRouter()
@ -17,4 +34,39 @@ api_router.include_router(health.router)
# 业务接口:/api/v1/*
v1_router = APIRouter(prefix="/v1")
v1_router.include_router(auth.router)
# 系统管理(需求 2.5.1):/api/v1/system/*
system_router = APIRouter(prefix="/system")
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)
v1_router.include_router(system_router)
# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问
base_router = APIRouter(prefix="/base")
base_router.include_router(base_personnel.router)
base_router.include_router(base_products.router)
base_router.include_router(base_customers.router)
v1_router.include_router(base_router)
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
business_router = APIRouter(prefix="/business")
business_router.include_router(business_businesses.router)
business_router.include_router(business_shipments.router)
business_router.include_router(business_receipts.router)
business_router.include_router(business_ratings.router)
v1_router.include_router(business_router)
# 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤
v1_router.include_router(calc_router)
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
v1_router.include_router(settlements_router)
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导
v1_router.include_router(reports_router)
api_router.include_router(v1_router)

View File

@ -4,7 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Request
from app.api.deps import DbSession, get_current_user, get_raw_token
from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token
from app.core.response import ApiResponse, ok
from app.models import User
from app.repositories.user_repository import get_role_codes
@ -14,16 +14,6 @@ from app.services import auth_service, captcha_service
router = APIRouter(prefix="/auth", tags=["认证"])
def _client_ip(request: Request) -> str | None:
"""取客户端 IP。
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
"""
return request.client.host if request.client else None
@router.get("/captcha", response_model=ApiResponse[CaptchaOut], summary="获取图形验证码")
def get_captcha() -> ApiResponse[CaptchaOut]:
"""签发图形验证码。无需认证。
@ -52,7 +42,7 @@ def login(payload: LoginIn, request: Request, session: DbSession) -> ApiResponse
password=payload.password,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
ip=_client_ip(request),
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
)
return ok(result, message="登录成功")

View File

@ -0,0 +1,8 @@
"""基础数据管理接口(需求 2.1)。
挂载于 `/api/v1/base/*`,含人员管理、辐射区域、拓展计划等基础数据的维护入口。
与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
由 service 层写入操作日志。
"""

View File

@ -0,0 +1,142 @@
"""客户管理接口(需求 2.1.3)。
归属调整有专属端点 ``PUT /{id}/ownership``:它是唯一写归属留痕的入口,
编辑端点的 schema 不含归属字段 —— 防止出现「改了归属却无留痕」的旁路
(原因详见 `schemas.customer.CustomerUpdateIn`)。
与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
由 service 层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import User
from app.schemas.customer import (
CustomerCreateIn,
CustomerOut,
CustomerOwnershipIn,
CustomerQuery,
CustomerUpdateIn,
OwnershipChangeLogOut,
)
from app.services import customer_service
router = APIRouter(prefix="/customers", tags=["客户管理"])
# 基础数据不要求管理员(见模块 docstring),登录用户即可操作
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get("", response_model=ApiResponse[PageResult[CustomerOut]], summary="客户列表")
def list_customers(
session: DbSession,
_: CurrentUser,
query: Annotated[CustomerQuery, Query()],
) -> ApiResponse[PageResult[CustomerOut]]:
"""分页查询客户(keyword 对公司名称/客户负责人模糊匹配,附归属人员姓名)。"""
return ok(customer_service.list_customers(session, query))
@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户")
def create_customer(
session: DbSession,
current_user: CurrentUser,
payload: CustomerCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""新增客户。客户分类须在注册表内;「公司名称+负责人」组合唯一。"""
return ok(
customer_service.create_customer(session, payload, operator=current_user, meta=meta),
message="客户已创建",
)
@router.get("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="客户详情")
def get_customer(
session: DbSession,
_: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[CustomerOut]:
"""按主键查询客户详情。"""
return ok(customer_service.get_customer(session, customer_id))
@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户")
def update_customer(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""编辑客户档案。
**不含归属** —— 归属调整统一走 ``PUT /{id}/ownership``;编辑表单即使
提交 ownerPersonnelId 也会被忽略,归属以归属端点为准。
"""
return ok(
customer_service.update_customer(
session, customer_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户")
def delete_customer(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除客户(物理删除)。
已存在关联业务时返回 400(先处理相关业务单据)。
"""
customer_service.delete_customer(session, customer_id, operator=current_user, meta=meta)
return ok(message="客户已删除")
@router.put(
"/{customer_id}/ownership",
response_model=ApiResponse[CustomerOut],
summary="归属变更",
)
def change_ownership(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerOwnershipIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""调整客户归属(唯一写归属留痕的入口)。
只影响变更后核算,不回溯历史(对照表 #19);新旧归属人员ID任一为 null
表示清空归属。新旧归属相同报「归属未变化」。
"""
return ok(
customer_service.change_ownership(
session, customer_id, payload, operator=current_user, meta=meta
),
message="归属已调整",
)
@router.get(
"/{customer_id}/ownership-logs",
response_model=ApiResponse[PageResult[OwnershipChangeLogOut]],
summary="归属变更留痕",
)
def list_ownership_logs(
session: DbSession,
_: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
query: Annotated[PageQuery, Query()],
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
"""查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。"""
return ok(customer_service.list_ownership_logs(session, customer_id, query))

View File

@ -0,0 +1,143 @@
"""人员管理接口(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。
基础数据属业务操作,**登录即可访问**(不要求管理员,对照系统管理模块)。
校验规则(岗位/经理合法性、删除保护、子表全量替换的防御写法)见
`personnel_service` 模块 docstring —— 前端提示之外,后端校验是唯一防线。
写操作经 `RequestMetaDep` 带入请求元信息由 service 层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.personnel import (
PersonnelCreateIn,
PersonnelDetailOut,
PersonnelOut,
PersonnelQuery,
PersonnelStatusIn,
PlanReplaceIn,
RegionReplaceIn,
)
from app.services import personnel_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/personnel", tags=["人员管理"])
@router.get("", response_model=ApiResponse[PageResult[PersonnelOut]], summary="人员列表")
def list_personnel(
session: DbSession,
_: CurrentUser,
query: Annotated[PersonnelQuery, Query()],
) -> ApiResponse[PageResult[PersonnelOut]]:
"""分页查询人员(含离职)。姓名模糊匹配,可按状态与岗位筛选。"""
return ok(personnel_service.list_personnel(session, query))
@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员")
def create_personnel(
session: DbSession,
current_user: CurrentUser,
payload: PersonnelCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]:
"""新增人员,默认在职。岗位须存在且启用;直属经理不能是自己且须在职。"""
return ok(
personnel_service.create_personnel(session, payload, operator=current_user, meta=meta),
message="人员已创建",
)
@router.get("/{personnel_id}", response_model=ApiResponse[PersonnelDetailOut], summary="人员详情")
def get_personnel(
session: DbSession,
_: CurrentUser,
personnel_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[PersonnelDetailOut]:
"""人员详情,附辐射区域与拓展计划,供编辑抽屉一次回显。"""
return ok(personnel_service.get_detail(session, personnel_id))
@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员")
def update_personnel(
session: DbSession,
current_user: CurrentUser,
personnel_id: Annotated[int, Path(ge=1)],
payload: PersonnelCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]:
"""编辑人员(全量提交,不含状态)。校验规则同新增。"""
return ok(
personnel_service.update_personnel(
session, personnel_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员")
def delete_personnel(
session: DbSession,
current_user: CurrentUser,
personnel_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除人员。被团队成员或客户归属引用时禁删,应改为离职。"""
personnel_service.delete_personnel(session, personnel_id, operator=current_user, meta=meta)
return ok(message="人员已删除")
@router.put(
"/{personnel_id}/status",
response_model=ApiResponse[PersonnelOut],
summary="设为在职/离职",
)
def set_status(
session: DbSession,
current_user: CurrentUser,
personnel_id: Annotated[int, Path(ge=1)],
payload: PersonnelStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]:
"""在职/离职切换。离职人员不可再被业务与客户归属引用。"""
result = personnel_service.set_status(
session, personnel_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已设为在职" if payload.status == 1 else "已设为离职")
@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域")
def replace_regions(
session: DbSession,
current_user: CurrentUser,
personnel_id: Annotated[int, Path(ge=1)],
payload: RegionReplaceIn,
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""全量替换辐射区域,空列表即清空。"""
personnel_service.replace_regions(
session, personnel_id, payload, operator=current_user, meta=meta
)
return ok(message="已保存")
@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划")
def replace_plans(
session: DbSession,
current_user: CurrentUser,
personnel_id: Annotated[int, Path(ge=1)],
payload: PlanReplaceIn,
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""全量替换拓展计划,空列表即清空。"""
personnel_service.replace_plans(
session, personnel_id, payload, operator=current_user, meta=meta
)
return ok(message="已保存")

View File

@ -0,0 +1,112 @@
"""产品管理接口(需求 2.1.2:产品主数据维护)。
产品是折算阈值的行级锚点(product_category 决定 30%/20% 折算阈值),
类别值域由后端校验 —— 前端下拉只是展示约束,后端校验是唯一防线。
**数据提取(POST /products/extract)暂不提供**:它依赖业务明细表
(V5 迁移),待业务管理任务完成后补充。
基础数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.product import (
ProductCreateIn,
ProductOut,
ProductQuery,
ProductStatusIn,
ProductUpdateIn,
)
from app.services import product_service
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/products", tags=["产品管理"])
@router.get("", response_model=ApiResponse[PageResult[ProductOut]], summary="产品列表")
def list_products(
session: DbSession,
_: CurrentUser,
query: Annotated[ProductQuery, Query()],
) -> ApiResponse[PageResult[ProductOut]]:
"""分页查询产品(含停用)。keyword 对名称或规格模糊匹配。"""
return ok(product_service.list_products(session, query))
@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品")
def create_product(
session: DbSession,
current_user: CurrentUser,
payload: ProductCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ProductOut]:
"""新增产品。类别须在合法值域内;新产品默认启用。"""
return ok(
product_service.create_product(session, payload, operator=current_user, meta=meta),
message="产品已创建",
)
@router.get("/{product_id}", response_model=ApiResponse[ProductOut], summary="产品详情")
def get_product(
session: DbSession,
_: CurrentUser,
product_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ProductOut]:
"""按主键查询产品详情。"""
return ok(product_service.get_product(session, product_id))
@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品")
def update_product(
session: DbSession,
current_user: CurrentUser,
product_id: Annotated[int, Path(ge=1)],
payload: ProductUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[ProductOut]:
"""编辑产品基本信息(全量替换,不含状态;启用/停用走独立端点)。"""
return ok(
product_service.update_product(
session, product_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品")
def delete_product(
session: DbSession,
current_user: CurrentUser,
product_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""物理删除产品(仅限误录数据,常规下架用停用)。
被业务明细/发货明细引用时返回 400(引导改走停用)。
"""
product_service.delete_product(session, product_id, operator=current_user, meta=meta)
return ok(message="产品已删除")
@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品")
def set_status(
session: DbSession,
current_user: CurrentUser,
product_id: Annotated[int, Path(ge=1)],
payload: ProductStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[ProductOut]:
"""启用或停用产品(1启用 0停用)。"""
result = product_service.set_status(
session, product_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已启用" if payload.status == 1 else "已停用")

View File

@ -0,0 +1,8 @@
"""业务数据管理接口(需求 2.2)。
挂载于 `/api/v1/business/*`,含业务单据维护、客户评级等级配置等业务数据入口。
与 `api/v1/system` 的权限分工:业务数据是**业务操作**,登录即可访问
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
由 service 层写入操作日志。
"""

View File

@ -0,0 +1,177 @@
"""业务管理接口(需求 2.2.1/2.2.4)。
校验规则(编号生成、状态机、归属留痕、汇总重算)见 `business_service`
模块 docstring;基础数据属业务操作,登录即可访问。
"""
from datetime import date
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import User
from app.schemas.business import (
BusinessCreateIn,
BusinessDetailOut,
BusinessOut,
BusinessOwnershipIn,
BusinessQuery,
BusinessStatusIn,
BusinessStatusLogOut,
BusinessUpdateIn,
GrossProfitOut,
OwnershipChangeLogOut,
)
from app.services import business_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/businesses", tags=["业务管理"])
@router.get("", response_model=ApiResponse[PageResult[BusinessOut]], summary="业务列表")
def list_businesses(
session: DbSession,
_: CurrentUser,
query: Annotated[BusinessQuery, Query()],
) -> ApiResponse[PageResult[BusinessOut]]:
"""分页查询业务。编号/公司名称模糊,可按状态/客户/归属人员/日期范围筛选。"""
return ok(business_service.list_businesses(session, query))
@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务")
def create_business(
session: DbSession,
current_user: CurrentUser,
payload: BusinessCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]:
"""创建业务 + 明细行(≥1)。业务编号自动生成;创建写首条状态留痕。"""
return ok(
business_service.create_business(session, payload, operator=current_user, meta=meta),
message="业务已创建",
)
@router.get("/{business_id}", response_model=ApiResponse[BusinessDetailOut], summary="业务详情")
def get_business(
session: DbSession,
_: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[BusinessDetailOut]:
"""业务详情,附产品明细行,供编辑抽屉一次回显。"""
return ok(business_service.get_detail(session, business_id))
@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务")
def update_business(
session: DbSession,
current_user: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]:
"""编辑业务(明细全量替换并重算汇总)。归属不在此端点调整,走 /ownership。"""
return ok(
business_service.update_business(
session, business_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务")
def delete_business(
session: DbSession,
current_user: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除业务。有发货或回款记录时禁删,应改为已终止。"""
business_service.delete_business(session, business_id, operator=current_user, meta=meta)
return ok(message="业务已删除")
@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转")
def change_status(
session: DbSession,
current_user: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]:
"""状态流转:执行中→已完成/已终止;终态不可再变更;终止必填原因。"""
return ok(
business_service.change_status(
session, business_id, payload, operator=current_user, meta=meta
),
message="状态已变更",
)
@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属")
def change_ownership(
session: DbSession,
current_user: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessOwnershipIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]:
"""调整负责/协助营销人员,同事务写归属变更留痕(不回溯历史)。"""
return ok(
business_service.change_ownership(
session, business_id, payload, operator=current_user, meta=meta
),
message="归属已调整",
)
@router.get(
"/{business_id}/ownership-logs",
response_model=ApiResponse[PageResult[OwnershipChangeLogOut]],
summary="归属变更记录",
)
def list_ownership_logs(
session: DbSession,
_: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
query: Annotated[PageQuery, Query()],
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
"""某业务的归属变更留痕(分页,按时间倒序)。"""
return ok(business_service.list_ownership_logs(session, business_id, query))
@router.get(
"/{business_id}/status-logs",
response_model=ApiResponse[PageResult[BusinessStatusLogOut]],
summary="状态流转记录",
)
def list_status_logs(
session: DbSession,
_: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
query: Annotated[PageQuery, Query()],
) -> ApiResponse[PageResult[BusinessStatusLogOut]]:
"""某业务的状态流转时间线(分页,按时间正序,从创建起展示)。"""
return ok(business_service.list_status_logs(session, business_id, query))
@router.get(
"/{business_id}/gross-profit",
response_model=ApiResponse[GrossProfitOut],
summary="毛利润数据视图",
)
def gross_profit(
session: DbSession,
_: CurrentUser,
business_id: Annotated[int, Path(ge=1)],
as_of: Annotated[date | None, Query()] = None,
) -> ApiResponse[GrossProfitOut]:
"""按产品逐行实时计算毛利润与折算额(不含运费口径,对照表 #2)。
阈值按 asOf(默认今日)取参数快照;M3 核算实际按回款到账日取参。
"""
return ok(business_service.gross_profit(session, business_id, as_of))

View File

@ -0,0 +1,74 @@
"""客户评级等级配置接口(需求 2.2.5)。
评级计算器的等级映射判定表:判定按 sort_order 升序取首个 min_score ≤ 总分
的等级,max_score 仅展示。区间合法(0≤min≤max≤100)与互不重叠由后端校验
—— 前端表单只是展示约束,后端校验是唯一防线。
列表不分页全量返回:等级映射是判定表,数量个位数,分页无意义。
业务数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
from app.services import rating_service
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/rating-grades", tags=["客户评级"])
@router.get("", response_model=ApiResponse[list[RatingGradeOut]], summary="评级等级列表")
def list_grades(
session: DbSession,
_: CurrentUser,
) -> ApiResponse[list[RatingGradeOut]]:
"""全量等级列表,按判定顺序(sort_order 升序)返回,不分页。"""
return ok(rating_service.list_grades(session))
@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级")
def create_grade(
session: DbSession,
current_user: CurrentUser,
payload: RatingGradeCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[RatingGradeOut]:
"""新增评级等级。区间须 0≤min≤max≤100 且与既有等级不重叠。"""
return ok(
rating_service.create_grade(session, payload, operator=current_user, meta=meta),
message="评级等级已创建",
)
@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级")
def update_grade(
session: DbSession,
current_user: CurrentUser,
grade_id: Annotated[int, Path(ge=1)],
payload: RatingGradeUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[RatingGradeOut]:
"""编辑评级等级(全量替换)。区间须合法且与其他等级不重叠。"""
return ok(
rating_service.update_grade(session, grade_id, payload, operator=current_user, meta=meta),
message="已保存",
)
@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级")
def delete_grade(
session: DbSession,
current_user: CurrentUser,
grade_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""物理删除评级等级(等级表无下游引用,允许清空,理由见 service 说明)。"""
rating_service.delete_grade(session, grade_id, operator=current_user, meta=meta)
return ok(message="评级等级已删除")

View File

@ -0,0 +1,89 @@
"""回款管理接口(需求 2.2.3)。
回款按业务登记到账款项,批次号自动生成(HK 前缀)。校验规则(发货
批次同业务强校验、已核算禁改禁删、汇总重算)见 `receipt_service` 模块
docstring;基础数据属业务操作,登录即可访问。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.receipt import (
ReceiptCreateIn,
ReceiptOut,
ReceiptQuery,
ReceiptUpdateIn,
)
from app.services import receipt_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/receipts", tags=["回款管理"])
@router.get("", response_model=ApiResponse[PageResult[ReceiptOut]], summary="回款列表")
def list_receipts(
session: DbSession,
_: CurrentUser,
query: Annotated[ReceiptQuery, Query()],
) -> ApiResponse[PageResult[ReceiptOut]]:
"""分页查询回款。附业务号/发货单号与回款天数派生值,可按业务/日期/抵顶/核算状态筛选。"""
return ok(receipt_service.list_receipts(session, query))
@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款")
def create_receipt(
session: DbSession,
current_user: CurrentUser,
payload: ReceiptCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ReceiptOut]:
"""新增回款。批次号自动生成;发货批次须与本回款同属一个业务;业务须执行中。"""
return ok(
receipt_service.create_receipt(session, payload, operator=current_user, meta=meta),
message="回款已登记",
)
@router.get("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="回款详情")
def get_receipt(
session: DbSession,
_: CurrentUser,
receipt_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ReceiptOut]:
"""回款详情(含回款天数派生值,未指定发货批次时为空)。"""
return ok(receipt_service.get_detail(session, receipt_id))
@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款")
def update_receipt(
session: DbSession,
current_user: CurrentUser,
receipt_id: Annotated[int, Path(ge=1)],
payload: ReceiptUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[ReceiptOut]:
"""编辑回款(全量替换并重算合计)。已核算回款禁止修改;发货批次重新校验同业务。"""
return ok(
receipt_service.update_receipt(
session, receipt_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款")
def delete_receipt(
session: DbSession,
current_user: CurrentUser,
receipt_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除回款。已核算回款禁止删除;业务已完成或已终止时禁删。"""
receipt_service.delete_receipt(session, receipt_id, operator=current_user, meta=meta)
return ok(message="回款已删除")

View File

@ -0,0 +1,89 @@
"""发货管理接口(需求 2.2.2)。
校验规则(状态门槛、行金额固化、双重汇总回写、删除保护)见
`shipment_service` 模块 docstring;业务数据属业务操作,登录即可访问。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.shipment import (
ShipmentCreateIn,
ShipmentDetailOut,
ShipmentOut,
ShipmentQuery,
ShipmentUpdateIn,
)
from app.services import shipment_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/shipments", tags=["发货管理"])
@router.get("", response_model=ApiResponse[PageResult[ShipmentOut]], summary="发货单列表")
def list_shipments(
session: DbSession,
_: CurrentUser,
query: Annotated[ShipmentQuery, Query()],
) -> ApiResponse[PageResult[ShipmentOut]]:
"""分页查询发货单(附业务编号),可按业务/发货日期范围筛选。"""
return ok(shipment_service.list_shipments(session, query))
@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单")
def create_shipment(
session: DbSession,
current_user: CurrentUser,
payload: ShipmentCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ShipmentOut]:
"""创建发货单(明细 ≥1)。业务须处于执行中;锁业务行后生成 SH 编号。"""
return ok(
shipment_service.create_shipment(session, payload, operator=current_user, meta=meta),
message="发货单已创建",
)
@router.get("/{shipment_id}", response_model=ApiResponse[ShipmentDetailOut], summary="发货单详情")
def get_shipment(
session: DbSession,
_: CurrentUser,
shipment_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ShipmentDetailOut]:
"""发货单详情,附产品明细行,供编辑抽屉一次回显。"""
return ok(shipment_service.get_detail(session, shipment_id))
@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单")
def update_shipment(
session: DbSession,
current_user: CurrentUser,
shipment_id: Annotated[int, Path(ge=1)],
payload: ShipmentUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[ShipmentOut]:
"""编辑发货单(明细全量替换并重算汇总)。终态业务的发货单禁改。"""
return ok(
shipment_service.update_shipment(
session, shipment_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单")
def delete_shipment(
session: DbSession,
current_user: CurrentUser,
shipment_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除发货单(连带明细行并回写业务汇总)。被回款批次指定时禁删。"""
shipment_service.delete_shipment(session, shipment_id, operator=current_user, meta=meta)
return ok(message="发货单已删除")

107
backend/app/api/v1/calc.py Normal file
View File

@ -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="批量核算完成",
)

View File

@ -0,0 +1,178 @@
"""报表统计接口(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
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, 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 月份必填由模型校验)。"""
data = query.model_dump()
if code == "personal-detail":
return PersonalDetailQuery(**data)
if code == "department-summary":
return DepartmentSummaryQuery(**data)
if code == "business-stats":
return BusinessStatsQuery(**data)
return PaymentStatsQuery(**data)

View File

@ -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))

View File

@ -0,0 +1,7 @@
"""系统管理路由(需求 2.5.1)。
含用户管理、角色管理、日志查询(操作日志 + 登录日志)四部分,全部要求管理员权限。
与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录,
以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。
"""

View File

@ -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="已提交",
)

View File

@ -0,0 +1,122 @@
"""业务岗位角色管理接口(需求 #28:岗位可扩展 + 绑定公式模板)。
保护规则(内置岗位禁删禁停、编码不可改、模板须在注册表内)见
`business_role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。
全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service
层写入操作日志。
"""
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.business_role import (
BusinessRoleCreateIn,
BusinessRoleOut,
BusinessRoleQuery,
BusinessRoleStatusIn,
BusinessRoleUpdateIn,
TemplateOptionOut,
)
from app.services import business_role_service
router = APIRouter(prefix="/business-roles", tags=["系统管理 · 岗位角色"])
@router.get("", response_model=ApiResponse[PageResult[BusinessRoleOut]], summary="岗位列表")
def list_roles(
session: DbSession,
_: AdminUser,
query: Annotated[BusinessRoleQuery, Query()],
) -> ApiResponse[PageResult[BusinessRoleOut]]:
"""分页查询业务岗位角色(含停用)。"""
return ok(business_role_service.list_roles(session, query))
@router.post("", response_model=ApiResponse[BusinessRoleOut], summary="新增岗位")
def create_role(
session: DbSession,
current_user: AdminUser,
payload: BusinessRoleCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessRoleOut]:
"""新增岗位。编码全局唯一、创建后不可修改;模板须在注册表内;新岗位默认启用。"""
return ok(
business_role_service.create_role(session, payload, operator=current_user, meta=meta),
message="岗位已创建",
)
@router.get(
"/meta/templates",
response_model=ApiResponse[list[TemplateOptionOut]],
summary="公式模板下拉",
)
def list_template_options(session: DbSession, _: AdminUser) -> ApiResponse[list[TemplateOptionOut]]:
"""取公式模板下拉(含声明参数,供岗位表单展示)。
定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。
"""
return ok(business_role_service.list_template_options())
@router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情")
def get_role(
session: DbSession,
_: AdminUser,
role_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[BusinessRoleOut]:
"""按主键查询岗位详情。"""
return ok(business_role_service.get_role(session, role_id))
@router.put("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="编辑岗位")
def update_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: BusinessRoleUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessRoleOut]:
"""编辑岗位名称/模板/说明/排序。
**不含 role_code** —— 编码是参数维度值与模板绑定的锚点。
"""
return ok(
business_role_service.update_role(
session, role_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.put(
"/{role_id}/status", response_model=ApiResponse[BusinessRoleOut], summary="启用/禁用岗位"
)
def set_status(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: BusinessRoleStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessRoleOut]:
"""启用或禁用岗位。内置岗位不可停用。"""
result = business_role_service.set_status(
session, role_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除岗位")
def delete_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除岗位。内置岗位不可删除;有人员引用的岗位不可删除。"""
business_role_service.delete_role(session, role_id, operator=current_user, meta=meta)
return ok(message="岗位已删除")

View File

@ -0,0 +1,67 @@
"""日志查询接口(需求 2.5.1:支持日志查询)。
操作日志与登录日志是两个独立端点:两表字段与筛选项不同(前者含模块/操作/
请求详情,后者含失败原因),合并会让 `action` 等参数产生二义;前端正好是
两个 tab。操作日志另有单条详情(列表为性能排除 `request_data`)。
均要求管理员权限;只读查询不写审计(读操作记日志只会淹没审计表)。
"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import AdminUser, DbSession
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.log import (
LoginLogOut,
LoginLogQuery,
OperationLogDetailOut,
OperationLogOut,
OperationLogQuery,
)
from app.services import log_service
operation_router = APIRouter(prefix="/operation-logs", tags=["系统管理 · 日志"])
login_router = APIRouter(prefix="/login-logs", tags=["系统管理 · 日志"])
@operation_router.get(
"", response_model=ApiResponse[PageResult[OperationLogOut]], summary="操作日志"
)
def list_operation_logs(
session: DbSession,
_: AdminUser,
query: Annotated[OperationLogQuery, Query()],
) -> ApiResponse[PageResult[OperationLogOut]]:
"""分页查询操作日志,支持按操作人/模块/操作类型/结果/时间范围筛选。
按 id 倒序(时间倒序的稳定实现)。列表**不含** `request_data`(大字段),
展开详情请调 `GET /operation-logs/{log_id}`。
"""
return ok(log_service.list_operation_logs(session, query))
@operation_router.get(
"/{log_id}", response_model=ApiResponse[OperationLogDetailOut], summary="操作日志详情"
)
def get_operation_log(
session: DbSession,
_: AdminUser,
log_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[OperationLogDetailOut]:
"""查询单条操作日志详情(含已脱敏的请求参数)。
前端展开日志行时按 id 按需加载 —— 避免列表翻页携带大字段。
"""
return ok(log_service.get_operation_log(session, log_id))
@login_router.get("", response_model=ApiResponse[PageResult[LoginLogOut]], summary="登录日志")
def list_login_logs(
session: DbSession,
_: AdminUser,
query: Annotated[LoginLogQuery, Query()],
) -> ApiResponse[PageResult[LoginLogOut]]:
"""分页查询登录日志,支持按用户名/IP/结果/时间范围筛选。"""
return ok(log_service.list_login_logs(session, query))

View File

@ -0,0 +1,148 @@
"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。
权限分层(澄清 #17 业务答复 2026-10-19「查看可、修改仅管理员」):只读
端点(目录/变更记录/批次明细/对比/导出/预览/历史)要求登录即可;写操作
(提交/撤回)仅管理员。写操作经 `RequestMetaDep` 带入请求元信息写入操作
日志,变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
导出为文件流(非统一信封),前端以 blob 接收。
"""
from datetime import date
from typing import Annotated
from fastapi import APIRouter, Path, Query
from fastapi.responses import StreamingResponse
from app.api.deps import AdminUser, DbSession, LoginUser, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.param import (
ParamBatchDetailOut,
ParamBatchOut,
ParamBatchQuery,
ParamCatalogItemOut,
ParamChangeBatchOut,
ParamChangeIn,
ParamDiffOut,
ParamHistoryOut,
ParamHistoryQuery,
ParamPreviewIn,
ParamPreviewOut,
)
from app.services import param_service
router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"])
@router.get(
"", response_model=ApiResponse[list[ParamCatalogItemOut]], summary="参数目录(含当前生效值)"
)
def get_catalog(
session: DbSession,
_: LoginUser,
as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None,
) -> ApiResponse[list[ParamCatalogItemOut]]:
"""全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。"""
return ok(param_service.get_catalog(session, as_of=as_of))
@router.post("/changes", response_model=ApiResponse[ParamChangeBatchOut], summary="提交变更批次")
def submit_change(
session: DbSession,
current_user: AdminUser,
payload: ParamChangeIn,
meta: RequestMetaDep,
) -> ApiResponse[ParamChangeBatchOut]:
"""提交一批参数变更(1~50 项),返回版本号;立即生效或指定日期生效。"""
return ok(
param_service.submit_change(session, payload, operator=current_user, meta=meta),
message="变更已提交",
)
@router.get(
"/changes", response_model=ApiResponse[PageResult[ParamBatchOut]], summary="变更记录分页"
)
def list_changes(
session: DbSession,
_: LoginUser,
query: Annotated[ParamBatchQuery, Query()],
) -> ApiResponse[PageResult[ParamBatchOut]]:
"""按生效日期区间分页查询变更批次(创建时间倒序)。"""
return ok(param_service.list_batches(session, query))
@router.get(
"/changes/{batch_id}", response_model=ApiResponse[ParamBatchDetailOut], summary="批次明细"
)
def get_batch(
session: DbSession,
_: LoginUser,
batch_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ParamBatchDetailOut]:
"""批次头 + 逐项旧值 → 新值。"""
return ok(param_service.get_batch_detail(session, batch_id))
@router.delete("/changes/{batch_id}", response_model=ApiResponse[None], summary="撤回未生效批次")
def withdraw_batch(
session: DbSession,
current_user: AdminUser,
batch_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""撤回尚未生效的批次;已生效批次不可撤回。"""
param_service.withdraw_batch(session, batch_id, operator=current_user, meta=meta)
return ok(message="批次已撤回")
@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比")
def compare(
session: DbSession,
_: LoginUser,
from_date: Annotated[date, Query(description="起始业务日期(含)")],
to_date: Annotated[date, Query(description="截止业务日期(含)")],
) -> ApiResponse[list[ParamDiffOut]]:
"""两个业务日期的全量参数快照逐项对比,changed 标记是否变化。"""
return ok(param_service.compare(session, from_date=from_date, to_date=to_date))
@router.get("/export", summary="导出变更记录 Excel")
def export_changes(
session: DbSession,
_: LoginUser,
start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None,
end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None,
) -> StreamingResponse:
"""变更记录导出为 Excel 文件流(非统一信封,前端以 blob 接收)。"""
content = param_service.export_changes(session, start_date=start_date, end_date=end_date)
filename = f"param_changes_{date.today():%Y%m%d}.xlsx"
return StreamingResponse(
iter([content]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览")
def preview(
session: DbSession,
_: LoginUser,
payload: ParamPreviewIn,
) -> ApiResponse[ParamPreviewOut]:
"""输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。"""
return ok(param_service.preview(session, payload))
@router.get(
"/{param_code}/history",
response_model=ApiResponse[PageResult[ParamHistoryOut]],
summary="参数版本历史",
)
def get_history(
session: DbSession,
_: LoginUser,
param_code: Annotated[str, Path(max_length=64)],
query: Annotated[ParamHistoryQuery, Query()],
) -> ApiResponse[PageResult[ParamHistoryOut]]:
"""单参数的版本历史(含版本号、变更人与旧值 → 新值),最新在前。"""
return ok(param_service.get_history(session, param_code, query))

View File

@ -0,0 +1,100 @@
"""角色管理接口(自主完善模块:需求 2.5.1 仅定义 admin / user 两个角色)。
保护规则(admin 禁删禁停、有绑定的角色禁删、role_code 不可改)见
`role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。
全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service
层写入操作日志。
"""
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.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleStatusIn, RoleUpdateIn
from app.services import role_service
router = APIRouter(prefix="/roles", tags=["系统管理 · 角色"])
@router.get("", response_model=ApiResponse[PageResult[RoleManageOut]], summary="角色列表")
def list_roles(
session: DbSession,
_: AdminUser,
query: Annotated[RoleQuery, Query()],
) -> ApiResponse[PageResult[RoleManageOut]]:
"""分页查询角色。
与用户表单的角色下拉口径不同:**本列表含停用角色**(下拉只给启用项)。
每行附带 `user_count`(绑定用户数),供前端提示删除保护。
"""
return ok(role_service.list_roles(session, query))
@router.post("", response_model=ApiResponse[RoleManageOut], summary="新增角色")
def create_role(
session: DbSession,
current_user: AdminUser,
payload: RoleCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[RoleManageOut]:
"""新增角色。编码全局唯一、创建后不可修改;新角色默认启用。"""
return ok(
role_service.create_role(session, payload, operator=current_user, meta=meta),
message="角色已创建",
)
@router.put("/{role_id}", response_model=ApiResponse[RoleManageOut], summary="编辑角色")
def update_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: RoleUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[RoleManageOut]:
"""编辑角色名称/说明/排序。
**不含 role_code** —— 编码是授权判定(`ROLE_ADMIN` 常量)的锚点。
"""
return ok(
role_service.update_role(session, role_id, payload, operator=current_user, meta=meta),
message="已保存",
)
@router.put("/{role_id}/status", response_model=ApiResponse[RoleManageOut], summary="启用/禁用角色")
def set_status(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: RoleStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[RoleManageOut]:
"""启用或禁用角色。
停用**不解除**用户绑定、也不影响既有授权(授权判定只看编码、不看状态);
内置管理员角色不可停用。
"""
result = role_service.set_status(
session, role_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除角色")
def delete_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除角色。
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)——
`user_roles` 外键是 CASCADE,后端拦截是防止静默剥离权限的唯一防线。
"""
role_service.delete_role(session, role_id, operator=current_user, meta=meta)
return ok(message="角色已删除")

View File

@ -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"},
)

View File

@ -0,0 +1,146 @@
"""用户管理接口(需求 2.5.1:用户账号管理)。
全部要求管理员权限 —— 按需求「系统管理员(全部权限)、普通用户(业务操作)」,
用户管理属管理员专属。
删除、禁用、改角色三处都有**自我保护**:不允许对当前登录账号执行,
否则管理员误操作会立刻失去全部管理权限且无法自行恢复。
全部写操作经 `RequestMetaDep` 带入请求元信息后由 service 层写入操作日志
(成功与失败都记),操作人为当前登录管理员。
"""
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.role import RoleOut
from app.schemas.user import (
PasswordResetIn,
UserCreateIn,
UserOut,
UserQuery,
UserStatusIn,
UserUpdateIn,
)
from app.services import user_service
router = APIRouter(prefix="/users", tags=["系统管理 · 用户"])
@router.get("", response_model=ApiResponse[PageResult[UserOut]], summary="用户列表")
def list_users(
session: DbSession,
_: AdminUser,
query: Annotated[UserQuery, Query()],
) -> ApiResponse[PageResult[UserOut]]:
"""分页查询用户,支持按用户名/姓名模糊、状态与角色筛选。"""
return ok(user_service.list_users(session, query))
@router.get("/{user_id}", response_model=ApiResponse[UserOut], summary="用户详情")
def get_user(
session: DbSession,
_: AdminUser,
user_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[UserOut]:
"""查询用户详情。"""
return ok(user_service.get_user(session, user_id))
@router.post("", response_model=ApiResponse[UserOut], summary="新增用户")
def create_user(
session: DbSession,
current_user: AdminUser,
payload: UserCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[UserOut]:
"""新增用户。用户名须唯一,至少指定一个角色。"""
return ok(
user_service.create_user(session, payload, operator=current_user, meta=meta),
message="用户已创建",
)
@router.put("/{user_id}", response_model=ApiResponse[UserOut], summary="编辑用户")
def update_user(
session: DbSession,
current_user: AdminUser,
user_id: Annotated[int, Path(ge=1)],
payload: UserUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[UserOut]:
"""编辑用户的基本信息与角色。
**不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口。
"""
return ok(
user_service.update_user(session, user_id, payload, operator=current_user, meta=meta),
message="已保存",
)
@router.delete("/{user_id}", response_model=ApiResponse[None], summary="删除用户")
def delete_user(
session: DbSession,
current_user: AdminUser,
user_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除用户。
物理删除。历史操作日志通过 `user_id` 置空 + 冗余用户名保留可追溯性。
业务上更推荐用「禁用」代替删除 —— 禁用保留完整审计线索。
"""
user_service.delete_user(session, user_id, operator=current_user, meta=meta)
return ok(message="用户已删除")
@router.put("/{user_id}/password", response_model=ApiResponse[None], summary="重置密码")
def reset_password(
session: DbSession,
current_user: AdminUser,
user_id: Annotated[int, Path(ge=1)],
payload: PasswordResetIn,
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""重置用户密码。
会同时提升该用户的令牌版本,使其**已签发的令牌立即失效** ——
否则改密后旧令牌在有效期内仍可用。
"""
user_service.reset_password(
session, user_id, payload.new_password, operator=current_user, meta=meta
)
return ok(message="密码已重置,该用户需重新登录")
@router.put("/{user_id}/status", response_model=ApiResponse[UserOut], summary="启用/禁用用户")
def set_status(
session: DbSession,
current_user: AdminUser,
user_id: Annotated[int, Path(ge=1)],
payload: UserStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[UserOut]:
"""启用或禁用用户。
禁用会提升令牌版本使已签发令牌立即失效,否则被禁用者仍可凭旧令牌
继续访问直到过期(最长 2 小时)。
"""
result = user_service.set_status(
session, user_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
@router.get("/meta/roles", response_model=ApiResponse[list[RoleOut]], summary="角色下拉选项")
def list_roles(session: DbSession, _: AdminUser) -> ApiResponse[list[RoleOut]]:
"""取全部启用角色,供用户表单的角色选择使用。
路径放在 `/users/meta/roles` 而非 `/roles`,是为了避免与
`/{user_id}` 的路由匹配冲突(`roles` 会被当成 user_id 解析而报 422)。
"""
return ok(user_service.list_roles(session))

View File

@ -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

View File

@ -92,6 +92,15 @@ 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 upload_dir(self) -> Path:
"""上传文件根目录(公司 logo 等):backend/uploads/。
不叫 static/ 是刻意的:静态目录将来可能整体交给前端构建产物,
运行期上传物必须与之隔离;该目录不入版本库(.gitignore)。
"""
return BACKEND_DIR / "uploads"
@lru_cache
def get_settings() -> Settings:

View File

@ -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」,
@ -36,3 +49,61 @@ PASSWORD_MAX_BYTES = 72
# 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
CAPTCHA_LENGTH = 4
# ===== 业务岗位编码(与 db/migration 种子一致,business_roles.role_code)=====
BUSINESS_ROLE_SALESPERSON = "salesperson"
BUSINESS_ROLE_SALES_MANAGER = "sales_manager"
BUSINESS_ROLE_INFO_OFFICER = "info_officer"
# ===== 产品类别码(与 param_values.dimension_value 口径一致)=====
PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water"
PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT}
# ===== 客户分类码(customers.customer_type,计提规则匹配锚点)=====
CUSTOMER_TYPE_PERSONAL = "personal"
CUSTOMER_TYPE_MANAGER_PERSONAL = "manager_personal"
CUSTOMER_TYPE_ENTERPRISE = "enterprise"
CUSTOMER_TYPES = {
CUSTOMER_TYPE_PERSONAL,
CUSTOMER_TYPE_MANAGER_PERSONAL,
CUSTOMER_TYPE_ENTERPRISE,
}
# ===== 操作日志词表(operation_logs 的 module / action 取值)=====
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
MODULE_USER = "用户管理"
MODULE_ROLE = "角色管理"
MODULE_BUSINESS_ROLE = "岗位角色"
MODULE_PARAM = "计提参数"
MODULE_PERSONNEL = "人员管理"
MODULE_PRODUCT = "产品管理"
MODULE_CUSTOMER = "客户管理"
MODULE_BUSINESS = "业务管理"
MODULE_SHIPMENT = "发货管理"
MODULE_RECEIPT = "回款管理"
MODULE_RATING = "客户评级"
MODULE_SYSTEM_SETTINGS = "系统设置"
MODULE_APPROVAL = "审批管理"
MODULE_CALC = "绩效核算"
MODULE_SETTLEMENT = "月度结算"
MODULE_REPORT = "报表统计"
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
NO_PREFIX_BUSINESS = "YW"
NO_PREFIX_SHIPMENT = "SH"
NO_PREFIX_RECEIPT = "HK"
NO_PREFIX_PARAM = "P"
NO_PREFIX_APPROVAL = "AP"
NO_PREFIX_ADJUSTMENT = "TZ"
ACTION_CREATE = "新增"
ACTION_UPDATE = "修改"
ACTION_DELETE = "删除"
ACTION_RESET_PASSWORD = "重置密码"
ACTION_ENABLE = "启用"
ACTION_DISABLE = "禁用"
ACTION_APPROVE = "审批通过"
ACTION_REJECT = "审批驳回"
ACTION_EXPORT = "导出"

View File

@ -0,0 +1,18 @@
"""数据库完整性错误的统一识别与翻译口径。
判断依据:pymysql 原生异常 ``orig.args`` 首位是 MySQL 错误码 —— 只有
1062(ER_DUP_ENTRY,唯一键冲突)是「已存在」语义,可安全转 400 业务
提示;其余完整性错误(外键 RESTRICT、数据截断等)原样上抛,由全局
异常处理器收敛 500(程序缺陷或数据异常,均不应伪装成用户错误)。
"""
from sqlalchemy.exc import IntegrityError
# MySQL 唯一键冲突错误码(pymysql 原生异常 args 首位)
ERR_DUP_ENTRY = 1062
def is_duplicate_entry(exc: IntegrityError) -> bool:
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(错误码 1062)。"""
args = getattr(exc.orig, "args", ())
return bool(args) and args[0] == ERR_DUP_ENTRY

View File

@ -12,6 +12,7 @@ import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
@ -60,6 +61,19 @@ async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSON
return _build_response(exc.status_code, message)
async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONResponse:
"""完整性错误全局兜底为 400。
service 层已判别的 1062/引用冲突会自行翻译为业务提示,不会走到这里;
本 handler 拦截的是「新增父表后某 service 漏补引用保护」一类的遗漏
(e2e 撞见一个补一个的模式无法保证全覆盖):FK RESTRICT 等数据完整性
冲突对用户表现为「这行数据被别处引用/冲突」,转 400 比 500 更贴近事实。
完整异常记录日志供排查;不在此处 rollback —— 会话由请求级依赖统一关闭。
"""
logger.error("数据库完整性冲突(未在 service 层翻译)", exc_info=exc)
return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝")
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
"""兜底处理未预期异常。
@ -77,4 +91,5 @@ def register_exception_handlers(app: FastAPI) -> None:
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
app.add_exception_handler(IntegrityError, handle_integrity_error) # type: ignore[arg-type]
app.add_exception_handler(Exception, handle_unexpected_exception)

View File

@ -29,8 +29,8 @@ _SENSITIVE_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
),
# Authorization: Bearer xxx
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"),
# 手机号:138****8888
(re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"),
# 手机号:138****8888(兼容带空格/横线分隔的写法,如 138 0013 8000)
(re.compile(r"\b(1[3-9]\d)[-\s]?(\d{4})[-\s]?(\d{4})\b"), r"\1****\3"),
# 18 位身份证:保留前 6 位与后 4 位
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
)

View File

@ -0,0 +1,76 @@
"""报表统计元数据(区间桶、中文标签、导出上限)——叶子模块。
刻意不导入 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": "混合业务",
}
REPORT_TEMPLATE_VERSION = "V1.0"
# 导出超限即拒绝(需求「大数据量分批/超限提示」二选一取后者):普通
# 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")

View File

@ -9,17 +9,57 @@
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
"""
from app.models.approval import ApprovalOrder, ApprovalStep
from app.models.base import Base, TimestampMixin
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
from app.models.business_role import BusinessRole
from app.models.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.user import User, UserRole
__all__ = [
"AdjustmentOrder",
"ApprovalOrder",
"ApprovalStep",
"Base",
"Business",
"BusinessItem",
"BusinessRole",
"BusinessStatusLog",
"Customer",
"CustomerRatingGrade",
"LoginLog",
"MonthlySettlement",
"OperationLog",
"OwnershipChangeLog",
"ParamChangeBatch",
"ParamDefinition",
"ParamValue",
"PerformanceLedger",
"Permission",
"Personnel",
"PersonnelPlan",
"PersonnelRegion",
"Product",
"Receipt",
"Role",
"RolePermission",
"SecondaryAllocation",
"SettlementItem",
"Shipment",
"ShipmentItem",
"SystemSetting",
"TimestampMixin",
"User",
"UserRole",

View File

@ -0,0 +1,133 @@
"""审批模型:审批单与节点记录(2026-10-15 复核审批补充需求)。
三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数体系开关驱动
(`approval.*.enabled` BOOL):关闭=提交即生成「自动通过」节点留痕,
留痕不断链(开发计划 3.2(三));开启=按节点人工审核,一驳全驳。
依赖说明:节点准入按登录角色编码判定(ROLE_FINANCE/ROLE_LEADER),
不动 `permissions`/`role_permissions` 闲置表 —— 细粒度权限清单待业务方
答复(待澄清 #6)后再激活。
ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=2
(月结预留)将指向 ④ 尚未落地的表,外键无法统一指向;单据删除保护由
服务层承担(详见 approval_service)。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
# 审批单状态(approval_orders.status;流转机见 approval_service)
APPROVAL_STATUS_PENDING_FINANCE = 1 # 待财务复核
APPROVAL_STATUS_PENDING_LEADER = 2 # 待领导审核
APPROVAL_STATUS_APPROVED = 3 # 已通过(终态)
APPROVAL_STATUS_REJECTED = 4 # 已驳回(终态)
APPROVAL_STATUS_WITHDRAWN = 5 # 已撤回(被引用方重新提交时旧在途单注销)
# 节点(approval_steps.node;0 仅用于撤回留痕)
APPROVAL_NODE_FINANCE = 1
APPROVAL_NODE_LEADER = 2
APPROVAL_NODE_WITHDRAW = 0
APPROVAL_NODES = {
APPROVAL_STATUS_PENDING_FINANCE: APPROVAL_NODE_FINANCE,
APPROVAL_STATUS_PENDING_LEADER: APPROVAL_NODE_LEADER,
}
# 动作(approval_steps.action)
APPROVAL_ACTION_APPROVE = 1 # 人工通过
APPROVAL_ACTION_REJECT = 2 # 人工驳回
APPROVAL_ACTION_AUTO_APPROVE = 3 # 系统自动通过(开关关闭时逐节点留痕)
APPROVAL_ACTION_WITHDRAW = 4 # 撤回(业务重新提交时旧在途单注销)
# 审批对象类型(approval_orders.order_type)
APPROVAL_TYPE_SECONDARY_ALLOCATION = 1
APPROVAL_TYPE_MONTHLY_SETTLEMENT = 2
APPROVAL_TYPE_ADJUSTMENT = 3
class ApprovalOrder(Base, TimestampMixin):
"""审批单:一张待审对象一条审批流(状态可流转,含 update_time)。"""
__tablename__ = "approval_orders"
__table_args__ = mysql_table_args(
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
Index("idx_approval_orders_type_status", "order_type", "status"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
order_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
)
order_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="审批对象类型:1二次分配方案 2月度结算 3调整单"
)
ref_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)",
)
refer_no: Mapped[str] = mapped_column(
String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回",
)
submitter_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="提交人ID(账号删除后置空)",
)
submitter_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="提交人用户名(冗余固化)"
)
class ApprovalStep(Base):
"""审批节点记录表(append-only):每次动作只增不改,构成时间线。
不继承 TimestampMixin(留痕无更新语义,同 business_status_logs 先例)。
"""
__tablename__ = "approval_steps"
__table_args__ = mysql_table_args(
"审批节点记录表(append-only;自动通过/撤回均有留痕)",
Index("idx_approval_steps_order_id", "order_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
order_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("approval_orders.id", ondelete="CASCADE"),
nullable=False,
comment="审批单ID(approval_orders.id;单删记录随删)",
)
node: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="审批节点:0撤回留痕 1财务复核 2领导审核"
)
action: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回"
)
opinion: Mapped[str | None] = mapped_column(String(255), comment="审批意见(驳回时填写)")
operator_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="操作人ID(自动通过/系统动作置空)",
)
operator_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="操作人用户名(冗余固化;系统动作记「系统」)"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)

View File

@ -7,7 +7,7 @@
from datetime import datetime
from sqlalchemy import DateTime, MetaData, text
from sqlalchemy import DateTime, MetaData, String, text
from sqlalchemy.dialects.mysql import BIGINT, TINYINT
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@ -28,7 +28,14 @@ BIGINT_UNSIGNED = BIGINT(unsigned=True)
# 状态类字段统一用 TINYINT(0/1 或少量枚举值)
TINYINT_TYPE = TINYINT
__all__ = ["BIGINT_UNSIGNED", "TINYINT_TYPE", "Base", "TimestampMixin", "mysql_table_args"]
__all__ = [
"BIGINT_UNSIGNED",
"TINYINT_TYPE",
"Base",
"TPlusSyncMixin",
"TimestampMixin",
"mysql_table_args",
]
class Base(DeclarativeBase):
@ -62,6 +69,30 @@ class TimestampMixin:
)
class TPlusSyncMixin:
"""T+(用友)数据同步预留列,六张业务主表共用。
对照表 #26:T+ 对接的字段映射与幂等键语义未定(IT 牵头),故四列全部
可空、不设唯一键 —— 键语义未定就锁死唯一约束,对接时大概率返工;
source_no 先建普通索引承担查询与去重,IT 定键后以小迁移升级唯一约束。
同步守则(M4 同步模块必须遵守):同步只写 T+ 侧字段与本组列,
**不覆盖人工维护字段**(岗位/工资标准/归属/客户分类/业务状态等,
各表注释已标注)。同步频率:一周一次定时 + 手动触发(对照表 #10)。
"""
source_no: Mapped[str | None] = mapped_column(
String(64),
index=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
)
t_plus_id: Mapped[str | None] = mapped_column(String(64), comment="T+ 系统内部标识")
sync_batch_no: Mapped[str | None] = mapped_column(
String(32), comment="同步批次号(同步模块写入)"
)
synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间")
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
"""构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。

View File

@ -0,0 +1,275 @@
"""业务数据模型:业务单、产品明细与状态流转留痕。
- 汇总冗余列(total_* 五列)由服务层在明细/发货/回款增删改时**事务内单点
重算**,禁手工直改 —— 列表页与 M3 起付判定直接读冗余列,避免每次 JOIN
聚合;代价是所有写路径必须重算(business_service 统一收口)
- 毛利润相关值(毛利润额/毛利润率/折算毛利润额)**不落库**:它们是
sale_amount/cost_amount + 时点化阈值参数的派生值,落库会在成本调整或
参数改版后漂移出「假快照」;M2 以只读毛利润视图实时计算(复用
formula.profit_rate/converted_profit),M3 核算台账才是法定固化点
- product_category 存在明细行级:混合业务按产品逐条计算(对照表 #14)
- 运费仅记录展示,不计入毛利润口径(对照表 #2);协助营销人员一期核算
不使用(对照表 #15 剔除协助 25% 分成),字段保留仅记录事实(需规 2.2.1)
"""
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
# 业务状态(businesses.status,流转规则见 business_service:终态不可变更)
BUSINESS_STATUS_ACTIVE = 1
BUSINESS_STATUS_DONE = 2
BUSINESS_STATUS_TERMINATED = 3
class SecondaryAllocation(Base, TimestampMixin):
"""二次分配方案明细表(2026-10-15 补充需求「二次分配方案」)。
口径(AI_CONTEXT 待澄清 #1/#2 未答复前的边界):
- 只出现在合同刚建立时;修改在首次月结前留痕放行,月结后只读(月结在 ④,
终态门槛先行承担「修改不影响已结算月份」)
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
与比例,响应以 hint 提示合计/余额,不做强制校验
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
- **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按
核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id),
同一合同的同一参与人仅一行,重复提交在服务层聚合。
"""
__tablename__ = "business_secondary_allocations"
__table_args__ = mysql_table_args(
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
"业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
UniqueConstraint(
"business_id",
"personnel_id",
name="uk_business_secondary_allocations_business_personnel",
),
Index("idx_business_secondary_allocations_business_id", "business_id"),
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("businesses.id", ondelete="CASCADE"),
nullable=False,
comment="业务ID(businesses.id;业务删除随删)",
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
comment="参与业务员ID(personnel.id;被方案引用禁删)",
)
personnel_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="参与业务员姓名(冗余固化,离职后回显有名)"
)
ratio: Mapped[Decimal] = mapped_column(
Numeric(5, 2),
nullable=False,
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
)
class Business(Base, TimestampMixin, TPlusSyncMixin):
"""业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。"""
__tablename__ = "businesses"
__table_args__ = mysql_table_args(
"业务表(业务编号自动生成;T+同步不覆盖业务状态)",
Index("idx_businesses_customer_id", "customer_id"),
Index("idx_businesses_owner_personnel_id", "owner_personnel_id"),
Index("idx_businesses_status", "status"),
Index("idx_businesses_business_date", "business_date"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
business_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)",
)
customer_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("customers.id", ondelete="RESTRICT"),
nullable=False,
comment="客户ID(customers.id)",
)
business_date: Mapped[date | None] = mapped_column(Date, comment="业务日期(T+ 同步口径)")
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)",
)
owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)",
)
assistant_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)",
)
is_special: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="特殊业务标记:1是 0否(公司参与等)",
)
special_note: Mapped[str | None] = mapped_column(String(255), comment="特殊业务说明")
total_sale_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)",
)
total_cost_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)",
)
total_ship_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)",
)
total_received_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)",
)
total_freight_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
server_default="0",
comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class BusinessItem(Base, TimestampMixin):
"""业务产品明细表(需求 2.2.1/2.2.4):毛利润的行级源数据。
product_id 可空=自由录入产品(配合「数据提取」一键入产品库);
产品名称/规格冗余固化,防产品改名影响历史业务。
sale_amount 由服务层按 数量×单价 计算固化,不信任前端传值。
cost_amount 来源待 T+ 对接确认(可空,人工可录先行)。
"""
__tablename__ = "business_items"
__table_args__ = mysql_table_args(
"业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)",
Index("idx_business_items_business_id", "business_id"),
Index("idx_business_items_product_id", "product_id"),
Index("idx_business_items_product_category", "product_category"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("businesses.id", ondelete="RESTRICT"),
nullable=False,
comment="业务ID(businesses.id)",
)
product_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("products.id", ondelete="RESTRICT"),
comment="产品ID(products.id;NULL=自由录入,未入产品库)",
)
product_name: Mapped[str] = mapped_column(
String(128), nullable=False, comment="产品名称(引用产品时冗余固化)"
)
spec_model: Mapped[str] = mapped_column(
String(128),
nullable=False,
server_default="''",
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
)
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
product_category: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
)
quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)")
sale_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="销售单价(元;T+ 同步或人工)"
)
sale_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)",
)
cost_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2),
comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)",
)
guided_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class BusinessStatusLog(Base):
"""业务状态变更记录表(append-only,需求 2.2.1「支持状态变更记录」)。
不继承 TimestampMixin(留痕无更新语义);business_id 为无外键的固化 ID
(businesses 建表在本表之后的迁移批次内 —— 实为同批,但留痕统一口径:
被引用方删除后记录仍完整可读,删除保护由服务层检查承担)。
首条记录在业务创建时写入(old=new=1 执行中),详情页时间线从创建起展示。
"""
__tablename__ = "business_status_logs"
__table_args__ = mysql_table_args(
"业务状态变更记录表(append-only)",
Index("idx_business_status_logs_business_id", "business_id"),
Index("idx_business_status_logs_create_time", "create_time"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
comment="业务ID(固化,无外键;删除保护由服务层承担)",
)
old_status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="变更前状态(创建首条为 1)"
)
new_status: Mapped[int] = mapped_column(TINYINT_TYPE, nullable=False, comment="变更后状态")
change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因(终止时必填)")
operator_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="操作人ID(账号删除后置空)",
)
operator_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="操作人用户名(冗余固化)"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)

View File

@ -0,0 +1,46 @@
"""业务岗位角色模型。
与系统登录角色(`roles`,权限维度)严格区分:本表是**业务岗位**维度,
决定人员适用的计提公式模板与参数。需求 #28 要求岗位角色可增删、
新增岗位时在系统设置中绑定公式与参数(无需改代码),故模板编码
存于本表、公式实现留在代码内注册表(一期不引入规则引擎)。
"""
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
class BusinessRole(Base, TimestampMixin):
"""业务岗位角色表。内置三个岗位(营销员/营销经理/信息员)随迁移初始化。"""
__tablename__ = "business_roles"
__table_args__ = mysql_table_args("业务岗位角色表(与登录角色roles区分,决定计提公式与参数)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
role_code: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改",
)
role_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="岗位名称")
calc_template: Mapped[str] = mapped_column(
String(32), nullable=False, comment="计提公式模板编码(以代码内模板注册表为准)"
)
description: Mapped[str | None] = mapped_column(String(255), comment="岗位说明")
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0", comment="显示排序"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用"
)
is_builtin: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="是否内置岗位:1内置(禁删禁停) 0自定义",
)

View File

@ -0,0 +1,121 @@
"""客户模型:客户档案与归属变更留痕。
- 「公司名称 + 负责人」是业务唯一标识(需规 2.1.3);负责人是客户公司的
负责人**人名**(字符串),不是系统内的营销人员 —— 系统内归属另用
owner_personnel_id 外键表达,两者语义不同
- 归属变更只改主表当前值 + 插一条留痕,**不回溯历史**(对照表 #19):
历史核算结果在 M3 台账已固化,无需 effective_date 时点化设计
- 留痕表(OwnershipChangeLog)对客户/业务/人员 ID 均为**无外键的冗余固化**:
留痕是历史记录,被引用方被删除后记录仍须完整可读;RESTRICT 外键反而会
把「删除客户」逼成 500。业务实体删除保护由服务层检查承担
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
# 归属类型(ownership_change_logs.owner_type)
OWNER_TYPE_CUSTOMER = 1
OWNER_TYPE_BUSINESS = 2
class Customer(Base, TimestampMixin, TPlusSyncMixin):
"""客户表(需求 2.1.3)。customer_type 是计提规则匹配锚点(M3 消费)。"""
__tablename__ = "customers"
__table_args__ = mysql_table_args(
"客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)",
UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
company_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="公司名称")
owner_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="客户负责人姓名(客户公司负责人的人名,非系统人员)"
)
contact_person: Mapped[str | None] = mapped_column(String(64), comment="联系人")
contact_phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
address: Mapped[str | None] = mapped_column(String(255), comment="地址")
industry: Mapped[str | None] = mapped_column(String(64), comment="所属行业(自由文本)")
customer_type: Mapped[str] = mapped_column(
String(32),
nullable=False,
server_default="personal",
index=True,
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
)
owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
index=True,
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class OwnershipChangeLog(Base):
"""归属变更记录表(append-only,需求 2.1.3 + 对照表 #19)。
owner_type 区分客户归属/业务归属:1 → customer_id 非空,2 → business_id
非空,由应用层校验(无外键,见模块 docstring 的固化论证)。
前后值冗余固化(无外键):人员被删除后留痕仍可读。
"""
__tablename__ = "ownership_change_logs"
__table_args__ = mysql_table_args(
"归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)",
Index("idx_ownership_change_logs_customer_id", "customer_id"),
Index("idx_ownership_change_logs_business_id", "business_id"),
Index("idx_ownership_change_logs_create_time", "create_time"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
owner_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="归属类型:1客户归属 2业务归属"
)
customer_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
comment="客户ID(固化,owner_type=1 时非空)",
)
business_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
comment="业务ID(固化,owner_type=2 时非空)",
)
old_owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更前负责营销人员ID(固化)"
)
new_owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更后负责营销人员ID(固化)"
)
old_assistant_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更前协助营销人员ID(固化,仅业务归属使用)"
)
new_assistant_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更后协助营销人员ID(固化,仅业务归属使用)"
)
change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因")
operator_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="操作人ID(账号删除后置空)",
)
operator_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="操作人用户名(冗余固化)"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)

View File

@ -0,0 +1,99 @@
"""操作日志模型。
需求 2.5.1 要求「记录用户登录、操作行为及计提参数/系数表变更,支持日志查询」。
登录行为已在 `login_logs` 中单独记录(登录失败也要留痕,且不依赖认证上下文);
本表记录**已认证用户的操作行为**,两者分工明确、互不重叠。
不继承 TimestampMixin:日志是追加写、不可变的,没有"更新时间"语义。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, mysql_table_args
# 各文本列的上限。**写入方必须按此截断**,否则 MySQL 严格模式抛 1406
# (Data too long),而按"审计失败不得影响业务"的约定该异常会被吞掉 ——
# 结果是整条日志静默丢失。截断值集中定义于此,供 repository 层引用。
USERNAME_MAX_LEN = 64
MODULE_MAX_LEN = 64
ACTION_MAX_LEN = 32
SUMMARY_MAX_LEN = 255
METHOD_MAX_LEN = 8
PATH_MAX_LEN = 255
IP_MAX_LEN = 45
USER_AGENT_MAX_LEN = 255
ERROR_MESSAGE_MAX_LEN = 500
# request_data 是 MySQL TEXT(上限 65535 字节)。脱敏后的 JSON 可能超限
# (如批量操作提交数千个 ID),超限同样会抛 1406,故写入方需按字节截断。
REQUEST_DATA_MAX_BYTES = 60000
class OperationLog(Base):
"""操作日志表。
记录原则与 `login_logs` 一致:
- **请求参数必须脱敏后入库**(复用 `core.logging.scrub`),日志是最容易泄漏
密码与令牌的地方
- 用户名冗余存储而非只存 user_id —— 账号被删除后仍能追溯操作者,
故 `username` 单独建索引(与 `login_logs` 保持一致)
- 写入失败不得影响业务(审计可丢,业务不可断)
"""
__tablename__ = "operation_logs"
__table_args__ = mysql_table_args(
"操作日志表",
Index("idx_operation_logs_user_id", "user_id"),
# 账号删除后 user_id 被置空,只能按用户名追溯,故该列必须有索引,
# 否则日志表增长后查询退化为全表扫描
Index("idx_operation_logs_username", "username"),
Index("idx_operation_logs_module", "module"),
Index("idx_operation_logs_create_time", "create_time"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
user_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="操作人ID(账号删除后置空)",
)
username: Mapped[str] = mapped_column(
String(USERNAME_MAX_LEN), nullable=False, comment="操作人用户名(冗余存储便于追溯)"
)
module: Mapped[str] = mapped_column(
String(MODULE_MAX_LEN), nullable=False, comment="所属模块,如 用户管理"
)
action: Mapped[str] = mapped_column(
String(ACTION_MAX_LEN), nullable=False, comment="操作类型:新增/修改/删除/导出"
)
summary: Mapped[str] = mapped_column(
String(SUMMARY_MAX_LEN), nullable=False, comment="操作摘要(中文,供人阅读)"
)
method: Mapped[str | None] = mapped_column(String(METHOD_MAX_LEN), comment="HTTP 方法")
path: Mapped[str | None] = mapped_column(String(PATH_MAX_LEN), comment="请求路径")
ip: Mapped[str | None] = mapped_column(String(IP_MAX_LEN), comment="客户端IP(45位兼容IPv6)")
user_agent: Mapped[str | None] = mapped_column(
String(USER_AGENT_MAX_LEN), comment="客户端User-Agent(截断存储)"
)
# 请求参数,**入库前已脱敏**;长文本故用 TEXT
request_data: Mapped[str | None] = mapped_column(Text, comment="请求参数(已脱敏)")
# 默认 0 而非 1:与 login_logs 保持一致,且方向更保守 ——
# 写入方漏传时记为「失败」,管理员会去核查;反之漏传记为「成功」则是
# 审计漏报,且无声无息。写入方应显式传值(见 repository 层)。
success: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="0", comment="是否成功:1成功 0失败"
)
error_message: Mapped[str | None] = mapped_column(
String(ERROR_MESSAGE_MAX_LEN), comment="失败原因(写入方须截断)"
)
duration_ms: Mapped[int | None] = mapped_column(Integer, comment="处理耗时(毫秒)")
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)

159
backend/app/models/param.py Normal file
View File

@ -0,0 +1,159 @@
"""计提参数模型:定义、变更批次与版本条目。
存储设计(M1 评审定稿):
- 参数值按**生效日期时点化**存储(逐项版本条目,append-only),核算按业务
日期回溯取值(对照表 #11:按回款到账日匹配参数版本),禁读「当前值」算历史
- 「版本」由变更批次承载:一次保存 = 一个批次 = 一条变更记录;条目逐项留痕
(旧值/新值),满足 #28 版本管理与留痕(需规 2.3.1 四)
- 值一律为 Fraction 可解析字符串(如 "4/360",禁 0.011111),解析不经 float
- **不设 (param_id, dimension_value, effective_date) 唯一键**:同日多次修正
是合法留痕,解析取 (effective_date, id) 最新一条;唯一键会把同日二次变更
逼成覆盖写、丢失中间值
- 批次与条目均为 append-only(仅 create_time、无 update_time,同 login_logs
先例):带 ON UPDATE 的列会让「未改也被刷新」造成留痕假象
"""
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
class ParamDefinition(Base, TimestampMixin):
"""计提参数定义表。目录元数据(类型/单位/维度/取值域),随迁移种子初始化。"""
__tablename__ = "param_definitions"
__table_args__ = mysql_table_args("计提参数定义表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
param_code: Mapped[str] = mapped_column(
String(64), nullable=False, unique=True, comment="参数编码,如 salesperson.rate.slope"
)
param_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="参数名称")
param_group: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径",
)
value_type: Mapped[str] = mapped_column(
String(16), nullable=False, comment="值类型:DECIMAL/INT/BOOL/ENUM/TEXT"
)
unit: Mapped[str | None] = mapped_column(String(16), comment="展示单位:‰/%/元/天,无单位为空")
dimension_type: Mapped[str] = mapped_column(
String(32),
nullable=False,
server_default="NONE",
comment="维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE",
)
options_text: Mapped[str | None] = mapped_column(
String(255), comment="枚举选项(竖线分隔),仅 ENUM 使用"
)
default_value: Mapped[str | None] = mapped_column(
String(64), comment="系统默认值(Fraction 可解析字符串)"
)
value_min: Mapped[str | None] = mapped_column(String(64), comment="取值下限(含),校验用")
value_max: Mapped[str | None] = mapped_column(String(64), comment="取值上限(含),校验用")
description: Mapped[str | None] = mapped_column(String(255), comment="口径说明(配置页展示)")
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0", comment="显示排序"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
)
class ParamChangeBatch(Base):
"""参数变更批次表(append-only)。
一次提交 = 一个批次;版本号(如 P20261012-01)供对比与追溯引用。
未生效批次可整批撤回(删批次与条目);已生效条目禁改禁删。
"""
__tablename__ = "param_change_batches"
__table_args__ = mysql_table_args("参数变更批次表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
version_no: Mapped[str] = mapped_column(
String(32), nullable=False, unique=True, comment="版本号,如 P20261012-01"
)
effective_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="生效日期(业务日期口径)"
)
change_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="生效方式:1立即生效 2指定日期生效"
)
remark: 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="操作人用户名(冗余固化)"
)
item_count: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0", comment="变更参数项数"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)
class ParamValue(Base):
"""参数版本条目表(append-only)。
解析规则:按 (param_id, dimension_value) 取 effective_date <= 业务日期 的
ORDER BY effective_date DESC, id DESC 首条(同日多次变更取最新)。
dimension_value 用空串而非 NULL:MySQL 组合索引对 NULL 不去重,空串语义更稳。
"""
__tablename__ = "param_values"
__table_args__ = mysql_table_args(
"参数版本条目表(append-only,按生效日期时点化取值)",
Index(
"idx_param_values_param_id_dimension_value_effective_date",
"param_id",
"dimension_value",
"effective_date",
),
Index("idx_param_values_effective_date", "effective_date"),
Index("idx_param_values_batch_id", "batch_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
param_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("param_definitions.id", ondelete="RESTRICT"),
nullable=False,
comment="参数定义ID(param_definitions.id)",
)
dimension_value: Mapped[str] = mapped_column(
String(32),
nullable=False,
server_default=text("''"),
comment="维度取值:NONE维度为空串;产品类别/岗位编码为具体值",
)
effective_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="生效日期(按业务日期时点化取值)"
)
value_text: Mapped[str] = mapped_column(
String(64), nullable=False, comment="参数值(Fraction 可解析字符串/枚举码/开关)"
)
old_value_text: Mapped[str | None] = mapped_column(String(64), comment="变更前值(留痕)")
batch_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("param_change_batches.id", ondelete="RESTRICT"),
nullable=False,
comment="变更批次ID(param_change_batches.id)",
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)

View File

@ -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="备注")

View File

@ -0,0 +1,127 @@
"""营销人员模型:人员档案、产品辐射区域与市场拓展计划。
- 岗位(business_role_id)决定计提公式模板,与工资标准同属「同步不覆盖」的
人工维护字段(对照表 #26 落地口径,表注释标注供 M4 同步模块遵守)
- manager_id 自引用表达「经理带团队」:NULL=经理本人或未分配;M3 经理
12.5% 聚合、M4 部门汇总均按该关系展开,无需独立团队表
- 一期营销人员不登录系统,不与 users 关联;若后续开放自助查工资再加列
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
# personnel.status 的在职值(对照 BUSINESS_STATUS_* 集中在 models/business.py
# 的先例:状态语义单点导出,业务/客户两侧的归属校验共用,防魔数各自漂移)
PERSONNEL_STATUS_ACTIVE = 1
class Personnel(Base, TimestampMixin, TPlusSyncMixin):
"""营销人员表(需求 2.1.1)。
表名 personnel 为集合名词(单复数同形),本身即复数语义。
试用期人员绩效照算不发放(对照表 #17),is_probation 供 M3 区分。
"""
__tablename__ = "personnel"
__table_args__ = mysql_table_args("营销人员表(T+同步不覆盖岗位与工资标准)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
personnel_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="姓名")
business_role_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("business_roles.id", ondelete="RESTRICT"),
nullable=False,
index=True,
comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)",
)
manager_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
index=True,
comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)",
)
phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
hire_date: Mapped[date | None] = mapped_column(Date, comment="入职日期")
is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="是否试用期:1是 0否(试用期绩效照算不发放)",
)
salary_standard: Mapped[Decimal | None] = mapped_column(
Numeric(12, 2), comment="月工资标准(元;T+同步不覆盖)"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class PersonnelRegion(Base, TimestampMixin):
"""产品辐射区域表(需求 2.1.1):人员负责的产品辐射地理范围,1:N 子表。
随人员编辑整表替换维护,无独立管理诉求,故不加 T+ 预留列。
"""
__tablename__ = "personnel_regions"
__table_args__ = mysql_table_args("产品辐射区域表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
index=True,
comment="人员ID(personnel.id)",
)
region_name: Mapped[str] = mapped_column(
String(128), nullable=False, comment="区域名称(产品辐射地理范围)"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class PersonnelPlan(Base, TimestampMixin):
"""市场拓展计划表(需求 2.1.1):计划期间 + 计划内容 + 指标说明的自由条目。
结构化指标粒度待 T+ 字段映射确认,先以自由条目容器落地;条目最终由
T+ 同步确定,故预留 source_no 单列(无索引:查询按 personnel_id 走)。
"""
__tablename__ = "personnel_plans"
__table_args__ = mysql_table_args("市场拓展计划表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
index=True,
comment="人员ID(personnel.id)",
)
plan_period: Mapped[str] = mapped_column(
String(32), nullable=False, comment="计划期间(如 2026Q1、2026-05)"
)
plan_content: Mapped[str] = mapped_column(String(500), nullable=False, comment="计划内容")
target_desc: Mapped[str | None] = mapped_column(String(255), comment="指标说明(文字描述)")
source_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 原始条目号(预留)")

View File

@ -0,0 +1,58 @@
"""产品模型:产品主数据。
产品类别(product_category)是**折算阈值的行级锚点**:混合业务按产品逐条
计算(对照表 #14),阈值本身由 M1 参数 `product.threshold` 按 PRODUCT_CATEGORY
维度承担(#28「全部阈值统一在系统设置维护」),本表不建阈值列 —— 两处维护
必然漂移。类别值域与 M1 参数维度共用 PRODUCT_CATEGORIES 常量。
"""
from decimal import Decimal
from sqlalchemy import Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
class Product(Base, TimestampMixin, TPlusSyncMixin):
"""产品表(需求 2.1.2)。停用(status=0)为主,不物理删除。"""
__tablename__ = "products"
__table_args__ = mysql_table_args(
"产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)",
UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
product_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="产品名称")
spec_model: Mapped[str] = mapped_column(
String(128),
nullable=False,
server_default="''",
comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)",
)
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
stock_quantity: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="数量(T+ 同步原样存储,语义待 IT 确认)"
)
sale_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="销售单价(元,T+ 同步)"
)
product_category: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")

View File

@ -0,0 +1,46 @@
"""客户评级等级配置模型(需求 2.2.5)。
评级计算器定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分记录
不落库,等级**映射关系**可配置由本表承载。判定规则:按 sort_order 升序取
第一个 min_score ≤ 总分的等级 —— 单边判定避免区间边界(如 75 分)在
「≥75 且 ≤89」与「>74 且 <75」两套写法间的重叠/遗漏歧义;max_score 仅展示。
"""
from decimal import Decimal
from sqlalchemy import Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class CustomerRatingGrade(Base, TimestampMixin):
"""客户评级等级配置表(需求 2.2.5「等级对应关系可配置」)。
种子四条随迁移初始化:A 优秀 90-100 / B 良好 75-89 / C 合格 60-74 /
D 待改进 0-59。区间不重叠与 0≤min≤max≤100 由应用层校验。
"""
__tablename__ = "customer_rating_grades"
__table_args__ = mysql_table_args(
"客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)"
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
grade_name: Mapped[str] = mapped_column(
String(32), nullable=False, unique=True, comment="等级名称(如 A/优秀)"
)
min_score: Mapped[Decimal] = mapped_column(
Numeric(5, 1),
nullable=False,
comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分",
)
max_score: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, comment="总分上限(展示用)"
)
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0", comment="排序(判定顺序,升序)"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="说明")

View File

@ -0,0 +1,83 @@
"""回款模型:回款记录与批次追溯。
- shipment_id 为**手动指定**的发货批次(对照表 #18):可空=暂未指定;
必须与 business_id 同属一个业务(服务层强校验,无数据库级约束可表达
跨表同属关系);指定后回款天数方可由 formula.payment_days 派生
- settlement_status 是 **M3 核算防重锚点**(需规 2.3.3「每笔回款仅核算一次,
台账记录核销状态」):预置于本表避免 M3 反向迁移改 M2 表;M3 台账建立后
由核算服务写入,M2 仅维护 0(未核算)
- receipt_date 是 M3 参数版本匹配日(对照表 #11:按回款到账日取时点参数)
- 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
# 绩效核算状态(receipts.settlement_status)
SETTLEMENT_PENDING = 0
SETTLEMENT_DONE = 1
class Receipt(Base, TimestampMixin, TPlusSyncMixin):
"""回款表(需求 2.2.3):按业务登记到账款项,批次号系统内唯一。"""
__tablename__ = "receipts"
__table_args__ = mysql_table_args(
"回款表(手动指定发货批次;已核算回款禁改禁删)",
Index("idx_receipts_business_id", "business_id"),
Index("idx_receipts_receipt_date", "receipt_date"),
Index("idx_receipts_settlement_status", "settlement_status"),
Index("idx_receipts_shipment_id", "shipment_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
receipt_batch_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)",
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("businesses.id", ondelete="RESTRICT"),
nullable=False,
comment="业务ID(businesses.id)",
)
shipment_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("shipments.id", ondelete="RESTRICT"),
comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)",
)
receipt_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)"
)
amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="回款金额(≥0;一期禁负数/红字)"
)
is_barter: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)",
)
settlement_status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")

View File

@ -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="提交人用户名(冗余固化)"
)

View File

@ -0,0 +1,115 @@
"""发货模型:发货单与发货产品明细。
- ship_date 是**回款天数计算基准日**(对照表 #1:发货日首日计第 1 天),
回款指定本发货单后由 formula.payment_days 派生回款天数,不在本表落库
- 运费仅记录展示,不计入毛利润口径(对照表 #2)
- total_amount 为明细行汇总冗余,服务层事务内维护
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
class Shipment(Base, TimestampMixin, TPlusSyncMixin):
"""发货单表(需求 2.2.2):每笔发货对应一个业务,支持分批发货。"""
__tablename__ = "shipments"
__table_args__ = mysql_table_args(
"发货单表(发货日为回款天数基准日,发货日首日计第 1 天)",
Index("idx_shipments_business_id", "business_id"),
Index("idx_shipments_ship_date", "ship_date"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
shipment_no: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)",
)
business_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("businesses.id", ondelete="RESTRICT"),
nullable=False,
comment="业务ID(businesses.id;终态业务禁止新发货)",
)
ship_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)"
)
freight_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
server_default="0",
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
)
total_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
server_default="0",
comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class ShipmentItem(Base, TimestampMixin):
"""发货产品明细表(需求 2.2.2):记录发货的产品名称、规格、数量等。
价格口径两可(T+ 供价或人工录入,待 IT 确认):sale_price 可空人工补,
amount 由服务层按 数量×单价 计算固化。
"""
__tablename__ = "shipment_items"
__table_args__ = mysql_table_args(
"发货产品明细表",
Index("idx_shipment_items_shipment_id", "shipment_id"),
Index("idx_shipment_items_product_id", "product_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
shipment_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("shipments.id", ondelete="RESTRICT"),
nullable=False,
comment="发货单ID(shipments.id)",
)
product_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("products.id", ondelete="RESTRICT"),
comment="产品ID(products.id;NULL=自由录入)",
)
product_name: Mapped[str] = mapped_column(
String(128), nullable=False, comment="产品名称(引用产品时冗余固化)"
)
spec_model: Mapped[str] = mapped_column(
String(128),
nullable=False,
server_default="''",
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
)
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
quantity: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="发货数量(>0)"
)
sale_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)"
)
amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=False,
comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)",
)

View File

@ -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=未上传使用默认)",
)

View File

@ -0,0 +1,299 @@
"""业务数据仓储:业务单、明细、状态留痕与归属留痕。
只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。
归属留痕复用 ownership_change_logs(owner_type=2 为业务维度,常量在
models/customer.py 定义)。
"""
from datetime import date
from sqlalchemy import Row, Select, delete, exists, func, or_, select
from sqlalchemy.orm import Session, aliased
from app.models import (
Business,
BusinessItem,
BusinessStatusLog,
Customer,
OwnershipChangeLog,
Personnel,
Receipt,
SecondaryAllocation,
Shipment,
)
from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import ownership_log_repository
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
"""按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。"""
stmt = select(Business).where(Business.id == business_id)
if for_update:
stmt = stmt.with_for_update()
return session.execute(stmt).scalars().first()
def has_docs(session: Session, business_id: int) -> bool:
"""业务名下是否已有发货单或回款(换客户/删除保护的引用检查)。
单条 OR exists 一次往返;新增挂靠业务的单据类型时在此补充条件,
编辑与删除两处共用同一判定,防「delete 补了检查、update 漏补」的分叉。
"""
stmt = select(
or_(
exists().where(Shipment.business_id == business_id),
exists().where(Receipt.business_id == business_id),
)
)
return bool(session.execute(stmt).scalar())
def create(session: Session, business: Business) -> None:
"""新增业务行(不 commit,flush 让自增 id 就位)。"""
session.add(business)
session.flush()
def get_with_names(
session: Session, business_id: int, *, for_update: bool = False
) -> tuple[Business, str, str | None, str | None] | None:
"""业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。
for_update 供编辑/状态流转/归属变更使用:与发货/回款写路径的
get_by_id(for_update=True) 同一锁口径,编辑事务与单据写事务互斥,
「有发货禁换客户」的快照读校验不再被并发新增旁路。
"""
owner = aliased(Personnel)
assistant = aliased(Personnel)
stmt = (
select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name)
.join(Customer, Customer.id == Business.customer_id)
.outerjoin(owner, owner.id == Business.owner_personnel_id)
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
.where(Business.id == business_id)
)
if for_update:
stmt = stmt.with_for_update()
return session.execute(stmt).first()
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
) -> Select:
"""业务列表的筛选条件(调用方须已 join Customer)。"""
if keyword:
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
stmt = stmt.where(
or_(
Business.business_no.contains(keyword, autoescape=True),
Customer.company_name.contains(keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(Business.status == status)
if customer_id is not None:
stmt = stmt.where(Business.customer_id == customer_id)
if owner_personnel_id is not None:
stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id)
if date_from is not None:
stmt = stmt.where(Business.business_date >= date_from)
if date_to is not None:
stmt = stmt.where(Business.business_date <= date_to)
return stmt
def list_businesses(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
) -> list[tuple[Business, str, str | None, str | None]]:
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
owner = aliased(Personnel)
assistant = aliased(Personnel)
stmt = select(
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
).join(Customer, Customer.id == Business.customer_id)
stmt = _apply_filters(
stmt,
keyword=keyword,
status=status,
customer_id=customer_id,
owner_personnel_id=owner_personnel_id,
date_from=date_from,
date_to=date_to,
)
stmt = (
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
.order_by(Business.id.desc())
.offset(offset)
.limit(limit)
)
return list(session.execute(stmt).all())
def count_businesses(
session: Session,
*,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
) -> int:
stmt = (
select(func.count())
.select_from(Business)
.join(Customer, Customer.id == Business.customer_id)
)
stmt = _apply_filters(
stmt,
keyword=keyword,
status=status,
customer_id=customer_id,
owner_personnel_id=owner_personnel_id,
date_from=date_from,
date_to=date_to,
)
return int(session.execute(stmt).scalar_one())
def list_items(session: Session, business_id: int) -> list[BusinessItem]:
"""业务明细行(编辑回显与汇总重算共用)。"""
return list(
session.execute(
select(BusinessItem)
.where(BusinessItem.business_id == business_id)
.order_by(BusinessItem.id)
).scalars()
)
def replace_items(session: Session, business_id: int, rows: list[BusinessItem]) -> None:
"""全量替换明细:先删、立即 flush、再插入(replace_roles 先例的防御写法)。
同一 flush 中 SQLAlchemy 先处理 INSERT 后处理 DELETE,先删后插不落库
会在有唯一键的表上撞键;business_items 虽无唯一键,统一按此写法防御。
"""
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
session.flush()
for row in rows:
row.business_id = business_id
session.add(row)
session.flush()
def list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]:
"""二次分配方案(详情回显;空列表=未设置方案)。"""
return list(
session.execute(
select(SecondaryAllocation)
.where(SecondaryAllocation.business_id == business_id)
.order_by(SecondaryAllocation.id)
).scalars()
)
def replace_allocations(
session: Session, business_id: int, rows: list[SecondaryAllocation]
) -> None:
"""全量替换二次分配方案(不 commit,事务边界在服务层)。
本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的
防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。
"""
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
)
session.flush()
for row in rows:
row.business_id = business_id
session.add(row)
session.flush()
def delete_allocations(session: Session, business_id: int) -> None:
"""清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。
被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到);
方案事实由审批单留痕与操作日志 request_data 承载。
"""
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
)
session.flush()
def add_status_log(session: Session, log: BusinessStatusLog) -> None:
"""追加状态留痕(不 commit)。"""
session.add(log)
session.flush()
def list_status_logs(
session: Session, business_id: int, *, offset: int, limit: int
) -> list[BusinessStatusLog]:
"""状态流转时间线(详情页),按时间正序(时间线从创建起展示)。"""
return list(
session.execute(
select(BusinessStatusLog)
.where(BusinessStatusLog.business_id == business_id)
.order_by(BusinessStatusLog.create_time, BusinessStatusLog.id)
.offset(offset)
.limit(limit)
).scalars()
)
def count_status_logs(session: Session, business_id: int) -> int:
return int(
session.execute(
select(func.count())
.select_from(BusinessStatusLog)
.where(BusinessStatusLog.business_id == business_id)
).scalar_one()
)
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None:
"""追加归属变更留痕(共享实现,不 commit)。"""
ownership_log_repository.add(session, log)
def list_ownership_logs(
session: Session, business_id: int, *, offset: int, limit: int
) -> list[Row]:
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
return ownership_log_repository.list_by_ref(
session,
owner_type=OWNER_TYPE_BUSINESS,
ref_column=OwnershipChangeLog.business_id,
ref_id=business_id,
offset=offset,
limit=limit,
)
def count_ownership_logs(session: Session, business_id: int) -> int:
return ownership_log_repository.count_by_ref(
session,
owner_type=OWNER_TYPE_BUSINESS,
ref_column=OwnershipChangeLog.business_id,
ref_id=business_id,
)

View File

@ -0,0 +1,102 @@
"""业务岗位角色数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import BusinessRole
# ==================== 读取 ====================
def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> BusinessRole | None:
"""按主键查询。删除校验走 ``for_update`` 锁行(同 role_repository 的理由)。"""
if for_update:
stmt = select(BusinessRole).where(BusinessRole.id == role_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(BusinessRole, role_id)
def get_by_code(session: Session, role_code: str) -> BusinessRole | None:
"""按编码查询岗位。编码是参数维度值(dimension_value)的锚点。"""
stmt = select(BusinessRole).where(BusinessRole.role_code == role_code)
return session.execute(stmt).scalar_one_or_none()
def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, BusinessRole]:
"""按编码批量取岗位(参数批次校验段用,消除逐条 N+1)。
返回 {编码: 岗位}。MySQL ci 校对下编码大小写变形也能命中,
键取库里的规范 role_code,调用方据此把维度值归一为规范值。
"""
if not role_codes:
return {}
rows = session.execute(
select(BusinessRole).where(BusinessRole.role_code.in_(role_codes))
).scalars()
return {role.role_code: role for role in rows}
def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> 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)
return list(session.execute(stmt).scalars())
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
"""统计符合条件的岗位总数,供分页使用。"""
stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
不转义时 ``_`` 会当单字符通配执行。
"""
if keyword:
stmt = stmt.where(
or_(
BusinessRole.role_code.contains(keyword, autoescape=True),
BusinessRole.role_name.contains(keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(BusinessRole.status == status)
return stmt
def count_personnel_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
"""批量统计各岗位下的人员数(删除保护的依据)。
② 基础数据管理落地 personnel 表后在此实现真实计数(一次 GROUP BY,
禁止列表循环内逐行查询);当前无人员表,恒返回空,删除校验对空字典放行。
"""
return {}
# ==================== 写入 ====================
def create(session: Session, role: BusinessRole) -> BusinessRole:
"""新增岗位。只 flush 以取得自增 id。"""
session.add(role)
session.flush()
return role
def delete(session: Session, role: BusinessRole) -> None:
"""删除岗位。调用方须先完成保护规则校验。"""
session.delete(role)
session.flush()

View File

@ -0,0 +1,178 @@
"""客户数据访问(含归属变更留痕)。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import ColumnElement, Row, Select, func, or_, select
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.query_utils import fuzzy
# ==================== 读取 ====================
def get_by_id(session: Session, customer_id: int) -> Customer | None:
"""按主键查询客户。"""
return session.get(Customer, customer_id)
def count_business_refs(session: Session, customer_id: int) -> int:
"""被业务单据引用的次数(删除保护;业务表就位后补齐的检查)。"""
from app.models import Business
return int(
session.execute(
select(func.count()).select_from(Business).where(Business.customer_id == customer_id)
).scalar_one()
)
def get_with_owner_name(session: Session, customer_id: int) -> Row | None:
"""查单个客户并带出归属人员姓名(详情、编辑回显与归属变更共用)。"""
stmt = _base_select().where(Customer.id == customer_id)
return session.execute(stmt).first()
def get_by_company_owner(session: Session, company_name: str, owner_name: str) -> Customer | None:
"""按「公司名称 + 负责人」查客户(uk_customers_company_owner 的先查后写兜底)。"""
stmt = select(Customer).where(
Customer.company_name == company_name,
Customer.owner_name == owner_name,
)
return session.execute(stmt).scalar_one_or_none()
def list_customers(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> list[Row]:
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
"""
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)
return list(session.execute(stmt).all())
def count_customers(
session: Session,
*,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> int:
"""统计符合条件的客户总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。
"""
stmt = _apply_filters(
select(func.count(Customer.id)),
keyword=keyword,
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
return int(session.execute(stmt).scalar_one())
def list_ownership_logs(
session: Session,
*,
customer_id: int,
offset: int,
limit: int,
) -> list[Row]:
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
return ownership_log_repository.list_by_ref(
session,
owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
offset=offset,
limit=limit,
)
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
"""统计某客户的归属留痕总数,供分页使用。"""
return ownership_log_repository.count_by_ref(
session,
owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
)
def _base_select() -> Select:
"""客户主表外联归属人员姓名。"""
return select(Customer, Personnel.personnel_name).outerjoin(
Personnel, Customer.owner_personnel_id == Personnel.id
)
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
customer_type: str | None,
owner_personnel_id: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if keyword:
stmt = stmt.where(_keyword_clause(keyword))
if customer_type:
stmt = stmt.where(Customer.customer_type == customer_type)
if owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id)
return stmt
def _keyword_clause(keyword: str) -> ColumnElement[bool]:
"""关键词条件:公司名称或客户负责人两列 OR 模糊。
借 ``query_utils.fuzzy`` 在空 select 上生成单列条件后取 whereclause 做
OR 合并 —— LIKE 通配符转义口径单点维护;两处手写 ``contains`` 迟早有
一处漏掉 ``autoescape``(人名含下划线是常态,不转义会当单字符通配执行)。
"""
probe = select(Customer.id)
return or_(
fuzzy(probe, Customer.company_name, keyword).whereclause,
fuzzy(probe, Customer.owner_name, keyword).whereclause,
)
# ==================== 写入 ====================
def create(session: Session, customer: Customer) -> Customer:
"""新增客户。只 flush 以取得自增 id。"""
session.add(customer)
session.flush()
return customer
def delete(session: Session, customer: Customer) -> None:
"""删除客户。调用方须先完成引用保护校验。"""
session.delete(customer)
session.flush()
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
"""写入一条归属变更留痕(共享实现)。只 flush 不 commit,与主表更新同事务。"""
return ownership_log_repository.add(session, log)

View File

@ -1,8 +1,10 @@
"""登录日志数据访问。"""
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
from app.models import LoginLog
from app.repositories.query_utils import LogFilters, apply_log_filters
# user_agent 列宽,超长需截断否则插入报错
_USER_AGENT_MAX_LEN = 255
@ -36,3 +38,40 @@ def add(
)
)
session.flush()
def list_logs(
session: Session,
*,
offset: int,
limit: int,
filters: LogFilters,
ip: str | None = None,
) -> 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)
return list(session.execute(stmt).scalars())
def count_logs(
session: Session,
*,
filters: LogFilters,
ip: str | None = None,
) -> int:
"""统计符合条件的登录日志总数,供分页使用。"""
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
stmt = apply_log_filters(stmt, LoginLog, filters)
if ip:
stmt = stmt.where(LoginLog.ip == ip)
return stmt

View File

@ -0,0 +1,214 @@
"""操作日志数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
写入前处理(截断 / 脱敏 / 序列化)集中在本层,与 `login_log_repository`
的做法一致:模型定义的长度常量在这里落实,调用方只需传业务值。
"""
import json
from dataclasses import dataclass
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session, defer
from app.core.logging import scrub
from app.models import OperationLog
from app.models.operation_log import (
ACTION_MAX_LEN,
ERROR_MESSAGE_MAX_LEN,
IP_MAX_LEN,
METHOD_MAX_LEN,
MODULE_MAX_LEN,
PATH_MAX_LEN,
REQUEST_DATA_MAX_BYTES,
SUMMARY_MAX_LEN,
USER_AGENT_MAX_LEN,
USERNAME_MAX_LEN,
)
from app.repositories.query_utils import LogFilters, apply_log_filters
# 键名(不区分大小写)含以下子串的字段,其值一律整体抹除。
# 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 ——
# 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、
# 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。
# phone/mobile/id_card 系 CLAUDE.md「日志禁输出手机号/身份证」红线的键名口径 ——
# 带分隔符的号码会穿过文本正则,必须在这里按整值抹除
_SENSITIVE_KEY_PARTS = (
"password",
"passwd",
"pwd",
"secret",
"token",
"app_key",
"access_key",
"phone",
"mobile",
"id_card",
"idcard",
)
_MASK = "***"
@dataclass(frozen=True, slots=True)
class OperationLogEntry:
"""一条待写入的操作日志(全字段 DTO)。
替代 14 个平铺参数:字段若在 audit() / _safe_write() / add() 三处
重复展开,增删字段时任何一侧漏改都只表现为审计数据不完整、不报错
(success 默认 0 的语义使漏传尤其隐蔽)。
"""
user_id: int | None
username: str
module: str
action: str
summary: str
success: bool
method: str | None = None
path: str | None = None
ip: str | None = None
user_agent: str | None = None
request_data: dict | None = None
error_message: str | None = None
duration_ms: int | None = None
def add(session: Session, entry: OperationLogEntry) -> None:
"""写入一条操作日志。只 flush 不 commit。
所有文本列按模型定义的长度常量截断 —— 超长在 MySQL 严格模式下抛 1406,
而「审计失败不影响业务」的约定会把该异常吞掉,结果是整条日志**静默丢失**,
事后极难排查。截断是唯一防线,必须在此层无条件执行。
"""
session.add(
OperationLog(
user_id=entry.user_id,
username=entry.username[:USERNAME_MAX_LEN],
module=entry.module[:MODULE_MAX_LEN],
action=entry.action[:ACTION_MAX_LEN],
summary=entry.summary[:SUMMARY_MAX_LEN],
method=_clip(entry.method, METHOD_MAX_LEN),
path=_clip(entry.path, PATH_MAX_LEN),
ip=_clip(entry.ip, IP_MAX_LEN),
user_agent=_clip(entry.user_agent, USER_AGENT_MAX_LEN),
request_data=_serialize_request_data(entry.request_data),
success=1 if entry.success else 0,
error_message=_clip(entry.error_message, ERROR_MESSAGE_MAX_LEN),
duration_ms=entry.duration_ms,
)
)
session.flush()
def get_by_id(session: Session, log_id: int) -> OperationLog | None:
"""按主键查询单条日志(详情端点用,含 request_data)。不存在返回 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]:
"""分页查询操作日志。
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
不确定,翻页会重复或遗漏(与用户列表同一理由)。
**排除 request_data 列**(TEXT,单行最大 60000 字节):列表页默认不
展示它,全列加载会让翻页负担随日志表增长线性放大;详情走 `get_by_id`
按需读取。
"""
stmt = _apply_filters(
select(OperationLog).options(defer(OperationLog.request_data)),
filters=filters,
module=module,
action=action,
)
stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_logs(
session: Session,
*,
filters: LogFilters,
module: str | None = None,
action: str | None = None,
) -> int:
"""统计符合条件的日志总数,供分页使用。"""
stmt = _apply_filters(
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
filters: LogFilters,
module: str | None,
action: str | None,
) -> 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)
return stmt
def _clip(text: str | None, limit: int) -> str | None:
"""按字符数截断(长度常量与数据库列宽均按字符定义)。"""
return text[:limit] if text else None
def _is_sensitive_key(key: object) -> bool:
name = str(key).lower()
return any(part in name for part in _SENSITIVE_KEY_PARTS)
def _sanitize_sensitive(value: object) -> object:
"""递归抹除请求参数中的敏感字段值(dict 按键名整体替换、list 逐项递归)。"""
if isinstance(value, dict):
return {
key: _MASK if _is_sensitive_key(key) else _sanitize_sensitive(item)
for key, item in value.items()
}
if isinstance(value, list):
return [_sanitize_sensitive(item) for item in value]
return value
def _serialize_request_data(data: dict | None) -> str | None:
"""请求参数 → JSON 文本:两段脱敏 → 字节边界截断。
脱敏分两层,各司其职:
- **结构化抹除**(`_sanitize_sensitive`):按键名整体置 ``***``,
不依赖文本模式匹配 —— 密码含空格、引号、前导空白等形态都不受影响
- `core.logging.scrub` 作文本兜底:覆盖手机号、Bearer 等无法按键名
判断的值
截断必须在 **UTF-8 字节边界** 进行:request_data 列是 TEXT(65535 字节
上限),而 request_data 上限 60000 字节也是按字节定的;若按字符切片,
中文场景下字符数与字节数相差 3 倍同样可能超限;先编码再按字节切片
则可能把多字节字符切成两半产生非法序列。故顺序为:编码 → 字节切片 →
宽容解码丢弃残片。encode 用 ``errors="replace"`` 兜底孤立代理字符 ——
请求 JSON 里的 ``\\ud800`` 转义会解析为孤立代理,直接 encode 会抛
UnicodeEncodeError,被审计兜底逻辑吞掉后该请求的记录**静默丢失**。
"""
if not data:
return None
text = scrub(json.dumps(_sanitize_sensitive(data), ensure_ascii=False, default=str))
encoded = text.encode("utf-8", errors="replace")
if len(encoded) <= REQUEST_DATA_MAX_BYTES:
return text
return encoded[:REQUEST_DATA_MAX_BYTES].decode("utf-8", errors="ignore")

View File

@ -0,0 +1,56 @@
"""归属变更留痕的共享数据访问(客户/业务两维度共用)。
owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧各持一套逐行
同构的 list/count/add,排序口径与 join 兜底的调整须同步两处;收口后
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
"""
from sqlalchemy import Row, func, select
from sqlalchemy.orm import Session, aliased
from app.models import OwnershipChangeLog, Personnel
def list_by_ref(
session: Session,
*,
owner_type: int,
ref_column,
ref_id: int,
offset: int,
limit: int,
) -> list[Row]:
"""查某引用对象的归属留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。
人员删除后 join 出 None,留痕固化 ID 兜底可读。按 (create_time, id)
双键倒序:create_time 是秒级精度,同一秒内多条排序不确定,id 兜底
保证翻页稳定(与操作日志同一理由)。
"""
old_person = aliased(Personnel)
new_person = aliased(Personnel)
stmt = (
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset)
.limit(limit)
)
return list(session.execute(stmt).all())
def count_by_ref(session: Session, *, owner_type: int, ref_column, ref_id: int) -> int:
"""统计某引用对象的归属留痕总数,供分页使用。"""
stmt = select(func.count(OwnershipChangeLog.id)).where(
OwnershipChangeLog.owner_type == owner_type,
ref_column == ref_id,
)
return int(session.execute(stmt).scalar_one())
def add(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
"""写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。"""
session.add(log)
session.flush()
return log

View File

@ -0,0 +1,242 @@
"""计提参数数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
时点化解析查询(窗口函数版)留存 db/queries/计提参数.sql。
"""
from datetime import date
from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session
from app.models import ParamChangeBatch, ParamDefinition, ParamValue
# ==================== 读取 ====================
def resolve_values(session: Session, biz_date: date) -> list[tuple[str, str, str, date]]:
"""时点化解析:每个 (param_id, dimension_value) 取生效日期 <= 业务日期的最新条目。
排序 (effective_date DESC, id DESC) 保证同日多次变更取最新一条
(不设日期唯一键,同日修正是合法留痕)。仅返回启用状态的参数定义。
返回 (param_code, dimension_value, value_text, effective_date)。
"""
row_number = (
func.row_number()
.over(
partition_by=(ParamValue.param_id, ParamValue.dimension_value),
order_by=(ParamValue.effective_date.desc(), ParamValue.id.desc()),
)
.label("rn")
)
subq = (
select(
ParamDefinition.param_code.label("param_code"),
ParamValue.dimension_value.label("dimension_value"),
ParamValue.value_text.label("value_text"),
ParamValue.effective_date.label("effective_date"),
row_number,
)
.join(ParamDefinition, ParamDefinition.id == ParamValue.param_id)
.where(ParamValue.effective_date <= biz_date, ParamDefinition.status == 1)
.subquery()
)
stmt = select(
subq.c.param_code, subq.c.dimension_value, subq.c.value_text, subq.c.effective_date
).where(subq.c.rn == 1)
return [
(row.param_code, row.dimension_value, row.value_text, row.effective_date)
for row in session.execute(stmt)
]
def latest_value_before(
session: Session, param_id: int, dimension_value: str, biz_date: date
) -> ParamValue | None:
"""取某参数某维度在业务日期(含)之前的最新条目 —— 提交变更时记录「旧值」。"""
stmt = (
select(ParamValue)
.where(
ParamValue.param_id == param_id,
ParamValue.dimension_value == dimension_value,
ParamValue.effective_date <= biz_date,
)
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
.limit(1)
)
return session.execute(stmt).scalar_one_or_none()
def list_definitions(session: Session, *, status: int | None = None) -> list[ParamDefinition]:
"""参数定义列表(目录)。"""
stmt = select(ParamDefinition).order_by(ParamDefinition.sort_order, ParamDefinition.id)
if status is not None:
stmt = stmt.where(ParamDefinition.status == status)
return list(session.execute(stmt).scalars())
def get_definition_by_code(session: Session, param_code: str) -> ParamDefinition | None:
"""按编码取参数定义。"""
stmt = select(ParamDefinition).where(ParamDefinition.param_code == param_code)
return session.execute(stmt).scalar_one_or_none()
def list_definitions_by_codes(
session: Session, param_codes: list[str]
) -> dict[str, ParamDefinition]:
"""按编码批量取参数定义(提交批次的校验段用,消除逐条 N+1)。
返回 {编码: 定义};未定义的编码不在结果中,由调用方判缺失。
"""
if not param_codes:
return {}
rows = session.execute(
select(ParamDefinition).where(ParamDefinition.param_code.in_(param_codes))
).scalars()
return {definition.param_code: definition for definition in rows}
def list_history(
session: Session,
param_id: int,
*,
offset: int,
limit: int,
dimension_value: str | None = None,
) -> 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))]
def count_history(session: Session, param_id: int, *, dimension_value: str | None = None) -> int:
"""统计单参数的历史条目数。"""
stmt = select(func.count()).select_from(
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
)
return int(session.execute(stmt).scalar_one())
def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select:
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
stmt = (
select(ParamValue, ParamChangeBatch)
.join(ParamChangeBatch, ParamChangeBatch.id == ParamValue.batch_id)
.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)
return stmt
# ==================== 批次 ====================
def create_batch(session: Session, batch: ParamChangeBatch) -> ParamChangeBatch:
"""新增变更批次。只 flush 以取得自增 id。"""
session.add(batch)
session.flush()
return batch
def get_batch_by_id(
session: Session, batch_id: int, *, for_update: bool = False
) -> ParamChangeBatch | None:
"""按主键取批次。撤回校验走 ``for_update`` 锁行(防并发双撤)。"""
if for_update:
stmt = select(ParamChangeBatch).where(ParamChangeBatch.id == batch_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
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]:
"""分页查询变更批次(按创建时间倒序)。"""
stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date)
return list(
session.execute(
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
.offset(offset)
.limit(limit)
).scalars()
)
def count_batches(
session: Session, *, start_date: date | None = None, end_date: date | None = None
) -> int:
"""统计符合条件的批次总数。"""
stmt = _batch_filters(
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
)
return int(session.execute(stmt).scalar_one())
def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> 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)
return stmt
def list_values_by_batch(
session: Session, batch_id: int
) -> list[tuple[ParamValue, ParamDefinition]]:
"""批次内全部条目(join 参数定义取编码/名称),按 id 排序。"""
stmt = (
select(ParamValue, ParamDefinition)
.join(ParamDefinition, ParamDefinition.id == ParamValue.param_id)
.where(ParamValue.batch_id == batch_id)
.order_by(ParamValue.id)
)
return [(value, definition) for value, definition in session.execute(stmt)]
def delete_values_by_batch(session: Session, batch_id: int) -> None:
"""删除批次下全部条目(撤回未生效批次时使用,2.0 风格 delete 语句)。"""
session.execute(delete(ParamValue).where(ParamValue.batch_id == batch_id))
def delete_batch(session: Session, batch: ParamChangeBatch) -> None:
"""删除批次本体(调用方须先删条目并完成保护校验)。"""
session.delete(batch)
session.flush()
# ==================== 写入 ====================
def create_value(session: Session, value: ParamValue) -> ParamValue:
"""新增版本条目(append-only,禁改禁删已生效条目)。只 flush。"""
session.add(value)
session.flush()
return value
# ==================== 导出 ====================
def list_export_rows(
session: Session, *, start_date: date | None = None, end_date: date | None = None
) -> list[tuple[ParamChangeBatch, ParamValue, ParamDefinition]]:
"""导出行:批次 × 条目 × 定义(变更人/时间/参数/维度/旧值/新值/生效日期)。"""
stmt = (
select(ParamChangeBatch, ParamValue, ParamDefinition)
.join(ParamValue, ParamValue.batch_id == ParamChangeBatch.id)
.join(ParamDefinition, ParamDefinition.id == ParamValue.param_id)
.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc(), ParamValue.id)
)
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)
return [(batch, value, definition) for batch, value, definition in session.execute(stmt)]

View File

@ -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)

View File

@ -0,0 +1,209 @@
"""营销人员数据访问(含辐射区域与拓展计划子表)。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import Row, Select, func, select
from sqlalchemy import delete as sa_delete
from sqlalchemy.orm import Session, aliased
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
from app.repositories.query_utils import fuzzy
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
_manager = aliased(Personnel)
# ==================== 读取 ====================
def get_by_id(session: Session, personnel_id: int, *, for_update: bool = False) -> Personnel | None:
"""按主键查询。删除校验走 ``for_update`` 锁行(同 business_role_repository 的理由)。"""
if for_update:
stmt = select(Personnel).where(Personnel.id == personnel_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(Personnel, personnel_id)
def get_with_names(session: Session, personnel_id: int) -> Row | None:
"""查单个人员并带出岗位名称与直属经理姓名(详情/编辑回显共用)。"""
stmt = _base_select().where(Personnel.id == personnel_id)
return session.execute(stmt).first()
def list_personnel(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> list[Row]:
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
"""
stmt = _apply_filters(
_base_select(), keyword=keyword, status=status, business_role_id=business_role_id
)
stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).all())
def count_personnel(
session: Session,
*,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> int:
"""统计符合条件的人员总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。
"""
stmt = _apply_filters(
select(func.count(Personnel.id)),
keyword=keyword,
status=status,
business_role_id=business_role_id,
)
return int(session.execute(stmt).scalar_one())
def count_team_members(session: Session, manager_ids: list[int]) -> dict[int, int]:
"""按直属经理批量统计团队成员数(删除保护的依据)。
一次 GROUP BY 完成,禁止调用方在列表循环内逐行查询。
"""
stmt = (
select(Personnel.manager_id, func.count(Personnel.id))
.where(Personnel.manager_id.in_(manager_ids))
.group_by(Personnel.manager_id)
)
return {int(manager_id): int(count) for manager_id, count in session.execute(stmt).all()}
def count_customer_ownerships(session: Session, personnel_ids: list[int]) -> dict[int, int]:
"""按归属人员批量统计客户数(删除保护的另一半依据)。"""
stmt = (
select(Customer.owner_personnel_id, func.count(Customer.id))
.where(Customer.owner_personnel_id.in_(personnel_ids))
.group_by(Customer.owner_personnel_id)
)
return {int(owner_id): int(count) for owner_id, count in session.execute(stmt).all()}
def count_business_roles_refs(session: Session, personnel_ids: list[int]) -> dict[int, int]:
"""按人员批量统计业务单据归属引用(负责/协助合计;业务表就位后补齐的检查)。
businesses.owner/assistant_personnel_id 均为 RESTRICT 外键——漏检会让
「删除被业务引用的人员」以 500 暴露而非可读的 400。
"""
from app.models import Business
owner = (
select(Business.owner_personnel_id, func.count(Business.id))
.where(Business.owner_personnel_id.in_(personnel_ids))
.group_by(Business.owner_personnel_id)
)
assistant = (
select(Business.assistant_personnel_id, func.count(Business.id))
.where(Business.assistant_personnel_id.in_(personnel_ids))
.group_by(Business.assistant_personnel_id)
)
refs: dict[int, int] = {}
for stmt in (owner, assistant):
for owner_id, count in session.execute(stmt).all():
if owner_id is not None:
refs[int(owner_id)] = refs.get(int(owner_id), 0) + int(count)
return refs
def list_regions(session: Session, personnel_id: int) -> list[PersonnelRegion]:
"""查人员的全部辐射区域,按插入顺序返回。"""
stmt = (
select(PersonnelRegion)
.where(PersonnelRegion.personnel_id == personnel_id)
.order_by(PersonnelRegion.id)
)
return list(session.execute(stmt).scalars())
def list_plans(session: Session, personnel_id: int) -> list[PersonnelPlan]:
"""查人员的全部拓展计划,按插入顺序返回。"""
stmt = (
select(PersonnelPlan)
.where(PersonnelPlan.personnel_id == personnel_id)
.order_by(PersonnelPlan.id)
)
return list(session.execute(stmt).scalars())
def _base_select() -> Select:
"""人员主表联岗位名称与直属经理姓名。
岗位外键非空,但统一用 outerjoin:岗位行被物理删除的脏数据不应让
整个列表查不出来(外键 RESTRICT 正常情况下不会发生)。
"""
return (
select(Personnel, BusinessRole.role_name, _manager.personnel_name)
.outerjoin(BusinessRole, Personnel.business_role_id == BusinessRole.id)
.outerjoin(_manager, Personnel.manager_id == _manager.id)
)
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
status: int | None,
business_role_id: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if keyword:
stmt = fuzzy(stmt, Personnel.personnel_name, keyword)
if status is not None:
stmt = stmt.where(Personnel.status == status)
if business_role_id is not None:
stmt = stmt.where(Personnel.business_role_id == business_role_id)
return stmt
# ==================== 写入 ====================
def create(session: Session, personnel: Personnel) -> Personnel:
"""新增人员。只 flush 以取得自增 id。"""
session.add(personnel)
session.flush()
return personnel
def delete(session: Session, personnel: Personnel) -> None:
"""删除人员。调用方须先完成引用保护校验。"""
session.delete(personnel)
session.flush()
def replace_regions(session: Session, personnel_id: int, rows: list[PersonnelRegion]) -> None:
"""全量替换人员的辐射区域。
**先删、立即 flush、再插入**:同一 flush 内 SQLAlchemy 先执行 INSERT
后执行 DELETE,新旧行并存会在带唯一键的表上撞 1062 —— M1 的
replace_roles 在 e2e 中踩过此坑。本表当前无唯一键,仍统一按防御写法,
避免后续加约束时翻车。
"""
session.execute(sa_delete(PersonnelRegion).where(PersonnelRegion.personnel_id == personnel_id))
session.flush()
session.add_all(rows)
session.flush()
def replace_plans(session: Session, personnel_id: int, rows: list[PersonnelPlan]) -> None:
"""全量替换人员的拓展计划(防御写法同 ``replace_regions``)。"""
session.execute(sa_delete(PersonnelPlan).where(PersonnelPlan.personnel_id == personnel_id))
session.flush()
session.add_all(rows)
session.flush()

View File

@ -0,0 +1,135 @@
"""产品主数据数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import BusinessItem, Product, ShipmentItem
# ==================== 读取 ====================
def get_by_id(session: Session, product_id: int, *, for_update: bool = False) -> Product | None:
"""按主键查询。删除校验走 ``for_update`` 锁行(同 business_role_repository
的理由:校验与删除之间若发生并发引用,锁行使并发写入可见地失败,
而非引用被静默悬空)。"""
if for_update:
stmt = select(Product).where(Product.id == product_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(Product, product_id)
def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Product | None:
"""按 (名称, 规格型号) 组合查询。
组合是 uk_products_name_spec 唯一键的键值,先查用于给出即时业务提示;
并发窗口仍存在,最终由 IntegrityError 兜底(见 product_service)。
"""
stmt = select(Product).where(
Product.product_name == product_name,
Product.spec_model == spec_model,
)
return session.execute(stmt).scalar_one_or_none()
def list_products(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
product_category: str | None = None,
status: int | None = None,
) -> list[Product]:
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
stmt = _apply_filters(
select(Product),
keyword=keyword,
product_category=product_category,
status=status,
)
stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_products(
session: Session,
*,
keyword: str | None = None,
product_category: str | None = None,
status: int | None = None,
) -> int:
"""统计符合条件的产品总数,供分页使用。"""
stmt = _apply_filters(
select(func.count(Product.id)),
keyword=keyword,
product_category=product_category,
status=status,
)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
product_category: str | None,
status: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
(``contains(autoescape=True)`` 转义 LIKE 通配符并附加 ESCAPE 子句);
因 fuzzy 是单列 where 语义(两次调用会叠加成 AND),双列 OR 在此直接
写 contains —— M1 business_role_repository 的双列关键词同款写法。
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
"""
if keyword:
stmt = stmt.where(
or_(
Product.product_name.contains(keyword, autoescape=True),
Product.spec_model.contains(keyword, autoescape=True),
)
)
if product_category:
stmt = stmt.where(Product.product_category == product_category)
if status is not None:
stmt = stmt.where(Product.status == status)
return stmt
def count_refs(session: Session, product_id: int) -> int:
"""统计引用该产品的业务明细与发货明细行数(删除保护,business_id 先例)。
两张明细表对 products 均 RESTRICT 外键:先查给出可读的 400;校验与
删除之间的并发引用由锁行 + FK 兜底(service 层再翻译 IntegrityError)。
"""
stmt = select(
select(func.count())
.select_from(BusinessItem)
.where(BusinessItem.product_id == product_id)
.scalar_subquery()
+ select(func.count())
.select_from(ShipmentItem)
.where(ShipmentItem.product_id == product_id)
.scalar_subquery()
)
return int(session.execute(stmt).scalar_one())
# ==================== 写入 ====================
def create(session: Session, product: Product) -> Product:
"""新增产品。只 flush 以取得自增 id。"""
session.add(product)
session.flush()
return product
def delete(session: Session, product: Product) -> None:
"""物理删除产品。调用方须先完成引用保护校验(count_refs + FK 兜底)。"""
session.delete(product)
session.flush()

View File

@ -0,0 +1,54 @@
"""repositories 层公共查询工具。
集中两类易错点,避免各 repository 各写一份、口径漂移:
- **模糊匹配的 LIKE 通配符转义**:直接 ``f"%{value}%"`` 拼接时,输入中的
下划线会作为单字符通配执行(本系统用户名普遍含下划线,误配是常态而非
理论),百分号则命中全表并触发全表扫描
- **两类日志共有的筛选条件**:用户名/结果/时间范围在操作日志与登录日志
之间逐字重复,抽为同一实现后「列表与计数」「操作与登录」不可能对不上
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from sqlalchemy import Select
@dataclass(frozen=True, slots=True)
class LogFilters:
"""两类日志(操作/登录)共有的筛选条件。"""
username: str | None = None
success: int | None = None
start_time: datetime | None = None
end_time: datetime | None = None
def fuzzy(stmt: Select, column: Any, value: str) -> Select:
"""模糊匹配(含通配符自动转义)。
``contains(autoescape=True)`` 会转义输入中的 ``%``/``_`` 并附加 ``ESCAPE``
子句,把用户输入当字面量匹配。
"""
return stmt.where(column.contains(value, autoescape=True))
def apply_log_filters(stmt: Select, model: Any, filters: LogFilters) -> Select:
"""应用两类日志共有的筛选条件。
``model`` 传 ``OperationLog`` / ``LoginLog`` 的模型类(两者字段同名同型)。
列表与计数共用本函数,避免「列表按条件过滤而总数不按条件过滤」——
漏改一侧时表现为翻到最后一页是空页或页码虚增,且没有任何报错。
"""
if filters.username:
stmt = fuzzy(stmt, model.username, filters.username)
if filters.success is not None:
stmt = stmt.where(model.success == filters.success)
if filters.start_time:
stmt = stmt.where(model.create_time >= filters.start_time)
if filters.end_time:
stmt = stmt.where(model.create_time <= filters.end_time)
return stmt

View File

@ -0,0 +1,65 @@
"""客户评级等级配置数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import CustomerRatingGrade
# ==================== 读取 ====================
def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRatingGrade]:
"""全量等级,按 sort_order 升序、id 兜底(同序次序稳定)。
不分页:等级映射是判定表,数量个位数,分页无意义。
for_update 供区间重叠判定使用:先查后写在无锁时,两管理员并发提交
重叠区间会互判不重叠而双双入库(区间重叠无唯一键可拦)——锁全表行
串行化并发的重叠判定(先到者提交后,后到者在锁下读到新区间即判重叠)。
只读直出(list_grades)不加锁。
"""
stmt = select(CustomerRatingGrade).order_by(
CustomerRatingGrade.sort_order, CustomerRatingGrade.id
)
if for_update:
stmt = stmt.with_for_update()
return list(session.execute(stmt).scalars())
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
"""按主键查询。
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
"""
return session.get(CustomerRatingGrade, grade_id)
def get_by_name(session: Session, grade_name: str) -> CustomerRatingGrade | None:
"""按等级名称查询。
grade_name 是 uk_customer_rating_grades_grade_name 唯一键的键值,先查
用于给出即时业务提示;并发窗口仍存在,最终由 IntegrityError 兜底
(见 rating_service)。
"""
stmt = select(CustomerRatingGrade).where(CustomerRatingGrade.grade_name == grade_name)
return session.execute(stmt).scalar_one_or_none()
# ==================== 写入 ====================
def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
"""新增等级。只 flush 以取得自增 id。"""
session.add(grade)
session.flush()
return grade
def delete(session: Session, grade: CustomerRatingGrade) -> None:
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
session.delete(grade)
session.flush()

View File

@ -0,0 +1,125 @@
"""回款数据访问:回款行与关联引用(业务号/发货单号/发货日)。
只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。
- 列表与详情统一 join 出 ``business_no / shipment_no / ship_date``,
ship_date 供 service 按 ``formula.payment_days`` 派生回款天数 —— 关联
引用经本层查询一次供给,service 不再对发货单二次查询
- shipment_id 是手动指定(可空),发货单关联一律 outerjoin
"""
from datetime import date
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
from app.models import Business, Receipt, Shipment
def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
"""按主键取回款行(供摘要预取与状态判定;不锁行,写保护锚在业务行)。"""
return session.get(Receipt, receipt_id)
def get_with_refs(
session: Session, receipt_id: int
) -> tuple[Receipt, str, str | None, date | None] | None:
"""回款行 + 业务号 + 发货单号 + 发货日(DTO 与 payment_days 派生共用)。
business 主表为 inner join(FK NOT NULL,业务与回款同存亡);发货单
可未指定,outerjoin 后 shipment_no / ship_date 可能为 None。
"""
row = session.execute(
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
.join(Business, Business.id == Receipt.business_id)
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id)
.where(Receipt.id == receipt_id)
).first()
return row
def get_shipment(session: Session, shipment_id: int) -> Shipment | None:
"""按主键取发货单(同业务强校验的只读预查;FK RESTRICT 为最终防线)。"""
return session.get(Shipment, shipment_id)
def _apply_filters(
stmt: Select,
*,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> Select:
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
if business_id is not None:
stmt = stmt.where(Receipt.business_id == business_id)
if date_from is not None:
stmt = stmt.where(Receipt.receipt_date >= date_from)
if date_to is not None:
stmt = stmt.where(Receipt.receipt_date <= date_to)
if is_barter is not None:
stmt = stmt.where(Receipt.is_barter == is_barter)
if settlement_status is not None:
stmt = stmt.where(Receipt.settlement_status == settlement_status)
return stmt
def list_receipts(
session: Session,
*,
offset: int,
limit: int,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> list[tuple[Receipt, str, str | None, date | None]]:
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
stmt = _apply_filters(
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
.join(Business, Business.id == Receipt.business_id)
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
business_id=business_id,
date_from=date_from,
date_to=date_to,
is_barter=is_barter,
settlement_status=settlement_status,
)
return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all())
def count_receipts(
session: Session,
*,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> int:
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
stmt = _apply_filters(
select(func.count()).select_from(Receipt),
business_id=business_id,
date_from=date_from,
date_to=date_to,
is_barter=is_barter,
settlement_status=settlement_status,
)
return int(session.execute(stmt).scalar_one())
def create(session: Session, receipt: Receipt) -> Receipt:
"""新增回款行。只 flush 以取得自增 id,编号唯一键冲突由 service 兜底。"""
session.add(receipt)
session.flush()
return receipt
def delete(session: Session, receipt: Receipt) -> None:
"""物理删除回款行(已核算与否的准入判定在 service 层)。"""
session.delete(receipt)
session.flush()

View File

@ -0,0 +1,423 @@
"""报表统计仓储:只读聚合查询;不 commit、不抛业务异常。
报表与台账分页查询刻意分开而非复用 ``performance_ledger_repository``:
- 台账查询允许聚合行(calc_type=2),报表明细必须显式排除——经理聚合
行 receipt_id 为 NULL,混入明细会让合同编号/天数列失真、业务统计的
绩效被整团队聚合额污染;
- 月结的 ``_confirm_scope`` 带 ``settlement_id IS NULL``(可入结口径),
报表要看的是同月**全量**生效单笔行,口径不同;
- 聚合需求(SUM/GROUP BY)台账仓储完全没有,报表在此统一实现。
发货额/回款额直接读 businesses 的汇总冗余列(服务层事务内单点维护),
不逐业务子查询发货/回款表;绩效额 businesses 无冗余列,用子查询联台账。
"""
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.report_meta import DayBucket
from app.models import (
AdjustmentOrder,
Business,
Customer,
MonthlySettlement,
PerformanceLedger,
Personnel,
)
from app.schemas.report import (
BusinessStatsQuery,
PaymentDetailQuery,
PaymentStatsQuery,
PersonalDetailQuery,
)
# 单笔生效行基础条件:报表四表共用(聚合行与已替代版本一律排除)
_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1)
# ==== 个人绩效明细(FR-029)====
def _personal_filters(query: PersonalDetailQuery) -> tuple:
filters = [*_ACTIVE_SINGLE]
if query.settlement_month:
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
if query.personnel_id is not None:
filters.append(PerformanceLedger.personnel_id == query.personnel_id)
if query.customer_type:
filters.append(PerformanceLedger.customer_type == query.customer_type)
if query.product_category:
filters.append(PerformanceLedger.product_category == query.product_category)
if query.role_code:
filters.append(PerformanceLedger.role_code == query.role_code)
return tuple(filters)
def list_personal_detail(
session: Session, query: PersonalDetailQuery, *, offset: int, limit: int
) -> list[PerformanceLedger]:
"""个人绩效明细分页(回款日倒序,同人按版本可追溯)。"""
stmt = select(PerformanceLedger).where(*_personal_filters(query))
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
stmt = stmt.offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_personal_detail(session: Session, query: PersonalDetailQuery) -> int:
"""个人绩效明细计数(与 list_personal_detail 同筛选)。"""
return int(
session.execute(
select(func.count()).select_from(PerformanceLedger).where(*_personal_filters(query))
).scalar_one()
)
def sum_personal_detail(session: Session, query: PersonalDetailQuery) -> dict:
"""全筛选口径合计(分页与合计同条件,避免「页脚合计≠全部结果」)。"""
return (
session.execute(
select(
func.count().label("row_count"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.coalesce(func.sum(PerformanceLedger.converted_profit), 0).label(
"converted_profit_total"
),
).where(*_personal_filters(query))
)
.mappings()
.one()
)
# ==== 部门汇总取数(FR-030)====
def list_month_receipt_rows(session: Session, month: str) -> list[PerformanceLedger]:
"""同月全量生效单笔台账行(部门汇总基数与成员明细的取数)。"""
return list(
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.settlement_month == month, *_ACTIVE_SINGLE
)
).scalars()
)
def list_month_manager_aggregates(session: Session, month: str) -> list[PerformanceLedger]:
"""同月经理聚合行(calc_type=2):已生成月结时的冻结对照值。"""
return list(
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.settlement_month == month,
PerformanceLedger.calc_type == 2,
PerformanceLedger.is_active == 1,
)
).scalars()
)
def list_month_adjustments(session: Session, month: str) -> list[AdjustmentOrder]:
"""同月已生效调整单(status=2)——报表汇总并入的口径。
与 list_adjustments(调整单管理页)不同:必须按月筛选且只取生效行,
待审批/已驳回/已撤回的金额绝不能混进报表。
"""
return list(
session.execute(
select(AdjustmentOrder).where(
AdjustmentOrder.settlement_month == month,
AdjustmentOrder.status == 2,
)
).scalars()
)
def list_month_settlement(session: Session, month: str) -> MonthlySettlement | None:
"""同月月结头(部门汇总展示锁定状态用;无则 None)。"""
return session.execute(
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
).scalar_one_or_none()
# ==== 业务统计(FR-031 之一)====
def _business_perf_subquery():
"""业务维度绩效子查询(排除聚合行,防 12.5%/5% 聚合额污染单业务绩效)。"""
return (
select(
PerformanceLedger.business_id.label("business_id"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label("perf"),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable"),
)
.where(*_ACTIVE_SINGLE)
.group_by(PerformanceLedger.business_id)
.subquery()
)
def _business_filters(query: BusinessStatsQuery) -> tuple:
filters = []
if query.keyword:
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 转义通配符,
# 与 business_repository._apply_filters 同口径(双列 OR 用法为该处既定)
filters.append(
Business.business_no.contains(query.keyword, autoescape=True)
| Customer.company_name.contains(query.keyword, autoescape=True)
)
if query.customer_id is not None:
filters.append(Business.customer_id == query.customer_id)
if query.owner_personnel_id is not None:
filters.append(Business.owner_personnel_id == query.owner_personnel_id)
if query.status is not None:
filters.append(Business.status == query.status)
if query.date_from is not None:
filters.append(Business.business_date >= query.date_from)
if query.date_to is not None:
filters.append(Business.business_date <= query.date_to)
return tuple(filters)
def _business_select(query: BusinessStatsQuery):
perf_sq = _business_perf_subquery()
return (
select(
Business,
Customer.company_name.label("customer_name"),
Personnel.personnel_name.label("owner_personnel_name"),
perf_sq.c.perf.label("performance_amount"),
perf_sq.c.payable.label("payable_amount"),
)
.outerjoin(Customer, Customer.id == Business.customer_id)
.outerjoin(Personnel, Personnel.id == Business.owner_personnel_id)
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
.where(*_business_filters(query))
)
def list_business_stats(
session: Session, query: BusinessStatsQuery, *, offset: int, limit: int
) -> list:
"""业务统计分页(业务日期倒序)。"""
stmt = _business_select(query).order_by(Business.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).all())
def count_business_stats(session: Session, query: BusinessStatsQuery) -> int:
"""业务统计计数(联客户表因关键字筛选涉及客户名)。"""
stmt = (
select(func.count())
.select_from(Business)
.outerjoin(Customer, Customer.id == Business.customer_id)
.where(*_business_filters(query))
)
return int(session.execute(stmt).scalar_one())
def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict:
"""业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。"""
perf_sq = _business_perf_subquery()
return (
session.execute(
select(
func.count().label("row_count"),
func.coalesce(func.sum(Business.total_ship_amount), 0).label("ship_total"),
func.coalesce(func.sum(Business.total_received_amount), 0).label("received_total"),
func.coalesce(func.sum(Business.total_sale_amount), 0).label("sale_total"),
func.coalesce(func.sum(perf_sq.c.perf), 0).label("performance_total"),
func.coalesce(func.sum(perf_sq.c.payable), 0).label("payable_total"),
)
.outerjoin(Customer, Customer.id == Business.customer_id)
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
.where(*_business_filters(query))
)
.mappings()
.one()
)
# ==== 回款统计(FR-031 之二)====
def _payment_filters(query: PaymentStatsQuery | PaymentDetailQuery) -> tuple:
filters = [*_ACTIVE_SINGLE]
if query.settlement_month:
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
if query.date_from is not None:
filters.append(PerformanceLedger.receipt_date >= query.date_from)
if query.date_to is not None:
filters.append(PerformanceLedger.receipt_date <= query.date_to)
if query.customer_type:
filters.append(PerformanceLedger.customer_type == query.customer_type)
if query.product_category:
filters.append(PerformanceLedger.product_category == query.product_category)
return tuple(filters)
def group_payment_by_days(session: Session, query: PaymentStatsQuery) -> list:
"""按(回款天数精确值, 客户类型, 产品类别)聚合绩效与应付。"""
return list(
session.execute(
select(
PerformanceLedger.payment_days,
PerformanceLedger.customer_type,
PerformanceLedger.product_category,
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count().label("ledger_count"),
)
.where(*_payment_filters(query))
.group_by(
PerformanceLedger.payment_days,
PerformanceLedger.customer_type,
PerformanceLedger.product_category,
)
).all()
)
def group_payment_receipt_by_days(session: Session, query: PaymentStatsQuery) -> list:
"""按维度聚合回款金额与笔数——金额侧必须按 receipt_id 去重。
二次分摊拆行使同一回款在台账出现多行,直接 SUM(receipts.amount) 会
按拆行数翻倍;台账拆行只改归属人,不改维度值(payment_days/客户类型/
产品类别),因此先对台账行 DISTINCT 出回款维度,再联回款表求和。
"""
distinct_receipts = (
select(
PerformanceLedger.receipt_id.label("receipt_id"),
PerformanceLedger.payment_days.label("payment_days"),
PerformanceLedger.customer_type.label("customer_type"),
PerformanceLedger.product_category.label("product_category"),
)
.where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None))
.distinct()
.subquery()
)
from app.models import Receipt
return list(
session.execute(
select(
distinct_receipts.c.payment_days,
distinct_receipts.c.customer_type,
distinct_receipts.c.product_category,
func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"),
func.count(distinct_receipts.c.receipt_id).label("receipt_count"),
)
.outerjoin(Receipt, Receipt.id == distinct_receipts.c.receipt_id)
.group_by(
distinct_receipts.c.payment_days,
distinct_receipts.c.customer_type,
distinct_receipts.c.product_category,
)
).all()
)
def group_payment_by_customer_type(session: Session, query: PaymentStatsQuery) -> list:
"""按客户类型聚合(绩效 + 回款金额)。"""
return _group_by_dimension(session, query, PerformanceLedger.customer_type)
def group_payment_by_product_category(session: Session, query: PaymentStatsQuery) -> list:
"""按产品类别聚合(绩效 + 回款金额)。"""
return _group_by_dimension(session, query, PerformanceLedger.product_category)
def _group_by_dimension(session: Session, query: PaymentStatsQuery, dim_column) -> list:
"""维度聚合共用实现(绩效侧 GROUP BY + 金额侧 DISTINCT 去重联回款)。"""
from app.models import Receipt
dim_perf = (
select(
dim_column.label("dim"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count().label("ledger_count"),
)
.where(*_payment_filters(query))
.group_by(dim_column)
)
dim_receipts = (
select(
dim_column.label("dim"),
PerformanceLedger.receipt_id.label("receipt_id"),
)
.where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None))
.distinct()
.subquery()
)
perf_rows = {row.dim: row for row in session.execute(dim_perf).all()}
receipt_rows = session.execute(
select(
dim_receipts.c.dim,
func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"),
)
.outerjoin(Receipt, Receipt.id == dim_receipts.c.receipt_id)
.group_by(dim_receipts.c.dim)
).all()
receipt_amounts = {row.dim: row.receipt_amount for row in receipt_rows}
return [
(
row.dim,
receipt_amounts.get(row.dim),
row.performance_total,
row.payable_total,
row.ledger_count,
)
for row in perf_rows.values()
]
def _bucket_condition(bucket: DayBucket) -> tuple:
"""区间桶的 days 过滤条件:none 桶 IS NULL,其余按 min/max 闭区间。"""
if bucket.code == "none":
return (PerformanceLedger.payment_days.is_(None),)
conds = []
if bucket.min_days is not None:
conds.append(PerformanceLedger.payment_days >= bucket.min_days)
if bucket.max_days is not None:
conds.append(PerformanceLedger.payment_days <= bucket.max_days)
return tuple(conds)
def list_payment_detail(
session: Session, query: PaymentDetailQuery, bucket: DayBucket, *, offset: int, limit: int
) -> list:
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
"""
from app.models import Receipt
stmt = (
select(
PerformanceLedger,
Receipt.amount.label("receipt_amount"),
Receipt.receipt_batch_no.label("receipt_batch_no"),
)
.outerjoin(Receipt, Receipt.id == PerformanceLedger.receipt_id)
.where(*_payment_filters(query), *_bucket_condition(bucket))
)
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
stmt = stmt.offset(offset).limit(limit)
return list(session.execute(stmt).all())
def count_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> int:
"""区间下钻计数(与 list_payment_detail 同筛选)。"""
return int(
session.execute(
select(func.count())
.select_from(PerformanceLedger)
.where(*_payment_filters(query), *_bucket_condition(bucket))
).scalar_one()
)

View File

@ -0,0 +1,123 @@
"""角色数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
`get_all_roles` 与 `get_roles_by_codes` 原位于 `user_repository`,
因角色管理成为独立模块而迁入本文件。
"""
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import Role, UserRole
# ==================== 读取 ====================
def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> Role | None:
"""按主键查询角色。不存在返回 None。
``for_update=True`` 走 ``SELECT ... FOR UPDATE`` 锁定角色行 —— 删除前的
「有用户绑定禁删」校验必须用它:校验与 DELETE 之间若发生并发绑定,
`user_roles` 的 CASCADE 会把新绑定一并静默剥离。锁行后,并发绑定的
外键检查会等待,删除得以基于最新状态做出(对方失败而非数据被吞)。
"""
if for_update:
stmt = select(Role).where(Role.id == role_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(Role, role_id)
def get_by_code(session: Session, role_code: str) -> Role | None:
"""按编码查询角色。不存在返回 None。"""
return session.execute(select(Role).where(Role.role_code == role_code)).scalar_one_or_none()
def get_roles_by_codes(session: Session, role_codes: list[str]) -> list[Role]:
"""按编码批量取角色。用于校验前端传来的角色是否存在。"""
if not role_codes:
return []
stmt = select(Role).where(Role.role_code.in_(role_codes))
return list(session.execute(stmt).scalars())
def get_all_roles(session: Session) -> list[Role]:
"""取全部启用角色,供前端下拉选择。"""
stmt = select(Role).where(Role.status == 1).order_by(Role.sort_order, Role.id)
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]:
"""分页查询角色(管理列表:含停用角色)。
排序与下拉口径一致(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)
return list(session.execute(stmt).scalars())
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
"""统计符合条件的角色总数,供分页使用。"""
stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
"""
if keyword:
stmt = stmt.where(
or_(
Role.role_code.contains(keyword, autoescape=True),
Role.role_name.contains(keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(Role.status == status)
return stmt
def count_users_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
"""批量统计各角色关联的用户数。
一次 GROUP BY 取回全部计数,而非在列表循环里逐行查询(N+1)。
"""
if not role_ids:
return {}
stmt = (
select(UserRole.role_id, func.count())
.where(UserRole.role_id.in_(role_ids))
.group_by(UserRole.role_id)
)
return {int(role_id): int(count) for role_id, count in session.execute(stmt)}
# ==================== 写入 ====================
def create(session: Session, role: Role) -> Role:
"""新增角色。只 flush 以取得自增 id。"""
session.add(role)
session.flush()
return role
def delete(session: Session, role: Role) -> None:
"""删除角色。
调用方(service)必须先校验该角色无用户绑定 —— `user_roles` 外键是
CASCADE,直接删会静默剥离绑定用户的权限。
"""
session.delete(role)
session.flush()

View File

@ -0,0 +1,112 @@
"""月度结算仓储:只读写数据并 flush;commit/rollback 在服务层。"""
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import MonthlySettlement, SettlementItem
from app.schemas.settlement import SettlementQuery
def get_by_id(session: Session, settlement_id: int) -> MonthlySettlement | None:
return session.get(MonthlySettlement, settlement_id)
def get_by_month(session: Session, month: str) -> MonthlySettlement | None:
"""按月份取月结行(settlement_month 全表唯一)。"""
return session.execute(
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
).scalar_one_or_none()
def add(session: Session, settlement: MonthlySettlement) -> MonthlySettlement:
session.add(settlement)
session.flush()
return settlement
def list_settlements(session: Session, query: SettlementQuery) -> list[MonthlySettlement]:
return list(
session.execute(
select(MonthlySettlement)
.where(*_filters(query))
.order_by(MonthlySettlement.settlement_month.desc())
.offset(query.offset)
.limit(query.page_size)
).scalars()
)
def count_settlements(session: Session, query: SettlementQuery) -> int:
return int(
session.execute(
select(func.count()).select_from(MonthlySettlement).where(*_filters(query))
).scalar_one()
)
def _filters(query: SettlementQuery) -> tuple:
filters = []
if query.status is not None:
filters.append(MonthlySettlement.status == query.status)
return tuple(filters)
def list_items(session: Session, settlement_id: int) -> list[SettlementItem]:
return list(
session.execute(
select(SettlementItem)
.where(SettlementItem.settlement_id == settlement_id)
.order_by(SettlementItem.id)
).scalars()
)
def replace_items(session: Session, settlement_id: int, rows: list[SettlementItem]) -> None:
"""全量替换明细(先删后 flush 再插,防御写法;不 commit)。"""
session.execute(delete_items_stmt(settlement_id))
session.flush()
for row in rows:
row.settlement_id = settlement_id
session.add(row)
session.flush()
def delete_items_stmt(settlement_id: int):
from sqlalchemy import delete
return delete(SettlementItem).where(SettlementItem.settlement_id == settlement_id)
def update_ledger_settlement_id(
session: Session, ledger_ids: list[int], settlement_id: int
) -> None:
"""月结生成时把纳入的台账行回填 settlement_id(append-only 的受控回填)。"""
from sqlalchemy import update
from app.models import PerformanceLedger
if ledger_ids:
session.execute(
update(PerformanceLedger)
.where(PerformanceLedger.id.in_(ledger_ids))
.values(settlement_id=settlement_id)
)
session.flush()
def clear_ledger_settlement_id(session: Session, settlement_id: int) -> None:
"""重生成前解绑:释放本结算名下台账行的 settlement_id(「未入结」判据复位)。
草稿/驳回态重新生成时,上一轮回填的行必须重新进入候选集——否则
``settlement_id IS NULL`` 会把它们永久排除,重生成漏算整月工资。
"""
from sqlalchemy import update
from app.models import PerformanceLedger
session.execute(
update(PerformanceLedger)
.where(PerformanceLedger.settlement_id == settlement_id)
.values(settlement_id=None)
)
session.flush()

View File

@ -0,0 +1,143 @@
"""发货数据访问:发货单行、明细行与业务号关联引用。
只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。
- 列表与详情统一 join 出 ``business_no``(业务主表 inner join,FK NOT
NULL,业务与发货单同存亡)
- 明细行汇总(sum_item_amount)是发货单 total_amount 重算的唯一数据源,
业务级汇总仍收口 business_service._recalc_totals
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session
from app.models import Business, Receipt, Shipment, ShipmentItem
def get_by_id(session: Session, shipment_id: int) -> Shipment | None:
"""按主键取发货单行(供摘要预取与编辑赋值;不锁行,写保护锚在业务行)。"""
return session.get(Shipment, shipment_id)
def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment, str] | None:
"""发货单行 + 业务号(DTO 与审计摘要共用)。"""
return session.execute(
select(Shipment, Business.business_no)
.join(Business, Business.id == Shipment.business_id)
.where(Shipment.id == shipment_id)
).first()
def _apply_filters(
stmt: Select,
*,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> Select:
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
if business_id is not None:
stmt = stmt.where(Shipment.business_id == business_id)
if ship_date_from is not None:
stmt = stmt.where(Shipment.ship_date >= ship_date_from)
if ship_date_to is not None:
stmt = stmt.where(Shipment.ship_date <= ship_date_to)
return stmt
def list_shipments(
session: Session,
*,
offset: int,
limit: int,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> list[tuple[Shipment, str]]:
"""分页查询发货单(附业务号),按 id 倒序。"""
stmt = _apply_filters(
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
business_id=business_id,
ship_date_from=ship_date_from,
ship_date_to=ship_date_to,
)
return list(
session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all()
)
def count_shipments(
session: Session,
*,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> int:
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
stmt = _apply_filters(
select(func.count())
.select_from(Shipment)
.join(Business, Business.id == Shipment.business_id),
business_id=business_id,
ship_date_from=ship_date_from,
ship_date_to=ship_date_to,
)
return int(session.execute(stmt).scalar_one())
def list_items(session: Session, shipment_id: int) -> list[ShipmentItem]:
"""发货明细行(详情回显与总金额重算共用)。"""
return list(
session.execute(
select(ShipmentItem)
.where(ShipmentItem.shipment_id == shipment_id)
.order_by(ShipmentItem.id)
).scalars()
)
def sum_item_amount(session: Session, shipment_id: int) -> Decimal | None:
"""明细行金额合计(发货单 total_amount 重算的唯一数据源)。"""
return session.execute(
select(func.sum(ShipmentItem.amount)).where(ShipmentItem.shipment_id == shipment_id)
).scalar_one()
def count_receipt_refs(session: Session, shipment_id: int) -> int:
"""被回款批次指定的引用数(删除保护依据,receipts.shipment_id)。"""
return int(
session.execute(
select(func.count()).select_from(Receipt).where(Receipt.shipment_id == shipment_id)
).scalar_one()
)
def create(session: Session, shipment: Shipment) -> Shipment:
"""新增发货单行。只 flush 以取得自增 id,单号唯一键冲突由 service 兜底。"""
session.add(shipment)
session.flush()
return shipment
def replace_items(session: Session, shipment_id: int, rows: list[ShipmentItem]) -> None:
"""全量替换明细:先删、立即 flush、再插入(business_repository 同款防御写法)。
同一 flush 中 SQLAlchemy 先处理 INSERT 后处理 DELETE,先删后插不落库
会在有唯一键的表上撞键;shipment_items 虽无唯一键,统一按此写法防御。
"""
session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id == shipment_id))
session.flush()
for row in rows:
row.shipment_id = shipment_id
session.add(row)
session.flush()
def delete_with_items(session: Session, shipment_id: int) -> None:
"""删除发货单及明细行(按依赖序:明细 → 主表,FK RESTRICT)。"""
session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id == shipment_id))
session.execute(delete(Shipment).where(Shipment.id == shipment_id))
session.flush()

View File

@ -1,14 +1,23 @@
"""用户与角色数据访问。
"""用户数据访问(角色相关的读取与维护见 `role_repository`)。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session, selectinload
from app.models import User
from app.models import Role, User, UserRole
from app.repositories.query_utils import fuzzy
def _base_query() -> Select[tuple[User]]:
"""用户查询基座。
预加载角色关联,避免列表渲染时逐行查角色(N+1)。
"""
return select(User).options(selectinload(User.user_roles).selectinload(UserRole.role))
def get_by_username(session: Session, username: str) -> User | None:
@ -34,3 +43,116 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None:
"""更新最后登录时间。只 flush,提交由 service 决定。"""
user.last_login_time = moment
session.flush()
# ==================== 列表查询 ====================
def list_users(
session: Session,
*,
offset: int,
limit: int,
username: str | None = None,
real_name: str | None = None,
status: int | None = None,
role_code: str | None = None,
) -> list[User]:
"""分页查询用户列表。
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
可能落在同一秒,按它排序的分页结果不确定 —— 翻页时会出现重复或遗漏。
id 是自增主键,天然唯一且与创建顺序一致。
"""
stmt = _base_query()
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if 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)
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:
"""统计符合条件的用户总数,供分页使用。"""
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)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
username: str | None,
real_name: str | None,
status: int | None,
) -> 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)
return stmt
# ==================== 写入 ====================
def create(session: Session, user: User) -> User:
"""新增用户。只 flush 以取得自增 id。"""
session.add(user)
session.flush()
return user
def delete(session: Session, user: User) -> None:
"""物理删除用户。
本项目对用户采用「删除即禁用」的**业务**约定(见模型注释),
但保留物理删除能力供测试清理与误建账号的彻底移除使用。
`user_roles` 与 `operation_logs` 的外键分别配置了 CASCADE 与 SET NULL,
故不会留下悬挂引用。
"""
session.delete(user)
session.flush()
# ==================== 角色关联 ====================
def replace_roles(session: Session, user: User, roles: list[Role]) -> None:
"""整体替换用户的角色。
采用「先清后建」而非逐条比对:角色数量极少(本期仅两个),
差异计算的复杂度换不来收益,且整体替换的语义更直白。
**clear 之后必须立刻 flush 一次**:SQLAlchemy 在同一事务的 flush 中
**先处理 INSERT 再处理 DELETE**,若中间不落库,重插与旧集合有交集的
(user_id, role_id)(编辑用户时保留既有角色就会发生)会撞
`uk_user_roles_user_id_role_id` 唯一键,报 1062。
**必须经 `user.user_roles` 集合追加,不能用 `session.add(UserRole(...))`**:
后者只把对象放进 session,不会同步到关系的已加载集合,于是同一事务内
再读 `user.user_roles` 仍是空的 —— 表现为「保存成功,但接口返回的角色
列表是 []」。经集合操作时 `user_id` 由关系自动填充,集合也立即反映新状态。
"""
user.user_roles.clear()
session.flush()
for role in roles:
user.user_roles.append(UserRole(role_id=role.id))
session.flush()

View File

@ -0,0 +1,66 @@
"""审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.core.response import PageQuery
class ApprovalStepOut(BaseModel):
"""节点记录(时间线;action=3 自动通过 operator 记「系统」)。"""
model_config = ConfigDict(from_attributes=True)
id: int
node: int
action: int
opinion: str | None = None
operator_id: int | None = None
operator_name: str
create_time: datetime
class ApprovalOrderOut(BaseModel):
"""审批单列表行。"""
model_config = ConfigDict(from_attributes=True)
id: int
order_no: str
order_type: int
ref_id: int
refer_no: str
status: int
submitter_id: int | None = None
submitter_name: str
create_time: datetime
class ApprovalOrderDetailOut(ApprovalOrderOut):
"""审批单详情:附节点时间线。"""
steps: list[ApprovalStepOut] = Field(default_factory=list)
class ApprovalQuery(PageQuery):
"""审批单列表查询条件。"""
keyword: str | None = Field(default=None, max_length=32, description="审批单号/引用单号,模糊")
status: int | None = Field(default=None, ge=1, le=5, description="状态筛选")
order_type: int | None = Field(default=None, ge=1, le=2, description="对象类型筛选")
class ApprovalActionIn(BaseModel):
"""节点动作:1 通过 / 2 驳回(驳回须填意见——空驳无从追溯)。"""
model_config = ConfigDict(str_strip_whitespace=True)
action: int = Field(ge=1, le=2, description="1通过 2驳回")
opinion: str | None = Field(default=None, max_length=255, description="审批意见")
@model_validator(mode="after")
def _check_opinion(self) -> "ApprovalActionIn":
if self.action == 2 and not self.opinion:
raise ValueError("驳回必须填写审批意见")
return self

View File

@ -0,0 +1,396 @@
"""业务管理的请求/响应模型(需求 2.2.1/2.2.4)。
金额/数量入参出参一律 str(服务层 Decimal/Fraction,全程禁 float);
派生值(毛利润额/毛利润率/折算毛利润额)不在写接口入参内 —— 落库列只有
源数据,派生值由毛利润视图实时计算。
"""
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.constants import (
PRODUCT_CATEGORIES,
PRODUCT_CATEGORY_MUNICIPAL_WATER,
PRODUCT_CATEGORY_RAIL_TRANSIT,
)
from app.core.response import PageQuery
# DECIMAL(14,2) 容量上限:整数部分 12 位(同 personnel.py 的金额校验口径)
AMOUNT_MAX = Decimal("999999999999.99")
# DECIMAL(14,4) 容量上限:整数部分 10 位
PRICE_MAX = Decimal("9999999999.9999")
def _check_amount(
value: str | None,
label: str,
cap: Decimal,
allow_positive_only: bool = False,
scale: int = 2,
) -> str | None:
"""字符串金额的通用校验:数字/有限/非负/容量/小数位上限。
小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱(工资系统红线)。
``scale`` 按列口径区分:金额/数量列 DECIMAL(14,2) 为 2;单价类列
DECIMAL(14,4) 为 4(回显 "5000.0000" 必须能原样提交,否则任何带
精度的历史单价都被编辑端点永远拒收)。
"""
if value is None:
return None
try:
amount = Decimal(value)
except InvalidOperation:
raise ValueError(f"{label}须为有效数字") from None
if not amount.is_finite():
raise ValueError(f"{label}须为有效数字")
if allow_positive_only:
if amount <= 0:
raise ValueError(f"{label}必须大于 0")
elif amount < 0:
raise ValueError(f"{label}不能为负数")
if amount > cap:
raise ValueError(f"{label}超出可录入上限")
if -amount.as_tuple().exponent > scale:
raise ValueError(f"{label}最多{'两' if scale == 2 else '四'}位小数")
return value
class BusinessItemIn(BaseModel):
"""业务产品明细行(需求 2.2.1「支持添加多种产品,录入指导价」)。
product_category 保留蛇形:该值与 M1 参数维度值域同源(枚举码本就是
蛇形);其余标识类字段驼峰兼容(snake/驼峰双入,同 customer.py 契约)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
product_id: int | None = Field(default=None, ge=1, description="产品ID;空=自由录入")
product_name: str = Field(min_length=1, max_length=128, description="产品名称")
spec_model: str | None = Field(
default=None, max_length=128, description="规格型号,空=空串归一"
)
unit: str | None = Field(default=None, max_length=16, description="计量单位")
product_category: str = Field(
description="产品类别:municipal_water/rail_transit(行级折算锚点)"
)
quantity: str = Field(description="数量,>0")
sale_price: str | None = Field(default=None, description="销售单价(元)")
cost_amount: str | None = Field(
default=None, description="成本额(元);毛利润额 = 销售额 − 成本额"
)
guided_price: str | None = Field(default=None, description="指导价(元)")
remark: str | None = Field(default=None, max_length=255)
@field_validator("product_category")
@classmethod
def _check_category(cls, value: str) -> str:
if value not in PRODUCT_CATEGORIES:
raise ValueError("产品类别不合法")
return value
@field_validator("quantity")
@classmethod
def _check_quantity(cls, value: str) -> str:
return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value
@field_validator("sale_price", "guided_price")
@classmethod
def _check_unit_prices(cls, value: str | None) -> str | None:
"""单价类字段按 DECIMAL(14,4) 口径校验(4 位小数)。"""
return _check_amount(value, "单价", PRICE_MAX, scale=4)
@field_validator("cost_amount")
@classmethod
def _check_cost(cls, value: str | None) -> str | None:
return _check_amount(value, "金额", AMOUNT_MAX)
class BusinessItemOut(BaseModel):
"""业务产品明细行响应。金额/数量一律 str。"""
model_config = ConfigDict(from_attributes=True)
id: int
product_id: int | None = None
product_name: str
spec_model: str | None = None
unit: str | None = None
product_category: str
quantity: str
sale_price: str | None = None
sale_amount: str
cost_amount: str | None = None
guided_price: str | None = None
remark: str | None = None
class BusinessOut(BaseModel):
"""业务响应(列表与写操作回包共用)。"""
model_config = ConfigDict(from_attributes=True)
id: int
business_no: str
customer_id: int
customer_name: str | None = None
business_date: date | None = None
status: int = 1
owner_personnel_id: int | None = None
owner_personnel_name: str | None = None
assistant_personnel_id: int | None = None
assistant_personnel_name: str | None = None
is_special: int = 0
special_note: str | None = None
total_sale_amount: str | None = None
total_cost_amount: str | None = None
total_ship_amount: str | None = None
total_received_amount: str | None = None
total_freight_amount: str | None = None
remark: str | None = None
class SecondaryAllocationIn(BaseModel):
"""二次分配参与人入参(2026-10-15 补充需求「二次分配方案」)。
比例**不要求合计 100%**(需求原文):余额归属待业务澄清(待澄清 #2),
服务层仅以 hint 展示合计/余额,不做强制校验。ratio 沿金额红线口径:
字符串入参、两位小数、超限拒绝不静默舍入。
"""
model_config = ConfigDict(str_strip_whitespace=True)
personnel_id: int = Field(ge=1, description="参与业务员ID(personnel.id)")
ratio: str = Field(description="分摊比例(0-100,两位小数)")
@field_validator("ratio")
@classmethod
def _check_ratio(cls, value: str) -> str:
return _check_amount(value, "分摊比例", Decimal("100"), allow_positive_only=True) or value
class SecondaryAllocationOut(BaseModel):
"""二次分配参与人响应(personnel_name 冗余固化,离职后回显有名)。"""
model_config = ConfigDict(from_attributes=True)
personnel_id: int
personnel_name: str
ratio: str
def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
"""同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示
(让 400 文案说「重复参与人」而不是裸唯一键冲突)。"""
seen: set[int] = set()
for item in items:
if item.personnel_id in seen:
raise ValueError(f"二次分配参与人重复:人员ID {item.personnel_id}")
seen.add(item.personnel_id)
class BusinessDetailOut(BusinessOut):
"""业务详情:附产品明细行与二次分配方案。"""
items: list[BusinessItemOut] = Field(default_factory=list)
secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list)
allocation_hint: str | None = Field(
default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)"
)
class BusinessCreateIn(BaseModel):
"""创建业务:主表信息 + 明细行数组(明细 ≥1 行)。
字段名全线蛇形直出(M1 既有契约:前端 api 封装与 schemas 同名对应,
仅分页信封 list/pageNum 驼峰)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
customer_id: int = Field(ge=1, description="客户ID")
business_date: date | None = Field(default=None, description="业务日期,ISO 格式")
owner_personnel_id: int | None = Field(
default=None, ge=1, description="负责营销人员ID,可空=待指定"
)
assistant_personnel_id: int | None = Field(default=None, ge=1, description="协助营销人员ID")
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
items: list[BusinessItemIn] = Field(min_length=1, description="产品明细行,至少 1 行")
secondary_allocations: list[SecondaryAllocationIn] = Field(
default_factory=list, description="二次分配方案(只出现在合同刚建立时;可空)"
)
remark: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def _check_special_note(self) -> "BusinessCreateIn":
"""特殊业务须有说明:标记的意义就在于可追溯「特殊在哪」。"""
if self.is_special == 1 and not self.special_note:
raise ValueError("特殊业务必须填写说明")
return self
@model_validator(mode="after")
def _check_allocations(self) -> "BusinessCreateIn":
_ensure_allocations_unique(self.secondary_allocations)
return self
class BusinessUpdateIn(BaseModel):
"""编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
model_config = ConfigDict(str_strip_whitespace=True)
customer_id: int = Field(ge=1, description="客户ID")
business_date: date | None = Field(default=None, description="业务日期,ISO 格式")
owner_personnel_id: int | None = Field(
default=None,
ge=1,
description="负责营销人员ID(编辑端点忽略,仅 /ownership 生效)",
)
assistant_personnel_id: int | None = Field(
default=None,
ge=1,
description="协助营销人员ID(编辑端点忽略)",
)
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
items: list[BusinessItemIn] = Field(
min_length=1, description="产品明细行,至少 1 行(全量替换)"
)
secondary_allocations: list[SecondaryAllocationIn] = Field(
default_factory=list, description="二次分配方案(全量替换;空数组=清空方案)"
)
remark: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def _check_special_note(self) -> "BusinessUpdateIn":
if self.is_special == 1 and not self.special_note:
raise ValueError("特殊业务必须填写说明")
return self
@model_validator(mode="after")
def _check_allocations(self) -> "BusinessUpdateIn":
_ensure_allocations_unique(self.secondary_allocations)
return self
class BusinessStatusIn(BaseModel):
"""状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。"""
model_config = ConfigDict(str_strip_whitespace=True)
status: int = Field(ge=1, le=3, description="目标状态:2已完成 3已终止")
reason: str | None = Field(default=None, max_length=255, description="变更原因(终止时必填)")
@model_validator(mode="after")
def _check_reason(self) -> "BusinessStatusIn":
if self.status == 3 and not self.reason:
raise ValueError("终止业务必须填写原因")
return self
class BusinessOwnershipIn(BaseModel):
"""业务归属变更(负责/协助;前后值写入留痕)。"""
model_config = ConfigDict(str_strip_whitespace=True)
new_owner_personnel_id: int | None = Field(default=None, ge=1)
new_assistant_personnel_id: int | None = Field(default=None, ge=1)
reason: str | None = Field(default=None, max_length=255)
class BusinessQuery(PageQuery):
"""业务列表查询条件(筛选参数驼峰 alias,customer.py 同款契约)。"""
keyword: str | None = Field(default=None, max_length=32, description="业务编号/公司名称,模糊")
status: int | None = Field(default=None, ge=1, le=3, description="状态筛选")
customer_id: int | None = Field(default=None, ge=1, description="客户筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="负责营销人员筛选")
date_from: date | None = Field(default=None, description="业务日期起(含)")
date_to: date | None = Field(default=None, description="业务日期止(含)")
class GrossProfitRowOut(BaseModel):
"""毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。"""
item_id: int
product_category: str
product_name: str
spec_model: str | None = None
quantity: str
sale_amount: str
cost_amount: str | None = None
"""成本缺失(未录入)时为 None,派生列同为 None 并置 missing_reason"""
profit_amount: str | None = None
profit_rate: str | None = None
threshold: str
converted_profit: str | None = None
missing_reason: str | None = None
class GrossProfitCategoryOut(BaseModel):
"""按产品类别的毛利润汇总(可计算行聚合;成本缺失行不计入)。"""
product_category: str
sale_amount: str
cost_amount: str
profit_amount: str
profit_rate: str
threshold: str
converted_profit: str
class GrossProfitOut(BaseModel):
"""毛利润数据视图(需求 2.2.4):行级 + 类别汇总。
口径注释(前端须展示):毛利润率 = 毛利润额 / 销售额,**不含运费**
(对照表 #2 作废需规含运费公式);阈值按 asOf 取 M1 参数快照;M3 核算
实际按回款到账日取参,本视图默认今日仅供录入期核对。
"""
business_id: int
as_of: date
rows: list[GrossProfitRowOut] = Field(default_factory=list)
categories: list[GrossProfitCategoryOut] = Field(default_factory=list)
missing_count: int = 0
note: str = "毛利润率与折算额不含运费;M3 核算按回款到账日取参数版本"
class OwnershipChangeLogOut(BaseModel):
"""业务归属变更留痕(人员删除后姓名为 None,固化 ID 兜底可追溯)。"""
id: int
business_id: int
old_owner_personnel_id: int | None = None
new_owner_personnel_id: int | None = None
old_assistant_personnel_id: int | None = None
new_assistant_personnel_id: int | None = None
old_owner_name: str | None = None
new_owner_name: str | None = None
change_reason: str | None = None
operator_id: int | None = None
operator_name: str
create_time: datetime
class BusinessStatusLogOut(BaseModel):
"""业务状态流转记录(详情页时间线;创建首条 old=new=1)。"""
model_config = ConfigDict(from_attributes=True)
id: int
business_id: int
old_status: int
new_status: int
change_reason: str | None = None
operator_id: int | None = None
operator_name: str
create_time: datetime
# 供 schema 外部引用的两类别常量(类别汇总展示用)
CATEGORY_LABELS = {
PRODUCT_CATEGORY_MUNICIPAL_WATER: "市政水利",
PRODUCT_CATEGORY_RAIL_TRANSIT: "轨道交通",
}

View File

@ -0,0 +1,90 @@
"""业务岗位角色的请求/响应模型。"""
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
# 岗位编码规则与系统角色一致:小写字母开头,后接小写字母/数字/下划线,长度 2–32。
# 编码会作为参数维度值(param_values.dimension_value)与核算模板绑定的锚点,
# 保持单一风格避免大小写/全角混用造成对照困难
BUSINESS_ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$")
class BusinessRoleOut(BaseModel):
"""岗位响应(列表与详情共用)。"""
model_config = ConfigDict(from_attributes=True)
id: int
role_code: str
role_name: str
calc_template: str
description: str | None = None
sort_order: int = 0
status: int = 1
is_builtin: int = 0
class TemplateParamOut(BaseModel):
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
code: str
name: str
class TemplateOptionOut(BaseModel):
"""公式模板下拉项。"""
code: str
name: str
description: str
params: list[TemplateParamOut]
class BusinessRoleCreateIn(BaseModel):
"""新增岗位。"""
model_config = ConfigDict(str_strip_whitespace=True)
role_code: str = Field(min_length=2, max_length=32, description="岗位编码,创建后不可修改")
role_name: str = Field(min_length=1, max_length=64, description="岗位名称")
calc_template: str = Field(min_length=1, max_length=32, description="计提公式模板编码")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
@field_validator("role_code")
@classmethod
def _check_code(cls, value: str) -> str:
if not BUSINESS_ROLE_CODE_PATTERN.match(value):
raise ValueError("岗位编码须以小写字母开头,仅可含小写字母、数字与下划线")
return value
class BusinessRoleUpdateIn(BaseModel):
"""编辑岗位。
**不含 role_code** —— 编码是参数维度值与模板绑定的锚点(同系统角色的保护理由)。
前端表单全量提交,故为全量替换语义。
"""
model_config = ConfigDict(str_strip_whitespace=True)
role_name: str = Field(min_length=1, max_length=64, description="岗位名称")
calc_template: str = Field(min_length=1, max_length=32, description="计提公式模板编码")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
class BusinessRoleStatusIn(BaseModel):
"""启用/禁用岗位。"""
status: int = Field(ge=0, le=1, description="1启用 0禁用")
class BusinessRoleQuery(PageQuery):
"""岗位列表查询条件。"""
keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")

View File

@ -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

View File

@ -0,0 +1,135 @@
"""客户管理的请求/响应模型(需求 2.1.3)。"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.core.response import PageQuery
class CustomerOut(BaseModel):
"""客户响应(列表、详情与写操作回包共用)。
owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的
脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕
「ID 固化、姓名随联表」同一口径。
"""
model_config = ConfigDict(from_attributes=True)
id: int
company_name: str
owner_name: str
contact_person: str | None = None
contact_phone: str | None = None
address: str | None = None
industry: str | None = None
customer_type: str
owner_personnel_id: int | None = None
owner_personnel_name: str | None = None
remark: str | None = None
class CustomerCreateIn(BaseModel):
"""新增客户。
owner_personnel_id 为**初始归属**,可空(客户先建档、后派归属也合法);
归属的后续调整不走本模型,统一走 ``PUT /{id}/ownership``(见
``CustomerUpdateIn`` 的说明)。字段名蛇形直出(M1 契约:前端 api 封装
与 schema 同名对应,仅分页信封 list/pageNum 驼峰)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
company_name: str = Field(min_length=1, max_length=128, description="公司名称")
owner_name: str = Field(
min_length=1,
max_length=64,
description="客户负责人姓名(客户公司的负责人人名,非系统内营销人员)",
)
contact_person: str | None = Field(default=None, max_length=64, description="联系人")
contact_phone: str | None = Field(default=None, max_length=20, description="联系电话")
address: str | None = Field(default=None, max_length=255, description="地址")
industry: str | None = Field(default=None, max_length=64, description="所属行业")
customer_type: str = Field(
min_length=1,
max_length=32,
description="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户",
)
owner_personnel_id: int | None = Field(
default=None,
ge=1,
description="归属负责营销人员ID(初始归属,可空)",
)
remark: str | None = Field(default=None, max_length=255, description="备注")
class CustomerUpdateIn(BaseModel):
"""编辑客户(全量替换,前端表单全量提交)。
**不含 owner_personnel_id** —— 归属调整统一走 ``PUT /{id}/ownership``,
只有那条路径才写归属变更留痕。若在本端点放开该字段,会出现「改了归属
却无留痕」的旁路,破坏留痕的 append-only 完整性;编辑表单即使提交
ownerPersonnelId,pydantic 默认忽略未声明字段,归属以归属端点为准。
"""
model_config = ConfigDict(str_strip_whitespace=True)
company_name: str = Field(min_length=1, max_length=128, description="公司名称")
owner_name: str = Field(min_length=1, max_length=64, description="客户负责人姓名")
contact_person: str | None = Field(default=None, max_length=64, description="联系人")
contact_phone: str | None = Field(default=None, max_length=20, description="联系电话")
address: str | None = Field(default=None, max_length=255, description="地址")
industry: str | None = Field(default=None, max_length=64, description="所属行业")
customer_type: str = Field(
min_length=1,
max_length=32,
description="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户",
)
remark: str | None = Field(default=None, max_length=255, description="备注")
class CustomerQuery(PageQuery):
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。"""
keyword: str | None = Field(
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
)
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
class CustomerOwnershipIn(BaseModel):
"""归属变更入参。
new_owner_personnel_id 可空:null 表示清空归属(客户暂无负责人也合法)。
"""
new_owner_personnel_id: int | None = Field(
default=None,
ge=1,
description="变更后归属人员ID,null=清空归属",
)
reason: str | None = Field(default=None, max_length=255, description="变更原因")
class OwnershipChangeLogOut(BaseModel):
"""归属变更留痕响应。
新旧归属人员姓名由 LEFT JOIN 带出,人员被删除后为 null;固化的
``*_personnel_id`` 始终原样返回 —— 留痕表对人员 ID 无外键固化
(见 `models/customer.py` 的论证),姓名可随人员消失,ID 不可。
"""
model_config = ConfigDict(from_attributes=True)
id: int
customer_id: int
old_owner_personnel_id: int | None = None
new_owner_personnel_id: int | None = None
old_owner_name: str | None = None
new_owner_name: str | None = None
change_reason: str | None = None
operator_id: int | None = None
operator_name: str
create_time: datetime

105
backend/app/schemas/log.py Normal file
View File

@ -0,0 +1,105 @@
"""日志查询的请求/响应模型。
操作日志与登录日志字段不同(前者含模块/操作/请求详情,后者含失败原因),
故各自定义 Query 与 Out,仅共用「用户名 + 结果 + 时间范围」的筛选基类。
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.response import PageQuery
class LogQueryBase(PageQuery):
"""日志公共查询条件:模糊用户名 + 结果 + 时间范围。"""
username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配")
success: int | None = Field(default=None, ge=0, le=1, description="1成功 0失败")
start_time: datetime | None = Field(default=None, description="起始时间(含)")
end_time: datetime | None = Field(default=None, description="结束时间(含)")
@field_validator("start_time", "end_time")
@classmethod
def _to_local_naive(cls, value: datetime | None) -> datetime | None:
"""统一为本地时区的 naive 时间。
库列存本地时间(容器 TZ=Asia/Shanghai),而 API 接受 ISO 8601
(可带 ``Z`` 等时区后缀)。不归一时有两种故障:aware 与 naive
混用比较抛 TypeError(穿透成 500);aware 直接透传给驱动会
**静默丢弃偏移**(查询窗口整体偏 8 小时且无任何提示)。
aware 先转系统本地时区、再摘除 tzinfo 是唯一正确姿势;
naive 视为本地时间原样保留。
"""
if value is not None and value.tzinfo is not None:
return value.astimezone().replace(tzinfo=None)
return value
@model_validator(mode="after")
def _check_time_range(self) -> "LogQueryBase":
if self.start_time and self.end_time and self.start_time > self.end_time:
raise ValueError("起始时间不能晚于结束时间")
return self
class OperationLogQuery(LogQueryBase):
"""操作日志查询条件。"""
module: str | None = Field(default=None, max_length=64, description="所属模块,精确匹配")
action: str | None = Field(default=None, max_length=32, description="操作类型,精确匹配")
class OperationLogOut(BaseModel):
"""操作日志列表项。
**不含 request_data**(TEXT,单行最大 60000 字节):列表页默认不展示它,
随列表返回会让翻页负担随日志表增长线性放大;展开详情走
`GET /operation-logs/{id}`(`OperationLogDetailOut`),前端按需加载。
"""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int | None = None
username: str
module: str
action: str
summary: str
method: str | None = None
path: str | None = None
ip: str | None = None
user_agent: str | None = None
success: int = 0
error_message: str | None = None
duration_ms: int | None = None
create_time: datetime | None = None
class OperationLogDetailOut(OperationLogOut):
"""操作日志详情:列表字段 + 请求参数。
`request_data` 入库时已经结构化 + 文本两层脱敏,可直接展示。
"""
request_data: str | None = None
class LoginLogQuery(LogQueryBase):
"""登录日志查询条件。"""
ip: str | None = Field(default=None, max_length=45, description="客户端IP,精确匹配")
class LoginLogOut(BaseModel):
"""登录日志响应。"""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int | None = None
username: str
ip: str | None = None
user_agent: str | None = None
success: int = 0
fail_reason: str | None = None
create_time: datetime | None = None

View File

@ -0,0 +1,238 @@
"""计提参数管理的请求/响应模型。
字段命名与 user/role 一致采用蛇形直出(前端 TS 接口同构)。
金额类入参用字符串承载(Decimal 解析),杜绝 float 精度损失。
"""
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.response import PageQuery
def _parse_amount(value: str, *, field_name: str, allow_negative: bool) -> str:
"""金额字符串校验:必须可被 Decimal 解析;默认拒绝负数(毛利润额除外)。"""
try:
amount = Decimal(value.strip())
except InvalidOperation as exc:
raise ValueError(f"{field_name}必须是有效数字:{value}") from exc
if not amount.is_finite():
# 'NaN' 的比较会抛 InvalidOperation、'Infinity' 会在后续计算抛溢出,均须在入口拦下
raise ValueError(f"{field_name}必须为有限数字:{value}")
if not allow_negative and amount < 0:
raise ValueError(f"{field_name}不得为负数:{value}")
return value
class ParamDimensionValueOut(BaseModel):
"""某参数单个维度的当前生效值。"""
dimension_value: str
value_text: str
effective_date: date | None = None
class ParamCatalogItemOut(BaseModel):
"""参数目录项:定义元数据 + 各维度截至查询时点的生效值。"""
param_code: str
param_name: str
param_group: str
value_type: str
unit: str | None = None
dimension_type: str
options: list[str] = Field(default_factory=list)
default_value: str | None = None
value_min: str | None = None
value_max: str | None = None
description: str | None = None
values: list[ParamDimensionValueOut] = Field(default_factory=list)
class ParamHistoryOut(BaseModel):
"""单参数的版本历史条目(append-only 留痕)。"""
version_no: str
dimension_value: str
old_value_text: str | None = None
value_text: str
effective_date: date
operator_name: str
create_time: datetime
class ParamChangeItemIn(BaseModel):
"""变更条目:参数 + 维度 + 新值。"""
model_config = ConfigDict(str_strip_whitespace=True)
param_code: str = Field(min_length=1, max_length=64)
dimension_value: str = Field(default="", max_length=32)
value: str = Field(min_length=1, max_length=64)
class ParamChangeIn(BaseModel):
"""提交变更批次。
change_type=1 立即生效(生效日期取今日);change_type=2 指定日期生效,
生效日期必填且不得早于今日(过去没有「预约」的意义,历史回溯走重算授权)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
change_type: int = Field(ge=1, le=2, description="生效方式:1立即生效 2指定日期生效")
effective_date: date | None = Field(default=None, description="指定日期生效时必填")
remark: str | None = Field(default=None, max_length=255)
items: list[ParamChangeItemIn] = Field(min_length=1, max_length=50)
@model_validator(mode="after")
def _check_effective_date(self) -> "ParamChangeIn":
if self.change_type == 2 and self.effective_date is None:
raise ValueError("指定日期生效时必须提供生效日期")
return self
class ParamChangeBatchOut(BaseModel):
"""变更批次(提交/撤回/列表共用)。"""
id: int
version_no: str
effective_date: date
change_type: int
remark: str | None = None
operator_name: str
item_count: int
create_time: datetime
class ParamBatchItemOut(BaseModel):
"""批次明细条目(旧值 → 新值)。"""
param_code: str
param_name: str
dimension_value: str
old_value_text: str | None = None
value_text: str
effective_date: date
class ParamBatchOut(BaseModel):
"""变更批次(列表项)。"""
id: int
version_no: str
effective_date: date
change_type: int
remark: str | None = None
operator_name: str
item_count: int
create_time: datetime
class ParamBatchDetailOut(BaseModel):
"""批次明细:批次头 + 逐项变更。"""
id: int
version_no: str
effective_date: date
change_type: int
remark: str | None = None
operator_name: str
item_count: int
create_time: datetime
items: list[ParamBatchItemOut] = Field(default_factory=list)
class ParamDiffOut(BaseModel):
"""两日期快照对比项(全量参数,changed 标记是否变化)。"""
param_code: str
param_name: str
param_group: str
dimension_value: str
old_value: str | None = None
new_value: str | None = None
changed: bool
class ParamPreviewIn(BaseModel):
"""计算预览入参。金额用字符串承载,Decimal 解析。"""
model_config = ConfigDict(str_strip_whitespace=True)
business_role: str | None = Field(
default=None, max_length=32, description="岗位编码,如 salesperson"
)
customer_type: str | None = Field(
default=None, max_length=32, description="客户类型:personal个人户 enterprise企业户"
)
product_category: str | None = Field(
default=None, max_length=32, description="产品类别:municipal_water/rail_transit"
)
ship_date: date = Field(description="发货日")
receipt_date: date = Field(description="回款到账日")
revenue: str = Field(min_length=1, description="销售额(不含运费)")
profit: str = Field(min_length=1, description="毛利润额(可为负)")
received_amount: str = Field(min_length=1, description="本笔回款金额")
barter: bool = Field(default=False, description="是否以物抵顶")
probation: bool = Field(default=False, description="是否试用期")
@field_validator("revenue", "received_amount")
@classmethod
def _check_non_negative(cls, value: str, info) -> str:
return _parse_amount(value, field_name=info.field_name, allow_negative=False)
@field_validator("profit")
@classmethod
def _check_profit(cls, value: str, info) -> str:
return _parse_amount(value, field_name=info.field_name, allow_negative=True)
@model_validator(mode="after")
def _check_dates(self) -> "ParamPreviewIn":
if self.receipt_date < self.ship_date:
raise ValueError("回款到账日不得早于发货日")
return self
class ParamUsedOut(BaseModel):
"""预览实际用到的参数(对账透明:取值与生效时点)。"""
param_code: str
value_text: str
effective_date: date | None = None
class ParamPreviewOut(BaseModel):
"""计算预览出参。分数串(如 97/10000)为精确值,金额为 Decimal 串。"""
payment_days: int
rate: str
rate_text: str
profit_rate: str
converted_profit: str
performance_amount: str
barter_applied: bool
performance_after_barter: str | None = None
start_payment_ok: bool
payment_weight_r: int
payment_weight: str
payable_amount: str
probation_note: str | None = None
params_used: list[ParamUsedOut] = Field(default_factory=list)
class ParamBatchQuery(PageQuery):
"""变更记录查询条件。"""
start_date: date | None = Field(default=None, description="生效日期起(含)")
end_date: date | None = Field(default=None, description="生效日期止(含)")
class ParamHistoryQuery(PageQuery):
"""参数历史查询条件。"""
dimension_value: str | None = Field(
default=None, max_length=32, description="按维度值筛选,空为全部"
)

View File

@ -0,0 +1,149 @@
"""营销人员的请求/响应模型(需求 2.1.1)。"""
from datetime import date
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
# salary_standard 列为 Numeric(12,2):整数部分最多 10 位。
# 工资系统金额红线——入参出参一律 str,服务层转 Decimal,全程不经 float
SALARY_MAX = Decimal("9999999999.99")
class PersonnelRegionOut(BaseModel):
"""辐射区域条目响应。"""
model_config = ConfigDict(from_attributes=True)
id: int
region_name: str
remark: str | None = None
class PersonnelPlanOut(BaseModel):
"""拓展计划条目响应。"""
model_config = ConfigDict(from_attributes=True)
id: int
plan_period: str
plan_content: str
target_desc: str | None = None
class PersonnelOut(BaseModel):
"""人员响应(列表与写操作回包共用)。"""
model_config = ConfigDict(from_attributes=True)
id: int
personnel_name: str
business_role_id: int
role_name: str | None = None
manager_id: int | None = None
manager_name: str | None = None
phone: str | None = None
hire_date: date | None = None
is_probation: int = 0
salary_standard: str | None = None
status: int = 1
remark: str | None = None
class PersonnelDetailOut(PersonnelOut):
"""人员详情:附区域与拓展计划,供编辑抽屉一次回显。"""
regions: list[PersonnelRegionOut] = Field(default_factory=list)
plans: list[PersonnelPlanOut] = Field(default_factory=list)
class PersonnelCreateIn(BaseModel):
"""新增人员。"""
model_config = ConfigDict(str_strip_whitespace=True)
personnel_name: str = Field(min_length=1, max_length=64, description="姓名")
business_role_id: int = Field(ge=1, description="岗位角色ID")
manager_id: int | None = Field(default=None, ge=1, description="直属营销经理ID,空=无")
phone: str | None = Field(default=None, max_length=20, description="联系电话")
hire_date: date | None = Field(default=None, description="入职日期,ISO 格式")
is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否")
salary_standard: str | None = Field(
default=None, description="月工资标准(元),字符串形式的数字"
)
remark: str | None = Field(default=None, max_length=255)
@field_validator("salary_standard")
@classmethod
def _check_salary_standard(cls, value: str | None) -> str | None:
"""按 Numeric(12,2) 的容量校验字符串金额。
金额以字符串进出避免 float 精度损失(项目金额红线);
小数位超过两位直接拒绝而非静默舍入——舍入即算错钱。
"""
if value is None:
return None
try:
amount = Decimal(value)
except InvalidOperation:
raise ValueError("工资标准须为有效数字") from None
if not amount.is_finite():
raise ValueError("工资标准须为有效数字")
if amount < 0:
raise ValueError("工资标准不能为负数")
if amount > SALARY_MAX:
raise ValueError("工资标准超出可录入上限")
if -amount.as_tuple().exponent > 2:
raise ValueError("工资标准最多两位小数")
return value
class PersonnelUpdateIn(PersonnelCreateIn):
"""编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。"""
class PersonnelStatusIn(BaseModel):
"""在职/离职切换。"""
status: int = Field(ge=0, le=1, description="1在职 0离职")
class RegionItemIn(BaseModel):
"""辐射区域条目。"""
model_config = ConfigDict(str_strip_whitespace=True)
region_name: str = Field(min_length=1, max_length=128, description="区域名称")
remark: str | None = Field(default=None, max_length=255)
class RegionReplaceIn(BaseModel):
"""辐射区域全量替换。空列表即清空该人员的全部区域。"""
items: list[RegionItemIn] = Field(default_factory=list, description="全量条目")
class PlanItemIn(BaseModel):
"""拓展计划条目。"""
model_config = ConfigDict(str_strip_whitespace=True)
plan_period: str = Field(min_length=1, max_length=32, description="计划期间,如 2026Q1")
plan_content: str = Field(min_length=1, max_length=500, description="计划内容")
target_desc: str | None = Field(default=None, max_length=255, description="指标说明")
class PlanReplaceIn(BaseModel):
"""拓展计划全量替换。空列表即清空该人员的全部计划。"""
items: list[PlanItemIn] = Field(default_factory=list, description="全量条目")
class PersonnelQuery(PageQuery):
"""人员列表查询条件。"""
keyword: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选:1在职 0离职")
business_role_id: int | None = Field(default=None, ge=1, description="岗位筛选")

View File

@ -0,0 +1,97 @@
"""产品管理的请求/响应模型。
数量与单价(stock_quantity/sale_price)入参出参一律用**字符串**承载:
前端 JS 的 number 无法精确表示全部十进制小数,字符串是金额红线的入口闸门,
Decimal 转换统一收敛在 service 层。字段命名与 user/role/param 一致采用
蛇形直出(前端 TS 接口同构)。
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount
class ProductOut(BaseModel):
"""产品响应(列表与详情共用)。数量/单价按红线以字符串出参。"""
id: int
product_name: str
spec_model: str
unit: str | None = None
stock_quantity: str | None = None
sale_price: str | None = None
product_category: str
status: int = 1
remark: str | None = None
create_time: datetime
update_time: datetime
class ProductCreateIn(BaseModel):
"""新增产品。
spec_model 允许缺省/空:service 层归一为空串(组合唯一键
uk_products_name_spec 对 NULL 不去重,归 NULL 会放行重名同规格数据)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
product_name: str = Field(min_length=1, max_length=128, description="产品名称")
spec_model: str | None = Field(
default=None, max_length=128, description="规格型号,空归一为空串"
)
unit: str | None = Field(default=None, max_length=16, description="计量单位")
stock_quantity: str | None = Field(default=None, description="数量,非负数字字符串")
sale_price: str | None = Field(default=None, description="销售单价(元),非负数字字符串")
product_category: str = Field(min_length=1, max_length=32, description="产品类别")
remark: str | None = Field(default=None, max_length=255)
@field_validator("stock_quantity")
@classmethod
def _check_stock_quantity(cls, value: str | None) -> str | None:
"""空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
复用 _check_amount:容量/小数位与 DECIMAL(14,2) 列口径一致
(此前私写的 _parse_non_negative 不查小数位与容量,属同语义
字段两处口径漂移)。
"""
if not value:
return None
return _check_amount(value, "数量", AMOUNT_MAX)
@field_validator("sale_price")
@classmethod
def _check_sale_price(cls, value: str | None) -> str | None:
"""空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
scale=4 与列口径 Numeric(14,4) 一致 —— 带精度的历史单价必须能
原样提交(「编辑任何带精度历史行都失败」的 fba8cfc 教训)。
"""
if not value:
return None
return _check_amount(value, "销售单价", PRICE_MAX, scale=4)
class ProductUpdateIn(ProductCreateIn):
"""编辑产品。
前端表单全量提交,故为全量替换语义;**不含 status** —— 启用/停用是
独立操作(走 PUT /{id}/status),避免编辑表单误触停用。
"""
class ProductStatusIn(BaseModel):
"""启用/停用产品。"""
status: int = Field(ge=0, le=1, description="1启用 0停用")
class ProductQuery(PageQuery):
"""产品列表查询条件。"""
keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配")
product_category: str | None = Field(default=None, max_length=32, description="按类别筛选")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")

View File

@ -0,0 +1,79 @@
"""客户评级等级配置的请求/响应模型。
分值(min_score/max_score)入参出参一律用**字符串**承载:前端 JS 的 number
无法精确表示全部十进制小数,字符串是分值红线的入口闸门,Decimal 转换统一
收敛在 service 层(与 schemas/product.py 同一口径)。
注意:schema 只拦**格式**(可解析、有限数);0≤min≤max≤100 与区间不重叠
是横跨两字段/多行的业务值域规则,在 service 层审计上下文内统一判定 ——
值域校验失败的 400 也要留痕(2026-10-13 定稿口径)。
"""
from datetime import datetime
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, ConfigDict, Field, field_validator
def _parse_score(value: str, *, label: str) -> str:
"""分值字符串格式校验:必须可被 Decimal 解析且为有限数。
``NaN`` 参与比较抛 InvalidOperation、``Infinity`` 落库即溢出,均须在
入口用 ``is_finite()`` 拦下(与 schemas/product.py 的 _parse_non_negative
同口径);非负与 ≤100 属业务值域,留给 service 层在审计上下文内判定。
"""
try:
score = Decimal(value.strip())
except InvalidOperation as exc:
raise ValueError(f"{label}必须是有效数字:{value}") from exc
if not score.is_finite():
raise ValueError(f"{label}必须为有限数字:{value}")
return value
class RatingGradeOut(BaseModel):
"""评级等级响应(列表直出)。分值按红线以字符串出参。"""
id: int
grade_name: str
min_score: str
max_score: str
sort_order: int
remark: str | None = None
create_time: datetime
update_time: datetime
class RatingGradeCreateIn(BaseModel):
"""新增评级等级。
min_score/max_score 必填:区间是判定表的判定依据,缺省无从判定。
sort_order 缺省 0(列默认值同源),判定按其升序取首个命中等级。
"""
model_config = ConfigDict(str_strip_whitespace=True)
grade_name: str = Field(min_length=1, max_length=32, description="等级名称(如 A/优秀)")
min_score: str = Field(description="总分下限(含),0-100 数字字符串")
max_score: str = Field(description="总分上限(展示用),0-100 数字字符串")
sort_order: int = Field(default=0, ge=0, description="判定顺序,升序")
remark: str | None = Field(default=None, max_length=255, description="说明")
@field_validator("min_score")
@classmethod
def _check_min_score(cls, value: str) -> str:
"""总分下限须为有限数字(值域 0-100 由 service 层判定)。"""
return _parse_score(value, label="总分下限")
@field_validator("max_score")
@classmethod
def _check_max_score(cls, value: str) -> str:
"""总分上限须为有限数字(值域 0-100 由 service 层判定)。"""
return _parse_score(value, label="总分上限")
class RatingGradeUpdateIn(RatingGradeCreateIn):
"""编辑评级等级。
前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。
"""

View File

@ -0,0 +1,102 @@
"""回款管理的请求/响应模型(需求 2.2.3)。
金额入参出参一律 str(服务层 Decimal,全程禁 float);字段契约**纯蛇形
直出**(2026-10-13 定稿,无驼峰 alias)。
payment_days 是派生值(发货日 → 回款日的天数,对照表 #1),不在写接口
入参内 —— 落库列只有 shipment_id 源数据,派生值实时计算,发货批次变更
后无需回写历史。
"""
from datetime import date
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
# 金额校验是工资系统的红线口径(数字/非负/容量/两位小数拒绝),跨 schema
# 单点复用 T7 定稿工具,禁止各处复制导致口径漂移;AMOUNT_MAX 即
# DECIMAL(14,2) 的容量上限,与回款列容量同源
from app.schemas.business import AMOUNT_MAX, _check_amount
class ReceiptCreateIn(BaseModel):
"""新增回款:业务必选,发货批次可暂不指定(手动指定,对照表 #18)。
shipment_id 指向的发货单必须与本回款同属一个业务(服务层强校验,
数据完整性红线);shipment_id 可空 = 暂未指定,指定后回款天数方可派生。
"""
model_config = ConfigDict(str_strip_whitespace=True)
business_id: int = Field(ge=1, description="业务ID")
shipment_id: int | None = Field(default=None, ge=1, description="发货单ID,可空=暂未指定")
receipt_date: date = Field(description="回款到账日(M3 按此日匹配参数版本)")
amount: str = Field(description="回款金额(元),≥0,最多两位小数")
is_barter: int = Field(default=0, ge=0, le=1, description="以物抵顶标记:1是 0否")
remark: str | None = Field(default=None, max_length=255, description="备注")
@field_validator("amount")
@classmethod
def _check_amount_value(cls, value: str) -> str:
return _check_amount(value, "回款金额", AMOUNT_MAX) or value
class ReceiptUpdateIn(BaseModel):
"""编辑回款(全量替换语义)。
shipment_id 传 None = 清除指定(回到「暂未指定」);amount 是必填项,
全量替换下不存在「不传即不变」的歧义。已核算(settlement_status=1)
的回款在服务层禁改,schema 层不感知该状态。
"""
model_config = ConfigDict(str_strip_whitespace=True)
shipment_id: int | None = Field(
default=None, ge=1, description="发货单ID,可空=暂未指定(传空清除既有指定)"
)
receipt_date: date = Field(description="回款到账日")
amount: str = Field(description="回款金额(元),≥0,最多两位小数")
is_barter: int = Field(default=0, ge=0, le=1, description="以物抵顶标记:1是 0否")
remark: str | None = Field(default=None, max_length=255, description="备注")
@field_validator("amount")
@classmethod
def _check_amount_value(cls, value: str) -> str:
return _check_amount(value, "回款金额", AMOUNT_MAX) or value
class ReceiptOut(BaseModel):
"""回款响应(列表与写操作回包共用)。
business_no/shipment_no 为关联冗余展示;payment_days 为派生值:
未指定发货批次时为 None,指定后 = 发货日到回款日的天数(发货日首日
计第 1 天)。
"""
model_config = ConfigDict(from_attributes=True)
id: int
receipt_batch_no: str
business_id: int
business_no: str
shipment_id: int | None = None
shipment_no: str | None = None
receipt_date: date
amount: str
is_barter: int
settlement_status: int
payment_days: int | None = None
remark: str | None = None
class ReceiptQuery(PageQuery):
"""回款列表查询条件(筛选参数纯蛇形直出,同 BusinessQuery 契约)。"""
business_id: int | None = Field(default=None, ge=1, description="业务筛选")
date_from: date | None = Field(default=None, description="回款到账日起(含)")
date_to: date | None = Field(default=None, description="回款到账日止(含)")
is_barter: int | None = Field(default=None, ge=0, le=1, description="以物抵顶筛选:1是 0否")
settlement_status: int | None = Field(
default=None, ge=0, le=1, description="核算状态筛选:0未核算 1已核算"
)

View File

@ -0,0 +1,269 @@
"""报表统计的请求/响应模型(M4 ⑤)。
金额与比例一律 `Decimal`(pydantic v2 序列化为字符串,零精度损失);
比例原样输出 `rate_text`(如 88/9000),禁止转 float 或截断展示。
"""
from datetime import date
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from app.core.response import PageQuery
# ==== 查询入参 ====
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="岗位筛选")
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="仅看某经理团队")
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="产品类别筛选")
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="产品类别筛选")
# ==== 响应出参 ====
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
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% 全公司分成(同一基数,各团队不互斥)")
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% 全公司分成")
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
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

View File

@ -0,0 +1,80 @@
"""角色管理的请求/响应模型。
`RoleOut` 原位于 `schemas/user.py`,因角色管理成为独立模块而迁入本文件 ——
用户表单的角色下拉与角色管理列表共用同一响应模型。
"""
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
# 角色编码规则:小写字母开头,后接小写字母/数字/下划线,长度 2–32(与列宽一致)。
# 不允许大写与中文 —— 编码会出现在授权判断(`ROLE_ADMIN` 常量按编码比对)与
# 操作日志里,保持单一风格避免大小写/全角混用造成对照困难
ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$")
class RoleOut(BaseModel):
"""角色基础响应(用户表单下拉与角色列表共用)。"""
model_config = ConfigDict(from_attributes=True)
id: int
role_code: str
role_name: str
description: str | None = None
sort_order: int = 0
status: int = 1
class RoleManageOut(RoleOut):
"""角色管理列表项:额外携带绑定用户数,供前端提示删除保护的原因。"""
user_count: int = 0
class RoleCreateIn(BaseModel):
"""新增角色。"""
model_config = ConfigDict(str_strip_whitespace=True)
role_code: str = Field(min_length=2, max_length=32, description="角色编码,创建后不可修改")
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
@field_validator("role_code")
@classmethod
def _check_code(cls, value: str) -> str:
if not ROLE_CODE_PATTERN.match(value):
raise ValueError("角色编码须以小写字母开头,仅可含小写字母、数字与下划线")
return value
class RoleUpdateIn(BaseModel):
"""编辑角色。
**不含 role_code** —— 编码是授权判定的锚点,修改会让既有授权语义静默改变。
前端表单全量提交,故为全量替换语义。
"""
model_config = ConfigDict(str_strip_whitespace=True)
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
class RoleStatusIn(BaseModel):
"""启用/禁用角色。"""
status: int = Field(ge=0, le=1, description="1启用 0禁用")
class RoleQuery(PageQuery):
"""角色列表查询条件。"""
keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")

View File

@ -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="公司名称")

View File

@ -0,0 +1,95 @@
"""月度结算的请求/响应模型(M3 ④)。"""
import re
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
_MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
def _check_month(value: str) -> str:
"""月份格式 YYYY-MM(settlement_month 全表唯一的维度)。"""
if not _MONTH_RE.match(value):
raise ValueError("月份格式须为 YYYY-MM")
return value
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="扣补说明")

View File

@ -0,0 +1,138 @@
"""发货管理的请求/响应模型(需求 2.2.2)。
金额/数量入参出参一律 str(服务层 Decimal 运算,全程禁 float),容量与
小数位校验复用 business.py 的 ``_check_amount``(同一套口径,防两处漂移);
字段契约全线蛇形直出(2026-10-13 定稿:无驼峰 alias)。
amount(行金额)与 total_amount(发货总金额)不在入参内 —— 由服务层按
数量×单价 计算固化,不信任前端传值;运费仅记录展示,不计入毛利润口径。
"""
from datetime import date
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount
class ShipmentItemIn(BaseModel):
"""发货产品明细行(product_id 空=自由录入;价格口径两可,可空人工补)。"""
model_config = ConfigDict(str_strip_whitespace=True)
product_id: int | None = Field(default=None, ge=1, description="产品ID;空=自由录入")
product_name: str = Field(min_length=1, max_length=128, description="产品名称")
spec_model: str | None = Field(
default=None, max_length=128, description="规格型号,空=空串归一"
)
unit: str | None = Field(default=None, max_length=16, description="计量单位")
quantity: str = Field(description="数量,>0")
sale_price: str | None = Field(
default=None, description="发货单价(元),可空=待人工补价,补价前行金额记 0"
)
@field_validator("quantity")
@classmethod
def _check_quantity(cls, value: str) -> str:
return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value
@field_validator("sale_price")
@classmethod
def _check_price(cls, value: str | None) -> str | None:
"""发货单价列 DECIMAL(14,4):按 4 位小数口径(回显可原样提交)。"""
return _check_amount(value, "单价", PRICE_MAX, scale=4)
class ShipmentItemOut(BaseModel):
"""发货产品明细行响应。金额/数量一律 str。"""
model_config = ConfigDict(from_attributes=True)
id: int
product_id: int | None = None
product_name: str
spec_model: str | None = None
unit: str | None = None
quantity: str
sale_price: str | None = None
amount: str
class ShipmentOut(BaseModel):
"""发货单响应(列表与写操作回包共用);business_no 为 join 附带列。"""
model_config = ConfigDict(from_attributes=True)
id: int
shipment_no: str
business_id: int
business_no: str | None = None
ship_date: date
freight_amount: str
total_amount: str
remark: str | None = None
class ShipmentDetailOut(ShipmentOut):
"""发货单详情:附产品明细行。"""
items: list[ShipmentItemOut] = Field(default_factory=list)
class ShipmentCreateIn(BaseModel):
"""创建发货单:业务维度 + 明细行数组(明细 ≥1 行)。
字段名全线蛇形直出(同 business.py 契约,仅分页信封 list/pageNum 驼峰)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
business_id: int = Field(ge=1, description="业务ID")
ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)")
freight_amount: str | None = Field(
default=None, description="运费金额(元),空记 0;仅记录展示,不计入毛利润口径"
)
items: list[ShipmentItemIn] = Field(min_length=1, description="发货明细行,至少 1 行")
remark: str | None = Field(default=None, max_length=255)
@field_validator("freight_amount")
@classmethod
def _check_freight(cls, value: str | None) -> str | None:
"""运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。"""
return _check_amount(value, "运费金额", AMOUNT_MAX)
class ShipmentUpdateIn(BaseModel):
"""编辑发货单(明细全量替换)。
business_id 不在编辑入参内 —— 发货单挂靠的业务创建时定死,改挂会让
已指定本发货单的回款语义断裂。
"""
model_config = ConfigDict(str_strip_whitespace=True)
ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)")
freight_amount: str | None = Field(default=None, description="运费金额(元),空记 0")
items: list[ShipmentItemIn] = Field(
min_length=1, description="发货明细行,至少 1 行(全量替换)"
)
remark: str | None = Field(default=None, max_length=255)
@field_validator("freight_amount")
@classmethod
def _check_freight(cls, value: str | None) -> str | None:
"""运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。"""
return _check_amount(value, "运费金额", AMOUNT_MAX)
class ShipmentQuery(PageQuery):
"""发货单列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
business_id 可空=全量:发货单以业务维度查看为主(一期),但单号可
独立检索,不强制挂业务条件。
"""
business_id: int | None = Field(default=None, ge=1, description="业务筛选,空=全部")
ship_date_from: date | None = Field(default=None, description="发货日期起(含)")
ship_date_to: date | None = Field(default=None, description="发货日期止(含)")

113
backend/app/schemas/user.py Normal file
View File

@ -0,0 +1,113 @@
"""用户管理的请求/响应模型(角色相关模型见 `role.py`)。
与 `auth.py` 的分工:`auth.py` 管登录链路(验证码、登录、当前用户),
本模块管**用户管理**(增删改查、重置密码、启停、角色分配)。
字段名驼峰(规范约定)与蛇形属性之间用 alias 映射,与响应模型保持同一套做法。
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES
from app.core.response import PageQuery
def _validate_password_bytes(value: str) -> str:
"""按**字节数**校验密码长度。
必须在这里拦下:bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError,
而中文一个字符占 3 字节 —— 按字符数校验(如 max_length=20)根本拦不住。
与 `auth.py` 的登录校验共用同一规则,避免两处口径不一致。
"""
length = len(value.encode("utf-8"))
if length < PASSWORD_MIN_BYTES:
raise ValueError(f"密码不足 {PASSWORD_MIN_BYTES} 字节")
if length > PASSWORD_MAX_BYTES:
raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限")
return value
class UserOut(BaseModel):
"""用户列表/详情响应。
不含 password —— 密码哈希绝不外泄,这是硬约束而非疏漏。
"""
model_config = ConfigDict(from_attributes=True)
id: int
username: str
real_name: str | None = None
phone: str | None = None
email: str | None = None
status: int = Field(description="状态:1启用 0禁用")
roles: list[str] = Field(default_factory=list, description="角色编码列表")
last_login_time: datetime | None = None
remark: str | None = None
create_time: datetime | None = None
class UserCreateIn(BaseModel):
"""新增用户。"""
model_config = ConfigDict(str_strip_whitespace=True)
username: str = Field(min_length=2, max_length=64, description="登录用户名,须唯一")
password: str = Field(description="初始密码")
real_name: str | None = Field(default=None, max_length=64)
phone: str | None = Field(default=None, max_length=20)
email: str | None = Field(default=None, max_length=128)
roles: list[str] = Field(default_factory=list, description="角色编码列表,至少一个")
remark: str | None = Field(default=None, max_length=255)
@field_validator("password")
@classmethod
def _check_password(cls, value: str) -> str:
return _validate_password_bytes(value)
class UserUpdateIn(BaseModel):
"""编辑用户。
**不含 username** —— 用户名是登录凭据与审计追溯的锚点,改名会让历史
操作日志与登录日志对不上。确需改名应走专门的流程(本期不提供)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
real_name: str | None = Field(default=None, max_length=64)
phone: str | None = Field(default=None, max_length=20)
email: str | None = Field(default=None, max_length=128)
roles: list[str] | None = Field(default=None, description="传 null 表示不改角色")
remark: str | None = Field(default=None, max_length=255)
class PasswordResetIn(BaseModel):
"""重置密码(管理员操作,不需要原密码)。"""
new_password: str = Field(description="新密码")
@field_validator("new_password")
@classmethod
def _check_password(cls, value: str) -> str:
return _validate_password_bytes(value)
class UserStatusIn(BaseModel):
"""启用/禁用用户。"""
status: int = Field(ge=0, le=1, description="1启用 0禁用")
class UserQuery(PageQuery):
"""用户列表查询条件。
继承分页基类,额外支持按用户名/姓名模糊、按状态与角色筛选。
"""
username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配")
real_name: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
role_code: str | None = Field(default=None, max_length=32, description="角色编码筛选")

View File

@ -0,0 +1,377 @@
"""审批流业务编排(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 datetime import date
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_APPROVE,
ACTION_REJECT,
MODULE_APPROVAL,
NO_PREFIX_APPROVAL,
ROLE_FINANCE,
ROLE_LEADER,
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import ApprovalOrder, ApprovalStep, User
from app.models.approval import (
APPROVAL_ACTION_APPROVE,
APPROVAL_ACTION_AUTO_APPROVE,
APPROVAL_ACTION_REJECT,
APPROVAL_ACTION_WITHDRAW,
APPROVAL_NODE_FINANCE,
APPROVAL_NODE_LEADER,
APPROVAL_NODE_WITHDRAW,
APPROVAL_NODES,
APPROVAL_STATUS_APPROVED,
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
APPROVAL_STATUS_REJECTED,
APPROVAL_STATUS_WITHDRAWN,
)
from app.repositories import user_repository
from app.schemas.approval import (
ApprovalActionIn,
ApprovalOrderDetailOut,
ApprovalOrderOut,
ApprovalQuery,
ApprovalStepOut,
)
from app.services import audit_service, param_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.no_generator import next_no
logger = logging.getLogger(__name__)
# 开关参数编码(与 param_registry 种子同源;新增审批对象类型必须在此登记,
# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用)
ENABLE_CODE_BY_TYPE: dict[int, str] = {
1: "approval.secondary_allocation.enabled",
2: "approval.monthly_settlement.enabled",
3: "approval.adjustment.enabled",
}
# 在途状态集合(同一引用对象至多一张在途单)
_PENDING_STATUSES = (APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
# 在途状态 → 当前节点应操作的角色(admin 不代办:单人审批绕过双人复核)
_NODE_ROLE_BY_STATUS = {
APPROVAL_STATUS_PENDING_FINANCE: ROLE_FINANCE,
APPROVAL_STATUS_PENDING_LEADER: ROLE_LEADER,
}
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
def is_enabled(session: Session, order_type: int) -> bool:
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
code = ENABLE_CODE_BY_TYPE.get(order_type)
if code is None:
raise BadRequestError("未知的审批对象类型")
return param_service.resolve_snapshot(session, date.today()).boolean(code)
def submit_order(
session: Session,
*,
order_type: int,
ref_id: int,
refer_no: str,
submitter: User,
) -> ApprovalOrder:
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
(全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透:
create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
"""
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
order = ApprovalOrder(
order_no=next_no(
session,
model=ApprovalOrder,
column_name="order_no",
prefix=NO_PREFIX_APPROVAL,
),
order_type=order_type,
ref_id=ref_id,
refer_no=refer_no,
submitter_id=submitter.id,
submitter_name=submitter.username,
)
session.add(order)
if is_enabled(session, order_type):
order.status = APPROVAL_STATUS_PENDING_FINANCE
session.flush()
logger.info("审批单待审 | order=%s", order.order_no)
return order
# 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链
order.status = APPROVAL_STATUS_APPROVED
session.flush()
session.add_all(
[
ApprovalStep(
order_id=order.id,
node=node,
action=APPROVAL_ACTION_AUTO_APPROVE,
operator_name="系统",
)
for node in (APPROVAL_NODE_FINANCE, APPROVAL_NODE_LEADER)
]
)
session.flush()
logger.info("审批单自动通过 | order=%s(开关关闭)", order.order_no)
return order
def withdraw_pending_orders(
session: Session, *, order_type: int, ref_id: int, operator: User
) -> int:
"""撤回引用对象的全部在途单(供业务清空方案等场景显式调用;不 commit)。
返回撤回张数(0 = 本就无在途单)。
"""
before = len(
session.execute(
select(ApprovalOrder.id).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
ApprovalOrder.status.in_(_PENDING_STATUSES),
)
)
.scalars()
.all()
)
if before:
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=operator)
return before
def act(
session: Session,
order_id: int,
payload: ApprovalActionIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> ApprovalOrderDetailOut:
"""节点动作(财务复核/领导审核):状态机流转 + 节点留痕(审计上下文)。
通过:status=1 → 2(下一步领导);status=2 → 3(终态已通过)。
驳回:任一在途状态 → 4(整体驳回,一期不退回重走)。
节点留痕的 node 取**动作发生时的当前节点**(通过/驳回都记在操作节点上)。
"""
codes = set(user_repository.get_role_codes(operator))
verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_APPROVAL,
action=verb,
summary=f"审批单 #{order_id} {verb}",
request_data=payload.model_dump(),
),
meta=meta,
):
order = session.get(ApprovalOrder, order_id, with_for_update=True)
if order is None:
raise NotFoundError("审批单不存在")
if order.status not in _PENDING_STATUSES:
raise BadRequestError("审批单已结束,不可重复操作")
required_role = _NODE_ROLE_BY_STATUS[order.status]
if required_role not in codes:
raise ForbiddenError(f"当前节点须{_ROLE_LABEL[required_role]}角色操作")
node_now = APPROVAL_NODES[order.status]
rejected = payload.action == APPROVAL_ACTION_REJECT
if rejected:
order.status = APPROVAL_STATUS_REJECTED
else:
order.status = (
APPROVAL_STATUS_PENDING_LEADER
if order.status == APPROVAL_STATUS_PENDING_FINANCE
else APPROVAL_STATUS_APPROVED
)
# 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前
# 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。
# 驳回在任何节点都是终态,即时联动。
final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED
session.add(
ApprovalStep(
order_id=order.id,
node=node_now,
action=payload.action,
opinion=payload.opinion,
operator_id=operator.id,
operator_name=operator.username,
)
)
# 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以
# 「当前生效行」身份留在表里、被 M3 核算快照算进工资;方案事实由
# 审批单留痕与操作日志 request_data 承载(分摊语义落定后如需保留
# 历史版本,在 ④ 设计时演进)。审批单的清理由调用方(act 内联)
# 委托 business_repository 完成 —— service 层互调、不触 commit。
if rejected and order.order_type == 1:
from app.repositories import business_repository
business_repository.delete_allocations(session, order.ref_id)
# 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿;
# 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。
if order.order_type == 2 or order.order_type == 3:
from app.models import AdjustmentOrder
from app.models.settlement import (
ADJUSTMENT_STATUS_EFFECTIVE,
)
from app.models.settlement import (
ADJUSTMENT_STATUS_REJECTED as ADJ_REJECTED,
)
from app.services import settlement_service
if order.order_type == 2:
from app.models.settlement import MonthlySettlement
target = session.get(MonthlySettlement, order.ref_id)
if target is not None:
if rejected:
settlement_service.mark_rejected(session, target, operator=operator)
elif final_approved:
settlement_service.mark_locked(session, target, operator=operator)
else:
adj = session.get(AdjustmentOrder, order.ref_id)
if adj is not None:
if rejected:
adj.status = ADJ_REJECTED
elif final_approved:
adj.status = ADJUSTMENT_STATUS_EFFECTIVE
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
# 成失败审计,与库中事实相反(全 services 层唯一例外不许再犯)
session.flush()
detail = _detail_of(session, order)
session.commit()
return detail
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
"""分页查询审批单(准入由 api 层依赖判定)。"""
filters = _order_filters(query)
rows = (
session.execute(
select(ApprovalOrder)
.where(*filters)
.order_by(ApprovalOrder.id.desc())
.offset(query.offset)
.limit(query.page_size)
)
.scalars()
.all()
)
total = session.execute(
select(func.count()).select_from(ApprovalOrder).where(*filters)
).scalar_one()
return PageResult[ApprovalOrderOut](
records=[ApprovalOrderOut.model_validate(r) for r in rows],
total=int(total),
page_num=query.page_num,
page_size=query.page_size,
)
def get_order(session: Session, order_id: int) -> ApprovalOrderDetailOut:
"""审批单详情(附节点时间线)。"""
order = session.get(ApprovalOrder, order_id)
if order is None:
raise NotFoundError("审批单不存在")
return _detail_of(session, order)
def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operator: User) -> None:
"""撤回引用对象的在途单(重新提交前调用;不 commit)。"""
pending = (
session.execute(
select(ApprovalOrder).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
ApprovalOrder.status.in_(_PENDING_STATUSES),
)
)
.scalars()
.all()
)
for order in pending:
order.status = APPROVAL_STATUS_WITHDRAWN
session.add(
ApprovalStep(
order_id=order.id,
node=APPROVAL_NODE_WITHDRAW,
action=APPROVAL_ACTION_WITHDRAW,
operator_id=operator.id,
operator_name=operator.username,
)
)
if pending:
session.flush()
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id)
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
"""出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。"""
steps = (
session.execute(
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
)
.scalars()
.all()
)
detail = ApprovalOrderDetailOut.model_validate(order)
detail.steps = [ApprovalStepOut.model_validate(s) for s in steps]
return detail
def _order_filters(query: ApprovalQuery) -> tuple:
filters = []
if query.keyword:
# autoescape:用户输入里的 %/_ 按 URL 片段字面匹配(全库统一口径,
# 见 customer_repository.contains 用法)——否则 "AP2026_10" 会把
# 下划线当单字符通配,搜出无关单据
filters.append(
ApprovalOrder.order_no.contains(query.keyword, autoescape=True)
| ApprovalOrder.refer_no.contains(query.keyword, autoescape=True)
)
if query.status is not None:
filters.append(ApprovalOrder.status == query.status)
if query.order_type is not None:
filters.append(ApprovalOrder.order_type == query.order_type)
return tuple(filters)

View File

@ -0,0 +1,173 @@
"""操作日志审计服务。
需求 2.5.1 要求记录用户的操作行为。与登录行为的分工:登录在 `login_logs`
单独记录(失败也要留痕且不依赖认证上下文),本模块记录**已认证用户的管理类
操作**。
设计要点:
- **成功与失败都留痕** —— 失败的操作(如试图删除不存在的用户)同样是审计
对象,模型里专门有 `success` / `error_message` 两列承载它
- **审计失败不影响业务** —— 落库过程整体兜底,失败只记应用日志、绝不外抛
(与 `auth_service._record` 同一原则:"审计可丢,业务不可断")
- **独立提交** —— 日志单独 commit,不与业务同事务。同事务时业务失败回滚会
连带丢掉最该留痕的失败记录
用法(上下文管理器)::
target = user_repository.get_by_id(session, user_id) # 只读预取,供摘要
with audit(session, operator=current_user,
record=AuditRecord(MODULE_USER, ACTION_DELETE,
f"删除用户 {target.username}"),
meta=meta):
...业务操作与 session.commit()...
return out
约定:
- 业务代码在上下文内自行 commit(事务边界仍在 service 层)。**commit 之后
的任何数据库查询、ORM 属性读取都必须移出上下文**(提到 commit 之前或
用已固化的局部变量)—— 业务已提交却因后置查询抛错被记成「失败」,会
产生与事实相反的审计记录
- 上下文内不得吞掉异常后正常退出——吞掉后会被记为成功
- 摘要需要"目标实体名"时,应在进入上下文**之前**只读预取,
这样目标不存在(NotFound 失败)时也能留下可追溯的记录
- 不支持嵌套(在 audit 内再调用会自行 commit 的服务)——进入侦测到
未提交变更会记 warning
"""
import logging
import time
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.core.exceptions import AppException
from app.models import User
from app.repositories import operation_log_repository
from app.repositories.operation_log_repository import OperationLogEntry
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class RequestMeta:
"""HTTP 请求元信息。由路由层依赖 `RequestMetaDep` 提取后传入 service。"""
ip: str | None = None
user_agent: str | None = None
method: str | None = None
path: str | None = None
@dataclass(frozen=True, slots=True)
class AuditRecord:
"""审计的业务侧内容(模块 / 操作 / 摘要 / 请求参数)。"""
module: str
action: str
summary: str
request_data: dict | None = None
@contextmanager
def audit(
session: Session,
*,
operator: User,
record: AuditRecord,
meta: RequestMeta | None = None,
) -> Iterator[None]:
"""审计上下文:正常退出记成功;抛异常记失败并**原样重抛**。"""
started = time.perf_counter()
meta = meta or RequestMeta()
# 固化操作人为普通标量,之后不再触碰 ORM 对象 —— 失败路径先 rollback
# 会使其全部过期,再读 id/username 将触发额外 SELECT;若账号恰被并发
# 删除,ObjectDeletedError 还会被下面的兜底吞掉、失败审计静默丢失
base: dict = {
"user_id": operator.id,
"username": operator.username,
"module": record.module,
"action": record.action,
"summary": record.summary,
"method": meta.method,
"path": meta.path,
"ip": meta.ip,
"user_agent": meta.user_agent,
"request_data": record.request_data,
}
# 进入审计时应无未提交变更(业务写操作在上下文内才发生)。出现变更
# 意味着调用方可能嵌套了会自行 commit 的服务:失败路径的回滚将无法
# 撤销已提交的部分,审计与数据状态会不一致
if session.new or session.dirty or session.deleted:
logger.warning(
"进入审计上下文时存在未提交的变更 | module=%s | action=%s",
record.module,
record.action,
)
try:
yield
except Exception as exc:
# 先回滚再写日志:业务异常可能源于 IntegrityError 等数据库错误,
# 不回滚的话 session 处于「必须回滚」状态,写日志会抛
# PendingRollbackError —— 最该留下的失败记录反而被丢掉
_rollback_quietly(session)
_safe_write(
session,
entry=OperationLogEntry(
**base,
success=False,
error_message=_error_text(exc),
duration_ms=_elapsed_ms(started),
),
)
raise
else:
_safe_write(
session,
entry=OperationLogEntry(**base, success=True, duration_ms=_elapsed_ms(started)),
)
def _safe_write(session: Session, *, entry: OperationLogEntry) -> None:
"""落库并独立提交。整体兜底:任何失败只记应用日志,绝不外抛。"""
try:
operation_log_repository.add(session, entry)
session.commit()
except Exception:
logger.warning(
"操作日志写入失败(不影响业务) | module=%s | action=%s | summary=%s",
entry.module,
entry.action,
entry.summary,
exc_info=True,
)
_rollback_quietly(session)
def _rollback_quietly(session: Session) -> None:
try:
session.rollback()
except Exception:
logger.warning("会话回滚失败", exc_info=True)
def _error_text(exc: Exception) -> str:
"""失败原因文案。
业务异常取其面向用户的消息;未预期异常返回通用文案 —— 完整堆栈由全局
异常处理器记入应用日志,审计表不落内部细节。
"""
if isinstance(exc, AppException):
return exc.message
return "服务端异常,请稍后重试"
def _elapsed_ms(started: float) -> int:
"""度量业务处理耗时(不含响应序列化)。"""
return max(0, int((time.perf_counter() - started) * 1000))

View File

@ -0,0 +1,287 @@
"""业务岗位角色管理业务编排。
需求 #28 的落点:岗位可增删、绑定公式模板(有限集合,代码内注册表),
新增岗位无需改代码。保护规则(前端会禁用相应按钮,**后端校验是唯一防线**):
- 内置岗位(is_builtin=1:营销员/营销经理/信息员)**禁删禁停** ——
参数种子与核算模板按编码锚定(如 salesperson 的递减公式参数行)
- `role_code` 创建后不可改 —— 它是参数维度值(param_values.dimension_value)
与模板绑定语义的锚点
- `calc_template` 必须是模板注册表(calc_templates.TEMPLATES)内的编码
- 有人员引用的岗位禁删 —— ② 落地 personnel 表后由引用计数生效,
当前钩子恒为空(见 business_role_repository.count_personnel_by_role)
"""
import logging
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_DISABLE,
ACTION_ENABLE,
ACTION_UPDATE,
MODULE_BUSINESS_ROLE,
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import BusinessRole, User
from app.repositories import business_role_repository
from app.schemas.business_role import (
BusinessRoleCreateIn,
BusinessRoleOut,
BusinessRoleQuery,
BusinessRoleUpdateIn,
TemplateOptionOut,
TemplateParamOut,
)
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.calc_templates import TEMPLATES
from app.services.param_registry import PARAM_REGISTRY
logger = logging.getLogger(__name__)
def _to_out(role: BusinessRole) -> BusinessRoleOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
return BusinessRoleOut(
id=role.id,
role_code=role.role_code,
role_name=role.role_name,
calc_template=role.calc_template,
description=role.description,
sort_order=role.sort_order,
status=role.status,
is_builtin=role.is_builtin,
)
def _ensure_template(calc_template: str) -> None:
"""模板必须存在于代码内注册表(前端为受控下拉,后端仍须校验)。"""
if calc_template not in TEMPLATES:
raise BadRequestError(f"计提公式模板 {calc_template!r} 不存在")
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
)
return PageResult[BusinessRoleOut](
records=[_to_out(role) for role in roles],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_role(session: Session, role_id: int) -> BusinessRoleOut:
"""岗位详情。
:raises NotFoundError: 岗位不存在
"""
role = business_role_repository.get_by_id(session, role_id)
if role is None:
raise NotFoundError("岗位不存在")
return _to_out(role)
def list_template_options() -> list[TemplateOptionOut]:
"""公式模板下拉(含声明参数,供岗位表单展示与引导配置)。"""
options: list[TemplateOptionOut] = []
for tpl in TEMPLATES.values():
options.append(
TemplateOptionOut(
code=tpl.code,
name=tpl.name,
description=tpl.description,
params=[
TemplateParamOut(code=code, name=PARAM_REGISTRY[code].name)
for code in tpl.params
],
)
)
return options
def create_role(
session: Session,
payload: BusinessRoleCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessRoleOut:
"""新增岗位。编码唯一、创建后不可修改,新岗位固定启用、非内置。
:raises BadRequestError: 编码已存在 / 模板不存在
"""
_ensure_template(payload.calc_template)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_ROLE,
action=ACTION_CREATE,
summary=f"新增岗位 {payload.role_name}({payload.role_code})",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if business_role_repository.get_by_code(session, payload.role_code) is not None:
raise BadRequestError(f"岗位编码 {payload.role_code!r} 已存在")
role = BusinessRole(
role_code=payload.role_code,
role_name=payload.role_name,
calc_template=payload.calc_template,
description=payload.description,
sort_order=payload.sort_order,
status=1,
is_builtin=0,
)
try:
business_role_repository.create(session, role)
out = _to_out(role)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发提交同一编码会撞 uk_business_roles_role_code
session.rollback()
raise BadRequestError(f"岗位编码 {payload.role_code!r} 已存在") from exc
logger.info(
"新增岗位 | role_code=%s | template=%s", payload.role_code, payload.calc_template
)
return out
def update_role(
session: Session,
role_id: int,
payload: BusinessRoleUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessRoleOut:
"""编辑岗位名称/模板/说明/排序(全量替换,不含 role_code)。
:raises NotFoundError: 岗位不存在
:raises BadRequestError: 模板不存在
"""
role = business_role_repository.get_by_id(session, role_id)
_ensure_template(payload.calc_template)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_ROLE,
action=ACTION_UPDATE,
summary=f"修改岗位 {role.role_name if role else f'#{role_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if role is None:
raise NotFoundError("岗位不存在")
role.role_name = payload.role_name
role.calc_template = payload.calc_template
role.description = payload.description
role.sort_order = payload.sort_order
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
out = _to_out(role)
session.commit()
logger.info("修改岗位 | role_id=%s | template=%s", role_id, payload.calc_template)
return out
def set_status(
session: Session,
role_id: int,
status: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessRoleOut:
"""启用或禁用岗位。内置岗位不可停用(参数种子按编码锚定)。
:raises NotFoundError: 岗位不存在
:raises ForbiddenError: 试图停用内置岗位
"""
role = business_role_repository.get_by_id(session, role_id)
verb = "启用" if status == 1 else "禁用"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_ROLE,
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
summary=f"{verb}岗位 {role.role_name if role else f'#{role_id}'}",
request_data={"status": status},
),
meta=meta,
):
if role is None:
raise NotFoundError("岗位不存在")
if role.is_builtin == 1 and status == 0:
raise ForbiddenError("内置岗位不可停用")
role.status = status
out = _to_out(role)
session.commit()
logger.info("变更岗位状态 | role_id=%s | status=%s", role_id, status)
return out
def delete_role(
session: Session,
role_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除岗位。内置岗位不可删除;有人员引用的岗位不可删除。
查询走 ``FOR UPDATE`` 锁行:校验与删除之间若发生并发引用(M2 接入后),
锁行使并发写入失败(可见)而非引用被静默悬空(不可见)。
:raises NotFoundError: 岗位不存在
:raises ForbiddenError: 试图删除内置岗位
:raises BadRequestError: 岗位下仍有人引用
"""
role = business_role_repository.get_by_id(session, role_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_ROLE,
action=ACTION_DELETE,
summary=f"删除岗位 {role.role_name if role else f'#{role_id}'}",
),
meta=meta,
):
if role is None:
raise NotFoundError("岗位不存在")
if role.is_builtin == 1:
raise ForbiddenError("内置岗位不可删除")
counts = business_role_repository.count_personnel_by_role(session, [role_id])
person_count = counts.get(role_id, 0)
if person_count > 0:
raise BadRequestError(f"该岗位下仍有 {person_count} 名人员,请先调整归属")
role_code = role.role_code # 固化:对象随删除失效
business_role_repository.delete(session, role)
session.commit()
logger.info("删除岗位 | role_code=%s", role_code)

View File

@ -0,0 +1,929 @@
"""业务管理业务编排(需求 2.2.1/2.2.4)。
事务边界在本层:repository 只 flush,commit/rollback 只写在这里。
- 业务编号自动生成(no_generator.next_no):MAX+FOR UPDATE 锁当日序号区间,
残余竞态以唯一键(1062)冲突重试一次兜底
- 五项汇总冗余(total_sale/cost/ship/received/freight)由 ``_recalc_totals``
单点重算 —— 明细/发货/回款任何写路径收口到此函数,禁止各处手写
- 状态机:1执行中 → 2已完成 / 3已终止;终态不可再变更(一期口径,开放问题 #8);
终止必填原因;每次流转写 business_status_logs(创建写首条 old=new=1)
- 归属变更(负责/协助)写 ownership_change_logs(owner_type=2),同事务一次
commit;只影响变更后核算,不回溯历史(对照表 #19)
- 毛利润视图为只读派生值(复用 formula.profit_rate/converted_profit),
阈值按 asOf 取 M1 参数快照 —— 落库即漂移,M3 台账才是固化点
"""
import logging
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from sqlalchemy import Row, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_UPDATE,
MODULE_BUSINESS,
NO_PREFIX_BUSINESS,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageQuery, PageResult
from app.models import (
ApprovalOrder,
Business,
BusinessItem,
BusinessStatusLog,
Customer,
OwnershipChangeLog,
Personnel,
Receipt,
SecondaryAllocation,
Shipment,
User,
)
from app.models.approval import APPROVAL_TYPE_SECONDARY_ALLOCATION
from app.models.business import (
BUSINESS_STATUS_ACTIVE,
BUSINESS_STATUS_DONE,
)
from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import business_repository, customer_repository, personnel_repository
from app.schemas.business import (
BusinessCreateIn,
BusinessDetailOut,
BusinessItemIn,
BusinessItemOut,
BusinessOut,
BusinessOwnershipIn,
BusinessQuery,
BusinessStatusIn,
BusinessStatusLogOut,
BusinessUpdateIn,
GrossProfitCategoryOut,
GrossProfitOut,
GrossProfitRowOut,
OwnershipChangeLogOut,
SecondaryAllocationIn,
SecondaryAllocationOut,
)
from app.services import approval_service, audit_service, param_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import converted_profit, profit_rate
from app.services.no_generator import next_no
logger = logging.getLogger(__name__)
# 编号唯一键冲突后的重试次数:MAX+FOR UPDATE 已覆盖绝大多数竞态,
# 重试是为极端场景(锁等待超时后重入)兜底 —— 无限重试反而掩盖死锁类缺陷
_NO_RETRY = 1
def _to_out(row: tuple) -> BusinessOut:
"""(business, company_name, owner_name, assistant_name) → 响应模型。
金额冗余列出参 str;汇总列可能为 NULL(server_default 只对 INSERT 生效,
存量行可能为 NULL),统一转 "0" 保持契约稳定。
"""
business, company_name, owner_name, assistant_name = row
return BusinessOut(
id=business.id,
business_no=business.business_no,
customer_id=business.customer_id,
customer_name=company_name,
business_date=business.business_date,
status=business.status,
owner_personnel_id=business.owner_personnel_id,
owner_personnel_name=owner_name,
assistant_personnel_id=business.assistant_personnel_id,
assistant_personnel_name=assistant_name,
is_special=business.is_special,
special_note=business.special_note,
total_sale_amount=_str_or_zero(business.total_sale_amount),
total_cost_amount=_str_or_zero(business.total_cost_amount),
total_ship_amount=_str_or_zero(business.total_ship_amount),
total_received_amount=_str_or_zero(business.total_received_amount),
total_freight_amount=_str_or_zero(business.total_freight_amount),
remark=business.remark,
)
def _str_or_zero(value: Decimal | None) -> str:
return str(value) if value is not None else "0"
def _item_to_out(item: BusinessItem) -> BusinessItemOut:
return BusinessItemOut(
id=item.id,
product_id=item.product_id,
product_name=item.product_name,
spec_model=item.spec_model,
unit=item.unit,
product_category=item.product_category,
quantity=str(item.quantity),
sale_price=str(item.sale_price) if item.sale_price is not None else None,
sale_amount=str(item.sale_amount),
cost_amount=str(item.cost_amount) if item.cost_amount is not None else None,
guided_price=str(item.guided_price) if item.guided_price is not None else None,
remark=item.remark,
)
def _build_item_rows(business_id: int, items: list[BusinessItemIn]) -> list[BusinessItem]:
"""入参明细行转 ORM 行:sale_amount 服务层按 数量×单价 计算固化。
不信任前端传值(schema 亦无 sale_amount 入参)——单价可空时销售额记 0,
由用户在编辑中补价后重算(毛利润视图中销售额 0 的行全部为 0,防除零)。
乘积显式 quantize 两位固化:数量(2位)×单价(4位) 可产 6 位小数,直写
DECIMAL(14,2) 会被 MySQL 静默舍入且回显漂移(创建响应读内存值、复查
读库值前后不一致);HALF_UP 与 MySQL 的 DECIMAL 舍入方向一致。
"""
rows: list[BusinessItem] = []
for item in items:
quantity = Decimal(item.quantity)
sale_price = Decimal(item.sale_price) if item.sale_price is not None else None
sale_amount = (
(quantity * sale_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if sale_price is not None
else Decimal("0.00")
)
rows.append(
BusinessItem(
business_id=business_id,
product_id=item.product_id,
product_name=item.product_name,
spec_model=item.spec_model or "",
unit=item.unit,
product_category=item.product_category,
quantity=quantity,
sale_price=sale_price,
sale_amount=sale_amount,
cost_amount=Decimal(item.cost_amount) if item.cost_amount is not None else None,
guided_price=Decimal(item.guided_price) if item.guided_price is not None else None,
remark=item.remark,
)
)
return rows
def _recalc_totals(session: Session, business: Business) -> None:
"""重算业务的五项汇总冗余(明细/发货/回款写路径统一收口)。
五条独立聚合查询(各表单列 SUM,走 business_id 索引)——业务单据量级
为单业务数行,不值得为省查询做单大 JOIN。
"""
sale = session.execute(
_sum(BusinessItem.sale_amount, BusinessItem.business_id, business.id)
).scalar_one()
cost = session.execute(
_sum(BusinessItem.cost_amount, BusinessItem.business_id, business.id)
).scalar_one()
ship = session.execute(_sum_ship_items(business.id)).scalar_one()
freight = session.execute(
_sum(Shipment.freight_amount, Shipment.business_id, business.id)
).scalar_one()
received = session.execute(_sum(Receipt.amount, Receipt.business_id, business.id)).scalar_one()
business.total_sale_amount = sale or Decimal("0")
business.total_cost_amount = cost or Decimal("0")
business.total_ship_amount = ship or Decimal("0")
business.total_freight_amount = freight or Decimal("0")
business.total_received_amount = received or Decimal("0")
def _sum(column, match_column, value) -> object:
return select(func.sum(column)).where(match_column == value)
def _sum_ship_items(business_id: int):
"""发货明细行金额合计(经发货单关联到业务)。"""
from app.models import ShipmentItem
return (
select(func.sum(ShipmentItem.amount))
.select_from(ShipmentItem)
.join(Shipment, Shipment.id == ShipmentItem.shipment_id)
.where(Shipment.business_id == business_id)
)
def _generate_no(session: Session) -> str:
"""生成业务编号(MAX+FOR UPDATE;1062 兜底重试一次,见模块 docstring)。"""
return next_no(session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS)
def _ensure_personnel_active(session: Session, personnel_id: int, label: str) -> Personnel:
"""归属人员须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
return personnel_service.ensure_active(session, personnel_id, label=label)
def _ensure_customer(session: Session, customer_id: int) -> Customer:
customer = customer_repository.get_by_id(session, customer_id)
if customer is None:
raise NotFoundError("客户不存在")
return customer
def _is_duplicate_entry(exc: IntegrityError) -> bool:
return is_duplicate_entry(exc)
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
"""分页查询业务(附客户名与归属人名)。"""
rows = business_repository.list_businesses(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
customer_id=query.customer_id,
owner_personnel_id=query.owner_personnel_id,
date_from=query.date_from,
date_to=query.date_to,
)
total = business_repository.count_businesses(
session,
keyword=query.keyword,
status=query.status,
customer_id=query.customer_id,
owner_personnel_id=query.owner_personnel_id,
date_from=query.date_from,
date_to=query.date_to,
)
return PageResult[BusinessOut](
records=[_to_out(row) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
"""业务详情(附明细行与二次分配方案,供编辑抽屉回显)。
:raises NotFoundError: 业务不存在
"""
row = business_repository.get_with_names(session, business_id)
if row is None:
raise NotFoundError("业务不存在")
out = BusinessDetailOut(**_to_out(row).model_dump())
out.items = [
_item_to_out(item) for item in business_repository.list_items(session, business_id)
]
allocations = business_repository.list_allocations(session, business_id)
out.secondary_allocations = [
SecondaryAllocationOut(
personnel_id=a.personnel_id, personnel_name=a.personnel_name, ratio=str(a.ratio)
)
for a in allocations
]
out.allocation_hint = _allocation_hint(allocations)
return out
def _build_allocation_rows(
session: Session,
business_id: int,
items: list[SecondaryAllocationIn],
*,
old_ids: set[int],
) -> list[SecondaryAllocation]:
"""构造二次分配明细行:人员校验 + 姓名冗余固化 + 比例转 Decimal。
:param old_ids: 编辑前已参与的人员 ID 集合(create 传空集)。这些人的
行是**历史事实**——personnel_name 冗余固化的设计本就允许离职后回显,
编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对
**新加入**的参与人做在职校验。
:raises BadRequestError: 新增参与人员不存在或已离职 / 比例合计超 100%
/ 参与人含合同负责人(口径见澄清 #1/#2 答复 2026-10-19:分配绩效、
余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人
自分份额则双重计入)
"""
business = business_repository.get_by_id(session, business_id)
if business is None:
raise NotFoundError("业务不存在")
total = sum((Decimal(item.ratio) for item in items), Decimal("0"))
if total > Decimal("100"):
raise BadRequestError(
f"二次分配比例合计 {total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}%"
" 超过 100%(余额归合同负责营销人员,比例不可超满额)"
)
owner_id = business.owner_personnel_id
if owner_id is not None and any(item.personnel_id == owner_id for item in items):
raise BadRequestError("分摊参与人不可包含合同负责营销人员(负责人得余额份额)")
rows: list[SecondaryAllocation] = []
for item in items:
if item.personnel_id in old_ids:
person = personnel_repository.get_by_id(session, item.personnel_id)
if person is None:
raise BadRequestError("分摊参与人员不存在")
else:
person = _ensure_personnel_active(session, item.personnel_id, "分摊参与")
rows.append(
SecondaryAllocation(
business_id=business_id,
personnel_id=item.personnel_id,
personnel_name=person.personnel_name,
ratio=Decimal(item.ratio),
)
)
return rows
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。
"""
if not allocations:
return None
total = sum((a.ratio for a in allocations), Decimal("0"))
total_text = total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if total == Decimal("100"):
return f"二次分配合计 {total_text}%"
rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"二次分配合计 {total_text}%,余额 {rest}%(归合同负责营销人员)"
def create_business(
session: Session,
payload: BusinessCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessOut:
"""创建业务 + 明细行 + 首条状态留痕,一个事务。
创建时指定的归属不写归属留痕(留痕记「变更」,创建即初始事实)。
:raises NotFoundError: 客户不存在
:raises BadRequestError: 归属人员不存在或已离职 / 明细校验不通过
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_CREATE,
summary=f"创建业务(客户ID={payload.customer_id},明细 {len(payload.items)} 行)",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
_ensure_customer(session, payload.customer_id)
if payload.owner_personnel_id is not None:
_ensure_personnel_active(session, payload.owner_personnel_id, "负责")
if payload.assistant_personnel_id is not None:
_ensure_personnel_active(session, payload.assistant_personnel_id, "协助")
# 编号唯一键冲突的重试整体收在审计上下文内:rollback 后同一事务重来,
# 成功/最终失败各只记一条审计(吞异常 return 会被记成假成功,重入
# 整个 create_* 则违反「不支持嵌套」约定);上限 _NO_RETRY 防无限重试
# 掩盖死锁类缺陷。审计摘要预取的是首次编号——重试成功时入库编号见
# 应用日志(AuditRecord frozen,不回填)。
for attempt in range(_NO_RETRY + 1):
try:
business = Business(
business_no=_generate_no(session),
customer_id=payload.customer_id,
business_date=payload.business_date,
status=BUSINESS_STATUS_ACTIVE,
owner_personnel_id=payload.owner_personnel_id,
assistant_personnel_id=payload.assistant_personnel_id,
is_special=payload.is_special,
special_note=payload.special_note,
remark=payload.remark,
)
business_repository.create(session, business)
business_repository.replace_items(
session, business.id, _build_item_rows(business.id, payload.items)
)
_recalc_totals(session, business)
business_repository.add_status_log(
session,
BusinessStatusLog(
business_id=business.id,
old_status=BUSINESS_STATUS_ACTIVE,
new_status=BUSINESS_STATUS_ACTIVE,
operator_id=operator.id,
operator_name=operator.username,
),
)
# 二次分配方案与业务同事务(需求:只出现在合同刚建立时);
# 放在重试循环内——rollback 后方案行随业务一并重来
business_repository.replace_allocations(
session,
business.id,
_build_allocation_rows(
session, business.id, payload.secondary_allocations, old_ids=set()
),
)
# 方案非空才提单:开关关闭=自动通过留痕,开启=待财务复核。
# AP 编号撞键时 IntegrityError 穿透 → 外层编号重试循环把
# 业务/方案/审批单整体重来
if payload.secondary_allocations:
approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business.id,
refer_no=business.business_no,
submitter=operator,
)
customer = customer_repository.get_by_id(session, business.customer_id)
out = _to_out((business, customer.company_name if customer else None, None, None))
new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM
session.commit()
break
except IntegrityError as exc:
session.rollback()
if _is_duplicate_entry(exc) and attempt < _NO_RETRY:
continue
raise
logger.info("创建业务 | business_id=%s | no=%s", new_id, new_no)
return out
def update_business(
session: Session,
business_id: int,
payload: BusinessUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessOut:
"""编辑业务:全量替换明细 + 重算汇总;归属不在此端点(防旁路)。
客户可改,但有发货或回款记录后禁改 —— 单据 FK 挂在业务上,换客户会让
发货/回款语义断裂(明细同理随业务走)。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 业务已完成或已终止 / 归属人员不合法 / 有发货或回款时换客户
"""
row = business_repository.get_with_names(session, business_id, for_update=True)
business = row[0] if row else None
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_UPDATE,
summary=f"修改业务 {business.business_no if business else f'#{business_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if row is None:
raise NotFoundError("业务不存在")
business, _, owner_name, assistant_name = row
# 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线,
# 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务)
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止编辑业务")
if payload.customer_id != business.customer_id and business_repository.has_docs(
session, business_id
):
raise BadRequestError("业务已有发货或回款记录,不能更换客户")
customer = _ensure_customer(session, payload.customer_id)
if payload.owner_personnel_id is not None:
_ensure_personnel_active(session, payload.owner_personnel_id, "负责")
if payload.assistant_personnel_id is not None:
_ensure_personnel_active(session, payload.assistant_personnel_id, "协助")
business.customer_id = payload.customer_id
business.business_date = payload.business_date
business.is_special = payload.is_special
business.special_note = payload.special_note
business.remark = payload.remark
# 归属刻意不赋值:留痕只由 /ownership 端点产生,编辑路径改归属构成
# 「改归属不留痕」的旁路(与 customers 编辑不触归属同一口径)
business_repository.replace_items(
session, business_id, _build_item_rows(business_id, payload.items)
)
# 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
# 前置场景(月结在 ④,快照固化后按同一哲学隔离)
old_ids = {
a.personnel_id for a in business_repository.list_allocations(session, business_id)
}
business_repository.replace_allocations(
session,
business_id,
_build_allocation_rows(
session, business_id, payload.secondary_allocations, old_ids=old_ids
),
)
# 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回
# 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批
if payload.secondary_allocations:
approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
refer_no=business.business_no,
submitter=operator,
)
else:
approval_service.withdraw_pending_orders(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
operator=operator,
)
_recalc_totals(session, business)
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
out = _to_out((business, customer.company_name, owner_name, assistant_name))
session.commit()
logger.info("修改业务 | business_id=%s", business_id)
return out
def delete_business(
session: Session,
business_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除业务(物理删除):有发货/回款 → 400 引导走「已终止」。
无单据引用的业务(建错的空单)方可删除;删除时连带清理明细、状态留痕
与业务维度的归属留痕(无外键固化表,须手动清)。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 存在发货或回款记录 / 存在在途审批单
"""
business = business_repository.get_by_id(session, business_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_DELETE,
summary=f"删除业务 {business.business_no if business else f'#{business_id}'}",
),
meta=meta,
):
if business is None:
raise NotFoundError("业务不存在")
if business_repository.has_docs(session, business_id):
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
# 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批):
# 禁删引导先完成/撤回审批,而不是静默留下指向已删业务的活单
if _count_pending_approvals(session, business_id):
raise BadRequestError("该业务有在途审批单,请先完成或撤回审批后再删除")
business_no = business.business_no
_hard_delete(session, business_id)
session.commit()
logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no)
def _count_pending_approvals(session: Session, business_id: int) -> int:
"""该业务的在途审批单数(status 1/2;终态单随业务删除一并清理)。"""
from sqlalchemy import func, select
from app.models import ApprovalOrder
from app.models.approval import (
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
)
return int(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
ApprovalOrder.ref_id == business_id,
ApprovalOrder.status.in_(
(APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
),
)
).scalar_one()
)
def _hard_delete(session: Session, business_id: int) -> None:
"""按依赖序删除:明细 → 二次分配方案 → 审批单(steps 随 CASCADE)→
状态留痕 → 业务维度归属留痕 → 主表。"""
from sqlalchemy import delete
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
)
session.execute(
delete(ApprovalOrder).where(
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
ApprovalOrder.ref_id == business_id,
)
)
session.execute(delete(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id))
session.execute(
delete(OwnershipChangeLog).where(
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
OwnershipChangeLog.business_id == business_id,
)
)
session.execute(delete(Business).where(Business.id == business_id))
def change_status(
session: Session,
business_id: int,
payload: BusinessStatusIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessOut:
"""状态流转:1→2 / 1→3,终态不可再变更;每次流转写留痕。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 非执行中状态流转 / 目标与当前相同
"""
row = business_repository.get_with_names(session, business_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_UPDATE,
summary=(
f"业务状态变更 {row[0].business_no if row else f'#{business_id}'} → "
f"{'已完成' if payload.status == BUSINESS_STATUS_DONE else '已终止'}"
),
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if row is None:
raise NotFoundError("业务不存在")
business, _, _, _ = row
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("已完成或已终止的业务不可再变更状态")
if business.status == payload.status:
raise BadRequestError("状态未变化")
# 变更前状态须在赋值前固化:ORM 属性赋值后 old_status 就取不到旧值
# (留痕会记成 old==new 失真,时间线无法回溯)
old_status = business.status
business.status = payload.status
business_repository.add_status_log(
session,
BusinessStatusLog(
business_id=business_id,
old_status=old_status,
new_status=payload.status,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
),
)
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新状态
out = _to_out(row)
session.commit()
logger.info(
"业务状态变更 | business_id=%s | %s -> %s",
business_id,
old_status,
payload.status,
)
return out
def change_ownership(
session: Session,
business_id: int,
payload: BusinessOwnershipIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessOut:
"""归属变更(负责/协助):同事务「更新主表 + 插留痕」,一次 commit。
负责与协助之一发生变化即记一条留痕(前后值都含两个字段);两者均未变化
→ 400「归属未变化」。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 归属人员不存在或已离职 / 归属未变化
"""
row = business_repository.get_with_names(session, business_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_UPDATE,
summary=f"调整业务归属 {row[0].business_no if row else f'#{business_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if row is None:
raise NotFoundError("业务不存在")
business, _, old_owner_name, old_assistant_name = row
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
# 固化标量,新归属人名必须按新 ID 重取
new_owner = (
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
if payload.new_owner_personnel_id is not None
else None
)
new_assistant = (
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
if payload.new_assistant_personnel_id is not None
else None
)
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
if not owner_changed and not assistant_changed:
raise BadRequestError("归属未变化")
old_owner_id = business.owner_personnel_id
old_assistant_id = business.assistant_personnel_id
business.owner_personnel_id = payload.new_owner_personnel_id
business.assistant_personnel_id = payload.new_assistant_personnel_id
business_repository.add_ownership_log(
session,
OwnershipChangeLog(
owner_type=OWNER_TYPE_BUSINESS,
business_id=business_id,
old_owner_personnel_id=old_owner_id,
new_owner_personnel_id=payload.new_owner_personnel_id,
old_assistant_personnel_id=old_assistant_id,
new_assistant_personnel_id=payload.new_assistant_personnel_id,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
),
)
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
out = _to_out(
(
business,
row[1],
new_owner.personnel_name if new_owner is not None else old_owner_name,
new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
)
)
session.commit()
logger.info(
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
business_id,
old_owner_id,
payload.new_owner_personnel_id,
old_assistant_id,
payload.new_assistant_personnel_id,
)
return out
def list_ownership_logs(
session: Session, business_id: int, query: PageQuery
) -> PageResult[OwnershipChangeLogOut]:
"""某业务的归属变更留痕(分页)。
:raises NotFoundError: 业务不存在
"""
if business_repository.get_by_id(session, business_id) is None:
raise NotFoundError("业务不存在")
rows = business_repository.list_ownership_logs(
session, business_id, offset=query.offset, limit=query.page_size
)
total = business_repository.count_ownership_logs(session, business_id=business_id)
return PageResult[OwnershipChangeLogOut](
records=[_log_to_out(row) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def _log_to_out(row: Row) -> OwnershipChangeLogOut:
log, old_owner_name, new_owner_name = row
return OwnershipChangeLogOut(
id=log.id,
business_id=log.business_id,
old_owner_personnel_id=log.old_owner_personnel_id,
new_owner_personnel_id=log.new_owner_personnel_id,
old_assistant_personnel_id=log.old_assistant_personnel_id,
new_assistant_personnel_id=log.new_assistant_personnel_id,
old_owner_name=old_owner_name,
new_owner_name=new_owner_name,
change_reason=log.change_reason,
operator_id=log.operator_id,
operator_name=log.operator_name,
create_time=log.create_time,
)
def list_status_logs(
session: Session, business_id: int, query: PageQuery
) -> PageResult[BusinessStatusLogOut]:
"""某业务的状态流转时间线(分页,时间正序)。
:raises NotFoundError: 业务不存在
"""
if business_repository.get_by_id(session, business_id) is None:
raise NotFoundError("业务不存在")
rows = business_repository.list_status_logs(
session, business_id, offset=query.offset, limit=query.page_size
)
total = business_repository.count_status_logs(session, business_id=business_id)
return PageResult[BusinessStatusLogOut](
records=[BusinessStatusLogOut.model_validate(row) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut:
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
阈值按 asOf(默认今日)取 M1 参数快照(product.threshold,按行类别维度,
存储口径为 % 数如 30,直接进 converted_profit);毛利润率按 M1 预览同一
出参口径输出 Fraction 分数字符串(如 "97/10000");成本缺失的行派生列为
None 并标注原因;汇总只聚合可计算行。
:raises NotFoundError: 业务不存在
"""
if business_repository.get_by_id(session, business_id) is None:
raise NotFoundError("业务不存在")
as_of = as_of or date.today()
snapshot = param_service.resolve_snapshot(session, as_of)
def threshold_of(category: str) -> Fraction:
return snapshot.fraction("product.threshold", product_category=category)
rows_out: list[GrossProfitRowOut] = []
missing_count = 0
# 类别聚合容器:{类别: [sale, cost]},成本缺失行不计入该类别聚合
agg: dict[str, list[Decimal]] = {}
for item in business_repository.list_items(session, business_id):
threshold = threshold_of(item.product_category)
row_kwargs: dict = {
"item_id": item.id,
"product_category": item.product_category,
"product_name": item.product_name,
"spec_model": item.spec_model,
"quantity": str(item.quantity),
"sale_amount": str(item.sale_amount),
"threshold": str(threshold),
}
if item.cost_amount is None:
missing_count += 1
row_kwargs.update(
cost_amount=None,
profit_amount=None,
profit_rate=None,
converted_profit=None,
missing_reason="成本缺失,无法计算毛利润",
)
else:
profit = item.sale_amount - item.cost_amount
rate = profit_rate(profit, item.sale_amount)
conv = converted_profit(item.sale_amount, rate, threshold)
row_kwargs.update(
cost_amount=str(item.cost_amount),
profit_amount=str(profit),
profit_rate=str(rate),
converted_profit=str(conv),
missing_reason=None,
)
bucket = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
bucket[0] += item.sale_amount
bucket[1] += item.cost_amount
rows_out.append(GrossProfitRowOut(**row_kwargs))
categories = [
GrossProfitCategoryOut(
product_category=category,
sale_amount=str(sale),
cost_amount=str(cost),
profit_amount=str(sale - cost),
profit_rate=str(profit_rate(sale - cost, sale)),
threshold=str(threshold_of(category)),
converted_profit=str(
converted_profit(sale, profit_rate(sale - cost, sale), threshold_of(category))
),
)
for category, [sale, cost] in agg.items()
]
return GrossProfitOut(
business_id=business_id,
as_of=as_of,
rows=rows_out,
categories=categories,
missing_count=missing_count,
)

View File

@ -0,0 +1,719 @@
"""绩效核算服务(M3 ④):回款 → 台账的固化流水线。
单笔核算(calc_receipt)与计算预览(param_service.preview)**共用同一
公式路径与取值防线**:resolve_snapshot 按回款到账日取参(裁决 #11)、按
岗位模板分派计提比例(‰ 存储口径 ÷1000 转纯比例)、行级折算(混合业务
按行汇总,对照表 #14)。两者的任何口径修改必须同步——预览给业务对数、
核算才是算钱的法定固化点。
策略点(业务答复 2026-10-19 已回填,后续调整仍集中在对应函数 + 按回款批量重算):
- ``_personal_rate``:#12 经理个人户单笔由参数 manager.personal.included
分流——0=单笔落 0(聚合口径在月结时由 manager_team_share 计算)、
1=按业务员递减公式计提;#20 在 ``_row_profit``(暂按不含税原样相减)
- 二次分摊(澄清 #1 答复「分配绩效」,算后分):``_load_allocations``
读生效方案,``_persist_ledger`` 按比例拆行(参与人份额 + 负责人余额,
行合计守恒);#2 余额归负责人、#3 二次分配即协助场景的核算载体
- 核算粒度 #25:业务级一次性计提——首个触发核算的回款计算全额应发,
后续回款仅置 settlement_status=1(repeated=True),不重复计提
"""
import logging
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from typing import Any
from sqlalchemy import update
from sqlalchemy.orm import Session
from app.core.constants import MODULE_CALC
from app.core.exceptions import BadRequestError
from app.models import (
Business,
BusinessItem,
Customer,
PerformanceLedger,
Personnel,
Receipt,
Shipment,
User,
)
from app.models.performance_ledger import CALC_TYPE_RECEIPT
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
from app.repositories import business_repository, performance_ledger_repository
from app.schemas.calc import CalcReceiptOut
from app.services import audit_service, param_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import (
apply_barter,
clamp_weight_r,
converted_profit,
payment_days,
payment_weight,
performance_amount,
profit_rate,
salesperson_rate,
start_payment_ok,
)
from app.services.param_service import parse_fraction, resolve_snapshot
logger = logging.getLogger(__name__)
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算"
@dataclass(frozen=True, slots=True)
class _RateDecision:
"""单笔计提比例的分派结果。"""
rate: Fraction # 纯比例(str() 产出分数原文如 88/9000,直接落台账 rate_text)
rate_note: str # 人读说明(入 snapshot)
base_override: Decimal | None # 非 None 时绩效基数用它(信息员=销售额)
manager_pending: bool # True=经理聚合口径,单笔落 0(#12 策略点)
def calc_receipt(
session: Session,
receipt_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
recalc_reason: str | None = None,
) -> CalcReceiptOut:
"""单笔回款核算 / 重算(recalc_reason 非 None 时为重算模式)。
防重前两层在 ``_load_and_guard``(FOR UPDATE 锁行 + settlement_status
与月结锁定校验),第三层是落台账 uk(receipt_id, calc_version)。全程
一个 audit 事务,出参在 commit 前固化。
首算模式:回款必须未核算;业务级一次性计提(#25)——已有生效台账行时
仅登记进度(repeated=True,``_prepare_mode`` 内提前返回)。
重算模式:回款必须已核算过;作废旧版本行(is_active=0),按当前参数
落 calc_version+1 新行,calc_reason 留痕。
本函数只做编排:锁行校验 → 模式预处理 → 输入装载 → 逐行折算 →
计提组成 → 落库,各段独立私有函数(口径修改只动对应段)。
"""
is_recalc = recalc_reason is not None
action = "重算" if is_recalc else "核算"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CALC,
action=action,
summary=f"{action}回款 #{receipt_id}",
request_data={
"receipt_id": receipt_id,
**({"reason": recalc_reason} if is_recalc else {}),
},
),
meta=meta,
):
receipt, business = _load_and_guard(session, receipt_id, is_recalc=is_recalc)
repeated_out = _prepare_mode(session, receipt, business, is_recalc=is_recalc)
if repeated_out is not None:
return repeated_out
shipment = _load_shipment(session, receipt)
person = _load_person(session, business)
snapshot = resolve_snapshot(session, receipt.receipt_date)
items = _load_items(session, business)
allocations = _load_allocations(session, business, person.id)
conversion = _convert_items(snapshot, person, items)
amounts = _compose_amounts(
session, snapshot, person, business, receipt, shipment, conversion
)
rows = _persist_ledger(
session,
receipt=receipt,
business=business,
person=person,
shipment=shipment,
items=items,
allocations=allocations,
conversion=conversion,
amounts=amounts,
recalc_reason=recalc_reason,
is_recalc=is_recalc,
)
session.flush()
out = CalcReceiptOut(ledger=_to_out(rows.owner_row), repeated=False)
session.commit()
logger.info(
"核算完成 | receipt=%s | ledger=%s | payable=%s | 分摊行=%s",
receipt_id,
rows.owner_row.id,
amounts.payable,
len(rows.share_rows),
)
return out
def _load_and_guard(
session: Session, receipt_id: int, *, is_recalc: bool
) -> tuple[Receipt, Business]:
"""锁行 + 模式校验 + 月结锁定拦截 + 业务归属校验(核算/重算共用)。"""
receipt = session.get(Receipt, receipt_id, with_for_update=True)
if receipt is None:
raise BadRequestError("回款不存在")
if is_recalc:
if receipt.settlement_status == SETTLEMENT_PENDING:
raise BadRequestError("该回款尚未核算过(无旧版本可重算,走首次核算)")
elif receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)")
# 月结锁定拦截:已审定月份的回款不得再核算/重算——金额已固化,
# 变动唯一通道是调整单(否则重算会绕开审批改写已锁定月份的应付)
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
business = session.get(Business, receipt.business_id, with_for_update=True)
if business is None:
raise BadRequestError("回款关联的业务不存在")
if business.owner_personnel_id is None:
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
return receipt, business
def _prepare_mode(
session: Session, receipt: Receipt, business: Business, *, is_recalc: bool
) -> CalcReceiptOut | None:
"""模式预处理:重算作废旧版本;首算判业务级一次性计提。
返回非 None = 本笔仅登记进度(repeated=True 的出参,已 commit),
调用方直接返回。
"""
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算。
# 无生效行 = 该回款是「进度登记」(业务级一次性计提 #25 的第二笔及
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
# 两行生效行导致双重计提 → 一律 400
if is_recalc:
# 分摊拆行后旧版本可能多行(负责人+参与人)——全部作废再落新版本
olds = performance_ledger_repository.list_active_by_receipt(session, receipt.id)
if not olds:
raise BadRequestError("该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算")
for old in olds:
performance_ledger_repository.mark_inactive(session, old)
return None
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
if not performance_ledger_repository.has_active_for_business(
session, business.id, calc_type=CALC_TYPE_RECEIPT
):
return None
_mark_settled(session, receipt)
row = performance_ledger_repository.get_active_by_receipt_of_business(session, business.id)
session.flush()
out = CalcReceiptOut(ledger=_to_out(row), repeated=True)
session.commit()
logger.info("回款登记进度(业务已核算)| receipt=%s | business=%s", receipt.id, business.id)
return out
def _load_shipment(session: Session, receipt: Receipt) -> Shipment:
"""核销基准:发货批次存在且带发货日(回款天数公式的输入)。"""
if receipt.shipment_id is None:
raise BadRequestError("回款未指定发货批次,无法确定发货日期(先指定批次再核算)")
shipment = session.get(Shipment, receipt.shipment_id)
if shipment is None or shipment.ship_date is None:
raise BadRequestError("发货批次缺少发货日期,无法核算")
return shipment
def _load_person(session: Session, business: Business):
"""核算归属:在职 + 绑定岗位角色(计提公式锚点)。"""
person = personnel_service.ensure_active(session, business.owner_personnel_id, label="核算归属")
if person.business_role_id is None:
raise BadRequestError("归属人员未绑定岗位角色,无法确定计提公式")
return person
def _load_items(session: Session, business: Business) -> list[BusinessItem]:
"""业务产品明细行(逐行折算的输入;空明细无法核算)。"""
items = business_repository.list_items(session, business.id)
if not items:
raise BadRequestError("业务无产品明细行,无法核算")
return items
def _load_allocations(session: Session, business: Business, owner_id: int) -> list:
"""业务生效的二次分配方案(澄清 #1 答复 2026-10-19:分配绩效——算后分)。
校验双保险之一(保存侧在 business_service):比例合计 >100% 会把负责人
份额算成负数(多算参与人绩效);合同负责人不可出现在参与人列表(自分
份额会双重计入)。无方案返回空列表 = 全额归负责人。
"""
allocations = business_repository.list_allocations(session, business.id)
if not allocations:
return []
total = sum((a.ratio for a in allocations), Decimal("0"))
if total > Decimal("100"):
raise BadRequestError(
f"二次分配比例合计 {total.quantize(Decimal('0.01'))}% 超过 100%,"
"无法核算(先调整方案比例)"
)
if any(a.personnel_id == owner_id for a in allocations):
raise BadRequestError("二次分配参与人含合同负责营销人员,无法核算(先调整方案)")
return list(allocations)
@dataclass(frozen=True, slots=True)
class _Conversion:
"""逐行折算结果(行级明细入台账快照,汇总值驱动计提与起付判定)。"""
total_revenue: Decimal
total_profit: Decimal
converted_total: Decimal
missing_cost: bool
line_details: list[dict[str, Any]]
def _convert_items(snapshot, person, items: list[BusinessItem]) -> _Conversion:
"""逐行折算(行级 product_category 阈值;成本缺失行毛利按 0 计并标记)。"""
total_revenue = Decimal("0")
total_profit = Decimal("0")
converted_total = Decimal("0")
missing_cost = False
line_details: list[dict[str, Any]] = []
for item in items:
row_revenue = item.sale_amount
if item.cost_amount is None:
missing_cost = True
row_profit = Decimal("0")
else:
row_profit = _row_profit(row_revenue, item.cost_amount)
total_revenue += row_revenue
total_profit += row_profit
threshold = parse_fraction(
snapshot.get(
"product.threshold",
role_code=role_code_of(person),
product_category=item.product_category,
)
)
row_converted = converted_profit(
row_revenue, profit_rate(row_profit, row_revenue), threshold
)
converted_total += row_converted
line_details.append(
{
"item_id": item.id,
"product_category": item.product_category,
"revenue": str(row_revenue),
"profit": str(row_profit),
# 行级折算同样固化为两位(to_decimal 对整数值无小数位,
# 快照/台账口径需与列一致)
"converted": str(_q2(row_converted)),
}
)
return _Conversion(total_revenue, total_profit, converted_total, missing_cost, line_details)
@dataclass(frozen=True, slots=True)
class _Amounts:
"""计提组成结果(比例分派 → 绩效 → 抵顶 → 支付权重 → 应付)。"""
days: int
decision: _RateDecision
converted: Decimal
performance: Decimal
barter_applied: bool
weight_r: int
weight: Fraction
payable: Decimal
start_ok: bool
def _compose_amounts(
session: Session,
snapshot,
person,
business: Business,
receipt: Receipt,
shipment: Shipment,
conversion: _Conversion,
) -> _Amounts:
"""按比例分派与支付权重组成应付(与 param_service.preview 同公式路径)。"""
# 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入)
days = payment_days(shipment.ship_date, receipt.receipt_date)
decision = _personal_rate(session, snapshot, person, business, days)
if decision.base_override is not None:
converted = decision.base_override # 信息员:基数=本人销售额
else:
converted = conversion.converted_total
performance = (
Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate)
)
barter_applied = receipt.is_barter == 1
base_amount = performance
if barter_applied:
discount = parse_fraction(snapshot.get("barter.discount", role_code=role_code_of(person)))
base_amount = apply_barter(performance, discount)
received_total = business.total_received_amount or Decimal("0")
start_ratio = parse_fraction(
snapshot.get("payment.start.ratio", role_code=role_code_of(person))
)
start_ok = start_payment_ok(received_total, conversion.total_revenue, start_ratio)
weight_r = clamp_weight_r(received_total, conversion.total_revenue)
weight = payment_weight(weight_r)
payable = param_service.to_decimal(Fraction(base_amount) * weight)
return _Amounts(
days=days,
decision=decision,
converted=converted,
performance=performance,
barter_applied=barter_applied,
weight_r=weight_r,
weight=weight,
payable=payable,
start_ok=start_ok,
)
@dataclass(frozen=True, slots=True)
class _LedgerRows:
"""拆行落库结果:负责人行恒存在(出参与 CAS 锚点),参与人份额行可空。"""
owner_row: PerformanceLedger
share_rows: list[PerformanceLedger]
def _persist_ledger(
session: Session,
*,
receipt: Receipt,
business: Business,
person,
shipment: Shipment,
items: list[BusinessItem],
allocations: list,
conversion: _Conversion,
amounts: _Amounts,
recalc_reason: str | None,
is_recalc: bool,
) -> _LedgerRows:
"""落台账行(分摊拆行;版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。
分摊(澄清 #1 答复 2026-10-19「分配绩效」,算后分):业务级全额(含
递减比例、抵顶、支付权重全环节)算完后按方案比例拆给参与人,余额归
合同负责人。行合计与业务级全额**守恒**(负责人行取 q2 全额减参与人
q2 份额的差额,防分漂);每行快照带完整方案快照,行级可独立回溯。
参与人离职照分(历史方案事实,方案调整是业务侧责任——入职校验只在
保存侧,核算侧只要求人员存在,FK RESTRICT 已保证)。
"""
receipt_date = receipt.receipt_date
base_snapshot: dict[str, Any] = {
"params_as_of": str(receipt_date),
"line_details": conversion.line_details,
"start_payment_ok": amounts.start_ok,
"rate_note": amounts.decision.rate_note,
"revenue_total": str(conversion.total_revenue),
"profit_total": str(conversion.total_profit),
}
if amounts.decision.manager_pending:
base_snapshot["strategy_note"] = _NOTE_MANAGER_PENDING
if allocations:
base_snapshot["alloc_plan"] = [
{
"personnel_id": a.personnel_id,
"personnel_name": a.personnel_name,
"ratio": str(a.ratio),
}
for a in allocations
]
version = performance_ledger_repository.next_version(session, receipt.id)
customer_type = _customer_type(session, business)
category = _category_label(items)
# 份额清单:(person, ratio, tag)——ratio 为 None 时走余额公式(q2 全额
# 减已分累计,守恒);负责人余额的百分数(100−∑参与人)仅作快照展示,
# 不参与计算——按它乘全额会丢 q2 尾差(39.1111×30% ≠ 39.11−27.37)
shares: list[tuple[Any, Decimal | None, str]] = []
owner_ratio_text: str | None = None
if not allocations:
shares.append((person, None, "owner"))
owner_ratio_text = "100"
else:
total_ratio = Decimal("0")
for a in allocations:
shares.append((session.get(Personnel, a.personnel_id), a.ratio, "participant"))
total_ratio += a.ratio
owner_ratio_text = str((Decimal("100") - total_ratio).quantize(Decimal("0.01")))
shares.append((person, None, "owner"))
def _share(full: Decimal, ratio: Decimal | None, *, added: Decimal = Decimal("0")) -> Decimal:
"""单份额金额:参与人 = 全额×比例(q2);负责人 = q2 全额 − 已分累计。"""
if ratio is None:
return _q2(full - added) or Decimal("0")
# Fraction 乘积先 to_decimal 再 q2(Fraction 无 quantize,直接喂 _q2 会炸)
return _q2(param_service.to_decimal(Fraction(full) * Fraction(ratio) / 100)) or Decimal("0")
added_perf = Decimal("0")
added_conv = Decimal("0")
added_pay = Decimal("0")
owner_row: PerformanceLedger | None = None
share_rows: list[PerformanceLedger] = []
for target, ratio, tag in shares:
perf = _share(amounts.performance, ratio, added=added_perf)
conv = _share(amounts.converted, ratio, added=added_conv)
pay = _share(amounts.payable, ratio, added=added_pay)
added_perf += perf
added_conv += conv
added_pay += pay
row = performance_ledger_repository.add(
session,
PerformanceLedger(
receipt_id=receipt.id,
calc_version=version,
calc_type=CALC_TYPE_RECEIPT,
business_id=business.id,
business_no=business.business_no,
personnel_id=target.id,
personnel_name=target.personnel_name,
role_code=role_code_of(target),
customer_type=customer_type,
product_category=category,
ship_date=shipment.ship_date,
receipt_date=receipt_date,
payment_days=amounts.days,
rate_text=str(amounts.decision.rate),
converted_profit=conv,
performance_amount=perf,
barter_applied=1 if amounts.barter_applied else 0,
weight_r=amounts.weight_r,
payment_weight=param_service.to_decimal(amounts.weight),
payable_amount=pay,
settlement_month=receipt_date.strftime("%Y-%m"),
is_probation=1 if target.is_probation == 1 else 0,
missing_cost=1 if conversion.missing_cost else 0,
snapshot_json={
**base_snapshot,
"alloc_role": tag,
"alloc_ratio": owner_ratio_text if tag == "owner" else str(ratio),
},
calc_reason=recalc_reason,
),
)
if tag == "owner":
owner_row = row
else:
share_rows.append(row)
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
# 会 rowcount=0 误报并发撞车)
if not is_recalc:
_mark_settled(session, receipt)
return _LedgerRows(owner_row=owner_row, share_rows=share_rows)
def _q2(value: Decimal | None) -> Decimal | None:
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。
显式传 rounding——quantize 缺省是上下文 ROUND_HALF_EVEN(银行家舍入),
x.xx5 会舍成偶数值,与业务四舍五入口径差 1 分。
"""
if value is None:
return None
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _personal_rate(
session: Session,
snapshot,
person,
business: Business,
days: int,
) -> _RateDecision:
"""按岗位模板分派单笔计提比例(与 param_service._preview_rate 同构)。
#12 策略点:经理个人户(owner 岗位绑 TPL_MANAGER_SHARE)单笔不计提
—— 12.5%+5% 是月度团队聚合口径,单笔落 rate=0/绩效 0 的台账行保事实。
"""
role = _role_of(session, person)
customer_type = _customer_type(session, business)
if customer_type == "enterprise":
# 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线)
rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000
return _RateDecision(rate, "企业户固定计提比例", None, False)
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
if template == "TPL_MANAGER_SHARE":
# #12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户
# 单按业务员递减公式单笔计提(个人户单本质是正常业务,有发货/回款/
# 毛利;聚合基数开关在 settlement_service 同参数消费)——0 时单笔落 0
if snapshot.get("manager.personal.included") != "1":
return _RateDecision(
Decimal("0"),
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
None,
True,
)
start = parse_fraction(snapshot.get("salesperson.rate.start"))
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"经理个人户(基数开关=1):按业务员递减 {start}‰ 起,{days} 天"
return _RateDecision(rate, note, None, False)
if template == "TPL_INFO_SALES_FIXED":
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
rate = parse_fraction(snapshot.get("info.rate")) / 1000
return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False)
if template == "TPL_SALESPERSON_DECAY":
start = parse_fraction(snapshot.get("salesperson.rate.start"))
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
# int(参数原文) 对合法入库的 '360.0' 会抛 ValueError 使整月核算 500;
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)"
return _RateDecision(rate, note, None, False)
raise BadRequestError(f"公式模板 {template} 暂不支持核算")
def _role_of(session: Session, person):
from app.repositories import business_role_repository
if person.business_role_id is None:
return None
return business_role_repository.get_by_id(session, person.business_role_id)
def role_code_of(person) -> str:
"""人员岗位编码(台账冗余固化用;无岗位为空串——前置校验已拦)。"""
from app.repositories import business_role_repository
if person.business_role_id is None:
return ""
session = Session.object_session(person)
role = business_role_repository.get_by_id(session, person.business_role_id)
return role.role_code if role else ""
def _customer_type(session: Session, business: Business) -> str:
customer = session.get(Customer, business.customer_id)
return customer.customer_type if customer else "personal"
def _category_label(items: list[BusinessItem]) -> str:
"""单类别直出;混合业务记 mixed(行级明细在 snapshot_json)。"""
categories = {item.product_category for item in items}
return categories.pop() if len(categories) == 1 else "mixed"
def _mark_settled(session: Session, receipt: Receipt) -> None:
"""CAS 置已核算:并发下另一事务先行置 1 时 rowcount=0 → 400。"""
result = session.execute(
update(Receipt)
.where(Receipt.id == receipt.id, Receipt.settlement_status == SETTLEMENT_PENDING)
.values(settlement_status=SETTLEMENT_DONE)
)
if result.rowcount == 0:
raise BadRequestError("该回款已被并发核算,请刷新后查看台账")
def _row_profit(revenue: Decimal, cost: Decimal) -> Decimal:
"""行毛利 = 行销售额 − 行成本。
#20 策略点(待业务答复,2026-10-16 暂定口径):金额暂按**不含税**原样
相减;答复若为含税口径,只改本函数(含税换算/取不含税金额列)+ 按回款
批量重算回填。
"""
return revenue - cost
def recalc_receipt(
session: Session,
receipt_id: int,
reason: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CalcReceiptOut:
"""重算(授权动作,管理员/财务):作废旧版本行、按当前参数重算新版本。
薄委托 calc_receipt(recalc_reason=...)——折算体只有一份;所属月已审定
的拦截在 calc_receipt 内(核算与重算同口径)。
"""
if not reason or not reason.strip():
raise BadRequestError("重算必须填写原因")
return calc_receipt(
session, receipt_id, operator=operator, meta=meta, recalc_reason=reason.strip()
)
def batch_calc(
session: Session,
receipt_ids: list[int],
*,
operator: User,
meta: RequestMeta | None = None,
) -> list[dict]:
"""批量核算:逐笔独立事务(各笔自带 audit),返回逐笔成败清单。
**不开外层 audit**——外层包 audit 会违反嵌套禁令,且单笔失败不应
回滚其它笔的核算结果。
"""
results: list[dict] = []
for rid in receipt_ids:
try:
out = calc_receipt(session, rid, operator=operator, meta=meta)
results.append(
{
"receipt_id": rid,
"ok": True,
"repeated": out.repeated,
"ledger_id": out.ledger.id,
}
)
except Exception as exc:
results.append(
{"receipt_id": rid, "ok": False, "error": f"{type(exc).__name__}: {exc}"}
)
ok_count = sum(1 for r in results if r["ok"])
logger.info("批量核算 | 总数=%s 成功=%s", len(results), ok_count)
return results
def list_ledger_page(session: Session, query):
"""分页查询台账(api 层转发)。"""
rows = performance_ledger_repository.list_ledger(session, query)
total = performance_ledger_repository.count_ledger(session, query)
from app.core.response import PageResult
from app.schemas.calc import LedgerOut
return PageResult[LedgerOut](
records=[LedgerOut.model_validate(r) for r in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_ledger(session: Session, ledger_id: int):
"""台账详情(api 层转发)。"""
row = performance_ledger_repository.get_by_id(session, ledger_id)
if row is None:
from app.core.exceptions import NotFoundError
raise NotFoundError("台账记录不存在")
from app.schemas.calc import LedgerOut
return LedgerOut.model_validate(row)
def _to_out(row: PerformanceLedger):
from app.schemas.calc import LedgerOut
return LedgerOut.model_validate(row)

View File

@ -0,0 +1,72 @@
"""计提公式模板注册表(代码内有限集合)。
一期不引入规则引擎的落地方式:公式骨架固定(本表枚举),变化的是参数值
(系统设置维护)。岗位角色(business_roles.calc_template)绑定模板编码,
新增岗位 = 选模板 + 配参数,无需改代码(需求 #28)。
新增公式形态(如未来「按产品数量计提」)需要新增模板 —— 一期接受,
岗位表单中模板为带说明的受控下拉,避免误以为可自由写公式。
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class TemplateSpec:
"""公式模板元数据。"""
code: str
name: str
params: tuple[str, ...]
description: str
TEMPLATES: dict[str, TemplateSpec] = {
"TPL_SALESPERSON_DECAY": TemplateSpec(
code="TPL_SALESPERSON_DECAY",
name="业务员个人户·天数线性递减",
params=(
"salesperson.rate.start",
"salesperson.rate.slope",
"salesperson.rate.floor",
"salesperson.days.boundary",
),
description="分段:≤分界天数 = 起始 − 天数×斜率;>分界天数 = 下限",
),
"TPL_MANAGER_SHARE": TemplateSpec(
code="TPL_MANAGER_SHARE",
name="营销经理·团队与全公司分成",
params=("manager.share.team", "manager.share.company"),
description="聚合:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%",
),
"TPL_ENTERPRISE_FIXED": TemplateSpec(
code="TPL_ENTERPRISE_FIXED",
name="企业户·固定计提",
params=("enterprise.rate",),
description="乘法:折算毛利润额 × 计提比例",
),
"TPL_INFO_SALES_FIXED": TemplateSpec(
code="TPL_INFO_SALES_FIXED",
name="信息员·销售额固定比例",
params=("info.rate",),
description="乘法:本人销售额 × 计提比例",
),
"TPL_PREPAY_FIXED": TemplateSpec(
code="TPL_PREPAY_FIXED",
name="预付款·固定计提",
params=("prepay.rate",),
description="乘法:回款额 × 计提比例",
),
}
def get_template(code: str) -> TemplateSpec:
"""取模板元数据。未注册编码属代码缺陷(500),不是用户错误。"""
tpl = TEMPLATES.get(code)
if tpl is None:
raise ValueError(f"公式模板编码未注册:{code}")
return tpl
def required_params(code: str) -> tuple[str, ...]:
"""模板声明的参数编码列表(岗位表单展示与校验用)。"""
return get_template(code).params

Some files were not shown because too many files have changed in this diff Show More