PayrollManagementSystem/backend
liuwenxin b552f1e567 fix(部署): 将模板迁移 SQL 打包到后端镜像
V28 迁移运行时需要读取 db/migration,补齐镜像复制与构建白名单。
同时保留后端镜像使用的周期检查入口。
2026-11-03 17:18:49 +08:00
..
alembic style(迁移): 修正迁移脚本的 Ruff 检查问题 2026-11-03 17:18:44 +08:00
app fix(连接): 默认校验 T+ 证书并限制开发端口 2026-11-03 17:18:43 +08:00
tests fix(连接): 默认校验 T+ 证书并限制开发端口 2026-11-03 17:18:43 +08:00
.env.example fix(连接): 默认校验 T+ 证书并限制开发端口 2026-11-03 17:18:43 +08:00
.python-version chore: 初始化项目结构与登录鉴权模块 2026-10-09 18:46:36 +08:00
alembic.ini chore: 初始化项目结构与登录鉴权模块 2026-10-09 18:46:36 +08:00
Dockerfile fix(部署): 将模板迁移 SQL 打包到后端镜像 2026-11-03 17:18:49 +08:00
pyproject.toml feat(后端): 添加授权体系、系统监控与试用期计提 2026-11-03 11:20:55 +08:00
README.md feat(后端): 添加授权体系、系统监控与试用期计提 2026-11-03 11:20:55 +08:00
uv.lock feat(后端): 添加授权体系、系统监控与试用期计提 2026-11-03 11:20:55 +08:00

后端

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+ 数据同步

技术栈与版本

组件 版本 说明
Python 3.12 uv 锁定。系统默认是 3.14,不要用——第三方库轮子覆盖不如 3.12 完备
FastAPI / Starlette 0.143 / 1.7 starlette 显式限定 <2:FastAPI 对它的约束无上界,升级时可能漂移
SQLAlchemy 2.1 同步引擎,路由写 def 由 FastAPI 放入线程池
PyMySQL 1.2 必须带 [rsa]:MySQL 8.0 默认 caching_sha2_password,缺 cryptography 时首次连接抛 RuntimeError,且报错在建连阶段、极易误读为「密码错误」
alembic 1.20 迁移;运行期与迁移共用同一份 mysql+pymysql 连接串
redis 8.1 同步客户端
pwdlib 0.3 密码哈希。不用 passlib(已停更且与 bcrypt ≥4.1 不兼容)。算法显式指定 BcryptHasher,不用 recommended()
captcha 0.7 仅用其内置字体文件,渲染已自绘(见 core/captcha_image.py)

分层结构与红线

app/
├── api/            HTTP 层:路由、依赖注入、参数校验
│   ├── deps.py     公共依赖:get_db、get_current_user、get_raw_token
│   └── v1/         版本化业务路由
├── services/       业务编排 + **唯一事务边界**(commit/rollback 只在此层)
├── repositories/   数据访问,**只 flush 不 commit**
├── models/         ORM 映射,不含业务逻辑
├── schemas/        请求/响应模型,禁止把 ORM 对象直接当 response_model
└── core/           横切基础设施:配置、安全、连接、异常、响应、日志
层 可以做 不可做
api/ 参数绑定、调 service、返回统一响应 写业务判断、直接拼 SQL
services/ 编排多个 repository、提交事务 —
repositories/ 读写数据、flush commit()、抛业务异常
models/ 表映射 业务逻辑
core/ 基础设施 业务规则

核心接口

方法 路径 认证 说明
GET /api/health 否 探活,上报 app/db/redis 三项状态
GET /api/v1/auth/captcha 否 签发图形验证码,返回 id + 图片 base64
POST /api/v1/auth/login 否 账号 + 密码 + 验证码 → JWT
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 客户列表(公司/负责人模糊、分类/归属筛选;order_by=rating_score 按评分排序,未评分垫底)/ 新增(分类值域;公司名+负责人唯一)
GET / PUT / DELETE /api/v1/base/customers/{id} 详情 / 编辑(不触归属)/ 删除
PUT /api/v1/base/customers/{id}/rating 保存评级(评级计算器「保存评分」;只写 rating_score/rating_time,五项明细不落库 #25)
PUT /api/v1/base/customers/{id}/ownership 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19)
GET /api/v1/base/customers/{id}/ownership-logs 归属变更留痕分页(join 人员出姓名)

业务数据接口(需求 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)。

工作台接口(M4 ⑧,限管理员/财务/领导)

方法 路径 说明
GET /api/v1/dashboard?month=YYYY-MM 四区块一次性返回:经营绩效总览(当月绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步降级)、风险预警(回款逾期/审批超时 + 同步降级)、最近动态(操作日志)

待办与预警实时派生(不落表、不引调度器);预警阈值走参数体系 (warning.enabled / warning.receipt.overdue.days / warning.approval.timeout.days, V10 种子);⑥ T+ 同步未建,同步相关条目以 sync_available=false 显式降级。

T+ 本地只读同步与管理

T+ 数据库是 SQL Server 只读源库,业务库仍为 MySQL。Docker 恢复 PoC、备份检查和 D 盘目录约定见 docs/T+本地恢复与同步开发手册.md。

  • 连接配置在 backend/.env 的 T_PLUS_* 变量中,默认关闭,不配置时不影响主业务启动。
  • uv run python -m app.cli tplus-stage --entity personnel 按主键分页,将源行写入 tplus_sync_records staging,并记录批次和失败信息。水位表已预留,时间增量未启用,--watermark 会明确拒绝。
  • T+ 驱动通过 uv sync --extra tplus 安装到应用环境,ODBC 驱动名称必须显式配置;交付兼容目标为 SQL Server 2008 R2,2022 副本不能代替同版本验收。
  • 查询目录 db/queries/tplus/ 随部署交付,或使用 T_PLUS_QUERY_DIR 指定受控位置;支持六类主表和三类明细。
  • T+ 字典中的候选字段在实际备份核验和客户确认前,不得直接覆盖岗位、工资标准、归属、业务状态或核算状态。
  • 管理 API /api/v1/system/tplus 全部限管理员:概况、批次/异常分页、按需报告、创建与失败重试。HTTP 只创建 QUEUED 任务;uv run python -m app.cli tplus-worker --watch --connection-file <项目外JSON路径> 独立处理。
  • 核心文件:services/tplus/task_service.py(幂等/重试)、worker.py(执行)、relationship_audit.py(字典与关联)、query_service.py(安全输出)、core/tplus_lock.py(跨提交执行锁)。原 CLI 读取与 worker 共用执行锁。
  • V15 / Alembic e5f607a8b9c0 增加范围、跳过数、请求键、父批次、请求人和核验结果。未启用时间增量与工资业务导入;完整运行步骤和异常处理见本地恢复手册。

常用命令

uv sync                                  # 安装依赖
uv run uvicorn app.main:app --reload --port 8000   # 起服务(开发务必加 --reload)
uv run alembic upgrade head              # 应用迁移
uv run alembic revision --autogenerate -m "说明"   # 生成迁移
uv run python -m app.cli create-admin    # 建管理员(幂等,已存在不覆盖)
uv run pytest tests/unit -q              # 纯单元测试,无需容器
uv run pytest -q                         # 全部测试
uv run ruff check .                      # 代码检查
uv run ruff check --fix .                # 代码检查并自动修复可修项
uv run ruff format .                     # 格式化代码

Python 版本由 .python-version 与 pyproject.toml 的 requires-python 双重锁定。

代码检查与格式化

配置在 pyproject.toml 的 [tool.ruff]。三处刻意关闭的规则,不要误以为是漏配:

规则 关闭原因
E501 行宽 已由 formatter 控制,lint 重复报无意义;长中文注释极易触发
B008 参数默认值调用 FastAPI 依赖注入要求在参数默认值处写 Depends(),是框架约定而非缺陷
RUF001/002/003 易混淆 Unicode 该规则默认针对英文文本,会把中文的全角逗号、句号、括号全部误报(本项目实测 800+ 条)。中文里全角标点正是规范写法

另有 known-first-party = ["app", "tests"]:本项目 [tool.uv] package = false(不作为包安装),不显式声明的话 ruff 无法识别 app 是第一方包,会把第三方依赖排到它后面。

关键实现约定

JWT 时间一律用 UTC

exp 是 UTC 时间戳,而库列存本地时间(容器 TZ=Asia/Shanghai)。混算会产生 8 小时偏差, 表现为「刚登录就过期」或「过期了还能用」。库列用 datetime.now(),JWT 用 datetime.now(timezone.utc)。

「离职即失效」用 users.token_version,不依赖 Redis

禁用 / 改密 / 强制下线只需 UPDATE users SET token_version = token_version + 1。 鉴权时比对 JWT 的 ver 声明与库中值,Redis 故障时依然生效。 Redis 黑名单只负责「用户主动登出」。

Redis 键 TTL 由业务语义决定,不要套用固定值

键 TTL 依据
ys:captcha:{id} 300s 规范要求
ys:login:fail:{username} 900s = 锁定时长,写死会导致锁定提前失效
ys:token:blacklist:{jti} 令牌剩余有效期 写死会导致「登出 N 分钟后令牌又可用」

验证码用 GETDEL 原子取出即删

写成「先 GET 再 DEL」存在竞态窗口,并发请求可用同一验证码重复提交。

密码按字节数校验,不按字符数

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。

审批快照与方案版本化(2026-10-20 定稿,改动前先读)

  • 审批单带版本号与内容快照:approval_orders.version_no 按 (order_type, ref_id) 从 1 递增,重提先撤回旧在途单、但旧单快照不覆盖;snapshot_json 统一信封 {schema_version, order_type, captured_at, data},data 由 approval_service.{allocation,settlement,adjustment}_content 组装(Decimal 转 str、日期转 ISO),开关关闭的自动通过路径同样写快照。审批期间业务再改,复核者看到的仍是送审版本。
  • 两个 version_no 语义不同,别混用:business_secondary_allocations.version_no = 方案内容第几版,仅方案内容(参与人+比例)变化时 +1;approval_orders.version_no = 同一对象第几次送审,仅送审内容变化时 +1。改备注不动金额 → 两者都不动;改金额 → 只有审批单版本号动;改方案比例 → 两者都动。
  • 送审内容未变不重提:编辑业务时若存在在途审批单且其快照 data 与本次送审内容逐键相等(approval_service.pending_matches_snapshot,只比 data 不比 captured_at),则保留原单不撤回不重建;否则撤旧提新。方案内容未变时不新增方案版本行(business_service._plan_changed 按「人员+比例」集合比较,与行序无关)。
  • 快照的覆盖范围是「方案 + 钱 + 归属」(业务编号/日期、客户、负责与协助人、销售额、成本额、方案参与人与比例、合计与余额),不含产品明细行。因此只改产品构成而金额不变时不会重提——分摊依据(谁分、分多少、基于多少钱)未变;产品明细由复核者按业务编号回业务页查看。
  • 列表不加载快照列:快照可达上百 KB,审批单列表、工作台待办、工作台审批超时预警三处查询一律 defer(ApprovalOrder.snapshot_json)(与操作日志列表排除 request_data 同一理由,参考 operation_log_repository.list_logs);详情走 get_order 按需读取。
  • 详情内容来源三态:content_source=snapshot(送审快照)/ live(V11 前的历史单体无快照,只读回退当前内容并显式告警)/ missing(引用对象不存在且无快照)。禁止回填历史单快照——回填等于伪造提交时内容。
  • 二次分配方案 append-only:business_secondary_allocations 行不物理删除,编辑全量替换与驳回清空只把旧行 is_active=0;唯一键为 (business_id, version_no, personnel_id),同一版本内参与人唯一、跨版本可重复。business_repository.list_allocations(active_only=True) 是核算与详情回显的唯一取数口径;业务详情 allocation_history 返回全部版本供回溯。
  • 月结提交要锁行:settlement_service.submit 取月结行必须 for_update=True——并发提交会让两次 submit_order 算出同一个审批单 version_no,送审次序失真。
  • 审批单 version_no 有唯一键兜底(V12):uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)——同一对象的第 N 次送审在库里有唯一一行。ORM 侧约束名必须与迁移 DDL 严格一致,否则 autogenerate 误判。
  • 取版本号必须是「当前读」(approval_service._next_version),两个禁忌:(a) 不能用普通 MAX 查询——请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻,父行锁只串行化写入时刻、不刷新快照,MAX 会读到旧值;(b) 不能写成 select(func.max(...)).with_for_update()——MySQL 官方明确「Locking clauses are not permitted in queries with aggregate functions or GROUP BY」,聚合上的 FOR UPDATE 语义无保证。正解:锁读该 ref 的现有版本行(select(version_no)...with_for_update()),最大值 Python 侧算(同 no_generator 套路)。
  • 版本号冲突的收敛口径:submit_order 内不 catch、不 rollback(红线)。create 路径由外层编号重试循环整体重来(business_service._NO_RETRY);编辑/月结提交路径由审计上下文统一回滚,再把 1062 翻译为 400「提交冲突(版本号生成重复),请重试」(同 param_service 版本号冲突模板)。唯一键是最后一道防线,不是取号正确性的前提。
  • 迁移前先修存量:V11 把存量行 version_no 一律置 1,而 V11 前每次重提都新建行——同一 (order_type, ref_id) 存在多行 version_no=1,直接加唯一键必报 1062。V12 先重排「组内有重复」的分组(按 id 升序 1..n),其余分组不动(全量重排会改掉前端已展示过的历史版本号)。
  • 新增审批对象类型须同时提供快照组装函数并接入 _content_of 的 live 回退分支,不要在各业务服务内联 JSON 结构。

工作台口径(M4 ⑧,改动前先读)

  • 只读聚合:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 COUNT(DISTINCT personnel_id)——二次分摊拆行会让行数虚高。
  • 待办/预警实时派生:查询即算,不建待办表/预警表。审批超时取 approval_orders.create_time(待审批状态);回款逾期按「发货单 ship_date 超阈值且该批次无任何回款」判定,已终止业务(status=3)排除;回款未指定批次 = receipts.shipment_id IS NULL。
  • 待办按角色过滤:pending_statuses_for_roles(approval_service)把当前用户角色映射到在途节点——财务只看待财务复核(status=1)、领导只看待领导审核(status=2)、admin 可看全部;多角色取并集。风险预警仍是全局经营视角,不按人裁剪。
  • 预警展示上限:items 最多 20 条,total 是全部命中数(前端徽标用它,不能用 items.length)。
  • 趋势窗口:回款趋势先按 receipt_date 范围过滤再按 DATE_FORMAT 分组,函数列不参与 WHERE,保证索引可用。
  • 同步降级:⑥ 未建时 sync_available=false,前端显示「模块未启用」——不要返回 0 条冒充「无失败」。
  • 参数快照一次解析:warning.* 三个阈值在每次请求只 resolve_snapshot 一次,多条规则复用。
  • 导出 SQL 的编码坑:Windows 下 alembic upgrade --sql > V*.sql 默认按控制台 GBK 输出,test_schema_sync 按 UTF-8 读取会报 UnicodeDecodeError——必须 PYTHONIOENCODING=utf-8 PYTHONUTF8=1 前缀。

报表口径(M4 ⑤,改动前先读)

  • 数据源固定单笔生效行:四张报表的明细一律 performance_ledgers.is_active=1 AND calc_type=1;经理聚合行(calc_type=2)只在部门汇总作冻结对照(frozen_amount)单读。聚合行 receipt_id 为 NULL、payment_days 为 NULL,混入明细会让合同编号/天数列失真,业务统计的绩效也会被整团队聚合额污染。
  • 调整单并入:仅 status=2(生效),按 (settlement_month, personnel_id) 并入,不要求该人当月有台账行(只有调整单也要出零绩效成员行);direction=1 补发记 +、2 扣减记 −(金额恒正)。待审批/驳回/撤回的金额绝不能进报表——report_repository.list_month_adjustments 与调整单管理页的 list_adjustments 是两个口径。调整单归属跟随该人普通行的实时 manager_id;只有经理个人户的经理跟随本人团队。
  • 回款金额按回款单去重:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 DISTINCT receipt_id 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。
  • 部门口径 = personnel.manager_id 实时上溯(无独立部门表);离职人员不按 status 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(services/manager_share.py 共享模块)。
  • 全公司 5% 的发放口径:月结只为有台账 bucket 的营销经理各生成一条聚合行(12.5%+5%),每位经理各计一次 5%;零成员团队无聚合行、不发 5%。报表的 aggregate_amount 优先取冻结聚合行、未生成月结时才用 manager_team_share 一次舍入预览;team_subtotal/net_total = 聚合额 + 调整净额。team_share_total 与 company_share_total 仅分列展示,两者之和与聚合额的差记入 aggregate_rounding_diff(分列各自 q2 会有 0.01~0.02 尾差)。
  • 对拍边界:报表应发 = 月结 total_payable + 调整单净额(调整单不进月结 items);筛选 manager_id 时只汇总展示团队,隐藏金额进 other_*,不能与全公司月结总额对拍。
  • 人员归属按行类型:普通台账行跟随 manager_id;经理个人户行在 manager.personal.included=1 时归经理本人团队,且经理成员行只取个人户金额(其普通行业绩留在上级团队或未分配),保证团队基数 = 成员绩效合计。岗位判定范围必须覆盖所有成员引用的上级,否则其他经理的下属会被误判为「无经理」。按 manager_id 筛选时 unassigned 为空(语义是「仅看该经理团队」)。
  • 回款天数区间是 8 档代码常量(core/report_meta.py,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。
  • 导出:openpyxl 普通 Workbook + Decimal 原值 + number_format(不写 Excel 公式);超 REPORT_EXPORT_MAX_ROWS(20000)行返回 400;文本列防公式注入(= + - @ 前缀加 ');服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 link.download 指定。部门汇总是单 sheet 分块签字稿(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);第二块展示单经理 5%、计入次数与合计,对账块展示团队分成/全公司分成(分列)、聚合行舍入差、调整净额,应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。
  • 导出审计:audit 上下文覆盖取数与构建全过程,超限 400 和取数异常都会写失败操作日志(success=0);成功摘要为静态报表标题。
  • 汇总 = 明细合计:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 团队基数 + 未分配绩效 = 全公司基数;集成测试逐项断言。
  • 岗位筛选选项:GET /api/v1/base/business-roles/options 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。

面向业务人员的文案(2026-10-23 定稿,改文案前先读)

系统由财务与营销人员日常使用,不是给开发者看的。凡是会渲染到页面的文本(参数说明、错误提示、口径注释)一律按业务语言写:

  • param_registry.py 的 description 是唯一显示源,会直出到「计提参数」页参数名下方。写清「这个参数管什么、钱怎么变、什么时候该调它」,不写英文枚举名(FULL/BALANCE)、需求编号(澄清 #10)、内部阶段号(M3/M4 ⑧)、实现层精度约束(「禁止按 0.011111 截断存储」留在代码注释与测试里)。
  • 枚举值在页面上必须翻译:前端 params/index.vue 的 ENUM_VALUE_LABELS(FULL 当月全额 / BALANCE 余额 / INCLUDE 含基本工资 / DEDUCT 扣除基本工资 / EXCLUDE 不计基本工资 / 1 开 / 0 关)与 DIMENSION_LABELS(municipal_water 市政水利 / rail_transit 轨道交通)。后端报错也不许把原始枚举值甩给用户 —— param_service._preview_rate 的产品类别校验用 「市政水利、轨道交通」 而不是 municipal_water / rail_transit。
  • 公式模板报错用中文模板名:calc_templates.TEMPLATES[code].name,取不到才回退 code;文案要给出下一步(「请在岗位角色里改用业务员递减或信息员固定计提」),而不是只说「不支持」。
  • db/init/init-dev.sql 的种子 description 必须与 registry 同源:它只在数据卷为空时首次启动执行,已有库要跑 ai-aux/preview/sync_param_desc.py(读 PARAM_REGISTRY 逐条 UPDATE)才看得到新文案。db/migration/V*.sql 里的旧描述是历史记录,不要回头改 —— 改了会让已部署库与脚本不一致。
  • 前端模板层禁止出现开发者语汇:澄清 / 待业务 / M3 / M4 / 一期 / 二阶段 / 暂口径 / 待确认 / 待接 / 暂不 / 待补 只允许出现在代码注释里。改完用 grep 扫一遍 frontend/src/views。
  • 台账与预览里落库的说明文字(rate_text / rate_note / 聚合行 snapshot_json.note)同样受管:这些会出现在绩效台账、个人明细、打印稿与计算预览上。写成「这笔为什么是这个比例、什么时候不再降」(如「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」),不写 基数开关=1、#12 参数化、9.8 方案公式 这类需求编号。分数原文(11/1125)与百分比拼装(聚合:20%+5%)不算说明文字,那是金额口径的可复核原文,不要为了「口语化」把它改成四舍五入的小数。

常见问题

起服务报 APP_SECRET_KEY 相关错误 —— backend/.env 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。

连数据库抛 RuntimeError: 'cryptography' package is required —— 依赖被改回不带 [rsa] 的 pymysql。装回 pymysql[rsa]。

alembic upgrade head 报表已存在 —— 开发库是 init-dev.sql 建的、alembic_version 未写入。手工补版本记录,或 down -v 重建。

改了代码不生效 —— 启动时没加 --reload。

系统监控(2026-11-02)

新增管理员只读接口 /api/v1/system/monitor/{overview,trends,interfaces,events,diagnostic},事件详情为 /events/{id}。后台独立线程每 15 秒采集,分钟聚合保留 7 天;配置见 .env.example 的 MONITOR_*,新增依赖 psutil。部署限单个后端应用进程,按项目流程应用 V23 迁移后启动。异常通过 X-Request-ID 与脱敏服务端日志关联,/api/health 原契约保持不变。

详细采集口径、故障边界、排查步骤和接口见 系统监控运维说明。

角色授权(2026-11-02)

系统权限统一为“角色配置权限 → 用户分配角色 → 用户继承权限”。V26(26e7f8a9b0c1)停用旧个人覆盖和全局制作人入口;工资核算、生成、调整分别检查角色权限,月份实际经办人、生成锁和历史留痕继续保留。接口与迁移边界见 角色授权使用说明。