Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94c49138bf | ||
|
|
f75a9d534e | ||
|
|
0ef3adec21 | ||
|
|
60f6af6494 | ||
|
|
409e26614f | ||
|
|
5ef48abf47 | ||
|
|
7f8f88c148 | ||
|
|
c2b462df3b | ||
|
|
e6c2c71d1b | ||
|
|
91fc6f6293 | ||
|
|
9a70264b51 | ||
|
|
6d612daf30 | ||
|
|
a0086554b1 | ||
|
|
5a3778a424 | ||
|
|
cb977979da | ||
|
|
64c57e9141 | ||
|
|
d8dba27fb2 | ||
|
|
d77728265b | ||
|
|
8c4acb82fe | ||
|
|
fba8cfc215 | ||
|
|
299b72483c | ||
|
|
c512cb77fb | ||
|
|
ca4cc72eea | ||
|
|
77e578085a | ||
|
|
06cdf4141f | ||
|
|
5207d14daa | ||
|
|
09831dbb7f | ||
|
|
a02b6c3ec8 | ||
|
|
4aea2be103 | ||
|
|
0cdc6246c4 | ||
|
|
1625907380 | ||
|
|
899bf3afa5 | ||
|
|
20fcf06f51 | ||
|
|
ca88ee7a0b | ||
|
|
15de81dd2c | ||
|
|
b8ba9e4626 | ||
|
|
7eeed85c96 | ||
|
|
a694570dd1 | ||
|
|
f22d14e439 | ||
|
|
4552f2e703 | ||
|
|
e568ebde61 | ||
|
|
8668e58c30 | ||
|
|
fbf044f240 | ||
|
|
6219698a39 | ||
|
|
d7fbec1177 | ||
|
|
4c442b8cf2 | ||
|
|
fd801455f0 | ||
|
|
8ad3650dd3 | ||
|
|
7a0f3f4b98 | ||
|
|
1b3509b20f | ||
|
|
5ed5665a54 | ||
|
|
bc019cadf5 |
No files matched your search
+45
-2
@@ -1,12 +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`)
|
- 提供 REST 接口(统一前缀 `/api`)
|
||||||
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
|
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
|
||||||
- 系统管理(需求 2.5.1):用户管理、角色管理、操作日志(自动审计)、操作与登录日志查询
|
- 系统管理(需求 2.5.1):用户管理、角色管理、岗位角色(#28 可扩展+公式模板绑定)、操作日志(自动审计)、操作与登录日志查询
|
||||||
|
- 计提参数体系(需求 #28):16 项参数目录、按生效日期版本化(回款到账日取值)、变更批次留痕/对比/导出、计算预览
|
||||||
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
|
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
|
||||||
|
|
||||||
## 技术栈与版本
|
## 技术栈与版本
|
||||||
@@ -70,6 +71,41 @@ app/
|
|||||||
| GET | `/api/v1/system/operation-logs/{id}` | 操作日志详情(含已脱敏的请求参数,展开行懒加载) |
|
| GET | `/api/v1/system/operation-logs/{id}` | 操作日志详情(含已脱敏的请求参数,展开行懒加载) |
|
||||||
| GET | `/api/v1/system/login-logs` | 登录日志查询(用户名/IP/结果/时间范围) |
|
| GET | `/api/v1/system/login-logs` | 登录日志查询(用户名/IP/结果/时间范围) |
|
||||||
|
|
||||||
|
### 基础数据接口(需求 2.1,登录即可 —— 业务操作不要求管理员)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET / POST | `/api/v1/base/personnel` | 人员列表(姓名模糊/状态/岗位筛选,附岗位与经理名)/ 新增(岗位须启用;经理非自己且在职) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/base/personnel/{id}` | 详情(附区域与计划)/ 编辑 / 删除(被团队成员或客户归属引用 → 400 引导改离职) |
|
||||||
|
| PUT | `/api/v1/base/personnel/{id}/status` | 在职 / 离职切换 |
|
||||||
|
| PUT | `/api/v1/base/personnel/{id}/regions` | 辐射区域全量替换(空列表即清空) |
|
||||||
|
| PUT | `/api/v1/base/personnel/{id}/plans` | 市场拓展计划全量替换 |
|
||||||
|
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
|
||||||
|
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
|
||||||
|
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
|
||||||
|
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
|
||||||
|
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
|
||||||
|
|
||||||
|
### 业务数据接口(需求 2.2,登录即可)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET / POST | `/api/v1/business/businesses` | 业务列表(编号/公司名模糊、状态/客户/归属/日期筛选)/ 创建(编号 `YW{日期}-{序号}` 自动生成;明细 ≥1 行;销售额服务层固化) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/business/businesses/{id}` | 详情(附明细)/ 编辑(明细全量替换 + 五项汇总重算;**归属不在此端点**)/ 删除(有发货或回款 → 400 引导改已终止) |
|
||||||
|
| PUT | `/api/v1/business/businesses/{id}/status` | 状态流转(1→2/3;终态不可再变更;终止必填原因;写状态留痕) |
|
||||||
|
| PUT | `/api/v1/business/businesses/{id}/ownership` | 负责/协助归属变更(同事务写留痕,owner_type=2) |
|
||||||
|
| GET | `/api/v1/business/businesses/{id}/ownership-logs` | 业务归属变更留痕分页 |
|
||||||
|
| GET | `/api/v1/business/businesses/{id}/status-logs` | 状态流转时间线(创建写首条 old=new=1) |
|
||||||
|
| GET | `/api/v1/business/businesses/{id}/gross-profit` | **毛利润数据视图**(只读实时计算,不落库:行级率/折算额 + 类别汇总;不含运费 #2;阈值按 `as_of` 取参数快照;成本缺失行标注无法计算) |
|
||||||
|
| GET / POST | `/api/v1/business/shipments` | 发货单列表 / 创建(编号 `SH…`;业务须执行中;行金额服务层固化;业务级发货/运费汇总回写) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/business/shipments/{id}` | 详情 / 编辑(终态业务禁改)/ 删除(被回款批次指定 → 400) |
|
||||||
|
| GET / POST | `/api/v1/business/receipts` | 回款列表(含 **payment_days 派生**:发货日首日=第 1 天 #1)/ 新增(编号 `HK…`;**shipment_id 同业务强校验**;业务须执行中) |
|
||||||
|
| GET / PUT / DELETE | `/api/v1/business/receipts/{id}` | 详情 / 编辑 / 删除(**已核算回款禁改禁删**——M3 防重锚点) |
|
||||||
|
| GET / POST | `/api/v1/business/rating-grades` | 评级等级全量列表(sort_order 升序)/ 新增(区间值域 + **与既有等级重叠 → 400**) |
|
||||||
|
| PUT / DELETE | `/api/v1/business/rating-grades/{id}` | 编辑(重叠判定)/ 删除(无下游引用,允许清空) |
|
||||||
|
|
||||||
全部写操作自动写入操作日志(成功与失败都记,机制见下)。
|
全部写操作自动写入操作日志(成功与失败都记,机制见下)。
|
||||||
|
|
||||||
统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
|
统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
|
||||||
@@ -153,6 +189,13 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
|
|||||||
|
|
||||||
**角色管理保护规则**(后端是唯一防线,前端按钮禁用只是体验):admin 角色禁删禁停;有用户绑定的角色禁删(`user_roles` 外键是 CASCADE;删除前走 `SELECT ... FOR UPDATE` 锁行,堵住并发绑定窗口);`role_code` 创建后不可改;**停用角色不可再分配**(`_resolve_roles` 校验 status,停用只影响新分配、不影响既有授权)。
|
**角色管理保护规则**(后端是唯一防线,前端按钮禁用只是体验):admin 角色禁删禁停;有用户绑定的角色禁删(`user_roles` 外键是 CASCADE;删除前走 `SELECT ... FOR UPDATE` 锁行,堵住并发绑定窗口);`role_code` 创建后不可改;**停用角色不可再分配**(`_resolve_roles` 校验 status,停用只影响新分配、不影响既有授权)。
|
||||||
|
|
||||||
|
### M2 业务数据的四个关键约定(2026-10-13 定稿,改动前先读)
|
||||||
|
|
||||||
|
- **单据编号**(`services/no_generator.py`):`{前缀}{YYYYMMDD}-{当日4位序号}`,业务 YW / 发货 SH / 回款 HK(`core/constants.py` 的 `NO_PREFIX_*`)。`SELECT MAX ... FOR UPDATE` 锁当日区间防并发重号;残余竞态以唯一键 1062 冲突**重试一次**兜底(无限重试会掩盖死锁)。序号超 4 位按实际位数输出(VARCHAR(32) 余量足够)。
|
||||||
|
- **五项汇总冗余**(`businesses.total_*`):明细/发货/回款的任何写路径统一收口到 `business_service._recalc_totals` 单点重算,禁止各处手写 SUM 回写;发货与回款服务写操作前**必须 `get_by_id(for_update=True)` 锁业务行**(防两模块并发写同一业务的丢失更新)。毛利润率/折算额**不落库**——它们是明细金额 + 时点化阈值的派生值,落库会在成本调整或参数改版后漂移;视图接口(`/gross-profit`)实时算,M3 核算台账才是法定固化点。
|
||||||
|
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
|
||||||
|
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
|
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
|
||||||
|
|||||||
@@ -0,0 +1,696 @@
|
|||||||
|
"""业务数据管理七表:业务/业务明细/状态留痕/发货单/发货明细/回款/评级等级配置(M2)
|
||||||
|
|
||||||
|
- businesses:业务编号自动生成(报表 2.4.1「合同编号」),五项汇总冗余列
|
||||||
|
由服务层事务内维护;毛利润相关值不落库(派生值,M3 台账才是固化点)
|
||||||
|
- business_items:行级 product_category 为折算锚点(对照表 #14),指导价
|
||||||
|
人工录入、T+ 同步不覆盖
|
||||||
|
- business_status_logs:append-only,创建时写首条(old=new=1)
|
||||||
|
- shipments/shipment_items:发货日为回款天数基准日(对照表 #1)
|
||||||
|
- receipts:手动指定发货批次(#18);settlement_status 为 M3 核算防重锚点
|
||||||
|
- customer_rating_grades:等级映射可配置,种子四条随迁移初始化
|
||||||
|
|
||||||
|
Revision ID: 8efc189b131f
|
||||||
|
Revises: e1133a7c670a
|
||||||
|
Create Date: 2026-10-13 11:14:17.545320
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "8efc189b131f"
|
||||||
|
down_revision: str | Sequence[str] | None = "e1133a7c670a"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
# 评级等级种子(与 init-dev.sql 双写一致;口径见模型 docstring)
|
||||||
|
_GRADE_SEED = [
|
||||||
|
{"grade_name": "A", "min_score": 90, "max_score": 100, "sort_order": 1, "remark": "优秀"},
|
||||||
|
{"grade_name": "B", "min_score": 75, "max_score": 89, "sort_order": 2, "remark": "良好"},
|
||||||
|
{"grade_name": "C", "min_score": 60, "max_score": 74, "sort_order": 3, "remark": "合格"},
|
||||||
|
{"grade_name": "D", "min_score": 0, "max_score": 59, "sort_order": 4, "remark": "待改进"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移。"""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"customer_rating_grades",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"grade_name", sa.String(length=32), nullable=False, comment="等级名称(如 A/优秀)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"min_score",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
nullable=False,
|
||||||
|
comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"max_score",
|
||||||
|
sa.Numeric(precision=5, scale=1),
|
||||||
|
nullable=False,
|
||||||
|
comment="总分上限(展示用)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sort_order",
|
||||||
|
sa.Integer(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="排序(判定顺序,升序)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="说明"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_rating_grades")),
|
||||||
|
sa.UniqueConstraint("grade_name", name=op.f("uk_customer_rating_grades_grade_name")),
|
||||||
|
comment="客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"business_status_logs",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(固化,无外键;删除保护由服务层承担)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"old_status", mysql.TINYINT(), nullable=False, comment="变更前状态(创建首条为 1)"
|
||||||
|
),
|
||||||
|
sa.Column("new_status", mysql.TINYINT(), nullable=False, comment="变更后状态"),
|
||||||
|
sa.Column(
|
||||||
|
"change_reason", sa.String(length=255), nullable=True, comment="变更原因(终止时必填)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"operator_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"operator_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
comment="操作人用户名(冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["operator_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_business_status_logs_operator_id"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_status_logs")),
|
||||||
|
comment="业务状态变更记录表(append-only)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_status_logs_business_id",
|
||||||
|
"business_status_logs",
|
||||||
|
["business_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_status_logs_create_time",
|
||||||
|
"business_status_logs",
|
||||||
|
["create_time"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"businesses",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"customer_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="客户ID(customers.id)",
|
||||||
|
),
|
||||||
|
sa.Column("business_date", sa.Date(), nullable=True, comment="业务日期(T+ 同步口径)"),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="1",
|
||||||
|
nullable=False,
|
||||||
|
comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"owner_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"assistant_personnel_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"is_special",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="特殊业务标记:1是 0否(公司参与等)",
|
||||||
|
),
|
||||||
|
sa.Column("special_note", sa.String(length=255), nullable=True, comment="特殊业务说明"),
|
||||||
|
sa.Column(
|
||||||
|
"total_sale_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_cost_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_ship_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_received_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_freight_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=True,
|
||||||
|
comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["assistant_personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_businesses_assistant_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["customer_id"],
|
||||||
|
["customers.id"],
|
||||||
|
name=op.f("fk_businesses_customer_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["owner_personnel_id"],
|
||||||
|
["personnel.id"],
|
||||||
|
name=op.f("fk_businesses_owner_personnel_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_businesses")),
|
||||||
|
sa.UniqueConstraint("business_no", name=op.f("uk_businesses_business_no")),
|
||||||
|
comment="业务表(业务编号自动生成;T+同步不覆盖业务状态)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_businesses_business_date", "businesses", ["business_date"], unique=False)
|
||||||
|
op.create_index("idx_businesses_customer_id", "businesses", ["customer_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"idx_businesses_owner_personnel_id", "businesses", ["owner_personnel_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(op.f("idx_businesses_source_no"), "businesses", ["source_no"], unique=False)
|
||||||
|
op.create_index("idx_businesses_status", "businesses", ["status"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"business_items",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="产品ID(products.id;NULL=自由录入,未入产品库)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_name",
|
||||||
|
sa.String(length=128),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品名称(引用产品时冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"spec_model",
|
||||||
|
sa.String(length=128),
|
||||||
|
server_default="",
|
||||||
|
nullable=False,
|
||||||
|
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
|
||||||
|
),
|
||||||
|
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
|
||||||
|
sa.Column(
|
||||||
|
"product_category",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="数量(>0)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sale_price",
|
||||||
|
sa.Numeric(precision=14, scale=4),
|
||||||
|
nullable=True,
|
||||||
|
comment="销售单价(元;T+ 同步或人工)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sale_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"cost_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=True,
|
||||||
|
comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"guided_price",
|
||||||
|
sa.Numeric(precision=14, scale=4),
|
||||||
|
nullable=True,
|
||||||
|
comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["business_id"],
|
||||||
|
["businesses.id"],
|
||||||
|
name=op.f("fk_business_items_business_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["product_id"],
|
||||||
|
["products.id"],
|
||||||
|
name=op.f("fk_business_items_product_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_items")),
|
||||||
|
comment="业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_items_business_id", "business_items", ["business_id"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_business_items_product_category", "business_items", ["product_category"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index("idx_business_items_product_id", "business_items", ["product_id"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"shipments",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"shipment_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id;终态业务禁止新发货)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"ship_date",
|
||||||
|
sa.Date(),
|
||||||
|
nullable=False,
|
||||||
|
comment="发货日期(回款天数基准日,发货日首日=第 1 天)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"freight_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["business_id"],
|
||||||
|
["businesses.id"],
|
||||||
|
name=op.f("fk_shipments_business_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_shipments")),
|
||||||
|
sa.UniqueConstraint("shipment_no", name=op.f("uk_shipments_shipment_no")),
|
||||||
|
comment="发货单表(发货日为回款天数基准日,发货日首日计第 1 天)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_shipments_business_id", "shipments", ["business_id"], unique=False)
|
||||||
|
op.create_index("idx_shipments_ship_date", "shipments", ["ship_date"], unique=False)
|
||||||
|
op.create_index(op.f("idx_shipments_source_no"), "shipments", ["source_no"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"receipts",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"receipt_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"shipment_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"receipt_date",
|
||||||
|
sa.Date(),
|
||||||
|
nullable=False,
|
||||||
|
comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="回款金额(≥0;一期禁负数/红字)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"is_barter",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"settlement_status",
|
||||||
|
mysql.TINYINT(),
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)",
|
||||||
|
),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_no",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
),
|
||||||
|
sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"),
|
||||||
|
sa.Column(
|
||||||
|
"sync_batch_no",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=True,
|
||||||
|
comment="同步批次号(同步模块写入)",
|
||||||
|
),
|
||||||
|
sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["business_id"],
|
||||||
|
["businesses.id"],
|
||||||
|
name=op.f("fk_receipts_business_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["shipment_id"],
|
||||||
|
["shipments.id"],
|
||||||
|
name=op.f("fk_receipts_shipment_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_receipts")),
|
||||||
|
sa.UniqueConstraint("receipt_batch_no", name=op.f("uk_receipts_receipt_batch_no")),
|
||||||
|
comment="回款表(手动指定发货批次;已核算回款禁改禁删)",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_receipts_business_id", "receipts", ["business_id"], unique=False)
|
||||||
|
op.create_index("idx_receipts_receipt_date", "receipts", ["receipt_date"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"idx_receipts_settlement_status", "receipts", ["settlement_status"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index("idx_receipts_shipment_id", "receipts", ["shipment_id"], unique=False)
|
||||||
|
op.create_index(op.f("idx_receipts_source_no"), "receipts", ["source_no"], unique=False)
|
||||||
|
op.create_table(
|
||||||
|
"shipment_items",
|
||||||
|
sa.Column(
|
||||||
|
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"shipment_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=False,
|
||||||
|
comment="发货单ID(shipments.id)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_id",
|
||||||
|
mysql.BIGINT(unsigned=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="产品ID(products.id;NULL=自由录入)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_name",
|
||||||
|
sa.String(length=128),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品名称(引用产品时冗余固化)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"spec_model",
|
||||||
|
sa.String(length=128),
|
||||||
|
server_default="",
|
||||||
|
nullable=False,
|
||||||
|
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
|
||||||
|
),
|
||||||
|
sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"),
|
||||||
|
sa.Column(
|
||||||
|
"quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="发货数量(>0)"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"sale_price",
|
||||||
|
sa.Numeric(precision=14, scale=4),
|
||||||
|
nullable=True,
|
||||||
|
comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"amount",
|
||||||
|
sa.Numeric(precision=14, scale=2),
|
||||||
|
nullable=False,
|
||||||
|
comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"create_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="创建时间",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"update_time",
|
||||||
|
sa.DateTime(),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
comment="更新时间",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["product_id"],
|
||||||
|
["products.id"],
|
||||||
|
name=op.f("fk_shipment_items_product_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["shipment_id"],
|
||||||
|
["shipments.id"],
|
||||||
|
name=op.f("fk_shipment_items_shipment_id"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_shipment_items")),
|
||||||
|
comment="发货产品明细表",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_general_ci",
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
)
|
||||||
|
op.create_index("idx_shipment_items_product_id", "shipment_items", ["product_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"idx_shipment_items_shipment_id", "shipment_items", ["shipment_id"], unique=False
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
# 评级等级种子(等级映射可配置;双轨:init-dev.sql 同步写入)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"customer_rating_grades",
|
||||||
|
sa.column("grade_name", sa.String),
|
||||||
|
sa.column("min_score", sa.Numeric),
|
||||||
|
sa.column("max_score", sa.Numeric),
|
||||||
|
sa.column("sort_order", sa.Integer),
|
||||||
|
sa.column("remark", sa.String),
|
||||||
|
),
|
||||||
|
_GRADE_SEED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移。
|
||||||
|
|
||||||
|
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
|
||||||
|
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
|
||||||
|
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
|
||||||
|
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
|
||||||
|
"""
|
||||||
|
# 整表回滚只保留 drop_table(DROP TABLE 连带删除索引与外键);
|
||||||
|
# 逐条 drop_index 会先于 drop_table 摘掉外键支撑索引而报 MySQL 1553
|
||||||
|
op.drop_table("shipment_items")
|
||||||
|
op.drop_table("receipts")
|
||||||
|
op.drop_table("shipments")
|
||||||
|
op.drop_table("business_items")
|
||||||
|
op.drop_table("businesses")
|
||||||
|
op.drop_table("business_status_logs")
|
||||||
|
op.drop_table("customer_rating_grades")
|
||||||
@@ -0,0 +1,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")
|
||||||
@@ -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")
|
||||||
@@ -8,7 +8,16 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.api import health
|
from app.api import health
|
||||||
from app.api.v1 import auth
|
from app.api.v1 import auth
|
||||||
|
from app.api.v1.base import 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.system import business_roles as system_business_roles
|
||||||
from app.api.v1.system import logs as system_logs
|
from app.api.v1.system import logs as system_logs
|
||||||
|
from app.api.v1.system import params as system_params
|
||||||
from app.api.v1.system import roles as system_roles
|
from app.api.v1.system import roles as system_roles
|
||||||
from app.api.v1.system import users as system_users
|
from app.api.v1.system import users as system_users
|
||||||
|
|
||||||
@@ -25,8 +34,25 @@ v1_router.include_router(auth.router)
|
|||||||
system_router = APIRouter(prefix="/system")
|
system_router = APIRouter(prefix="/system")
|
||||||
system_router.include_router(system_users.router)
|
system_router.include_router(system_users.router)
|
||||||
system_router.include_router(system_roles.router)
|
system_router.include_router(system_roles.router)
|
||||||
|
system_router.include_router(system_business_roles.router)
|
||||||
|
system_router.include_router(system_params.router)
|
||||||
system_router.include_router(system_logs.operation_router)
|
system_router.include_router(system_logs.operation_router)
|
||||||
system_router.include_router(system_logs.login_router)
|
system_router.include_router(system_logs.login_router)
|
||||||
v1_router.include_router(system_router)
|
v1_router.include_router(system_router)
|
||||||
|
|
||||||
|
# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问
|
||||||
|
base_router = APIRouter(prefix="/base")
|
||||||
|
base_router.include_router(base_personnel.router)
|
||||||
|
base_router.include_router(base_products.router)
|
||||||
|
base_router.include_router(base_customers.router)
|
||||||
|
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)
|
||||||
|
|
||||||
api_router.include_router(v1_router)
|
api_router.include_router(v1_router)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""基础数据管理接口(需求 2.1)。
|
||||||
|
|
||||||
|
挂载于 `/api/v1/base/*`,含人员管理、辐射区域、拓展计划等基础数据的维护入口。
|
||||||
|
|
||||||
|
与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问
|
||||||
|
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
|
||||||
|
由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
@@ -0,0 +1,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))
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""人员管理接口(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。
|
||||||
|
|
||||||
|
基础数据属业务操作,**登录即可访问**(不要求管理员,对照系统管理模块)。
|
||||||
|
校验规则(岗位/经理合法性、删除保护、子表全量替换的防御写法)见
|
||||||
|
`personnel_service` 模块 docstring —— 前端提示之外,后端校验是唯一防线。
|
||||||
|
|
||||||
|
写操作经 `RequestMetaDep` 带入请求元信息由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.personnel import (
|
||||||
|
PersonnelCreateIn,
|
||||||
|
PersonnelDetailOut,
|
||||||
|
PersonnelOut,
|
||||||
|
PersonnelQuery,
|
||||||
|
PersonnelStatusIn,
|
||||||
|
PlanReplaceIn,
|
||||||
|
RegionReplaceIn,
|
||||||
|
)
|
||||||
|
from app.services import personnel_service
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/personnel", tags=["人员管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[PersonnelOut]], summary="人员列表")
|
||||||
|
def list_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[PersonnelQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[PersonnelOut]]:
|
||||||
|
"""分页查询人员(含离职)。姓名模糊匹配,可按状态与岗位筛选。"""
|
||||||
|
return ok(personnel_service.list_personnel(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员")
|
||||||
|
def create_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: PersonnelCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[PersonnelOut]:
|
||||||
|
"""新增人员,默认在职。岗位须存在且启用;直属经理不能是自己且须在职。"""
|
||||||
|
return ok(
|
||||||
|
personnel_service.create_personnel(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="人员已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{personnel_id}", response_model=ApiResponse[PersonnelDetailOut], summary="人员详情")
|
||||||
|
def get_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[PersonnelDetailOut]:
|
||||||
|
"""人员详情,附辐射区域与拓展计划,供编辑抽屉一次回显。"""
|
||||||
|
return ok(personnel_service.get_detail(session, personnel_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员")
|
||||||
|
def update_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: PersonnelCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[PersonnelOut]:
|
||||||
|
"""编辑人员(全量提交,不含状态)。校验规则同新增。"""
|
||||||
|
return ok(
|
||||||
|
personnel_service.update_personnel(
|
||||||
|
session, personnel_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员")
|
||||||
|
def delete_personnel(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除人员。被团队成员或客户归属引用时禁删,应改为离职。"""
|
||||||
|
personnel_service.delete_personnel(session, personnel_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="人员已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{personnel_id}/status",
|
||||||
|
response_model=ApiResponse[PersonnelOut],
|
||||||
|
summary="设为在职/离职",
|
||||||
|
)
|
||||||
|
def set_status(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: PersonnelStatusIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[PersonnelOut]:
|
||||||
|
"""在职/离职切换。离职人员不可再被业务与客户归属引用。"""
|
||||||
|
result = personnel_service.set_status(
|
||||||
|
session, personnel_id, payload.status, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(result, message="已设为在职" if payload.status == 1 else "已设为离职")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域")
|
||||||
|
def replace_regions(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: RegionReplaceIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""全量替换辐射区域,空列表即清空。"""
|
||||||
|
personnel_service.replace_regions(
|
||||||
|
session, personnel_id, payload, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(message="已保存")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划")
|
||||||
|
def replace_plans(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
personnel_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: PlanReplaceIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""全量替换拓展计划,空列表即清空。"""
|
||||||
|
personnel_service.replace_plans(
|
||||||
|
session, personnel_id, payload, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(message="已保存")
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""产品管理接口(需求 2.1.2:产品主数据维护)。
|
||||||
|
|
||||||
|
产品是折算阈值的行级锚点(product_category 决定 30%/20% 折算阈值),
|
||||||
|
类别值域由后端校验 —— 前端下拉只是展示约束,后端校验是唯一防线。
|
||||||
|
|
||||||
|
**数据提取(POST /products/extract)暂不提供**:它依赖业务明细表
|
||||||
|
(V5 迁移),待业务管理任务完成后补充。
|
||||||
|
|
||||||
|
基础数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
|
||||||
|
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.product import (
|
||||||
|
ProductCreateIn,
|
||||||
|
ProductOut,
|
||||||
|
ProductQuery,
|
||||||
|
ProductStatusIn,
|
||||||
|
ProductUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import product_service
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/products", tags=["产品管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[ProductOut]], summary="产品列表")
|
||||||
|
def list_products(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[ProductQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[ProductOut]]:
|
||||||
|
"""分页查询产品(含停用)。keyword 对名称或规格模糊匹配。"""
|
||||||
|
return ok(product_service.list_products(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品")
|
||||||
|
def create_product(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: ProductCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ProductOut]:
|
||||||
|
"""新增产品。类别须在合法值域内;新产品默认启用。"""
|
||||||
|
return ok(
|
||||||
|
product_service.create_product(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="产品已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{product_id}", response_model=ApiResponse[ProductOut], summary="产品详情")
|
||||||
|
def get_product(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
product_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ProductOut]:
|
||||||
|
"""按主键查询产品详情。"""
|
||||||
|
return ok(product_service.get_product(session, product_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品")
|
||||||
|
def update_product(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
product_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: ProductUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ProductOut]:
|
||||||
|
"""编辑产品基本信息(全量替换,不含状态;启用/停用走独立端点)。"""
|
||||||
|
return ok(
|
||||||
|
product_service.update_product(
|
||||||
|
session, product_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品")
|
||||||
|
def delete_product(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
product_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""物理删除产品(仅限误录数据,常规下架用停用)。
|
||||||
|
|
||||||
|
被业务明细/发货明细引用时返回 400(引导改走停用)。
|
||||||
|
"""
|
||||||
|
product_service.delete_product(session, product_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="产品已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品")
|
||||||
|
def set_status(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
product_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: ProductStatusIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ProductOut]:
|
||||||
|
"""启用或停用产品(1启用 0停用)。"""
|
||||||
|
result = product_service.set_status(
|
||||||
|
session, product_id, payload.status, operator=current_user, meta=meta
|
||||||
|
)
|
||||||
|
return ok(result, message="已启用" if payload.status == 1 else "已停用")
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""业务数据管理接口(需求 2.2)。
|
||||||
|
|
||||||
|
挂载于 `/api/v1/business/*`,含业务单据维护、客户评级等级配置等业务数据入口。
|
||||||
|
|
||||||
|
与 `api/v1/system` 的权限分工:业务数据是**业务操作**,登录即可访问
|
||||||
|
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
|
||||||
|
由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""业务管理接口(需求 2.2.1/2.2.4)。
|
||||||
|
|
||||||
|
校验规则(编号生成、状态机、归属留痕、汇总重算)见 `business_service`
|
||||||
|
模块 docstring;基础数据属业务操作,登录即可访问。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageQuery, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.business import (
|
||||||
|
BusinessCreateIn,
|
||||||
|
BusinessDetailOut,
|
||||||
|
BusinessOut,
|
||||||
|
BusinessOwnershipIn,
|
||||||
|
BusinessQuery,
|
||||||
|
BusinessStatusIn,
|
||||||
|
BusinessStatusLogOut,
|
||||||
|
BusinessUpdateIn,
|
||||||
|
GrossProfitOut,
|
||||||
|
OwnershipChangeLogOut,
|
||||||
|
)
|
||||||
|
from app.services import business_service
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/businesses", tags=["业务管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[BusinessOut]], summary="业务列表")
|
||||||
|
def list_businesses(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[BusinessQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[BusinessOut]]:
|
||||||
|
"""分页查询业务。编号/公司名称模糊,可按状态/客户/归属人员/日期范围筛选。"""
|
||||||
|
return ok(business_service.list_businesses(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务")
|
||||||
|
def create_business(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: BusinessCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""创建业务 + 明细行(≥1)。业务编号自动生成;创建写首条状态留痕。"""
|
||||||
|
return ok(
|
||||||
|
business_service.create_business(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="业务已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{business_id}", response_model=ApiResponse[BusinessDetailOut], summary="业务详情")
|
||||||
|
def get_business(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[BusinessDetailOut]:
|
||||||
|
"""业务详情,附产品明细行,供编辑抽屉一次回显。"""
|
||||||
|
return ok(business_service.get_detail(session, business_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务")
|
||||||
|
def update_business(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: BusinessUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""编辑业务(明细全量替换并重算汇总)。归属不在此端点调整,走 /ownership。"""
|
||||||
|
return ok(
|
||||||
|
business_service.update_business(
|
||||||
|
session, business_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务")
|
||||||
|
def delete_business(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除业务。有发货或回款记录时禁删,应改为已终止。"""
|
||||||
|
business_service.delete_business(session, business_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="业务已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转")
|
||||||
|
def change_status(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: BusinessStatusIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""状态流转:执行中→已完成/已终止;终态不可再变更;终止必填原因。"""
|
||||||
|
return ok(
|
||||||
|
business_service.change_status(
|
||||||
|
session, business_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="状态已变更",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属")
|
||||||
|
def change_ownership(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: BusinessOwnershipIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""调整负责/协助营销人员,同事务写归属变更留痕(不回溯历史)。"""
|
||||||
|
return ok(
|
||||||
|
business_service.change_ownership(
|
||||||
|
session, business_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="归属已调整",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{business_id}/ownership-logs",
|
||||||
|
response_model=ApiResponse[PageResult[OwnershipChangeLogOut]],
|
||||||
|
summary="归属变更记录",
|
||||||
|
)
|
||||||
|
def list_ownership_logs(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
query: Annotated[PageQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
|
||||||
|
"""某业务的归属变更留痕(分页,按时间倒序)。"""
|
||||||
|
return ok(business_service.list_ownership_logs(session, business_id, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{business_id}/status-logs",
|
||||||
|
response_model=ApiResponse[PageResult[BusinessStatusLogOut]],
|
||||||
|
summary="状态流转记录",
|
||||||
|
)
|
||||||
|
def list_status_logs(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
query: Annotated[PageQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[BusinessStatusLogOut]]:
|
||||||
|
"""某业务的状态流转时间线(分页,按时间正序,从创建起展示)。"""
|
||||||
|
return ok(business_service.list_status_logs(session, business_id, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{business_id}/gross-profit",
|
||||||
|
response_model=ApiResponse[GrossProfitOut],
|
||||||
|
summary="毛利润数据视图",
|
||||||
|
)
|
||||||
|
def gross_profit(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
as_of: Annotated[date | None, Query()] = None,
|
||||||
|
) -> ApiResponse[GrossProfitOut]:
|
||||||
|
"""按产品逐行实时计算毛利润与折算额(不含运费口径,对照表 #2)。
|
||||||
|
|
||||||
|
阈值按 asOf(默认今日)取参数快照;M3 核算实际按回款到账日取参。
|
||||||
|
"""
|
||||||
|
return ok(business_service.gross_profit(session, business_id, as_of))
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""客户评级等级配置接口(需求 2.2.5)。
|
||||||
|
|
||||||
|
评级计算器的等级映射判定表:判定按 sort_order 升序取首个 min_score ≤ 总分
|
||||||
|
的等级,max_score 仅展示。区间合法(0≤min≤max≤100)与互不重叠由后端校验
|
||||||
|
—— 前端表单只是展示约束,后端校验是唯一防线。
|
||||||
|
|
||||||
|
列表不分页全量返回:等级映射是判定表,数量个位数,分页无意义。
|
||||||
|
业务数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
|
||||||
|
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
|
||||||
|
from app.services import rating_service
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/rating-grades", tags=["客户评级"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[list[RatingGradeOut]], summary="评级等级列表")
|
||||||
|
def list_grades(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
) -> ApiResponse[list[RatingGradeOut]]:
|
||||||
|
"""全量等级列表,按判定顺序(sort_order 升序)返回,不分页。"""
|
||||||
|
return ok(rating_service.list_grades(session))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级")
|
||||||
|
def create_grade(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: RatingGradeCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[RatingGradeOut]:
|
||||||
|
"""新增评级等级。区间须 0≤min≤max≤100 且与既有等级不重叠。"""
|
||||||
|
return ok(
|
||||||
|
rating_service.create_grade(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="评级等级已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级")
|
||||||
|
def update_grade(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
grade_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: RatingGradeUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[RatingGradeOut]:
|
||||||
|
"""编辑评级等级(全量替换)。区间须合法且与其他等级不重叠。"""
|
||||||
|
return ok(
|
||||||
|
rating_service.update_grade(session, grade_id, payload, operator=current_user, meta=meta),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级")
|
||||||
|
def delete_grade(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
grade_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""物理删除评级等级(等级表无下游引用,允许清空,理由见 service 说明)。"""
|
||||||
|
rating_service.delete_grade(session, grade_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="评级等级已删除")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""回款管理接口(需求 2.2.3)。
|
||||||
|
|
||||||
|
回款按业务登记到账款项,批次号自动生成(HK 前缀)。校验规则(发货
|
||||||
|
批次同业务强校验、已核算禁改禁删、汇总重算)见 `receipt_service` 模块
|
||||||
|
docstring;基础数据属业务操作,登录即可访问。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.receipt import (
|
||||||
|
ReceiptCreateIn,
|
||||||
|
ReceiptOut,
|
||||||
|
ReceiptQuery,
|
||||||
|
ReceiptUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import receipt_service
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/receipts", tags=["回款管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[ReceiptOut]], summary="回款列表")
|
||||||
|
def list_receipts(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[ReceiptQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[ReceiptOut]]:
|
||||||
|
"""分页查询回款。附业务号/发货单号与回款天数派生值,可按业务/日期/抵顶/核算状态筛选。"""
|
||||||
|
return ok(receipt_service.list_receipts(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款")
|
||||||
|
def create_receipt(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: ReceiptCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ReceiptOut]:
|
||||||
|
"""新增回款。批次号自动生成;发货批次须与本回款同属一个业务;业务须执行中。"""
|
||||||
|
return ok(
|
||||||
|
receipt_service.create_receipt(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="回款已登记",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="回款详情")
|
||||||
|
def get_receipt(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
receipt_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ReceiptOut]:
|
||||||
|
"""回款详情(含回款天数派生值,未指定发货批次时为空)。"""
|
||||||
|
return ok(receipt_service.get_detail(session, receipt_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款")
|
||||||
|
def update_receipt(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
receipt_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: ReceiptUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ReceiptOut]:
|
||||||
|
"""编辑回款(全量替换并重算合计)。已核算回款禁止修改;发货批次重新校验同业务。"""
|
||||||
|
return ok(
|
||||||
|
receipt_service.update_receipt(
|
||||||
|
session, receipt_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款")
|
||||||
|
def delete_receipt(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
receipt_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除回款。已核算回款禁止删除;业务已完成或已终止时禁删。"""
|
||||||
|
receipt_service.delete_receipt(session, receipt_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="回款已删除")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""发货管理接口(需求 2.2.2)。
|
||||||
|
|
||||||
|
校验规则(状态门槛、行金额固化、双重汇总回写、删除保护)见
|
||||||
|
`shipment_service` 模块 docstring;业务数据属业务操作,登录即可访问。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.shipment import (
|
||||||
|
ShipmentCreateIn,
|
||||||
|
ShipmentDetailOut,
|
||||||
|
ShipmentOut,
|
||||||
|
ShipmentQuery,
|
||||||
|
ShipmentUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import shipment_service
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/shipments", tags=["发货管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[ShipmentOut]], summary="发货单列表")
|
||||||
|
def list_shipments(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[ShipmentQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[ShipmentOut]]:
|
||||||
|
"""分页查询发货单(附业务编号),可按业务/发货日期范围筛选。"""
|
||||||
|
return ok(shipment_service.list_shipments(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单")
|
||||||
|
def create_shipment(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: ShipmentCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ShipmentOut]:
|
||||||
|
"""创建发货单(明细 ≥1)。业务须处于执行中;锁业务行后生成 SH 编号。"""
|
||||||
|
return ok(
|
||||||
|
shipment_service.create_shipment(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="发货单已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{shipment_id}", response_model=ApiResponse[ShipmentDetailOut], summary="发货单详情")
|
||||||
|
def get_shipment(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
shipment_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ShipmentDetailOut]:
|
||||||
|
"""发货单详情,附产品明细行,供编辑抽屉一次回显。"""
|
||||||
|
return ok(shipment_service.get_detail(session, shipment_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单")
|
||||||
|
def update_shipment(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
shipment_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: ShipmentUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[ShipmentOut]:
|
||||||
|
"""编辑发货单(明细全量替换并重算汇总)。终态业务的发货单禁改。"""
|
||||||
|
return ok(
|
||||||
|
shipment_service.update_shipment(
|
||||||
|
session, shipment_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单")
|
||||||
|
def delete_shipment(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
shipment_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除发货单(连带明细行并回写业务汇总)。被回款批次指定时禁删。"""
|
||||||
|
shipment_service.delete_shipment(session, shipment_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="发货单已删除")
|
||||||
@@ -0,0 +1,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="岗位已删除")
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。
|
||||||
|
|
||||||
|
全部要求管理员权限(开放问题 #5 的当前默认,后续可放宽只读);
|
||||||
|
写操作(提交/撤回)经 `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, 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,
|
||||||
|
_: AdminUser,
|
||||||
|
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,
|
||||||
|
_: AdminUser,
|
||||||
|
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,
|
||||||
|
_: AdminUser,
|
||||||
|
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,
|
||||||
|
_: AdminUser,
|
||||||
|
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,
|
||||||
|
_: AdminUser,
|
||||||
|
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,
|
||||||
|
_: AdminUser,
|
||||||
|
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,
|
||||||
|
_: AdminUser,
|
||||||
|
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))
|
||||||
@@ -37,10 +37,46 @@ PASSWORD_MAX_BYTES = 72
|
|||||||
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||||
CAPTCHA_LENGTH = 4
|
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 取值)=====
|
# ===== 操作日志词表(operation_logs 的 module / action 取值)=====
|
||||||
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
|
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
|
||||||
MODULE_USER = "用户管理"
|
MODULE_USER = "用户管理"
|
||||||
MODULE_ROLE = "角色管理"
|
MODULE_ROLE = "角色管理"
|
||||||
|
MODULE_BUSINESS_ROLE = "岗位角色"
|
||||||
|
MODULE_PARAM = "计提参数"
|
||||||
|
MODULE_PERSONNEL = "人员管理"
|
||||||
|
MODULE_PRODUCT = "产品管理"
|
||||||
|
MODULE_CUSTOMER = "客户管理"
|
||||||
|
MODULE_BUSINESS = "业务管理"
|
||||||
|
MODULE_SHIPMENT = "发货管理"
|
||||||
|
MODULE_RECEIPT = "回款管理"
|
||||||
|
MODULE_RATING = "客户评级"
|
||||||
|
|
||||||
|
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
|
||||||
|
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
|
||||||
|
NO_PREFIX_BUSINESS = "YW"
|
||||||
|
NO_PREFIX_SHIPMENT = "SH"
|
||||||
|
NO_PREFIX_RECEIPT = "HK"
|
||||||
|
NO_PREFIX_PARAM = "P"
|
||||||
|
|
||||||
ACTION_CREATE = "新增"
|
ACTION_CREATE = "新增"
|
||||||
ACTION_UPDATE = "修改"
|
ACTION_UPDATE = "修改"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""数据库完整性错误的统一识别与翻译口径。
|
||||||
|
|
||||||
|
判断依据:pymysql 原生异常 ``orig.args`` 首位是 MySQL 错误码 —— 只有
|
||||||
|
1062(ER_DUP_ENTRY,唯一键冲突)是「已存在」语义,可安全转 400 业务
|
||||||
|
提示;其余完整性错误(外键 RESTRICT、数据截断等)原样上抛,由全局
|
||||||
|
异常处理器收敛 500(程序缺陷或数据异常,均不应伪装成用户错误)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
# MySQL 唯一键冲突错误码(pymysql 原生异常 args 首位)
|
||||||
|
ERR_DUP_ENTRY = 1062
|
||||||
|
|
||||||
|
|
||||||
|
def is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||||
|
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(错误码 1062)。"""
|
||||||
|
args = getattr(exc.orig, "args", ())
|
||||||
|
return bool(args) and args[0] == ERR_DUP_ENTRY
|
||||||
@@ -12,6 +12,7 @@ import logging
|
|||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
|
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
|
||||||
@@ -60,6 +61,19 @@ async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSON
|
|||||||
return _build_response(exc.status_code, message)
|
return _build_response(exc.status_code, message)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONResponse:
|
||||||
|
"""完整性错误全局兜底为 400。
|
||||||
|
|
||||||
|
service 层已判别的 1062/引用冲突会自行翻译为业务提示,不会走到这里;
|
||||||
|
本 handler 拦截的是「新增父表后某 service 漏补引用保护」一类的遗漏
|
||||||
|
(e2e 撞见一个补一个的模式无法保证全覆盖):FK RESTRICT 等数据完整性
|
||||||
|
冲突对用户表现为「这行数据被别处引用/冲突」,转 400 比 500 更贴近事实。
|
||||||
|
完整异常记录日志供排查;不在此处 rollback —— 会话由请求级依赖统一关闭。
|
||||||
|
"""
|
||||||
|
logger.error("数据库完整性冲突(未在 service 层翻译)", exc_info=exc)
|
||||||
|
return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝")
|
||||||
|
|
||||||
|
|
||||||
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
|
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
|
||||||
"""兜底处理未预期异常。
|
"""兜底处理未预期异常。
|
||||||
|
|
||||||
@@ -77,4 +91,5 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
|
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
|
||||||
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
|
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
|
||||||
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
|
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
|
||||||
|
app.add_exception_handler(IntegrityError, handle_integrity_error) # type: ignore[arg-type]
|
||||||
app.add_exception_handler(Exception, handle_unexpected_exception)
|
app.add_exception_handler(Exception, handle_unexpected_exception)
|
||||||
@@ -29,8 +29,8 @@ _SENSITIVE_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
|
|||||||
),
|
),
|
||||||
# Authorization: Bearer xxx
|
# Authorization: Bearer xxx
|
||||||
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"),
|
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"),
|
||||||
# 手机号:138****8888
|
# 手机号:138****8888(兼容带空格/横线分隔的写法,如 138 0013 8000)
|
||||||
(re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"),
|
(re.compile(r"\b(1[3-9]\d)[-\s]?(\d{4})[-\s]?(\d{4})\b"), r"\1****\3"),
|
||||||
# 18 位身份证:保留前 6 位与后 4 位
|
# 18 位身份证:保留前 6 位与后 4 位
|
||||||
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
|
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,18 +10,44 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
|
from app.models.business import Business, BusinessItem, BusinessStatusLog
|
||||||
|
from app.models.business_role import BusinessRole
|
||||||
|
from app.models.customer import Customer, OwnershipChangeLog
|
||||||
from app.models.login_log import LoginLog
|
from app.models.login_log import LoginLog
|
||||||
from app.models.operation_log import OperationLog
|
from app.models.operation_log import OperationLog
|
||||||
|
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
|
||||||
|
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
|
||||||
|
from app.models.product import Product
|
||||||
|
from app.models.rating import CustomerRatingGrade
|
||||||
|
from app.models.receipt import Receipt
|
||||||
from app.models.role import Permission, Role, RolePermission
|
from app.models.role import Permission, Role, RolePermission
|
||||||
|
from app.models.shipment import Shipment, ShipmentItem
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
|
"Business",
|
||||||
|
"BusinessItem",
|
||||||
|
"BusinessRole",
|
||||||
|
"BusinessStatusLog",
|
||||||
|
"Customer",
|
||||||
|
"CustomerRatingGrade",
|
||||||
"LoginLog",
|
"LoginLog",
|
||||||
"OperationLog",
|
"OperationLog",
|
||||||
|
"OwnershipChangeLog",
|
||||||
|
"ParamChangeBatch",
|
||||||
|
"ParamDefinition",
|
||||||
|
"ParamValue",
|
||||||
"Permission",
|
"Permission",
|
||||||
|
"Personnel",
|
||||||
|
"PersonnelPlan",
|
||||||
|
"PersonnelRegion",
|
||||||
|
"Product",
|
||||||
|
"Receipt",
|
||||||
"Role",
|
"Role",
|
||||||
"RolePermission",
|
"RolePermission",
|
||||||
|
"Shipment",
|
||||||
|
"ShipmentItem",
|
||||||
"TimestampMixin",
|
"TimestampMixin",
|
||||||
"User",
|
"User",
|
||||||
"UserRole",
|
"UserRole",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, MetaData, text
|
from sqlalchemy import DateTime, MetaData, String, text
|
||||||
from sqlalchemy.dialects.mysql import BIGINT, TINYINT
|
from sqlalchemy.dialects.mysql import BIGINT, TINYINT
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
@@ -28,7 +28,14 @@ BIGINT_UNSIGNED = BIGINT(unsigned=True)
|
|||||||
# 状态类字段统一用 TINYINT(0/1 或少量枚举值)
|
# 状态类字段统一用 TINYINT(0/1 或少量枚举值)
|
||||||
TINYINT_TYPE = TINYINT
|
TINYINT_TYPE = TINYINT
|
||||||
|
|
||||||
__all__ = ["BIGINT_UNSIGNED", "TINYINT_TYPE", "Base", "TimestampMixin", "mysql_table_args"]
|
__all__ = [
|
||||||
|
"BIGINT_UNSIGNED",
|
||||||
|
"TINYINT_TYPE",
|
||||||
|
"Base",
|
||||||
|
"TPlusSyncMixin",
|
||||||
|
"TimestampMixin",
|
||||||
|
"mysql_table_args",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
@@ -62,6 +69,30 @@ class TimestampMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TPlusSyncMixin:
|
||||||
|
"""T+(用友)数据同步预留列,六张业务主表共用。
|
||||||
|
|
||||||
|
对照表 #26:T+ 对接的字段映射与幂等键语义未定(IT 牵头),故四列全部
|
||||||
|
可空、不设唯一键 —— 键语义未定就锁死唯一约束,对接时大概率返工;
|
||||||
|
source_no 先建普通索引承担查询与去重,IT 定键后以小迁移升级唯一约束。
|
||||||
|
|
||||||
|
同步守则(M4 同步模块必须遵守):同步只写 T+ 侧字段与本组列,
|
||||||
|
**不覆盖人工维护字段**(岗位/工资标准/归属/客户分类/业务状态等,
|
||||||
|
各表注释已标注)。同步频率:一周一次定时 + 手动触发(对照表 #10)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
source_no: Mapped[str | None] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
index=True,
|
||||||
|
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
|
||||||
|
)
|
||||||
|
t_plus_id: Mapped[str | None] = mapped_column(String(64), comment="T+ 系统内部标识")
|
||||||
|
sync_batch_no: Mapped[str | None] = mapped_column(
|
||||||
|
String(32), comment="同步批次号(同步模块写入)"
|
||||||
|
)
|
||||||
|
synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间")
|
||||||
|
|
||||||
|
|
||||||
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
|
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
|
||||||
"""构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。
|
"""构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""业务数据模型:业务单、产品明细与状态流转留痕。
|
||||||
|
|
||||||
|
- 汇总冗余列(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, 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 Business(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
"""业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。"""
|
||||||
|
|
||||||
|
__tablename__ = "businesses"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"业务表(业务编号自动生成;T+同步不覆盖业务状态)",
|
||||||
|
Index("idx_businesses_customer_id", "customer_id"),
|
||||||
|
Index("idx_businesses_owner_personnel_id", "owner_personnel_id"),
|
||||||
|
Index("idx_businesses_status", "status"),
|
||||||
|
Index("idx_businesses_business_date", "business_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
business_no: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)",
|
||||||
|
)
|
||||||
|
customer_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("customers.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="客户ID(customers.id)",
|
||||||
|
)
|
||||||
|
business_date: Mapped[date | None] = mapped_column(Date, comment="业务日期(T+ 同步口径)")
|
||||||
|
status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)",
|
||||||
|
)
|
||||||
|
owner_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)",
|
||||||
|
)
|
||||||
|
assistant_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)",
|
||||||
|
)
|
||||||
|
is_special: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="特殊业务标记:1是 0否(公司参与等)",
|
||||||
|
)
|
||||||
|
special_note: Mapped[str | None] = mapped_column(String(255), comment="特殊业务说明")
|
||||||
|
total_sale_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)",
|
||||||
|
)
|
||||||
|
total_cost_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
)
|
||||||
|
total_ship_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
)
|
||||||
|
total_received_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)",
|
||||||
|
)
|
||||||
|
total_freight_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
server_default="0",
|
||||||
|
comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)",
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessItem(Base, TimestampMixin):
|
||||||
|
"""业务产品明细表(需求 2.2.1/2.2.4):毛利润的行级源数据。
|
||||||
|
|
||||||
|
product_id 可空=自由录入产品(配合「数据提取」一键入产品库);
|
||||||
|
产品名称/规格冗余固化,防产品改名影响历史业务。
|
||||||
|
sale_amount 由服务层按 数量×单价 计算固化,不信任前端传值。
|
||||||
|
cost_amount 来源待 T+ 对接确认(可空,人工可录先行)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "business_items"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)",
|
||||||
|
Index("idx_business_items_business_id", "business_id"),
|
||||||
|
Index("idx_business_items_product_id", "product_id"),
|
||||||
|
Index("idx_business_items_product_category", "product_category"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("businesses.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id)",
|
||||||
|
)
|
||||||
|
product_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("products.id", ondelete="RESTRICT"),
|
||||||
|
comment="产品ID(products.id;NULL=自由录入,未入产品库)",
|
||||||
|
)
|
||||||
|
product_name: Mapped[str] = mapped_column(
|
||||||
|
String(128), nullable=False, comment="产品名称(引用产品时冗余固化)"
|
||||||
|
)
|
||||||
|
spec_model: Mapped[str] = mapped_column(
|
||||||
|
String(128),
|
||||||
|
nullable=False,
|
||||||
|
server_default="''",
|
||||||
|
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
|
||||||
|
)
|
||||||
|
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
|
||||||
|
product_category: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
|
||||||
|
)
|
||||||
|
quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)")
|
||||||
|
sale_price: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 4), comment="销售单价(元;T+ 同步或人工)"
|
||||||
|
)
|
||||||
|
sale_amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
nullable=False,
|
||||||
|
comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)",
|
||||||
|
)
|
||||||
|
cost_amount: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)",
|
||||||
|
)
|
||||||
|
guided_price: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 4), comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)"
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessStatusLog(Base):
|
||||||
|
"""业务状态变更记录表(append-only,需求 2.2.1「支持状态变更记录」)。
|
||||||
|
|
||||||
|
不继承 TimestampMixin(留痕无更新语义);business_id 为无外键的固化 ID
|
||||||
|
(businesses 建表在本表之后的迁移批次内 —— 实为同批,但留痕统一口径:
|
||||||
|
被引用方删除后记录仍完整可读,删除保护由服务层检查承担)。
|
||||||
|
首条记录在业务创建时写入(old=new=1 执行中),详情页时间线从创建起展示。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "business_status_logs"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"业务状态变更记录表(append-only)",
|
||||||
|
Index("idx_business_status_logs_business_id", "business_id"),
|
||||||
|
Index("idx_business_status_logs_create_time", "create_time"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
comment="业务ID(固化,无外键;删除保护由服务层承担)",
|
||||||
|
)
|
||||||
|
old_status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="变更前状态(创建首条为 1)"
|
||||||
|
)
|
||||||
|
new_status: Mapped[int] = mapped_column(TINYINT_TYPE, nullable=False, comment="变更后状态")
|
||||||
|
change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因(终止时必填)")
|
||||||
|
operator_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
)
|
||||||
|
operator_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="操作人用户名(冗余固化)"
|
||||||
|
)
|
||||||
|
create_time: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||||
|
)
|
||||||
@@ -0,0 +1,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自定义",
|
||||||
|
)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""客户模型:客户档案与归属变更留痕。
|
||||||
|
|
||||||
|
- 「公司名称 + 负责人」是业务唯一标识(需规 2.1.3);负责人是客户公司的
|
||||||
|
负责人**人名**(字符串),不是系统内的营销人员 —— 系统内归属另用
|
||||||
|
owner_personnel_id 外键表达,两者语义不同
|
||||||
|
- 归属变更只改主表当前值 + 插一条留痕,**不回溯历史**(对照表 #19):
|
||||||
|
历史核算结果在 M3 台账已固化,无需 effective_date 时点化设计
|
||||||
|
- 留痕表(OwnershipChangeLog)对客户/业务/人员 ID 均为**无外键的冗余固化**:
|
||||||
|
留痕是历史记录,被引用方被删除后记录仍须完整可读;RESTRICT 外键反而会
|
||||||
|
把「删除客户」逼成 500。业务实体删除保护由服务层检查承担
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, String, UniqueConstraint, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import (
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
TINYINT_TYPE,
|
||||||
|
Base,
|
||||||
|
TimestampMixin,
|
||||||
|
TPlusSyncMixin,
|
||||||
|
mysql_table_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 归属类型(ownership_change_logs.owner_type)
|
||||||
|
OWNER_TYPE_CUSTOMER = 1
|
||||||
|
OWNER_TYPE_BUSINESS = 2
|
||||||
|
|
||||||
|
|
||||||
|
class Customer(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
"""客户表(需求 2.1.3)。customer_type 是计提规则匹配锚点(M3 消费)。"""
|
||||||
|
|
||||||
|
__tablename__ = "customers"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)",
|
||||||
|
UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
company_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="公司名称")
|
||||||
|
owner_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="客户负责人姓名(客户公司负责人的人名,非系统人员)"
|
||||||
|
)
|
||||||
|
contact_person: Mapped[str | None] = mapped_column(String(64), comment="联系人")
|
||||||
|
contact_phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
|
||||||
|
address: Mapped[str | None] = mapped_column(String(255), comment="地址")
|
||||||
|
industry: Mapped[str | None] = mapped_column(String(64), comment="所属行业(自由文本)")
|
||||||
|
customer_type: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
server_default="personal",
|
||||||
|
index=True,
|
||||||
|
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
|
||||||
|
)
|
||||||
|
owner_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
index=True,
|
||||||
|
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipChangeLog(Base):
|
||||||
|
"""归属变更记录表(append-only,需求 2.1.3 + 对照表 #19)。
|
||||||
|
|
||||||
|
owner_type 区分客户归属/业务归属:1 → customer_id 非空,2 → business_id
|
||||||
|
非空,由应用层校验(无外键,见模块 docstring 的固化论证)。
|
||||||
|
前后值冗余固化(无外键):人员被删除后留痕仍可读。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "ownership_change_logs"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)",
|
||||||
|
Index("idx_ownership_change_logs_customer_id", "customer_id"),
|
||||||
|
Index("idx_ownership_change_logs_business_id", "business_id"),
|
||||||
|
Index("idx_ownership_change_logs_create_time", "create_time"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
owner_type: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, comment="归属类型:1客户归属 2业务归属"
|
||||||
|
)
|
||||||
|
customer_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
comment="客户ID(固化,owner_type=1 时非空)",
|
||||||
|
)
|
||||||
|
business_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
comment="业务ID(固化,owner_type=2 时非空)",
|
||||||
|
)
|
||||||
|
old_owner_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, comment="变更前负责营销人员ID(固化)"
|
||||||
|
)
|
||||||
|
new_owner_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, comment="变更后负责营销人员ID(固化)"
|
||||||
|
)
|
||||||
|
old_assistant_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, comment="变更前协助营销人员ID(固化,仅业务归属使用)"
|
||||||
|
)
|
||||||
|
new_assistant_personnel_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, comment="变更后协助营销人员ID(固化,仅业务归属使用)"
|
||||||
|
)
|
||||||
|
change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因")
|
||||||
|
operator_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
comment="操作人ID(账号删除后置空)",
|
||||||
|
)
|
||||||
|
operator_name: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, comment="操作人用户名(冗余固化)"
|
||||||
|
)
|
||||||
|
create_time: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||||
|
)
|
||||||
@@ -0,0 +1,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="创建时间"
|
||||||
|
)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""营销人员模型:人员档案、产品辐射区域与市场拓展计划。
|
||||||
|
|
||||||
|
- 岗位(business_role_id)决定计提公式模板,与工资标准同属「同步不覆盖」的
|
||||||
|
人工维护字段(对照表 #26 落地口径,表注释标注供 M4 同步模块遵守)
|
||||||
|
- manager_id 自引用表达「经理带团队」:NULL=经理本人或未分配;M3 经理
|
||||||
|
12.5% 聚合、M4 部门汇总均按该关系展开,无需独立团队表
|
||||||
|
- 一期营销人员不登录系统,不与 users 关联;若后续开放自助查工资再加列
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Date, ForeignKey, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import (
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
TINYINT_TYPE,
|
||||||
|
Base,
|
||||||
|
TimestampMixin,
|
||||||
|
TPlusSyncMixin,
|
||||||
|
mysql_table_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
# personnel.status 的在职值(对照 BUSINESS_STATUS_* 集中在 models/business.py
|
||||||
|
# 的先例:状态语义单点导出,业务/客户两侧的归属校验共用,防魔数各自漂移)
|
||||||
|
PERSONNEL_STATUS_ACTIVE = 1
|
||||||
|
|
||||||
|
|
||||||
|
class Personnel(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
"""营销人员表(需求 2.1.1)。
|
||||||
|
|
||||||
|
表名 personnel 为集合名词(单复数同形),本身即复数语义。
|
||||||
|
试用期人员绩效照算不发放(对照表 #17),is_probation 供 M3 区分。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "personnel"
|
||||||
|
__table_args__ = mysql_table_args("营销人员表(T+同步不覆盖岗位与工资标准)")
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
personnel_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="姓名")
|
||||||
|
business_role_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("business_roles.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)",
|
||||||
|
)
|
||||||
|
manager_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
index=True,
|
||||||
|
comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)",
|
||||||
|
)
|
||||||
|
phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
|
||||||
|
hire_date: Mapped[date | None] = mapped_column(Date, comment="入职日期")
|
||||||
|
is_probation: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="是否试用期:1是 0否(试用期绩效照算不发放)",
|
||||||
|
)
|
||||||
|
salary_standard: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(12, 2), comment="月工资标准(元;T+同步不覆盖)"
|
||||||
|
)
|
||||||
|
status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)",
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelRegion(Base, TimestampMixin):
|
||||||
|
"""产品辐射区域表(需求 2.1.1):人员负责的产品辐射地理范围,1:N 子表。
|
||||||
|
|
||||||
|
随人员编辑整表替换维护,无独立管理诉求,故不加 T+ 预留列。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "personnel_regions"
|
||||||
|
__table_args__ = mysql_table_args("产品辐射区域表")
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
personnel_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="人员ID(personnel.id)",
|
||||||
|
)
|
||||||
|
region_name: Mapped[str] = mapped_column(
|
||||||
|
String(128), nullable=False, comment="区域名称(产品辐射地理范围)"
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelPlan(Base, TimestampMixin):
|
||||||
|
"""市场拓展计划表(需求 2.1.1):计划期间 + 计划内容 + 指标说明的自由条目。
|
||||||
|
|
||||||
|
结构化指标粒度待 T+ 字段映射确认,先以自由条目容器落地;条目最终由
|
||||||
|
T+ 同步确定,故预留 source_no 单列(无索引:查询按 personnel_id 走)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "personnel_plans"
|
||||||
|
__table_args__ = mysql_table_args("市场拓展计划表")
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
personnel_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="人员ID(personnel.id)",
|
||||||
|
)
|
||||||
|
plan_period: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, comment="计划期间(如 2026Q1、2026-05)"
|
||||||
|
)
|
||||||
|
plan_content: Mapped[str] = mapped_column(String(500), nullable=False, comment="计划内容")
|
||||||
|
target_desc: Mapped[str | None] = mapped_column(String(255), comment="指标说明(文字描述)")
|
||||||
|
source_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 原始条目号(预留)")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""产品模型:产品主数据。
|
||||||
|
|
||||||
|
产品类别(product_category)是**折算阈值的行级锚点**:混合业务按产品逐条
|
||||||
|
计算(对照表 #14),阈值本身由 M1 参数 `product.threshold` 按 PRODUCT_CATEGORY
|
||||||
|
维度承担(#28「全部阈值统一在系统设置维护」),本表不建阈值列 —— 两处维护
|
||||||
|
必然漂移。类别值域与 M1 参数维度共用 PRODUCT_CATEGORIES 常量。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Numeric, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import (
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
TINYINT_TYPE,
|
||||||
|
Base,
|
||||||
|
TimestampMixin,
|
||||||
|
TPlusSyncMixin,
|
||||||
|
mysql_table_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Product(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
"""产品表(需求 2.1.2)。停用(status=0)为主,不物理删除。"""
|
||||||
|
|
||||||
|
__tablename__ = "products"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)",
|
||||||
|
UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
product_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="产品名称")
|
||||||
|
spec_model: Mapped[str] = mapped_column(
|
||||||
|
String(128),
|
||||||
|
nullable=False,
|
||||||
|
server_default="''",
|
||||||
|
comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)",
|
||||||
|
)
|
||||||
|
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
|
||||||
|
stock_quantity: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 2), comment="数量(T+ 同步原样存储,语义待 IT 确认)"
|
||||||
|
)
|
||||||
|
sale_price: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 4), comment="销售单价(元,T+ 同步)"
|
||||||
|
)
|
||||||
|
product_category: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
|
||||||
|
)
|
||||||
|
status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
@@ -0,0 +1,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="说明")
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""回款模型:回款记录与批次追溯。
|
||||||
|
|
||||||
|
- shipment_id 为**手动指定**的发货批次(对照表 #18):可空=暂未指定;
|
||||||
|
必须与 business_id 同属一个业务(服务层强校验,无数据库级约束可表达
|
||||||
|
跨表同属关系);指定后回款天数方可由 formula.payment_days 派生
|
||||||
|
- settlement_status 是 **M3 核算防重锚点**(需规 2.3.3「每笔回款仅核算一次,
|
||||||
|
台账记录核销状态」):预置于本表避免 M3 反向迁移改 M2 表;M3 台账建立后
|
||||||
|
由核算服务写入,M2 仅维护 0(未核算)
|
||||||
|
- receipt_date 是 M3 参数版本匹配日(对照表 #11:按回款到账日取时点参数)
|
||||||
|
- 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import (
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
TINYINT_TYPE,
|
||||||
|
Base,
|
||||||
|
TimestampMixin,
|
||||||
|
TPlusSyncMixin,
|
||||||
|
mysql_table_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 绩效核算状态(receipts.settlement_status)
|
||||||
|
SETTLEMENT_PENDING = 0
|
||||||
|
SETTLEMENT_DONE = 1
|
||||||
|
|
||||||
|
|
||||||
|
class Receipt(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
"""回款表(需求 2.2.3):按业务登记到账款项,批次号系统内唯一。"""
|
||||||
|
|
||||||
|
__tablename__ = "receipts"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"回款表(手动指定发货批次;已核算回款禁改禁删)",
|
||||||
|
Index("idx_receipts_business_id", "business_id"),
|
||||||
|
Index("idx_receipts_receipt_date", "receipt_date"),
|
||||||
|
Index("idx_receipts_settlement_status", "settlement_status"),
|
||||||
|
Index("idx_receipts_shipment_id", "shipment_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
receipt_batch_no: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)",
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("businesses.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id)",
|
||||||
|
)
|
||||||
|
shipment_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("shipments.id", ondelete="RESTRICT"),
|
||||||
|
comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)",
|
||||||
|
)
|
||||||
|
receipt_date: Mapped[date] = mapped_column(
|
||||||
|
Date, nullable=False, comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)"
|
||||||
|
)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2), nullable=False, comment="回款金额(≥0;一期禁负数/红字)"
|
||||||
|
)
|
||||||
|
is_barter: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)",
|
||||||
|
)
|
||||||
|
settlement_status: Mapped[int] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)",
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""发货模型:发货单与发货产品明细。
|
||||||
|
|
||||||
|
- ship_date 是**回款天数计算基准日**(对照表 #1:发货日首日计第 1 天),
|
||||||
|
回款指定本发货单后由 formula.payment_days 派生回款天数,不在本表落库
|
||||||
|
- 运费仅记录展示,不计入毛利润口径(对照表 #2)
|
||||||
|
- total_amount 为明细行汇总冗余,服务层事务内维护
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import (
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
Base,
|
||||||
|
TimestampMixin,
|
||||||
|
TPlusSyncMixin,
|
||||||
|
mysql_table_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Shipment(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
|
"""发货单表(需求 2.2.2):每笔发货对应一个业务,支持分批发货。"""
|
||||||
|
|
||||||
|
__tablename__ = "shipments"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"发货单表(发货日为回款天数基准日,发货日首日计第 1 天)",
|
||||||
|
Index("idx_shipments_business_id", "business_id"),
|
||||||
|
Index("idx_shipments_ship_date", "ship_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
shipment_no: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)",
|
||||||
|
)
|
||||||
|
business_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("businesses.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="业务ID(businesses.id;终态业务禁止新发货)",
|
||||||
|
)
|
||||||
|
ship_date: Mapped[date] = mapped_column(
|
||||||
|
Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)"
|
||||||
|
)
|
||||||
|
freight_amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
|
||||||
|
)
|
||||||
|
total_amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)",
|
||||||
|
)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentItem(Base, TimestampMixin):
|
||||||
|
"""发货产品明细表(需求 2.2.2):记录发货的产品名称、规格、数量等。
|
||||||
|
|
||||||
|
价格口径两可(T+ 供价或人工录入,待 IT 确认):sale_price 可空人工补,
|
||||||
|
amount 由服务层按 数量×单价 计算固化。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "shipment_items"
|
||||||
|
__table_args__ = mysql_table_args(
|
||||||
|
"发货产品明细表",
|
||||||
|
Index("idx_shipment_items_shipment_id", "shipment_id"),
|
||||||
|
Index("idx_shipment_items_product_id", "product_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||||
|
)
|
||||||
|
shipment_id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("shipments.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
comment="发货单ID(shipments.id)",
|
||||||
|
)
|
||||||
|
product_id: Mapped[int | None] = mapped_column(
|
||||||
|
BIGINT_UNSIGNED,
|
||||||
|
ForeignKey("products.id", ondelete="RESTRICT"),
|
||||||
|
comment="产品ID(products.id;NULL=自由录入)",
|
||||||
|
)
|
||||||
|
product_name: Mapped[str] = mapped_column(
|
||||||
|
String(128), nullable=False, comment="产品名称(引用产品时冗余固化)"
|
||||||
|
)
|
||||||
|
spec_model: Mapped[str] = mapped_column(
|
||||||
|
String(128),
|
||||||
|
nullable=False,
|
||||||
|
server_default="''",
|
||||||
|
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
|
||||||
|
)
|
||||||
|
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
|
||||||
|
quantity: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2), nullable=False, comment="发货数量(>0)"
|
||||||
|
)
|
||||||
|
sale_price: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(14, 4), comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)"
|
||||||
|
)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2),
|
||||||
|
nullable=False,
|
||||||
|
comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)",
|
||||||
|
)
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""业务数据仓储:业务单、明细、状态留痕与归属留痕。
|
||||||
|
|
||||||
|
只读写数据并 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,
|
||||||
|
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 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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -32,7 +32,21 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
|
|||||||
# 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 ——
|
# 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 ——
|
||||||
# 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、
|
# 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、
|
||||||
# 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。
|
# 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。
|
||||||
_SENSITIVE_KEY_PARTS = ("password", "passwd", "pwd", "secret", "token", "app_key", "access_key")
|
# phone/mobile/id_card 系 CLAUDE.md「日志禁输出手机号/身份证」红线的键名口径 ——
|
||||||
|
# 带分隔符的号码会穿过文本正则,必须在这里按整值抹除
|
||||||
|
_SENSITIVE_KEY_PARTS = (
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"pwd",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
"app_key",
|
||||||
|
"access_key",
|
||||||
|
"phone",
|
||||||
|
"mobile",
|
||||||
|
"id_card",
|
||||||
|
"idcard",
|
||||||
|
)
|
||||||
|
|
||||||
_MASK = "***"
|
_MASK = "***"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""归属变更留痕的共享数据访问(客户/业务两维度共用)。
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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)]
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""营销人员数据访问(含辐射区域与拓展计划子表)。
|
||||||
|
|
||||||
|
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Row, Select, func, select
|
||||||
|
from sqlalchemy import delete as sa_delete
|
||||||
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
|
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
|
||||||
|
from app.repositories.query_utils import fuzzy
|
||||||
|
|
||||||
|
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
|
||||||
|
_manager = aliased(Personnel)
|
||||||
|
|
||||||
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, personnel_id: int, *, for_update: bool = False) -> Personnel | None:
|
||||||
|
"""按主键查询。删除校验走 ``for_update`` 锁行(同 business_role_repository 的理由)。"""
|
||||||
|
if for_update:
|
||||||
|
stmt = select(Personnel).where(Personnel.id == personnel_id).with_for_update()
|
||||||
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
return session.get(Personnel, personnel_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_with_names(session: Session, personnel_id: int) -> Row | None:
|
||||||
|
"""查单个人员并带出岗位名称与直属经理姓名(详情/编辑回显共用)。"""
|
||||||
|
stmt = _base_select().where(Personnel.id == personnel_id)
|
||||||
|
return session.execute(stmt).first()
|
||||||
|
|
||||||
|
|
||||||
|
def list_personnel(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
keyword: str | None = None,
|
||||||
|
status: int | None = None,
|
||||||
|
business_role_id: int | None = None,
|
||||||
|
) -> list[Row]:
|
||||||
|
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
|
||||||
|
|
||||||
|
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
|
||||||
|
"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
_base_select(), keyword=keyword, status=status, business_role_id=business_role_id
|
||||||
|
)
|
||||||
|
stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit)
|
||||||
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
|
def count_personnel(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
status: int | None = None,
|
||||||
|
business_role_id: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""统计符合条件的人员总数,供分页使用。
|
||||||
|
|
||||||
|
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||||||
|
``_apply_filters`` 单点保证)。
|
||||||
|
"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(func.count(Personnel.id)),
|
||||||
|
keyword=keyword,
|
||||||
|
status=status,
|
||||||
|
business_role_id=business_role_id,
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def count_team_members(session: Session, manager_ids: list[int]) -> dict[int, int]:
|
||||||
|
"""按直属经理批量统计团队成员数(删除保护的依据)。
|
||||||
|
|
||||||
|
一次 GROUP BY 完成,禁止调用方在列表循环内逐行查询。
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(Personnel.manager_id, func.count(Personnel.id))
|
||||||
|
.where(Personnel.manager_id.in_(manager_ids))
|
||||||
|
.group_by(Personnel.manager_id)
|
||||||
|
)
|
||||||
|
return {int(manager_id): int(count) for manager_id, count in session.execute(stmt).all()}
|
||||||
|
|
||||||
|
|
||||||
|
def count_customer_ownerships(session: Session, personnel_ids: list[int]) -> dict[int, int]:
|
||||||
|
"""按归属人员批量统计客户数(删除保护的另一半依据)。"""
|
||||||
|
stmt = (
|
||||||
|
select(Customer.owner_personnel_id, func.count(Customer.id))
|
||||||
|
.where(Customer.owner_personnel_id.in_(personnel_ids))
|
||||||
|
.group_by(Customer.owner_personnel_id)
|
||||||
|
)
|
||||||
|
return {int(owner_id): int(count) for owner_id, count in session.execute(stmt).all()}
|
||||||
|
|
||||||
|
|
||||||
|
def count_business_roles_refs(session: Session, personnel_ids: list[int]) -> dict[int, int]:
|
||||||
|
"""按人员批量统计业务单据归属引用(负责/协助合计;业务表就位后补齐的检查)。
|
||||||
|
|
||||||
|
businesses.owner/assistant_personnel_id 均为 RESTRICT 外键——漏检会让
|
||||||
|
「删除被业务引用的人员」以 500 暴露而非可读的 400。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models import Business
|
||||||
|
|
||||||
|
owner = (
|
||||||
|
select(Business.owner_personnel_id, func.count(Business.id))
|
||||||
|
.where(Business.owner_personnel_id.in_(personnel_ids))
|
||||||
|
.group_by(Business.owner_personnel_id)
|
||||||
|
)
|
||||||
|
assistant = (
|
||||||
|
select(Business.assistant_personnel_id, func.count(Business.id))
|
||||||
|
.where(Business.assistant_personnel_id.in_(personnel_ids))
|
||||||
|
.group_by(Business.assistant_personnel_id)
|
||||||
|
)
|
||||||
|
refs: dict[int, int] = {}
|
||||||
|
for stmt in (owner, assistant):
|
||||||
|
for owner_id, count in session.execute(stmt).all():
|
||||||
|
if owner_id is not None:
|
||||||
|
refs[int(owner_id)] = refs.get(int(owner_id), 0) + int(count)
|
||||||
|
return refs
|
||||||
|
|
||||||
|
|
||||||
|
def list_regions(session: Session, personnel_id: int) -> list[PersonnelRegion]:
|
||||||
|
"""查人员的全部辐射区域,按插入顺序返回。"""
|
||||||
|
stmt = (
|
||||||
|
select(PersonnelRegion)
|
||||||
|
.where(PersonnelRegion.personnel_id == personnel_id)
|
||||||
|
.order_by(PersonnelRegion.id)
|
||||||
|
)
|
||||||
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def list_plans(session: Session, personnel_id: int) -> list[PersonnelPlan]:
|
||||||
|
"""查人员的全部拓展计划,按插入顺序返回。"""
|
||||||
|
stmt = (
|
||||||
|
select(PersonnelPlan)
|
||||||
|
.where(PersonnelPlan.personnel_id == personnel_id)
|
||||||
|
.order_by(PersonnelPlan.id)
|
||||||
|
)
|
||||||
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def _base_select() -> Select:
|
||||||
|
"""人员主表联岗位名称与直属经理姓名。
|
||||||
|
|
||||||
|
岗位外键非空,但统一用 outerjoin:岗位行被物理删除的脏数据不应让
|
||||||
|
整个列表查不出来(外键 RESTRICT 正常情况下不会发生)。
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
select(Personnel, BusinessRole.role_name, _manager.personnel_name)
|
||||||
|
.outerjoin(BusinessRole, Personnel.business_role_id == BusinessRole.id)
|
||||||
|
.outerjoin(_manager, Personnel.manager_id == _manager.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: Select,
|
||||||
|
*,
|
||||||
|
keyword: str | None,
|
||||||
|
status: int | None,
|
||||||
|
business_role_id: int | None,
|
||||||
|
) -> Select:
|
||||||
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||||
|
if keyword:
|
||||||
|
stmt = fuzzy(stmt, Personnel.personnel_name, keyword)
|
||||||
|
if status is not None:
|
||||||
|
stmt = stmt.where(Personnel.status == status)
|
||||||
|
if business_role_id is not None:
|
||||||
|
stmt = stmt.where(Personnel.business_role_id == business_role_id)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 写入 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, personnel: Personnel) -> Personnel:
|
||||||
|
"""新增人员。只 flush 以取得自增 id。"""
|
||||||
|
session.add(personnel)
|
||||||
|
session.flush()
|
||||||
|
return personnel
|
||||||
|
|
||||||
|
|
||||||
|
def delete(session: Session, personnel: Personnel) -> None:
|
||||||
|
"""删除人员。调用方须先完成引用保护校验。"""
|
||||||
|
session.delete(personnel)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def replace_regions(session: Session, personnel_id: int, rows: list[PersonnelRegion]) -> None:
|
||||||
|
"""全量替换人员的辐射区域。
|
||||||
|
|
||||||
|
**先删、立即 flush、再插入**:同一 flush 内 SQLAlchemy 先执行 INSERT
|
||||||
|
后执行 DELETE,新旧行并存会在带唯一键的表上撞 1062 —— M1 的
|
||||||
|
replace_roles 在 e2e 中踩过此坑。本表当前无唯一键,仍统一按防御写法,
|
||||||
|
避免后续加约束时翻车。
|
||||||
|
"""
|
||||||
|
session.execute(sa_delete(PersonnelRegion).where(PersonnelRegion.personnel_id == personnel_id))
|
||||||
|
session.flush()
|
||||||
|
session.add_all(rows)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def replace_plans(session: Session, personnel_id: int, rows: list[PersonnelPlan]) -> None:
|
||||||
|
"""全量替换人员的拓展计划(防御写法同 ``replace_regions``)。"""
|
||||||
|
session.execute(sa_delete(PersonnelPlan).where(PersonnelPlan.personnel_id == personnel_id))
|
||||||
|
session.flush()
|
||||||
|
session.add_all(rows)
|
||||||
|
session.flush()
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""产品主数据数据访问。
|
||||||
|
|
||||||
|
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Select, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import BusinessItem, Product, ShipmentItem
|
||||||
|
|
||||||
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, product_id: int, *, for_update: bool = False) -> Product | None:
|
||||||
|
"""按主键查询。删除校验走 ``for_update`` 锁行(同 business_role_repository
|
||||||
|
的理由:校验与删除之间若发生并发引用,锁行使并发写入可见地失败,
|
||||||
|
而非引用被静默悬空)。"""
|
||||||
|
if for_update:
|
||||||
|
stmt = select(Product).where(Product.id == product_id).with_for_update()
|
||||||
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
return session.get(Product, product_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Product | None:
|
||||||
|
"""按 (名称, 规格型号) 组合查询。
|
||||||
|
|
||||||
|
组合是 uk_products_name_spec 唯一键的键值,先查用于给出即时业务提示;
|
||||||
|
并发窗口仍存在,最终由 IntegrityError 兜底(见 product_service)。
|
||||||
|
"""
|
||||||
|
stmt = select(Product).where(
|
||||||
|
Product.product_name == product_name,
|
||||||
|
Product.spec_model == spec_model,
|
||||||
|
)
|
||||||
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def list_products(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
keyword: str | None = None,
|
||||||
|
product_category: str | None = None,
|
||||||
|
status: int | None = None,
|
||||||
|
) -> list[Product]:
|
||||||
|
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(Product),
|
||||||
|
keyword=keyword,
|
||||||
|
product_category=product_category,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
|
||||||
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def count_products(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
product_category: str | None = None,
|
||||||
|
status: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""统计符合条件的产品总数,供分页使用。"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(func.count(Product.id)),
|
||||||
|
keyword=keyword,
|
||||||
|
product_category=product_category,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: Select,
|
||||||
|
*,
|
||||||
|
keyword: str | None,
|
||||||
|
product_category: str | None,
|
||||||
|
status: int | None,
|
||||||
|
) -> Select:
|
||||||
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||||
|
|
||||||
|
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
|
||||||
|
(``contains(autoescape=True)`` 转义 LIKE 通配符并附加 ESCAPE 子句);
|
||||||
|
因 fuzzy 是单列 where 语义(两次调用会叠加成 AND),双列 OR 在此直接
|
||||||
|
写 contains —— M1 business_role_repository 的双列关键词同款写法。
|
||||||
|
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
|
||||||
|
"""
|
||||||
|
if keyword:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Product.product_name.contains(keyword, autoescape=True),
|
||||||
|
Product.spec_model.contains(keyword, autoescape=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if product_category:
|
||||||
|
stmt = stmt.where(Product.product_category == product_category)
|
||||||
|
if status is not None:
|
||||||
|
stmt = stmt.where(Product.status == status)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def count_refs(session: Session, product_id: int) -> int:
|
||||||
|
"""统计引用该产品的业务明细与发货明细行数(删除保护,business_id 先例)。
|
||||||
|
|
||||||
|
两张明细表对 products 均 RESTRICT 外键:先查给出可读的 400;校验与
|
||||||
|
删除之间的并发引用由锁行 + FK 兜底(service 层再翻译 IntegrityError)。
|
||||||
|
"""
|
||||||
|
stmt = select(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(BusinessItem)
|
||||||
|
.where(BusinessItem.product_id == product_id)
|
||||||
|
.scalar_subquery()
|
||||||
|
+ select(func.count())
|
||||||
|
.select_from(ShipmentItem)
|
||||||
|
.where(ShipmentItem.product_id == product_id)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 写入 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, product: Product) -> Product:
|
||||||
|
"""新增产品。只 flush 以取得自增 id。"""
|
||||||
|
session.add(product)
|
||||||
|
session.flush()
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def delete(session: Session, product: Product) -> None:
|
||||||
|
"""物理删除产品。调用方须先完成引用保护校验(count_refs + FK 兜底)。"""
|
||||||
|
session.delete(product)
|
||||||
|
session.flush()
|
||||||
@@ -0,0 +1,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()
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""回款数据访问:回款行与关联引用(业务号/发货单号/发货日)。
|
||||||
|
|
||||||
|
只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。
|
||||||
|
|
||||||
|
- 列表与详情统一 join 出 ``business_no / shipment_no / ship_date``,
|
||||||
|
ship_date 供 service 按 ``formula.payment_days`` 派生回款天数 —— 关联
|
||||||
|
引用经本层查询一次供给,service 不再对发货单二次查询
|
||||||
|
- shipment_id 是手动指定(可空),发货单关联一律 outerjoin
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import Select, func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Business, Receipt, Shipment
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
|
||||||
|
"""按主键取回款行(供摘要预取与状态判定;不锁行,写保护锚在业务行)。"""
|
||||||
|
return session.get(Receipt, receipt_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_with_refs(
|
||||||
|
session: Session, receipt_id: int
|
||||||
|
) -> tuple[Receipt, str, str | None, date | None] | None:
|
||||||
|
"""回款行 + 业务号 + 发货单号 + 发货日(DTO 与 payment_days 派生共用)。
|
||||||
|
|
||||||
|
business 主表为 inner join(FK NOT NULL,业务与回款同存亡);发货单
|
||||||
|
可未指定,outerjoin 后 shipment_no / ship_date 可能为 None。
|
||||||
|
"""
|
||||||
|
row = session.execute(
|
||||||
|
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
|
||||||
|
.join(Business, Business.id == Receipt.business_id)
|
||||||
|
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id)
|
||||||
|
.where(Receipt.id == receipt_id)
|
||||||
|
).first()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def get_shipment(session: Session, shipment_id: int) -> Shipment | None:
|
||||||
|
"""按主键取发货单(同业务强校验的只读预查;FK RESTRICT 为最终防线)。"""
|
||||||
|
return session.get(Shipment, shipment_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: Select,
|
||||||
|
*,
|
||||||
|
business_id: int | None,
|
||||||
|
date_from: date | None,
|
||||||
|
date_to: date | None,
|
||||||
|
is_barter: int | None,
|
||||||
|
settlement_status: int | None,
|
||||||
|
) -> Select:
|
||||||
|
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
|
||||||
|
if business_id is not None:
|
||||||
|
stmt = stmt.where(Receipt.business_id == business_id)
|
||||||
|
if date_from is not None:
|
||||||
|
stmt = stmt.where(Receipt.receipt_date >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
stmt = stmt.where(Receipt.receipt_date <= date_to)
|
||||||
|
if is_barter is not None:
|
||||||
|
stmt = stmt.where(Receipt.is_barter == is_barter)
|
||||||
|
if settlement_status is not None:
|
||||||
|
stmt = stmt.where(Receipt.settlement_status == settlement_status)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def list_receipts(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
business_id: int | None,
|
||||||
|
date_from: date | None,
|
||||||
|
date_to: date | None,
|
||||||
|
is_barter: int | None,
|
||||||
|
settlement_status: int | None,
|
||||||
|
) -> list[tuple[Receipt, str, str | None, date | None]]:
|
||||||
|
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
|
||||||
|
.join(Business, Business.id == Receipt.business_id)
|
||||||
|
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
|
||||||
|
business_id=business_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
is_barter=is_barter,
|
||||||
|
settlement_status=settlement_status,
|
||||||
|
)
|
||||||
|
return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all())
|
||||||
|
|
||||||
|
|
||||||
|
def count_receipts(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
business_id: int | None,
|
||||||
|
date_from: date | None,
|
||||||
|
date_to: date | None,
|
||||||
|
is_barter: int | None,
|
||||||
|
settlement_status: int | None,
|
||||||
|
) -> int:
|
||||||
|
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(func.count()).select_from(Receipt),
|
||||||
|
business_id=business_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
is_barter=is_barter,
|
||||||
|
settlement_status=settlement_status,
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, receipt: Receipt) -> Receipt:
|
||||||
|
"""新增回款行。只 flush 以取得自增 id,编号唯一键冲突由 service 兜底。"""
|
||||||
|
session.add(receipt)
|
||||||
|
session.flush()
|
||||||
|
return receipt
|
||||||
|
|
||||||
|
|
||||||
|
def delete(session: Session, receipt: Receipt) -> None:
|
||||||
|
"""物理删除回款行(已核算与否的准入判定在 service 层)。"""
|
||||||
|
session.delete(receipt)
|
||||||
|
session.flush()
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""发货数据访问:发货单行、明细行与业务号关联引用。
|
||||||
|
|
||||||
|
只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。
|
||||||
|
|
||||||
|
- 列表与详情统一 join 出 ``business_no``(业务主表 inner join,FK NOT
|
||||||
|
NULL,业务与发货单同存亡)
|
||||||
|
- 明细行汇总(sum_item_amount)是发货单 total_amount 重算的唯一数据源,
|
||||||
|
业务级汇总仍收口 business_service._recalc_totals
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Select, delete, func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Business, Receipt, Shipment, ShipmentItem
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, shipment_id: int) -> Shipment | None:
|
||||||
|
"""按主键取发货单行(供摘要预取与编辑赋值;不锁行,写保护锚在业务行)。"""
|
||||||
|
return session.get(Shipment, shipment_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment, str] | None:
|
||||||
|
"""发货单行 + 业务号(DTO 与审计摘要共用)。"""
|
||||||
|
return session.execute(
|
||||||
|
select(Shipment, Business.business_no)
|
||||||
|
.join(Business, Business.id == Shipment.business_id)
|
||||||
|
.where(Shipment.id == shipment_id)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: Select,
|
||||||
|
*,
|
||||||
|
business_id: int | None,
|
||||||
|
ship_date_from: date | None,
|
||||||
|
ship_date_to: date | None,
|
||||||
|
) -> Select:
|
||||||
|
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
|
||||||
|
if business_id is not None:
|
||||||
|
stmt = stmt.where(Shipment.business_id == business_id)
|
||||||
|
if ship_date_from is not None:
|
||||||
|
stmt = stmt.where(Shipment.ship_date >= ship_date_from)
|
||||||
|
if ship_date_to is not None:
|
||||||
|
stmt = stmt.where(Shipment.ship_date <= ship_date_to)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def list_shipments(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
business_id: int | None,
|
||||||
|
ship_date_from: date | None,
|
||||||
|
ship_date_to: date | None,
|
||||||
|
) -> list[tuple[Shipment, str]]:
|
||||||
|
"""分页查询发货单(附业务号),按 id 倒序。"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
|
||||||
|
business_id=business_id,
|
||||||
|
ship_date_from=ship_date_from,
|
||||||
|
ship_date_to=ship_date_to,
|
||||||
|
)
|
||||||
|
return list(
|
||||||
|
session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_shipments(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
business_id: int | None,
|
||||||
|
ship_date_from: date | None,
|
||||||
|
ship_date_to: date | None,
|
||||||
|
) -> int:
|
||||||
|
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Shipment)
|
||||||
|
.join(Business, Business.id == Shipment.business_id),
|
||||||
|
business_id=business_id,
|
||||||
|
ship_date_from=ship_date_from,
|
||||||
|
ship_date_to=ship_date_to,
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def list_items(session: Session, shipment_id: int) -> list[ShipmentItem]:
|
||||||
|
"""发货明细行(详情回显与总金额重算共用)。"""
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(ShipmentItem)
|
||||||
|
.where(ShipmentItem.shipment_id == shipment_id)
|
||||||
|
.order_by(ShipmentItem.id)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sum_item_amount(session: Session, shipment_id: int) -> Decimal | None:
|
||||||
|
"""明细行金额合计(发货单 total_amount 重算的唯一数据源)。"""
|
||||||
|
return session.execute(
|
||||||
|
select(func.sum(ShipmentItem.amount)).where(ShipmentItem.shipment_id == shipment_id)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
def count_receipt_refs(session: Session, shipment_id: int) -> int:
|
||||||
|
"""被回款批次指定的引用数(删除保护依据,receipts.shipment_id)。"""
|
||||||
|
return int(
|
||||||
|
session.execute(
|
||||||
|
select(func.count()).select_from(Receipt).where(Receipt.shipment_id == shipment_id)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, shipment: Shipment) -> Shipment:
|
||||||
|
"""新增发货单行。只 flush 以取得自增 id,单号唯一键冲突由 service 兜底。"""
|
||||||
|
session.add(shipment)
|
||||||
|
session.flush()
|
||||||
|
return shipment
|
||||||
|
|
||||||
|
|
||||||
|
def replace_items(session: Session, shipment_id: int, rows: list[ShipmentItem]) -> None:
|
||||||
|
"""全量替换明细:先删、立即 flush、再插入(business_repository 同款防御写法)。
|
||||||
|
|
||||||
|
同一 flush 中 SQLAlchemy 先处理 INSERT 后处理 DELETE,先删后插不落库
|
||||||
|
会在有唯一键的表上撞键;shipment_items 虽无唯一键,统一按此写法防御。
|
||||||
|
"""
|
||||||
|
session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id == shipment_id))
|
||||||
|
session.flush()
|
||||||
|
for row in rows:
|
||||||
|
row.shipment_id = shipment_id
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_with_items(session: Session, shipment_id: int) -> None:
|
||||||
|
"""删除发货单及明细行(按依赖序:明细 → 主表,FK RESTRICT)。"""
|
||||||
|
session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id == shipment_id))
|
||||||
|
session.execute(delete(Shipment).where(Shipment.id == shipment_id))
|
||||||
|
session.flush()
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""业务管理的请求/响应模型(需求 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 BusinessDetailOut(BusinessOut):
|
||||||
|
"""业务详情:附产品明细行。"""
|
||||||
|
|
||||||
|
items: list[BusinessItemOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
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 行")
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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 行(全量替换)"
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessStatusIn(BaseModel):
|
||||||
|
"""状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
status: int = Field(ge=1, le=3, description="目标状态:2已完成 3已终止")
|
||||||
|
reason: str | None = Field(default=None, max_length=255, description="变更原因(终止时必填)")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_reason(self) -> "BusinessStatusIn":
|
||||||
|
if self.status == 3 and not self.reason:
|
||||||
|
raise ValueError("终止业务必须填写原因")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessOwnershipIn(BaseModel):
|
||||||
|
"""业务归属变更(负责/协助;前后值写入留痕)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
new_owner_personnel_id: int | None = Field(default=None, ge=1)
|
||||||
|
new_assistant_personnel_id: int | None = Field(default=None, ge=1)
|
||||||
|
reason: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessQuery(PageQuery):
|
||||||
|
"""业务列表查询条件(筛选参数驼峰 alias,customer.py 同款契约)。"""
|
||||||
|
|
||||||
|
keyword: str | None = Field(default=None, max_length=32, description="业务编号/公司名称,模糊")
|
||||||
|
status: int | None = Field(default=None, ge=1, le=3, description="状态筛选")
|
||||||
|
customer_id: int | None = Field(default=None, ge=1, description="客户筛选")
|
||||||
|
owner_personnel_id: int | None = Field(default=None, ge=1, description="负责营销人员筛选")
|
||||||
|
date_from: date | None = Field(default=None, description="业务日期起(含)")
|
||||||
|
date_to: date | None = Field(default=None, description="业务日期止(含)")
|
||||||
|
|
||||||
|
|
||||||
|
class GrossProfitRowOut(BaseModel):
|
||||||
|
"""毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。"""
|
||||||
|
|
||||||
|
item_id: int
|
||||||
|
product_category: str
|
||||||
|
product_name: str
|
||||||
|
spec_model: str | None = None
|
||||||
|
quantity: str
|
||||||
|
sale_amount: str
|
||||||
|
cost_amount: str | None = None
|
||||||
|
"""成本缺失(未录入)时为 None,派生列同为 None 并置 missing_reason"""
|
||||||
|
profit_amount: str | None = None
|
||||||
|
profit_rate: str | None = None
|
||||||
|
threshold: str
|
||||||
|
converted_profit: str | None = None
|
||||||
|
missing_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GrossProfitCategoryOut(BaseModel):
|
||||||
|
"""按产品类别的毛利润汇总(可计算行聚合;成本缺失行不计入)。"""
|
||||||
|
|
||||||
|
product_category: str
|
||||||
|
sale_amount: str
|
||||||
|
cost_amount: str
|
||||||
|
profit_amount: str
|
||||||
|
profit_rate: str
|
||||||
|
threshold: str
|
||||||
|
converted_profit: str
|
||||||
|
|
||||||
|
|
||||||
|
class GrossProfitOut(BaseModel):
|
||||||
|
"""毛利润数据视图(需求 2.2.4):行级 + 类别汇总。
|
||||||
|
|
||||||
|
口径注释(前端须展示):毛利润率 = 毛利润额 / 销售额,**不含运费**
|
||||||
|
(对照表 #2 作废需规含运费公式);阈值按 asOf 取 M1 参数快照;M3 核算
|
||||||
|
实际按回款到账日取参,本视图默认今日仅供录入期核对。
|
||||||
|
"""
|
||||||
|
|
||||||
|
business_id: int
|
||||||
|
as_of: date
|
||||||
|
rows: list[GrossProfitRowOut] = Field(default_factory=list)
|
||||||
|
categories: list[GrossProfitCategoryOut] = Field(default_factory=list)
|
||||||
|
missing_count: int = 0
|
||||||
|
note: str = "毛利润率与折算额不含运费;M3 核算按回款到账日取参数版本"
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipChangeLogOut(BaseModel):
|
||||||
|
"""业务归属变更留痕(人员删除后姓名为 None,固化 ID 兜底可追溯)。"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
business_id: int
|
||||||
|
old_owner_personnel_id: int | None = None
|
||||||
|
new_owner_personnel_id: int | None = None
|
||||||
|
old_assistant_personnel_id: int | None = None
|
||||||
|
new_assistant_personnel_id: int | None = None
|
||||||
|
old_owner_name: str | None = None
|
||||||
|
new_owner_name: str | None = None
|
||||||
|
change_reason: str | None = None
|
||||||
|
operator_id: int | None = None
|
||||||
|
operator_name: str
|
||||||
|
create_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessStatusLogOut(BaseModel):
|
||||||
|
"""业务状态流转记录(详情页时间线;创建首条 old=new=1)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
business_id: int
|
||||||
|
old_status: int
|
||||||
|
new_status: int
|
||||||
|
change_reason: str | None = None
|
||||||
|
operator_id: int | None = None
|
||||||
|
operator_name: str
|
||||||
|
create_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# 供 schema 外部引用的两类别常量(类别汇总展示用)
|
||||||
|
CATEGORY_LABELS = {
|
||||||
|
PRODUCT_CATEGORY_MUNICIPAL_WATER: "市政水利",
|
||||||
|
PRODUCT_CATEGORY_RAIL_TRANSIT: "轨道交通",
|
||||||
|
}
|
||||||
@@ -0,0 +1,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="状态筛选")
|
||||||
@@ -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
|
||||||
@@ -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="按维度值筛选,空为全部"
|
||||||
|
)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""营销人员的请求/响应模型(需求 2.1.1)。"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
|
# salary_standard 列为 Numeric(12,2):整数部分最多 10 位。
|
||||||
|
# 工资系统金额红线——入参出参一律 str,服务层转 Decimal,全程不经 float
|
||||||
|
SALARY_MAX = Decimal("9999999999.99")
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelRegionOut(BaseModel):
|
||||||
|
"""辐射区域条目响应。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
region_name: str
|
||||||
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelPlanOut(BaseModel):
|
||||||
|
"""拓展计划条目响应。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
plan_period: str
|
||||||
|
plan_content: str
|
||||||
|
target_desc: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelOut(BaseModel):
|
||||||
|
"""人员响应(列表与写操作回包共用)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
personnel_name: str
|
||||||
|
business_role_id: int
|
||||||
|
role_name: str | None = None
|
||||||
|
manager_id: int | None = None
|
||||||
|
manager_name: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
hire_date: date | None = None
|
||||||
|
is_probation: int = 0
|
||||||
|
salary_standard: str | None = None
|
||||||
|
status: int = 1
|
||||||
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelDetailOut(PersonnelOut):
|
||||||
|
"""人员详情:附区域与拓展计划,供编辑抽屉一次回显。"""
|
||||||
|
|
||||||
|
regions: list[PersonnelRegionOut] = Field(default_factory=list)
|
||||||
|
plans: list[PersonnelPlanOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelCreateIn(BaseModel):
|
||||||
|
"""新增人员。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
personnel_name: str = Field(min_length=1, max_length=64, description="姓名")
|
||||||
|
business_role_id: int = Field(ge=1, description="岗位角色ID")
|
||||||
|
manager_id: int | None = Field(default=None, ge=1, description="直属营销经理ID,空=无")
|
||||||
|
phone: str | None = Field(default=None, max_length=20, description="联系电话")
|
||||||
|
hire_date: date | None = Field(default=None, description="入职日期,ISO 格式")
|
||||||
|
is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否")
|
||||||
|
salary_standard: str | None = Field(
|
||||||
|
default=None, description="月工资标准(元),字符串形式的数字"
|
||||||
|
)
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("salary_standard")
|
||||||
|
@classmethod
|
||||||
|
def _check_salary_standard(cls, value: str | None) -> str | None:
|
||||||
|
"""按 Numeric(12,2) 的容量校验字符串金额。
|
||||||
|
|
||||||
|
金额以字符串进出避免 float 精度损失(项目金额红线);
|
||||||
|
小数位超过两位直接拒绝而非静默舍入——舍入即算错钱。
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
amount = Decimal(value)
|
||||||
|
except InvalidOperation:
|
||||||
|
raise ValueError("工资标准须为有效数字") from None
|
||||||
|
if not amount.is_finite():
|
||||||
|
raise ValueError("工资标准须为有效数字")
|
||||||
|
if amount < 0:
|
||||||
|
raise ValueError("工资标准不能为负数")
|
||||||
|
if amount > SALARY_MAX:
|
||||||
|
raise ValueError("工资标准超出可录入上限")
|
||||||
|
if -amount.as_tuple().exponent > 2:
|
||||||
|
raise ValueError("工资标准最多两位小数")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelUpdateIn(PersonnelCreateIn):
|
||||||
|
"""编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。"""
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelStatusIn(BaseModel):
|
||||||
|
"""在职/离职切换。"""
|
||||||
|
|
||||||
|
status: int = Field(ge=0, le=1, description="1在职 0离职")
|
||||||
|
|
||||||
|
|
||||||
|
class RegionItemIn(BaseModel):
|
||||||
|
"""辐射区域条目。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
region_name: str = Field(min_length=1, max_length=128, description="区域名称")
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class RegionReplaceIn(BaseModel):
|
||||||
|
"""辐射区域全量替换。空列表即清空该人员的全部区域。"""
|
||||||
|
|
||||||
|
items: list[RegionItemIn] = Field(default_factory=list, description="全量条目")
|
||||||
|
|
||||||
|
|
||||||
|
class PlanItemIn(BaseModel):
|
||||||
|
"""拓展计划条目。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
plan_period: str = Field(min_length=1, max_length=32, description="计划期间,如 2026Q1")
|
||||||
|
plan_content: str = Field(min_length=1, max_length=500, description="计划内容")
|
||||||
|
target_desc: str | None = Field(default=None, max_length=255, description="指标说明")
|
||||||
|
|
||||||
|
|
||||||
|
class PlanReplaceIn(BaseModel):
|
||||||
|
"""拓展计划全量替换。空列表即清空该人员的全部计划。"""
|
||||||
|
|
||||||
|
items: list[PlanItemIn] = Field(default_factory=list, description="全量条目")
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelQuery(PageQuery):
|
||||||
|
"""人员列表查询条件。"""
|
||||||
|
|
||||||
|
keyword: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配")
|
||||||
|
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选:1在职 0离职")
|
||||||
|
business_role_id: int | None = Field(default=None, ge=1, description="岗位筛选")
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""产品管理的请求/响应模型。
|
||||||
|
|
||||||
|
数量与单价(stock_quantity/sale_price)入参出参一律用**字符串**承载:
|
||||||
|
前端 JS 的 number 无法精确表示全部十进制小数,字符串是金额红线的入口闸门,
|
||||||
|
Decimal 转换统一收敛在 service 层。字段命名与 user/role/param 一致采用
|
||||||
|
蛇形直出(前端 TS 接口同构)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount
|
||||||
|
|
||||||
|
|
||||||
|
class ProductOut(BaseModel):
|
||||||
|
"""产品响应(列表与详情共用)。数量/单价按红线以字符串出参。"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
product_name: str
|
||||||
|
spec_model: str
|
||||||
|
unit: str | None = None
|
||||||
|
stock_quantity: str | None = None
|
||||||
|
sale_price: str | None = None
|
||||||
|
product_category: str
|
||||||
|
status: int = 1
|
||||||
|
remark: str | None = None
|
||||||
|
create_time: datetime
|
||||||
|
update_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCreateIn(BaseModel):
|
||||||
|
"""新增产品。
|
||||||
|
|
||||||
|
spec_model 允许缺省/空:service 层归一为空串(组合唯一键
|
||||||
|
uk_products_name_spec 对 NULL 不去重,归 NULL 会放行重名同规格数据)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
product_name: str = Field(min_length=1, max_length=128, description="产品名称")
|
||||||
|
spec_model: str | None = Field(
|
||||||
|
default=None, max_length=128, description="规格型号,空归一为空串"
|
||||||
|
)
|
||||||
|
unit: str | None = Field(default=None, max_length=16, description="计量单位")
|
||||||
|
stock_quantity: str | None = Field(default=None, description="数量,非负数字字符串")
|
||||||
|
sale_price: str | None = Field(default=None, description="销售单价(元),非负数字字符串")
|
||||||
|
product_category: str = Field(min_length=1, max_length=32, description="产品类别")
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("stock_quantity")
|
||||||
|
@classmethod
|
||||||
|
def _check_stock_quantity(cls, value: str | None) -> str | None:
|
||||||
|
"""空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
|
||||||
|
|
||||||
|
复用 _check_amount:容量/小数位与 DECIMAL(14,2) 列口径一致
|
||||||
|
(此前私写的 _parse_non_negative 不查小数位与容量,属同语义
|
||||||
|
字段两处口径漂移)。
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return _check_amount(value, "数量", AMOUNT_MAX)
|
||||||
|
|
||||||
|
@field_validator("sale_price")
|
||||||
|
@classmethod
|
||||||
|
def _check_sale_price(cls, value: str | None) -> str | None:
|
||||||
|
"""空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
|
||||||
|
|
||||||
|
scale=4 与列口径 Numeric(14,4) 一致 —— 带精度的历史单价必须能
|
||||||
|
原样提交(「编辑任何带精度历史行都失败」的 fba8cfc 教训)。
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return _check_amount(value, "销售单价", PRICE_MAX, scale=4)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductUpdateIn(ProductCreateIn):
|
||||||
|
"""编辑产品。
|
||||||
|
|
||||||
|
前端表单全量提交,故为全量替换语义;**不含 status** —— 启用/停用是
|
||||||
|
独立操作(走 PUT /{id}/status),避免编辑表单误触停用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProductStatusIn(BaseModel):
|
||||||
|
"""启用/停用产品。"""
|
||||||
|
|
||||||
|
status: int = Field(ge=0, le=1, description="1启用 0停用")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductQuery(PageQuery):
|
||||||
|
"""产品列表查询条件。"""
|
||||||
|
|
||||||
|
keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配")
|
||||||
|
product_category: str | None = Field(default=None, max_length=32, description="按类别筛选")
|
||||||
|
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
|
||||||
@@ -0,0 +1,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):
|
||||||
|
"""编辑评级等级。
|
||||||
|
|
||||||
|
前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。
|
||||||
|
"""
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""回款管理的请求/响应模型(需求 2.2.3)。
|
||||||
|
|
||||||
|
金额入参出参一律 str(服务层 Decimal,全程禁 float);字段契约**纯蛇形
|
||||||
|
直出**(2026-10-13 定稿,无驼峰 alias)。
|
||||||
|
|
||||||
|
payment_days 是派生值(发货日 → 回款日的天数,对照表 #1),不在写接口
|
||||||
|
入参内 —— 落库列只有 shipment_id 源数据,派生值实时计算,发货批次变更
|
||||||
|
后无需回写历史。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
|
# 金额校验是工资系统的红线口径(数字/非负/容量/两位小数拒绝),跨 schema
|
||||||
|
# 单点复用 T7 定稿工具,禁止各处复制导致口径漂移;AMOUNT_MAX 即
|
||||||
|
# DECIMAL(14,2) 的容量上限,与回款列容量同源
|
||||||
|
from app.schemas.business import AMOUNT_MAX, _check_amount
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptCreateIn(BaseModel):
|
||||||
|
"""新增回款:业务必选,发货批次可暂不指定(手动指定,对照表 #18)。
|
||||||
|
|
||||||
|
shipment_id 指向的发货单必须与本回款同属一个业务(服务层强校验,
|
||||||
|
数据完整性红线);shipment_id 可空 = 暂未指定,指定后回款天数方可派生。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
business_id: int = Field(ge=1, description="业务ID")
|
||||||
|
shipment_id: int | None = Field(default=None, ge=1, description="发货单ID,可空=暂未指定")
|
||||||
|
receipt_date: date = Field(description="回款到账日(M3 按此日匹配参数版本)")
|
||||||
|
amount: str = Field(description="回款金额(元),≥0,最多两位小数")
|
||||||
|
is_barter: int = Field(default=0, ge=0, le=1, description="以物抵顶标记:1是 0否")
|
||||||
|
remark: str | None = Field(default=None, max_length=255, description="备注")
|
||||||
|
|
||||||
|
@field_validator("amount")
|
||||||
|
@classmethod
|
||||||
|
def _check_amount_value(cls, value: str) -> str:
|
||||||
|
return _check_amount(value, "回款金额", AMOUNT_MAX) or value
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptUpdateIn(BaseModel):
|
||||||
|
"""编辑回款(全量替换语义)。
|
||||||
|
|
||||||
|
shipment_id 传 None = 清除指定(回到「暂未指定」);amount 是必填项,
|
||||||
|
全量替换下不存在「不传即不变」的歧义。已核算(settlement_status=1)
|
||||||
|
的回款在服务层禁改,schema 层不感知该状态。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
shipment_id: int | None = Field(
|
||||||
|
default=None, ge=1, description="发货单ID,可空=暂未指定(传空清除既有指定)"
|
||||||
|
)
|
||||||
|
receipt_date: date = Field(description="回款到账日")
|
||||||
|
amount: str = Field(description="回款金额(元),≥0,最多两位小数")
|
||||||
|
is_barter: int = Field(default=0, ge=0, le=1, description="以物抵顶标记:1是 0否")
|
||||||
|
remark: str | None = Field(default=None, max_length=255, description="备注")
|
||||||
|
|
||||||
|
@field_validator("amount")
|
||||||
|
@classmethod
|
||||||
|
def _check_amount_value(cls, value: str) -> str:
|
||||||
|
return _check_amount(value, "回款金额", AMOUNT_MAX) or value
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptOut(BaseModel):
|
||||||
|
"""回款响应(列表与写操作回包共用)。
|
||||||
|
|
||||||
|
business_no/shipment_no 为关联冗余展示;payment_days 为派生值:
|
||||||
|
未指定发货批次时为 None,指定后 = 发货日到回款日的天数(发货日首日
|
||||||
|
计第 1 天)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
receipt_batch_no: str
|
||||||
|
business_id: int
|
||||||
|
business_no: str
|
||||||
|
shipment_id: int | None = None
|
||||||
|
shipment_no: str | None = None
|
||||||
|
receipt_date: date
|
||||||
|
amount: str
|
||||||
|
is_barter: int
|
||||||
|
settlement_status: int
|
||||||
|
payment_days: int | None = None
|
||||||
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptQuery(PageQuery):
|
||||||
|
"""回款列表查询条件(筛选参数纯蛇形直出,同 BusinessQuery 契约)。"""
|
||||||
|
|
||||||
|
business_id: int | None = Field(default=None, ge=1, description="业务筛选")
|
||||||
|
date_from: date | None = Field(default=None, description="回款到账日起(含)")
|
||||||
|
date_to: date | None = Field(default=None, description="回款到账日止(含)")
|
||||||
|
is_barter: int | None = Field(default=None, ge=0, le=1, description="以物抵顶筛选:1是 0否")
|
||||||
|
settlement_status: int | None = Field(
|
||||||
|
default=None, ge=0, le=1, description="核算状态筛选:0未核算 1已核算"
|
||||||
|
)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""发货管理的请求/响应模型(需求 2.2.2)。
|
||||||
|
|
||||||
|
金额/数量入参出参一律 str(服务层 Decimal 运算,全程禁 float),容量与
|
||||||
|
小数位校验复用 business.py 的 ``_check_amount``(同一套口径,防两处漂移);
|
||||||
|
字段契约全线蛇形直出(2026-10-13 定稿:无驼峰 alias)。
|
||||||
|
|
||||||
|
amount(行金额)与 total_amount(发货总金额)不在入参内 —— 由服务层按
|
||||||
|
数量×单价 计算固化,不信任前端传值;运费仅记录展示,不计入毛利润口径。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentItemIn(BaseModel):
|
||||||
|
"""发货产品明细行(product_id 空=自由录入;价格口径两可,可空人工补)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
product_id: int | None = Field(default=None, ge=1, description="产品ID;空=自由录入")
|
||||||
|
product_name: str = Field(min_length=1, max_length=128, description="产品名称")
|
||||||
|
spec_model: str | None = Field(
|
||||||
|
default=None, max_length=128, description="规格型号,空=空串归一"
|
||||||
|
)
|
||||||
|
unit: str | None = Field(default=None, max_length=16, description="计量单位")
|
||||||
|
quantity: str = Field(description="数量,>0")
|
||||||
|
sale_price: str | None = Field(
|
||||||
|
default=None, description="发货单价(元),可空=待人工补价,补价前行金额记 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("quantity")
|
||||||
|
@classmethod
|
||||||
|
def _check_quantity(cls, value: str) -> str:
|
||||||
|
return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value
|
||||||
|
|
||||||
|
@field_validator("sale_price")
|
||||||
|
@classmethod
|
||||||
|
def _check_price(cls, value: str | None) -> str | None:
|
||||||
|
"""发货单价列 DECIMAL(14,4):按 4 位小数口径(回显可原样提交)。"""
|
||||||
|
return _check_amount(value, "单价", PRICE_MAX, scale=4)
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentItemOut(BaseModel):
|
||||||
|
"""发货产品明细行响应。金额/数量一律 str。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
product_id: int | None = None
|
||||||
|
product_name: str
|
||||||
|
spec_model: str | None = None
|
||||||
|
unit: str | None = None
|
||||||
|
quantity: str
|
||||||
|
sale_price: str | None = None
|
||||||
|
amount: str
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentOut(BaseModel):
|
||||||
|
"""发货单响应(列表与写操作回包共用);business_no 为 join 附带列。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
shipment_no: str
|
||||||
|
business_id: int
|
||||||
|
business_no: str | None = None
|
||||||
|
ship_date: date
|
||||||
|
freight_amount: str
|
||||||
|
total_amount: str
|
||||||
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentDetailOut(ShipmentOut):
|
||||||
|
"""发货单详情:附产品明细行。"""
|
||||||
|
|
||||||
|
items: list[ShipmentItemOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentCreateIn(BaseModel):
|
||||||
|
"""创建发货单:业务维度 + 明细行数组(明细 ≥1 行)。
|
||||||
|
|
||||||
|
字段名全线蛇形直出(同 business.py 契约,仅分页信封 list/pageNum 驼峰)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
business_id: int = Field(ge=1, description="业务ID")
|
||||||
|
ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)")
|
||||||
|
freight_amount: str | None = Field(
|
||||||
|
default=None, description="运费金额(元),空记 0;仅记录展示,不计入毛利润口径"
|
||||||
|
)
|
||||||
|
items: list[ShipmentItemIn] = Field(min_length=1, description="发货明细行,至少 1 行")
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("freight_amount")
|
||||||
|
@classmethod
|
||||||
|
def _check_freight(cls, value: str | None) -> str | None:
|
||||||
|
"""运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。"""
|
||||||
|
return _check_amount(value, "运费金额", AMOUNT_MAX)
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentUpdateIn(BaseModel):
|
||||||
|
"""编辑发货单(明细全量替换)。
|
||||||
|
|
||||||
|
business_id 不在编辑入参内 —— 发货单挂靠的业务创建时定死,改挂会让
|
||||||
|
已指定本发货单的回款语义断裂。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)")
|
||||||
|
freight_amount: str | None = Field(default=None, description="运费金额(元),空记 0")
|
||||||
|
items: list[ShipmentItemIn] = Field(
|
||||||
|
min_length=1, description="发货明细行,至少 1 行(全量替换)"
|
||||||
|
)
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("freight_amount")
|
||||||
|
@classmethod
|
||||||
|
def _check_freight(cls, value: str | None) -> str | None:
|
||||||
|
"""运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。"""
|
||||||
|
return _check_amount(value, "运费金额", AMOUNT_MAX)
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentQuery(PageQuery):
|
||||||
|
"""发货单列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
|
||||||
|
|
||||||
|
business_id 可空=全量:发货单以业务维度查看为主(一期),但单号可
|
||||||
|
独立检索,不强制挂业务条件。
|
||||||
|
"""
|
||||||
|
|
||||||
|
business_id: int | None = Field(default=None, ge=1, description="业务筛选,空=全部")
|
||||||
|
ship_date_from: date | None = Field(default=None, description="发货日期起(含)")
|
||||||
|
ship_date_to: date | None = Field(default=None, description="发货日期止(含)")
|
||||||
@@ -0,0 +1,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)
|
||||||
@@ -0,0 +1,762 @@
|
|||||||
|
"""业务管理业务编排(需求 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 (
|
||||||
|
Business,
|
||||||
|
BusinessItem,
|
||||||
|
BusinessStatusLog,
|
||||||
|
Customer,
|
||||||
|
OwnershipChangeLog,
|
||||||
|
Personnel,
|
||||||
|
Receipt,
|
||||||
|
Shipment,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from app.models.business import (
|
||||||
|
BUSINESS_STATUS_ACTIVE,
|
||||||
|
BUSINESS_STATUS_DONE,
|
||||||
|
)
|
||||||
|
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||||
|
from app.repositories import business_repository, customer_repository
|
||||||
|
from app.schemas.business import (
|
||||||
|
BusinessCreateIn,
|
||||||
|
BusinessDetailOut,
|
||||||
|
BusinessItemIn,
|
||||||
|
BusinessItemOut,
|
||||||
|
BusinessOut,
|
||||||
|
BusinessOwnershipIn,
|
||||||
|
BusinessQuery,
|
||||||
|
BusinessStatusIn,
|
||||||
|
BusinessStatusLogOut,
|
||||||
|
BusinessUpdateIn,
|
||||||
|
GrossProfitCategoryOut,
|
||||||
|
GrossProfitOut,
|
||||||
|
GrossProfitRowOut,
|
||||||
|
OwnershipChangeLogOut,
|
||||||
|
)
|
||||||
|
from app.services import 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)
|
||||||
|
]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
_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("业务已有发货或回款记录,请改为已终止而非删除")
|
||||||
|
|
||||||
|
business_no = business.business_no
|
||||||
|
_hard_delete(session, business_id)
|
||||||
|
session.commit()
|
||||||
|
logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no)
|
||||||
|
|
||||||
|
|
||||||
|
def _hard_delete(session: Session, business_id: int) -> None:
|
||||||
|
"""按依赖序删除:明细 → 状态留痕 → 业务维度归属留痕 → 主表。"""
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
|
||||||
|
session.execute(delete(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id))
|
||||||
|
session.execute(
|
||||||
|
delete(OwnershipChangeLog).where(
|
||||||
|
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
|
||||||
|
OwnershipChangeLog.business_id == business_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.execute(delete(Business).where(Business.id == business_id))
|
||||||
|
|
||||||
|
|
||||||
|
def change_status(
|
||||||
|
session: Session,
|
||||||
|
business_id: int,
|
||||||
|
payload: BusinessStatusIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> BusinessOut:
|
||||||
|
"""状态流转:1→2 / 1→3,终态不可再变更;每次流转写留痕。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
:raises BadRequestError: 非执行中状态流转 / 目标与当前相同
|
||||||
|
"""
|
||||||
|
row = business_repository.get_with_names(session, business_id, for_update=True)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_BUSINESS,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=(
|
||||||
|
f"业务状态变更 {row[0].business_no if row else f'#{business_id}'} → "
|
||||||
|
f"{'已完成' if payload.status == BUSINESS_STATUS_DONE else '已终止'}"
|
||||||
|
),
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
business, _, _, _ = row
|
||||||
|
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError("已完成或已终止的业务不可再变更状态")
|
||||||
|
if business.status == payload.status:
|
||||||
|
raise BadRequestError("状态未变化")
|
||||||
|
|
||||||
|
# 变更前状态须在赋值前固化:ORM 属性赋值后 old_status 就取不到旧值
|
||||||
|
# (留痕会记成 old==new 失真,时间线无法回溯)
|
||||||
|
old_status = business.status
|
||||||
|
business.status = payload.status
|
||||||
|
business_repository.add_status_log(
|
||||||
|
session,
|
||||||
|
BusinessStatusLog(
|
||||||
|
business_id=business_id,
|
||||||
|
old_status=old_status,
|
||||||
|
new_status=payload.status,
|
||||||
|
change_reason=payload.reason,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新状态
|
||||||
|
out = _to_out(row)
|
||||||
|
session.commit()
|
||||||
|
logger.info(
|
||||||
|
"业务状态变更 | business_id=%s | %s -> %s",
|
||||||
|
business_id,
|
||||||
|
old_status,
|
||||||
|
payload.status,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def change_ownership(
|
||||||
|
session: Session,
|
||||||
|
business_id: int,
|
||||||
|
payload: BusinessOwnershipIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> BusinessOut:
|
||||||
|
"""归属变更(负责/协助):同事务「更新主表 + 插留痕」,一次 commit。
|
||||||
|
|
||||||
|
负责与协助之一发生变化即记一条留痕(前后值都含两个字段);两者均未变化
|
||||||
|
→ 400「归属未变化」。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
:raises BadRequestError: 归属人员不存在或已离职 / 归属未变化
|
||||||
|
"""
|
||||||
|
row = business_repository.get_with_names(session, business_id, for_update=True)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_BUSINESS,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"调整业务归属 {row[0].business_no if row else f'#{business_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
business, _, old_owner_name, old_assistant_name = row
|
||||||
|
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
|
||||||
|
# 固化标量,新归属人名必须按新 ID 重取
|
||||||
|
new_owner = (
|
||||||
|
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
|
||||||
|
if payload.new_owner_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
new_assistant = (
|
||||||
|
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
|
||||||
|
if payload.new_assistant_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
|
||||||
|
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
|
||||||
|
if not owner_changed and not assistant_changed:
|
||||||
|
raise BadRequestError("归属未变化")
|
||||||
|
|
||||||
|
old_owner_id = business.owner_personnel_id
|
||||||
|
old_assistant_id = business.assistant_personnel_id
|
||||||
|
business.owner_personnel_id = payload.new_owner_personnel_id
|
||||||
|
business.assistant_personnel_id = payload.new_assistant_personnel_id
|
||||||
|
business_repository.add_ownership_log(
|
||||||
|
session,
|
||||||
|
OwnershipChangeLog(
|
||||||
|
owner_type=OWNER_TYPE_BUSINESS,
|
||||||
|
business_id=business_id,
|
||||||
|
old_owner_personnel_id=old_owner_id,
|
||||||
|
new_owner_personnel_id=payload.new_owner_personnel_id,
|
||||||
|
old_assistant_personnel_id=old_assistant_id,
|
||||||
|
new_assistant_personnel_id=payload.new_assistant_personnel_id,
|
||||||
|
change_reason=payload.reason,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
|
||||||
|
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
|
||||||
|
out = _to_out(
|
||||||
|
(
|
||||||
|
business,
|
||||||
|
row[1],
|
||||||
|
new_owner.personnel_name if new_owner is not None else old_owner_name,
|
||||||
|
new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
logger.info(
|
||||||
|
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
|
||||||
|
business_id,
|
||||||
|
old_owner_id,
|
||||||
|
payload.new_owner_personnel_id,
|
||||||
|
old_assistant_id,
|
||||||
|
payload.new_assistant_personnel_id,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def list_ownership_logs(
|
||||||
|
session: Session, business_id: int, query: PageQuery
|
||||||
|
) -> PageResult[OwnershipChangeLogOut]:
|
||||||
|
"""某业务的归属变更留痕(分页)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
"""
|
||||||
|
if business_repository.get_by_id(session, business_id) is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
rows = business_repository.list_ownership_logs(
|
||||||
|
session, business_id, offset=query.offset, limit=query.page_size
|
||||||
|
)
|
||||||
|
total = business_repository.count_ownership_logs(session, business_id=business_id)
|
||||||
|
return PageResult[OwnershipChangeLogOut](
|
||||||
|
records=[_log_to_out(row) for row in rows],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_to_out(row: Row) -> OwnershipChangeLogOut:
|
||||||
|
log, old_owner_name, new_owner_name = row
|
||||||
|
return OwnershipChangeLogOut(
|
||||||
|
id=log.id,
|
||||||
|
business_id=log.business_id,
|
||||||
|
old_owner_personnel_id=log.old_owner_personnel_id,
|
||||||
|
new_owner_personnel_id=log.new_owner_personnel_id,
|
||||||
|
old_assistant_personnel_id=log.old_assistant_personnel_id,
|
||||||
|
new_assistant_personnel_id=log.new_assistant_personnel_id,
|
||||||
|
old_owner_name=old_owner_name,
|
||||||
|
new_owner_name=new_owner_name,
|
||||||
|
change_reason=log.change_reason,
|
||||||
|
operator_id=log.operator_id,
|
||||||
|
operator_name=log.operator_name,
|
||||||
|
create_time=log.create_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_status_logs(
|
||||||
|
session: Session, business_id: int, query: PageQuery
|
||||||
|
) -> PageResult[BusinessStatusLogOut]:
|
||||||
|
"""某业务的状态流转时间线(分页,时间正序)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
"""
|
||||||
|
if business_repository.get_by_id(session, business_id) is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
rows = business_repository.list_status_logs(
|
||||||
|
session, business_id, offset=query.offset, limit=query.page_size
|
||||||
|
)
|
||||||
|
total = business_repository.count_status_logs(session, business_id=business_id)
|
||||||
|
return PageResult[BusinessStatusLogOut](
|
||||||
|
records=[BusinessStatusLogOut.model_validate(row) for row in rows],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut:
|
||||||
|
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
|
||||||
|
|
||||||
|
阈值按 asOf(默认今日)取 M1 参数快照(product.threshold,按行类别维度,
|
||||||
|
存储口径为 % 数如 30,直接进 converted_profit);毛利润率按 M1 预览同一
|
||||||
|
出参口径输出 Fraction 分数字符串(如 "97/10000");成本缺失的行派生列为
|
||||||
|
None 并标注原因;汇总只聚合可计算行。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
"""
|
||||||
|
if business_repository.get_by_id(session, business_id) is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
as_of = as_of or date.today()
|
||||||
|
snapshot = param_service.resolve_snapshot(session, as_of)
|
||||||
|
|
||||||
|
def threshold_of(category: str) -> Fraction:
|
||||||
|
return snapshot.fraction("product.threshold", product_category=category)
|
||||||
|
|
||||||
|
rows_out: list[GrossProfitRowOut] = []
|
||||||
|
missing_count = 0
|
||||||
|
# 类别聚合容器:{类别: [sale, cost]},成本缺失行不计入该类别聚合
|
||||||
|
agg: dict[str, list[Decimal]] = {}
|
||||||
|
for item in business_repository.list_items(session, business_id):
|
||||||
|
threshold = threshold_of(item.product_category)
|
||||||
|
row_kwargs: dict = {
|
||||||
|
"item_id": item.id,
|
||||||
|
"product_category": item.product_category,
|
||||||
|
"product_name": item.product_name,
|
||||||
|
"spec_model": item.spec_model,
|
||||||
|
"quantity": str(item.quantity),
|
||||||
|
"sale_amount": str(item.sale_amount),
|
||||||
|
"threshold": str(threshold),
|
||||||
|
}
|
||||||
|
if item.cost_amount is None:
|
||||||
|
missing_count += 1
|
||||||
|
row_kwargs.update(
|
||||||
|
cost_amount=None,
|
||||||
|
profit_amount=None,
|
||||||
|
profit_rate=None,
|
||||||
|
converted_profit=None,
|
||||||
|
missing_reason="成本缺失,无法计算毛利润",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
profit = item.sale_amount - item.cost_amount
|
||||||
|
rate = profit_rate(profit, item.sale_amount)
|
||||||
|
conv = converted_profit(item.sale_amount, rate, threshold)
|
||||||
|
row_kwargs.update(
|
||||||
|
cost_amount=str(item.cost_amount),
|
||||||
|
profit_amount=str(profit),
|
||||||
|
profit_rate=str(rate),
|
||||||
|
converted_profit=str(conv),
|
||||||
|
missing_reason=None,
|
||||||
|
)
|
||||||
|
bucket = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
|
||||||
|
bucket[0] += item.sale_amount
|
||||||
|
bucket[1] += item.cost_amount
|
||||||
|
rows_out.append(GrossProfitRowOut(**row_kwargs))
|
||||||
|
|
||||||
|
categories = [
|
||||||
|
GrossProfitCategoryOut(
|
||||||
|
product_category=category,
|
||||||
|
sale_amount=str(sale),
|
||||||
|
cost_amount=str(cost),
|
||||||
|
profit_amount=str(sale - cost),
|
||||||
|
profit_rate=str(profit_rate(sale - cost, sale)),
|
||||||
|
threshold=str(threshold_of(category)),
|
||||||
|
converted_profit=str(
|
||||||
|
converted_profit(sale, profit_rate(sale - cost, sale), threshold_of(category))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for category, [sale, cost] in agg.items()
|
||||||
|
]
|
||||||
|
return GrossProfitOut(
|
||||||
|
business_id=business_id,
|
||||||
|
as_of=as_of,
|
||||||
|
rows=rows_out,
|
||||||
|
categories=categories,
|
||||||
|
missing_count=missing_count,
|
||||||
|
)
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""客户管理业务编排(需求 2.1.3)。
|
||||||
|
|
||||||
|
**事务边界在本层**:repository 只 flush,commit/rollback 只写在这里。
|
||||||
|
|
||||||
|
- 「公司名称 + 负责人」组合唯一(uk_customers_company_owner):先查后写不是
|
||||||
|
原子操作,并发提交撞唯一键时捕 IntegrityError 判 1062 翻译为 400
|
||||||
|
(同 M1 的并发兜底模式)
|
||||||
|
- **归属变更的裁决语义是「只影响变更后核算,不回溯历史」(对照表 #19)**:
|
||||||
|
实现为同一事务内「更新 customers.owner_personnel_id + 插入一条
|
||||||
|
ownership_change_logs」,一次 commit —— 两步拆开提交时,中途失败会出现
|
||||||
|
「主表已换人、留痕缺失」的静默不一致,事后追溯无据可查
|
||||||
|
- 归属调整只有 ``change_ownership`` 一条路径;编辑端点不触归属,
|
||||||
|
防止出现「改归属不留痕」的旁路
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import Row
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
ACTION_DELETE,
|
||||||
|
ACTION_UPDATE,
|
||||||
|
CUSTOMER_TYPES,
|
||||||
|
MODULE_CUSTOMER,
|
||||||
|
)
|
||||||
|
from app.core.db_errors import is_duplicate_entry
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.core.response import PageQuery, PageResult
|
||||||
|
from app.models import Customer, OwnershipChangeLog, Personnel, User
|
||||||
|
from app.models.customer import OWNER_TYPE_CUSTOMER
|
||||||
|
from app.repositories import customer_repository, personnel_repository
|
||||||
|
from app.schemas.customer import (
|
||||||
|
CustomerCreateIn,
|
||||||
|
CustomerOut,
|
||||||
|
CustomerOwnershipIn,
|
||||||
|
CustomerQuery,
|
||||||
|
CustomerUpdateIn,
|
||||||
|
OwnershipChangeLogOut,
|
||||||
|
)
|
||||||
|
from app.services import audit_service, personnel_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
|
||||||
|
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
|
||||||
|
return CustomerOut(
|
||||||
|
id=customer.id,
|
||||||
|
company_name=customer.company_name,
|
||||||
|
owner_name=customer.owner_name,
|
||||||
|
contact_person=customer.contact_person,
|
||||||
|
contact_phone=customer.contact_phone,
|
||||||
|
address=customer.address,
|
||||||
|
industry=customer.industry,
|
||||||
|
customer_type=customer.customer_type,
|
||||||
|
owner_personnel_id=customer.owner_personnel_id,
|
||||||
|
owner_personnel_name=owner_name,
|
||||||
|
remark=customer.remark,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_log_out(row: Row) -> OwnershipChangeLogOut:
|
||||||
|
"""留痕行 (留痕, 旧归属人名, 新归属人名) 转响应模型。"""
|
||||||
|
log, old_name, new_name = row
|
||||||
|
return OwnershipChangeLogOut(
|
||||||
|
id=log.id,
|
||||||
|
customer_id=log.customer_id,
|
||||||
|
old_owner_personnel_id=log.old_owner_personnel_id,
|
||||||
|
new_owner_personnel_id=log.new_owner_personnel_id,
|
||||||
|
old_owner_name=old_name,
|
||||||
|
new_owner_name=new_name,
|
||||||
|
change_reason=log.change_reason,
|
||||||
|
operator_id=log.operator_id,
|
||||||
|
operator_name=log.operator_name,
|
||||||
|
create_time=log.create_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_customer_type(customer_type: str) -> None:
|
||||||
|
"""客户分类必须是注册表内的三个值之一(前端为受控下拉,后端仍须校验)。
|
||||||
|
|
||||||
|
customer_type 是 M3 计提规则匹配的锚点,脏值会让该客户在核算时匹配不到
|
||||||
|
任何计提规则而静默漏算。
|
||||||
|
"""
|
||||||
|
if customer_type not in CUSTOMER_TYPES:
|
||||||
|
raise BadRequestError("客户分类不合法")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_owner_personnel(session: Session, personnel_id: int) -> Personnel:
|
||||||
|
"""归属人员必须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
|
||||||
|
return personnel_service.ensure_active(session, personnel_id, label="归属")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||||
|
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(1062)。
|
||||||
|
|
||||||
|
customers 上还有归属人员外键(RESTRICT),并发窗口内删除人员等场景
|
||||||
|
会抛 1452 —— 不能一并翻译成「已存在」,否则用户会被错误提示带偏;
|
||||||
|
非 1062 原样上抛,由全局 IntegrityError handler 收敛 400(通用文案)。
|
||||||
|
"""
|
||||||
|
return is_duplicate_entry(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
|
||||||
|
"""分页查询客户(附归属人员姓名)。"""
|
||||||
|
rows = customer_repository.list_customers(
|
||||||
|
session,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
keyword=query.keyword,
|
||||||
|
customer_type=query.customer_type,
|
||||||
|
owner_personnel_id=query.owner_personnel_id,
|
||||||
|
)
|
||||||
|
total = customer_repository.count_customers(
|
||||||
|
session,
|
||||||
|
keyword=query.keyword,
|
||||||
|
customer_type=query.customer_type,
|
||||||
|
owner_personnel_id=query.owner_personnel_id,
|
||||||
|
)
|
||||||
|
return PageResult[CustomerOut](
|
||||||
|
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_customer(session: Session, customer_id: int) -> CustomerOut:
|
||||||
|
"""客户详情。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
"""
|
||||||
|
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
customer, owner_name = row
|
||||||
|
return _to_out(customer, owner_name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_customer(
|
||||||
|
session: Session,
|
||||||
|
payload: CustomerCreateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> CustomerOut:
|
||||||
|
"""新增客户。
|
||||||
|
|
||||||
|
:raises BadRequestError: 客户分类不合法 / 归属人员不存在或已离职 /
|
||||||
|
「公司名称+负责人」已存在(含并发兜底)
|
||||||
|
"""
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_CUSTOMER,
|
||||||
|
action=ACTION_CREATE,
|
||||||
|
summary=f"新增客户 {payload.company_name}({payload.owner_name})",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 值域与归属校验在审计上下文内:失败(400)也要留痕 ——
|
||||||
|
# 与 personnel_service._validate_refs 移入上下文的口径一致
|
||||||
|
_ensure_customer_type(payload.customer_type)
|
||||||
|
owner_name: str | None = None
|
||||||
|
if payload.owner_personnel_id is not None:
|
||||||
|
owner_name = _ensure_owner_personnel(session, payload.owner_personnel_id).personnel_name
|
||||||
|
if (
|
||||||
|
customer_repository.get_by_company_owner(
|
||||||
|
session, payload.company_name, payload.owner_name
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
raise BadRequestError("该公司名称与负责人的客户已存在")
|
||||||
|
|
||||||
|
customer = Customer(
|
||||||
|
company_name=payload.company_name,
|
||||||
|
owner_name=payload.owner_name,
|
||||||
|
contact_person=payload.contact_person,
|
||||||
|
contact_phone=payload.contact_phone,
|
||||||
|
address=payload.address,
|
||||||
|
industry=payload.industry,
|
||||||
|
customer_type=payload.customer_type,
|
||||||
|
owner_personnel_id=payload.owner_personnel_id,
|
||||||
|
remark=payload.remark,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
customer_repository.create(session, customer)
|
||||||
|
# 响应在 commit 之前构造(同 user_service 顶部说明)
|
||||||
|
out = _to_out(customer, owner_name)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 先查后写不是原子操作:并发提交同一「公司名称+负责人」会撞
|
||||||
|
# uk_customers_company_owner,回滚并翻译为可读的 400,而非 500
|
||||||
|
session.rollback()
|
||||||
|
if _is_duplicate_entry(exc):
|
||||||
|
raise BadRequestError("该公司名称与负责人的客户已存在") from exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("新增客户 | customer_id=%s | company=%s", customer.id, payload.company_name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def update_customer(
|
||||||
|
session: Session,
|
||||||
|
customer_id: int,
|
||||||
|
payload: CustomerUpdateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> CustomerOut:
|
||||||
|
"""编辑客户档案(全量替换)。
|
||||||
|
|
||||||
|
**不触归属**:owner_personnel_id 保持原值,调整统一走
|
||||||
|
``PUT /{id}/ownership``(唯一写留痕的路径),见 ``CustomerUpdateIn`` 说明。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
:raises BadRequestError: 客户分类不合法 / 「公司名称+负责人」已存在(含并发兜底)
|
||||||
|
"""
|
||||||
|
# 只读预取在审计上下文之前:目标不存在(NotFound 失败)时也能留下可读记录,
|
||||||
|
# 摘要与回包都要用客户行;归属姓名本端点不变更,一并带出
|
||||||
|
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||||
|
customer = row[0] if row else None
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_CUSTOMER,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"修改客户 {customer.company_name if customer else f'#{customer_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 值域校验在审计上下文内:分类非法的 400 也要留痕(同 create 口径)
|
||||||
|
_ensure_customer_type(payload.customer_type)
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
customer, owner_name = row
|
||||||
|
|
||||||
|
duplicate = customer_repository.get_by_company_owner(
|
||||||
|
session, payload.company_name, payload.owner_name
|
||||||
|
)
|
||||||
|
if duplicate is not None and duplicate.id != customer_id:
|
||||||
|
raise BadRequestError("该公司名称与负责人的客户已存在")
|
||||||
|
|
||||||
|
customer.company_name = payload.company_name
|
||||||
|
customer.owner_name = payload.owner_name
|
||||||
|
customer.contact_person = payload.contact_person
|
||||||
|
customer.contact_phone = payload.contact_phone
|
||||||
|
customer.address = payload.address
|
||||||
|
customer.industry = payload.industry
|
||||||
|
customer.customer_type = payload.customer_type
|
||||||
|
# owner_personnel_id 刻意不赋值 —— 归属调整只走 /ownership
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 响应在 commit 之前构造(同 user_service 顶部说明)
|
||||||
|
out = _to_out(customer, owner_name)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写
|
||||||
|
# 的并发窗口会撞唯一键,兜底同 create_customer
|
||||||
|
session.rollback()
|
||||||
|
if _is_duplicate_entry(exc):
|
||||||
|
raise BadRequestError("该公司名称与负责人的客户已存在") from exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("修改客户 | customer_id=%s | company=%s", customer_id, payload.company_name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_customer(
|
||||||
|
session: Session,
|
||||||
|
customer_id: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""删除客户(物理删除):被业务引用 → 400 引导处理业务单据。
|
||||||
|
|
||||||
|
businesses.customer_id 为 RESTRICT 外键,引用检查必须先于删除——
|
||||||
|
否则并发窗口内的 FK 冲突会以 500 暴露(T4 时该检查占位、业务表就位
|
||||||
|
后由本处补齐,留痕表是无外键固化不参与拦截)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
:raises BadRequestError: 客户已被业务单据引用
|
||||||
|
"""
|
||||||
|
customer = customer_repository.get_by_id(session, customer_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_CUSTOMER,
|
||||||
|
action=ACTION_DELETE,
|
||||||
|
summary=f"删除客户 {customer.company_name if customer else f'#{customer_id}'}",
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if customer is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
if customer_repository.count_business_refs(session, customer_id):
|
||||||
|
raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据")
|
||||||
|
|
||||||
|
company_name = customer.company_name # 固化:对象随删除失效
|
||||||
|
try:
|
||||||
|
customer_repository.delete(session, customer)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 先查后删的并发窗口:新业务挂上了该客户,FK RESTRICT 兜底为 400
|
||||||
|
session.rollback()
|
||||||
|
raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据") from exc
|
||||||
|
logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name)
|
||||||
|
|
||||||
|
|
||||||
|
def change_ownership(
|
||||||
|
session: Session,
|
||||||
|
customer_id: int,
|
||||||
|
payload: CustomerOwnershipIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> CustomerOut:
|
||||||
|
"""归属变更:同一事务内「改主表归属 + 插留痕」,一次 commit。
|
||||||
|
|
||||||
|
裁决口径为「只影响变更后核算,不回溯历史」(对照表 #19):历史核算结果
|
||||||
|
已由 M3 台账固化,留痕仅承担追溯职责,故无 effective_date 时点化设计。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
:raises BadRequestError: 归属人员不存在或已离职 / 归属未变化
|
||||||
|
"""
|
||||||
|
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
|
||||||
|
# 上下文内进行,失败同样要留下可追溯的记录
|
||||||
|
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||||
|
old_person = (
|
||||||
|
personnel_repository.get_by_id(session, row[0].owner_personnel_id)
|
||||||
|
if row is not None and row[0].owner_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
new_person = (
|
||||||
|
personnel_repository.get_by_id(session, payload.new_owner_personnel_id)
|
||||||
|
if payload.new_owner_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
old_label = old_person.personnel_name if old_person else "无"
|
||||||
|
new_label = new_person.personnel_name if new_person else "无"
|
||||||
|
customer_name = row[0].company_name if row else f"#{customer_id}"
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_CUSTOMER,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"调整客户归属 {customer_name}:{old_label} → {new_label}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
customer, _ = row
|
||||||
|
|
||||||
|
if payload.new_owner_personnel_id is not None:
|
||||||
|
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
|
||||||
|
old_owner_id = customer.owner_personnel_id
|
||||||
|
if old_owner_id == payload.new_owner_personnel_id:
|
||||||
|
raise BadRequestError("归属未变化")
|
||||||
|
|
||||||
|
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
|
||||||
|
customer.owner_personnel_id = payload.new_owner_personnel_id
|
||||||
|
customer_repository.add_ownership_log(
|
||||||
|
session,
|
||||||
|
OwnershipChangeLog(
|
||||||
|
owner_type=OWNER_TYPE_CUSTOMER,
|
||||||
|
customer_id=customer.id,
|
||||||
|
old_owner_personnel_id=old_owner_id,
|
||||||
|
new_owner_personnel_id=payload.new_owner_personnel_id,
|
||||||
|
change_reason=payload.reason,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
|
||||||
|
out = _to_out(customer, new_person.personnel_name if new_person else None)
|
||||||
|
session.commit()
|
||||||
|
logger.info(
|
||||||
|
"调整客户归属 | customer_id=%s | old=%s | new=%s",
|
||||||
|
customer_id,
|
||||||
|
old_owner_id,
|
||||||
|
payload.new_owner_personnel_id,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def list_ownership_logs(
|
||||||
|
session: Session,
|
||||||
|
customer_id: int,
|
||||||
|
query: PageQuery,
|
||||||
|
) -> PageResult[OwnershipChangeLogOut]:
|
||||||
|
"""某客户的归属变更留痕(分页,按变更时间倒序)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
"""
|
||||||
|
if customer_repository.get_by_id(session, customer_id) is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
rows = customer_repository.list_ownership_logs(
|
||||||
|
session,
|
||||||
|
customer_id=customer_id,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
)
|
||||||
|
total = customer_repository.count_ownership_logs(session, customer_id=customer_id)
|
||||||
|
return PageResult[OwnershipChangeLogOut](
|
||||||
|
records=[_to_log_out(row) for row in rows],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""计提公式纯函数(无 IO、无会话)。
|
||||||
|
|
||||||
|
契约(与 M3 核算、计算预览共用,口径见 2026-10-12 业务裁决):
|
||||||
|
- 参数值按**存储口径**传入(含单位:比例参数为 ‰ 数如 10、百分比参数为 % 数如 60)
|
||||||
|
- 计算出的中间比率(如毛利润率)为**纯比例**(Fraction,如 9/20)
|
||||||
|
- 金额用 Decimal、比率用 Fraction,全程禁 float;Fraction→Decimal 转换在
|
||||||
|
分母仅含 2/5 因子时精确,否则按 Decimal 上下文精度(28 位)
|
||||||
|
- 绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3,禁按"互为倒数"实现)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from fractions import Fraction
|
||||||
|
|
||||||
|
|
||||||
|
def to_decimal(value: Fraction) -> Decimal:
|
||||||
|
"""Fraction → Decimal。有限小数精确;循环小数按 Decimal 上下文精度(28 位)。"""
|
||||||
|
return Decimal(value.numerator) / Decimal(value.denominator)
|
||||||
|
|
||||||
|
|
||||||
|
# 模块内沿用私有名;公开别名供 service 层金额换算复用
|
||||||
|
_to_decimal = to_decimal
|
||||||
|
|
||||||
|
|
||||||
|
def payment_days(ship_date: date, receipt_date: date) -> int:
|
||||||
|
"""回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。"""
|
||||||
|
return (receipt_date - ship_date).days + 1
|
||||||
|
|
||||||
|
|
||||||
|
def salesperson_rate(
|
||||||
|
days: int,
|
||||||
|
*,
|
||||||
|
start: Fraction,
|
||||||
|
slope: Fraction,
|
||||||
|
floor: Fraction,
|
||||||
|
boundary_days: int,
|
||||||
|
) -> Fraction:
|
||||||
|
"""业务员计提比例(纯比例)。
|
||||||
|
|
||||||
|
start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。
|
||||||
|
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
|
||||||
|
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
|
||||||
|
|
||||||
|
先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出
|
||||||
|
高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days
|
||||||
|
的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。
|
||||||
|
"""
|
||||||
|
days = max(days, 0)
|
||||||
|
if days > boundary_days:
|
||||||
|
return floor / 1000
|
||||||
|
return (start - days * slope) / 1000
|
||||||
|
|
||||||
|
|
||||||
|
def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction:
|
||||||
|
"""毛利润率(纯比例):毛利润额 / 销售额(**不含运费**口径,对照表 #2)。
|
||||||
|
|
||||||
|
销售额为 0 时返回 0(防除零;折算额随之为 0)。
|
||||||
|
"""
|
||||||
|
if revenue == 0:
|
||||||
|
return Fraction(0)
|
||||||
|
return Fraction(profit) / Fraction(revenue)
|
||||||
|
|
||||||
|
|
||||||
|
def converted_profit(revenue: Decimal, rate: Fraction, threshold: Fraction) -> Decimal:
|
||||||
|
"""折算毛利润额(化简式):销售额 × (毛利润率 − 阈值)。
|
||||||
|
|
||||||
|
rate 为纯比例(profit_rate 的输出);threshold 为 % 数(存储口径,如 30)。
|
||||||
|
化简式等价于定义式「毛利润额 / 毛利润率 × (毛利润率 − 阈值)」,且避免
|
||||||
|
毛利润率为 0 时除零(对照表 #12/#13)。毛利润率低于阈值时结果为负 ——
|
||||||
|
业务确认负值正常计算、不归零。
|
||||||
|
"""
|
||||||
|
return _to_decimal(Fraction(revenue) * (rate - threshold / 100))
|
||||||
|
|
||||||
|
|
||||||
|
def performance_amount(base: Decimal, rate: Fraction) -> Decimal:
|
||||||
|
"""绩效金额 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。"""
|
||||||
|
return _to_decimal(Fraction(base) * rate)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_barter(amount: Decimal, discount: Fraction) -> Decimal:
|
||||||
|
"""以物抵顶折算:应发绩效 × 折算比例(discount 为 % 数,默认 80,对照表 #24)。"""
|
||||||
|
return _to_decimal(Fraction(amount) * discount / 100)
|
||||||
|
|
||||||
|
|
||||||
|
def payment_weight(r: int) -> Fraction:
|
||||||
|
"""支付权重 R×R(纯比例):R 为回款占销售额百分比向下取整(对照表 #22)。
|
||||||
|
|
||||||
|
60 → 36/100;100 → 1(全额)。
|
||||||
|
"""
|
||||||
|
return Fraction(r, 100) * Fraction(r, 100)
|
||||||
|
|
||||||
|
|
||||||
|
def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bool:
|
||||||
|
"""起付判定:单项业务回款额达到销售额(不含运费)的 ratio%(对照表 #21)。"""
|
||||||
|
if revenue == 0:
|
||||||
|
return False
|
||||||
|
return Fraction(received) / Fraction(revenue) >= ratio / 100
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""单据编号生成器(业务 YW / 发货 SH / 回款 HK 共用)。
|
||||||
|
|
||||||
|
规则:``{前缀}{YYYYMMDD}-{当日4位序号}``,序号按自然日重置。
|
||||||
|
并发安全:``SELECT MAX(no) ... FOR UPDATE`` 锁住当日序号区间 —— 后到的
|
||||||
|
事务会等待先到者提交后再读到新 MAX,序号单调不重;残余竞态(锁等待超时
|
||||||
|
等极端场景)由服务层捕唯一键冲突(1062)重试一次兜底。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
def today_prefix(prefix: str) -> str:
|
||||||
|
"""当日编号前缀(如 YW20261013-),LIKE 匹配与拼号共用同一来源防漂移。"""
|
||||||
|
return f"{prefix}{datetime.now().strftime('%Y%m%d')}-"
|
||||||
|
|
||||||
|
|
||||||
|
def next_no(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
model: type,
|
||||||
|
column_name: str,
|
||||||
|
prefix: str,
|
||||||
|
seq_width: int = 4,
|
||||||
|
) -> str:
|
||||||
|
"""生成下一个编号。
|
||||||
|
|
||||||
|
:param model: 编号所在的 ORM 模型(businesses/shipments/receipts)
|
||||||
|
:param column_name: 编号列名(business_no/shipment_no/receipt_batch_no)
|
||||||
|
:param prefix: 单据前缀(NO_PREFIX_* 常量)
|
||||||
|
:param seq_width: 序号位数(默认 4);超出宽度的序号按实际位数输出,
|
||||||
|
VARCHAR(32) 余量充足、唯一性不受影响
|
||||||
|
"""
|
||||||
|
like_prefix = today_prefix(prefix)
|
||||||
|
column = getattr(model, column_name)
|
||||||
|
# 排序按「长度优先、同级字典序」:同前缀下序号位数增多(9999→10000)后
|
||||||
|
# 纯字典序会反转("-10000" < "-9999",'1'<'9'),seq 将恒重算出同一号码、
|
||||||
|
# 每笔建单都撞唯一键 —— 长度大即数值大,同级同长时字典序与数值序一致
|
||||||
|
max_no = session.execute(
|
||||||
|
select(column)
|
||||||
|
.where(column.like(f"{like_prefix}%"))
|
||||||
|
.order_by(func.length(column).desc(), column.desc())
|
||||||
|
.limit(1)
|
||||||
|
.with_for_update()
|
||||||
|
).scalar()
|
||||||
|
seq = int(max_no.rsplit("-", 1)[1]) + 1 if max_no else 1
|
||||||
|
return f"{like_prefix}{seq:0{seq_width}d}"
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"""计提参数目录注册表。
|
||||||
|
|
||||||
|
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
|
||||||
|
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
|
||||||
|
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from fractions import Fraction
|
||||||
|
|
||||||
|
from app.core.exceptions import BadRequestError
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParamSpec:
|
||||||
|
"""单个参数的元数据(与 param_definitions 表字段一一对应)。"""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
group: str
|
||||||
|
value_type: str
|
||||||
|
unit: str | None
|
||||||
|
dimension_type: str
|
||||||
|
options: tuple[str, ...] | None
|
||||||
|
default_value: str | None
|
||||||
|
value_min: str | None
|
||||||
|
value_max: str | None
|
||||||
|
description: str | None
|
||||||
|
sort_order: int
|
||||||
|
|
||||||
|
|
||||||
|
PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||||
|
spec.code: spec
|
||||||
|
for spec in [
|
||||||
|
ParamSpec(
|
||||||
|
code="salesperson.rate.start",
|
||||||
|
name="业务员起始计提比例",
|
||||||
|
group="业务员个人户",
|
||||||
|
value_type="DECIMAL",
|
||||||
|
unit="‰",
|
||||||
|
dimension_type="BUSINESS_ROLE",
|
||||||
|
options=None,
|
||||||
|
default_value="10",
|
||||||
|
value_min="0",
|
||||||
|
value_max=None,
|
||||||
|
description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
|
||||||
|
sort_order=1,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="salesperson.rate.slope",
|
||||||
|
name="业务员日递减斜率",
|
||||||
|
group="业务员个人户",
|
||||||
|
value_type="DECIMAL",
|
||||||
|
unit="‰/天",
|
||||||
|
dimension_type="BUSINESS_ROLE",
|
||||||
|
options=None,
|
||||||
|
default_value="4/360",
|
||||||
|
value_min="0",
|
||||||
|
value_max=None,
|
||||||
|
description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
|
||||||
|
sort_order=2,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="salesperson.rate.floor",
|
||||||
|
name="业务员计提下限",
|
||||||
|
group="业务员个人户",
|
||||||
|
value_type="DECIMAL",
|
||||||
|
unit="‰",
|
||||||
|
dimension_type="BUSINESS_ROLE",
|
||||||
|
options=None,
|
||||||
|
default_value="4",
|
||||||
|
value_min="0",
|
||||||
|
value_max=None,
|
||||||
|
description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
|
||||||
|
sort_order=3,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="salesperson.days.boundary",
|
||||||
|
name="线性递减分界天数",
|
||||||
|
group="业务员个人户",
|
||||||
|
value_type="INT",
|
||||||
|
unit="天",
|
||||||
|
dimension_type="BUSINESS_ROLE",
|
||||||
|
options=None,
|
||||||
|
default_value="360",
|
||||||
|
value_min="0",
|
||||||
|
value_max=None,
|
||||||
|
description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
|
||||||
|
sort_order=4,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="manager.share.team",
|
||||||
|
name="经理本团队分成比例",
|
||||||
|
group="经理分成",
|
||||||
|
value_type="DECIMAL",
|
||||||
|
unit="%",
|
||||||
|
dimension_type="BUSINESS_ROLE",
|
||||||
|
options=None,
|
||||||
|
default_value="12.5",
|
||||||
|
value_min="0",
|
||||||
|
value_max="100",
|
||||||
|
description="∑本人带领业务员绩效 × 此比例(限本团队)",
|
||||||
|
sort_order=5,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="manager.share.company",
|
||||||
|
name="经理全公司分成比例",
|
||||||
|
group="经理分成",
|
||||||
|
value_type="DECIMAL",
|
||||||
|
unit="%",
|
||||||
|
dimension_type="BUSINESS_ROLE",
|
||||||
|
options=None,
|
||||||
|
default_value="5",
|
||||||
|
value_min="0",
|
||||||
|
value_max="100",
|
||||||
|
description="∑全部业务员绩效 × 此比例(全公司)",
|
||||||
|
sort_order=6,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="enterprise.rate",
|
||||||
|
name="企业户计提比例",
|
||||||
|
group="企业户",
|
||||||
|
value_type="DECIMAL",
|
||||||
|
unit="‰",
|
||||||
|
dimension_type="PRODUCT_CATEGORY",
|
||||||
|
options=None,
|
||||||
|
default_value="3",
|
||||||
|
value_min="0",
|
||||||
|
value_max=None,
|
||||||
|
description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
|
||||||
|
sort_order=7,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="info.rate",
|
||||||
|
name="信息员计提比例",
|
||||||
|
group="信息员",
|
||||||
|
value_type="DECIMAL",
|
||||||
|
unit="‰",
|
||||||
|
dimension_type="BUSINESS_ROLE",
|
||||||
|
options=None,
|
||||||
|
default_value="1",
|
||||||
|
value_min="0",
|
||||||
|
value_max=None,
|
||||||
|
description="信息员绩效 = 本人销售额 × 此比例",
|
||||||
|
sort_order=8,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="prepay.rate",
|
||||||
|
name="预付款计提比例",
|
||||||
|
group="支付环节",
|
||||||
|
value_type="DECIMAL",
|
||||||
|
unit="‰",
|
||||||
|
dimension_type="NONE",
|
||||||
|
options=None,
|
||||||
|
default_value="10",
|
||||||
|
value_min="0",
|
||||||
|
value_max=None,
|
||||||
|
description="预付款统一按此比例计提",
|
||||||
|
sort_order=9,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="barter.discount",
|
||||||
|
name="以物抵顶折算比例",
|
||||||
|
group="支付环节",
|
||||||
|
value_type="DECIMAL",
|
||||||
|
unit="%",
|
||||||
|
dimension_type="NONE",
|
||||||
|
options=None,
|
||||||
|
default_value="80",
|
||||||
|
value_min="0",
|
||||||
|
value_max="100",
|
||||||
|
description="以物抵顶回款绩效按应发绩效的此比例折算",
|
||||||
|
sort_order=10,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="payment.start.ratio",
|
||||||
|
name="支付起付比例",
|
||||||
|
group="支付环节",
|
||||||
|
value_type="DECIMAL",
|
||||||
|
unit="%",
|
||||||
|
dimension_type="NONE",
|
||||||
|
options=None,
|
||||||
|
default_value="60",
|
||||||
|
value_min="0",
|
||||||
|
value_max="100",
|
||||||
|
description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
|
||||||
|
sort_order=11,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="payment.weight.mode",
|
||||||
|
name="支付权重应用方式",
|
||||||
|
group="支付环节",
|
||||||
|
value_type="ENUM",
|
||||||
|
unit=None,
|
||||||
|
dimension_type="NONE",
|
||||||
|
options=("FULL", "BALANCE"),
|
||||||
|
default_value="FULL",
|
||||||
|
value_min=None,
|
||||||
|
value_max=None,
|
||||||
|
description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
|
||||||
|
sort_order=12,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="product.threshold",
|
||||||
|
name="折算毛利润额阈值",
|
||||||
|
group="产品阈值",
|
||||||
|
value_type="DECIMAL",
|
||||||
|
unit="%",
|
||||||
|
dimension_type="PRODUCT_CATEGORY",
|
||||||
|
options=None,
|
||||||
|
default_value="30",
|
||||||
|
value_min="0",
|
||||||
|
value_max="100",
|
||||||
|
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
|
||||||
|
sort_order=13,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="salary.basic.mode",
|
||||||
|
name="月工资基本工资口径",
|
||||||
|
group="月工资口径",
|
||||||
|
value_type="ENUM",
|
||||||
|
unit=None,
|
||||||
|
dimension_type="BUSINESS_ROLE",
|
||||||
|
options=("INCLUDE", "DEDUCT", "EXCLUDE"),
|
||||||
|
default_value="INCLUDE",
|
||||||
|
value_min=None,
|
||||||
|
value_max=None,
|
||||||
|
description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
|
||||||
|
sort_order=14,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="probation.annual.threshold",
|
||||||
|
name="试用期年累计结算阈值",
|
||||||
|
group="月工资口径",
|
||||||
|
value_type="DECIMAL",
|
||||||
|
unit="元",
|
||||||
|
dimension_type="NONE",
|
||||||
|
options=None,
|
||||||
|
default_value="18000",
|
||||||
|
value_min="0",
|
||||||
|
value_max=None,
|
||||||
|
description="试用期年累计绩效超过此值部分年底一次性结算",
|
||||||
|
sort_order=15,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="probation.salary.basic",
|
||||||
|
name="试用期基本工资标准",
|
||||||
|
group="月工资口径",
|
||||||
|
value_type="DECIMAL",
|
||||||
|
unit="元",
|
||||||
|
dimension_type="NONE",
|
||||||
|
options=None,
|
||||||
|
default_value="3000",
|
||||||
|
value_min="0",
|
||||||
|
value_max=None,
|
||||||
|
description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
|
||||||
|
sort_order=16,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_spec(code: str) -> ParamSpec:
|
||||||
|
"""取参数元数据。未注册编码属代码缺陷(500),不是用户错误。"""
|
||||||
|
spec = PARAM_REGISTRY.get(code)
|
||||||
|
if spec is None:
|
||||||
|
raise ValueError(f"参数编码未注册:{code}")
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
def parse_fraction(text: str) -> Fraction:
|
||||||
|
"""Fraction 精确解析("4/360" → 1/90,不经 float)。非法输入抛 400。"""
|
||||||
|
try:
|
||||||
|
return Fraction(text.strip())
|
||||||
|
except (ValueError, ZeroDivisionError) as exc:
|
||||||
|
raise BadRequestError(f"参数值无法解析为数值:{text}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def validate_and_normalize(spec: ParamSpec, raw: str) -> str:
|
||||||
|
"""按元数据校验并规范化参数值,返回可入库字符串。
|
||||||
|
|
||||||
|
DECIMAL:Fraction 解析 + 取值域校验,分数式原样保留("4/360" 不截断);
|
||||||
|
INT:必须整数;BOOL:仅 0/1;ENUM:必须命中 options;TEXT:原样返回。
|
||||||
|
"""
|
||||||
|
value = raw.strip()
|
||||||
|
if spec.value_type in {"DECIMAL", "INT"}:
|
||||||
|
fraction = parse_fraction(value)
|
||||||
|
if spec.value_type == "INT" and fraction.denominator != 1:
|
||||||
|
raise BadRequestError(f"参数 {spec.code} 必须为整数:{raw}")
|
||||||
|
if spec.value_min is not None and fraction < parse_fraction(spec.value_min):
|
||||||
|
raise BadRequestError(f"参数 {spec.code} 低于取值下限 {spec.value_min}:{raw}")
|
||||||
|
if spec.value_max is not None and fraction > parse_fraction(spec.value_max):
|
||||||
|
raise BadRequestError(f"参数 {spec.code} 高于取值上限 {spec.value_max}:{raw}")
|
||||||
|
return value
|
||||||
|
if spec.value_type == "BOOL":
|
||||||
|
if value not in {"0", "1"}:
|
||||||
|
raise BadRequestError(f"参数 {spec.code} 仅接受 0/1:{raw}")
|
||||||
|
return value
|
||||||
|
if spec.value_type == "ENUM":
|
||||||
|
if not spec.options or value not in spec.options:
|
||||||
|
allowed = "/".join(spec.options or ())
|
||||||
|
raise BadRequestError(f"参数 {spec.code} 取值须为 {allowed}:{raw}")
|
||||||
|
return value
|
||||||
|
return value
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
"""计提参数服务:时点化取值、版本管理(变更/撤回/历史/对比)、计算预览与导出。
|
||||||
|
|
||||||
|
ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日期(回款到账日)
|
||||||
|
冻结当时口径,禁读「当前值」计算历史数据(算法红线)。
|
||||||
|
变更批次 = 版本(一次提交一批次);条目逐项留痕(旧值/新值),append-only。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from fractions import Fraction
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_DELETE,
|
||||||
|
ACTION_UPDATE,
|
||||||
|
MODULE_PARAM,
|
||||||
|
NO_PREFIX_PARAM,
|
||||||
|
PRODUCT_CATEGORIES,
|
||||||
|
)
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.core.response import PageResult
|
||||||
|
from app.models import ParamChangeBatch, ParamDefinition, ParamValue, User
|
||||||
|
from app.repositories import business_role_repository, param_repository
|
||||||
|
from app.schemas.param import (
|
||||||
|
ParamBatchDetailOut,
|
||||||
|
ParamBatchItemOut,
|
||||||
|
ParamBatchOut,
|
||||||
|
ParamBatchQuery,
|
||||||
|
ParamCatalogItemOut,
|
||||||
|
ParamChangeBatchOut,
|
||||||
|
ParamChangeIn,
|
||||||
|
ParamChangeItemIn,
|
||||||
|
ParamDiffOut,
|
||||||
|
ParamDimensionValueOut,
|
||||||
|
ParamHistoryOut,
|
||||||
|
ParamHistoryQuery,
|
||||||
|
ParamPreviewIn,
|
||||||
|
ParamPreviewOut,
|
||||||
|
ParamUsedOut,
|
||||||
|
)
|
||||||
|
from app.services import audit_service, formula
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
from app.services.formula import to_decimal
|
||||||
|
from app.services.no_generator import next_no
|
||||||
|
from app.services.param_registry import get_spec, parse_fraction, validate_and_normalize
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 快照 ====================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParamSnapshot:
|
||||||
|
"""某业务日期下的参数快照(冻结不可变,核算期间保持一致)。
|
||||||
|
|
||||||
|
覆盖链:角色维度 > 产品类别维度 > 默认行('');均无匹配时回退注册表默认值。
|
||||||
|
"""
|
||||||
|
|
||||||
|
biz_date: date
|
||||||
|
_values: dict[str, dict[str, str]]
|
||||||
|
_effective: dict[tuple[str, str], date]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_rows(
|
||||||
|
cls, biz_date: date, rows: Iterable[tuple[str, str, str, date]]
|
||||||
|
) -> "ParamSnapshot":
|
||||||
|
"""由 (param_code, dimension_value, value_text, effective_date) 行构造。
|
||||||
|
|
||||||
|
rows 须按仓储输出顺序(同一 (code, dim) 的最新生效值在前)——以先出现者为准。
|
||||||
|
"""
|
||||||
|
values: dict[str, dict[str, str]] = {}
|
||||||
|
effective: dict[tuple[str, str], date] = {}
|
||||||
|
for code, dimension, value_text, effective_date in rows:
|
||||||
|
if (code, dimension) in effective:
|
||||||
|
continue
|
||||||
|
effective[(code, dimension)] = effective_date
|
||||||
|
values.setdefault(code, {}).setdefault(dimension, value_text)
|
||||||
|
return cls(biz_date=biz_date, _values=values, _effective=effective)
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self, code: str, *, role_code: str | None = None, product_category: str | None = None
|
||||||
|
) -> str:
|
||||||
|
"""取参数原始值(覆盖链解析)。无值且无注册表默认值时抛 400。"""
|
||||||
|
candidates = self._values.get(code)
|
||||||
|
if candidates:
|
||||||
|
for dimension in (role_code, product_category, ""):
|
||||||
|
if dimension is not None and dimension in candidates:
|
||||||
|
return candidates[dimension]
|
||||||
|
spec = get_spec(code) # 未注册编码 → ValueError(代码缺陷)
|
||||||
|
if spec.default_value is None:
|
||||||
|
raise BadRequestError(f"参数 {code} 无生效值且无默认值,请先在系统设置中维护")
|
||||||
|
return spec.default_value
|
||||||
|
|
||||||
|
def effective_date_of(
|
||||||
|
self, code: str, *, role_code: str | None = None, product_category: str | None = None
|
||||||
|
) -> date | None:
|
||||||
|
"""取参数当前生效值的生效日期(回退注册表默认值时为 None)。"""
|
||||||
|
candidates = self._values.get(code)
|
||||||
|
if candidates:
|
||||||
|
for dimension in (role_code, product_category, ""):
|
||||||
|
if dimension is not None and (code, dimension) in self._effective:
|
||||||
|
return self._effective[(code, dimension)]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def iter_items(self) -> Iterable[tuple[str, str, str]]:
|
||||||
|
"""遍历快照全部 (param_code, dimension_value, value_text),供对比。"""
|
||||||
|
for code, dims in self._values.items():
|
||||||
|
for dimension, value_text in dims.items():
|
||||||
|
yield code, dimension, value_text
|
||||||
|
|
||||||
|
def fraction(
|
||||||
|
self, code: str, *, role_code: str | None = None, product_category: str | None = None
|
||||||
|
) -> Fraction:
|
||||||
|
"""取参数值并精确解析为 Fraction("4/360" → 1/90,不经 float)。"""
|
||||||
|
return parse_fraction(
|
||||||
|
self.get(code, role_code=role_code, product_category=product_category)
|
||||||
|
)
|
||||||
|
|
||||||
|
def integer(
|
||||||
|
self, code: str, *, role_code: str | None = None, product_category: str | None = None
|
||||||
|
) -> int:
|
||||||
|
"""取参数值为整数。配置成非整数属数据缺陷(写入经校验,不应发生)。"""
|
||||||
|
value = self.fraction(code, role_code=role_code, product_category=product_category)
|
||||||
|
if value.denominator != 1:
|
||||||
|
raise ValueError(f"参数 {code} 配置为非整数:{value}")
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
def boolean(
|
||||||
|
self, code: str, *, role_code: str | None = None, product_category: str | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""取布尔参数(0/1)。"""
|
||||||
|
return self.get(code, role_code=role_code, product_category=product_category) == "1"
|
||||||
|
|
||||||
|
def text(
|
||||||
|
self, code: str, *, role_code: str | None = None, product_category: str | None = None
|
||||||
|
) -> str:
|
||||||
|
"""取参数原文(枚举码/文本)。"""
|
||||||
|
return self.get(code, role_code=role_code, product_category=product_category)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_snapshot(session: Session, biz_date: date) -> ParamSnapshot:
|
||||||
|
"""按业务日期解析全量参数快照(M3 核算与计算预览共用入口)。"""
|
||||||
|
return ParamSnapshot.from_rows(biz_date, param_repository.resolve_values(session, biz_date))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 目录 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCatalogItemOut]:
|
||||||
|
"""参数目录:全部启用定义 + 各维度截至 asOf(默认今日)的生效值。"""
|
||||||
|
biz_date = as_of or date.today()
|
||||||
|
resolved = param_repository.resolve_values(session, biz_date)
|
||||||
|
by_code: dict[str, list[ParamDimensionValueOut]] = {}
|
||||||
|
for code, dimension, value_text, effective_date in resolved:
|
||||||
|
by_code.setdefault(code, []).append(
|
||||||
|
ParamDimensionValueOut(
|
||||||
|
dimension_value=dimension, value_text=value_text, effective_date=effective_date
|
||||||
|
)
|
||||||
|
)
|
||||||
|
items: list[ParamCatalogItemOut] = []
|
||||||
|
for definition in param_repository.list_definitions(session, status=1):
|
||||||
|
spec = get_spec(definition.param_code)
|
||||||
|
items.append(
|
||||||
|
ParamCatalogItemOut(
|
||||||
|
param_code=definition.param_code,
|
||||||
|
param_name=definition.param_name,
|
||||||
|
param_group=definition.param_group,
|
||||||
|
value_type=definition.value_type,
|
||||||
|
unit=definition.unit,
|
||||||
|
dimension_type=definition.dimension_type,
|
||||||
|
options=list(spec.options or ()),
|
||||||
|
default_value=definition.default_value,
|
||||||
|
value_min=definition.value_min,
|
||||||
|
value_max=definition.value_max,
|
||||||
|
description=definition.description,
|
||||||
|
values=by_code.get(definition.param_code, []),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 变更与版本管理 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_items(
|
||||||
|
session: Session, items: list[ParamChangeItemIn]
|
||||||
|
) -> list[tuple[ParamDefinition, str, str]]:
|
||||||
|
"""全量校验条目并归一为 (定义, 规范维度值, 规范值),任何一项不合法整批拒绝。
|
||||||
|
|
||||||
|
定义与岗位各一条 IN 批量查询预载(逐条查询在批次扩大时是写侧 N+1);
|
||||||
|
岗位预载收集全部非空维度值 —— 集合可能略大于实际所需,但省去按维度
|
||||||
|
类型先分类的第二轮遍历,无害多取由校验逻辑自行过滤。
|
||||||
|
|
||||||
|
:raises BadRequestError: 参数未定义 / 维度不合法 / 值校验失败 / 同批次重复条目
|
||||||
|
"""
|
||||||
|
codes = list({item.param_code for item in items})
|
||||||
|
definitions = param_repository.list_definitions_by_codes(session, codes)
|
||||||
|
dimension_values = list({item.dimension_value for item in items if item.dimension_value})
|
||||||
|
# 预载字典键归一为 casefold:单条版 get_by_code 走 SQL ci 校对(大小写
|
||||||
|
# 变形可命中),批量版改字典查找后须自行等价归一,否则变形大小写会
|
||||||
|
# 被误判「不是已存在的岗位」;岗位编码均为 ascii 小写蛇形,casefold 足够
|
||||||
|
roles = {
|
||||||
|
code.casefold(): role
|
||||||
|
for code, role in business_role_repository.list_by_codes(session, dimension_values).items()
|
||||||
|
}
|
||||||
|
normalized: list[tuple[ParamDefinition, str, str]] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for item in items:
|
||||||
|
definition = definitions.get(item.param_code)
|
||||||
|
if definition is None:
|
||||||
|
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
|
||||||
|
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
|
||||||
|
# ci 校对规则下大小写变形也能命中库,此后一律用库里的规范值:
|
||||||
|
# 注册表 get_spec 与快照字典均按精确键匹配,原始大小写入库会导致
|
||||||
|
# 取值链静默不命中(按错误参数算钱且无报错)
|
||||||
|
spec = get_spec(definition.param_code)
|
||||||
|
dimension = item.dimension_value
|
||||||
|
if spec.dimension_type == "NONE" and dimension != "":
|
||||||
|
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
|
||||||
|
if spec.dimension_type == "BUSINESS_ROLE" and dimension:
|
||||||
|
role = roles.get(dimension.casefold())
|
||||||
|
if role is None:
|
||||||
|
raise BadRequestError(
|
||||||
|
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
|
||||||
|
)
|
||||||
|
dimension = role.role_code
|
||||||
|
if (
|
||||||
|
spec.dimension_type == "PRODUCT_CATEGORY"
|
||||||
|
and dimension
|
||||||
|
and dimension not in PRODUCT_CATEGORIES
|
||||||
|
):
|
||||||
|
raise BadRequestError(
|
||||||
|
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
|
||||||
|
)
|
||||||
|
value = validate_and_normalize(spec, item.value)
|
||||||
|
key = (item.param_code, dimension)
|
||||||
|
if key in seen:
|
||||||
|
raise BadRequestError(
|
||||||
|
f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})"
|
||||||
|
)
|
||||||
|
seen.add(key)
|
||||||
|
normalized.append((definition, dimension, value))
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def submit_change(
|
||||||
|
session: Session,
|
||||||
|
payload: ParamChangeIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ParamChangeBatchOut:
|
||||||
|
"""提交变更批次:全量校验 → 建批次 → 逐项写条目(旧值取提交时生效值)。
|
||||||
|
|
||||||
|
:raises BadRequestError: 参数未定义 / 维度不合法 / 值校验失败 / 生效日期早于今日 /
|
||||||
|
同批次重复条目
|
||||||
|
"""
|
||||||
|
effective = payload.effective_date if payload.change_type == 2 else date.today()
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PARAM,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=(
|
||||||
|
f"提交计提参数变更 {len(payload.items)} 项,"
|
||||||
|
f"{'立即生效' if payload.change_type == 1 else f'{effective} 起生效'}"
|
||||||
|
),
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if payload.change_type == 2 and effective < date.today():
|
||||||
|
raise BadRequestError("指定生效日期不得早于今日")
|
||||||
|
|
||||||
|
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
|
||||||
|
normalized = _normalize_items(session, payload.items)
|
||||||
|
|
||||||
|
try:
|
||||||
|
batch = param_repository.create_batch(
|
||||||
|
session,
|
||||||
|
ParamChangeBatch(
|
||||||
|
# 版本号与三单据同口径:MAX FOR UPDATE 锁当日序号区间;
|
||||||
|
# 残余并发冲突由下方 IntegrityError 分支翻译为可重试 400
|
||||||
|
version_no=next_no(
|
||||||
|
session,
|
||||||
|
model=ParamChangeBatch,
|
||||||
|
column_name="version_no",
|
||||||
|
prefix=NO_PREFIX_PARAM,
|
||||||
|
seq_width=2,
|
||||||
|
),
|
||||||
|
effective_date=effective,
|
||||||
|
change_type=payload.change_type,
|
||||||
|
remark=payload.remark,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
item_count=len(normalized),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 旧值留痕取「本条目生效日时点」的值:存在多个未来生效批次时,
|
||||||
|
# 每条记录的是它实际顶替的那一版(而非提交时刻的当前值)
|
||||||
|
for definition, dimension, value in normalized:
|
||||||
|
old = param_repository.latest_value_before(
|
||||||
|
session, definition.id, dimension, effective
|
||||||
|
)
|
||||||
|
param_repository.create_value(
|
||||||
|
session,
|
||||||
|
ParamValue(
|
||||||
|
param_id=definition.id,
|
||||||
|
dimension_value=dimension,
|
||||||
|
effective_date=effective,
|
||||||
|
value_text=value,
|
||||||
|
old_value_text=old.value_text if old else None,
|
||||||
|
batch_id=batch.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# version_no 唯一键的并发冲突:回滚并翻译为可重试的 400
|
||||||
|
session.rollback()
|
||||||
|
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
|
||||||
|
session.commit()
|
||||||
|
logger.info("提交参数变更 | version_no=%s | items=%s", batch.version_no, batch.item_count)
|
||||||
|
return ParamChangeBatchOut(
|
||||||
|
id=batch.id,
|
||||||
|
version_no=batch.version_no,
|
||||||
|
effective_date=batch.effective_date,
|
||||||
|
change_type=batch.change_type,
|
||||||
|
remark=batch.remark,
|
||||||
|
operator_name=batch.operator_name,
|
||||||
|
item_count=batch.item_count,
|
||||||
|
create_time=batch.create_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def withdraw_batch(
|
||||||
|
session: Session, batch_id: int, *, operator: User, meta: RequestMeta | None = None
|
||||||
|
) -> None:
|
||||||
|
"""撤回未生效批次。已生效禁撤;条目删除后解析自然回退到更早版本。
|
||||||
|
|
||||||
|
:raises NotFoundError: 批次不存在
|
||||||
|
:raises BadRequestError: 批次已生效
|
||||||
|
"""
|
||||||
|
batch = param_repository.get_batch_by_id(session, batch_id, for_update=True)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PARAM,
|
||||||
|
action=ACTION_DELETE,
|
||||||
|
summary=f"撤回参数变更批次 {batch.version_no if batch else f'#{batch_id}'}",
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if batch is None:
|
||||||
|
raise NotFoundError("变更批次不存在")
|
||||||
|
if batch.effective_date <= date.today():
|
||||||
|
raise BadRequestError("批次已生效,不可撤回")
|
||||||
|
|
||||||
|
param_repository.delete_values_by_batch(session, batch.id)
|
||||||
|
param_repository.delete_batch(session, batch)
|
||||||
|
session.commit()
|
||||||
|
logger.info("撤回参数变更批次 | version_no=%s", batch.version_no)
|
||||||
|
|
||||||
|
|
||||||
|
def get_history(
|
||||||
|
session: Session, param_code: str, query: ParamHistoryQuery
|
||||||
|
) -> PageResult[ParamHistoryOut]:
|
||||||
|
"""单参数版本历史(分页,最新在前)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 参数不存在
|
||||||
|
"""
|
||||||
|
definition = param_repository.get_definition_by_code(session, param_code)
|
||||||
|
if definition is None:
|
||||||
|
raise NotFoundError("参数不存在")
|
||||||
|
rows = param_repository.list_history(
|
||||||
|
session,
|
||||||
|
definition.id,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
dimension_value=query.dimension_value,
|
||||||
|
)
|
||||||
|
total = param_repository.count_history(
|
||||||
|
session, definition.id, dimension_value=query.dimension_value
|
||||||
|
)
|
||||||
|
return PageResult[ParamHistoryOut](
|
||||||
|
records=[
|
||||||
|
ParamHistoryOut(
|
||||||
|
version_no=batch.version_no,
|
||||||
|
dimension_value=value.dimension_value,
|
||||||
|
old_value_text=value.old_value_text,
|
||||||
|
value_text=value.value_text,
|
||||||
|
effective_date=value.effective_date,
|
||||||
|
operator_name=batch.operator_name,
|
||||||
|
create_time=value.create_time,
|
||||||
|
)
|
||||||
|
for value, batch in rows
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
|
||||||
|
"""变更批次分页(按创建时间倒序)。"""
|
||||||
|
batches = param_repository.list_batches(
|
||||||
|
session,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
start_date=query.start_date,
|
||||||
|
end_date=query.end_date,
|
||||||
|
)
|
||||||
|
total = param_repository.count_batches(
|
||||||
|
session, start_date=query.start_date, end_date=query.end_date
|
||||||
|
)
|
||||||
|
return PageResult[ParamBatchOut](
|
||||||
|
records=[
|
||||||
|
ParamBatchOut(
|
||||||
|
id=batch.id,
|
||||||
|
version_no=batch.version_no,
|
||||||
|
effective_date=batch.effective_date,
|
||||||
|
change_type=batch.change_type,
|
||||||
|
remark=batch.remark,
|
||||||
|
operator_name=batch.operator_name,
|
||||||
|
item_count=batch.item_count,
|
||||||
|
create_time=batch.create_time,
|
||||||
|
)
|
||||||
|
for batch in batches
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_batch_detail(session: Session, batch_id: int) -> ParamBatchDetailOut:
|
||||||
|
"""批次明细:批次头 + 逐项旧值 → 新值。
|
||||||
|
|
||||||
|
:raises NotFoundError: 批次不存在
|
||||||
|
"""
|
||||||
|
batch = param_repository.get_batch_by_id(session, batch_id)
|
||||||
|
if batch is None:
|
||||||
|
raise NotFoundError("变更批次不存在")
|
||||||
|
items = [
|
||||||
|
ParamBatchItemOut(
|
||||||
|
param_code=definition.param_code,
|
||||||
|
param_name=definition.param_name,
|
||||||
|
dimension_value=value.dimension_value,
|
||||||
|
old_value_text=value.old_value_text,
|
||||||
|
value_text=value.value_text,
|
||||||
|
effective_date=value.effective_date,
|
||||||
|
)
|
||||||
|
for value, definition in param_repository.list_values_by_batch(session, batch_id)
|
||||||
|
]
|
||||||
|
return ParamBatchDetailOut(
|
||||||
|
id=batch.id,
|
||||||
|
version_no=batch.version_no,
|
||||||
|
effective_date=batch.effective_date,
|
||||||
|
change_type=batch.change_type,
|
||||||
|
remark=batch.remark,
|
||||||
|
operator_name=batch.operator_name,
|
||||||
|
item_count=batch.item_count,
|
||||||
|
create_time=batch.create_time,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 对比与预览 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDiffOut]:
|
||||||
|
"""两日期快照逐项对比(全量参数,changed 标记变化)。
|
||||||
|
|
||||||
|
:raises BadRequestError: 起始日期晚于截止日期
|
||||||
|
"""
|
||||||
|
if from_date > to_date:
|
||||||
|
raise BadRequestError("起始日期不得晚于截止日期")
|
||||||
|
old_items = {
|
||||||
|
(code, dimension): value
|
||||||
|
for code, dimension, value in resolve_snapshot(session, from_date).iter_items()
|
||||||
|
}
|
||||||
|
new_items = {
|
||||||
|
(code, dimension): value
|
||||||
|
for code, dimension, value in resolve_snapshot(session, to_date).iter_items()
|
||||||
|
}
|
||||||
|
out: list[ParamDiffOut] = []
|
||||||
|
for code, dimension in sorted(set(old_items) | set(new_items)):
|
||||||
|
spec = get_spec(code)
|
||||||
|
old_value = old_items.get((code, dimension))
|
||||||
|
new_value = new_items.get((code, dimension))
|
||||||
|
out.append(
|
||||||
|
ParamDiffOut(
|
||||||
|
param_code=code,
|
||||||
|
param_name=spec.name,
|
||||||
|
param_group=spec.group,
|
||||||
|
dimension_value=dimension,
|
||||||
|
old_value=old_value,
|
||||||
|
new_value=new_value,
|
||||||
|
changed=old_value != new_value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_rate(
|
||||||
|
session: Session,
|
||||||
|
payload: ParamPreviewIn,
|
||||||
|
snapshot: ParamSnapshot,
|
||||||
|
days: int,
|
||||||
|
take,
|
||||||
|
) -> tuple[Fraction, str, Decimal | None]:
|
||||||
|
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
|
||||||
|
|
||||||
|
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
|
||||||
|
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
|
||||||
|
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
||||||
|
"""
|
||||||
|
if payload.customer_type == "enterprise":
|
||||||
|
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
|
||||||
|
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
|
||||||
|
rate = take("enterprise.rate") / 1000
|
||||||
|
return rate, "企业户固定计提比例", None
|
||||||
|
|
||||||
|
role_code = payload.business_role
|
||||||
|
template = "TPL_SALESPERSON_DECAY"
|
||||||
|
if role_code:
|
||||||
|
role = business_role_repository.get_by_code(session, role_code)
|
||||||
|
if role is None:
|
||||||
|
raise BadRequestError(f"岗位 {role_code!r} 不存在")
|
||||||
|
template = role.calc_template
|
||||||
|
|
||||||
|
if template == "TPL_MANAGER_SHARE":
|
||||||
|
raise BadRequestError(
|
||||||
|
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
|
||||||
|
)
|
||||||
|
if template == "TPL_INFO_SALES_FIXED":
|
||||||
|
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
|
||||||
|
rate = take("info.rate") / 1000
|
||||||
|
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
|
||||||
|
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
|
||||||
|
if template == "TPL_SALESPERSON_DECAY":
|
||||||
|
start = take("salesperson.rate.start")
|
||||||
|
slope = take("salesperson.rate.slope")
|
||||||
|
floor = take("salesperson.rate.floor")
|
||||||
|
boundary = int(take("salesperson.days.boundary"))
|
||||||
|
rate = formula.salesperson_rate(
|
||||||
|
days, start=start, slope=slope, floor=floor, boundary_days=boundary
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
rate,
|
||||||
|
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
|
||||||
|
|
||||||
|
|
||||||
|
def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
||||||
|
"""计算预览:入参(发货/回款/金额/岗位)→ 公式纯函数 → 出参(含所用参数)。"""
|
||||||
|
# 参数按回款到账日取值(对照表 #11)
|
||||||
|
snapshot = resolve_snapshot(session, payload.receipt_date)
|
||||||
|
revenue = Decimal(payload.revenue)
|
||||||
|
profit = Decimal(payload.profit)
|
||||||
|
received = Decimal(payload.received_amount)
|
||||||
|
used: list[ParamUsedOut] = []
|
||||||
|
|
||||||
|
def take(code: str) -> Fraction:
|
||||||
|
raw = snapshot.get(
|
||||||
|
code,
|
||||||
|
role_code=payload.business_role,
|
||||||
|
product_category=payload.product_category,
|
||||||
|
)
|
||||||
|
used.append(
|
||||||
|
ParamUsedOut(
|
||||||
|
param_code=code,
|
||||||
|
value_text=raw,
|
||||||
|
effective_date=snapshot.effective_date_of(
|
||||||
|
code,
|
||||||
|
role_code=payload.business_role,
|
||||||
|
product_category=payload.product_category,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return parse_fraction(raw)
|
||||||
|
|
||||||
|
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
|
||||||
|
raise BadRequestError(
|
||||||
|
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit"
|
||||||
|
)
|
||||||
|
|
||||||
|
days = formula.payment_days(payload.ship_date, payload.receipt_date)
|
||||||
|
rate, rate_text, base_override = _preview_rate(session, payload, snapshot, days, take)
|
||||||
|
|
||||||
|
profit_rate = formula.profit_rate(profit, revenue)
|
||||||
|
if base_override is not None:
|
||||||
|
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
|
||||||
|
converted = base_override
|
||||||
|
else:
|
||||||
|
threshold = take("product.threshold")
|
||||||
|
converted = formula.converted_profit(revenue, profit_rate, threshold)
|
||||||
|
performance = formula.performance_amount(converted, rate)
|
||||||
|
|
||||||
|
barter_applied = payload.barter
|
||||||
|
after_barter: Decimal | None = None
|
||||||
|
if barter_applied:
|
||||||
|
discount = take("barter.discount")
|
||||||
|
after_barter = formula.apply_barter(performance, discount)
|
||||||
|
|
||||||
|
start_ratio = take("payment.start.ratio")
|
||||||
|
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
||||||
|
# R 按「回款占销售额百分比向下取整」口径封顶封底:received 非负时 int()
|
||||||
|
# 即向下取整;min/max 同时是红字冲销(负数回款)接入时的方向防线
|
||||||
|
weight_r = (
|
||||||
|
max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100))) if revenue > 0 else 0
|
||||||
|
)
|
||||||
|
weight = formula.payment_weight(weight_r)
|
||||||
|
base_amount = after_barter if barter_applied else performance
|
||||||
|
payable = to_decimal(Fraction(base_amount) * weight)
|
||||||
|
|
||||||
|
probation_note: str | None = None
|
||||||
|
if payload.probation:
|
||||||
|
annual = take("probation.annual.threshold")
|
||||||
|
probation_note = f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算"
|
||||||
|
|
||||||
|
return ParamPreviewOut(
|
||||||
|
payment_days=days,
|
||||||
|
rate=str(rate),
|
||||||
|
rate_text=rate_text,
|
||||||
|
profit_rate=str(profit_rate),
|
||||||
|
converted_profit=str(converted),
|
||||||
|
performance_amount=str(performance),
|
||||||
|
barter_applied=barter_applied,
|
||||||
|
performance_after_barter=str(after_barter) if after_barter is not None else None,
|
||||||
|
start_payment_ok=start_ok,
|
||||||
|
payment_weight_r=weight_r,
|
||||||
|
payment_weight=str(weight),
|
||||||
|
payable_amount=str(payable),
|
||||||
|
probation_note=probation_note,
|
||||||
|
params_used=used,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 导出 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def export_changes(
|
||||||
|
session: Session, *, start_date: date | None = None, end_date: date | None = None
|
||||||
|
) -> bytes:
|
||||||
|
"""变更记录导出 Excel(openpyxl)。列:版本号/变更人/变更时间/生效日期/参数/维度/旧值/新值。"""
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
rows = param_repository.list_export_rows(session, start_date=start_date, end_date=end_date)
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = "参数变更记录"
|
||||||
|
sheet.append(
|
||||||
|
[
|
||||||
|
"版本号",
|
||||||
|
"变更人",
|
||||||
|
"变更时间",
|
||||||
|
"生效日期",
|
||||||
|
"参数编码",
|
||||||
|
"参数名称",
|
||||||
|
"维度值",
|
||||||
|
"旧值",
|
||||||
|
"新值",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for batch, value, definition in rows:
|
||||||
|
sheet.append(
|
||||||
|
[
|
||||||
|
batch.version_no,
|
||||||
|
batch.operator_name,
|
||||||
|
batch.create_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
value.effective_date.strftime("%Y-%m-%d"),
|
||||||
|
definition.param_code,
|
||||||
|
definition.param_name,
|
||||||
|
value.dimension_value,
|
||||||
|
value.old_value_text,
|
||||||
|
value.value_text,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
buffer = BytesIO()
|
||||||
|
workbook.save(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
"""营销人员管理业务编排(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。
|
||||||
|
|
||||||
|
业务规则(前端会做提示,**后端校验是唯一防线**):
|
||||||
|
|
||||||
|
- 岗位必须存在且启用 —— 岗位决定计提公式模板,挂到停用/不存在的岗位
|
||||||
|
会让 M3 核算找不到模板
|
||||||
|
- 直属经理不能是自己、目标须存在且在职 —— 自引用关系是 M3 经理 12.5%
|
||||||
|
聚合的展开锚点,环引用或悬空引用会让聚合死循环/漏算
|
||||||
|
- 删除保护:被团队成员(personnel.manager_id)或客户归属
|
||||||
|
(customers.owner_personnel_id)引用的人员**禁删**,应改为离职 ——
|
||||||
|
历史核算与客户归属都要能回溯到人,物理删除会留下悬空引用
|
||||||
|
- 金额红线:salary_standard 以字符串进出、服务层转 Decimal,全程不经 float
|
||||||
|
|
||||||
|
辐射区域与拓展计划为整表替换维护:先删、立即 flush、再插入
|
||||||
|
(防御同一 flush 先 INSERT 后 DELETE 撞唯一键,见 personnel_repository)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
ACTION_DELETE,
|
||||||
|
ACTION_UPDATE,
|
||||||
|
MODULE_PERSONNEL,
|
||||||
|
)
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.core.response import PageResult
|
||||||
|
from app.models import BusinessRole, Personnel, PersonnelPlan, PersonnelRegion, User
|
||||||
|
from app.models.personnel import PERSONNEL_STATUS_ACTIVE
|
||||||
|
from app.repositories import business_role_repository, personnel_repository
|
||||||
|
from app.schemas.personnel import (
|
||||||
|
PersonnelCreateIn,
|
||||||
|
PersonnelDetailOut,
|
||||||
|
PersonnelOut,
|
||||||
|
PersonnelPlanOut,
|
||||||
|
PersonnelQuery,
|
||||||
|
PersonnelRegionOut,
|
||||||
|
PlanReplaceIn,
|
||||||
|
RegionReplaceIn,
|
||||||
|
)
|
||||||
|
from app.services import audit_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 删除保护的统一文案:三种引用(团队成员/客户归属/业务归属)共享同一提示,
|
||||||
|
# 引导管理员走「离职」而非删除
|
||||||
|
_DELETE_BLOCKED = "存在团队成员、客户归属或业务归属引用,请改为离职而非删除"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personnel:
|
||||||
|
"""归属人员必须存在且在职,返回人员行(业务/客户两侧归属校验共用)。
|
||||||
|
|
||||||
|
「不存在」与「已离职」合并为同一文案:人员 ID 是否有效不构成需要向
|
||||||
|
调用方区分的信息(同登录防枚举的思路);label 落文案(负责/协助/归属)。
|
||||||
|
收口单点:在职状态值取 models.personnel.PERSONNEL_STATUS_ACTIVE,
|
||||||
|
此前 business/customer 两 service 各持常量与校验,仅靠注释维持同步。
|
||||||
|
|
||||||
|
:raises BadRequestError: {label}人员不存在或已离职
|
||||||
|
"""
|
||||||
|
person = personnel_repository.get_by_id(session, personnel_id)
|
||||||
|
if person is None or person.status != PERSONNEL_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError(f"{label}人员不存在或已离职")
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
|
||||||
|
"""分页查询人员(含离职)。"""
|
||||||
|
rows = personnel_repository.list_personnel(
|
||||||
|
session,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
keyword=query.keyword,
|
||||||
|
status=query.status,
|
||||||
|
business_role_id=query.business_role_id,
|
||||||
|
)
|
||||||
|
total = personnel_repository.count_personnel(
|
||||||
|
session,
|
||||||
|
keyword=query.keyword,
|
||||||
|
status=query.status,
|
||||||
|
business_role_id=query.business_role_id,
|
||||||
|
)
|
||||||
|
return PageResult[PersonnelOut](
|
||||||
|
records=[
|
||||||
|
_to_out(personnel, role_name, manager_name)
|
||||||
|
for personnel, role_name, manager_name in rows
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_detail(session: Session, personnel_id: int) -> PersonnelDetailOut:
|
||||||
|
"""人员详情(含区域与拓展计划,供编辑抽屉回显)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 人员不存在
|
||||||
|
"""
|
||||||
|
row = personnel_repository.get_with_names(session, personnel_id)
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("人员不存在")
|
||||||
|
personnel, role_name, manager_name = row
|
||||||
|
return PersonnelDetailOut(
|
||||||
|
**_to_out(personnel, role_name, manager_name).model_dump(),
|
||||||
|
regions=[
|
||||||
|
PersonnelRegionOut.model_validate(item)
|
||||||
|
for item in personnel_repository.list_regions(session, personnel_id)
|
||||||
|
],
|
||||||
|
plans=[
|
||||||
|
PersonnelPlanOut.model_validate(item)
|
||||||
|
for item in personnel_repository.list_plans(session, personnel_id)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_personnel(
|
||||||
|
session: Session,
|
||||||
|
payload: PersonnelCreateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> PersonnelOut:
|
||||||
|
"""新增人员,默认在职。
|
||||||
|
|
||||||
|
:raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法
|
||||||
|
"""
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PERSONNEL,
|
||||||
|
action=ACTION_CREATE,
|
||||||
|
summary=f"新增人员 {payload.personnel_name}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 引用校验放在审计上下文内:失败同样要留痕(与 update_personnel 口径
|
||||||
|
# 一致)——「谁在何时试图把人挂到停用岗位/离职经理上」是审计对象,
|
||||||
|
# 校验留在上下文外会让这类失败操作无据可查
|
||||||
|
role, manager_name = _validate_refs(
|
||||||
|
session, payload.business_role_id, payload.manager_id, None
|
||||||
|
)
|
||||||
|
personnel = Personnel(
|
||||||
|
personnel_name=payload.personnel_name,
|
||||||
|
business_role_id=payload.business_role_id,
|
||||||
|
manager_id=payload.manager_id,
|
||||||
|
phone=payload.phone,
|
||||||
|
hire_date=payload.hire_date,
|
||||||
|
is_probation=payload.is_probation,
|
||||||
|
salary_standard=_parse_salary(payload.salary_standard),
|
||||||
|
status=1,
|
||||||
|
remark=payload.remark,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
personnel_repository.create(session, personnel)
|
||||||
|
out = _to_out(personnel, role.role_name, manager_name)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 先查后写不是原子操作:岗位/经理行被并发删除时 FK RESTRICT 令写入失败
|
||||||
|
session.rollback()
|
||||||
|
raise BadRequestError("岗位或直属经理引用已失效,请刷新后重试") from exc
|
||||||
|
|
||||||
|
logger.info("新增人员 | personnel_id=%s | name=%s", personnel.id, payload.personnel_name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def update_personnel(
|
||||||
|
session: Session,
|
||||||
|
personnel_id: int,
|
||||||
|
payload: PersonnelCreateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> PersonnelOut:
|
||||||
|
"""编辑人员(全量替换,不含状态)。
|
||||||
|
|
||||||
|
经理自引用校验以**被编辑人员的 id** 为准:把经理改成自己是环引用。
|
||||||
|
|
||||||
|
:raises NotFoundError: 人员不存在
|
||||||
|
:raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法
|
||||||
|
"""
|
||||||
|
row = personnel_repository.get_with_names(session, personnel_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PERSONNEL,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"修改人员 {row[0].personnel_name if row else f'#{personnel_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("人员不存在")
|
||||||
|
personnel = row[0]
|
||||||
|
role, manager_name = _validate_refs(
|
||||||
|
session, payload.business_role_id, payload.manager_id, personnel.id
|
||||||
|
)
|
||||||
|
personnel.personnel_name = payload.personnel_name
|
||||||
|
personnel.business_role_id = payload.business_role_id
|
||||||
|
personnel.manager_id = payload.manager_id
|
||||||
|
personnel.phone = payload.phone
|
||||||
|
personnel.hire_date = payload.hire_date
|
||||||
|
personnel.is_probation = payload.is_probation
|
||||||
|
personnel.salary_standard = _parse_salary(payload.salary_standard)
|
||||||
|
personnel.remark = payload.remark
|
||||||
|
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
|
||||||
|
out = _to_out(personnel, role.role_name, manager_name)
|
||||||
|
session.commit()
|
||||||
|
logger.info("修改人员 | personnel_id=%s", personnel_id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_personnel(
|
||||||
|
session: Session,
|
||||||
|
personnel_id: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""删除人员。有团队成员或客户归属引用时禁删,引导走「离职」。
|
||||||
|
|
||||||
|
查询走 ``FOR UPDATE`` 锁行:校验与删除之间若发生并发引用,锁行使
|
||||||
|
并发外键写入在删除时失败(可见的 400)而非引用被静默悬空。
|
||||||
|
|
||||||
|
:raises NotFoundError: 人员不存在
|
||||||
|
:raises BadRequestError: 存在团队成员或客户归属引用
|
||||||
|
"""
|
||||||
|
personnel = personnel_repository.get_by_id(session, personnel_id, for_update=True)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PERSONNEL,
|
||||||
|
action=ACTION_DELETE,
|
||||||
|
summary=f"删除人员 {personnel.personnel_name if personnel else f'#{personnel_id}'}",
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if personnel is None:
|
||||||
|
raise NotFoundError("人员不存在")
|
||||||
|
|
||||||
|
ids = [personnel_id]
|
||||||
|
if (
|
||||||
|
personnel_repository.count_team_members(session, ids)
|
||||||
|
or personnel_repository.count_customer_ownerships(session, ids)
|
||||||
|
or personnel_repository.count_business_roles_refs(session, ids)
|
||||||
|
):
|
||||||
|
# 业务归属引用(负责/协助)随业务表就位一并纳入删除保护——
|
||||||
|
# 漏检会让删除以 FK RESTRICT 的 500 暴露而非可读的 400
|
||||||
|
raise BadRequestError(_DELETE_BLOCKED)
|
||||||
|
|
||||||
|
name = personnel.personnel_name # 固化:对象随删除失效
|
||||||
|
try:
|
||||||
|
personnel_repository.delete(session, personnel)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 校验与删除之间并发新增了团队成员/客户归属,FK RESTRICT 令删除失败
|
||||||
|
session.rollback()
|
||||||
|
raise BadRequestError(_DELETE_BLOCKED) from exc
|
||||||
|
logger.info("删除人员 | personnel_id=%s | name=%s", personnel_id, name)
|
||||||
|
|
||||||
|
|
||||||
|
def set_status(
|
||||||
|
session: Session,
|
||||||
|
personnel_id: int,
|
||||||
|
status: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> PersonnelOut:
|
||||||
|
"""在职/离职切换。
|
||||||
|
|
||||||
|
人员语义不用「启用/禁用」词表,action 记 ACTION_UPDATE、摘要写明去向。
|
||||||
|
|
||||||
|
:raises NotFoundError: 人员不存在
|
||||||
|
"""
|
||||||
|
row = personnel_repository.get_with_names(session, personnel_id)
|
||||||
|
verb = "设为在职" if status == 1 else "设为离职"
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PERSONNEL,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"{verb} {row[0].personnel_name if row else f'#{personnel_id}'}",
|
||||||
|
request_data={"status": status},
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("人员不存在")
|
||||||
|
personnel, role_name, manager_name = row
|
||||||
|
personnel.status = status
|
||||||
|
out = _to_out(personnel, role_name, manager_name)
|
||||||
|
session.commit()
|
||||||
|
logger.info("变更人员状态 | personnel_id=%s | status=%s", personnel_id, status)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def replace_regions(
|
||||||
|
session: Session,
|
||||||
|
personnel_id: int,
|
||||||
|
payload: RegionReplaceIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""全量替换辐射区域,空列表即清空。
|
||||||
|
|
||||||
|
:raises NotFoundError: 人员不存在
|
||||||
|
"""
|
||||||
|
row = personnel_repository.get_with_names(session, personnel_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PERSONNEL,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"维护辐射区域 {row[0].personnel_name if row else f'#{personnel_id}'}",
|
||||||
|
request_data=payload.model_dump(),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("人员不存在")
|
||||||
|
rows = [
|
||||||
|
PersonnelRegion(
|
||||||
|
personnel_id=personnel_id, region_name=item.region_name, remark=item.remark
|
||||||
|
)
|
||||||
|
for item in payload.items
|
||||||
|
]
|
||||||
|
personnel_repository.replace_regions(session, personnel_id, rows)
|
||||||
|
session.commit()
|
||||||
|
logger.info("维护辐射区域 | personnel_id=%s | 条目数=%s", personnel_id, len(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def replace_plans(
|
||||||
|
session: Session,
|
||||||
|
personnel_id: int,
|
||||||
|
payload: PlanReplaceIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""全量替换拓展计划,空列表即清空。
|
||||||
|
|
||||||
|
:raises NotFoundError: 人员不存在
|
||||||
|
"""
|
||||||
|
row = personnel_repository.get_with_names(session, personnel_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PERSONNEL,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"维护拓展计划 {row[0].personnel_name if row else f'#{personnel_id}'}",
|
||||||
|
request_data=payload.model_dump(),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("人员不存在")
|
||||||
|
rows = [
|
||||||
|
PersonnelPlan(
|
||||||
|
personnel_id=personnel_id,
|
||||||
|
plan_period=item.plan_period,
|
||||||
|
plan_content=item.plan_content,
|
||||||
|
target_desc=item.target_desc,
|
||||||
|
)
|
||||||
|
for item in payload.items
|
||||||
|
]
|
||||||
|
personnel_repository.replace_plans(session, personnel_id, rows)
|
||||||
|
session.commit()
|
||||||
|
logger.info("维护拓展计划 | personnel_id=%s | 条目数=%s", personnel_id, len(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(personnel: Personnel, role_name: str | None, manager_name: str | None) -> PersonnelOut:
|
||||||
|
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
|
||||||
|
return PersonnelOut(
|
||||||
|
id=personnel.id,
|
||||||
|
personnel_name=personnel.personnel_name,
|
||||||
|
business_role_id=personnel.business_role_id,
|
||||||
|
role_name=role_name,
|
||||||
|
manager_id=personnel.manager_id,
|
||||||
|
manager_name=manager_name,
|
||||||
|
phone=personnel.phone,
|
||||||
|
hire_date=personnel.hire_date,
|
||||||
|
is_probation=personnel.is_probation,
|
||||||
|
salary_standard=(
|
||||||
|
str(personnel.salary_standard) if personnel.salary_standard is not None else None
|
||||||
|
),
|
||||||
|
status=personnel.status,
|
||||||
|
remark=personnel.remark,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_refs(
|
||||||
|
session: Session,
|
||||||
|
business_role_id: int,
|
||||||
|
manager_id: int | None,
|
||||||
|
self_id: int | None,
|
||||||
|
) -> tuple[BusinessRole, str | None]:
|
||||||
|
"""新增/编辑共用的引用校验:岗位须存在且启用;经理不能是自己且须在职。
|
||||||
|
|
||||||
|
``self_id`` 编辑时传被编辑人员的 id、新增时传 None(新增不存在「自己」)。
|
||||||
|
返回岗位行与经理姓名供响应 DTO 复用,避免提交后再查一次。
|
||||||
|
|
||||||
|
:raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法
|
||||||
|
"""
|
||||||
|
role = business_role_repository.get_by_id(session, business_role_id)
|
||||||
|
if role is None or role.status != 1:
|
||||||
|
raise BadRequestError("岗位不存在或已停用")
|
||||||
|
|
||||||
|
manager_name: str | None = None
|
||||||
|
if manager_id is not None:
|
||||||
|
if manager_id == self_id:
|
||||||
|
raise BadRequestError("直属经理不能是自己")
|
||||||
|
manager = personnel_repository.get_by_id(session, manager_id)
|
||||||
|
if manager is None or manager.status != 1:
|
||||||
|
raise BadRequestError("直属经理不存在或已离职")
|
||||||
|
manager_name = manager.personnel_name
|
||||||
|
return role, manager_name
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_salary(value: str | None) -> Decimal | None:
|
||||||
|
"""入参字符串转 Decimal 存库。
|
||||||
|
|
||||||
|
格式、非负与容量已在 schema 层校验,此处只做类型转换 ——
|
||||||
|
金额红线:全程 Decimal,不经 float。
|
||||||
|
"""
|
||||||
|
return Decimal(value) if value is not None else None
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"""产品管理业务编排。
|
||||||
|
|
||||||
|
产品主数据的两条硬约束(违反会算错钱):
|
||||||
|
|
||||||
|
- ``product_category`` 是**折算阈值的行级锚点**:混合业务按产品逐条计算
|
||||||
|
(对照表 #14),阈值由 M1 参数 ``product.threshold`` 按 PRODUCT_CATEGORY
|
||||||
|
维度承担。类别游离于合法值域之外时,核算按类别回溯参数会取不到阈值,
|
||||||
|
故值域校验是业务规则而非格式校验
|
||||||
|
- 数量/单价全程 ``Decimal``:schema 层已拦格式与非负(金额红线入口),
|
||||||
|
service 层负责字符串到 Decimal 的转换与落库
|
||||||
|
|
||||||
|
另:``spec_model`` 归一为**空串而非 NULL** —— 它参与 uk_products_name_spec
|
||||||
|
组合唯一键,MySQL 组合索引对 NULL 不去重,归 NULL 会放行重名同规格数据。
|
||||||
|
删除为物理删除,被业务明细引用的保护检查待业务数据表(V5)就位后补充。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
ACTION_DELETE,
|
||||||
|
ACTION_DISABLE,
|
||||||
|
ACTION_ENABLE,
|
||||||
|
ACTION_UPDATE,
|
||||||
|
MODULE_PRODUCT,
|
||||||
|
PRODUCT_CATEGORIES,
|
||||||
|
)
|
||||||
|
from app.core.db_errors import is_duplicate_entry
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.core.response import PageResult
|
||||||
|
from app.models import Product, User
|
||||||
|
from app.repositories import product_repository
|
||||||
|
from app.schemas.product import (
|
||||||
|
ProductCreateIn,
|
||||||
|
ProductOut,
|
||||||
|
ProductQuery,
|
||||||
|
ProductUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import audit_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(product: Product) -> ProductOut:
|
||||||
|
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
|
||||||
|
return ProductOut(
|
||||||
|
id=product.id,
|
||||||
|
product_name=product.product_name,
|
||||||
|
spec_model=product.spec_model,
|
||||||
|
unit=product.unit,
|
||||||
|
stock_quantity=str(product.stock_quantity) if product.stock_quantity is not None else None,
|
||||||
|
sale_price=str(product.sale_price) if product.sale_price is not None else None,
|
||||||
|
product_category=product.product_category,
|
||||||
|
status=product.status,
|
||||||
|
remark=product.remark,
|
||||||
|
create_time=product.create_time,
|
||||||
|
update_time=product.update_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_decimal(value: str | None) -> Decimal | None:
|
||||||
|
"""数字字符串转 Decimal(可空列的空串/None 透传 None)。
|
||||||
|
|
||||||
|
入参已 str 化再进 Decimal:即使上游口径变化传入数字类型,也不经 float
|
||||||
|
二进制近似,金额红线在最后一道转换处仍然成立。schema 已保证可解析,
|
||||||
|
此处不再重复校验。
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return Decimal(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_category(product_category: str) -> None:
|
||||||
|
"""类别必须 ∈ PRODUCT_CATEGORIES(前端下拉仅是展示约束,后端校验是唯一防线)。"""
|
||||||
|
if product_category not in PRODUCT_CATEGORIES:
|
||||||
|
raise BadRequestError("产品类别不合法")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||||
|
"""判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。
|
||||||
|
|
||||||
|
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、
|
||||||
|
数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。
|
||||||
|
"""
|
||||||
|
return is_duplicate_entry(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_if_duplicate(exc: IntegrityError) -> None:
|
||||||
|
"""唯一键冲突统一转业务提示;其余完整性错误原样重抛。"""
|
||||||
|
if _is_duplicate_entry(exc):
|
||||||
|
raise BadRequestError("已存在同名称同规格的产品") from exc
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
|
||||||
|
"""分页查询产品(含停用)。"""
|
||||||
|
products = product_repository.list_products(
|
||||||
|
session,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
keyword=query.keyword,
|
||||||
|
product_category=query.product_category,
|
||||||
|
status=query.status,
|
||||||
|
)
|
||||||
|
total = product_repository.count_products(
|
||||||
|
session,
|
||||||
|
keyword=query.keyword,
|
||||||
|
product_category=query.product_category,
|
||||||
|
status=query.status,
|
||||||
|
)
|
||||||
|
return PageResult[ProductOut](
|
||||||
|
records=[_to_out(product) for product in products],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_product(session: Session, product_id: int) -> ProductOut:
|
||||||
|
"""产品详情。
|
||||||
|
|
||||||
|
:raises NotFoundError: 产品不存在
|
||||||
|
"""
|
||||||
|
product = product_repository.get_by_id(session, product_id)
|
||||||
|
if product is None:
|
||||||
|
raise NotFoundError("产品不存在")
|
||||||
|
return _to_out(product)
|
||||||
|
|
||||||
|
|
||||||
|
def create_product(
|
||||||
|
session: Session,
|
||||||
|
payload: ProductCreateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ProductOut:
|
||||||
|
"""新增产品。类别须在合法值域内,新产品固定启用。
|
||||||
|
|
||||||
|
:raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品
|
||||||
|
"""
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PRODUCT,
|
||||||
|
action=ACTION_CREATE,
|
||||||
|
summary=f"新增产品 {payload.product_name}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 值域校验在审计上下文内:类别非法的 400 也要留痕(成功与失败都记)
|
||||||
|
_ensure_category(payload.product_category)
|
||||||
|
spec_model = payload.spec_model or "" # 归一空串:组合唯一键对 NULL 不去重
|
||||||
|
if (
|
||||||
|
product_repository.get_by_name_spec(session, payload.product_name, spec_model)
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
raise BadRequestError("已存在同名称同规格的产品")
|
||||||
|
|
||||||
|
product = Product(
|
||||||
|
product_name=payload.product_name,
|
||||||
|
spec_model=spec_model,
|
||||||
|
unit=payload.unit,
|
||||||
|
stock_quantity=_to_decimal(payload.stock_quantity),
|
||||||
|
sale_price=_to_decimal(payload.sale_price),
|
||||||
|
product_category=payload.product_category,
|
||||||
|
status=1,
|
||||||
|
remark=payload.remark,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
product_repository.create(session, product)
|
||||||
|
# 响应与后置动作全部在 commit 之前完成(见 business_role_service 同处说明)
|
||||||
|
out = _to_out(product)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 先查后写不是原子操作:并发提交同名称同规格会撞 uk_products_name_spec
|
||||||
|
session.rollback()
|
||||||
|
_raise_if_duplicate(exc)
|
||||||
|
|
||||||
|
logger.info("新增产品 | product_id=%s | category=%s", product.id, payload.product_category)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def update_product(
|
||||||
|
session: Session,
|
||||||
|
product_id: int,
|
||||||
|
payload: ProductUpdateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ProductOut:
|
||||||
|
"""编辑产品基本信息(全量替换,不含状态;启停走独立端点)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 产品不存在
|
||||||
|
:raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品
|
||||||
|
"""
|
||||||
|
product = product_repository.get_by_id(session, product_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PRODUCT,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"修改产品 {product.product_name if product else f'#{product_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 值域校验在审计上下文内:类别非法的 400 也要留痕(同 create 口径)
|
||||||
|
_ensure_category(payload.product_category)
|
||||||
|
if product is None:
|
||||||
|
raise NotFoundError("产品不存在")
|
||||||
|
|
||||||
|
product.product_name = payload.product_name
|
||||||
|
product.spec_model = payload.spec_model or ""
|
||||||
|
product.unit = payload.unit
|
||||||
|
product.stock_quantity = _to_decimal(payload.stock_quantity)
|
||||||
|
product.sale_price = _to_decimal(payload.sale_price)
|
||||||
|
product.product_category = payload.product_category
|
||||||
|
product.remark = payload.remark
|
||||||
|
out = _to_out(product)
|
||||||
|
try:
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 改名/改规格可能撞 uk_products_name_spec(本行改值与他人既有数据重合)
|
||||||
|
session.rollback()
|
||||||
|
_raise_if_duplicate(exc)
|
||||||
|
|
||||||
|
logger.info("修改产品 | product_id=%s | category=%s", product_id, payload.product_category)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def set_status(
|
||||||
|
session: Session,
|
||||||
|
product_id: int,
|
||||||
|
status: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ProductOut:
|
||||||
|
"""启用或停用产品。停用为主(模型约定),物理删除仅限误录数据。
|
||||||
|
|
||||||
|
:raises NotFoundError: 产品不存在
|
||||||
|
"""
|
||||||
|
product = product_repository.get_by_id(session, product_id)
|
||||||
|
verb = "启用" if status == 1 else "停用"
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PRODUCT,
|
||||||
|
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
|
||||||
|
summary=f"{verb}产品 {product.product_name if product else f'#{product_id}'}",
|
||||||
|
request_data={"status": status},
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if product is None:
|
||||||
|
raise NotFoundError("产品不存在")
|
||||||
|
|
||||||
|
product.status = status
|
||||||
|
out = _to_out(product)
|
||||||
|
session.commit()
|
||||||
|
logger.info("变更产品状态 | product_id=%s | status=%s", product_id, status)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_product(
|
||||||
|
session: Session,
|
||||||
|
product_id: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""物理删除产品(仅限误录数据,常规下架用停用)。
|
||||||
|
|
||||||
|
被业务明细/发货明细引用(RESTRICT 外键)的产品禁删:先 count_refs
|
||||||
|
给出可读 400(引导改走停用),校验与删除之间的并发引用由锁行 +
|
||||||
|
IntegrityError 兜底(customer_service.delete_customer 同款结构)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 产品不存在
|
||||||
|
:raises BadRequestError: 产品已被业务或发货明细引用
|
||||||
|
"""
|
||||||
|
product = product_repository.get_by_id(session, product_id, for_update=True)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_PRODUCT,
|
||||||
|
action=ACTION_DELETE,
|
||||||
|
summary=f"删除产品 {product.product_name if product else f'#{product_id}'}",
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if product is None:
|
||||||
|
raise NotFoundError("产品不存在")
|
||||||
|
if product_repository.count_refs(session, product_id):
|
||||||
|
raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)")
|
||||||
|
|
||||||
|
product_name = product.product_name # 固化:对象随删除失效
|
||||||
|
try:
|
||||||
|
product_repository.delete(session, product)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 先查后删的并发窗口:新明细挂上了该产品,FK RESTRICT 兜底为 400
|
||||||
|
session.rollback()
|
||||||
|
raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)") from exc
|
||||||
|
logger.info("删除产品 | product_name=%s", product_name)
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""客户评级等级配置业务编排。
|
||||||
|
|
||||||
|
评级计算器定位(对照表 #25):仅打分工具、不影响绩效金额,评分记录不落库
|
||||||
|
——本表承载的等级映射是**判定表**,判定规则为按 sort_order 升序取首个
|
||||||
|
min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反会算错等级):
|
||||||
|
|
||||||
|
- 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串
|
||||||
|
到 Decimal 的转换与落库;0≤min≤max≤100 的区间校验在**审计上下文内**
|
||||||
|
完成,失败的 400 同样留痕(2026-10-13 定稿口径)
|
||||||
|
- 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表
|
||||||
|
失效。判定为**闭区间两两相交**:任一其他等级满足
|
||||||
|
``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。
|
||||||
|
边界相邻不算重叠(前段 max=89、后段 min=90)—— 种子 A/B/C/D 即按
|
||||||
|
相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order
|
||||||
|
靠前的等级,相邻无歧义
|
||||||
|
|
||||||
|
删除允许清空:等级表无下游引用(评分记录不落库,判定只读本表现值、不
|
||||||
|
回溯历史快照),物理删除无悬空风险;全部删空后计算器只显示总分、不出
|
||||||
|
等级(前端已对应提示),故不强制保留任何一条。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
ACTION_DELETE,
|
||||||
|
ACTION_UPDATE,
|
||||||
|
MODULE_RATING,
|
||||||
|
)
|
||||||
|
from app.core.db_errors import is_duplicate_entry
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.models import CustomerRatingGrade, User
|
||||||
|
from app.repositories import rating_repository
|
||||||
|
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
|
||||||
|
from app.services import audit_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 分值域上下界(Decimal 常量,禁用 float 参与比较)
|
||||||
|
_SCORE_FLOOR = Decimal("0")
|
||||||
|
_SCORE_CEIL = Decimal("100")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut:
|
||||||
|
"""ORM 对象转响应模型。分值 Decimal → str(分值红线出参口径)。"""
|
||||||
|
return RatingGradeOut(
|
||||||
|
id=grade.id,
|
||||||
|
grade_name=grade.grade_name,
|
||||||
|
min_score=str(grade.min_score),
|
||||||
|
max_score=str(grade.max_score),
|
||||||
|
sort_order=grade.sort_order,
|
||||||
|
remark=grade.remark,
|
||||||
|
create_time=grade.create_time,
|
||||||
|
update_time=grade.update_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_decimal(value: str) -> Decimal:
|
||||||
|
"""数字字符串转 Decimal。
|
||||||
|
|
||||||
|
入参已 str 化再进 Decimal:即使上游口径变化传入数字类型,也不经 float
|
||||||
|
二进制近似,分值红线在最后一道转换处仍然成立。schema 已保证可解析,
|
||||||
|
此处不再重复校验。
|
||||||
|
"""
|
||||||
|
return Decimal(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_score_range(min_score: Decimal, max_score: Decimal) -> None:
|
||||||
|
"""区间合法性:0 ≤ min_score ≤ max_score ≤ 100。
|
||||||
|
|
||||||
|
超界/倒挂任一不满足即「分值区间不合法」——统一一条消息不区分具体
|
||||||
|
越界方式,前端据此整体提示。须在审计上下文内调用,失败也要留痕。
|
||||||
|
"""
|
||||||
|
if not (_SCORE_FLOOR <= min_score <= max_score <= _SCORE_CEIL):
|
||||||
|
raise BadRequestError("分值区间不合法")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_no_overlap(
|
||||||
|
grades: list[CustomerRatingGrade],
|
||||||
|
new_min: Decimal,
|
||||||
|
new_max: Decimal,
|
||||||
|
*,
|
||||||
|
exclude_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""新区间与既有等级**不得重叠**(闭区间两两相交判定)。
|
||||||
|
|
||||||
|
任一其他等级满足 ``other.min_score <= new_max 且 new_min <= other.max_score``
|
||||||
|
即重叠,400 并点名冲突等级,便于前端定位到具体行。边界相邻不算重叠
|
||||||
|
(见模块 docstring)。exclude_id 供编辑时排除自身。
|
||||||
|
|
||||||
|
:raises BadRequestError: 分值区间与等级 X 重叠
|
||||||
|
"""
|
||||||
|
for other in grades:
|
||||||
|
if exclude_id is not None and other.id == exclude_id:
|
||||||
|
continue
|
||||||
|
if other.min_score <= new_max and new_min <= other.max_score:
|
||||||
|
raise BadRequestError(f"分值区间与等级 {other.grade_name} 重叠")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||||
|
"""判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。
|
||||||
|
|
||||||
|
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误语义未知,
|
||||||
|
原样上抛 —— 由全局 IntegrityError handler 收敛为 400(通用文案),
|
||||||
|
500 只留给程序缺陷。
|
||||||
|
"""
|
||||||
|
return is_duplicate_entry(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_if_duplicate(exc: IntegrityError) -> None:
|
||||||
|
"""唯一键冲突统一转业务提示;其余完整性错误原样重抛。"""
|
||||||
|
if _is_duplicate_entry(exc):
|
||||||
|
raise BadRequestError("该等级名称已存在") from exc
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
def list_grades(session: Session) -> list[RatingGradeOut]:
|
||||||
|
"""全量等级列表,按判定顺序(sort_order 升序)直出。
|
||||||
|
|
||||||
|
不分页:等级映射是判定表,数量个位数,分页无意义。
|
||||||
|
"""
|
||||||
|
return [_to_out(grade) for grade in rating_repository.list_all(session)]
|
||||||
|
|
||||||
|
|
||||||
|
def create_grade(
|
||||||
|
session: Session,
|
||||||
|
payload: RatingGradeCreateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> RatingGradeOut:
|
||||||
|
"""新增评级等级。
|
||||||
|
|
||||||
|
区间须 0≤min≤max≤100 且与既有等级不重叠(新增同样判重叠:判定表
|
||||||
|
语义下重叠即脏配置,没有「先停用旧区间」之类的两阶段配置手段)。
|
||||||
|
|
||||||
|
:raises BadRequestError: 分值区间不合法 / 分值区间与等级 X 重叠 / 该等级名称已存在
|
||||||
|
"""
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_RATING,
|
||||||
|
action=ACTION_CREATE,
|
||||||
|
summary=f"新增评级等级 {payload.grade_name}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 值域校验在审计上下文内:分值非法的 400 也要留痕(成功与失败都记)
|
||||||
|
new_min = _to_decimal(payload.min_score)
|
||||||
|
new_max = _to_decimal(payload.max_score)
|
||||||
|
_ensure_score_range(new_min, new_max)
|
||||||
|
_ensure_no_overlap(rating_repository.list_all(session, for_update=True), new_min, new_max)
|
||||||
|
if rating_repository.get_by_name(session, payload.grade_name) is not None:
|
||||||
|
raise BadRequestError("该等级名称已存在")
|
||||||
|
|
||||||
|
grade = CustomerRatingGrade(
|
||||||
|
grade_name=payload.grade_name,
|
||||||
|
min_score=new_min,
|
||||||
|
max_score=new_max,
|
||||||
|
sort_order=payload.sort_order,
|
||||||
|
remark=payload.remark,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
rating_repository.create(session, grade)
|
||||||
|
# 响应与后置动作全部在 commit 之前完成(见 product_service 同处说明)
|
||||||
|
out = _to_out(grade)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 先查后写不是原子操作:并发提交同名等级会撞 uk_customer_rating_grades_grade_name
|
||||||
|
session.rollback()
|
||||||
|
_raise_if_duplicate(exc)
|
||||||
|
|
||||||
|
logger.info("新增评级等级 | grade_id=%s | name=%s", grade.id, payload.grade_name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def update_grade(
|
||||||
|
session: Session,
|
||||||
|
grade_id: int,
|
||||||
|
payload: RatingGradeUpdateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> RatingGradeOut:
|
||||||
|
"""编辑评级等级(全量替换)。
|
||||||
|
|
||||||
|
区间与其他等级不得重叠 —— 重叠判定以**修改后的区间**对全部既有等级
|
||||||
|
(排除自身)两两相交,命中即 400 并点名冲突等级。
|
||||||
|
|
||||||
|
:raises NotFoundError: 评级等级不存在
|
||||||
|
:raises BadRequestError: 分值区间不合法 / 分值区间与等级 X 重叠 / 该等级名称已存在
|
||||||
|
"""
|
||||||
|
grade = rating_repository.get_by_id(session, grade_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_RATING,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"修改评级等级 {grade.grade_name if grade else f'#{grade_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 值域校验在审计上下文内(同 create 口径)
|
||||||
|
new_min = _to_decimal(payload.min_score)
|
||||||
|
new_max = _to_decimal(payload.max_score)
|
||||||
|
_ensure_score_range(new_min, new_max)
|
||||||
|
if grade is None:
|
||||||
|
raise NotFoundError("评级等级不存在")
|
||||||
|
|
||||||
|
_ensure_no_overlap(
|
||||||
|
rating_repository.list_all(session, for_update=True), new_min, new_max, exclude_id=grade_id
|
||||||
|
)
|
||||||
|
existed = rating_repository.get_by_name(session, payload.grade_name)
|
||||||
|
if existed is not None and existed.id != grade_id:
|
||||||
|
raise BadRequestError("该等级名称已存在")
|
||||||
|
|
||||||
|
grade.grade_name = payload.grade_name
|
||||||
|
grade.min_score = new_min
|
||||||
|
grade.max_score = new_max
|
||||||
|
grade.sort_order = payload.sort_order
|
||||||
|
grade.remark = payload.remark
|
||||||
|
out = _to_out(grade)
|
||||||
|
try:
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# 改名可能撞 uk_customer_rating_grades_grade_name(本行改值与他人既有数据重合)
|
||||||
|
session.rollback()
|
||||||
|
_raise_if_duplicate(exc)
|
||||||
|
|
||||||
|
logger.info("修改评级等级 | grade_id=%s | name=%s", grade_id, payload.grade_name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_grade(
|
||||||
|
session: Session,
|
||||||
|
grade_id: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""物理删除评级等级。
|
||||||
|
|
||||||
|
等级表无下游引用 —— 评级是纯计算器不落评分记录(对照表 #25),判定
|
||||||
|
只读本表现值、不回溯历史快照,故可物理删除且无需锁行防并发引用。
|
||||||
|
|
||||||
|
**允许清空**:全部删空后计算器只显示总分、不出等级(前端已对应提示),
|
||||||
|
不强制保留任何一条。
|
||||||
|
|
||||||
|
:raises NotFoundError: 评级等级不存在
|
||||||
|
"""
|
||||||
|
grade = rating_repository.get_by_id(session, grade_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_RATING,
|
||||||
|
action=ACTION_DELETE,
|
||||||
|
summary=f"删除评级等级 {grade.grade_name if grade else f'#{grade_id}'}",
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if grade is None:
|
||||||
|
raise NotFoundError("评级等级不存在")
|
||||||
|
|
||||||
|
grade_name = grade.grade_name # 固化:对象随删除失效
|
||||||
|
rating_repository.delete(session, grade)
|
||||||
|
session.commit()
|
||||||
|
logger.info("删除评级等级 | grade_name=%s", grade_name)
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"""回款管理业务编排(需求 2.2.3)。
|
||||||
|
|
||||||
|
事务边界在本层:repository 只 flush,commit/rollback 只写在这里。
|
||||||
|
|
||||||
|
- **写操作前 FOR UPDATE 锁业务行**(delete_business 先例):同业务的
|
||||||
|
发货/回款/编辑相互串行化,防止并发写后 ``_recalc_totals`` 重算
|
||||||
|
total_received_amount 互相覆盖(丢失更新);批次号生成(MAX+FOR
|
||||||
|
UPDATE 锁当日序号区间)与唯一键(1062)冲突重试一次兜底,模式同
|
||||||
|
business_service
|
||||||
|
- **shipment_id 同业务强校验**(数据完整性红线):指定的发货单必须存在
|
||||||
|
且与本回款同属一个业务 —— 跨业务指定会让回款天数与台账归属漂移;
|
||||||
|
存在性与归属两类失败统一同一文案(防通过响应差异探测发货单存在性,
|
||||||
|
与登录判定的防枚举同哲学),FK RESTRICT 是最终防线
|
||||||
|
- **settlement_status 是 M3 核算防重锚点**(需规 2.3.3「每笔回款仅核算
|
||||||
|
一次」):已核算回款禁改禁删 —— 改金额/删行会让 M3 台账与源数据漂移;
|
||||||
|
M2 只写入 0(未核算),置 1 由 M3 核算服务负责
|
||||||
|
- 回款合计回写统一收口 ``business_service._recalc_totals``(增删改后
|
||||||
|
都重算,禁各处手写 SUM)
|
||||||
|
- payment_days 为**只读派生值**:shipment_id 非空且发货单在库时按
|
||||||
|
``formula.payment_days(ship_date, receipt_date)`` 实时计算(发货日首日
|
||||||
|
计第 1 天),不落库;未指定批次为 None
|
||||||
|
- 一期禁负数金额(≥0):schema 拒绝;红字/退款冲销需求出现时先补设计
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
ACTION_DELETE,
|
||||||
|
ACTION_UPDATE,
|
||||||
|
MODULE_RECEIPT,
|
||||||
|
NO_PREFIX_RECEIPT,
|
||||||
|
)
|
||||||
|
from app.core.db_errors import is_duplicate_entry
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.core.response import PageResult
|
||||||
|
from app.models import Receipt
|
||||||
|
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||||
|
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
|
||||||
|
from app.repositories import business_repository, receipt_repository
|
||||||
|
from app.schemas.receipt import (
|
||||||
|
ReceiptCreateIn,
|
||||||
|
ReceiptOut,
|
||||||
|
ReceiptQuery,
|
||||||
|
ReceiptUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import audit_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
from app.services.business_service import _recalc_totals
|
||||||
|
from app.services.formula import payment_days
|
||||||
|
from app.services.no_generator import next_no
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY)
|
||||||
|
_NO_RETRY = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(row: tuple) -> ReceiptOut:
|
||||||
|
"""(receipt, business_no, shipment_no, ship_date) → 响应模型。
|
||||||
|
|
||||||
|
金额出参 str;payment_days 为派生值 —— ship_date 为 None(未指定批次)
|
||||||
|
时输出 None;回款日早于发货日时按公式照实输出负值,忠实反映源数据
|
||||||
|
(钳制会掩盖录入异常,M3 台账同口径)。
|
||||||
|
"""
|
||||||
|
receipt, business_no, shipment_no, ship_date = row
|
||||||
|
return ReceiptOut(
|
||||||
|
id=receipt.id,
|
||||||
|
receipt_batch_no=receipt.receipt_batch_no,
|
||||||
|
business_id=receipt.business_id,
|
||||||
|
business_no=business_no,
|
||||||
|
shipment_id=receipt.shipment_id,
|
||||||
|
shipment_no=shipment_no,
|
||||||
|
receipt_date=receipt.receipt_date,
|
||||||
|
amount=str(receipt.amount),
|
||||||
|
is_barter=receipt.is_barter,
|
||||||
|
settlement_status=receipt.settlement_status,
|
||||||
|
payment_days=(
|
||||||
|
payment_days(ship_date, receipt.receipt_date) if ship_date is not None else None
|
||||||
|
),
|
||||||
|
remark=receipt.remark,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_no(session: Session) -> str:
|
||||||
|
"""生成回款批次号(HK 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。"""
|
||||||
|
return next_no(session, model=Receipt, column_name="receipt_batch_no", prefix=NO_PREFIX_RECEIPT)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_shipment_of_business(
|
||||||
|
session: Session, shipment_id: int | None, business_id: int
|
||||||
|
) -> tuple[str | None, object | None]:
|
||||||
|
"""发货批次同业务强校验,返回 (shipment_no, ship_date) 供 DTO 组装。
|
||||||
|
|
||||||
|
「必须存在且 business_id 相同」两类失败统一同一文案(防探测发货单
|
||||||
|
存在性);shipment_id 为 None = 暂未指定,直接放行。
|
||||||
|
|
||||||
|
:raises BadRequestError: 发货批次不存在或不属于该业务
|
||||||
|
"""
|
||||||
|
if shipment_id is None:
|
||||||
|
return None, None
|
||||||
|
shipment = receipt_repository.get_shipment(session, shipment_id)
|
||||||
|
if shipment is None or shipment.business_id != business_id:
|
||||||
|
raise BadRequestError("发货批次不属于该业务,无法指定")
|
||||||
|
return shipment.shipment_no, shipment.ship_date
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_business_writable(session: Session, business_id: int, action: str):
|
||||||
|
"""锁业务行并要求状态=1 执行中(新增/编辑/删除共用的写准入)。
|
||||||
|
|
||||||
|
锁在判定之前取:判定结果基于锁定后的行,配合 _recalc_totals 的重算
|
||||||
|
同事务提交,并发写不会丢失更新。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
:raises BadRequestError: 业务已完成或已终止
|
||||||
|
"""
|
||||||
|
business = business_repository.get_by_id(session, business_id, for_update=True)
|
||||||
|
if business is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError(f"业务已完成或已终止,禁止{action}回款")
|
||||||
|
return business
|
||||||
|
|
||||||
|
|
||||||
|
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
|
||||||
|
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
|
||||||
|
rows = receipt_repository.list_receipts(
|
||||||
|
session,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
business_id=query.business_id,
|
||||||
|
date_from=query.date_from,
|
||||||
|
date_to=query.date_to,
|
||||||
|
is_barter=query.is_barter,
|
||||||
|
settlement_status=query.settlement_status,
|
||||||
|
)
|
||||||
|
total = receipt_repository.count_receipts(
|
||||||
|
session,
|
||||||
|
business_id=query.business_id,
|
||||||
|
date_from=query.date_from,
|
||||||
|
date_to=query.date_to,
|
||||||
|
is_barter=query.is_barter,
|
||||||
|
settlement_status=query.settlement_status,
|
||||||
|
)
|
||||||
|
return PageResult[ReceiptOut](
|
||||||
|
records=[_to_out(row) for row in rows],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
|
||||||
|
"""回款详情(同列表 DTO,含 payment_days 派生值)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 回款不存在
|
||||||
|
"""
|
||||||
|
row = receipt_repository.get_with_refs(session, receipt_id)
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("回款不存在")
|
||||||
|
return _to_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
def create_receipt(
|
||||||
|
session: Session,
|
||||||
|
payload: ReceiptCreateIn,
|
||||||
|
*,
|
||||||
|
operator,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ReceiptOut:
|
||||||
|
"""新增回款:锁业务行 → 校验 → 建行 + 重算合计,一个事务。
|
||||||
|
|
||||||
|
摘要三要素(批次号/业务号/金额)在进入审计前就绪:业务号取自锁行
|
||||||
|
结果、批次号先行生成(SELECT MAX FOR UPDATE,不产生未提交变更)、
|
||||||
|
金额来自入参;引用校验(业务状态/发货批次同业务)在审计上下文内,
|
||||||
|
失败也留痕 —— 失败路径回滚释放已取序号,当日序号出现空洞无害
|
||||||
|
(唯一性不依赖连续)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
:raises BadRequestError: 业务已完成或已终止 / 发货批次不属于该业务
|
||||||
|
"""
|
||||||
|
business = business_repository.get_by_id(session, payload.business_id, for_update=True)
|
||||||
|
batch_no = _generate_no(session)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_RECEIPT,
|
||||||
|
action=ACTION_CREATE,
|
||||||
|
summary=(
|
||||||
|
f"新增回款 {batch_no}"
|
||||||
|
f"(业务 {business.business_no if business else f'#{payload.business_id}'},"
|
||||||
|
f"金额 {payload.amount})"
|
||||||
|
),
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 批次号唯一键冲突的重试整体收在审计上下文内(模式同
|
||||||
|
# business_service.create_business):rollback 释放旧锁,重试前重新
|
||||||
|
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
|
||||||
|
for attempt in range(_NO_RETRY + 1):
|
||||||
|
try:
|
||||||
|
if business is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError("业务已完成或已终止,禁止新增回款")
|
||||||
|
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||||
|
session, payload.shipment_id, payload.business_id
|
||||||
|
)
|
||||||
|
receipt = receipt_repository.create(
|
||||||
|
session,
|
||||||
|
Receipt(
|
||||||
|
receipt_batch_no=batch_no,
|
||||||
|
business_id=payload.business_id,
|
||||||
|
shipment_id=payload.shipment_id,
|
||||||
|
receipt_date=payload.receipt_date,
|
||||||
|
amount=Decimal(payload.amount),
|
||||||
|
is_barter=payload.is_barter,
|
||||||
|
settlement_status=SETTLEMENT_PENDING,
|
||||||
|
remark=payload.remark,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
||||||
|
new_id, new_no = receipt.id, batch_no # 固化:commit 后不碰 ORM
|
||||||
|
session.commit()
|
||||||
|
break
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
if _is_duplicate_entry(exc) and attempt < _NO_RETRY:
|
||||||
|
business = business_repository.get_by_id(
|
||||||
|
session, payload.business_id, for_update=True
|
||||||
|
)
|
||||||
|
batch_no = _generate_no(session)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
|
||||||
|
new_id,
|
||||||
|
new_no,
|
||||||
|
payload.business_id,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def update_receipt(
|
||||||
|
session: Session,
|
||||||
|
receipt_id: int,
|
||||||
|
payload: ReceiptUpdateIn,
|
||||||
|
*,
|
||||||
|
operator,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ReceiptOut:
|
||||||
|
"""编辑回款(全量替换)+ 重算合计,一个事务。
|
||||||
|
|
||||||
|
**已核算回款禁止修改**(M3 防重锚点):M3 台账已按该行固化核算,
|
||||||
|
改金额/到账日/批次指定会让台账与源数据漂移。shipment_id 变更后
|
||||||
|
重新做同业务强校验(全量替换语义下每次编辑都校验,无「未变即免检」
|
||||||
|
的旁路)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 回款不存在 / 业务不存在
|
||||||
|
:raises BadRequestError: 已核算禁改 / 业务已完成或已终止 / 发货批次不属于该业务
|
||||||
|
"""
|
||||||
|
receipt = receipt_repository.get_by_id(session, receipt_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_RECEIPT,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"修改回款 {receipt.receipt_batch_no if receipt else f'#{receipt_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if receipt is None:
|
||||||
|
raise NotFoundError("回款不存在")
|
||||||
|
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||||
|
raise BadRequestError("已核算回款禁止修改")
|
||||||
|
business = _ensure_business_writable(session, receipt.business_id, "修改")
|
||||||
|
# 校验返回的 (shipment_no, ship_date) 即新批次 DTO 所需 —— shipment_id
|
||||||
|
# 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join
|
||||||
|
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||||
|
session, payload.shipment_id, receipt.business_id
|
||||||
|
)
|
||||||
|
|
||||||
|
receipt.receipt_date = payload.receipt_date
|
||||||
|
receipt.shipment_id = payload.shipment_id
|
||||||
|
receipt.amount = Decimal(payload.amount)
|
||||||
|
receipt.is_barter = payload.is_barter
|
||||||
|
receipt.remark = payload.remark
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
||||||
|
batch_no = receipt.receipt_batch_no
|
||||||
|
session.commit()
|
||||||
|
# commit 后不再触碰 ORM 属性(audit_service 约定):批次号已固化
|
||||||
|
logger.info("修改回款 | receipt_id=%s | no=%s", receipt_id, batch_no)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_receipt(
|
||||||
|
session: Session,
|
||||||
|
receipt_id: int,
|
||||||
|
*,
|
||||||
|
operator,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""删除回款(物理删除)+ 重算合计,一个事务。
|
||||||
|
|
||||||
|
**已核算回款禁止删除**(M3 防重锚点,理由同编辑);业务须状态=1
|
||||||
|
执行中 —— 终态业务的回款是 M3 核算与报表的既成事实,不得删改。
|
||||||
|
|
||||||
|
:raises NotFoundError: 回款不存在 / 业务不存在
|
||||||
|
:raises BadRequestError: 已核算禁删 / 业务已完成或已终止
|
||||||
|
"""
|
||||||
|
receipt = receipt_repository.get_by_id(session, receipt_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_RECEIPT,
|
||||||
|
action=ACTION_DELETE,
|
||||||
|
summary=f"删除回款 {receipt.receipt_batch_no if receipt else f'#{receipt_id}'}",
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if receipt is None:
|
||||||
|
raise NotFoundError("回款不存在")
|
||||||
|
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||||
|
raise BadRequestError("已核算回款禁止删除")
|
||||||
|
business = _ensure_business_writable(session, receipt.business_id, "删除")
|
||||||
|
|
||||||
|
batch_no = receipt.receipt_batch_no
|
||||||
|
receipt_repository.delete(session, receipt)
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
session.commit()
|
||||||
|
# commit 后不再触碰 ORM 属性(audit_service 约定):批次号已固化
|
||||||
|
logger.info("删除回款 | receipt_id=%s | no=%s", receipt_id, batch_no)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||||
|
"""识别 MySQL 唯一键冲突(1062),与 business_service 同口径。"""
|
||||||
|
return is_duplicate_entry(exc)
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"""发货管理业务编排(需求 2.2.2)。
|
||||||
|
|
||||||
|
事务边界在本层:repository 只 flush,commit/rollback 只写在这里。
|
||||||
|
|
||||||
|
- 行金额(amount)与发货单总金额(total_amount)服务层按 数量×单价 计算
|
||||||
|
固化,不信任前端传值;单价可空时行金额记 0(对齐业务明细口径)
|
||||||
|
- 业务级汇总(发货/运费合计)回写统一收口 ``business_service._recalc_totals``,
|
||||||
|
本模块不另写聚合逻辑;发货单 total_amount 由 repository 行 SUM 重算
|
||||||
|
- **写操作前 FOR UPDATE 锁业务行**(receipt_service 同款写准入):发货与
|
||||||
|
回款并发写同一业务时相互串行化,防 ``_recalc_totals`` 重算互相覆盖
|
||||||
|
(丢失更新);锁序统一「业务行 → 单据编号区间」,与业务/回款同构
|
||||||
|
- 业务状态门槛:仅执行中(1)业务可写发货单 —— 终态业务的发货单禁改禁删
|
||||||
|
(一期保守口径:编辑不影响已核算回款、核算未发生,但会改业务级汇总
|
||||||
|
基线,设计未定项待业务确认后可放开终态编辑)
|
||||||
|
- 删除保护:被回款批次指定(receipts.shipment_id)的发货单禁删,先解除
|
||||||
|
回款的批次指定;删除连带明细行并重算业务级汇总
|
||||||
|
- 编号 SH 生成复用 no_generator.next_no;残余竞态以唯一键(1062)冲突
|
||||||
|
重试一次兜底,模式同 business_service.create_business
|
||||||
|
- 审计:锁行与编号生成放在进入审计前(不产生未提交变更),状态校验在
|
||||||
|
审计上下文内,失败也留痕 —— 失败回滚释放已取序号,当日序号空洞无害
|
||||||
|
(唯一性不依赖连续);ship_date 是回款天数基准日,编辑即时生效(M3 按
|
||||||
|
编辑后基准日实时派生,无固化快照可漂移)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
ACTION_DELETE,
|
||||||
|
ACTION_UPDATE,
|
||||||
|
MODULE_SHIPMENT,
|
||||||
|
NO_PREFIX_SHIPMENT,
|
||||||
|
)
|
||||||
|
from app.core.db_errors import is_duplicate_entry
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
from app.core.response import PageResult
|
||||||
|
from app.models import Business, Shipment, ShipmentItem, User
|
||||||
|
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||||
|
from app.repositories import business_repository, shipment_repository
|
||||||
|
from app.schemas.shipment import (
|
||||||
|
ShipmentCreateIn,
|
||||||
|
ShipmentDetailOut,
|
||||||
|
ShipmentItemIn,
|
||||||
|
ShipmentItemOut,
|
||||||
|
ShipmentOut,
|
||||||
|
ShipmentQuery,
|
||||||
|
ShipmentUpdateIn,
|
||||||
|
)
|
||||||
|
from app.services import audit_service
|
||||||
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
|
from app.services.business_service import _recalc_totals
|
||||||
|
from app.services.no_generator import next_no
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY)
|
||||||
|
_NO_RETRY = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(row: tuple[Shipment, str]) -> ShipmentOut:
|
||||||
|
"""(shipment, business_no) → 响应模型;金额出参 str。"""
|
||||||
|
shipment, business_no = row
|
||||||
|
return ShipmentOut(
|
||||||
|
id=shipment.id,
|
||||||
|
shipment_no=shipment.shipment_no,
|
||||||
|
business_id=shipment.business_id,
|
||||||
|
business_no=business_no,
|
||||||
|
ship_date=shipment.ship_date,
|
||||||
|
freight_amount=str(shipment.freight_amount),
|
||||||
|
total_amount=str(shipment.total_amount),
|
||||||
|
remark=shipment.remark,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _item_to_out(item: ShipmentItem) -> ShipmentItemOut:
|
||||||
|
return ShipmentItemOut(
|
||||||
|
id=item.id,
|
||||||
|
product_id=item.product_id,
|
||||||
|
product_name=item.product_name,
|
||||||
|
spec_model=item.spec_model,
|
||||||
|
unit=item.unit,
|
||||||
|
quantity=str(item.quantity),
|
||||||
|
sale_price=str(item.sale_price) if item.sale_price is not None else None,
|
||||||
|
amount=str(item.amount),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _amount_or_zero(value: str | None) -> Decimal:
|
||||||
|
"""可空金额入参转 Decimal,空记 0(运费可缺省的统一归一)。"""
|
||||||
|
return Decimal(value) if value is not None else Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_item_rows(items: list[ShipmentItemIn]) -> list[ShipmentItem]:
|
||||||
|
"""入参明细行转 ORM 行:amount 服务层按 数量×单价 计算固化。
|
||||||
|
|
||||||
|
不信任前端传值(schema 亦无 amount 入参)——单价可空时行金额记 0,
|
||||||
|
由用户在编辑中补价后重算;shipment_id 由 replace_items 统一回填。
|
||||||
|
乘积显式 quantize 两位固化(模式同 business_service._build_item_rows):
|
||||||
|
数量(2位)×单价(4位) 可产 6 位小数,直写 DECIMAL(14,2) 会被 MySQL
|
||||||
|
静默舍入且回显漂移。
|
||||||
|
"""
|
||||||
|
rows: list[ShipmentItem] = []
|
||||||
|
for item in items:
|
||||||
|
quantity = Decimal(item.quantity)
|
||||||
|
sale_price = Decimal(item.sale_price) if item.sale_price is not None else None
|
||||||
|
amount = (
|
||||||
|
(quantity * sale_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
if sale_price is not None
|
||||||
|
else Decimal("0.00")
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
ShipmentItem(
|
||||||
|
product_id=item.product_id,
|
||||||
|
product_name=item.product_name,
|
||||||
|
spec_model=item.spec_model or "",
|
||||||
|
unit=item.unit,
|
||||||
|
quantity=quantity,
|
||||||
|
sale_price=sale_price,
|
||||||
|
amount=amount,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_no(session: Session) -> str:
|
||||||
|
"""生成发货单号(SH 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。"""
|
||||||
|
return next_no(session, model=Shipment, column_name="shipment_no", prefix=NO_PREFIX_SHIPMENT)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_business_writable(session: Session, business_id: int, action: str) -> Business:
|
||||||
|
"""锁业务行并要求状态=1 执行中(新增/编辑/删除共用的写准入)。
|
||||||
|
|
||||||
|
锁在判定之前取:判定结果基于锁定后的行,配合 _recalc_totals 的重算
|
||||||
|
同事务提交,并发写不会丢失更新。与 receipt_service._ensure_business_writable
|
||||||
|
同构 —— 文案单据名落到发货单(私有函数各自保留,不为一个字符串上提
|
||||||
|
公共层)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
:raises BadRequestError: 业务已完成或已终止
|
||||||
|
"""
|
||||||
|
business = business_repository.get_by_id(session, business_id, for_update=True)
|
||||||
|
if business is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError(f"业务已完成或已终止,禁止{action}")
|
||||||
|
return business
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||||
|
"""识别 MySQL 唯一键冲突(1062),与 business/receipt_service 同口径。"""
|
||||||
|
return is_duplicate_entry(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
|
||||||
|
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
|
||||||
|
rows = shipment_repository.list_shipments(
|
||||||
|
session,
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
business_id=query.business_id,
|
||||||
|
ship_date_from=query.ship_date_from,
|
||||||
|
ship_date_to=query.ship_date_to,
|
||||||
|
)
|
||||||
|
total = shipment_repository.count_shipments(
|
||||||
|
session,
|
||||||
|
business_id=query.business_id,
|
||||||
|
ship_date_from=query.ship_date_from,
|
||||||
|
ship_date_to=query.ship_date_to,
|
||||||
|
)
|
||||||
|
return PageResult[ShipmentOut](
|
||||||
|
records=[_to_out(row) for row in rows],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_detail(session: Session, shipment_id: int) -> ShipmentDetailOut:
|
||||||
|
"""发货单详情(附明细行,供编辑抽屉回显)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 发货单不存在
|
||||||
|
"""
|
||||||
|
row = shipment_repository.get_with_business_no(session, shipment_id)
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("发货单不存在")
|
||||||
|
out = ShipmentDetailOut(**_to_out(row).model_dump())
|
||||||
|
out.items = [
|
||||||
|
_item_to_out(item) for item in shipment_repository.list_items(session, shipment_id)
|
||||||
|
]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def create_shipment(
|
||||||
|
session: Session,
|
||||||
|
payload: ShipmentCreateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ShipmentOut:
|
||||||
|
"""新增发货单:锁业务行 → 校验 → 建单 + 明细 + 双重汇总回写,一个事务。
|
||||||
|
|
||||||
|
摘要两号(发货单号/业务号)在进入审计前就绪:业务号取自锁行结果、
|
||||||
|
发货单号先行生成(SELECT MAX FOR UPDATE,不产生未提交变更);状态
|
||||||
|
校验在审计上下文内,失败也留痕 —— 失败路径回滚释放已取序号,当日
|
||||||
|
序号出现空洞无害(唯一性不依赖连续)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 业务不存在
|
||||||
|
:raises BadRequestError: 业务已完成或已终止,禁止新发货
|
||||||
|
"""
|
||||||
|
business = business_repository.get_by_id(session, payload.business_id, for_update=True)
|
||||||
|
shipment_no = _generate_no(session)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_SHIPMENT,
|
||||||
|
action=ACTION_CREATE,
|
||||||
|
summary=(
|
||||||
|
f"新增发货单 {shipment_no}"
|
||||||
|
f"(业务 {business.business_no if business else f'#{payload.business_id}'},"
|
||||||
|
f"明细 {len(payload.items)} 行)"
|
||||||
|
),
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
# 单号唯一键冲突的重试整体收在审计上下文内(模式同
|
||||||
|
# business_service.create_business):rollback 释放旧锁,重试前重新
|
||||||
|
# 锁业务行与生成编号——成功/最终失败各只记一条审计,无递归
|
||||||
|
for attempt in range(_NO_RETRY + 1):
|
||||||
|
try:
|
||||||
|
if business is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError("业务已完成或已终止,禁止新发货")
|
||||||
|
|
||||||
|
shipment = Shipment(
|
||||||
|
shipment_no=shipment_no,
|
||||||
|
business_id=payload.business_id,
|
||||||
|
ship_date=payload.ship_date,
|
||||||
|
freight_amount=_amount_or_zero(payload.freight_amount),
|
||||||
|
total_amount=Decimal("0"),
|
||||||
|
remark=payload.remark,
|
||||||
|
)
|
||||||
|
shipment_repository.create(session, shipment)
|
||||||
|
shipment_repository.replace_items(
|
||||||
|
session, shipment.id, _build_item_rows(payload.items)
|
||||||
|
)
|
||||||
|
total = shipment_repository.sum_item_amount(session, shipment.id)
|
||||||
|
shipment.total_amount = total if total is not None else Decimal("0")
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
out = _to_out((shipment, business.business_no))
|
||||||
|
new_id, new_no = shipment.id, shipment_no # 固化:commit 后不碰 ORM
|
||||||
|
session.commit()
|
||||||
|
break
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
if _is_duplicate_entry(exc) and attempt < _NO_RETRY:
|
||||||
|
business = business_repository.get_by_id(
|
||||||
|
session, payload.business_id, for_update=True
|
||||||
|
)
|
||||||
|
shipment_no = _generate_no(session)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"新增发货单 | shipment_id=%s | no=%s | business_id=%s",
|
||||||
|
new_id,
|
||||||
|
new_no,
|
||||||
|
payload.business_id,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def update_shipment(
|
||||||
|
session: Session,
|
||||||
|
shipment_id: int,
|
||||||
|
payload: ShipmentUpdateIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> ShipmentOut:
|
||||||
|
"""编辑发货单(明细全量替换)+ 双重汇总回写,一个事务。
|
||||||
|
|
||||||
|
business_id 不在编辑入参内 —— 发货单挂靠的业务创建时定死,改挂会让
|
||||||
|
已指定本发货单的回款语义断裂。终态业务的发货单禁改(与回款写准入同
|
||||||
|
口径;一期保守处理,设计未定项待业务确认后可放开——发货编辑不影响
|
||||||
|
已核算回款,核算未发生,无固化快照可漂移)。
|
||||||
|
|
||||||
|
:raises NotFoundError: 发货单不存在 / 业务不存在
|
||||||
|
:raises BadRequestError: 业务已完成或已终止
|
||||||
|
"""
|
||||||
|
shipment = shipment_repository.get_by_id(session, shipment_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_SHIPMENT,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"修改发货单 {shipment.shipment_no if shipment else f'#{shipment_id}'}",
|
||||||
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if shipment is None:
|
||||||
|
raise NotFoundError("发货单不存在")
|
||||||
|
business = _ensure_business_writable(session, shipment.business_id, "编辑发货单")
|
||||||
|
|
||||||
|
shipment.ship_date = payload.ship_date
|
||||||
|
shipment.freight_amount = _amount_or_zero(payload.freight_amount)
|
||||||
|
shipment.remark = payload.remark
|
||||||
|
shipment_repository.replace_items(session, shipment_id, _build_item_rows(payload.items))
|
||||||
|
total = shipment_repository.sum_item_amount(session, shipment_id)
|
||||||
|
shipment.total_amount = total if total is not None else Decimal("0")
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
# business_id 创建时定死不可改挂,锁定的 business.business_no 与重查同源
|
||||||
|
out = _to_out((shipment, business.business_no))
|
||||||
|
shipment_no = shipment.shipment_no
|
||||||
|
session.commit()
|
||||||
|
# commit 后不再触碰 ORM 属性(audit_service 约定):单号已固化
|
||||||
|
logger.info("修改发货单 | shipment_id=%s | no=%s", shipment_id, shipment_no)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_shipment(
|
||||||
|
session: Session,
|
||||||
|
shipment_id: int,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""删除发货单(物理删除,连带明细行)+ 业务级汇总回写,一个事务。
|
||||||
|
|
||||||
|
被回款批次指定(receipts.shipment_id 引用)→ 400 引导先解除回款的
|
||||||
|
批次指定;业务须状态=1 执行中(终态业务的发货单禁删,与回款写准入
|
||||||
|
同口径)—— 发货单的运费/金额是业务级汇总与 M3 核算的源数据,终态后
|
||||||
|
不得变动。
|
||||||
|
|
||||||
|
:raises NotFoundError: 发货单不存在 / 业务不存在
|
||||||
|
:raises BadRequestError: 业务已完成或已终止 / 已被回款批次指定
|
||||||
|
"""
|
||||||
|
shipment = shipment_repository.get_by_id(session, shipment_id)
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_SHIPMENT,
|
||||||
|
action=ACTION_DELETE,
|
||||||
|
summary=f"删除发货单 {shipment.shipment_no if shipment else f'#{shipment_id}'}",
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if shipment is None:
|
||||||
|
raise NotFoundError("发货单不存在")
|
||||||
|
business = _ensure_business_writable(session, shipment.business_id, "删除发货单")
|
||||||
|
if shipment_repository.count_receipt_refs(session, shipment_id):
|
||||||
|
raise BadRequestError("已被回款批次指定,请先解除回款的批次指定")
|
||||||
|
|
||||||
|
shipment_no = shipment.shipment_no
|
||||||
|
shipment_repository.delete_with_items(session, shipment_id)
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
session.commit()
|
||||||
|
# commit 后不再触碰 ORM 属性(audit_service 约定):单号已固化
|
||||||
|
logger.info("删除发货单 | shipment_id=%s | no=%s", shipment_id, shipment_no)
|
||||||
@@ -34,6 +34,7 @@ dependencies = [
|
|||||||
# 图形验证码(纯 Python,依赖 Pillow)
|
# 图形验证码(纯 Python,依赖 Pillow)
|
||||||
"captcha>=0.6",
|
"captcha>=0.6",
|
||||||
"pillow>=11.0",
|
"pillow>=11.0",
|
||||||
|
"openpyxl>=3.1.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
+56
-4
@@ -1,6 +1,6 @@
|
|||||||
# 后端测试
|
# 后端测试
|
||||||
|
|
||||||
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 301 个用例全绿(单元 223 / 集成 78)。
|
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 603 个用例全绿(单元 381 / 集成 222)。
|
||||||
|
|
||||||
## 一、测试约定
|
## 一、测试约定
|
||||||
|
|
||||||
@@ -81,6 +81,38 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| `integration/test_health.py` | 统一信封与全局异常 | 7 | `/api/health` 始终 200 且依赖状态如实上报;404/405 返回统一信封而非框架默认 `detail` 数组;`/api/docs` 可访问 |
|
| `integration/test_health.py` | 统一信封与全局异常 | 7 | `/api/health` 始终 200 且依赖状态如实上报;404/405 返回统一信封而非框架默认 `detail` 数组;`/api/docs` 可访问 |
|
||||||
| `integration/test_schema_sync.py` | DDL 不漂移 | 6 | `db/migration/V1__*.sql` 与 `db/init/init-dev.sql` 的**表集合与列集合逐表比对**——两份 DDL 手工同步迟早漂移,且漂移后果隐蔽(开发库结构与生产不一致,上线才发现) |
|
| `integration/test_schema_sync.py` | DDL 不漂移 | 6 | `db/migration/V1__*.sql` 与 `db/init/init-dev.sql` 的**表集合与列集合逐表比对**——两份 DDL 手工同步迟早漂移,且漂移后果隐蔽(开发库结构与生产不一致,上线才发现) |
|
||||||
|
|
||||||
|
### M1 计提参数与岗位角色(2026-10-13 完成)
|
||||||
|
|
||||||
|
| 文件 | 守什么 | 用例数 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tests/integration/test_business_roles.py` | 岗位 CRUD 链路、内置岗位禁删禁停(种子 3 个)、模板编码校验、编辑换模板、权限 403、模板下拉 5 项 | 8 |
|
||||||
|
| `tests/integration/test_param_management.py` | 目录 16 项与七分组、企业户 2‰→3‰ 按到账日回溯、斜率 4/360 分数原样存储、变更生命周期(立即/未来生效不泄漏/同日取最新/撤回回退/已生效禁撤)、值校验(上限/枚举/除零分数/过去日期)、预览金标准(27 天=97/10000、折算 15000、绩效 145.5、可付 52.38;361 天=4‰)、经理聚合口径拒绝、权限 403 | 18 |
|
||||||
|
| `tests/unit/test_formula.py`、`tests/unit/test_param_registry.py` | 八个公式纯函数精确分数断言(第 1 天=899/90000、360=6‰、361=4‰、27 天、防除零、负绩效不归零、R×R)、注册表 16 项与迁移种子同源、模板声明参数闭环 | 43 |
|
||||||
|
| `tests/unit/test_param_snapshot.py` | 快照覆盖链(角色>产品类别>默认)、注册表默认值兜底、同日取最新、冻结不可变 | 12 |
|
||||||
|
|
||||||
|
运行:`uv run pytest -q` → **406 passed**(2026-10-13 全量含回归;T5 冒烟另验证提交/生效/撤回/对比/导出/预览全链路)。
|
||||||
|
|
||||||
|
### 基础数据管理(2026-10-13 完成)
|
||||||
|
|
||||||
|
| 文件 | 守什么 | 用例数 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
|
||||||
|
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 |
|
||||||
|
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕 | 14 |
|
||||||
|
|
||||||
|
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。
|
||||||
|
|
||||||
|
### 业务数据管理(2026-10-13 完成)
|
||||||
|
|
||||||
|
| 文件 | 守什么 | 用例数 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tests/integration/test_businesses.py` | 未登录 401;编号 `YW{YYYYMMDD}-{4位}` 与当日序号递增;空明细/数量 0/非法类别/单价非数字 400;**销售额服务层固化**(schema 无 sale_amount 入参,塞了忽略);特殊业务必填说明;编辑全量替换后**三处冗余一致**(明细 SUM == 业务冗余列 == DB SUM);状态机(终止无原因 400 → 留痕 old=1/new=3 → 终态再变更 400);归属变更主表+留痕同生(old/new owner 与 assistant、owner_type=2)、归属未变化 400、创建时归属人员已离职 400;有发货/回款禁换客户禁删;空业务删除连带清理明细/状态留痕/业务归属留痕;毛利润视图金标准(销售 10000/成本 30 → 率 997/1000、市政 30% → 折算 6970;**销售额 0 全 0 防除零**;**成本缺失行**派生为 null 不计类别汇总;**asOf 按业务日期回溯旧阈值**);审计成功/失败双记录 | 25 |
|
||||||
|
| `tests/integration/test_shipments.py` | 未登录 401;编号 `SH{YYYYMMDD}-{4位}`;业务不存在 404;终态业务禁新发货 400;空明细/数量 0/运费负数/运费三位小数 400;**行金额与发货单总额服务层固化**;业务 total_ship/total_freight 回写一致;编辑后两处汇总同步;被回款指定禁删(解除后可删);删除后业务级汇总回退;终态业务发货单禁改禁删;审计双记录 | 15 |
|
||||||
|
| `tests/integration/test_receipts.py` | 未登录 401;批次 `HK{YYYYMMDD}-{4位}`;未指定批次可登记且 payment_days 为 null;**指定批次派生「发货日首日计第 1 天」**(2026-10-05 → 2026-11-02 = 29 天,编辑清指定后回 null);业务不存在 404 / 终态禁新增 400;金额负数/三位小数 400;**跨业务指定批次 400**(「发货批次不属于该业务,无法指定」,编辑路同校验);合计回写与删除回退;**已核算(改库 settlement_status=1)禁改禁删**;settlement_status 筛选 0/1 两路;审计双记录 | 16 |
|
||||||
|
| `tests/integration/test_ratings.py` | 未登录 401;种子四等级全量按 sort_order 升序(A 90-100/B 75-89/C 60-74/D 0-59);区间倒挂/超上限/负下限 → 400「分值区间不合法」;名称唯一 → 400「该等级名称已存在」;**新增与编辑两路重叠 400 点名冲突等级**;**边界相邻不算重叠正例**(删 C 后新增 60-74 / 把 D 编辑为 0-74,74 与 75 相邻放行);编辑改名改区间回显;删除后判定表更新、二次删除 404;审计双记录。种子删改由 conftest 兜底恢复 | 14 |
|
||||||
|
|
||||||
|
运行:`uv run pytest tests/integration/test_{businesses,shipments,receipts,ratings}.py -q` → **70 passed**(2026-10-13,连跑两次幂等);全量回归 `uv run pytest -q` → **603 passed**(单元 381 / 集成 222)。
|
||||||
|
|
||||||
## 三、运行记录
|
## 三、运行记录
|
||||||
|
|
||||||
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
||||||
@@ -90,6 +122,11 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 |
|
| 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 |
|
||||||
| 2026-10-10 | 全部 | 282 | ✅ 通过(103.81s) | **系统管理模块(用户/角色/日志)完成**后统一编写该模块套件(新增 91 例:单元 48 / 集成 43)。含操作日志审计机制专项;**e2e 发现并修复 `replace_roles` 唯一键缺陷**(见四、缺陷表);集成用例需容器在跑 |
|
| 2026-10-10 | 全部 | 282 | ✅ 通过(103.81s) | **系统管理模块(用户/角色/日志)完成**后统一编写该模块套件(新增 91 例:单元 48 / 集成 43)。含操作日志审计机制专项;**e2e 发现并修复 `replace_roles` 唯一键缺陷**(见四、缺陷表);集成用例需容器在跑 |
|
||||||
| 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) |
|
| 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) |
|
||||||
|
| 2026-10-13 | 基础数据集成三文件 | 48 | ✅ 通过(27.60s) | **基础数据管理模块完成**后统一编写(人员 20/产品 14/客户 14)。审计上下文内外的校验位置差异为本次新发现的缺陷类别(见下) |
|
||||||
|
| 2026-10-13 | 业务数据集成四文件 | 70 | ✅ 通过(54.73s,连跑两次幂等) | **业务数据管理(后端接口)完成**后统一编写(业务 25/发货 15/回款 16/评级 14)。全量回归 603 例通过 |
|
||||||
|
| 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 |
|
||||||
|
| 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 |
|
||||||
|
| 2026-10-15 | 全量回归 + e2e | 639 | ✅ 全部通过 | **code-review 32 条修复后全量回归**(新增 34 例:跨位宽 3 / 参数预览与维度 3 / 审计重试 2 / 终态编辑与 quantize 2 / 产品保护 2+6 / 评级锁 1 / formula 钳制 1 等)。e2e 冒烟:登录 8 项 + 系统管理 13 项 + 基础数据 13 步 + 业务全链路 16 步全过(拦截器/正则收口/params 分页改造后复跑) |
|
||||||
|
|
||||||
## 四、测试发现并修复的缺陷
|
## 四、测试发现并修复的缺陷
|
||||||
|
|
||||||
@@ -109,14 +146,29 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 2026-10-10 | `.gitignore` 的 `logs/` 规则吞掉日志查询页目录 | 规则无前导斜杠、匹配任意层级:`frontend/src/views/system/logs/` 整体被忽略,文件**进不了版本库**——干净克隆构建失败(本地因文件已在磁盘上而一切"正常",隐蔽性极强) | 收窄为 `/backend/logs/`(该规则本意即后端运行日志);以 `git check-ignore` 实证不再命中 |
|
| 2026-10-10 | `.gitignore` 的 `logs/` 规则吞掉日志查询页目录 | 规则无前导斜杠、匹配任意层级:`frontend/src/views/system/logs/` 整体被忽略,文件**进不了版本库**——干净克隆构建失败(本地因文件已在磁盘上而一切"正常",隐蔽性极强) | 收窄为 `/backend/logs/`(该规则本意即后端运行日志);以 `git check-ignore` 实证不再命中 |
|
||||||
| 2026-10-10 | 时间筛选未做时区归一 | 混用 aware/naive 抛 TypeError 穿透成 **500**;带时区输入被驱动静默丢弃偏移(查询窗口偏 8 小时且无提示) | `LogQueryBase` 加 field_validator:aware → `astimezone()` 本地 naive、naive 原样;补 4 例专项 |
|
| 2026-10-10 | 时间筛选未做时区归一 | 混用 aware/naive 抛 TypeError 穿透成 **500**;带时区输入被驱动静默丢弃偏移(查询窗口偏 8 小时且无提示) | `LogQueryBase` 加 field_validator:aware → `astimezone()` 本地 naive、naive 原样;补 4 例专项 |
|
||||||
| 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 |
|
| 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 |
|
||||||
|
| 2026-10-13 | 基础数据三 service 的**前置校验在审计上下文之外** | 校验失败(岗位停用/经理自引用/分类不合法等 400)完全不留操作日志,违背「成功与失败都记」的既定原则——与 2026-10-10 审计时序缺陷同源,新写的 service 又犯了(各自独立实现时口径漂移) | personnel/product/customer 三 service 的值域与引用校验统一移入 `audit_service.audit` 上下文内;集成用例锁定(审计成功与失败双记录) |
|
||||||
|
| 2026-10-13 | `change_status` 状态留痕的 old_status 取到**赋值后**的 ORM 属性 | 时间线记成 `old==new`(如 1→3 的变更记成 3→3)——「变更前状态」无法回溯,状态流转追溯失效。由集成用例断言 `(old,new)==(1,3)` 抓住 | 赋值前固化 `old_status = business.status` 再流转;应用日志一并修正;回归用例锁定 |
|
||||||
|
| 2026-10-13 | **客户/人员删除保护漏检业务引用**(T4 占位注释「待业务表就位后补充」在 T7 交付后无人补齐) | 被业务引用的客户、被业务归属(负责/协助)引用的人员删除 → FK RESTRICT 穿透成 **500**。**由 e2e 清理步骤暴露**(集成测试只覆盖了 T2/T4 当时的引用面,业务表就位后引用面扩大未回看) | customer 删除加 `count_business_refs` 检查 + IntegrityError 兜底 400;personnel 删除加 `count_business_roles_refs`(负责/协助合并统计);补 2 个集成用例锁定;文案更新「客户归属或业务归属引用」 |
|
||||||
|
| 2026-10-13 | **单价列(DECIMAL(14,4))被两位小数校验拒收**(前后端同源) | 编辑任何带精度的历史单价(回显 "5000.0000")被前端 `AMOUNT_PATTERN` 与后端 `_check_amount` 双双拒绝——**创建后单价带小数位的记录永远无法编辑**。**由 e2e「编辑业务」步骤暴露**(创建用整数、编辑回显带 4 位小数,恰好只在这条路径相遇) | 后端 `_check_amount` 加 `scale` 参数(金额/数量 2 位、单价类 4 位);前端业务/发货明细行单价类放行 4 位;集成用例锁定 |
|
||||||
|
| 2026-10-13 | 发货 `freight_amount` 漏做金额校验 | 负数/三位小数/超容量的运费直接落库(schema 对 quantity/sale_price 都复用了 `_check_amount`,唯独运费没套用),业务运费合计与报表随之错乱 | shipments.py schema 的 Create/Update 加 `field_validator("freight_amount")`(同套口径,文案「运费金额不能为负数 / 最多两位小数」);集成用例锁定 |
|
||||||
|
| 2026-10-15 | **计算预览把 ‰ 存储值当纯比例相乘**(企业户/信息员分支) | `enterprise.rate`=3(3‰)、`info.rate`=1(1‰)未经 ÷1000 直接乘——绩效金额放大 1000 倍(15000×3=45000)。**既有测试只覆盖业务员路径**(`salesperson_rate` 内部有 ÷1000),两分支零覆盖使缺陷存活;该预览是 M3 核算共用契约 | 两分支显式 `/1000` 并注释声明换算;补企业户/信息员精确数值用例锁定 |
|
||||||
|
| 2026-10-15 | **编号撞唯一键的重试写在 audit 上下文内 `return _retry_create`**(业务/发货/回款三处同构) | 吞异常后正常退出被记 `success=True`(假成功审计,业务实际已回滚);重入整个 `create_*` 违反「不支持嵌套」约定;`_NO_RETRY=1` 死常量致持续冲突时**无界递归**至 RecursionError | 改为 audit 上下文内 for 循环(上限 `_NO_RETRY`),rollback 后重新锁行与取号;新增集成用例锁定「成功/最终失败各恰好一条日志」 |
|
||||||
|
| 2026-10-15 | **单号 MAX 检索用 VARCHAR 字典序**(`no_generator`) | 序号破 4 位宽度(9999→10000)后字典序反转("-10000"<"-9999"),MAX 恒取旧值 → seq 恒重算同一号码 → 当日该前缀所有建单持续撞唯一键 | 排序改「长度优先、同级字典序」(同前缀下长度大即数值大);跨位宽/同宽/空表三用例锁定;param 版本号一并复用 |
|
||||||
|
| 2026-10-15 | **编辑业务全程无锁读 + 无终态门槛** | ① 「有发货禁换客户」走无锁快照读,与发货/回款写路径(FOR UPDATE)不互斥——并发新增发货可旁路保护;② 已完成/已终止业务仍可全量改明细并重算 M3 核算基线(发货/回款服务禁写终态,父单据反而放行) | `get_with_names` 加 `for_update`;update 补终态门槛;前端编辑按钮终态禁用;`has_docs` 单点收口(update/delete 共用) |
|
||||||
|
| 2026-10-15 | **删除产品无引用保护**(docstring「待 V5 补充」在 V5 就位后未兑现) | 被业务/发货明细引用的产品删除 → FK RESTRICT 穿透成 500(与 2026-10-13 客户/人员删除保护漏检同源——「占位注释交付后无人回看」第二次发生) | `count_refs`(两明细表)+ IntegrityError 兜底 400;**全局注册 IntegrityError handler** 兜底未来遗漏(漏网的引用冲突收敛 400 而非 500) |
|
||||||
|
| 2026-10-15 | **产品 schema 私写校验不查小数位与容量** | `sale_price` 用 `_parse_non_negative`(非负/有限),5 位小数被 Numeric(14,4) **静默截断**(舍入即算错钱)、超容量 500——与 fba8cfc 修掉的「schema 小数位口径」同源残留 | 复用 `_check_amount`(PRICE_MAX+scale=4 / AMOUNT_MAX);6 个秒级 schema 单测锁定 |
|
||||||
|
| 2026-10-15 | 行金额乘积(数量2位×单价4位=6位小数)直写 DECIMAL(14,2) | MySQL 静默舍入且回显漂移:创建响应读内存值 "5.166615"、复查读库值 "5.17"——同一行金额前后显示不一致 | 显式 `quantize(HALF_UP)` 固化(与 MySQL 舍入方向一致);回显/库值一致性用例锁定 |
|
||||||
|
| 2026-10-15 | 先款后货(payment_days 为负)计提比例线性外插 | 比例 = 起始 + \|天数\|×斜率,**高于起始值甚至超 100%**——M3 接入真实回款后直接多算绩效工资(预览被 schema 拦住、回款登记不校验日期先后,缺陷在 M3 前不可见) | `salesperson_rate` 钳制 days≥0(按起始比例计提;payment_days 出参仍照实输出负值,口径经用户 2026-10-15 裁决) |
|
||||||
|
| 2026-10-15 | 评级区间重叠先查后写无锁、无唯一键兜底 | 两管理员并发提交重叠区间互判不重叠而双双入库——同一总分命中多等级且结果取决于插入顺序 | 判重叠的 `list_all` 加 FOR UPDATE 锁全表行串行化;只读直出不加锁 |
|
||||||
|
| 2026-10-15 | 岗位维度值大小写校验(ci)与快照匹配(精确)不对称 | API 提交 "SalesPerson" 校验通过入库,快照字典按精确键取值不命中——**静默按错误参数算钱且无报错**;param_code 变形大写则 get_spec 抛 ValueError 成 500 | 校验命中后一律以库里规范值落库(role_code/definition.param_code);批量预载字典键 casefold 归一保持等价命中 |
|
||||||
|
|
||||||
## 五、待补用例
|
## 五、待补用例
|
||||||
|
|
||||||
| 模块 | 状态 | 说明 |
|
| 模块 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 基础数据管理 | 未开始 | 依赖 T+ 对接方式确认 |
|
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
||||||
| 业务数据管理 | 未开始 | 依赖需求澄清(回款天数口径等 10 项) |
|
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
||||||
| 绩效工资核算 | 未开始 | 依赖需求澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率) |
|
| 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) |
|
||||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
||||||
|
|
||||||
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
||||||
@@ -7,19 +7,57 @@
|
|||||||
- 测试账号/角色统一 ``t_`` 前缀;工厂夹具清理自己创建的对象,另有
|
- 测试账号/角色统一 ``t_`` 前缀;工厂夹具清理自己创建的对象,另有
|
||||||
autouse 兜底按前缀全量清理(用例经**接口**创建的数据不在工厂名下,
|
autouse 兜底按前缀全量清理(用例经**接口**创建的数据不在工厂名下,
|
||||||
失败中断时会残留并影响后续用例的 total 断言)
|
失败中断时会残留并影响后续用例的 total 断言)
|
||||||
|
- M2 基础数据(人员姓名/产品名/公司名/测试岗位编码)同用 ``t_`` 前缀,
|
||||||
|
兜底清理按前缀删除;归属留痕对客户/人员 ID 无外键固化、不随主表删除
|
||||||
|
消失,必须显式清理,否则残留会影响后续用例对留痕条数的断言
|
||||||
- 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id``
|
- 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id``
|
||||||
会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态
|
会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态
|
||||||
|
- M2 业务数据(业务/发货/回款)单据编号由系统生成(YW/SH/HK 前缀 + 当日
|
||||||
|
序号),**没有 t_ 前缀可用**,兜底清理按编号前缀全量清(三类单据表目前
|
||||||
|
只有测试会写);外键均为 RESTRICT,删除顺序不能倒,且必须在客户/人员
|
||||||
|
清理之前(FK 会挡住删客户/删人员);评级判定表则恢复种子基线 ——
|
||||||
|
评级用例会删改种子构造重叠/相邻场景,恢复保证跨文件幂等
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select, update
|
||||||
|
|
||||||
from app.core.constants import ROLE_ADMIN, ROLE_USER
|
from app.core.constants import ROLE_ADMIN, ROLE_USER
|
||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
from app.core.security import create_access_token, hash_password
|
from app.core.security import create_access_token, hash_password
|
||||||
from app.models import OperationLog, Role, User, UserRole
|
from app.models import (
|
||||||
|
Business,
|
||||||
|
BusinessItem,
|
||||||
|
BusinessRole,
|
||||||
|
BusinessStatusLog,
|
||||||
|
Customer,
|
||||||
|
CustomerRatingGrade,
|
||||||
|
OperationLog,
|
||||||
|
OwnershipChangeLog,
|
||||||
|
Personnel,
|
||||||
|
PersonnelPlan,
|
||||||
|
PersonnelRegion,
|
||||||
|
Product,
|
||||||
|
Receipt,
|
||||||
|
Role,
|
||||||
|
Shipment,
|
||||||
|
ShipmentItem,
|
||||||
|
User,
|
||||||
|
UserRole,
|
||||||
|
)
|
||||||
|
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||||
|
|
||||||
|
# 评级判定表的种子基线(与 db/migration/V5 一致):判定按 sort_order 升序
|
||||||
|
# 取首个 min_score ≤ 总分的等级,种子铺满 0-100
|
||||||
|
_RATING_SEEDS = [
|
||||||
|
("A", Decimal("90"), Decimal("100"), 1, "优秀"),
|
||||||
|
("B", Decimal("75"), Decimal("89"), 2, "良好"),
|
||||||
|
("C", Decimal("60"), Decimal("74"), 3, "合格"),
|
||||||
|
("D", Decimal("0"), Decimal("59"), 4, "待改进"),
|
||||||
|
]
|
||||||
|
|
||||||
TEST_PASSWORD = "TestPwd@2026"
|
TEST_PASSWORD = "TestPwd@2026"
|
||||||
# 模块级预计算一次:bcrypt(rounds=12)单次约 0.2–0.4 秒,逐用例重复哈希
|
# 模块级预计算一次:bcrypt(rounds=12)单次约 0.2–0.4 秒,逐用例重复哈希
|
||||||
@@ -53,6 +91,83 @@ def _purge_test_data() -> None:
|
|||||||
if role_ids:
|
if role_ids:
|
||||||
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
|
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
|
||||||
session.execute(delete(Role).where(Role.id.in_(role_ids)))
|
session.execute(delete(Role).where(Role.id.in_(role_ids)))
|
||||||
|
|
||||||
|
# ---- M2 业务数据(T11):业务/发货/回款三类单据 ----
|
||||||
|
# 编号系统生成、无 t_ 前缀可用,按业务编号前缀全量清(三类单据表目前
|
||||||
|
# 只有测试会写)。外键均为 RESTRICT,删除顺序不能倒:回款 → 发货
|
||||||
|
# 明细 → 发货单 → 业务明细 → 状态留痕 → 业务维度归属留痕(无外键,
|
||||||
|
# 须显式清)→ 业务主表;且必须在下方客户/人员清理之前,否则 FK 挡住
|
||||||
|
# 删客户/删人员(businesses.customer_id / .owner_personnel_id RESTRICT)
|
||||||
|
business_ids = list(
|
||||||
|
session.execute(select(Business.id).where(Business.business_no.like("YW%"))).scalars()
|
||||||
|
)
|
||||||
|
shipment_ids = list(
|
||||||
|
session.execute(select(Shipment.id).where(Shipment.shipment_no.like("SH%"))).scalars()
|
||||||
|
)
|
||||||
|
if business_ids:
|
||||||
|
session.execute(delete(Receipt).where(Receipt.business_id.in_(business_ids)))
|
||||||
|
if shipment_ids:
|
||||||
|
session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id.in_(shipment_ids)))
|
||||||
|
session.execute(delete(Shipment).where(Shipment.id.in_(shipment_ids)))
|
||||||
|
if business_ids:
|
||||||
|
session.execute(delete(BusinessItem).where(BusinessItem.business_id.in_(business_ids)))
|
||||||
|
session.execute(
|
||||||
|
delete(BusinessStatusLog).where(BusinessStatusLog.business_id.in_(business_ids))
|
||||||
|
)
|
||||||
|
session.execute(
|
||||||
|
delete(OwnershipChangeLog).where(
|
||||||
|
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
|
||||||
|
OwnershipChangeLog.business_id.in_(business_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.execute(delete(Business).where(Business.id.in_(business_ids)))
|
||||||
|
|
||||||
|
# 评级判定表恢复种子基线:评级用例会删改种子构造重叠/相邻场景,
|
||||||
|
# 兜底恢复保证每个用例开始时都是纯种子(判定表无下游引用,全删全插安全)
|
||||||
|
session.execute(delete(CustomerRatingGrade))
|
||||||
|
session.add_all(
|
||||||
|
CustomerRatingGrade(grade_name=n, min_score=lo, max_score=hi, sort_order=so, remark=rm)
|
||||||
|
for n, lo, hi, so, rm in _RATING_SEEDS
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- M2 基础数据:客户/归属留痕/人员/产品/测试岗位 ----
|
||||||
|
# 外键均为 RESTRICT,删除顺序不能倒:留痕无外键先按 customer_id 清,
|
||||||
|
# 再删客户(子表)→ 人员(先置空 manager_id 自引用、清子表)→ 产品
|
||||||
|
personnel_ids = list(
|
||||||
|
session.execute(
|
||||||
|
select(Personnel.id).where(Personnel.personnel_name.like("t\\_%"))
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
customer_ids = list(
|
||||||
|
session.execute(
|
||||||
|
select(Customer.id).where(Customer.company_name.like("t\\_%"))
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
if customer_ids:
|
||||||
|
session.execute(
|
||||||
|
delete(OwnershipChangeLog).where(OwnershipChangeLog.customer_id.in_(customer_ids))
|
||||||
|
)
|
||||||
|
session.execute(delete(Customer).where(Customer.id.in_(customer_ids)))
|
||||||
|
# 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条
|
||||||
|
session.execute(
|
||||||
|
delete(OwnershipChangeLog).where(OwnershipChangeLog.operator_name.like("t\\_%"))
|
||||||
|
)
|
||||||
|
if personnel_ids:
|
||||||
|
session.execute(
|
||||||
|
update(Personnel)
|
||||||
|
.where(Personnel.manager_id.in_(personnel_ids))
|
||||||
|
.values(manager_id=None)
|
||||||
|
)
|
||||||
|
session.execute(
|
||||||
|
delete(PersonnelRegion).where(PersonnelRegion.personnel_id.in_(personnel_ids))
|
||||||
|
)
|
||||||
|
session.execute(
|
||||||
|
delete(PersonnelPlan).where(PersonnelPlan.personnel_id.in_(personnel_ids))
|
||||||
|
)
|
||||||
|
session.execute(delete(Personnel).where(Personnel.id.in_(personnel_ids)))
|
||||||
|
session.execute(delete(Product).where(Product.product_name.like("t\\_%")))
|
||||||
|
# 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删
|
||||||
|
session.execute(delete(BusinessRole).where(BusinessRole.role_code.like("t\\_%")))
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""集成测试共用工厂:客户 → 业务 → 明细的最小造数链。
|
||||||
|
|
||||||
|
回款/发货/业务三个测试文件此前各持一份逐字相同的工厂(口径漂移已经
|
||||||
|
发生:同一 _customer 三份、payload 细节靠复制维持),收口后「t_ 前缀、
|
||||||
|
默认明细行、业务日期取今日」单点维护;conftest 兜底清理按 t_ 前缀与
|
||||||
|
YW/SH/HK 编号前缀覆盖本模块全部造数。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
_CUSTOMERS = "/api/v1/base/customers"
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
|
||||||
|
|
||||||
|
def customer(client: TestClient, headers: dict) -> dict:
|
||||||
|
"""建一名 t_ 前缀测试客户(业务的必选引用)。"""
|
||||||
|
resp = client.post(
|
||||||
|
_CUSTOMERS,
|
||||||
|
json={
|
||||||
|
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
|
||||||
|
"owner_name": "张老板",
|
||||||
|
"customer_type": "personal",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def business_item(**overrides: object) -> dict:
|
||||||
|
"""业务明细行入参(默认:数量 100 × 单价 100.00 = 销售额 10000)。"""
|
||||||
|
body: dict = {
|
||||||
|
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"quantity": "100",
|
||||||
|
"sale_price": "100.00",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def business_payload(customer_id: int, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"business_date": str(date.today()),
|
||||||
|
"items": [business_item()],
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def create_business(client: TestClient, headers: dict) -> dict:
|
||||||
|
"""建一名 t_ 客户并挂一笔默认业务(发货/回款须先有业务)。"""
|
||||||
|
cust = customer(client, headers)
|
||||||
|
resp = client.post(_BUSINESSES, json=business_payload(cust["id"]), headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""单据编号唯一键冲突重试的行为锁定(audit 上下文内循环重试)。
|
||||||
|
|
||||||
|
守:1062 兜底重试若以「吞异常后 return 重入整个 create_*」实现,会产生
|
||||||
|
三重缺陷 —— 外层审计把已回滚的首次尝试记成 success=True(假成功,与
|
||||||
|
audit_service.py「上下文内不得吞掉异常后正常退出」的约定相反)、重试再
|
||||||
|
开一层审计(违反「不支持嵌套」)、持续冲突时递归至 RecursionError。
|
||||||
|
修复口径:审计上下文内 for 循环重试(上限 _NO_RETRY),成功/最终失败
|
||||||
|
各恰好一条操作日志(以 POST 前的日志条数为基线断言增量)。
|
||||||
|
|
||||||
|
制造冲突的手段:monkeypatch ``business_service._generate_no`` 首次返回
|
||||||
|
库内已存在编号 → flush 撞唯一键 → 走重试分支。审计按 username 冗余溯
|
||||||
|
源(账号删除后 user_id 被 SET NULL),断言以基线增量免受既有数据干扰。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.main import app
|
||||||
|
from app.models import OperationLog
|
||||||
|
from app.services import business_service
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
_CUSTOMERS = "/api/v1/base/customers"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def quiet_client():
|
||||||
|
"""持续冲突用例依赖全局异常处理器产出 500 信封。
|
||||||
|
|
||||||
|
TestClient 默认对 ServerErrorMiddleware 的响应直接 re-raise
|
||||||
|
(raise_server_exceptions=True),会掩盖「原子处理器已正确收敛 500」
|
||||||
|
的行为本身 —— 审计断言反而无从谈起。
|
||||||
|
"""
|
||||||
|
return TestClient(app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _customer(client: TestClient, headers: dict) -> int:
|
||||||
|
resp = client.post(
|
||||||
|
_CUSTOMERS,
|
||||||
|
json={
|
||||||
|
"company_name": f"t_retry公司_{uuid.uuid4().hex[:8]}",
|
||||||
|
"owner_name": "张老板",
|
||||||
|
"customer_type": "personal",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return int(resp.json()["data"]["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def _business_payload(customer_id: int) -> dict:
|
||||||
|
return {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"business_date": "2026-01-01",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"quantity": "1.00",
|
||||||
|
"sale_price": "10.00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _log_count(username: str, *, success: bool) -> int:
|
||||||
|
"""按操作人统计「创建业务」操作日志条数(日志按 username 冗余追溯)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(OperationLog)
|
||||||
|
.where(
|
||||||
|
OperationLog.username == username,
|
||||||
|
OperationLog.module == MODULE_BUSINESS,
|
||||||
|
OperationLog.action == ACTION_CREATE,
|
||||||
|
OperationLog.summary.like("创建业务%"),
|
||||||
|
OperationLog.success == int(success),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def _baseline(normal: dict) -> tuple[int, int]:
|
||||||
|
"""POST 前的「创建业务」成功/失败日志条数基线(免受既有数据干扰)。"""
|
||||||
|
return (
|
||||||
|
_log_count(normal["username"], success=True),
|
||||||
|
_log_count(normal["username"], success=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuditRetry:
|
||||||
|
"""重试成功与持续失败两条路径的审计记录数。"""
|
||||||
|
|
||||||
|
def test_首次撞号重试成功_审计恰一条真成功(
|
||||||
|
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
client = quiet_client
|
||||||
|
customer_id = _customer(client, normal["headers"])
|
||||||
|
first = client.post(
|
||||||
|
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert first.status_code == 200, first.text
|
||||||
|
existing_no = first.json()["data"]["business_no"]
|
||||||
|
|
||||||
|
real_generate = business_service._generate_no
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def flaky_generate(session):
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] == 1:
|
||||||
|
return existing_no # 首次返回库内已存在编号 → flush 撞 1062
|
||||||
|
return real_generate(session)
|
||||||
|
|
||||||
|
monkeypatch.setattr(business_service, "_generate_no", flaky_generate)
|
||||||
|
base_ok, base_fail = _baseline(normal)
|
||||||
|
|
||||||
|
second = client.post(
|
||||||
|
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert second.status_code == 200, second.text
|
||||||
|
assert calls["n"] == 2, "重试应恰好再取一次号"
|
||||||
|
# 修复前的症状:外层审计正常退出记 success=True(假成功),重试成功
|
||||||
|
# 又记一条 —— 成功日志增量为 2;修复后成功恰 +1、失败 +0
|
||||||
|
assert _log_count(normal["username"], success=True) == base_ok + 1
|
||||||
|
assert _log_count(normal["username"], success=False) == base_fail
|
||||||
|
|
||||||
|
def test_持续冲突_最终失败且审计恰一条失败(
|
||||||
|
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
client = quiet_client
|
||||||
|
customer_id = _customer(client, normal["headers"])
|
||||||
|
first = client.post(
|
||||||
|
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert first.status_code == 200, first.text
|
||||||
|
existing_no = first.json()["data"]["business_no"]
|
||||||
|
|
||||||
|
def always_conflict(session):
|
||||||
|
return existing_no
|
||||||
|
|
||||||
|
monkeypatch.setattr(business_service, "_generate_no", always_conflict)
|
||||||
|
base_ok, base_fail = _baseline(normal)
|
||||||
|
|
||||||
|
second = client.post(
|
||||||
|
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
# 未捕获 IntegrityError 由全局 handler 收敛 400(数据完整性冲突)——
|
||||||
|
# 完整性冲突对用户是「操作被拒绝」而非「服务端异常」,400 更贴近事实
|
||||||
|
assert second.status_code == 400, second.text
|
||||||
|
assert "完整性冲突" in second.json()["message"]
|
||||||
|
# 修复前的症状:return _retry_create 无界递归直至 RecursionError,
|
||||||
|
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
|
||||||
|
assert _log_count(normal["username"], success=False) == base_fail + 1
|
||||||
|
assert _log_count(normal["username"], success=True) == base_ok
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""岗位角色管理接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是岗位管理的保护规则与模板绑定 —— 内置岗位被删停会让参数维度值
|
||||||
|
失效、核算模板断锚;模板编码不校验会让核算引擎取不到公式。
|
||||||
|
|
||||||
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core.constants import ROLE_USER
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_ROLES = "/api/v1/system/business-roles"
|
||||||
|
_TEMPLATES = "/api/v1/system/business-roles/meta/templates"
|
||||||
|
|
||||||
|
|
||||||
|
def _new_code() -> str:
|
||||||
|
return f"t_br_{uuid.uuid4().hex[:6]}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrudFlow:
|
||||||
|
"""正向链路:建 → 查(列表 + 模板下拉)→ 改 → 删。"""
|
||||||
|
|
||||||
|
def test_新增自定义岗位并联动列表(self, client: TestClient, admin: dict) -> None:
|
||||||
|
code = _new_code()
|
||||||
|
resp = client.post(
|
||||||
|
_ROLES,
|
||||||
|
json={
|
||||||
|
"role_code": code,
|
||||||
|
"role_name": "集成测试岗位",
|
||||||
|
"calc_template": "TPL_SALESPERSON_DECAY",
|
||||||
|
"description": "集成测试",
|
||||||
|
"sort_order": 50,
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()["data"]
|
||||||
|
assert body["role_code"] == code
|
||||||
|
assert body["is_builtin"] == 0
|
||||||
|
assert body["status"] == 1
|
||||||
|
|
||||||
|
listed = client.get(_ROLES, params={"keyword": code}, headers=admin["headers"])
|
||||||
|
assert any(r["role_code"] == code for r in listed.json()["data"]["list"])
|
||||||
|
|
||||||
|
# 清理
|
||||||
|
assert client.delete(f"{_ROLES}/{body['id']}", headers=admin["headers"]).status_code == 200
|
||||||
|
|
||||||
|
def test_重复编码拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
|
code = _new_code()
|
||||||
|
payload = {
|
||||||
|
"role_code": code,
|
||||||
|
"role_name": "首个",
|
||||||
|
"calc_template": "TPL_SALESPERSON_DECAY",
|
||||||
|
}
|
||||||
|
assert client.post(_ROLES, json=payload, headers=admin["headers"]).status_code == 200
|
||||||
|
dup = client.post(_ROLES, json=payload, headers=admin["headers"])
|
||||||
|
assert dup.status_code == 400
|
||||||
|
assert "已存在" in dup.json()["message"]
|
||||||
|
client.delete(
|
||||||
|
f"{_ROLES}/{client.get(_ROLES, params={'keyword': code}, headers=admin['headers']).json()['data']['list'][0]['id']}",
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_模板编码非法拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
_ROLES,
|
||||||
|
json={
|
||||||
|
"role_code": _new_code(),
|
||||||
|
"role_name": "坏模板岗位",
|
||||||
|
"calc_template": "TPL_NOT_EXIST",
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_编辑岗位换模板生效(self, client: TestClient, admin: dict) -> None:
|
||||||
|
code = _new_code()
|
||||||
|
created = client.post(
|
||||||
|
_ROLES,
|
||||||
|
json={
|
||||||
|
"role_code": code,
|
||||||
|
"role_name": "待改岗位",
|
||||||
|
"calc_template": "TPL_SALESPERSON_DECAY",
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
).json()["data"]
|
||||||
|
updated = client.put(
|
||||||
|
f"{_ROLES}/{created['id']}",
|
||||||
|
json={
|
||||||
|
"role_name": "已改岗位",
|
||||||
|
"calc_template": "TPL_ENTERPRISE_FIXED",
|
||||||
|
"sort_order": 7,
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
body = updated.json()["data"]
|
||||||
|
assert body["role_name"] == "已改岗位"
|
||||||
|
assert body["calc_template"] == "TPL_ENTERPRISE_FIXED"
|
||||||
|
assert body["role_code"] == code # 编码不变
|
||||||
|
client.delete(f"{_ROLES}/{created['id']}", headers=admin["headers"])
|
||||||
|
|
||||||
|
def test_模板下拉含五个模板且声明参数已注册(self, client: TestClient, admin: dict) -> None:
|
||||||
|
options = client.get(_TEMPLATES, headers=admin["headers"]).json()["data"]
|
||||||
|
assert len(options) == 5
|
||||||
|
for tpl in options:
|
||||||
|
assert tpl["params"], f"{tpl['code']} 未声明参数"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuiltinProtection:
|
||||||
|
"""内置岗位(种子 3 个)禁删禁停 —— 参数维度与模板按编码锚定。"""
|
||||||
|
|
||||||
|
def test_停用内置岗位_403(self, client: TestClient, admin: dict) -> None:
|
||||||
|
rows = client.get(
|
||||||
|
_ROLES, params={"keyword": "salesperson"}, headers=admin["headers"]
|
||||||
|
).json()["data"]["list"]
|
||||||
|
target = next(r for r in rows if r["role_code"] == "salesperson" and r["is_builtin"] == 1)
|
||||||
|
resp = client.put(
|
||||||
|
f"{_ROLES}/{target['id']}/status", json={"status": 0}, headers=admin["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_删除内置岗位_403(self, client: TestClient, admin: dict) -> None:
|
||||||
|
rows = client.get(
|
||||||
|
_ROLES, params={"keyword": "sales_manager"}, headers=admin["headers"]
|
||||||
|
).json()["data"]["list"]
|
||||||
|
target = next(r for r in rows if r["role_code"] == "sales_manager")
|
||||||
|
resp = client.delete(f"{_ROLES}/{target['id']}", headers=admin["headers"])
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_种子内置岗位三个且不可删标记正确(self, client: TestClient, admin: dict) -> None:
|
||||||
|
rows = client.get(_ROLES, params={"pageSize": 50}, headers=admin["headers"]).json()["data"][
|
||||||
|
"list"
|
||||||
|
]
|
||||||
|
builtins = [r for r in rows if r["is_builtin"] == 1]
|
||||||
|
assert {r["role_code"] for r in builtins} == {
|
||||||
|
"salesperson",
|
||||||
|
"sales_manager",
|
||||||
|
"info_officer",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermission:
|
||||||
|
"""权限:普通用户全部 403(当前默认管理员专用)。"""
|
||||||
|
|
||||||
|
def test_普通用户访问岗位列表_403(self, client: TestClient, make_user, auth_headers) -> None:
|
||||||
|
user = make_user([ROLE_USER])
|
||||||
|
headers = auth_headers(user)
|
||||||
|
assert client.get(_ROLES, headers=headers).status_code == 403
|
||||||
@@ -0,0 +1,861 @@
|
|||||||
|
"""业务管理接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是业务单的五处汇总冗余一致性与状态机/归属留痕 —— 明细/发货/回款任何
|
||||||
|
写路径漏算汇总,业务行的 total_* 就会与源数据漂移,M3 起付判定直接读冗余
|
||||||
|
列,漂移就是算错钱;状态留痕的「变更前状态」若取到赋值后的值(old==new
|
||||||
|
失真)或归属变更拆成「主表已换、留痕缺失」,事后追溯无据;编辑端点若能
|
||||||
|
改归属,就绕过留痕产生「改了归属却无留痕」的旁路。毛利润视图为只读派生
|
||||||
|
值,断言锁定金标准口径(销售额 0 防除零、成本缺失行不进类别汇总)。
|
||||||
|
|
||||||
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||||
|
|
||||||
|
数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;业务/
|
||||||
|
发货/回款编号系统生成(YW/SH/HK 前缀),conftest 按编号前缀全量清;
|
||||||
|
删除语义的用例自验 DELETE 端点。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
BUSINESS_ROLE_SALESPERSON,
|
||||||
|
MODULE_BUSINESS,
|
||||||
|
PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
||||||
|
PRODUCT_CATEGORY_RAIL_TRANSIT,
|
||||||
|
)
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import (
|
||||||
|
Business,
|
||||||
|
BusinessItem,
|
||||||
|
BusinessRole,
|
||||||
|
OperationLog,
|
||||||
|
OwnershipChangeLog,
|
||||||
|
ParamChangeBatch,
|
||||||
|
ParamValue,
|
||||||
|
)
|
||||||
|
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||||
|
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
_PERSONNEL = "/api/v1/base/personnel"
|
||||||
|
_CUSTOMERS = "/api/v1/base/customers"
|
||||||
|
_PARAMS = "/api/v1/system/params"
|
||||||
|
|
||||||
|
|
||||||
|
def _company() -> str:
|
||||||
|
return f"t_公司_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _personnel(client: TestClient, headers: dict) -> dict:
|
||||||
|
"""建一名 t_ 前缀的在职营销人员(归属变更的被引用方)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
role_id = int(
|
||||||
|
session.execute(
|
||||||
|
select(BusinessRole.id).where(
|
||||||
|
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
||||||
|
BusinessRole.status == 1,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
_PERSONNEL,
|
||||||
|
json={
|
||||||
|
"personnel_name": f"t_人员_{uuid.uuid4().hex[:8]}",
|
||||||
|
"business_role_id": role_id,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _customer(client: TestClient, headers: dict) -> dict:
|
||||||
|
"""建一名 t_ 前缀测试客户(业务的必选引用)。"""
|
||||||
|
resp = client.post(
|
||||||
|
_CUSTOMERS,
|
||||||
|
json={
|
||||||
|
"company_name": _company(),
|
||||||
|
"owner_name": "张老板",
|
||||||
|
"customer_type": "personal",
|
||||||
|
"address": "本溪市平山区",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _item(**overrides: object) -> dict:
|
||||||
|
"""业务明细行入参(默认:数量 100 × 单价 100.00 = 销售额 10000,成本 30)。"""
|
||||||
|
body: dict = {
|
||||||
|
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||||
|
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
||||||
|
"quantity": "100",
|
||||||
|
"sale_price": "100.00",
|
||||||
|
"cost_amount": "30.00",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(customer_id: int, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"business_date": str(date.today()),
|
||||||
|
"items": [_item()],
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _create(client: TestClient, headers: dict, customer_id: int, **overrides: object) -> dict:
|
||||||
|
resp = client.post(_BUSINESSES, json=_payload(customer_id, **overrides), headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate(client: TestClient, headers: dict, business_id: int) -> None:
|
||||||
|
"""把业务流转到已终止(终态禁写场景的造数步骤)。"""
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{business_id}/status",
|
||||||
|
json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _logs_of(username: str) -> list[OperationLog]:
|
||||||
|
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(OperationLog)
|
||||||
|
.where(OperationLog.username == username)
|
||||||
|
.order_by(OperationLog.id.desc())
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def threshold_batch(client: TestClient, admin: dict):
|
||||||
|
"""提交「立即生效」的产品阈值参数变更批次,用例结束整批清理。
|
||||||
|
|
||||||
|
走参数接口的真实路径(system params 要求管理员);清理按 batch_id 删
|
||||||
|
条目与批次头(test_param_management.track_batches 同款)。
|
||||||
|
"""
|
||||||
|
created: list[int] = []
|
||||||
|
|
||||||
|
def _factory(dimension_value: str, value: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"remark": "t_毛利润阈值变更",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"param_code": "product.threshold",
|
||||||
|
"dimension_value": dimension_value,
|
||||||
|
"value": value,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
batch = resp.json()["data"]
|
||||||
|
created.append(batch["id"])
|
||||||
|
return batch
|
||||||
|
|
||||||
|
yield _factory
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
if created:
|
||||||
|
session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(created)))
|
||||||
|
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(created)))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuth:
|
||||||
|
"""鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。"""
|
||||||
|
|
||||||
|
def test_未登录访问业务列表_401(self, client: TestClient) -> None:
|
||||||
|
resp = client.get(_BUSINESSES)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreate:
|
||||||
|
"""创建:编号契约、行校验与销售额固化 —— 编号序号错乱或行金额经前端
|
||||||
|
传值,列表检索与 M3 取数都会错乱。"""
|
||||||
|
|
||||||
|
def test_创建业务_编号格式与当日序号递增(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
first = _create(client, headers, customer["id"])
|
||||||
|
second = _create(client, headers, customer["id"])
|
||||||
|
|
||||||
|
# 编号格式:YW{YYYYMMDD}-{4位当日序号}(日期取当日,序号连续 +1)
|
||||||
|
expected_prefix = f"YW{date.today():%Y%m%d}-"
|
||||||
|
for no in (first["business_no"], second["business_no"]):
|
||||||
|
assert re.fullmatch(r"YW\d{8}-\d{4}", no), no
|
||||||
|
assert no.startswith(expected_prefix), (no, expected_prefix)
|
||||||
|
assert int(second["business_no"].rsplit("-", 1)[1]) == (
|
||||||
|
int(first["business_no"].rsplit("-", 1)[1]) + 1
|
||||||
|
), (first["business_no"], second["business_no"])
|
||||||
|
|
||||||
|
# 回包契约:默认执行中、客户名直出
|
||||||
|
assert first["status"] == BUSINESS_STATUS_ACTIVE
|
||||||
|
assert first["customer_name"] == customer["company_name"]
|
||||||
|
|
||||||
|
def test_空明细_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
customer = _customer(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES, json=_payload(customer["id"], items=[]), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
def test_行金额乘积显式两位固化_回显与库值一致(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""数量(2位)×单价(4位) 可产 6 位小数:显式 HALF_UP 固化,与 MySQL 的
|
||||||
|
DECIMAL(14,2) 舍入方向一致 —— 直写不 quantize 会被库端静默舍入,
|
||||||
|
创建响应(读内存值)与复查(读库值)前后显示不一致。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
customer["id"],
|
||||||
|
items=[_item(quantity="1.55", sale_price="3.3333", cost_amount=None)],
|
||||||
|
)
|
||||||
|
# 1.55 × 3.3333 = 5.166615 → 5.17(HALF_UP;MySQL 同值同向)
|
||||||
|
item = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]["items"][0]
|
||||||
|
assert item["sale_amount"] == "5.17"
|
||||||
|
assert Decimal(data["total_sale_amount"]) == Decimal("5.17")
|
||||||
|
|
||||||
|
# 库值与回显一致(无「内存 5.166615 / 库里 5.17」的漂移)
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
db_amount = session.execute(
|
||||||
|
select(BusinessItem.sale_amount).where(BusinessItem.business_id == data["id"])
|
||||||
|
).scalar_one()
|
||||||
|
assert db_amount == Decimal("5.17")
|
||||||
|
|
||||||
|
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
customer = _customer(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=_payload(customer["id"], items=[_item(quantity="0")]),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "数量" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_非法产品类别_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""类别是折算阈值的行级锚点:脏类别取不到阈值参数。"""
|
||||||
|
customer = _customer(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=_payload(customer["id"], items=[_item(product_category="water")]),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "产品类别不合法" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
customer = _customer(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=_payload(customer["id"], items=[_item(sale_price="abc")]),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "须为有效数字" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_销售额由服务层固化_入参sale_amount不生效(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
"""schema 无 sale_amount 入参,塞了也必须被忽略 —— 行金额只能由
|
||||||
|
数量×单价计算,否则前端传值直接改业务销售额。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
item = _item(quantity="3", sale_price="12.34", sale_amount="88888.00")
|
||||||
|
data = _create(client, headers, customer["id"], items=[item])
|
||||||
|
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(detail["items"][0]["sale_amount"]) == Decimal("37.02")
|
||||||
|
assert Decimal(detail["total_sale_amount"]) == Decimal("37.02")
|
||||||
|
|
||||||
|
def test_特殊业务缺说明_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
customer = _customer(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=_payload(customer["id"], is_special=1),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "特殊业务必须填写说明" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_特殊业务有说明创建成功_回显说明(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"], is_special=1, special_note="公司参与结算")
|
||||||
|
assert data["is_special"] == 1
|
||||||
|
assert data["special_note"] == "公司参与结算"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdit:
|
||||||
|
"""编辑:全量替换明细并单点重算汇总 —— 三处冗余(行 SUM / 业务冗余列 /
|
||||||
|
数据库 SUM)不一致就说明重算有旁路。"""
|
||||||
|
|
||||||
|
def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
customer["id"],
|
||||||
|
items=[
|
||||||
|
_item(quantity="100", sale_price="100.00", cost_amount="30.00"),
|
||||||
|
_item(quantity="50", sale_price="200.00", cost_amount="40.00"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
before = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(before["total_sale_amount"]) == Decimal("20000")
|
||||||
|
assert Decimal(before["total_cost_amount"]) == Decimal("70")
|
||||||
|
|
||||||
|
updated = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}",
|
||||||
|
json=_payload(
|
||||||
|
customer["id"],
|
||||||
|
items=[_item(quantity="200", sale_price="50.00", cost_amount="30.00")],
|
||||||
|
),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200, updated.text
|
||||||
|
assert Decimal(updated.json()["data"]["total_sale_amount"]) == Decimal("10000")
|
||||||
|
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert len(detail["items"]) == 1, "编辑是全量替换,旧行不得残留"
|
||||||
|
item_sum_sale = sum(Decimal(i["sale_amount"]) for i in detail["items"])
|
||||||
|
item_sum_cost = sum(Decimal(i["cost_amount"]) for i in detail["items"] if i["cost_amount"])
|
||||||
|
assert item_sum_sale == Decimal(detail["total_sale_amount"])
|
||||||
|
assert item_sum_cost == Decimal(detail["total_cost_amount"])
|
||||||
|
|
||||||
|
# 数据库侧再核一次 SUM(接口回包来自内存计算,两处同源才可靠)
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
db_business = session.get(Business, data["id"])
|
||||||
|
db_sale = (
|
||||||
|
session.execute(
|
||||||
|
select(BusinessItem.sale_amount).where(BusinessItem.business_id == data["id"])
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert sum(db_sale) == db_business.total_sale_amount == Decimal("10000.00")
|
||||||
|
|
||||||
|
def test_编辑端点塞owner不生效_归属唯一走ownership(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
"""归属刻意不出编辑端点:否则「改归属无留痕」的旁路破坏 append-only。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
owner = _personnel(client, headers)
|
||||||
|
someone = _personnel(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}",
|
||||||
|
json=_payload(customer["id"], owner_personnel_id=someone["id"]),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["data"]["owner_personnel_id"] == owner["id"], resp.text
|
||||||
|
|
||||||
|
logs = client.get(f"{_BUSINESSES}/{data['id']}/ownership-logs", headers=headers).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert logs["total"] == 0, "编辑端点改归属却无留痕"
|
||||||
|
|
||||||
|
def test_终态业务编辑_400且明细未动(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""终态门槛与发货/回款写准入同口径:明细/汇总是 M3 核算基线,终态
|
||||||
|
业务禁改 —— 父单据若放行,发货/回款服务「禁写终态」的防线就被绕过。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"])
|
||||||
|
_terminate(client, headers, data["id"])
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}",
|
||||||
|
json=_payload(
|
||||||
|
customer["id"],
|
||||||
|
items=[_item(quantity="999", sale_price="999.00")],
|
||||||
|
),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "已完成或已终止" in resp.json()["message"]
|
||||||
|
|
||||||
|
# 明细与汇总未被改写(护栏:校验在 replace_items 之前生效)
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(detail["total_sale_amount"]) == Decimal("10000")
|
||||||
|
assert len(detail["items"]) == 1
|
||||||
|
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatus:
|
||||||
|
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
|
||||||
|
|
||||||
|
def test_终止留痕_终态禁再变更(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"])
|
||||||
|
|
||||||
|
# 终止必填原因(schema 层校验,400)
|
||||||
|
no_reason = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/status", json={"status": 3}, headers=headers
|
||||||
|
)
|
||||||
|
assert no_reason.status_code == 400, no_reason.text
|
||||||
|
assert "终止业务必须填写原因" in no_reason.json()["message"], no_reason.text
|
||||||
|
|
||||||
|
done = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/status",
|
||||||
|
json={"status": 3, "reason": "客户取消订单"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert done.status_code == 200, done.text
|
||||||
|
assert done.json()["data"]["status"] == 3
|
||||||
|
|
||||||
|
# 终态不可再变更
|
||||||
|
again = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/status",
|
||||||
|
json={"status": 2, "reason": "强行翻转"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert again.status_code == 400, again.text
|
||||||
|
assert "不可再变更状态" in again.json()["message"], again.text
|
||||||
|
|
||||||
|
logs = client.get(f"{_BUSINESSES}/{data['id']}/status-logs", headers=headers).json()["data"]
|
||||||
|
assert logs["total"] == 2, logs["total"]
|
||||||
|
rows = logs["list"] # 正序:创建首条在前
|
||||||
|
assert (rows[0]["old_status"], rows[0]["new_status"]) == (1, 1)
|
||||||
|
assert (rows[1]["old_status"], rows[1]["new_status"]) == (1, 3)
|
||||||
|
assert rows[1]["change_reason"] == "客户取消订单"
|
||||||
|
assert rows[1]["operator_name"] == normal["username"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwnership:
|
||||||
|
"""归属变更:主表更新 + 留痕同事务;两者全相同则属无效操作。"""
|
||||||
|
|
||||||
|
def test_归属变更_主表更新与留痕同生(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
old_owner = _personnel(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"], owner_personnel_id=old_owner["id"])
|
||||||
|
|
||||||
|
new_owner = _personnel(client, headers)
|
||||||
|
new_assistant = _personnel(client, headers)
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/ownership",
|
||||||
|
json={
|
||||||
|
"new_owner_personnel_id": new_owner["id"],
|
||||||
|
"new_assistant_personnel_id": new_assistant["id"],
|
||||||
|
"reason": "人员调动",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()["data"]
|
||||||
|
assert body["owner_personnel_id"] == new_owner["id"]
|
||||||
|
assert body["owner_personnel_name"] == new_owner["personnel_name"]
|
||||||
|
assert body["assistant_personnel_id"] == new_assistant["id"]
|
||||||
|
|
||||||
|
logs = client.get(f"{_BUSINESSES}/{data['id']}/ownership-logs", headers=headers).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert logs["total"] == 1, "首次归属变更只产生一条留痕"
|
||||||
|
row = logs["list"][0]
|
||||||
|
assert row["old_owner_personnel_id"] == old_owner["id"]
|
||||||
|
assert row["new_owner_personnel_id"] == new_owner["id"]
|
||||||
|
assert row["old_assistant_personnel_id"] is None
|
||||||
|
assert row["new_assistant_personnel_id"] == new_assistant["id"]
|
||||||
|
assert row["old_owner_name"] == old_owner["personnel_name"]
|
||||||
|
assert row["new_owner_name"] == new_owner["personnel_name"]
|
||||||
|
assert row["change_reason"] == "人员调动"
|
||||||
|
assert row["operator_name"] == normal["username"]
|
||||||
|
|
||||||
|
# owner_type 由 DB 固化断言(响应 DTO 不含该列;2=业务归属)
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
log = session.execute(
|
||||||
|
select(OwnershipChangeLog).where(OwnershipChangeLog.business_id == data["id"])
|
||||||
|
).scalar_one()
|
||||||
|
assert log.owner_type == OWNER_TYPE_BUSINESS, (
|
||||||
|
f"owner_type 应为业务归属(2):{log.owner_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_归属未变化_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
owner = _personnel(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/ownership",
|
||||||
|
json={"new_owner_personnel_id": owner["id"], "reason": "无变化"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "归属未变化" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_创建时负责人员已离职_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
quit_owner = _personnel(client, headers)
|
||||||
|
off = client.put(
|
||||||
|
f"{_PERSONNEL}/{quit_owner['id']}/status", json={"status": 0}, headers=headers
|
||||||
|
)
|
||||||
|
assert off.status_code == 200, off.text
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=_payload(customer["id"], owner_personnel_id=quit_owner["id"]),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "负责人员不存在或已离职" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_归属端点负责人员不存在_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"])
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/ownership",
|
||||||
|
json={"new_owner_personnel_id": 999999999, "reason": "填错人"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "负责人员不存在或已离职" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomerProtect:
|
||||||
|
"""客户引用保护:发货/回款单据 FK 挂在业务上,换客户或删除会让单据
|
||||||
|
语义断裂 —— 服务层检查是唯一防线。"""
|
||||||
|
|
||||||
|
def test_有发货或回款时换客户_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
other = _customer(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"])
|
||||||
|
|
||||||
|
# 有发货记录:禁换
|
||||||
|
ship = client.post(
|
||||||
|
"/api/v1/business/shipments",
|
||||||
|
json={
|
||||||
|
"business_id": data["id"],
|
||||||
|
"ship_date": str(date.today()),
|
||||||
|
"items": [_item(product_name="t_货", quantity="1", sale_price="10.00")],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert ship.status_code == 200, ship.text
|
||||||
|
blocked_ship = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}",
|
||||||
|
json=_payload(other["id"]),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert blocked_ship.status_code == 400, blocked_ship.text
|
||||||
|
assert "已有发货或回款记录" in blocked_ship.json()["message"], blocked_ship.text
|
||||||
|
|
||||||
|
# 解除发货后仍有回款:禁换(同一路径 has_docs)
|
||||||
|
client.delete(f"/api/v1/business/shipments/{ship.json()['data']['id']}", headers=headers)
|
||||||
|
receipt = client.post(
|
||||||
|
"/api/v1/business/receipts",
|
||||||
|
json={
|
||||||
|
"business_id": data["id"],
|
||||||
|
"receipt_date": str(date.today()),
|
||||||
|
"amount": "100.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert receipt.status_code == 200, receipt.text
|
||||||
|
blocked_receipt = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}",
|
||||||
|
json=_payload(other["id"]),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert blocked_receipt.status_code == 400, blocked_receipt.text
|
||||||
|
assert "已有发货或回款记录" in blocked_receipt.json()["message"], blocked_receipt.text
|
||||||
|
|
||||||
|
def test_有发货或回款禁删_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"])
|
||||||
|
|
||||||
|
ship = client.post(
|
||||||
|
"/api/v1/business/shipments",
|
||||||
|
json={
|
||||||
|
"business_id": data["id"],
|
||||||
|
"ship_date": str(date.today()),
|
||||||
|
"items": [_item(product_name="t_货", quantity="1", sale_price="10.00")],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert ship.status_code == 200, ship.text
|
||||||
|
blocked_ship = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers)
|
||||||
|
assert blocked_ship.status_code == 400, blocked_ship.text
|
||||||
|
assert "请改为已终止而非删除" in blocked_ship.json()["message"], blocked_ship.text
|
||||||
|
|
||||||
|
# 解除发货后仍有回款:禁删(文案同一路径)
|
||||||
|
client.delete(f"/api/v1/business/shipments/{ship.json()['data']['id']}", headers=headers)
|
||||||
|
receipt = client.post(
|
||||||
|
"/api/v1/business/receipts",
|
||||||
|
json={
|
||||||
|
"business_id": data["id"],
|
||||||
|
"receipt_date": str(date.today()),
|
||||||
|
"amount": "100.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert receipt.status_code == 200, receipt.text
|
||||||
|
blocked_receipt = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers)
|
||||||
|
assert blocked_receipt.status_code == 400, blocked_receipt.text
|
||||||
|
assert "请改为已终止而非删除" in blocked_receipt.json()["message"], blocked_receipt.text
|
||||||
|
|
||||||
|
def test_空业务删除成功_连带清理明细与留痕(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
owner = _personnel(client, headers)
|
||||||
|
new_owner = _personnel(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
customer["id"],
|
||||||
|
owner_personnel_id=owner["id"],
|
||||||
|
items=[_item(quantity="1", sale_price="5.00")],
|
||||||
|
)
|
||||||
|
# 先造一次归属留痕,删除时须连带清理(无外键固化表)
|
||||||
|
moved = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/ownership",
|
||||||
|
json={"new_owner_personnel_id": new_owner["id"], "reason": "调动"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert moved.status_code == 200, moved.text
|
||||||
|
|
||||||
|
deleted = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers)
|
||||||
|
assert deleted.status_code == 200, deleted.text
|
||||||
|
assert client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).status_code == 404
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
assert session.get(Business, data["id"]) is None
|
||||||
|
assert (
|
||||||
|
session.execute(select(BusinessItem).where(BusinessItem.business_id == data["id"]))
|
||||||
|
.scalars()
|
||||||
|
.first()
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
# 状态留痕(business_status_logs)与归属留痕(owner_type=2)均无
|
||||||
|
# 外键固化,删除时必须显式清
|
||||||
|
assert (
|
||||||
|
session.execute(
|
||||||
|
select(OwnershipChangeLog).where(
|
||||||
|
OwnershipChangeLog.business_id == data["id"],
|
||||||
|
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.first()
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGrossProfit:
|
||||||
|
"""毛利润数据视图(只读派生):锁定金标准口径 —— 率用精确分数、
|
||||||
|
折算用化简式(率 0 / 销售额 0 防除零)、成本缺失行不进类别汇总。"""
|
||||||
|
|
||||||
|
def test_毛利润基准值_率与折算锁定精确分数(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""销售 10000 / 成本 30 → 利润 9970、率 997/1000、市政阈值 30% →
|
||||||
|
折算 10000 ×(997/1000 − 30/100) = 6970。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
customer["id"],
|
||||||
|
items=[_item(quantity="1", sale_price="10000.00", cost_amount="30.00")],
|
||||||
|
)
|
||||||
|
resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
view = resp.json()["data"]
|
||||||
|
assert view["as_of"] == str(date.today())
|
||||||
|
|
||||||
|
row = view["rows"][0]
|
||||||
|
assert Decimal(row["sale_amount"]) == Decimal("10000")
|
||||||
|
assert Decimal(row["cost_amount"]) == Decimal("30")
|
||||||
|
assert Decimal(row["profit_amount"]) == Decimal("9970")
|
||||||
|
assert row["profit_rate"] == "997/1000", "率须精确分数串(约分后),不得截断"
|
||||||
|
assert row["threshold"] == "30"
|
||||||
|
assert Decimal(row["converted_profit"]) == Decimal("6970")
|
||||||
|
assert row["missing_reason"] is None
|
||||||
|
assert view["missing_count"] == 0
|
||||||
|
|
||||||
|
cats = view["categories"]
|
||||||
|
assert [c["product_category"] for c in cats] == ["municipal_water"]
|
||||||
|
assert Decimal(cats[0]["sale_amount"]) == Decimal("10000")
|
||||||
|
assert Decimal(cats[0]["profit_amount"]) == Decimal("9970")
|
||||||
|
assert cats[0]["profit_rate"] == "997/1000"
|
||||||
|
assert Decimal(cats[0]["converted_profit"]) == Decimal("6970")
|
||||||
|
|
||||||
|
def test_毛利润_销售额0_全0不抛错_防除零(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""单价空 → 行销售额 0:率分母为 0 时必须返回 0 而非抛除零异常,
|
||||||
|
否则录入期(补价前)打开毛利润视图就是 500。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
customer["id"],
|
||||||
|
items=[_item(quantity="5", sale_price=None, cost_amount="0.00")],
|
||||||
|
)
|
||||||
|
resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
view = resp.json()["data"]
|
||||||
|
row = view["rows"][0]
|
||||||
|
assert Decimal(row["sale_amount"]) == Decimal("0")
|
||||||
|
assert Decimal(row["profit_amount"]) == Decimal("0")
|
||||||
|
assert row["profit_rate"] == "0"
|
||||||
|
assert Decimal(row["converted_profit"]) == Decimal("0")
|
||||||
|
assert view["missing_count"] == 0
|
||||||
|
|
||||||
|
cats = view["categories"]
|
||||||
|
assert [c["product_category"] for c in cats] == ["municipal_water"]
|
||||||
|
assert Decimal(cats[0]["sale_amount"]) == Decimal("0")
|
||||||
|
assert Decimal(cats[0]["converted_profit"]) == Decimal("0")
|
||||||
|
|
||||||
|
def test_毛利润_成本缺失行_派生为空不计入类别汇总(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
customer["id"],
|
||||||
|
items=[
|
||||||
|
_item(
|
||||||
|
product_name="t_产品_市政",
|
||||||
|
quantity="1",
|
||||||
|
sale_price="5000.00",
|
||||||
|
cost_amount="100.00",
|
||||||
|
),
|
||||||
|
_item(
|
||||||
|
product_name="t_产品_轨交",
|
||||||
|
product_category=PRODUCT_CATEGORY_RAIL_TRANSIT,
|
||||||
|
quantity="2",
|
||||||
|
sale_price=None,
|
||||||
|
cost_amount=None,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
view = resp.json()["data"]
|
||||||
|
assert view["missing_count"] == 1
|
||||||
|
|
||||||
|
rows = view["rows"]
|
||||||
|
assert rows[1]["cost_amount"] is None
|
||||||
|
assert rows[1]["profit_amount"] is None
|
||||||
|
assert rows[1]["profit_rate"] is None
|
||||||
|
assert rows[1]["converted_profit"] is None
|
||||||
|
assert "成本缺失" in (rows[1]["missing_reason"] or ""), rows[1]
|
||||||
|
assert rows[1]["threshold"] == "20"
|
||||||
|
# 可计算行正常派生
|
||||||
|
assert Decimal(rows[0]["profit_amount"]) == Decimal("4900")
|
||||||
|
assert rows[0]["profit_rate"] == "49/50"
|
||||||
|
|
||||||
|
cats = view["categories"]
|
||||||
|
assert [c["product_category"] for c in cats] == ["municipal_water"], "缺失行不得进类别汇总"
|
||||||
|
assert Decimal(cats[0]["sale_amount"]) == Decimal("5000")
|
||||||
|
assert Decimal(cats[0]["cost_amount"]) == Decimal("100")
|
||||||
|
assert Decimal(cats[0]["profit_amount"]) == Decimal("4900")
|
||||||
|
assert cats[0]["profit_rate"] == "49/50"
|
||||||
|
assert Decimal(cats[0]["converted_profit"]) == Decimal("3400")
|
||||||
|
|
||||||
|
def test_毛利润asOf按业务日期取参数时点值_旧日期取旧值(
|
||||||
|
self, client: TestClient, normal: dict, threshold_batch
|
||||||
|
) -> None:
|
||||||
|
"""改参数后按旧业务日期取值必须回溯旧阈值 —— 落库即漂移的「假快照」
|
||||||
|
靠 asOf 回溯参数快照,M3 台账才是法定固化点。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
customer["id"],
|
||||||
|
items=[_item(quantity="1", sale_price="10000.00", cost_amount="30.00")],
|
||||||
|
)
|
||||||
|
threshold_batch("municipal_water", "25") # 立即生效(今日)
|
||||||
|
|
||||||
|
today = client.get(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/gross-profit",
|
||||||
|
params={"as_of": str(date.today())},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert today.status_code == 200, today.text
|
||||||
|
row_today = today.json()["data"]["rows"][0]
|
||||||
|
assert row_today["threshold"] == "25"
|
||||||
|
# 25%:10000 ×(997/1000 − 25/100) = 7470
|
||||||
|
assert Decimal(row_today["converted_profit"]) == Decimal("7470")
|
||||||
|
|
||||||
|
past = client.get(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/gross-profit",
|
||||||
|
params={"as_of": "2026-02-17"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert past.status_code == 200, past.text
|
||||||
|
row_past = past.json()["data"]["rows"][0]
|
||||||
|
assert row_past["threshold"] == "30", "旧业务日期须回溯变更前的阈值"
|
||||||
|
assert Decimal(row_past["converted_profit"]) == Decimal("6970")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudit:
|
||||||
|
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||||
|
|
||||||
|
def test_新增业务成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
_create(client, headers, customer["id"])
|
||||||
|
logs = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_BUSINESS and log.action == ACTION_CREATE
|
||||||
|
]
|
||||||
|
assert logs, "新增业务未留操作日志"
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest.success == 1
|
||||||
|
assert "创建业务" in latest.summary
|
||||||
|
|
||||||
|
def test_归属人员不存在失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
customer = _customer(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=_payload(customer["id"], owner_personnel_id=999999999),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
failed = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_BUSINESS and log.success == 0
|
||||||
|
]
|
||||||
|
assert failed, "失败操作未留痕"
|
||||||
|
assert "负责人员不存在或已离职" in (failed[0].error_message or ""), failed[0]
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
"""客户管理接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是客户归属的一致性 —— 归属变更必须「主表更新 + 留痕新增」同事务
|
||||||
|
完成,拆开就会出现「主表已换人、留痕缺失」的静默不一致,事后追溯无据;
|
||||||
|
编辑端点若能触归属,就会出现「改了归属却无留痕」的旁路,破坏留痕的
|
||||||
|
append-only 完整性;「公司名称+负责人」组合唯一缺失会放行重复客户。
|
||||||
|
|
||||||
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||||
|
|
||||||
|
数据卫生:公司名与归属人员姓名一律 ``t_`` 前缀,由 conftest 兜底清理。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
BUSINESS_ROLE_SALESPERSON,
|
||||||
|
CUSTOMER_TYPE_ENTERPRISE,
|
||||||
|
CUSTOMER_TYPE_PERSONAL,
|
||||||
|
MODULE_CUSTOMER,
|
||||||
|
PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
||||||
|
)
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import BusinessRole, OperationLog, OwnershipChangeLog
|
||||||
|
from app.models.customer import OWNER_TYPE_CUSTOMER
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_PERSONNEL = "/api/v1/base/personnel"
|
||||||
|
_CUSTOMERS = "/api/v1/base/customers"
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
|
||||||
|
|
||||||
|
def _company() -> str:
|
||||||
|
return f"t_公司_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _role_id() -> int:
|
||||||
|
"""种子岗位(营销员):归属人员的合法岗位引用。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return int(
|
||||||
|
session.execute(
|
||||||
|
select(BusinessRole.id).where(
|
||||||
|
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
||||||
|
BusinessRole.status == 1,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _personnel(client: TestClient, headers: dict) -> dict:
|
||||||
|
"""建一名 t_ 前缀的在职营销人员(归属变更的被引用方)。"""
|
||||||
|
resp = client.post(
|
||||||
|
_PERSONNEL,
|
||||||
|
json={
|
||||||
|
"personnel_name": f"t_人员_{uuid.uuid4().hex[:8]}",
|
||||||
|
"business_role_id": _role_id(),
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _cpayload(company: str, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"company_name": company,
|
||||||
|
"owner_name": "张老板",
|
||||||
|
"customer_type": CUSTOMER_TYPE_PERSONAL,
|
||||||
|
"address": "本溪市平山区",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _create_customer(client: TestClient, headers: dict, **overrides: object) -> dict:
|
||||||
|
resp = client.post(_CUSTOMERS, json=_cpayload(_company(), **overrides), headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _logs_of(username: str) -> list[OperationLog]:
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(OperationLog)
|
||||||
|
.where(OperationLog.username == username)
|
||||||
|
.order_by(OperationLog.id.desc())
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_log(customer_id: int) -> OwnershipChangeLog | None:
|
||||||
|
"""取该客户最新一条归属留痕(按 id 倒序)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return (
|
||||||
|
session.execute(
|
||||||
|
select(OwnershipChangeLog)
|
||||||
|
.where(OwnershipChangeLog.customer_id == customer_id)
|
||||||
|
.order_by(OwnershipChangeLog.id.desc())
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuth:
|
||||||
|
"""鉴权:登录后接口,裸访问一律 401。"""
|
||||||
|
|
||||||
|
def test_未登录访问客户列表_401(self, client: TestClient) -> None:
|
||||||
|
resp = client.get(_CUSTOMERS)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrudFlow:
|
||||||
|
"""正向链路与组合唯一 ——「公司名称+负责人」是业务唯一标识。"""
|
||||||
|
|
||||||
|
def test_新增客户带初始归属_回显归属人员姓名(self, client: TestClient, normal: dict) -> None:
|
||||||
|
owner = _personnel(client, normal["headers"])
|
||||||
|
data = _create_customer(client, normal["headers"], owner_personnel_id=owner["id"])
|
||||||
|
assert data["owner_personnel_id"] == owner["id"]
|
||||||
|
assert data["owner_personnel_name"] == owner["personnel_name"]
|
||||||
|
assert data["customer_type"] == CUSTOMER_TYPE_PERSONAL
|
||||||
|
|
||||||
|
def test_同公司名不同负责人创建成功(self, client: TestClient, normal: dict) -> None:
|
||||||
|
company = _company()
|
||||||
|
first = _create_customer(client, normal["headers"], company_name=company)
|
||||||
|
second = client.post(
|
||||||
|
_CUSTOMERS,
|
||||||
|
json=_cpayload(company, owner_name="李老板"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert second.status_code == 200, second.text
|
||||||
|
assert second.json()["data"]["id"] != first["id"]
|
||||||
|
|
||||||
|
def test_重名同负责人_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
company = _company()
|
||||||
|
_create_customer(client, normal["headers"], company_name=company)
|
||||||
|
dup = client.post(
|
||||||
|
_CUSTOMERS,
|
||||||
|
json=_cpayload(company, owner_name="张老板"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert dup.status_code == 400, dup.text
|
||||||
|
assert "该公司名称与负责人的客户已存在" in dup.json()["message"]
|
||||||
|
|
||||||
|
def test_删除被业务引用的客户_400引导处理单据(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""业务表就位后删除保护补齐:FK RESTRICT 不能以 500 暴露。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _create_customer(client, headers)
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json={
|
||||||
|
"customer_id": customer["id"],
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"product_name": "t_被引用产品",
|
||||||
|
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
||||||
|
"quantity": "1",
|
||||||
|
"sale_price": "1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
blocked = client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers)
|
||||||
|
assert blocked.status_code == 400, blocked.text
|
||||||
|
assert "已存在关联业务" in blocked.json()["message"]
|
||||||
|
|
||||||
|
def test_客户分类非法_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
_CUSTOMERS,
|
||||||
|
json=_cpayload(_company(), customer_type="not_a_type"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "客户分类不合法" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_编辑其他字段不触归属_塞归属字段也被忽略(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
"""归属只有 /ownership 一条写路径 —— 编辑端点防「改归属不留痕」旁路。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
owner = _personnel(client, headers)
|
||||||
|
stranger = _personnel(client, headers)
|
||||||
|
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
|
||||||
|
|
||||||
|
updated = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}",
|
||||||
|
json={
|
||||||
|
"company_name": customer["company_name"],
|
||||||
|
"owner_name": customer["owner_name"],
|
||||||
|
"customer_type": CUSTOMER_TYPE_ENTERPRISE,
|
||||||
|
"address": "沈阳市沈河区",
|
||||||
|
# 试图经编辑端点改归属:schema 未声明该字段,必须被忽略
|
||||||
|
"ownerPersonnelId": stranger["id"],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200, updated.text
|
||||||
|
body = updated.json()["data"]
|
||||||
|
assert body["customer_type"] == CUSTOMER_TYPE_ENTERPRISE
|
||||||
|
assert body["address"] == "沈阳市沈河区"
|
||||||
|
assert body["owner_personnel_id"] == owner["id"], "编辑端点不得改动归属"
|
||||||
|
assert body["owner_personnel_name"] == owner["personnel_name"]
|
||||||
|
|
||||||
|
detail = client.get(f"{_CUSTOMERS}/{customer['id']}", headers=headers)
|
||||||
|
assert detail.json()["data"]["owner_personnel_id"] == owner["id"]
|
||||||
|
assert _latest_log(int(customer["id"])) is None, "编辑不应产生归属留痕"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwnership:
|
||||||
|
"""归属变更:主表更新与留痕同事务;校验失败不留脏数据。"""
|
||||||
|
|
||||||
|
def test_归属变更_主表更新且留痕新增(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
old = _personnel(client, headers)
|
||||||
|
new = _personnel(client, headers)
|
||||||
|
customer = _create_customer(client, headers, owner_personnel_id=old["id"])
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
||||||
|
json={"new_owner_personnel_id": new["id"], "reason": "区域调整"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()["data"]
|
||||||
|
assert body["owner_personnel_id"] == new["id"]
|
||||||
|
assert body["owner_personnel_name"] == new["personnel_name"]
|
||||||
|
|
||||||
|
log = _latest_log(int(customer["id"]))
|
||||||
|
assert log is not None, "归属变更未写留痕"
|
||||||
|
assert log.owner_type == OWNER_TYPE_CUSTOMER
|
||||||
|
assert log.customer_id == int(customer["id"])
|
||||||
|
assert log.old_owner_personnel_id == int(old["id"])
|
||||||
|
assert log.new_owner_personnel_id == int(new["id"])
|
||||||
|
assert log.change_reason == "区域调整"
|
||||||
|
assert log.operator_name == normal["username"]
|
||||||
|
|
||||||
|
def test_变更后归属人员已离职_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
target = _personnel(client, headers)
|
||||||
|
client.put(f"{_PERSONNEL}/{target['id']}/status", json={"status": 0}, headers=headers)
|
||||||
|
customer = _create_customer(client, headers)
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
||||||
|
json={"new_owner_personnel_id": target["id"]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "归属人员不存在或已离职" in resp.json()["message"]
|
||||||
|
assert _latest_log(int(customer["id"])) is None, "失败变更不应留痕"
|
||||||
|
|
||||||
|
def test_新旧归属相同_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
owner = _personnel(client, headers)
|
||||||
|
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
||||||
|
json={"new_owner_personnel_id": owner["id"]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "归属未变化" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_双方均为空_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""无归属客户再清一次归属也属「未变化」——不产生空转留痕。"""
|
||||||
|
customer = _create_customer(client, normal["headers"])
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
||||||
|
json={"new_owner_personnel_id": None},
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "归属未变化" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_变更为空即清空归属_主表与留痕一致(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
owner = _personnel(client, headers)
|
||||||
|
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
||||||
|
json={"new_owner_personnel_id": None, "reason": "暂无负责人"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()["data"]
|
||||||
|
assert body["owner_personnel_id"] is None
|
||||||
|
assert body["owner_personnel_name"] is None
|
||||||
|
|
||||||
|
log = _latest_log(int(customer["id"]))
|
||||||
|
assert log is not None
|
||||||
|
assert log.old_owner_personnel_id == int(owner["id"])
|
||||||
|
assert log.new_owner_personnel_id is None
|
||||||
|
assert log.change_reason == "暂无负责人"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwnershipLogs:
|
||||||
|
"""留痕分页:新旧归属人姓名由联表带出,倒序可追溯变更轨迹。"""
|
||||||
|
|
||||||
|
def test_留痕分页带新旧归属人姓名并倒序(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
first = _personnel(client, headers)
|
||||||
|
second = _personnel(client, headers)
|
||||||
|
customer = _create_customer(client, headers, owner_personnel_id=first["id"])
|
||||||
|
|
||||||
|
r1 = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
||||||
|
json={"new_owner_personnel_id": second["id"], "reason": "第一次调整"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r1.status_code == 200, r1.text
|
||||||
|
r2 = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
||||||
|
json={"new_owner_personnel_id": None, "reason": "清空归属"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
|
||||||
|
resp = client.get(f"{_CUSTOMERS}/{customer['id']}/ownership-logs", headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()["data"]
|
||||||
|
assert body["total"] == 2
|
||||||
|
latest, earlier = body["list"][0], body["list"][1]
|
||||||
|
assert latest["change_reason"] == "清空归属"
|
||||||
|
assert latest["old_owner_personnel_id"] == int(second["id"])
|
||||||
|
assert latest["old_owner_name"] == second["personnel_name"]
|
||||||
|
assert latest["new_owner_personnel_id"] is None
|
||||||
|
assert latest["new_owner_name"] is None
|
||||||
|
assert earlier["old_owner_name"] == first["personnel_name"]
|
||||||
|
assert earlier["new_owner_name"] == second["personnel_name"]
|
||||||
|
assert earlier["new_owner_personnel_id"] == int(second["id"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudit:
|
||||||
|
"""写操作(含归属变更)留痕,摘要要能定位到客户。"""
|
||||||
|
|
||||||
|
def test_新增客户留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
data = _create_customer(client, normal["headers"])
|
||||||
|
logs = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_CUSTOMER and log.action == ACTION_CREATE
|
||||||
|
]
|
||||||
|
assert logs, "新增客户未留操作日志"
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest.success == 1
|
||||||
|
assert data["company_name"] in latest.summary
|
||||||
|
|
||||||
|
def test_归属变更审计摘要含客户名(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
old = _personnel(client, headers)
|
||||||
|
new = _personnel(client, headers)
|
||||||
|
customer = _create_customer(client, headers, owner_personnel_id=old["id"])
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
||||||
|
json={"new_owner_personnel_id": new["id"], "reason": "区域调整"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
logs = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_CUSTOMER and log.success == 1
|
||||||
|
]
|
||||||
|
assert logs, "归属变更未留操作日志"
|
||||||
|
latest = logs[0]
|
||||||
|
assert customer["company_name"] in latest.summary, "摘要应含客户名"
|
||||||
|
assert "→" in latest.summary, "摘要应体现新旧归属走向"
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""单据编号生成器的跨位宽正确性(no_generator)。
|
||||||
|
|
||||||
|
守:当日序号超过 4 位宽度(9999→10000)后,VARCHAR 字典序排序会失效
|
||||||
|
("-10000" < "-9999",'1'<'9')——MAX 检索若按纯字典序,seq 会恒重算出
|
||||||
|
同一号码,每笔建单都撞唯一键。修复口径:长度优先、同级按字典序
|
||||||
|
(同前缀下长度大即数值大;同长时字典序与数值序一致)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.constants import NO_PREFIX_BUSINESS
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import Business, Customer
|
||||||
|
from app.services.no_generator import next_no, today_prefix
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_business_nos(nos: list[str]) -> None:
|
||||||
|
"""直插同日前缀的业务编号行(仅编号用于 MAX 检索;其余列取默认/可空)。
|
||||||
|
|
||||||
|
直插绕过服务层(服务层走完整创建链太重),业务行为挂在 t_ 客户名下,
|
||||||
|
由 integration/conftest 兜底清理(YW% 单据 + t\\_% 客户)。
|
||||||
|
"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
customer = Customer(
|
||||||
|
company_name="t_no_generator_client",
|
||||||
|
owner_name="t_no_generator_owner",
|
||||||
|
customer_type="enterprise",
|
||||||
|
)
|
||||||
|
session.add(customer)
|
||||||
|
session.flush()
|
||||||
|
session.add_all(Business(business_no=no, customer_id=customer.id) for no in nos)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMaxCrossWidth:
|
||||||
|
"""MAX 检索在同日前缀下跨位宽取最大序号。"""
|
||||||
|
|
||||||
|
def test_序号破四位宽后仍按数值取最大(self) -> None:
|
||||||
|
"""4 位与 5 位并存:"-9999" 字典序大于 "-10000",纯字典序会取错。"""
|
||||||
|
prefix = today_prefix(NO_PREFIX_BUSINESS)
|
||||||
|
_insert_business_nos([f"{prefix}9999", f"{prefix}10000"])
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
no = next_no(
|
||||||
|
session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS
|
||||||
|
)
|
||||||
|
assert no == f"{prefix}10001"
|
||||||
|
|
||||||
|
def test_同位宽内字典序与数值序一致仍取最大(self) -> None:
|
||||||
|
"""同宽多行(修复的排序不破坏原行为):是长度优先而非只按长度取首行。"""
|
||||||
|
prefix = today_prefix(NO_PREFIX_BUSINESS)
|
||||||
|
_insert_business_nos([f"{prefix}0099", f"{prefix}8499", f"{prefix}8500"])
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
no = next_no(
|
||||||
|
session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS
|
||||||
|
)
|
||||||
|
assert no == f"{prefix}8501"
|
||||||
|
|
||||||
|
def test_无当日编号时从1起编(self) -> None:
|
||||||
|
"""当日无同前缀行(昨日编号不被 like 命中):从 0001 起,不受历史天数影响。
|
||||||
|
|
||||||
|
当日若已有其他用例留下的同前缀编号(用例间无隔离库),跳过——
|
||||||
|
空表场景由每次 `down -v` 重建后的首个用例覆盖。
|
||||||
|
"""
|
||||||
|
prefix = today_prefix(NO_PREFIX_BUSINESS)
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
existing = session.execute(
|
||||||
|
select(Business.id).where(Business.business_no.like(f"{prefix}%")).limit(1)
|
||||||
|
).scalar()
|
||||||
|
if existing is not None:
|
||||||
|
pytest.skip(f"当日已有同前缀编号 {existing},空表场景跳过")
|
||||||
|
with factory() as session:
|
||||||
|
no = next_no(
|
||||||
|
session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS
|
||||||
|
)
|
||||||
|
assert no == f"{prefix}0001"
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
"""计提参数管理接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是参数体系的口径与留痕 —— 时点化取值错一天、同日变更取错条目、
|
||||||
|
旧值丢失、未来值泄漏到当前快照,任何一条都会直接算错钱。
|
||||||
|
|
||||||
|
关键复现(全部来自 2026-10-12 业务裁决):
|
||||||
|
- 企业户 2‰(2026-02-17 起)→ 3‰(2026-08-13 起)的按日回溯
|
||||||
|
- 业务员 27 天 → 97/10000,折算 15000,绩效 145.5,可付 52.38(预览金标准)
|
||||||
|
- 同日多次变更取最新一条;撤回未生效批次后解析回退
|
||||||
|
|
||||||
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from fractions import Fraction
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from app.core.constants import ROLE_USER
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import ParamChangeBatch, ParamValue
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_PARAMS = "/api/v1/system/params"
|
||||||
|
|
||||||
|
# 种子基线(与 V3 迁移一致):断言的「预期值」均以此为准
|
||||||
|
_SEED_PREPAY = "10"
|
||||||
|
_SEED_START_RATIO = "60"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def track_batches():
|
||||||
|
"""记录测试创建的批次 id,用例结束整批清理(条目 + 批次头)。"""
|
||||||
|
created: list[int] = []
|
||||||
|
|
||||||
|
def _track(batch_id: int) -> None:
|
||||||
|
created.append(batch_id)
|
||||||
|
|
||||||
|
yield _track
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
if created:
|
||||||
|
session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(created)))
|
||||||
|
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(created)))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_role_info(client: TestClient, admin: dict):
|
||||||
|
"""造一个信息员模板岗位(t_ 前缀),返回 role_code,用例结束删除。
|
||||||
|
|
||||||
|
信息员分支的预览需要 TPL_INFO_SALES_FIXED 模板的非内置岗位;
|
||||||
|
内置 info_officer 的模板即为此模板,自定义岗位用于隔离造数。
|
||||||
|
"""
|
||||||
|
code = f"t_info_{uuid.uuid4().hex[:6]}"
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/system/business-roles",
|
||||||
|
json={
|
||||||
|
"role_code": code,
|
||||||
|
"role_name": "集成信息员",
|
||||||
|
"calc_template": "TPL_INFO_SALES_FIXED",
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
role_id = resp.json()["data"]["id"]
|
||||||
|
try:
|
||||||
|
yield code
|
||||||
|
finally:
|
||||||
|
client.delete(f"/api/v1/system/business-roles/{role_id}", headers=admin["headers"])
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of: str) -> str | None:
|
||||||
|
"""从目录接口取某参数默认维度的当前生效值。"""
|
||||||
|
items = client.get(_PARAMS, params={"as_of": as_of}, headers=admin["headers"]).json()["data"]
|
||||||
|
match = next(item for item in items if item["param_code"] == param_code)
|
||||||
|
dims = [v for v in match["values"] if v["dimension_value"] == ""]
|
||||||
|
return dims[0]["value_text"] if dims else None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatalog:
|
||||||
|
"""参数目录:种子完整性与时点化回溯。"""
|
||||||
|
|
||||||
|
def test_目录共16项且覆盖七个分组(self, client: TestClient, admin: dict) -> None:
|
||||||
|
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
||||||
|
assert len(items) == 16
|
||||||
|
groups = {item["param_group"] for item in items}
|
||||||
|
assert groups == {
|
||||||
|
"业务员个人户",
|
||||||
|
"经理分成",
|
||||||
|
"企业户",
|
||||||
|
"信息员",
|
||||||
|
"支付环节",
|
||||||
|
"产品阈值",
|
||||||
|
"月工资口径",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
|
||||||
|
before = _snapshot_value(client, admin, "enterprise.rate", as_of="2026-08-12")
|
||||||
|
after = _snapshot_value(client, admin, "enterprise.rate", as_of="2026-08-13")
|
||||||
|
assert (before, after) == ("2", "3")
|
||||||
|
|
||||||
|
def test_业务员斜率为精确分数_非截断小数(self, client: TestClient, admin: dict) -> None:
|
||||||
|
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
||||||
|
slope = next(item for item in items if item["param_code"] == "salesperson.rate.slope")
|
||||||
|
values = [v["value_text"] for v in slope["values"]]
|
||||||
|
assert values == ["4/360"] # 分数式原样存储,禁 0.011111 截断
|
||||||
|
|
||||||
|
|
||||||
|
class TestChangeLifecycle:
|
||||||
|
"""变更生命周期:提交 → 生效 → 同日取最新 → 撤回回退。"""
|
||||||
|
|
||||||
|
def test_立即生效_快照变化且历史含旧值(
|
||||||
|
self, client: TestClient, admin: dict, track_batches
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"remark": "集成-立即生效",
|
||||||
|
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "12"}],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
batch = resp.json()["data"]
|
||||||
|
track_batches(batch["id"])
|
||||||
|
assert batch["item_count"] == 1
|
||||||
|
assert batch["version_no"].startswith("P")
|
||||||
|
|
||||||
|
value = _snapshot_value(client, admin, "prepay.rate", as_of=str(date.today()))
|
||||||
|
assert value == "12"
|
||||||
|
|
||||||
|
history = client.get(
|
||||||
|
f"{_PARAMS}/prepay.rate/history",
|
||||||
|
params={"pageNum": 1, "pageSize": 5},
|
||||||
|
headers=admin["headers"],
|
||||||
|
).json()["data"]
|
||||||
|
assert history["total"] >= 2 # 种子 1 条 + 本次 1 条
|
||||||
|
assert history["list"][0]["old_value_text"] == _SEED_PREPAY
|
||||||
|
assert history["list"][0]["value_text"] == "12"
|
||||||
|
|
||||||
|
def test_未来生效_当前快照不泄漏_到期后生效(
|
||||||
|
self, client: TestClient, admin: dict, track_batches
|
||||||
|
) -> None:
|
||||||
|
future = date.today() + timedelta(days=30)
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 2,
|
||||||
|
"effective_date": future.isoformat(),
|
||||||
|
"remark": "集成-未来生效",
|
||||||
|
"items": [
|
||||||
|
{"param_code": "payment.start.ratio", "dimension_value": "", "value": "65"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
batch = resp.json()["data"]
|
||||||
|
track_batches(batch["id"])
|
||||||
|
|
||||||
|
today_value = _snapshot_value(client, admin, "payment.start.ratio", as_of=str(date.today()))
|
||||||
|
assert today_value == _SEED_START_RATIO # 未来值不泄漏
|
||||||
|
future_value = _snapshot_value(
|
||||||
|
client, admin, "payment.start.ratio", as_of=future.isoformat()
|
||||||
|
)
|
||||||
|
assert future_value == "65" # 到期即生效
|
||||||
|
|
||||||
|
def test_同日多次变更_取最新一条(self, client: TestClient, admin: dict, track_batches) -> None:
|
||||||
|
for value in ("12", "11"):
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": value}],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
track_batches(resp.json()["data"]["id"])
|
||||||
|
value = _snapshot_value(client, admin, "prepay.rate", as_of=str(date.today()))
|
||||||
|
assert value == "11"
|
||||||
|
|
||||||
|
def test_撤回未生效批次_解析回退(self, client: TestClient, admin: dict, track_batches) -> None:
|
||||||
|
future = date.today() + timedelta(days=30)
|
||||||
|
batch = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 2,
|
||||||
|
"effective_date": future.isoformat(),
|
||||||
|
"items": [{"param_code": "barter.discount", "dimension_value": "", "value": "85"}],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
).json()["data"]
|
||||||
|
track_batches(batch["id"])
|
||||||
|
|
||||||
|
future_value = _snapshot_value(client, admin, "barter.discount", as_of=future.isoformat())
|
||||||
|
assert future_value == "85"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
client.delete(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"]).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
rolled_back = _snapshot_value(client, admin, "barter.discount", as_of=future.isoformat())
|
||||||
|
assert rolled_back == _SEED_BARTER
|
||||||
|
|
||||||
|
def test_撤回已生效批次_400(self, client: TestClient, admin: dict, track_batches) -> None:
|
||||||
|
batch = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "12"}],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
).json()["data"]
|
||||||
|
track_batches(batch["id"])
|
||||||
|
resp = client.delete(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"])
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "已生效" in resp.json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
_SEED_BARTER = "80"
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidation:
|
||||||
|
"""值校验:畸形值在写入前拒绝(审查重点 1)。"""
|
||||||
|
|
||||||
|
def test_超取值上限拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"items": [{"param_code": "barter.discount", "dimension_value": "", "value": "120"}],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_枚举越界拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"items": [
|
||||||
|
{"param_code": "payment.weight.mode", "dimension_value": "", "value": "PARTIAL"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_除零分数拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"items": [{"param_code": "barter.discount", "dimension_value": "", "value": "1/0"}],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_指定日期早于今日拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
|
yesterday = date.today() - timedelta(days=1)
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 2,
|
||||||
|
"effective_date": yesterday.isoformat(),
|
||||||
|
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "9"}],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreview:
|
||||||
|
"""计算预览:业务裁决的「金标准」数值精确复现。"""
|
||||||
|
|
||||||
|
def test_业务员27天全链路精确复现(self, client: TestClient, admin: dict) -> None:
|
||||||
|
payload = {
|
||||||
|
"business_role": "salesperson",
|
||||||
|
"customer_type": "personal",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"ship_date": "2025-12-15",
|
||||||
|
"receipt_date": "2026-01-10",
|
||||||
|
"revenue": "100000",
|
||||||
|
"profit": "45000",
|
||||||
|
"received_amount": "60000",
|
||||||
|
}
|
||||||
|
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert data["payment_days"] == 27
|
||||||
|
assert data["rate"] == "97/10000" # 10‰ − 27 × 4/360‰
|
||||||
|
assert data["profit_rate"] == "9/20" # 45% 不含运费
|
||||||
|
assert data["converted_profit"] == "15000" # 100000 × (45% − 30%)
|
||||||
|
assert data["performance_amount"] == "145.5"
|
||||||
|
assert data["start_payment_ok"] is True
|
||||||
|
assert data["payment_weight_r"] == 60
|
||||||
|
assert data["payable_amount"] == "52.38" # 145.5 × 60% × 60%
|
||||||
|
|
||||||
|
def test_361天分界_固定下限4千分(self, client: TestClient, admin: dict) -> None:
|
||||||
|
payload = {
|
||||||
|
"business_role": "salesperson",
|
||||||
|
"customer_type": "personal",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"ship_date": "2025-01-10",
|
||||||
|
"receipt_date": "2026-01-10", # 366 天 ≥ 361
|
||||||
|
"revenue": "100000",
|
||||||
|
"profit": "45000",
|
||||||
|
"received_amount": "100000",
|
||||||
|
}
|
||||||
|
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
# Fraction 自动约分:4/1000 存储为 1/250,按数值断言
|
||||||
|
assert Fraction(data["rate"]) == Fraction(4, 1000)
|
||||||
|
|
||||||
|
def test_经理聚合口径_预览拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
|
payload = {
|
||||||
|
"business_role": "sales_manager",
|
||||||
|
"customer_type": "personal",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"ship_date": "2026-01-01",
|
||||||
|
"receipt_date": "2026-02-01",
|
||||||
|
"revenue": "100000",
|
||||||
|
"profit": "45000",
|
||||||
|
"received_amount": "0",
|
||||||
|
}
|
||||||
|
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "聚合" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None:
|
||||||
|
payload = {
|
||||||
|
"business_role": "salesperson",
|
||||||
|
"customer_type": "personal",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"ship_date": "2026-01-10",
|
||||||
|
"receipt_date": "2026-01-01",
|
||||||
|
"revenue": "100000",
|
||||||
|
"profit": "45000",
|
||||||
|
"received_amount": "0",
|
||||||
|
}
|
||||||
|
assert (
|
||||||
|
client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).status_code
|
||||||
|
== 400
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_企业户_千分比换算后金额精确(self, client: TestClient, admin: dict) -> None:
|
||||||
|
"""企业户分支:enterprise.rate 存储 "3"(3‰),须换算为 3/1000 再相乘。
|
||||||
|
|
||||||
|
修复前把存储值当纯比例直接乘,performance_amount 会放大 1000 倍
|
||||||
|
(15000 × 3 = 45000);既有用例只覆盖业务员路径,缺陷因此存活。
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"customer_type": "enterprise",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"ship_date": "2025-12-15",
|
||||||
|
"receipt_date": "2026-01-10",
|
||||||
|
"revenue": "100000",
|
||||||
|
"profit": "45000",
|
||||||
|
"received_amount": "100000",
|
||||||
|
}
|
||||||
|
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert Fraction(data["rate"]) == Fraction(3, 1000)
|
||||||
|
# 折算毛利润额 15000 × 3‰ = 45(修复前误返回 45000)
|
||||||
|
assert Fraction(data["performance_amount"]) == Fraction(45)
|
||||||
|
# 全额回款 R=100 → 权重 1,可付 == 绩效
|
||||||
|
assert data["payment_weight_r"] == 100
|
||||||
|
assert Fraction(data["payable_amount"]) == Fraction(45)
|
||||||
|
|
||||||
|
def test_信息员_销售额基数乘千分比例(
|
||||||
|
self, client: TestClient, admin: dict, make_role_info
|
||||||
|
) -> None:
|
||||||
|
"""信息员模板:基数覆盖为本人销售额(不走折算与阈值),比例 1‰。
|
||||||
|
|
||||||
|
修复前 take("info.rate")(存储 "1")直接当纯比例乘出本人销售额原额。
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"business_role": make_role_info,
|
||||||
|
"customer_type": "personal",
|
||||||
|
"ship_date": "2025-12-15",
|
||||||
|
"receipt_date": "2026-01-10",
|
||||||
|
"revenue": "100000",
|
||||||
|
"profit": "45000",
|
||||||
|
"received_amount": "100000",
|
||||||
|
}
|
||||||
|
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
# 基数覆盖为本人销售额:converted_profit == revenue(不扣阈值)
|
||||||
|
assert Fraction(data["converted_profit"]) == Fraction(100000)
|
||||||
|
assert Fraction(data["rate"]) == Fraction(1, 1000)
|
||||||
|
# 100000 × 1‰ = 100
|
||||||
|
assert Fraction(data["performance_amount"]) == Fraction(100)
|
||||||
|
|
||||||
|
def test_提交后维度取库里规范值(self, client: TestClient, admin: dict, track_batches) -> None:
|
||||||
|
"""岗位维度大小写归一:ci 校对下变形大小写可提交成功,但须落库规范值。
|
||||||
|
|
||||||
|
快照与注册表按精确键取值,原始大小写入库会让覆盖链静默不命中
|
||||||
|
(按错误参数算钱且无报错)。business_roles.role_code 唯一键的 ci
|
||||||
|
等价冲突由 IntegrityError 分支翻译 400 兜底。
|
||||||
|
"""
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"items": [
|
||||||
|
{"param_code": "info.rate", "dimension_value": "SALESPERSON", "value": "2"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code in (200, 400), resp.text
|
||||||
|
if resp.status_code == 400:
|
||||||
|
pytest.skip("种子岗位未命中(见断言),规范值回读由造岗位链路覆盖")
|
||||||
|
batch = resp.json()["data"]
|
||||||
|
track_batches(batch["id"])
|
||||||
|
detail = client.get(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
dims = [item["dimension_value"] for item in detail["items"]]
|
||||||
|
assert "salesperson" in dims
|
||||||
|
assert "SALESPERSON" not in dims
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermission:
|
||||||
|
"""权限:普通用户全部 403。"""
|
||||||
|
|
||||||
|
def test_普通用户访问目录与提交_403(self, client: TestClient, make_user, auth_headers) -> None:
|
||||||
|
user = make_user([ROLE_USER])
|
||||||
|
headers = auth_headers(user)
|
||||||
|
assert client.get(_PARAMS, headers=headers).status_code == 403
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "9"}],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
"""人员管理接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是人员档案的引用完整性 —— 岗位与直属经理是 M3 核算取公式模板与
|
||||||
|
经理聚合展开的锚点,悬空引用或环引用会让核算取不到公式、聚合死循环;
|
||||||
|
删除保护缺失会留下悬空的 manager_id 与客户归属,历史核算无法回溯到人;
|
||||||
|
辐射区域与拓展计划若把「全量替换」做成了追加,编辑一次就翻倍一份脏数据。
|
||||||
|
|
||||||
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||||
|
|
||||||
|
数据卫生:人员姓名一律 ``t_`` 前缀,由 conftest 兜底清理;仅「删除」
|
||||||
|
语义的用例自验 DELETE 端点。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
BUSINESS_ROLE_SALESPERSON,
|
||||||
|
MODULE_PERSONNEL,
|
||||||
|
)
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import BusinessRole, OperationLog
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_PERSONNEL = "/api/v1/base/personnel"
|
||||||
|
_CUSTOMERS = "/api/v1/base/customers"
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
|
||||||
|
|
||||||
|
def _uname() -> str:
|
||||||
|
return f"t_人员_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _role_id() -> int:
|
||||||
|
"""种子岗位(营销员,内置且启用):人员归属岗位的合法引用。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return int(
|
||||||
|
session.execute(
|
||||||
|
select(BusinessRole.id).where(
|
||||||
|
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
||||||
|
BusinessRole.status == 1,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(name: str, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"personnel_name": name,
|
||||||
|
"business_role_id": _role_id(),
|
||||||
|
"salary_standard": "3500.50",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _create(client: TestClient, headers: dict, **overrides: object) -> dict:
|
||||||
|
resp = client.post(_PERSONNEL, json=_payload(_uname(), **overrides), headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _logs_of(username: str) -> list[OperationLog]:
|
||||||
|
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(OperationLog)
|
||||||
|
.where(OperationLog.username == username)
|
||||||
|
.order_by(OperationLog.id.desc())
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuth:
|
||||||
|
"""鉴权:基础数据是业务操作不要求管理员,但仍是登录后接口。"""
|
||||||
|
|
||||||
|
def test_未登录访问人员列表_401(self, client: TestClient) -> None:
|
||||||
|
resp = client.get(_PERSONNEL)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrudFlow:
|
||||||
|
"""正向链路:建 → 查(列表/详情)→ 改 → 状态切换 → 删。"""
|
||||||
|
|
||||||
|
def test_新增人员默认在职且工资标准字符串直出(self, client: TestClient, normal: dict) -> None:
|
||||||
|
data = _create(client, normal["headers"])
|
||||||
|
assert data["status"] == 1, "新增人员应默认在职"
|
||||||
|
assert data["is_probation"] == 0
|
||||||
|
# 金额红线:str 进 str 出,不得经 float
|
||||||
|
assert data["salary_standard"] == "3500.50"
|
||||||
|
|
||||||
|
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"])
|
||||||
|
assert detail.status_code == 200, detail.text
|
||||||
|
body = detail.json()["data"]
|
||||||
|
assert body["salary_standard"] == "3500.50"
|
||||||
|
assert body["regions"] == []
|
||||||
|
assert body["plans"] == []
|
||||||
|
|
||||||
|
def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
manager = _create(client, headers)
|
||||||
|
member = _create(client, headers, manager_id=manager["id"])
|
||||||
|
assert member["manager_id"] == manager["id"]
|
||||||
|
assert member["manager_name"] == manager["personnel_name"]
|
||||||
|
assert member["role_name"] == manager["role_name"]
|
||||||
|
|
||||||
|
def test_列表按姓名关键词可查到新增人员(self, client: TestClient, normal: dict) -> None:
|
||||||
|
data = _create(client, normal["headers"])
|
||||||
|
listed = client.get(
|
||||||
|
_PERSONNEL, params={"keyword": data["personnel_name"]}, headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert listed.status_code == 200, listed.text
|
||||||
|
rows = listed.json()["data"]["list"]
|
||||||
|
assert [r["id"] for r in rows] == [data["id"]]
|
||||||
|
|
||||||
|
def test_编辑人员全量替换生效(self, client: TestClient, normal: dict) -> None:
|
||||||
|
data = _create(client, normal["headers"])
|
||||||
|
updated = client.put(
|
||||||
|
f"{_PERSONNEL}/{data['id']}",
|
||||||
|
json=_payload(f"t_人员_改_{uuid.uuid4().hex[:6]}", phone="13900001111", is_probation=1),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200, updated.text
|
||||||
|
body = updated.json()["data"]
|
||||||
|
assert body["phone"] == "13900001111"
|
||||||
|
assert body["is_probation"] == 1
|
||||||
|
|
||||||
|
def test_在职离职双向切换(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
data = _create(client, headers)
|
||||||
|
off = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 0}, headers=headers)
|
||||||
|
assert off.status_code == 200, off.text
|
||||||
|
assert off.json()["data"]["status"] == 0
|
||||||
|
on = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 1}, headers=headers)
|
||||||
|
assert on.status_code == 200, on.text
|
||||||
|
assert on.json()["data"]["status"] == 1
|
||||||
|
|
||||||
|
def test_删除无引用人员成功(self, client: TestClient, normal: dict) -> None:
|
||||||
|
data = _create(client, normal["headers"])
|
||||||
|
resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"])
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"])
|
||||||
|
assert detail.status_code == 404
|
||||||
|
assert "人员不存在" in detail.json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefRules:
|
||||||
|
"""岗位/经理引用校验 —— 核算锚点不悬空、不环引用。"""
|
||||||
|
|
||||||
|
def test_编辑时直属经理是自己_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
data = _create(client, normal["headers"])
|
||||||
|
resp = client.put(
|
||||||
|
f"{_PERSONNEL}/{data['id']}",
|
||||||
|
json=_payload(data["personnel_name"], manager_id=data["id"]),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "直属经理不能是自己" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_直属经理不存在_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
_PERSONNEL,
|
||||||
|
json=_payload(_uname(), manager_id=999999999),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "直属经理不存在或已离职" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_直属经理已离职_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
manager = _create(client, headers)
|
||||||
|
quit_resp = client.put(
|
||||||
|
f"{_PERSONNEL}/{manager['id']}/status", json={"status": 0}, headers=headers
|
||||||
|
)
|
||||||
|
assert quit_resp.status_code == 200, quit_resp.text
|
||||||
|
resp = client.post(
|
||||||
|
_PERSONNEL,
|
||||||
|
json=_payload(_uname(), manager_id=manager["id"]),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "直属经理不存在或已离职" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_岗位不存在_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
_PERSONNEL,
|
||||||
|
json=_payload(_uname(), business_role_id=999999999),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "岗位不存在或已停用" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_岗位已停用_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""停用岗位不能挂人:岗位决定计提模板,挂上去核算会取不到公式。"""
|
||||||
|
code = f"t_br_{uuid.uuid4().hex[:8]}"
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
session.add(
|
||||||
|
BusinessRole(
|
||||||
|
role_code=code,
|
||||||
|
role_name="已停用测试岗位",
|
||||||
|
calc_template="TPL_SALESPERSON_DECAY",
|
||||||
|
status=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
role_id = int(
|
||||||
|
session.execute(
|
||||||
|
select(BusinessRole.id).where(BusinessRole.role_code == code)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
_PERSONNEL,
|
||||||
|
json=_payload(_uname(), business_role_id=role_id),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "岗位不存在或已停用" in resp.json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteProtection:
|
||||||
|
"""删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。"""
|
||||||
|
|
||||||
|
def test_被团队成员引用禁删_解除后可删(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
manager = _create(client, headers)
|
||||||
|
member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用
|
||||||
|
blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers)
|
||||||
|
assert blocked.status_code == 400, blocked.text
|
||||||
|
assert (
|
||||||
|
"存在团队成员、客户归属或业务归属引用,请改为离职而非删除" in blocked.json()["message"]
|
||||||
|
)
|
||||||
|
|
||||||
|
client.delete(f"{_PERSONNEL}/{member['id']}", headers=headers)
|
||||||
|
freed = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers)
|
||||||
|
assert freed.status_code == 200, freed.text
|
||||||
|
|
||||||
|
def test_被客户归属引用禁删_离职不放宽_解除后可删(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
owner = _create(client, headers)
|
||||||
|
created = client.post(
|
||||||
|
_CUSTOMERS,
|
||||||
|
json={
|
||||||
|
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
|
||||||
|
"owner_name": "张老板",
|
||||||
|
"customer_type": "personal",
|
||||||
|
"owner_personnel_id": owner["id"],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert created.status_code == 200, created.text
|
||||||
|
customer = created.json()["data"]
|
||||||
|
|
||||||
|
blocked = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
|
||||||
|
assert blocked.status_code == 400, blocked.text
|
||||||
|
assert (
|
||||||
|
"存在团队成员、客户归属或业务归属引用,请改为离职而非删除" in blocked.json()["message"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 保护与在职状态无关:离职并不能让物理删除放行
|
||||||
|
client.put(f"{_PERSONNEL}/{owner['id']}/status", json={"status": 0}, headers=headers)
|
||||||
|
still = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
|
||||||
|
assert still.status_code == 400, still.text
|
||||||
|
|
||||||
|
# 删客户解除引用后方可删人
|
||||||
|
assert client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers).status_code == 200
|
||||||
|
freed = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
|
||||||
|
assert freed.status_code == 200, freed.text
|
||||||
|
|
||||||
|
def test_被业务归属引用禁删_负责或协助任一即拦(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""业务表就位后删除保护补齐:负责/协助引用任一即拦,FK 不以 500 暴露。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
owner = _create(client, headers)
|
||||||
|
assistant = _create(client, headers)
|
||||||
|
resp_customer = client.post(
|
||||||
|
_CUSTOMERS,
|
||||||
|
json={
|
||||||
|
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
|
||||||
|
"owner_name": "张老板",
|
||||||
|
"customer_type": "personal",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp_customer.status_code == 200, resp_customer.text
|
||||||
|
customer_id = resp_customer.json()["data"]["id"]
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json={
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"owner_personnel_id": owner["id"],
|
||||||
|
"assistant_personnel_id": assistant["id"],
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"product_name": "t_引用产品",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"quantity": "1",
|
||||||
|
"sale_price": "1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
for target, label in ((owner, "负责"), (assistant, "协助")):
|
||||||
|
blocked = client.delete(f"{_PERSONNEL}/{target['id']}", headers=headers)
|
||||||
|
assert blocked.status_code == 400, blocked.text
|
||||||
|
assert "业务归属" in blocked.json()["message"], f"{label}引用未拦截"
|
||||||
|
|
||||||
|
# 删除业务解除引用后,人员方可删除(owner 仍被客户归属引用,先删客户)
|
||||||
|
business_id = resp.json()["data"]["id"]
|
||||||
|
assert client.delete(f"{_BUSINESSES}/{business_id}", headers=headers).status_code == 200
|
||||||
|
assert client.delete(f"{_CUSTOMERS}/{customer_id}", headers=headers).status_code == 200
|
||||||
|
assert client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubTableReplace:
|
||||||
|
"""辐射区域/拓展计划为全量替换语义 —— 做成追加会让编辑翻倍脏数据。"""
|
||||||
|
|
||||||
|
def test_辐射区域设置后详情回显一致_再次替换生效(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
data = _create(client, headers)
|
||||||
|
resp = client.put(
|
||||||
|
f"{_PERSONNEL}/{data['id']}/regions",
|
||||||
|
json={
|
||||||
|
"items": [
|
||||||
|
{"region_name": "本溪市平山区", "remark": "核心区域"},
|
||||||
|
{"region_name": "本溪市溪湖区"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert [r["region_name"] for r in detail["regions"]] == ["本溪市平山区", "本溪市溪湖区"]
|
||||||
|
assert detail["regions"][0]["remark"] == "核心区域"
|
||||||
|
|
||||||
|
# 再替换为 1 条:是覆盖而非追加
|
||||||
|
resp2 = client.put(
|
||||||
|
f"{_PERSONNEL}/{data['id']}/regions",
|
||||||
|
json={"items": [{"region_name": "沈阳市沈河区"}]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200, resp2.text
|
||||||
|
detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert [r["region_name"] for r in detail2["regions"]] == ["沈阳市沈河区"]
|
||||||
|
|
||||||
|
def test_辐射区域替换为空列表即清空(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
data = _create(client, headers)
|
||||||
|
client.put(
|
||||||
|
f"{_PERSONNEL}/{data['id']}/regions",
|
||||||
|
json={"items": [{"region_name": "本溪市平山区"}]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
cleared = client.put(
|
||||||
|
f"{_PERSONNEL}/{data['id']}/regions", json={"items": []}, headers=headers
|
||||||
|
)
|
||||||
|
assert cleared.status_code == 200, cleared.text
|
||||||
|
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert detail["regions"] == []
|
||||||
|
|
||||||
|
def test_拓展计划设置后详情回显一致_再次替换生效(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
data = _create(client, headers)
|
||||||
|
resp = client.put(
|
||||||
|
f"{_PERSONNEL}/{data['id']}/plans",
|
||||||
|
json={
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"plan_period": "2026Q1",
|
||||||
|
"plan_content": "拓展本溪市政客户",
|
||||||
|
"target_desc": "新增3户",
|
||||||
|
},
|
||||||
|
{"plan_period": "2026Q2", "plan_content": "维护存量客户"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert [p["plan_period"] for p in detail["plans"]] == ["2026Q1", "2026Q2"]
|
||||||
|
assert detail["plans"][0]["target_desc"] == "新增3户"
|
||||||
|
|
||||||
|
resp2 = client.put(
|
||||||
|
f"{_PERSONNEL}/{data['id']}/plans",
|
||||||
|
json={"items": [{"plan_period": "2026Q3", "plan_content": "开拓轨交市场"}]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200, resp2.text
|
||||||
|
detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert [p["plan_period"] for p in detail2["plans"]] == ["2026Q3"]
|
||||||
|
|
||||||
|
def test_拓展计划替换为空列表即清空(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
data = _create(client, headers)
|
||||||
|
client.put(
|
||||||
|
f"{_PERSONNEL}/{data['id']}/plans",
|
||||||
|
json={"items": [{"plan_period": "2026Q1", "plan_content": "拓展本溪市政客户"}]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
cleared = client.put(
|
||||||
|
f"{_PERSONNEL}/{data['id']}/plans", json={"items": []}, headers=headers
|
||||||
|
)
|
||||||
|
assert cleared.status_code == 200, cleared.text
|
||||||
|
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert detail["plans"] == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudit:
|
||||||
|
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||||
|
|
||||||
|
def test_新增人员成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
data = _create(client, normal["headers"])
|
||||||
|
logs = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_PERSONNEL and log.action == ACTION_CREATE
|
||||||
|
]
|
||||||
|
assert logs, "新增人员未留操作日志"
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest.success == 1
|
||||||
|
assert data["personnel_name"] in latest.summary
|
||||||
|
|
||||||
|
def test_校验失败也留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
_PERSONNEL,
|
||||||
|
json=_payload(_uname(), manager_id=999999999),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
failed = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_PERSONNEL and log.success == 0
|
||||||
|
]
|
||||||
|
assert failed, "失败操作未留痕"
|
||||||
|
assert "直属经理不存在或已离职" in (failed[0].error_message or "")
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
"""产品管理接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是产品主数据的两条硬约束 —— product_category 是折算阈值的行级锚点,
|
||||||
|
脏类别会让核算按类别回溯参数取不到阈值;「名称+规格」组合唯一缺失会
|
||||||
|
放行重复产品,业务数据不知道该挂在哪一条上。数量/单价以字符串进出,
|
||||||
|
断言同时验证 str 直出(金额红线:不经 float)。
|
||||||
|
|
||||||
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||||
|
|
||||||
|
数据卫生:产品名一律 ``t_`` 前缀,由 conftest 兜底清理;删除语义用例
|
||||||
|
自验 DELETE 端点。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
MODULE_PRODUCT,
|
||||||
|
PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
||||||
|
PRODUCT_CATEGORY_RAIL_TRANSIT,
|
||||||
|
)
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import OperationLog
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_PRODUCTS = "/api/v1/base/products"
|
||||||
|
_CUSTOMERS = "/api/v1/base/customers"
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
|
||||||
|
|
||||||
|
def _pname() -> str:
|
||||||
|
return f"t_产品_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(name: str, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"product_name": name,
|
||||||
|
"spec_model": "DN100",
|
||||||
|
"unit": "台",
|
||||||
|
"stock_quantity": "100",
|
||||||
|
"sale_price": "12.5",
|
||||||
|
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _create(client: TestClient, headers: dict, **overrides: object) -> dict:
|
||||||
|
resp = client.post(_PRODUCTS, json=_payload(_pname(), **overrides), headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _logs_of(username: str) -> list[OperationLog]:
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(OperationLog)
|
||||||
|
.where(OperationLog.username == username)
|
||||||
|
.order_by(OperationLog.id.desc())
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_business_for_product(client: TestClient):
|
||||||
|
"""造「客户 → 业务 → 明细挂指定产品」的最小引用链(删除保护场景)。
|
||||||
|
|
||||||
|
business_items.product_id 是 RESTRICT 外键,产品删除保护以此为引用源;
|
||||||
|
客户/业务均按 t_ 前缀与系统编号由 conftest 兜底清理。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _factory(headers: dict, product_id: int) -> dict:
|
||||||
|
customer = client.post(
|
||||||
|
_CUSTOMERS,
|
||||||
|
json={
|
||||||
|
"company_name": f"t_引用公司_{uuid.uuid4().hex[:8]}",
|
||||||
|
"owner_name": "张老板",
|
||||||
|
"customer_type": "personal",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert customer.status_code == 200, customer.text
|
||||||
|
business = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json={
|
||||||
|
"customer_id": customer.json()["data"]["id"],
|
||||||
|
"business_date": "2026-01-01",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"product_id": product_id,
|
||||||
|
"product_name": f"t_引用产品_{uuid.uuid4().hex[:6]}",
|
||||||
|
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
||||||
|
"quantity": "1.00",
|
||||||
|
"sale_price": "10.00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert business.status_code == 200, business.text
|
||||||
|
return business.json()["data"]
|
||||||
|
|
||||||
|
return _factory
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuth:
|
||||||
|
"""鉴权:登录后接口,裸访问一律 401。"""
|
||||||
|
|
||||||
|
def test_未登录访问产品列表_401(self, client: TestClient) -> None:
|
||||||
|
resp = client.get(_PRODUCTS)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrudFlow:
|
||||||
|
"""正向链路:建 → 查 → 改(含换类别)→ 删。"""
|
||||||
|
|
||||||
|
def test_新增产品默认启用且数量单价字符串直出(self, client: TestClient, normal: dict) -> None:
|
||||||
|
data = _create(client, normal["headers"])
|
||||||
|
assert data["status"] == 1, "新产品应默认启用"
|
||||||
|
assert data["spec_model"] == "DN100"
|
||||||
|
# 金额红线:str 进 str 出,且数值与入参精确相等(Decimal 比较,不经 float)
|
||||||
|
assert isinstance(data["sale_price"], str)
|
||||||
|
assert Decimal(data["sale_price"]) == Decimal("12.5")
|
||||||
|
assert isinstance(data["stock_quantity"], str)
|
||||||
|
assert Decimal(data["stock_quantity"]) == Decimal("100")
|
||||||
|
|
||||||
|
def test_详情与列表均能查到新增产品(self, client: TestClient, normal: dict) -> None:
|
||||||
|
data = _create(client, normal["headers"])
|
||||||
|
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
|
||||||
|
assert detail.status_code == 200, detail.text
|
||||||
|
assert detail.json()["data"]["id"] == data["id"]
|
||||||
|
|
||||||
|
listed = client.get(
|
||||||
|
_PRODUCTS, params={"keyword": data["product_name"]}, headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert listed.status_code == 200, listed.text
|
||||||
|
assert [r["id"] for r in listed.json()["data"]["list"]] == [data["id"]]
|
||||||
|
|
||||||
|
def test_同名称不同规格创建成功(self, client: TestClient, normal: dict) -> None:
|
||||||
|
name = _pname()
|
||||||
|
first = _create(client, normal["headers"], product_name=name, spec_model="DN100")
|
||||||
|
second = client.post(
|
||||||
|
_PRODUCTS,
|
||||||
|
json=_payload(name, spec_model="DN200"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert second.status_code == 200, second.text
|
||||||
|
assert second.json()["data"]["id"] != first["id"]
|
||||||
|
|
||||||
|
def test_删除产品成功(self, client: TestClient, normal: dict) -> None:
|
||||||
|
data = _create(client, normal["headers"])
|
||||||
|
resp = client.delete(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
|
||||||
|
assert detail.status_code == 404
|
||||||
|
assert "产品不存在" in detail.json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteProtection:
|
||||||
|
"""删除保护:被业务/发货明细引用的产品禁删(customer 先例同构)。"""
|
||||||
|
|
||||||
|
def test_被业务明细引用_400且产品仍在(
|
||||||
|
self, client: TestClient, normal: dict, make_business_for_product
|
||||||
|
) -> None:
|
||||||
|
product = _create(client, normal["headers"])
|
||||||
|
business = make_business_for_product(normal["headers"], product["id"])
|
||||||
|
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "引用" in resp.json()["message"]
|
||||||
|
# 产品仍在(引导改走停用而非物理删除)
|
||||||
|
still = client.get(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||||
|
assert still.status_code == 200
|
||||||
|
# 业务明细行确实引用了该产品(护栏:确认保护不是空转)
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=normal["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert detail["items"][0]["product_id"] == product["id"]
|
||||||
|
|
||||||
|
def test_被发货明细引用_400(
|
||||||
|
self, client: TestClient, normal: dict, make_business_for_product
|
||||||
|
) -> None:
|
||||||
|
product = _create(client, normal["headers"])
|
||||||
|
make_business_for_product(normal["headers"], product["id"])
|
||||||
|
# 在业务明细引用之外再挂一笔发货明细(同样引用 products.id)
|
||||||
|
businesses = client.get("/api/v1/business/businesses", headers=normal["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
business_id = businesses["list"][0]["id"]
|
||||||
|
shipment = client.post(
|
||||||
|
"/api/v1/business/shipments",
|
||||||
|
json={
|
||||||
|
"business_id": business_id,
|
||||||
|
"ship_date": "2026-01-01",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"product_id": product["id"],
|
||||||
|
"product_name": product["product_name"],
|
||||||
|
"quantity": "1.00",
|
||||||
|
"sale_price": "10.00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert shipment.status_code == 200, shipment.text
|
||||||
|
# 此前业务明细引用已令删除被拒,此处再证发货引用同一防线
|
||||||
|
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidationRules:
|
||||||
|
"""类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。"""
|
||||||
|
|
||||||
|
def test_重名同规格_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
name = _pname()
|
||||||
|
_create(client, normal["headers"], product_name=name)
|
||||||
|
dup = client.post(
|
||||||
|
_PRODUCTS,
|
||||||
|
json=_payload(name, spec_model="DN100"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert dup.status_code == 400, dup.text
|
||||||
|
assert "已存在同名称同规格的产品" in dup.json()["message"]
|
||||||
|
|
||||||
|
def test_规格缺省归一空串且重名同空规格仍拦截(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""组合唯一键对 NULL 不去重:缺省规格若归 NULL 会放行重名同规格数据。"""
|
||||||
|
name = _pname()
|
||||||
|
first = _create(client, normal["headers"], product_name=name, spec_model=None)
|
||||||
|
assert first["spec_model"] == ""
|
||||||
|
dup = client.post(
|
||||||
|
_PRODUCTS,
|
||||||
|
json=_payload(name, spec_model=None),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert dup.status_code == 400, dup.text
|
||||||
|
assert "已存在同名称同规格的产品" in dup.json()["message"]
|
||||||
|
|
||||||
|
def test_产品类别非法_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
_PRODUCTS,
|
||||||
|
json=_payload(_pname(), product_category="not_a_category"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "产品类别不合法" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_销售单价为负数_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
_PRODUCTS,
|
||||||
|
json=_payload(_pname(), sale_price="-1"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
_PRODUCTS,
|
||||||
|
json=_payload(_pname(), sale_price="abc"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
def test_编辑改类别为合法值成功(self, client: TestClient, normal: dict) -> None:
|
||||||
|
data = _create(client, normal["headers"])
|
||||||
|
updated = client.put(
|
||||||
|
f"{_PRODUCTS}/{data['id']}",
|
||||||
|
json=_payload(
|
||||||
|
data["product_name"],
|
||||||
|
product_category=PRODUCT_CATEGORY_RAIL_TRANSIT,
|
||||||
|
sale_price="15.8",
|
||||||
|
),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200, updated.text
|
||||||
|
body = updated.json()["data"]
|
||||||
|
assert body["product_category"] == PRODUCT_CATEGORY_RAIL_TRANSIT
|
||||||
|
assert Decimal(body["sale_price"]) == Decimal("15.8")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusSwitch:
|
||||||
|
"""启用/停用独立于编辑端点 —— 编辑表单不携带状态,防误触停用。"""
|
||||||
|
|
||||||
|
def test_停用再启用切换(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
data = _create(client, headers)
|
||||||
|
off = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 0}, headers=headers)
|
||||||
|
assert off.status_code == 200, off.text
|
||||||
|
assert off.json()["message"] == "已停用"
|
||||||
|
assert off.json()["data"]["status"] == 0
|
||||||
|
on = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 1}, headers=headers)
|
||||||
|
assert on.status_code == 200, on.text
|
||||||
|
assert on.json()["message"] == "已启用"
|
||||||
|
assert on.json()["data"]["status"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudit:
|
||||||
|
"""写操作(含失败)留痕 —— 审计是验收项。"""
|
||||||
|
|
||||||
|
def test_新增产品成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
data = _create(client, normal["headers"])
|
||||||
|
logs = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_PRODUCT and log.action == ACTION_CREATE
|
||||||
|
]
|
||||||
|
assert logs, "新增产品未留操作日志"
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest.success == 1
|
||||||
|
assert data["product_name"] in latest.summary
|
||||||
|
|
||||||
|
def test_重名失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
name = _pname()
|
||||||
|
_create(client, normal["headers"], product_name=name)
|
||||||
|
dup = client.post(
|
||||||
|
_PRODUCTS,
|
||||||
|
json=_payload(name, spec_model="DN100"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert dup.status_code == 400, dup.text
|
||||||
|
failed = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_PRODUCT and log.success == 0
|
||||||
|
]
|
||||||
|
assert failed, "失败操作未留痕"
|
||||||
|
assert "已存在同名称同规格的产品" in (failed[0].error_message or "")
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""客户评级等级配置接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是评级**判定表**的两条硬约束 —— ① 区间互不重叠:重叠会让同一总分
|
||||||
|
命中多个等级(或两段都跳过),判定表失效,新增与编辑两路都必须校验;
|
||||||
|
② 分值域 0≤min≤max≤100:越界/倒挂的配置直接算错等级。边界相邻不算重叠
|
||||||
|
(前段 max=74、后段 min=75 相邻)是种子铺满 0-100 的铺法,要有正例锁住;
|
||||||
|
「已存在」的名称唯一在先查与 1062 两路都要收敛成 400。
|
||||||
|
|
||||||
|
评级用例会**删改种子**(删除 C、编辑 D 的区间构造相邻/重叠场景),conftest
|
||||||
|
兜底在每个用例后把判定表恢复为种子基线(全删全插,判定表无下游引用),
|
||||||
|
因此种子断言用例(列表全量返回)始终在纯种子上运行。
|
||||||
|
|
||||||
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||||
|
|
||||||
|
数据卫生:测试等级名一律 ``t_`` 前缀;种子(A/B/C/D)的删改由 conftest
|
||||||
|
恢复,删除语义的用例自验 DELETE 端点。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.constants import ACTION_CREATE, MODULE_RATING
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import OperationLog
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_GRADES = "/api/v1/business/rating-grades"
|
||||||
|
|
||||||
|
# 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1))
|
||||||
|
_SEED = [
|
||||||
|
("A", "90.0", "100.0", 1),
|
||||||
|
("B", "75.0", "89.0", 2),
|
||||||
|
("C", "60.0", "74.0", 3),
|
||||||
|
("D", "0.0", "59.0", 4),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _tname() -> str:
|
||||||
|
return f"t_等级_{uuid.uuid4().hex[:6]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _gpayload(grade_name: str, min_score: str, max_score: str, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"grade_name": grade_name,
|
||||||
|
"min_score": min_score,
|
||||||
|
"max_score": max_score,
|
||||||
|
"sort_order": 99,
|
||||||
|
"remark": "t_评级测试",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_id(client: TestClient, headers: dict, grade_name: str) -> int:
|
||||||
|
"""从判定表列表取种子等级 id(种子由迁移初始化,主键不可假造)。"""
|
||||||
|
rows = client.get(_GRADES, headers=headers).json()["data"]
|
||||||
|
match = next(row for row in rows if row["grade_name"] == grade_name)
|
||||||
|
return match["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_seed(client: TestClient, headers: dict, grade_name: str) -> None:
|
||||||
|
"""删掉种子等级,为测试等级清出分值空隙(种子由 conftest 兜底恢复)。
|
||||||
|
|
||||||
|
种子铺满 0-100 —— 凡要创建/编辑测试等级的用例,必须先清出与既有等级
|
||||||
|
完全分离的空隙,否则重叠约束(设计如此)一律 400。
|
||||||
|
"""
|
||||||
|
resp = client.delete(f"{_GRADES}/{_seed_id(client, headers, grade_name)}", headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _names(client: TestClient, headers: dict) -> list[str]:
|
||||||
|
return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]]
|
||||||
|
|
||||||
|
|
||||||
|
def _logs_of(username: str) -> list[OperationLog]:
|
||||||
|
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(OperationLog)
|
||||||
|
.where(OperationLog.username == username)
|
||||||
|
.order_by(OperationLog.id.desc())
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuth:
|
||||||
|
"""鉴权:评级配置属业务操作不要求管理员,但仍是登录后接口。"""
|
||||||
|
|
||||||
|
def test_未登录访问评级等级列表_401(self, client: TestClient) -> None:
|
||||||
|
resp = client.get(_GRADES)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestOverlapGuardLock:
|
||||||
|
"""区间重叠判定的并发防线:判重叠的 list_all 必须带 FOR UPDATE。
|
||||||
|
|
||||||
|
区间重叠无唯一键可拦(名称才有 uk),无锁时两管理员并发提交重叠区间
|
||||||
|
会互判不重叠而双双入库。双连接真并发需线程与锁等待控制,此处锁定
|
||||||
|
SQL 形态两条口径:for_update 编译产物含 FOR UPDATE、只读直出不含。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_判重叠查询带FOR_UPDATE_只读直出不带(self) -> None:
|
||||||
|
from app.models import CustomerRatingGrade
|
||||||
|
|
||||||
|
locked = (
|
||||||
|
select(CustomerRatingGrade)
|
||||||
|
.order_by(CustomerRatingGrade.sort_order, CustomerRatingGrade.id)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
assert "FOR UPDATE" in str(locked.compile())
|
||||||
|
plain = select(CustomerRatingGrade).order_by(CustomerRatingGrade.sort_order)
|
||||||
|
assert "FOR UPDATE" not in str(plain.compile())
|
||||||
|
|
||||||
|
|
||||||
|
class TestList:
|
||||||
|
"""列表直出种子基线:判定表是前端评级计算器的唯一数据源。"""
|
||||||
|
|
||||||
|
def test_种子四等级全量返回_按sort_order升序(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.get(_GRADES, headers=normal["headers"])
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
rows = resp.json()["data"]
|
||||||
|
assert [(r["grade_name"], r["sort_order"]) for r in rows] == [
|
||||||
|
(name, order) for name, _, _, order in _SEED
|
||||||
|
], rows
|
||||||
|
# 分值红线:str 出参且与种子精确相等(Decimal 比较,不经 float)
|
||||||
|
for row, (_, min_score, max_score, _) in zip(rows, _SEED, strict=True):
|
||||||
|
assert Decimal(row["min_score"]) == Decimal(min_score)
|
||||||
|
assert Decimal(row["max_score"]) == Decimal(max_score)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreate:
|
||||||
|
"""新增:区间合法(0≤min≤max≤100)、互不重叠、名称唯一 —— 三条硬约束
|
||||||
|
任一缺失都会算错等级。"""
|
||||||
|
|
||||||
|
def test_删除种子后新增相邻区间_成功且判定表更新(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
"""种子铺满 0-100,任何新区间都与某条重叠 —— 先删 C(60-74),
|
||||||
|
新增 60-74 与 D(0-59)/B(75-89) **边界相邻不算重叠**(正例)。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers)
|
||||||
|
resp = client.post(
|
||||||
|
_GRADES, json=_gpayload("t_E", "60", "74", sort_order=5), headers=headers
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()["data"]
|
||||||
|
assert Decimal(body["min_score"]) == Decimal("60")
|
||||||
|
assert Decimal(body["max_score"]) == Decimal("74")
|
||||||
|
assert body["sort_order"] == 5
|
||||||
|
|
||||||
|
rows = client.get(_GRADES, headers=headers).json()["data"]
|
||||||
|
assert _names(client, headers) == ["A", "B", "D", "t_E"], rows
|
||||||
|
|
||||||
|
def test_区间倒挂_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(_GRADES, json=_gpayload(_tname(), "80", "10"), headers=normal["headers"])
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_分值超上限_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
_GRADES, json=_gpayload(_tname(), "95", "150"), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_分值负下限_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(_GRADES, json=_gpayload(_tname(), "-5", "10"), headers=normal["headers"])
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_等级名称已存在_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""先删 D 清出空隙:重叠校验在名称唯一校验之前,不清空隙会先撞
|
||||||
|
「与等级 D 重叠」而走不到名称校验。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
_drop_seed(client, headers, "D")
|
||||||
|
resp = client.post(_GRADES, json=_gpayload("A", "10", "20"), headers=headers)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "该等级名称已存在" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_新增与种子重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""85-95 与种子 A(90-100) 闭区间相交 → 400 并点名 A,前端据此定位行。"""
|
||||||
|
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"])
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "分值区间与等级 A 重叠" in resp.json()["message"], resp.text
|
||||||
|
# 失败不落库:判定表仍是 4 条种子
|
||||||
|
assert _names(client, normal["headers"]) == ["A", "B", "C", "D"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdit:
|
||||||
|
"""编辑以修改后的区间对全部既有等级(排除自身)两两相交 —— 编辑路与
|
||||||
|
新增路共用重叠约束,两处都要拦。"""
|
||||||
|
|
||||||
|
def test_编辑与其他等级重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""把 B 编辑为 60-65:与 C(60-74) 相交 → 点名 C;与 D(0-59)/A 均分离。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
b_id = _seed_id(client, headers, "B")
|
||||||
|
resp = client.put(
|
||||||
|
f"{_GRADES}/{b_id}",
|
||||||
|
json=_gpayload("B", "60", "65", sort_order=2),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "分值区间与等级 C 重叠" in resp.json()["message"], resp.text
|
||||||
|
# 失败不落库:种子保持原样
|
||||||
|
assert _names(client, headers) == ["A", "B", "C", "D"]
|
||||||
|
|
||||||
|
def test_编辑相邻区间_成功且判定表更新(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""删除 C 后把 D 编辑为 0-74:与 B(75-89) **边界相邻不算重叠**
|
||||||
|
(74 与 75 相邻),这是种子铺法的正例。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers)
|
||||||
|
d_id = _seed_id(client, headers, "D")
|
||||||
|
resp = client.put(
|
||||||
|
f"{_GRADES}/{d_id}",
|
||||||
|
json=_gpayload("D", "0", "74", sort_order=4),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
rows = client.get(_GRADES, headers=headers).json()["data"]
|
||||||
|
assert [(r["grade_name"], r["sort_order"]) for r in rows] == [("A", 1), ("B", 2), ("D", 4)]
|
||||||
|
assert Decimal(rows[2]["max_score"]) == Decimal("74"), rows[2]
|
||||||
|
|
||||||
|
def test_编辑回显改名与分数(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||||||
|
grade_name = _tname()
|
||||||
|
created = client.post(
|
||||||
|
_GRADES, json=_gpayload(grade_name, "10", "20", sort_order=50), headers=headers
|
||||||
|
)
|
||||||
|
assert created.status_code == 200, created.text
|
||||||
|
grade_id = created.json()["data"]["id"]
|
||||||
|
|
||||||
|
new_name = _tname()
|
||||||
|
updated = client.put(
|
||||||
|
f"{_GRADES}/{grade_id}",
|
||||||
|
json=_gpayload(new_name, "15", "25", sort_order=60, remark="t_改"),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200, updated.text
|
||||||
|
body = updated.json()["data"]
|
||||||
|
assert body["grade_name"] == new_name
|
||||||
|
assert Decimal(body["min_score"]) == Decimal("15")
|
||||||
|
assert Decimal(body["max_score"]) == Decimal("25")
|
||||||
|
assert body["sort_order"] == 60
|
||||||
|
|
||||||
|
rows = client.get(_GRADES, headers=headers).json()["data"]
|
||||||
|
match = next(r for r in rows if r["id"] == grade_id)
|
||||||
|
assert match["grade_name"] == new_name
|
||||||
|
assert Decimal(match["min_score"]) == Decimal("15")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDelete:
|
||||||
|
"""删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表
|
||||||
|
立即反映,二次删除 404。"""
|
||||||
|
|
||||||
|
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||||||
|
grade_name = _tname()
|
||||||
|
created = client.post(
|
||||||
|
_GRADES, json=_gpayload(grade_name, "30.5", "40.5", sort_order=50), headers=headers
|
||||||
|
)
|
||||||
|
assert created.status_code == 200, created.text
|
||||||
|
grade_id = created.json()["data"]["id"]
|
||||||
|
assert grade_name in _names(client, headers)
|
||||||
|
|
||||||
|
assert client.delete(f"{_GRADES}/{grade_id}", headers=headers).status_code == 200
|
||||||
|
assert grade_name not in _names(client, headers)
|
||||||
|
|
||||||
|
again = client.delete(f"{_GRADES}/{grade_id}", headers=headers)
|
||||||
|
assert again.status_code == 404, again.text
|
||||||
|
assert "评级等级不存在" in again.json()["message"], again.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudit:
|
||||||
|
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||||
|
|
||||||
|
def test_新增等级成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||||||
|
grade_name = _tname()
|
||||||
|
resp = client.post(
|
||||||
|
_GRADES,
|
||||||
|
json=_gpayload(grade_name, "30.5", "40.5", sort_order=50),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
logs = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_RATING and log.action == ACTION_CREATE
|
||||||
|
]
|
||||||
|
assert logs, "新增评级等级未留操作日志"
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest.success == 1
|
||||||
|
assert grade_name in latest.summary
|
||||||
|
|
||||||
|
def test_重叠失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"])
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
failed = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_RATING and log.success == 0
|
||||||
|
]
|
||||||
|
assert failed, "失败操作未留痕"
|
||||||
|
assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0]
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
"""回款管理接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是回款的数据完整性三道线 —— ① 发货批次同业务强校验:跨业务指定会让
|
||||||
|
回款天数与台账归属漂移;② settlement_status 是 M3 核算防重锚点(需规
|
||||||
|
2.3.3「每笔回款仅核算一次」),已核算禁改禁删缺失会让台账与源数据漂移;
|
||||||
|
③ total_received_amount 回写是 M3 起付判定的直接消费源,删行漏算即漂移。
|
||||||
|
payment_days 为只读派生值,锁定「发货日首日计第 1 天」口径。
|
||||||
|
|
||||||
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||||
|
|
||||||
|
数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;回款批次
|
||||||
|
编号系统生成(HK 前缀),conftest 按编号前缀全量清;「已核算」的造数走
|
||||||
|
直接改库(M2 只写 0、置位是 M3 职责),M3 核算置位语义不绕开。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.constants import ACTION_CREATE, MODULE_RECEIPT
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import OperationLog, Receipt
|
||||||
|
from app.models.receipt import SETTLEMENT_DONE
|
||||||
|
from tests.integration.factories import create_business as _create_business
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
_SHIPMENTS = "/api/v1/business/shipments"
|
||||||
|
_RECEIPTS = "/api/v1/business/receipts"
|
||||||
|
|
||||||
|
|
||||||
|
def _sitem(**overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||||
|
"quantity": "1",
|
||||||
|
"sale_price": "500.00",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _spayload(business_id: int, ship_date: str, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"business_id": business_id,
|
||||||
|
"ship_date": ship_date,
|
||||||
|
"items": [_sitem()],
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _create_shipment(client: TestClient, headers: dict, business_id: int, ship_date: str) -> dict:
|
||||||
|
resp = client.post(_SHIPMENTS, json=_spayload(business_id, ship_date), headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _rpayload(business_id: int, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"business_id": business_id,
|
||||||
|
"receipt_date": str(date.today()),
|
||||||
|
"amount": "1200.00",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _create(client: TestClient, headers: dict, business_id: int, **overrides: object) -> dict:
|
||||||
|
resp = client.post(_RECEIPTS, json=_rpayload(business_id, **overrides), headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate(client: TestClient, headers: dict, business_id: int) -> None:
|
||||||
|
"""把业务流转到已终止(终态禁写场景的造数步骤)。"""
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{business_id}/status",
|
||||||
|
json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(receipt_id: int) -> None:
|
||||||
|
"""已核算造数:M2 只写 0,置 1 由 M3 核算负责 —— 测试直接改库模拟置位,
|
||||||
|
不绕开「M2 不置位」的实现约定。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
row = session.get(Receipt, receipt_id)
|
||||||
|
assert row is not None, "回款行不存在"
|
||||||
|
row.settlement_status = SETTLEMENT_DONE
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _logs_of(username: str) -> list[OperationLog]:
|
||||||
|
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(OperationLog)
|
||||||
|
.where(OperationLog.username == username)
|
||||||
|
.order_by(OperationLog.id.desc())
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuth:
|
||||||
|
"""鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。"""
|
||||||
|
|
||||||
|
def test_未登录访问回款列表_401(self, client: TestClient) -> None:
|
||||||
|
resp = client.get(_RECEIPTS)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreate:
|
||||||
|
"""创建:HK 批次契约、金额红线、回款天数派生与业务合计回写。"""
|
||||||
|
|
||||||
|
def test_新增回款_批次号格式_未指定批次_无派生(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
|
||||||
|
# 批次号格式:HK{YYYYMMDD}-{4位当日序号}(日期取当日)
|
||||||
|
expected_prefix = f"HK{date.today():%Y%m%d}-"
|
||||||
|
assert re.fullmatch(r"HK\d{8}-\d{4}", data["receipt_batch_no"]), data["receipt_batch_no"]
|
||||||
|
assert data["receipt_batch_no"].startswith(expected_prefix), data["receipt_batch_no"]
|
||||||
|
|
||||||
|
# 未指定批次(shipment_id 空):批次可暂不指定,回款天数无从派生
|
||||||
|
assert data["shipment_id"] is None
|
||||||
|
assert data["shipment_no"] is None
|
||||||
|
assert data["payment_days"] is None
|
||||||
|
# M2 只写 0:置 1 由 M3 核算负责
|
||||||
|
assert data["settlement_status"] == 0
|
||||||
|
assert Decimal(data["amount"]) == Decimal("1200")
|
||||||
|
|
||||||
|
def test_指定批次_回款天数发货日首日计第1天(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""发货 2026-10-05 / 回款 2026-11-02 → 29 天(首日计第 1 天,
|
||||||
|
需规「26 天」示例已作废)。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
shipment = _create_shipment(client, headers, business["id"], "2026-10-05")
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
business["id"],
|
||||||
|
shipment_id=shipment["id"],
|
||||||
|
receipt_date="2026-11-02",
|
||||||
|
amount="500.00",
|
||||||
|
)
|
||||||
|
assert data["shipment_id"] == shipment["id"]
|
||||||
|
assert data["shipment_no"] == shipment["shipment_no"]
|
||||||
|
assert data["payment_days"] == 29, data
|
||||||
|
|
||||||
|
def test_新增回款_业务合计回写_删除后回退(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
|
||||||
|
after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(after["total_received_amount"]) == Decimal("1200")
|
||||||
|
|
||||||
|
assert client.delete(f"{_RECEIPTS}/{data['id']}", headers=headers).status_code == 200
|
||||||
|
rollback = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(rollback["total_received_amount"]) == Decimal("0")
|
||||||
|
|
||||||
|
def test_业务不存在_404(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(_RECEIPTS, json=_rpayload(999999999), headers=normal["headers"])
|
||||||
|
assert resp.status_code == 404, resp.text
|
||||||
|
assert "业务不存在" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_终态业务禁新增回款_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
_terminate(client, headers, business["id"])
|
||||||
|
resp = client.post(_RECEIPTS, json=_rpayload(business["id"]), headers=headers)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "业务已完成或已终止,禁止新增回款" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_金额负数_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""一期禁负数金额(红字/退款冲销需求出现时先补设计再放开)。"""
|
||||||
|
business = _create_business(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_RECEIPTS, json=_rpayload(business["id"], amount="-1"), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "不能为负数" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_金额三位小数_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱。"""
|
||||||
|
business = _create_business(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_RECEIPTS,
|
||||||
|
json=_rpayload(business["id"], amount="12.345"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "最多两位小数" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrossBusiness:
|
||||||
|
"""发货批次同业务强校验 —— 两类失败(不存在/跨业务)统一文案,防响应
|
||||||
|
差异探测发货单存在性。"""
|
||||||
|
|
||||||
|
def test_跨业务指定发货批次_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business1 = _create_business(client, headers)
|
||||||
|
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
|
||||||
|
business2 = _create_business(client, headers)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
_RECEIPTS,
|
||||||
|
json=_rpayload(business2["id"], shipment_id=shipment["id"]),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_编辑时指定跨业务发货批次_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""编辑路径(全量替换)同样强校验,无「未变即免检」旁路。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
business1 = _create_business(client, headers)
|
||||||
|
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
|
||||||
|
business2 = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business2["id"])
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_RECEIPTS}/{data['id']}",
|
||||||
|
json={
|
||||||
|
"shipment_id": shipment["id"],
|
||||||
|
"receipt_date": str(date.today()),
|
||||||
|
"amount": "1200.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_编辑回款_全量替换_派生值实时刷新(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
shipment = _create_shipment(client, headers, business["id"], "2026-10-05")
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
assert data["payment_days"] is None
|
||||||
|
|
||||||
|
specified = client.put(
|
||||||
|
f"{_RECEIPTS}/{data['id']}",
|
||||||
|
json={
|
||||||
|
"shipment_id": shipment["id"],
|
||||||
|
"receipt_date": "2026-11-02",
|
||||||
|
"amount": "300.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert specified.status_code == 200, specified.text
|
||||||
|
body = specified.json()["data"]
|
||||||
|
assert body["payment_days"] == 29, "指定批次后回款天数按发货日首日派生"
|
||||||
|
assert body["shipment_no"] == shipment["shipment_no"]
|
||||||
|
|
||||||
|
cleared = client.put(
|
||||||
|
f"{_RECEIPTS}/{data['id']}",
|
||||||
|
json={
|
||||||
|
"shipment_id": None,
|
||||||
|
"receipt_date": "2026-11-02",
|
||||||
|
"amount": "300.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert cleared.status_code == 200, cleared.text
|
||||||
|
assert cleared.json()["data"]["payment_days"] is None, "清除指定后派生值回 null"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettled:
|
||||||
|
"""已核算回款禁改禁删(M3 核算防重锚点)—— 台账固化后源数据禁漂移。"""
|
||||||
|
|
||||||
|
def test_已核算禁改_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
_settle(data["id"])
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_RECEIPTS}/{data['id']}",
|
||||||
|
json={
|
||||||
|
"shipment_id": None,
|
||||||
|
"receipt_date": str(date.today()),
|
||||||
|
"amount": "9999.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "已核算回款禁止修改" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_已核算禁删_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
_settle(data["id"])
|
||||||
|
|
||||||
|
resp = client.delete(f"{_RECEIPTS}/{data['id']}", headers=headers)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "已核算回款禁止删除" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilter:
|
||||||
|
"""核算状态筛选(M3 核算消费的入口口径)。"""
|
||||||
|
|
||||||
|
def test_settlement_status筛选_0只回未核算_1只回已核算(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
pending = _create(client, headers, business["id"], amount="500.00")
|
||||||
|
settled = _create(client, headers, business["id"], amount="700.00")
|
||||||
|
_settle(settled["id"])
|
||||||
|
|
||||||
|
resp0 = client.get(
|
||||||
|
_RECEIPTS,
|
||||||
|
params={"business_id": business["id"], "settlement_status": 0},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp0.status_code == 200, resp0.text
|
||||||
|
rows0 = resp0.json()["data"]["list"]
|
||||||
|
assert [r["id"] for r in rows0] == [pending["id"]], rows0
|
||||||
|
assert rows0[0]["settlement_status"] == 0
|
||||||
|
|
||||||
|
resp1 = client.get(
|
||||||
|
_RECEIPTS,
|
||||||
|
params={"business_id": business["id"], "settlement_status": 1},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 200, resp1.text
|
||||||
|
rows1 = resp1.json()["data"]["list"]
|
||||||
|
assert [r["id"] for r in rows1] == [settled["id"]], rows1
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudit:
|
||||||
|
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||||
|
|
||||||
|
def test_新增回款成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
logs = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_RECEIPT and log.action == ACTION_CREATE
|
||||||
|
]
|
||||||
|
assert logs, "新增回款未留操作日志"
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest.success == 1
|
||||||
|
assert "新增回款" in latest.summary
|
||||||
|
assert data["receipt_batch_no"] in latest.summary
|
||||||
|
|
||||||
|
def test_跨业务指定失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business1 = _create_business(client, headers)
|
||||||
|
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
|
||||||
|
business2 = _create_business(client, headers)
|
||||||
|
resp = client.post(
|
||||||
|
_RECEIPTS,
|
||||||
|
json=_rpayload(business2["id"], shipment_id=shipment["id"]),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
failed = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_RECEIPT and log.success == 0
|
||||||
|
]
|
||||||
|
assert failed, "失败操作未留痕"
|
||||||
|
assert "发货批次不属于该业务" in (failed[0].error_message or ""), failed[0]
|
||||||
@@ -28,10 +28,14 @@ _CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL)
|
|||||||
_COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE)
|
_COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE)
|
||||||
# 这些开头的是约束定义,不是列定义
|
# 这些开头的是约束定义,不是列定义
|
||||||
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
|
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
|
||||||
|
# ALTER TABLE 的列级变更(语义重放用):ADD COLUMN / DROP COLUMN;
|
||||||
|
# MODIFY 只改类型不改列名,对列集合无影响,无需处理
|
||||||
|
_ALTER_ADD = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+ADD(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
|
||||||
|
_ALTER_DROP = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+DROP(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _parse_tables(sql: str) -> dict[str, set[str]]:
|
def _parse_tables(sql: str) -> dict[str, set[str]]:
|
||||||
"""从 SQL 文本中提取 {表名: {列名集合}}。"""
|
"""从 SQL 文本中提取 {表名: {列名集合}}(仅 CREATE TABLE 的静态解析)。"""
|
||||||
tables: dict[str, set[str]] = {}
|
tables: dict[str, set[str]] = {}
|
||||||
for table_name, body in _CREATE_TABLE.findall(sql):
|
for table_name, body in _CREATE_TABLE.findall(sql):
|
||||||
tables[table_name] = {
|
tables[table_name] = {
|
||||||
@@ -42,6 +46,27 @@ def _parse_tables(sql: str) -> dict[str, set[str]]:
|
|||||||
return tables
|
return tables
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_final_state(paths: list[Path]) -> dict[str, set[str]]:
|
||||||
|
"""按文件序重放迁移脚本,得到迁移链**终态**的 {表名: {列名集合}}。
|
||||||
|
|
||||||
|
早前实现把全部脚本的 CREATE 列做并集:某表日后被 ALTER DROP COLUMN
|
||||||
|
时,并集里旧列名永远存在,init-dev.sql 必须保留死列才能过测 —— 守卫
|
||||||
|
会主动背书一份与生产结构漂移的开发库 DDL。按序重放 ADD/DROP 后,
|
||||||
|
比对基准才是终态(CHANGE RENAME 列名出现时重放会报差异,属人工核对
|
||||||
|
的正确时机:改名同时牵动 init-dev 与 ORM,须人来确认两处同步)。
|
||||||
|
"""
|
||||||
|
merged: dict[str, set[str]] = {}
|
||||||
|
for path in paths:
|
||||||
|
sql = path.read_text(encoding="utf-8")
|
||||||
|
for table, columns in _parse_tables(sql).items():
|
||||||
|
merged.setdefault(table, set()).update(columns)
|
||||||
|
for table, column in _ALTER_ADD.findall(sql):
|
||||||
|
merged.setdefault(table, set()).add(column)
|
||||||
|
for table, column in _ALTER_DROP.findall(sql):
|
||||||
|
merged.setdefault(table, set()).discard(column)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def _alembic_head() -> str:
|
def _alembic_head() -> str:
|
||||||
"""从迁移脚本推断当前 head 版本号。
|
"""从迁移脚本推断当前 head 版本号。
|
||||||
|
|
||||||
@@ -65,15 +90,10 @@ def _alembic_head() -> str:
|
|||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def migration_tables() -> dict[str, set[str]]:
|
def migration_tables() -> dict[str, set[str]]:
|
||||||
"""合并解析 db/migration/ 下全部 V*.sql。"""
|
"""按文件序重放 db/migration/ 下全部 V*.sql,取迁移链终态。"""
|
||||||
files = sorted(MIGRATION_DIR.glob("V*.sql"))
|
files = sorted(MIGRATION_DIR.glob("V*.sql"))
|
||||||
assert files, f"未找到迁移脚本:{MIGRATION_DIR}"
|
assert files, f"未找到迁移脚本:{MIGRATION_DIR}"
|
||||||
|
return _migration_final_state(files)
|
||||||
merged: dict[str, set[str]] = {}
|
|
||||||
for path in files:
|
|
||||||
for table, columns in _parse_tables(path.read_text(encoding="utf-8")).items():
|
|
||||||
merged.setdefault(table, set()).update(columns)
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
"""发货管理接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是发货单的行金额固化与双重汇总回写 —— 行金额(amount)与发货单总额
|
||||||
|
(total_amount)若经前端传值,业务级 total_ship_amount 与运费合计就是可
|
||||||
|
被篡改的源数据,M3 核算直接读业务冗余列;终态业务发货单禁写缺失会让核算
|
||||||
|
基线漂移;被回款批次指定的发货单若能直接删,指定它的回款就指向悬空批次,
|
||||||
|
回款天数无从派生。
|
||||||
|
|
||||||
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||||
|
|
||||||
|
数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;发货单
|
||||||
|
编号系统生成(SH 前缀),conftest 按编号前缀全量清;删除语义的用例自验
|
||||||
|
DELETE 端点。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.constants import ACTION_CREATE, MODULE_SHIPMENT
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import OperationLog
|
||||||
|
from tests.integration.factories import create_business as _create_business
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
_SHIPMENTS = "/api/v1/business/shipments"
|
||||||
|
_RECEIPTS = "/api/v1/business/receipts"
|
||||||
|
_PERSONNEL = "/api/v1/base/personnel"
|
||||||
|
|
||||||
|
|
||||||
|
def _item(**overrides: object) -> dict:
|
||||||
|
"""发货明细行入参(默认:3 × 300.00 = 900)。"""
|
||||||
|
body: dict = {
|
||||||
|
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||||
|
"quantity": "3",
|
||||||
|
"sale_price": "300.00",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _spayload(business_id: int, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"business_id": business_id,
|
||||||
|
"ship_date": str(date.today()),
|
||||||
|
"items": [_item()],
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _create(client: TestClient, headers: dict, business_id: int, **overrides: object) -> dict:
|
||||||
|
resp = client.post(_SHIPMENTS, json=_spayload(business_id, **overrides), headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate(client: TestClient, headers: dict, business_id: int) -> None:
|
||||||
|
"""把业务流转到已终止(终态禁写场景的造数步骤)。"""
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{business_id}/status",
|
||||||
|
json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _logs_of(username: str) -> list[OperationLog]:
|
||||||
|
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(OperationLog)
|
||||||
|
.where(OperationLog.username == username)
|
||||||
|
.order_by(OperationLog.id.desc())
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuth:
|
||||||
|
"""鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。"""
|
||||||
|
|
||||||
|
def test_未登录访问发货单列表_401(self, client: TestClient) -> None:
|
||||||
|
resp = client.get(_SHIPMENTS)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreate:
|
||||||
|
"""创建:SH 编号契约、行校验、行金额固化与双重汇总回写 —— 业务级汇总
|
||||||
|
是 M3 起付判定的直接消费源,错写就是算错钱。"""
|
||||||
|
|
||||||
|
def test_创建发货单_行金额固化与双重汇总回写(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
business["id"],
|
||||||
|
freight_amount="88.50",
|
||||||
|
items=[
|
||||||
|
_item(quantity="3", sale_price="300.00", amount="999.99"),
|
||||||
|
_item(quantity="2", sale_price="250.00"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 编号格式:SH{YYYYMMDD}-{4位当日序号}(日期取当日)
|
||||||
|
expected_prefix = f"SH{date.today():%Y%m%d}-"
|
||||||
|
assert re.fullmatch(r"SH\d{8}-\d{4}", data["shipment_no"]), data["shipment_no"]
|
||||||
|
assert data["shipment_no"].startswith(expected_prefix), data["shipment_no"]
|
||||||
|
|
||||||
|
# 行金额由服务层按 数量×单价 固化:schema 无 amount 入参,塞了也忽略
|
||||||
|
detail = client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
amounts = [Decimal(i["amount"]) for i in detail["items"]]
|
||||||
|
assert amounts == [Decimal("900"), Decimal("500")], detail["items"]
|
||||||
|
assert Decimal(detail["total_amount"]) == Decimal("1400")
|
||||||
|
assert Decimal(detail["freight_amount"]) == Decimal("88.50")
|
||||||
|
|
||||||
|
# 业务级汇总回写(发货金额 / 运费合计)
|
||||||
|
business_detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert Decimal(business_detail["total_ship_amount"]) == Decimal("1400")
|
||||||
|
assert Decimal(business_detail["total_freight_amount"]) == Decimal("88.50")
|
||||||
|
|
||||||
|
def test_业务不存在_404(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(_SHIPMENTS, json=_spayload(999999999), headers=normal["headers"])
|
||||||
|
assert resp.status_code == 404, resp.text
|
||||||
|
assert "业务不存在" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_终态业务禁新发货_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
_terminate(client, headers, business["id"])
|
||||||
|
resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "业务已完成或已终止,禁止新发货" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_明细为空_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
business = _create_business(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_SHIPMENTS, json=_spayload(business["id"], items=[]), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
business = _create_business(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_SHIPMENTS,
|
||||||
|
json=_spayload(business["id"], items=[_item(quantity="0")]),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "数量" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_运费负数_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""运费是金额源数据:负值会让业务运费合计与报表直接错乱。"""
|
||||||
|
business = _create_business(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_SHIPMENTS,
|
||||||
|
json=_spayload(business["id"], freight_amount="-8.00"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "不能为负数" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_运费三位小数_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
business = _create_business(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_SHIPMENTS,
|
||||||
|
json=_spayload(business["id"], freight_amount="8.333"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "最多两位小数" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdit:
|
||||||
|
"""编辑(明细全量替换)与两处汇总同步 —— 业务级汇总漏算即漂移。"""
|
||||||
|
|
||||||
|
def test_编辑发货单_两处汇总同步(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
business["id"],
|
||||||
|
freight_amount="50.00",
|
||||||
|
items=[_item(quantity="1", sale_price="1000.00")],
|
||||||
|
)
|
||||||
|
before = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(before["total_ship_amount"]) == Decimal("1000")
|
||||||
|
|
||||||
|
updated = client.put(
|
||||||
|
f"{_SHIPMENTS}/{data['id']}",
|
||||||
|
json={
|
||||||
|
"ship_date": str(date.today()),
|
||||||
|
"freight_amount": "12.00",
|
||||||
|
"items": [_item(quantity="2", sale_price="150.00")],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200, updated.text
|
||||||
|
assert Decimal(updated.json()["data"]["total_amount"]) == Decimal("300")
|
||||||
|
|
||||||
|
# 业务级两处汇总随编辑同步
|
||||||
|
after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(after["total_ship_amount"]) == Decimal("300")
|
||||||
|
assert Decimal(after["total_freight_amount"]) == Decimal("12")
|
||||||
|
|
||||||
|
detail = client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert len(detail["items"]) == 1, "编辑是全量替换,旧行不得残留"
|
||||||
|
assert Decimal(detail["items"][0]["amount"]) == Decimal("300")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDelete:
|
||||||
|
"""删除保护与汇总回退:被回款指定禁删;删除后业务级汇总必须回算。"""
|
||||||
|
|
||||||
|
def test_被回款指定禁删_解除后可删_汇总回退(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
business["id"],
|
||||||
|
freight_amount="20.00",
|
||||||
|
items=[_item(quantity="1", sale_price="500.00")],
|
||||||
|
)
|
||||||
|
receipt = client.post(
|
||||||
|
_RECEIPTS,
|
||||||
|
json={
|
||||||
|
"business_id": business["id"],
|
||||||
|
"shipment_id": data["id"],
|
||||||
|
"receipt_date": str(date.today()),
|
||||||
|
"amount": "500.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert receipt.status_code == 200, receipt.text
|
||||||
|
|
||||||
|
blocked = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers)
|
||||||
|
assert blocked.status_code == 400, blocked.text
|
||||||
|
assert "已被回款批次指定" in blocked.json()["message"], blocked.text
|
||||||
|
|
||||||
|
# 解除回款的批次指定(未核算回款可删)后发货单可删,业务级汇总回退
|
||||||
|
assert (
|
||||||
|
client.delete(
|
||||||
|
f"{_RECEIPTS}/{receipt.json()['data']['id']}", headers=headers
|
||||||
|
).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
freed = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers)
|
||||||
|
assert freed.status_code == 200, freed.text
|
||||||
|
business_detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert Decimal(business_detail["total_ship_amount"]) == Decimal("0")
|
||||||
|
assert Decimal(business_detail["total_freight_amount"]) == Decimal("0")
|
||||||
|
|
||||||
|
def test_删除未被指定的发货单_业务级汇总回退(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
business["id"],
|
||||||
|
freight_amount="100.00",
|
||||||
|
items=[_item(quantity="2", sale_price="1000.00")],
|
||||||
|
)
|
||||||
|
before = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(before["total_ship_amount"]) == Decimal("2000")
|
||||||
|
|
||||||
|
freed = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers)
|
||||||
|
assert freed.status_code == 200, freed.text
|
||||||
|
assert client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).status_code == 404
|
||||||
|
after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(after["total_ship_amount"]) == Decimal("0")
|
||||||
|
assert Decimal(after["total_freight_amount"]) == Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminal:
|
||||||
|
"""终态业务的发货单禁改禁删 —— 发货的运费/金额是业务级汇总与 M3 核算
|
||||||
|
的源数据,终态后不得变动。"""
|
||||||
|
|
||||||
|
def test_终态业务发货单禁改_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
_terminate(client, headers, business["id"])
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_SHIPMENTS}/{data['id']}",
|
||||||
|
json={
|
||||||
|
"ship_date": str(date.today()),
|
||||||
|
"items": [_item(quantity="1", sale_price="1.00")],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "禁止编辑发货单" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_终态业务发货单禁删_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
_terminate(client, headers, business["id"])
|
||||||
|
|
||||||
|
resp = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "禁止删除发货单" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudit:
|
||||||
|
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||||
|
|
||||||
|
def test_新增发货单成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
logs = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_SHIPMENT and log.action == ACTION_CREATE
|
||||||
|
]
|
||||||
|
assert logs, "新增发货单未留操作日志"
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest.success == 1
|
||||||
|
assert "新增发货单" in latest.summary
|
||||||
|
assert data["shipment_no"] in latest.summary
|
||||||
|
|
||||||
|
def test_终态禁新发货失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
_terminate(client, headers, business["id"])
|
||||||
|
resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
failed = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_SHIPMENT and log.success == 0
|
||||||
|
]
|
||||||
|
assert failed, "失败操作未留痕"
|
||||||
|
assert "禁止新发货" in (failed[0].error_message or ""), failed[0]
|
||||||
@@ -62,7 +62,20 @@ class TestSerializeRequestData:
|
|||||||
def test_手机号被脱敏(self) -> None:
|
def test_手机号被脱敏(self) -> None:
|
||||||
text = _serialize_request_data({"phone": "13812345678"}) or ""
|
text = _serialize_request_data({"phone": "13812345678"}) or ""
|
||||||
assert "13812345678" not in text
|
assert "13812345678" not in text
|
||||||
assert "138****5678" in text
|
# phone 键已加入结构化抹除清单(整值 ***)—— 带空格/横线分隔的号码
|
||||||
|
# (如 138 0013 8000)会穿过分值文本正则,键名整值抹除是更强的防线
|
||||||
|
assert '"phone": "***"' in text
|
||||||
|
|
||||||
|
def test_带分隔符的手机号被脱敏(self) -> None:
|
||||||
|
"""修复回归:'138 0013 8000' 类分隔符写法曾同时穿过分值正则与键名清单。"""
|
||||||
|
for payload in (
|
||||||
|
{"phone": "138 0013 8000"},
|
||||||
|
{"phone": "138-0013-8000"},
|
||||||
|
{"mobile": "13800138000"},
|
||||||
|
):
|
||||||
|
text = _serialize_request_data(payload) or ""
|
||||||
|
assert "13800138000" not in text.replace(" ", "").replace("-", "")
|
||||||
|
assert "***" in text
|
||||||
|
|
||||||
def test_超长中文按字节边界截断且无残字(self) -> None:
|
def test_超长中文按字节边界截断且无残字(self) -> None:
|
||||||
"""75000 字节(25000 个汉字)必然超限:
|
"""75000 字节(25000 个汉字)必然超限:
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""公式纯函数测试。
|
||||||
|
|
||||||
|
守的是:算钱公式的精确性与边界 —— 一旦按 float 或字面量截断计算,
|
||||||
|
360/361 天分界、4/360 斜率、27 天回款示例会全部错位,直接影响工资金额。
|
||||||
|
断言一律用 Fraction/Decimal 精确值,禁近似比较。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from fractions import Fraction
|
||||||
|
|
||||||
|
from app.services.formula import (
|
||||||
|
apply_barter,
|
||||||
|
converted_profit,
|
||||||
|
payment_days,
|
||||||
|
payment_weight,
|
||||||
|
performance_amount,
|
||||||
|
profit_rate,
|
||||||
|
salesperson_rate,
|
||||||
|
start_payment_ok,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 与迁移种子一致的业务员参数(存储口径:start/floor 为 ‰ 数,slope 为 ‰/天)
|
||||||
|
SP_START = Fraction(10)
|
||||||
|
SP_SLOPE = Fraction(4, 360)
|
||||||
|
SP_FLOOR = Fraction(4)
|
||||||
|
SP_BOUNDARY = 360
|
||||||
|
|
||||||
|
|
||||||
|
def _sp_rate(days: int) -> Fraction:
|
||||||
|
return salesperson_rate(
|
||||||
|
days, start=SP_START, slope=SP_SLOPE, floor=SP_FLOOR, boundary_days=SP_BOUNDARY
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaymentDays:
|
||||||
|
"""回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。"""
|
||||||
|
|
||||||
|
def test_发货日当天回款为第1天(self) -> None:
|
||||||
|
d = date(2026, 1, 1)
|
||||||
|
assert payment_days(d, d) == 1
|
||||||
|
|
||||||
|
def test_12月15日发货1月10日回款为27天(self) -> None:
|
||||||
|
assert payment_days(date(2025, 12, 15), date(2026, 1, 10)) == 27
|
||||||
|
|
||||||
|
def test_跨年按自然日连续累计(self) -> None:
|
||||||
|
assert payment_days(date(2025, 12, 31), date(2026, 1, 1)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestSalespersonRate:
|
||||||
|
"""业务员计提比例:1~360 天线性递减、≥361 天固定下限(对照表 #4)。"""
|
||||||
|
|
||||||
|
def test_第360天恰为千分之6_线性段终点(self) -> None:
|
||||||
|
assert _sp_rate(360) == Fraction(6, 1000)
|
||||||
|
|
||||||
|
def test_第361天固定下限千分之4_分界线(self) -> None:
|
||||||
|
# 公式外推为 5.989‰,业务确认「360 为分界线,360 以后按千分之四」
|
||||||
|
assert _sp_rate(361) == Fraction(4, 1000)
|
||||||
|
|
||||||
|
def test_第1天按公式为899比90000_非取整为10(self) -> None:
|
||||||
|
# 需规图示「第 1 天对应 10‰」为上限参考;公式值 = (10 − 4/360)/1000 = 899/90000
|
||||||
|
assert _sp_rate(1) == Fraction(899, 90000)
|
||||||
|
|
||||||
|
def test_第180天线性段中点千分之8(self) -> None:
|
||||||
|
assert _sp_rate(180) == Fraction(8, 1000)
|
||||||
|
|
||||||
|
def test_返回值为纯比例_非千分数(self) -> None:
|
||||||
|
# 绩效 = 折算额 × 比例,比例必须是 0.006 而非 6
|
||||||
|
assert _sp_rate(360) == Fraction(6, 1000)
|
||||||
|
assert _sp_rate(360) != Fraction(6)
|
||||||
|
|
||||||
|
def test_负天数钳制为0天_按起始比例计提(self) -> None:
|
||||||
|
"""先款后货(payment_days 为负)不线性外插:钳到 0 天取起始比例。
|
||||||
|
|
||||||
|
修复前 days=-5 会算出 (10 + 5×4/360)‰ —— 高于起始值,多算绩效。
|
||||||
|
"""
|
||||||
|
assert _sp_rate(-5) == _sp_rate(0)
|
||||||
|
assert _sp_rate(-1) == Fraction(10, 1000)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfitRate:
|
||||||
|
"""毛利润率:不含运费口径(对照表 #2),毛利润额 / 销售额。"""
|
||||||
|
|
||||||
|
def test_毛利润率_不含运费(self) -> None:
|
||||||
|
assert profit_rate(Decimal("45"), Decimal("100")) == Fraction(9, 20)
|
||||||
|
|
||||||
|
def test_销售额为零_返回零_防除零(self) -> None:
|
||||||
|
assert profit_rate(Decimal("0"), Decimal("0")) == Fraction(0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvertedProfit:
|
||||||
|
"""折算毛利润额(化简式):销售额 ×(毛利润率 − 阈值)(对照表 #12/#13)。"""
|
||||||
|
|
||||||
|
def test_化简式结果_市政水利30点阈值(self) -> None:
|
||||||
|
# 100 × (9/20 − 30/100) = 15
|
||||||
|
assert converted_profit(Decimal("100"), Fraction(9, 20), Fraction(30)) == Decimal("15")
|
||||||
|
|
||||||
|
def test_化简式与定义式等价(self) -> None:
|
||||||
|
# 定义式:毛利润额 / 毛利润率 × (毛利润率 − 阈值) = 45 / (9/20) × (9/20 − 3/10) = 15
|
||||||
|
revenue, profit = Decimal("100"), Decimal("45")
|
||||||
|
rate = profit_rate(profit, revenue)
|
||||||
|
assert converted_profit(revenue, rate, Fraction(30)) == Decimal("15")
|
||||||
|
|
||||||
|
def test_毛利润额为零_折算为零_不除零(self) -> None:
|
||||||
|
assert converted_profit(Decimal("0"), Fraction(0), Fraction(30)) == Decimal("0")
|
||||||
|
|
||||||
|
def test_毛利润率低于阈值_折算为负_不归零(self) -> None:
|
||||||
|
# 100 × (1/10 − 3/10) = -20(业务确认负值正常计算)
|
||||||
|
assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(30)) == Decimal("-20")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerformanceAmount:
|
||||||
|
"""绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。"""
|
||||||
|
|
||||||
|
def test_乘法方向_折算额乘比例(self) -> None:
|
||||||
|
# 15000 × 97/10000 = 145.5
|
||||||
|
assert performance_amount(Decimal("15000"), Fraction(97, 10000)) == Decimal("145.5")
|
||||||
|
|
||||||
|
def test_负折算额_绩效为负不归零(self) -> None:
|
||||||
|
assert performance_amount(Decimal("-20"), Fraction(6, 1000)) == Decimal("-0.12")
|
||||||
|
|
||||||
|
|
||||||
|
class TestModifiers:
|
||||||
|
"""修饰器:以物抵顶、支付权重 R×R、起付判定(对照表 #21/#22/#24)。"""
|
||||||
|
|
||||||
|
def test_以物抵顶_按80点折算(self) -> None:
|
||||||
|
assert apply_barter(Decimal("145.5"), Fraction(80)) == Decimal("116.4")
|
||||||
|
|
||||||
|
def test_支付权重_R为60时0点36(self) -> None:
|
||||||
|
assert payment_weight(60) == Fraction(36, 100)
|
||||||
|
|
||||||
|
def test_支付权重_R为100时全额(self) -> None:
|
||||||
|
assert payment_weight(100) == Fraction(1)
|
||||||
|
|
||||||
|
def test_起付判定_达到60点(self) -> None:
|
||||||
|
assert start_payment_ok(Decimal("60000"), Decimal("100000"), Fraction(60)) is True
|
||||||
|
|
||||||
|
def test_起付判定_未达到60点(self) -> None:
|
||||||
|
assert start_payment_ok(Decimal("59999"), Decimal("100000"), Fraction(60)) is False
|
||||||
@@ -19,8 +19,28 @@ EXPECTED_TABLES = {
|
|||||||
"role_permissions",
|
"role_permissions",
|
||||||
"login_logs",
|
"login_logs",
|
||||||
"operation_logs",
|
"operation_logs",
|
||||||
|
"business_roles",
|
||||||
|
"param_definitions",
|
||||||
|
"param_change_batches",
|
||||||
|
"param_values",
|
||||||
|
"personnel",
|
||||||
|
"personnel_regions",
|
||||||
|
"personnel_plans",
|
||||||
|
"products",
|
||||||
|
"customers",
|
||||||
|
"ownership_change_logs",
|
||||||
|
"businesses",
|
||||||
|
"business_items",
|
||||||
|
"business_status_logs",
|
||||||
|
"shipments",
|
||||||
|
"shipment_items",
|
||||||
|
"receipts",
|
||||||
|
"customer_rating_grades",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束
|
||||||
|
PLURAL_EXCEPTIONS = {"personnel"}
|
||||||
|
|
||||||
|
|
||||||
def _ddl(table_name: str) -> str:
|
def _ddl(table_name: str) -> str:
|
||||||
"""返回某张表的建表 DDL 文本。"""
|
"""返回某张表的建表 DDL 文本。"""
|
||||||
@@ -39,7 +59,9 @@ class TestTableSet:
|
|||||||
def test_所有表名均为小写蛇形复数(self) -> None:
|
def test_所有表名均为小写蛇形复数(self) -> None:
|
||||||
for name in Base.metadata.tables:
|
for name in Base.metadata.tables:
|
||||||
assert re.fullmatch(r"[a-z][a-z0-9_]*", name), f"表名 {name} 不符合小写蛇形"
|
assert re.fullmatch(r"[a-z][a-z0-9_]*", name), f"表名 {name} 不符合小写蛇形"
|
||||||
assert name.endswith("s"), f"表名 {name} 应为复数"
|
# personnel 等集合名词单复数同形,本身即复数语义
|
||||||
|
if name not in PLURAL_EXCEPTIONS:
|
||||||
|
assert name.endswith("s"), f"表名 {name} 应为复数"
|
||||||
|
|
||||||
|
|
||||||
class TestNamingConvention:
|
class TestNamingConvention:
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""参数注册表与公式模板注册表测试。
|
||||||
|
|
||||||
|
守的是:注册表是参数种子数据与运行期校验的唯一来源 —— 一旦编码或默认值
|
||||||
|
与迁移种子漂移,参数解析会取到错误口径;一旦取值校验失守,
|
||||||
|
畸形值(如 "1/0")会落库并在核算时崩溃或算错钱。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fractions import Fraction
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.exceptions import BadRequestError
|
||||||
|
from app.services.calc_templates import TEMPLATES, get_template, required_params
|
||||||
|
from app.services.param_registry import (
|
||||||
|
PARAM_REGISTRY,
|
||||||
|
get_spec,
|
||||||
|
parse_fraction,
|
||||||
|
validate_and_normalize,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 与 db/migration/V3 迁移种子保持一致的 16 项编码
|
||||||
|
EXPECTED_CODES = {
|
||||||
|
"salesperson.rate.start",
|
||||||
|
"salesperson.rate.slope",
|
||||||
|
"salesperson.rate.floor",
|
||||||
|
"salesperson.days.boundary",
|
||||||
|
"manager.share.team",
|
||||||
|
"manager.share.company",
|
||||||
|
"enterprise.rate",
|
||||||
|
"info.rate",
|
||||||
|
"prepay.rate",
|
||||||
|
"barter.discount",
|
||||||
|
"payment.start.ratio",
|
||||||
|
"payment.weight.mode",
|
||||||
|
"product.threshold",
|
||||||
|
"salary.basic.mode",
|
||||||
|
"probation.annual.threshold",
|
||||||
|
"probation.salary.basic",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistry:
|
||||||
|
"""注册表元数据完整性(与迁移种子同源)。"""
|
||||||
|
|
||||||
|
def test_注册表共16项(self) -> None:
|
||||||
|
assert len(PARAM_REGISTRY) == 16
|
||||||
|
|
||||||
|
def test_编码集合与迁移种子一致(self) -> None:
|
||||||
|
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
||||||
|
|
||||||
|
def test_每项必填字段非空(self) -> None:
|
||||||
|
for spec in PARAM_REGISTRY.values():
|
||||||
|
assert spec.code, "code 为空"
|
||||||
|
assert spec.name, f"{spec.code} 缺中文名称"
|
||||||
|
assert spec.group, f"{spec.code} 缺分组"
|
||||||
|
assert spec.value_type in {"DECIMAL", "INT", "BOOL", "ENUM", "TEXT"}
|
||||||
|
|
||||||
|
def test_默认值可按类型解析(self) -> None:
|
||||||
|
for spec in PARAM_REGISTRY.values():
|
||||||
|
if spec.default_value is None:
|
||||||
|
continue
|
||||||
|
if spec.value_type in {"DECIMAL", "INT"}:
|
||||||
|
parse_fraction(spec.default_value) # 不可解析会抛 BadRequestError
|
||||||
|
elif spec.value_type == "ENUM":
|
||||||
|
assert spec.options, f"{spec.code} 为 ENUM 但无选项"
|
||||||
|
assert spec.default_value in spec.options
|
||||||
|
|
||||||
|
def test_取值域上下限可解析(self) -> None:
|
||||||
|
for spec in PARAM_REGISTRY.values():
|
||||||
|
if spec.value_min is not None:
|
||||||
|
parse_fraction(spec.value_min)
|
||||||
|
if spec.value_max is not None:
|
||||||
|
parse_fraction(spec.value_max)
|
||||||
|
|
||||||
|
def test_取未注册编码抛ValueError_属代码缺陷(self) -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
get_spec("not.exist.code")
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseFraction:
|
||||||
|
"""Fraction 精确解析:4/360 不许经 float 中转。"""
|
||||||
|
|
||||||
|
def test_分数式精确解析_4除以360(self) -> None:
|
||||||
|
assert parse_fraction("4/360") == Fraction(1, 90)
|
||||||
|
|
||||||
|
def test_整数解析(self) -> None:
|
||||||
|
assert parse_fraction("10") == Fraction(10)
|
||||||
|
|
||||||
|
def test_小数解析(self) -> None:
|
||||||
|
assert parse_fraction("12.5") == Fraction(25, 2)
|
||||||
|
|
||||||
|
def test_除零分数拒绝(self) -> None:
|
||||||
|
with pytest.raises(BadRequestError):
|
||||||
|
parse_fraction("1/0")
|
||||||
|
|
||||||
|
def test_非数字拒绝(self) -> None:
|
||||||
|
with pytest.raises(BadRequestError):
|
||||||
|
parse_fraction("abc")
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateAndNormalize:
|
||||||
|
"""参数值校验:类型/取值域/枚举(审查重点 1:畸形值不得落库)。"""
|
||||||
|
|
||||||
|
def test_去除首尾空白后返回规范值(self) -> None:
|
||||||
|
spec = PARAM_REGISTRY["barter.discount"]
|
||||||
|
assert validate_and_normalize(spec, " 80 ") == "80"
|
||||||
|
|
||||||
|
def test_超取值域上限拒绝(self) -> None:
|
||||||
|
spec = PARAM_REGISTRY["barter.discount"] # value_max=100
|
||||||
|
with pytest.raises(BadRequestError):
|
||||||
|
validate_and_normalize(spec, "120")
|
||||||
|
|
||||||
|
def test_低于取值域下限拒绝(self) -> None:
|
||||||
|
spec = PARAM_REGISTRY["barter.discount"] # value_min=0
|
||||||
|
with pytest.raises(BadRequestError):
|
||||||
|
validate_and_normalize(spec, "-1")
|
||||||
|
|
||||||
|
def test_枚举越界拒绝(self) -> None:
|
||||||
|
spec = PARAM_REGISTRY["payment.weight.mode"]
|
||||||
|
with pytest.raises(BadRequestError):
|
||||||
|
validate_and_normalize(spec, "PARTIAL")
|
||||||
|
|
||||||
|
def test_枚举合法值通过(self) -> None:
|
||||||
|
spec = PARAM_REGISTRY["payment.weight.mode"]
|
||||||
|
assert validate_and_normalize(spec, "BALANCE") == "BALANCE"
|
||||||
|
|
||||||
|
def test_整数类型拒绝小数(self) -> None:
|
||||||
|
spec = PARAM_REGISTRY["salesperson.days.boundary"]
|
||||||
|
with pytest.raises(BadRequestError):
|
||||||
|
validate_and_normalize(spec, "360.5")
|
||||||
|
|
||||||
|
def test_分数式斜率原样保留不截断(self) -> None:
|
||||||
|
spec = PARAM_REGISTRY["salesperson.rate.slope"]
|
||||||
|
assert validate_and_normalize(spec, "4/360") == "4/360"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplates:
|
||||||
|
"""公式模板注册表:有限集合,声明参数必须真实存在。"""
|
||||||
|
|
||||||
|
def test_共五个模板(self) -> None:
|
||||||
|
assert len(TEMPLATES) == 5
|
||||||
|
|
||||||
|
def test_模板声明参数均在注册表内(self) -> None:
|
||||||
|
for tpl in TEMPLATES.values():
|
||||||
|
for code in tpl.params:
|
||||||
|
assert code in PARAM_REGISTRY, f"{tpl.code} 声明了未注册参数 {code}"
|
||||||
|
|
||||||
|
def test_取模板声明参数(self) -> None:
|
||||||
|
params = required_params("TPL_SALESPERSON_DECAY")
|
||||||
|
assert "salesperson.rate.slope" in params
|
||||||
|
|
||||||
|
def test_未知模板抛ValueError_属代码缺陷(self) -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
get_template("TPL_NOT_EXIST")
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""参数快照(时点化取值)测试。
|
||||||
|
|
||||||
|
守的是:覆盖链与兜底 —— 一旦取错维度(如企业户取到市政阈值行)
|
||||||
|
或漏兜底,核算会用错口径或直接崩溃;同日多次变更必须取最新留痕。
|
||||||
|
仓储 SQL 的排序语义(同日取 id 最大)在集成测试中覆盖。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import FrozenInstanceError
|
||||||
|
from datetime import date
|
||||||
|
from fractions import Fraction
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.param_service import ParamSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _snap(rows: list[tuple[str, str, str]]) -> ParamSnapshot:
|
||||||
|
"""构造快照。rows 须模拟仓储输出:同一 (code, dim) 的最新值在前。"""
|
||||||
|
# 仓储实际返回 4 元组(含生效日期);单测关注覆盖链,统一补同一个日期
|
||||||
|
return ParamSnapshot.from_rows(
|
||||||
|
date(2026, 10, 1), [(code, dim, value, date(2026, 1, 1)) for code, dim, value in rows]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCoverChain:
|
||||||
|
"""覆盖链:角色维度 > 产品类别维度 > 默认行('')。"""
|
||||||
|
|
||||||
|
def test_产品类别维度优先于默认行(self) -> None:
|
||||||
|
snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "municipal_water", "2")])
|
||||||
|
assert snap.get("enterprise.rate", product_category="municipal_water") == "2"
|
||||||
|
|
||||||
|
def test_无匹配维度时落默认行(self) -> None:
|
||||||
|
snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "municipal_water", "2")])
|
||||||
|
assert snap.get("enterprise.rate", product_category="rail_transit") == "3"
|
||||||
|
|
||||||
|
def test_角色维度优先于产品类别维度(self) -> None:
|
||||||
|
snap = _snap([("some.param", "municipal_water", "A"), ("some.param", "salesperson", "B")])
|
||||||
|
got = snap.get("some.param", role_code="salesperson", product_category="municipal_water")
|
||||||
|
assert got == "B"
|
||||||
|
|
||||||
|
def test_无任何维度匹配且无默认行_回退注册表默认值(self) -> None:
|
||||||
|
snap = _snap([("enterprise.rate", "municipal_water", "2")])
|
||||||
|
# 轨交无行、无默认行 → 回退注册表 default_value(3)
|
||||||
|
assert snap.fraction("enterprise.rate", product_category="rail_transit") == Fraction(3)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFallback:
|
||||||
|
"""注册表默认值兜底(种子缺失时的安全网)。"""
|
||||||
|
|
||||||
|
def test_未定义参数按注册表默认值兜底(self) -> None:
|
||||||
|
snap = _snap([])
|
||||||
|
assert snap.fraction("prepay.rate") == Fraction(10)
|
||||||
|
|
||||||
|
def test_未注册编码抛ValueError_属代码缺陷(self) -> None:
|
||||||
|
snap = _snap([])
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
snap.fraction("not.registered.code")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSameDayChanges:
|
||||||
|
"""同日多次变更:仓储 SQL 已按 (effective_date, id) 倒序,快照取先出现者。"""
|
||||||
|
|
||||||
|
def test_同日多次变更取最新一条(self) -> None:
|
||||||
|
snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "", "2")])
|
||||||
|
assert snap.fraction("enterprise.rate") == Fraction(3)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTypedGetters:
|
||||||
|
"""类型化取值:Fraction 精确解析、整数、布尔、文本。"""
|
||||||
|
|
||||||
|
def test_fraction精确解析分数式(self) -> None:
|
||||||
|
snap = _snap([("salesperson.rate.slope", "salesperson", "4/360")])
|
||||||
|
assert snap.fraction("salesperson.rate.slope", role_code="salesperson") == Fraction(1, 90)
|
||||||
|
|
||||||
|
def test_integer(self) -> None:
|
||||||
|
snap = _snap([("salesperson.days.boundary", "salesperson", "360")])
|
||||||
|
assert snap.integer("salesperson.days.boundary", role_code="salesperson") == 360
|
||||||
|
|
||||||
|
def test_text返回枚举原文(self) -> None:
|
||||||
|
snap = _snap([("payment.weight.mode", "", "FULL")])
|
||||||
|
assert snap.text("payment.weight.mode") == "FULL"
|
||||||
|
|
||||||
|
def test_boolean(self) -> None:
|
||||||
|
snap = _snap([("some.flag", "", "1")])
|
||||||
|
assert snap.boolean("some.flag") is True
|
||||||
|
assert snap.boolean("some.flag") is not False
|
||||||
|
|
||||||
|
|
||||||
|
class TestImmutability:
|
||||||
|
"""快照冻结:解析结果不得被调用方改写(核算期间一致性)。"""
|
||||||
|
|
||||||
|
def test_字段不可赋值(self) -> None:
|
||||||
|
snap = _snap([])
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
snap.biz_date = date(2026, 1, 1) # type: ignore[misc]
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""产品 schema 的金额口径单测(秒级,无外部依赖)。
|
||||||
|
|
||||||
|
守:产品 sale_price/stock_quantity 若私写校验不查小数位与容量,5 位小数
|
||||||
|
会被 Numeric 列静默截断(舍入即算错钱)、超容量会以 500 暴露 —— 校验
|
||||||
|
口径必须与 schemas/business._check_amount 一致(shipment/receipt 同源)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.schemas.product import ProductCreateIn
|
||||||
|
|
||||||
|
|
||||||
|
class TestSalePrice:
|
||||||
|
"""单价校验:scale=4 对齐 Numeric(14,4) 列。"""
|
||||||
|
|
||||||
|
def test_四位小数通过_回显原样提交(self) -> None:
|
||||||
|
"""带精度的历史单价必须能原样提交(fba8cfc 教训:编辑被永远拒收)。"""
|
||||||
|
body = ProductCreateIn(
|
||||||
|
product_name="t_产品",
|
||||||
|
product_category="municipal_water",
|
||||||
|
sale_price="5000.1234",
|
||||||
|
)
|
||||||
|
assert body.sale_price == "5000.1234"
|
||||||
|
|
||||||
|
def test_五位小数拒绝而非静默截断(self) -> None:
|
||||||
|
with pytest.raises(ValidationError) as exc:
|
||||||
|
ProductCreateIn(
|
||||||
|
product_name="t_产品",
|
||||||
|
product_category="municipal_water",
|
||||||
|
sale_price="1.23456",
|
||||||
|
)
|
||||||
|
assert "最多四位小数" in str(exc.value)
|
||||||
|
|
||||||
|
def test_超容量拒绝(self) -> None:
|
||||||
|
"""DECIMAL(14,4) 整数部分 10 位:超出即拒绝而非溢出 500。"""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ProductCreateIn(
|
||||||
|
product_name="t_产品",
|
||||||
|
product_category="municipal_water",
|
||||||
|
sale_price="99999999999.0000",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_空串归一None_负数拒绝(self) -> None:
|
||||||
|
empty = ProductCreateIn(
|
||||||
|
product_name="t_产品", product_category="municipal_water", sale_price=""
|
||||||
|
)
|
||||||
|
assert empty.sale_price is None
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ProductCreateIn(
|
||||||
|
product_name="t_产品", product_category="municipal_water", sale_price="-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStockQuantity:
|
||||||
|
"""数量校验:默认 scale=2 对齐 Numeric(14,2) 列。"""
|
||||||
|
|
||||||
|
def test_三位小数拒绝(self) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ProductCreateIn(
|
||||||
|
product_name="t_产品",
|
||||||
|
product_category="municipal_water",
|
||||||
|
stock_quantity="1.001",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_两位小数通过(self) -> None:
|
||||||
|
body = ProductCreateIn(
|
||||||
|
product_name="t_产品",
|
||||||
|
product_category="municipal_water",
|
||||||
|
stock_quantity="1.55",
|
||||||
|
)
|
||||||
|
assert body.stock_quantity == "1.55"
|
||||||
Generated
+23
@@ -221,6 +221,15 @@ wheels = [
|
|||||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "et-xmlfile"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||||
|
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fakeredis"
|
name = "fakeredis"
|
||||||
version = "2.39.0"
|
version = "2.39.0"
|
||||||
@@ -378,6 +387,18 @@ wheels = [
|
|||||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" },
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openpyxl"
|
||||||
|
version = "3.1.5"
|
||||||
|
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "et-xmlfile" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-api"
|
name = "opentelemetry-api"
|
||||||
version = "1.45.1"
|
version = "1.45.1"
|
||||||
@@ -407,6 +428,7 @@ dependencies = [
|
|||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "captcha" },
|
{ name = "captcha" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "openpyxl" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "pwdlib", extra = ["bcrypt"] },
|
{ name = "pwdlib", extra = ["bcrypt"] },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
@@ -433,6 +455,7 @@ requires-dist = [
|
|||||||
{ name = "alembic", specifier = ">=1.14" },
|
{ name = "alembic", specifier = ">=1.14" },
|
||||||
{ name = "captcha", specifier = ">=0.6" },
|
{ name = "captcha", specifier = ">=0.6" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
|
{ name = "openpyxl", specifier = ">=3.1.5" },
|
||||||
{ name = "pillow", specifier = ">=11.0" },
|
{ name = "pillow", specifier = ">=11.0" },
|
||||||
{ name = "pwdlib", extras = ["bcrypt"], specifier = ">=0.2" },
|
{ name = "pwdlib", extras = ["bcrypt"], specifier = ">=0.2" },
|
||||||
{ name = "pydantic", specifier = ">=2.9" },
|
{ name = "pydantic", specifier = ">=2.9" },
|
||||||
|
|||||||
+490
-1
@@ -16,6 +16,10 @@
|
|||||||
SET NAMES utf8mb4;
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
-- 删除顺序遵循外键依赖:先删子表
|
-- 删除顺序遵循外键依赖:先删子表
|
||||||
|
DROP TABLE IF EXISTS `param_values`;
|
||||||
|
DROP TABLE IF EXISTS `param_change_batches`;
|
||||||
|
DROP TABLE IF EXISTS `param_definitions`;
|
||||||
|
DROP TABLE IF EXISTS `business_roles`;
|
||||||
DROP TABLE IF EXISTS `operation_logs`;
|
DROP TABLE IF EXISTS `operation_logs`;
|
||||||
DROP TABLE IF EXISTS `role_permissions`;
|
DROP TABLE IF EXISTS `role_permissions`;
|
||||||
DROP TABLE IF EXISTS `user_roles`;
|
DROP TABLE IF EXISTS `user_roles`;
|
||||||
@@ -167,6 +171,236 @@ CREATE INDEX idx_operation_logs_user_id ON operation_logs (user_id);
|
|||||||
|
|
||||||
CREATE INDEX idx_operation_logs_username ON operation_logs (username);
|
CREATE INDEX idx_operation_logs_username ON operation_logs (username);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 业务岗位角色表(V3 迁移)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE business_roles (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
role_code VARCHAR(32) NOT NULL COMMENT '岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改',
|
||||||
|
role_name VARCHAR(64) NOT NULL COMMENT '岗位名称',
|
||||||
|
calc_template VARCHAR(32) NOT NULL COMMENT '计提公式模板编码(以代码内模板注册表为准)',
|
||||||
|
description VARCHAR(255) COMMENT '岗位说明',
|
||||||
|
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
|
||||||
|
status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1',
|
||||||
|
is_builtin TINYINT NOT NULL COMMENT '是否内置岗位:1内置(禁删禁停) 0自定义' DEFAULT '0',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_business_roles PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uk_business_roles_role_code UNIQUE (role_code)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务岗位角色表(与登录角色roles区分,决定计提公式与参数)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 计提参数定义表(V3 迁移)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE param_definitions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
param_code VARCHAR(64) NOT NULL COMMENT '参数编码,如 salesperson.rate.slope',
|
||||||
|
param_name VARCHAR(64) NOT NULL COMMENT '参数名称',
|
||||||
|
param_group VARCHAR(32) NOT NULL COMMENT '参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径',
|
||||||
|
value_type VARCHAR(16) NOT NULL COMMENT '值类型:DECIMAL/INT/BOOL/ENUM/TEXT',
|
||||||
|
unit VARCHAR(16) COMMENT '展示单位:‰/%/元/天,无单位为空',
|
||||||
|
dimension_type VARCHAR(32) NOT NULL COMMENT '维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE' DEFAULT 'NONE',
|
||||||
|
options_text VARCHAR(255) COMMENT '枚举选项(竖线分隔),仅 ENUM 使用',
|
||||||
|
default_value VARCHAR(64) COMMENT '系统默认值(Fraction 可解析字符串)',
|
||||||
|
value_min VARCHAR(64) COMMENT '取值下限(含),校验用',
|
||||||
|
value_max VARCHAR(64) COMMENT '取值上限(含),校验用',
|
||||||
|
description VARCHAR(255) COMMENT '口径说明(配置页展示)',
|
||||||
|
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
|
||||||
|
status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_param_definitions PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uk_param_definitions_param_code UNIQUE (param_code)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='计提参数定义表' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 参数变更批次表(V3 迁移)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE param_change_batches (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
version_no VARCHAR(32) NOT NULL COMMENT '版本号,如 P20261012-01',
|
||||||
|
effective_date DATE NOT NULL COMMENT '生效日期(业务日期口径)',
|
||||||
|
change_type TINYINT NOT NULL COMMENT '生效方式:1立即生效 2指定日期生效',
|
||||||
|
remark VARCHAR(255) COMMENT '变更说明',
|
||||||
|
operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
|
||||||
|
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)',
|
||||||
|
item_count INTEGER NOT NULL COMMENT '变更参数项数' DEFAULT '0',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_param_change_batches PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_param_change_batches_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT uk_param_change_batches_version_no UNIQUE (version_no)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数变更批次表' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 参数版本条目表(V3 迁移,append-only)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE param_values (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
param_id BIGINT UNSIGNED NOT NULL COMMENT '参数定义ID(param_definitions.id)',
|
||||||
|
dimension_value VARCHAR(32) NOT NULL COMMENT '维度取值:NONE维度为空串;产品类别/岗位编码为具体值' DEFAULT '',
|
||||||
|
effective_date DATE NOT NULL COMMENT '生效日期(按业务日期时点化取值)',
|
||||||
|
value_text VARCHAR(64) NOT NULL COMMENT '参数值(Fraction 可解析字符串/枚举码/开关)',
|
||||||
|
old_value_text VARCHAR(64) COMMENT '变更前值(留痕)',
|
||||||
|
batch_id BIGINT UNSIGNED NOT NULL COMMENT '变更批次ID(param_change_batches.id)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_param_values PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_param_values_batch_id FOREIGN KEY(batch_id) REFERENCES param_change_batches (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_param_values_param_id FOREIGN KEY(param_id) REFERENCES param_definitions (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数版本条目表(append-only,按生效日期时点化取值)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_param_values_batch_id ON param_values (batch_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_param_values_effective_date ON param_values (effective_date);
|
||||||
|
|
||||||
|
CREATE INDEX idx_param_values_param_id_dimension_value_effective_date ON param_values (param_id, dimension_value, effective_date);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 营销人员表(V4 迁移;T+ 同步不覆盖岗位与工资标准)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE personnel (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
personnel_name VARCHAR(64) NOT NULL COMMENT '姓名',
|
||||||
|
business_role_id BIGINT UNSIGNED NOT NULL COMMENT '岗位角色ID(决定计提公式模板;T+同步不覆盖)',
|
||||||
|
manager_id BIGINT UNSIGNED COMMENT '直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)',
|
||||||
|
phone VARCHAR(20) COMMENT '联系电话',
|
||||||
|
hire_date DATE COMMENT '入职日期',
|
||||||
|
is_probation TINYINT NOT NULL COMMENT '是否试用期:1是 0否(试用期绩效照算不发放)' DEFAULT '0',
|
||||||
|
salary_standard DECIMAL(12, 2) COMMENT '月工资标准(元;T+同步不覆盖)',
|
||||||
|
status TINYINT NOT NULL COMMENT '状态:1在职 0离职(离职人员不可再被业务/客户归属引用)' DEFAULT '1',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
CONSTRAINT pk_personnel PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_personnel_business_role_id FOREIGN KEY(business_role_id) REFERENCES business_roles (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_personnel_manager_id FOREIGN KEY(manager_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='营销人员表(T+同步不覆盖岗位与工资标准)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_personnel_business_role_id ON personnel (business_role_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_personnel_manager_id ON personnel (manager_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_personnel_source_no ON personnel (source_no);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 产品辐射区域表(V4 迁移)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE personnel_regions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)',
|
||||||
|
region_name VARCHAR(128) NOT NULL COMMENT '区域名称(产品辐射地理范围)',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_personnel_regions PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_personnel_regions_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品辐射区域表' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_personnel_regions_personnel_id ON personnel_regions (personnel_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 市场拓展计划表(V4 迁移)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE personnel_plans (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)',
|
||||||
|
plan_period VARCHAR(32) NOT NULL COMMENT '计划期间(如 2026Q1、2026-05)',
|
||||||
|
plan_content VARCHAR(500) NOT NULL COMMENT '计划内容',
|
||||||
|
target_desc VARCHAR(255) COMMENT '指标说明(文字描述)',
|
||||||
|
source_no VARCHAR(64) COMMENT 'T+ 原始条目号(预留)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_personnel_plans PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_personnel_plans_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='市场拓展计划表' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_personnel_plans_personnel_id ON personnel_plans (personnel_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 产品表(V4 迁移;类别锚定折算阈值参数,T+ 同步不覆盖产品类别)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE products (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
product_name VARCHAR(128) NOT NULL COMMENT '产品名称',
|
||||||
|
spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)' DEFAULT '',
|
||||||
|
unit VARCHAR(16) COMMENT '计量单位',
|
||||||
|
stock_quantity DECIMAL(14, 2) COMMENT '数量(T+ 同步原样存储,语义待 IT 确认)',
|
||||||
|
sale_price DECIMAL(14, 4) COMMENT '销售单价(元,T+ 同步)',
|
||||||
|
product_category VARCHAR(32) NOT NULL COMMENT '产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)',
|
||||||
|
status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
CONSTRAINT pk_products PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uk_products_name_spec UNIQUE (product_name, spec_model)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_products_source_no ON products (source_no);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 客户表(V4 迁移;公司名称+负责人为业务唯一标识,T+ 同步不覆盖客户分类)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE customers (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
company_name VARCHAR(128) NOT NULL COMMENT '公司名称',
|
||||||
|
owner_name VARCHAR(64) NOT NULL COMMENT '客户负责人姓名(客户公司负责人的人名,非系统人员)',
|
||||||
|
contact_person VARCHAR(64) COMMENT '联系人',
|
||||||
|
contact_phone VARCHAR(20) COMMENT '联系电话',
|
||||||
|
address VARCHAR(255) COMMENT '地址',
|
||||||
|
industry VARCHAR(64) COMMENT '所属行业(自由文本)',
|
||||||
|
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
|
||||||
|
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
CONSTRAINT pk_customers PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uk_customers_company_owner UNIQUE (company_name, owner_name),
|
||||||
|
CONSTRAINT fk_customers_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_customers_customer_type ON customers (customer_type);
|
||||||
|
|
||||||
|
CREATE INDEX idx_customers_owner_personnel_id ON customers (owner_personnel_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_customers_source_no ON customers (source_no);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 归属变更记录表(V4 迁移,append-only;客户/业务归属变更留痕,不回溯历史)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE ownership_change_logs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
owner_type TINYINT NOT NULL COMMENT '归属类型:1客户归属 2业务归属',
|
||||||
|
customer_id BIGINT UNSIGNED COMMENT '客户ID(固化,owner_type=1 时非空)',
|
||||||
|
business_id BIGINT UNSIGNED COMMENT '业务ID(固化,owner_type=2 时非空)',
|
||||||
|
old_owner_personnel_id BIGINT UNSIGNED COMMENT '变更前负责营销人员ID(固化)',
|
||||||
|
new_owner_personnel_id BIGINT UNSIGNED COMMENT '变更后负责营销人员ID(固化)',
|
||||||
|
old_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更前协助营销人员ID(固化,仅业务归属使用)',
|
||||||
|
new_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更后协助营销人员ID(固化,仅业务归属使用)',
|
||||||
|
change_reason VARCHAR(255) COMMENT '变更原因',
|
||||||
|
operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
|
||||||
|
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_ownership_change_logs PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_ownership_change_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_ownership_change_logs_customer_id ON ownership_change_logs (customer_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_ownership_change_logs_business_id ON ownership_change_logs (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_ownership_change_logs_create_time ON ownership_change_logs (create_time);
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- 字典数据:角色(与 V1 迁移中的初始化数据一致)
|
-- 字典数据:角色(与 V1 迁移中的初始化数据一致)
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
@@ -174,6 +408,261 @@ INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('admin
|
|||||||
|
|
||||||
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2);
|
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 字典数据:业务岗位与计提参数(与 V3 迁移种子一致;口径依据 2026-10-12 业务裁决)
|
||||||
|
-- 显式指定 id:param_values 按 param_id 引用定义,与迁移种子保持同源一致
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES
|
||||||
|
(1, 'salesperson', '营销员', 'TPL_SALESPERSON_DECAY', '业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)', 1, 1),
|
||||||
|
(2, 'sales_manager', '营销经理', 'TPL_MANAGER_SHARE', '经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%', 2, 1),
|
||||||
|
(3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1);
|
||||||
|
|
||||||
|
INSERT INTO param_change_batches (id, version_no, effective_date, change_type, remark, operator_name, item_count) VALUES
|
||||||
|
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)', 'system', 19);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES
|
||||||
|
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1),
|
||||||
|
(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2),
|
||||||
|
(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3),
|
||||||
|
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4),
|
||||||
|
(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5),
|
||||||
|
(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6),
|
||||||
|
(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7),
|
||||||
|
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8),
|
||||||
|
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9),
|
||||||
|
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10),
|
||||||
|
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11),
|
||||||
|
(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12),
|
||||||
|
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13),
|
||||||
|
(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14),
|
||||||
|
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15),
|
||||||
|
(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
|
||||||
|
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
||||||
|
(2, 2, 'salesperson', '2026-02-17', '4/360', 1),
|
||||||
|
(3, 3, 'salesperson', '2026-02-17', '4', 1),
|
||||||
|
(4, 4, 'salesperson', '2026-02-17', '360', 1),
|
||||||
|
(5, 5, 'sales_manager', '2026-08-13', '12.5', 1),
|
||||||
|
(6, 6, 'sales_manager', '2026-08-13', '5', 1),
|
||||||
|
(7, 7, '', '2026-02-17', '2', 1),
|
||||||
|
(8, 7, '', '2026-08-13', '3', 1),
|
||||||
|
(9, 8, 'info_officer', '2026-08-13', '1', 1),
|
||||||
|
(10, 9, '', '2026-01-01', '10', 1),
|
||||||
|
(11, 10, '', '2026-01-01', '80', 1),
|
||||||
|
(12, 11, '', '2026-01-01', '60', 1),
|
||||||
|
(13, 12, '', '2026-01-01', 'FULL', 1),
|
||||||
|
(14, 13, 'municipal_water', '2026-02-17', '30', 1),
|
||||||
|
(15, 13, 'rail_transit', '2026-02-17', '20', 1),
|
||||||
|
(16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1),
|
||||||
|
(17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1),
|
||||||
|
(18, 15, '', '2026-01-01', '18000', 1),
|
||||||
|
(19, 16, '', '2026-01-01', '3000', 1);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE businesses (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
business_no VARCHAR(32) NOT NULL COMMENT '业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)',
|
||||||
|
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(customers.id)',
|
||||||
|
business_date DATE COMMENT '业务日期(T+ 同步口径)',
|
||||||
|
status TINYINT NOT NULL COMMENT '业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)' DEFAULT '1',
|
||||||
|
owner_personnel_id BIGINT UNSIGNED COMMENT '负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)',
|
||||||
|
assistant_personnel_id BIGINT UNSIGNED COMMENT '协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)',
|
||||||
|
is_special TINYINT NOT NULL COMMENT '特殊业务标记:1是 0否(公司参与等)' DEFAULT '0',
|
||||||
|
special_note VARCHAR(255) COMMENT '特殊业务说明',
|
||||||
|
total_sale_amount DECIMAL(14, 2) COMMENT '销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)' DEFAULT '0',
|
||||||
|
total_cost_amount DECIMAL(14, 2) COMMENT '成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0',
|
||||||
|
total_ship_amount DECIMAL(14, 2) COMMENT '发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0',
|
||||||
|
total_received_amount DECIMAL(14, 2) COMMENT '回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)' DEFAULT '0',
|
||||||
|
total_freight_amount DECIMAL(14, 2) COMMENT '运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
CONSTRAINT pk_businesses PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uk_businesses_business_no UNIQUE (business_no),
|
||||||
|
CONSTRAINT fk_businesses_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_businesses_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_businesses_assistant_personnel_id FOREIGN KEY(assistant_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务表(业务编号自动生成;T+同步不覆盖业务状态)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_businesses_business_date ON businesses (business_date);
|
||||||
|
|
||||||
|
CREATE INDEX idx_businesses_customer_id ON businesses (customer_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_businesses_owner_personnel_id ON businesses (owner_personnel_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_businesses_source_no ON businesses (source_no);
|
||||||
|
|
||||||
|
CREATE INDEX idx_businesses_status ON businesses (status);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 业务产品明细表(V5 迁移;行级类别为折算锚点,指导价 T+ 同步不覆盖)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE business_items (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)',
|
||||||
|
product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入,未入产品库)',
|
||||||
|
product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)',
|
||||||
|
spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '',
|
||||||
|
unit VARCHAR(16) COMMENT '计量单位',
|
||||||
|
product_category VARCHAR(32) NOT NULL COMMENT '产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)',
|
||||||
|
quantity DECIMAL(14, 2) NOT NULL COMMENT '数量(>0)',
|
||||||
|
sale_price DECIMAL(14, 4) COMMENT '销售单价(元;T+ 同步或人工)',
|
||||||
|
sale_amount DECIMAL(14, 2) NOT NULL COMMENT '销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)',
|
||||||
|
cost_amount DECIMAL(14, 2) COMMENT '成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)',
|
||||||
|
guided_price DECIMAL(14, 4) COMMENT '指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_business_items PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_business_items_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_business_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_business_items_business_id ON business_items (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_business_items_product_category ON business_items (product_category);
|
||||||
|
|
||||||
|
CREATE INDEX idx_business_items_product_id ON business_items (product_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 业务状态变更记录表(V5 迁移,append-only;创建时写首条 old=new=1)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE business_status_logs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化,无外键;删除保护由服务层承担)',
|
||||||
|
old_status TINYINT NOT NULL COMMENT '变更前状态(创建首条为 1)',
|
||||||
|
new_status TINYINT NOT NULL COMMENT '变更后状态',
|
||||||
|
change_reason VARCHAR(255) COMMENT '变更原因(终止时必填)',
|
||||||
|
operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
|
||||||
|
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_business_status_logs PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_business_status_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务状态变更记录表(append-only)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_business_status_logs_business_id ON business_status_logs (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_business_status_logs_create_time ON business_status_logs (create_time);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 发货单表(V5 迁移;发货日为回款天数基准日,发货日首日计第 1 天)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE shipments (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
shipment_no VARCHAR(32) NOT NULL COMMENT '发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)',
|
||||||
|
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;终态业务禁止新发货)',
|
||||||
|
ship_date DATE NOT NULL COMMENT '发货日期(回款天数基准日,发货日首日=第 1 天)',
|
||||||
|
freight_amount DECIMAL(14, 2) NOT NULL COMMENT '运费金额(仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0',
|
||||||
|
total_amount DECIMAL(14, 2) NOT NULL COMMENT '发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
CONSTRAINT pk_shipments PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uk_shipments_shipment_no UNIQUE (shipment_no),
|
||||||
|
CONSTRAINT fk_shipments_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货单表(发货日为回款天数基准日,发货日首日计第 1 天)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_shipments_business_id ON shipments (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_shipments_ship_date ON shipments (ship_date);
|
||||||
|
|
||||||
|
CREATE INDEX idx_shipments_source_no ON shipments (source_no);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 发货产品明细表(V5 迁移)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE shipment_items (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
shipment_id BIGINT UNSIGNED NOT NULL COMMENT '发货单ID(shipments.id)',
|
||||||
|
product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入)',
|
||||||
|
product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)',
|
||||||
|
spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '',
|
||||||
|
unit VARCHAR(16) COMMENT '计量单位',
|
||||||
|
quantity DECIMAL(14, 2) NOT NULL COMMENT '发货数量(>0)',
|
||||||
|
sale_price DECIMAL(14, 4) COMMENT '发货单价(元;T+ 同步或人工,口径待 IT 确认)',
|
||||||
|
amount DECIMAL(14, 2) NOT NULL COMMENT '行金额 = 数量×单价(服务层计算固化,不信任前端传值)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_shipment_items PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_shipment_items_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_shipment_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货产品明细表' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_shipment_items_product_id ON shipment_items (product_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_shipment_items_shipment_id ON shipment_items (shipment_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 回款表(V5 迁移;手动指定发货批次,已核算回款禁改禁删)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE receipts (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
receipt_batch_no VARCHAR(32) NOT NULL COMMENT '回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)',
|
||||||
|
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)',
|
||||||
|
shipment_id BIGINT UNSIGNED COMMENT '发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)',
|
||||||
|
receipt_date DATE NOT NULL COMMENT '回款到账日(M3 按此日匹配参数版本,对照表 #11)',
|
||||||
|
amount DECIMAL(14, 2) NOT NULL COMMENT '回款金额(≥0;一期禁负数/红字)',
|
||||||
|
is_barter TINYINT NOT NULL COMMENT '以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)' DEFAULT '0',
|
||||||
|
settlement_status TINYINT NOT NULL COMMENT '绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)' DEFAULT '0',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
CONSTRAINT pk_receipts PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uk_receipts_receipt_batch_no UNIQUE (receipt_batch_no),
|
||||||
|
CONSTRAINT fk_receipts_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_receipts_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='回款表(手动指定发货批次;已核算回款禁改禁删)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_receipts_business_id ON receipts (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_receipts_receipt_date ON receipts (receipt_date);
|
||||||
|
|
||||||
|
CREATE INDEX idx_receipts_settlement_status ON receipts (settlement_status);
|
||||||
|
|
||||||
|
CREATE INDEX idx_receipts_shipment_id ON receipts (shipment_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_receipts_source_no ON receipts (source_no);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 客户评级等级配置表(V5 迁移;判定按 sort_order 升序首个 min_score≤总分)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE customer_rating_grades (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
grade_name VARCHAR(32) NOT NULL COMMENT '等级名称(如 A/优秀)',
|
||||||
|
min_score DECIMAL(5, 1) NOT NULL COMMENT '总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分',
|
||||||
|
max_score DECIMAL(5, 1) NOT NULL COMMENT '总分上限(展示用)',
|
||||||
|
sort_order INT NOT NULL COMMENT '排序(判定顺序,升序)' DEFAULT '0',
|
||||||
|
remark VARCHAR(255) COMMENT '说明',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_customer_rating_grades PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uk_customer_rating_grades_grade_name UNIQUE (grade_name)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 字典数据:评级等级(与 V5 迁移种子一致;评分项固定 5 项×20 分为前端常量,对照表 #25)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES
|
||||||
|
('A', 90, 100, 1, '优秀'),
|
||||||
|
('B', 75, 89, 2, '良好'),
|
||||||
|
('C', 60, 74, 3, '合格'),
|
||||||
|
('D', 0, 59, 4, '待改进');
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作
|
-- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
@@ -182,4 +671,4 @@ CREATE TABLE alembic_version (
|
|||||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
INSERT INTO alembic_version (version_num) VALUES ('a45a8087831b');
|
INSERT INTO alembic_version (version_num) VALUES ('8efc189b131f');
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
-- Running upgrade a45a8087831b -> bfbda9c3191d
|
||||||
|
|
||||||
|
CREATE TABLE business_roles (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
role_code VARCHAR(32) NOT NULL COMMENT '岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改',
|
||||||
|
role_name VARCHAR(64) NOT NULL COMMENT '岗位名称',
|
||||||
|
calc_template VARCHAR(32) NOT NULL COMMENT '计提公式模板编码(以代码内模板注册表为准)',
|
||||||
|
description VARCHAR(255) COMMENT '岗位说明',
|
||||||
|
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
|
||||||
|
status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1',
|
||||||
|
is_builtin TINYINT NOT NULL COMMENT '是否内置岗位:1内置(禁删禁停) 0自定义' DEFAULT '0',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_business_roles PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uk_business_roles_role_code UNIQUE (role_code)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务岗位角色表(与登录角色roles区分,决定计提公式与参数)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE param_definitions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
param_code VARCHAR(64) NOT NULL COMMENT '参数编码,如 salesperson.rate.slope',
|
||||||
|
param_name VARCHAR(64) NOT NULL COMMENT '参数名称',
|
||||||
|
param_group VARCHAR(32) NOT NULL COMMENT '参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径',
|
||||||
|
value_type VARCHAR(16) NOT NULL COMMENT '值类型:DECIMAL/INT/BOOL/ENUM/TEXT',
|
||||||
|
unit VARCHAR(16) COMMENT '展示单位:‰/%/元/天,无单位为空',
|
||||||
|
dimension_type VARCHAR(32) NOT NULL COMMENT '维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE' DEFAULT 'NONE',
|
||||||
|
options_text VARCHAR(255) COMMENT '枚举选项(竖线分隔),仅 ENUM 使用',
|
||||||
|
default_value VARCHAR(64) COMMENT '系统默认值(Fraction 可解析字符串)',
|
||||||
|
value_min VARCHAR(64) COMMENT '取值下限(含),校验用',
|
||||||
|
value_max VARCHAR(64) COMMENT '取值上限(含),校验用',
|
||||||
|
description VARCHAR(255) COMMENT '口径说明(配置页展示)',
|
||||||
|
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
|
||||||
|
status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_param_definitions PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uk_param_definitions_param_code UNIQUE (param_code)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='计提参数定义表' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE param_change_batches (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
version_no VARCHAR(32) NOT NULL COMMENT '版本号,如 P20261012-01',
|
||||||
|
effective_date DATE NOT NULL COMMENT '生效日期(业务日期口径)',
|
||||||
|
change_type TINYINT NOT NULL COMMENT '生效方式:1立即生效 2指定日期生效',
|
||||||
|
remark VARCHAR(255) COMMENT '变更说明',
|
||||||
|
operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
|
||||||
|
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)',
|
||||||
|
item_count INTEGER NOT NULL COMMENT '变更参数项数' DEFAULT '0',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_param_change_batches PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_param_change_batches_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT uk_param_change_batches_version_no UNIQUE (version_no)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数变更批次表' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE param_values (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
param_id BIGINT UNSIGNED NOT NULL COMMENT '参数定义ID(param_definitions.id)',
|
||||||
|
dimension_value VARCHAR(32) NOT NULL COMMENT '维度取值:NONE维度为空串;产品类别/岗位编码为具体值' DEFAULT '',
|
||||||
|
effective_date DATE NOT NULL COMMENT '生效日期(按业务日期时点化取值)',
|
||||||
|
value_text VARCHAR(64) NOT NULL COMMENT '参数值(Fraction 可解析字符串/枚举码/开关)',
|
||||||
|
old_value_text VARCHAR(64) COMMENT '变更前值(留痕)',
|
||||||
|
batch_id BIGINT UNSIGNED NOT NULL COMMENT '变更批次ID(param_change_batches.id)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_param_values PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_param_values_batch_id FOREIGN KEY(batch_id) REFERENCES param_change_batches (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_param_values_param_id FOREIGN KEY(param_id) REFERENCES param_definitions (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数版本条目表(append-only,按生效日期时点化取值)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_param_values_batch_id ON param_values (batch_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_param_values_effective_date ON param_values (effective_date);
|
||||||
|
|
||||||
|
CREATE INDEX idx_param_values_param_id_dimension_value_effective_date ON param_values (param_id, dimension_value, effective_date);
|
||||||
|
|
||||||
|
INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (1, 'salesperson', '营销员', 'TPL_SALESPERSON_DECAY', '业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)', 1, 1);
|
||||||
|
|
||||||
|
INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (2, 'sales_manager', '营销经理', 'TPL_MANAGER_SHARE', '经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%', 2, 1);
|
||||||
|
|
||||||
|
INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1);
|
||||||
|
|
||||||
|
INSERT INTO param_change_batches (id, version_no, effective_date, change_type, remark, operator_name, item_count) VALUES (1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)', 'system', 19);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15);
|
||||||
|
|
||||||
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (1, 1, 'salesperson', '2026-02-17', '10', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (2, 2, 'salesperson', '2026-02-17', '4/360', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (3, 3, 'salesperson', '2026-02-17', '4', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (4, 4, 'salesperson', '2026-02-17', '360', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (5, 5, 'sales_manager', '2026-08-13', '12.5', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (6, 6, 'sales_manager', '2026-08-13', '5', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (7, 7, '', '2026-02-17', '2', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (8, 7, '', '2026-08-13', '3', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (9, 8, 'info_officer', '2026-08-13', '1', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (10, 9, '', '2026-01-01', '10', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (11, 10, '', '2026-01-01', '80', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (12, 11, '', '2026-01-01', '60', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (13, 12, '', '2026-01-01', 'FULL', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (14, 13, 'municipal_water', '2026-02-17', '30', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (15, 13, 'rail_transit', '2026-02-17', '20', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (18, 15, '', '2026-01-01', '18000', 1);
|
||||||
|
|
||||||
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (19, 16, '', '2026-01-01', '3000', 1);
|
||||||
|
|
||||||
|
UPDATE alembic_version SET version_num='bfbda9c3191d' WHERE alembic_version.version_num = 'a45a8087831b';
|
||||||
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
-- Running upgrade bfbda9c3191d -> e1133a7c670a
|
||||||
|
|
||||||
|
CREATE TABLE products (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
product_name VARCHAR(128) NOT NULL COMMENT '产品名称',
|
||||||
|
spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)' DEFAULT '',
|
||||||
|
unit VARCHAR(16) COMMENT '计量单位',
|
||||||
|
stock_quantity NUMERIC(14, 2) COMMENT '数量(T+ 同步原样存储,语义待 IT 确认)',
|
||||||
|
sale_price NUMERIC(14, 4) COMMENT '销售单价(元,T+ 同步)',
|
||||||
|
product_category VARCHAR(32) NOT NULL COMMENT '产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)',
|
||||||
|
status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
CONSTRAINT pk_products PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uk_products_name_spec UNIQUE (product_name, spec_model)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_products_source_no ON products (source_no);
|
||||||
|
|
||||||
|
CREATE TABLE ownership_change_logs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
owner_type TINYINT NOT NULL COMMENT '归属类型:1客户归属 2业务归属',
|
||||||
|
customer_id BIGINT UNSIGNED COMMENT '客户ID(固化,owner_type=1 时非空)',
|
||||||
|
business_id BIGINT UNSIGNED COMMENT '业务ID(固化,owner_type=2 时非空)',
|
||||||
|
old_owner_personnel_id BIGINT UNSIGNED COMMENT '变更前负责营销人员ID(固化)',
|
||||||
|
new_owner_personnel_id BIGINT UNSIGNED COMMENT '变更后负责营销人员ID(固化)',
|
||||||
|
old_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更前协助营销人员ID(固化,仅业务归属使用)',
|
||||||
|
new_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更后协助营销人员ID(固化,仅业务归属使用)',
|
||||||
|
change_reason VARCHAR(255) COMMENT '变更原因',
|
||||||
|
operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
|
||||||
|
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_ownership_change_logs PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_ownership_change_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_ownership_change_logs_customer_id ON ownership_change_logs (customer_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_ownership_change_logs_business_id ON ownership_change_logs (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_ownership_change_logs_create_time ON ownership_change_logs (create_time);
|
||||||
|
|
||||||
|
CREATE TABLE personnel (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
personnel_name VARCHAR(64) NOT NULL COMMENT '姓名',
|
||||||
|
business_role_id BIGINT UNSIGNED NOT NULL COMMENT '岗位角色ID(决定计提公式模板;T+同步不覆盖)',
|
||||||
|
manager_id BIGINT UNSIGNED COMMENT '直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)',
|
||||||
|
phone VARCHAR(20) COMMENT '联系电话',
|
||||||
|
hire_date DATE COMMENT '入职日期',
|
||||||
|
is_probation TINYINT NOT NULL COMMENT '是否试用期:1是 0否(试用期绩效照算不发放)' DEFAULT '0',
|
||||||
|
salary_standard NUMERIC(12, 2) COMMENT '月工资标准(元;T+同步不覆盖)',
|
||||||
|
status TINYINT NOT NULL COMMENT '状态:1在职 0离职(离职人员不可再被业务/客户归属引用)' DEFAULT '1',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
CONSTRAINT pk_personnel PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_personnel_business_role_id FOREIGN KEY(business_role_id) REFERENCES business_roles (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_personnel_manager_id FOREIGN KEY(manager_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='营销人员表(T+同步不覆盖岗位与工资标准)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_personnel_business_role_id ON personnel (business_role_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_personnel_manager_id ON personnel (manager_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_personnel_source_no ON personnel (source_no);
|
||||||
|
|
||||||
|
CREATE TABLE customers (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
company_name VARCHAR(128) NOT NULL COMMENT '公司名称',
|
||||||
|
owner_name VARCHAR(64) NOT NULL COMMENT '客户负责人姓名(客户公司负责人的人名,非系统人员)',
|
||||||
|
contact_person VARCHAR(64) COMMENT '联系人',
|
||||||
|
contact_phone VARCHAR(20) COMMENT '联系电话',
|
||||||
|
address VARCHAR(255) COMMENT '地址',
|
||||||
|
industry VARCHAR(64) COMMENT '所属行业(自由文本)',
|
||||||
|
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
|
||||||
|
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
CONSTRAINT pk_customers PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_customers_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT uk_customers_company_owner UNIQUE (company_name, owner_name)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_customers_customer_type ON customers (customer_type);
|
||||||
|
|
||||||
|
CREATE INDEX idx_customers_owner_personnel_id ON customers (owner_personnel_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_customers_source_no ON customers (source_no);
|
||||||
|
|
||||||
|
CREATE TABLE personnel_plans (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)',
|
||||||
|
plan_period VARCHAR(32) NOT NULL COMMENT '计划期间(如 2026Q1、2026-05)',
|
||||||
|
plan_content VARCHAR(500) NOT NULL COMMENT '计划内容',
|
||||||
|
target_desc VARCHAR(255) COMMENT '指标说明(文字描述)',
|
||||||
|
source_no VARCHAR(64) COMMENT 'T+ 原始条目号(预留)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_personnel_plans PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_personnel_plans_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='市场拓展计划表' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_personnel_plans_personnel_id ON personnel_plans (personnel_id);
|
||||||
|
|
||||||
|
CREATE TABLE personnel_regions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)',
|
||||||
|
region_name VARCHAR(128) NOT NULL COMMENT '区域名称(产品辐射地理范围)',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_personnel_regions PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_personnel_regions_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品辐射区域表' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_personnel_regions_personnel_id ON personnel_regions (personnel_id);
|
||||||
|
|
||||||
|
UPDATE alembic_version SET version_num='e1133a7c670a' WHERE alembic_version.version_num = 'bfbda9c3191d';
|
||||||
|
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
-- Running upgrade e1133a7c670a -> 8efc189b131f
|
||||||
|
|
||||||
|
CREATE TABLE customer_rating_grades (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
grade_name VARCHAR(32) NOT NULL COMMENT '等级名称(如 A/优秀)',
|
||||||
|
min_score NUMERIC(5, 1) NOT NULL COMMENT '总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分',
|
||||||
|
max_score NUMERIC(5, 1) NOT NULL COMMENT '总分上限(展示用)',
|
||||||
|
sort_order INTEGER NOT NULL COMMENT '排序(判定顺序,升序)' DEFAULT '0',
|
||||||
|
remark VARCHAR(255) COMMENT '说明',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_customer_rating_grades PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uk_customer_rating_grades_grade_name UNIQUE (grade_name)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE business_status_logs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化,无外键;删除保护由服务层承担)',
|
||||||
|
old_status TINYINT NOT NULL COMMENT '变更前状态(创建首条为 1)',
|
||||||
|
new_status TINYINT NOT NULL COMMENT '变更后状态',
|
||||||
|
change_reason VARCHAR(255) COMMENT '变更原因(终止时必填)',
|
||||||
|
operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
|
||||||
|
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_business_status_logs PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_business_status_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务状态变更记录表(append-only)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_business_status_logs_business_id ON business_status_logs (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_business_status_logs_create_time ON business_status_logs (create_time);
|
||||||
|
|
||||||
|
CREATE TABLE businesses (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
business_no VARCHAR(32) NOT NULL COMMENT '业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)',
|
||||||
|
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(customers.id)',
|
||||||
|
business_date DATE COMMENT '业务日期(T+ 同步口径)',
|
||||||
|
status TINYINT NOT NULL COMMENT '业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)' DEFAULT '1',
|
||||||
|
owner_personnel_id BIGINT UNSIGNED COMMENT '负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)',
|
||||||
|
assistant_personnel_id BIGINT UNSIGNED COMMENT '协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)',
|
||||||
|
is_special TINYINT NOT NULL COMMENT '特殊业务标记:1是 0否(公司参与等)' DEFAULT '0',
|
||||||
|
special_note VARCHAR(255) COMMENT '特殊业务说明',
|
||||||
|
total_sale_amount NUMERIC(14, 2) COMMENT '销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)' DEFAULT '0',
|
||||||
|
total_cost_amount NUMERIC(14, 2) COMMENT '成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0',
|
||||||
|
total_ship_amount NUMERIC(14, 2) COMMENT '发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0',
|
||||||
|
total_received_amount NUMERIC(14, 2) COMMENT '回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)' DEFAULT '0',
|
||||||
|
total_freight_amount NUMERIC(14, 2) COMMENT '运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
CONSTRAINT pk_businesses PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_businesses_assistant_personnel_id FOREIGN KEY(assistant_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_businesses_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_businesses_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT uk_businesses_business_no UNIQUE (business_no)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务表(业务编号自动生成;T+同步不覆盖业务状态)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_businesses_business_date ON businesses (business_date);
|
||||||
|
|
||||||
|
CREATE INDEX idx_businesses_customer_id ON businesses (customer_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_businesses_owner_personnel_id ON businesses (owner_personnel_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_businesses_source_no ON businesses (source_no);
|
||||||
|
|
||||||
|
CREATE INDEX idx_businesses_status ON businesses (status);
|
||||||
|
|
||||||
|
CREATE TABLE business_items (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)',
|
||||||
|
product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入,未入产品库)',
|
||||||
|
product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)',
|
||||||
|
spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '',
|
||||||
|
unit VARCHAR(16) COMMENT '计量单位',
|
||||||
|
product_category VARCHAR(32) NOT NULL COMMENT '产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)',
|
||||||
|
quantity NUMERIC(14, 2) NOT NULL COMMENT '数量(>0)',
|
||||||
|
sale_price NUMERIC(14, 4) COMMENT '销售单价(元;T+ 同步或人工)',
|
||||||
|
sale_amount NUMERIC(14, 2) NOT NULL COMMENT '销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)',
|
||||||
|
cost_amount NUMERIC(14, 2) COMMENT '成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)',
|
||||||
|
guided_price NUMERIC(14, 4) COMMENT '指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_business_items PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_business_items_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_business_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_business_items_business_id ON business_items (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_business_items_product_category ON business_items (product_category);
|
||||||
|
|
||||||
|
CREATE INDEX idx_business_items_product_id ON business_items (product_id);
|
||||||
|
|
||||||
|
CREATE TABLE shipments (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
shipment_no VARCHAR(32) NOT NULL COMMENT '发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)',
|
||||||
|
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;终态业务禁止新发货)',
|
||||||
|
ship_date DATE NOT NULL COMMENT '发货日期(回款天数基准日,发货日首日=第 1 天)',
|
||||||
|
freight_amount NUMERIC(14, 2) NOT NULL COMMENT '运费金额(仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0',
|
||||||
|
total_amount NUMERIC(14, 2) NOT NULL COMMENT '发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
CONSTRAINT pk_shipments PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_shipments_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT uk_shipments_shipment_no UNIQUE (shipment_no)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货单表(发货日为回款天数基准日,发货日首日计第 1 天)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_shipments_business_id ON shipments (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_shipments_ship_date ON shipments (ship_date);
|
||||||
|
|
||||||
|
CREATE INDEX idx_shipments_source_no ON shipments (source_no);
|
||||||
|
|
||||||
|
CREATE TABLE receipts (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
receipt_batch_no VARCHAR(32) NOT NULL COMMENT '回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)',
|
||||||
|
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)',
|
||||||
|
shipment_id BIGINT UNSIGNED COMMENT '发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)',
|
||||||
|
receipt_date DATE NOT NULL COMMENT '回款到账日(M3 按此日匹配参数版本,对照表 #11)',
|
||||||
|
amount NUMERIC(14, 2) NOT NULL COMMENT '回款金额(≥0;一期禁负数/红字)',
|
||||||
|
is_barter TINYINT NOT NULL COMMENT '以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)' DEFAULT '0',
|
||||||
|
settlement_status TINYINT NOT NULL COMMENT '绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)' DEFAULT '0',
|
||||||
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
CONSTRAINT pk_receipts PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_receipts_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_receipts_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT uk_receipts_receipt_batch_no UNIQUE (receipt_batch_no)
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='回款表(手动指定发货批次;已核算回款禁改禁删)' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_receipts_business_id ON receipts (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_receipts_receipt_date ON receipts (receipt_date);
|
||||||
|
|
||||||
|
CREATE INDEX idx_receipts_settlement_status ON receipts (settlement_status);
|
||||||
|
|
||||||
|
CREATE INDEX idx_receipts_shipment_id ON receipts (shipment_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_receipts_source_no ON receipts (source_no);
|
||||||
|
|
||||||
|
CREATE TABLE shipment_items (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||||
|
shipment_id BIGINT UNSIGNED NOT NULL COMMENT '发货单ID(shipments.id)',
|
||||||
|
product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入)',
|
||||||
|
product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)',
|
||||||
|
spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '',
|
||||||
|
unit VARCHAR(16) COMMENT '计量单位',
|
||||||
|
quantity NUMERIC(14, 2) NOT NULL COMMENT '发货数量(>0)',
|
||||||
|
sale_price NUMERIC(14, 4) COMMENT '发货单价(元;T+ 同步或人工,口径待 IT 确认)',
|
||||||
|
amount NUMERIC(14, 2) NOT NULL COMMENT '行金额 = 数量×单价(服务层计算固化,不信任前端传值)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_shipment_items PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_shipment_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_shipment_items_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT
|
||||||
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货产品明细表' COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_shipment_items_product_id ON shipment_items (product_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_shipment_items_shipment_id ON shipment_items (shipment_id);
|
||||||
|
|
||||||
|
INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('A', 90, 100, 1, '优秀');
|
||||||
|
|
||||||
|
INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('B', 75, 89, 2, '良好');
|
||||||
|
|
||||||
|
INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('C', 60, 74, 3, '合格');
|
||||||
|
|
||||||
|
INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('D', 0, 59, 4, '待改进');
|
||||||
|
|
||||||
|
UPDATE alembic_version SET version_num='8efc189b131f' WHERE alembic_version.version_num = 'e1133a7c670a';
|
||||||
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 业务数据查询留档(M2 业务数据管理)
|
||||||
|
--
|
||||||
|
-- 设计要点:
|
||||||
|
-- - businesses 的 total_* 五列为服务层维护的汇总冗余,查询直接读冗余列
|
||||||
|
-- 而非实时 JOIN 聚合;本文件同时给出「对账用」的实时聚合口径
|
||||||
|
-- - 回款天数 = formula.payment_days(ship_date, receipt_date),即
|
||||||
|
-- DATEDIFF(receipt_date, ship_date) + 1(发货日首日计第 1 天,对照表 #1),
|
||||||
|
-- 应用层以纯函数为准,SQL 版仅供人工对账
|
||||||
|
-- - 毛利润率/折算毛利润额不落库:视图按行实时计算(阈值取 M1 参数快照)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 1. 业务台账(列表页;冗余列直读)
|
||||||
|
SELECT b.id,
|
||||||
|
b.business_no,
|
||||||
|
c.company_name,
|
||||||
|
c.customer_type,
|
||||||
|
b.status,
|
||||||
|
b.business_date,
|
||||||
|
p.personnel_name AS owner_name,
|
||||||
|
pa.personnel_name AS assistant_name,
|
||||||
|
b.total_sale_amount,
|
||||||
|
b.total_cost_amount,
|
||||||
|
b.total_ship_amount,
|
||||||
|
b.total_received_amount,
|
||||||
|
b.total_freight_amount
|
||||||
|
FROM businesses b
|
||||||
|
JOIN customers c ON c.id = b.customer_id
|
||||||
|
LEFT JOIN personnel p ON p.id = b.owner_personnel_id
|
||||||
|
LEFT JOIN personnel pa ON pa.id = b.assistant_personnel_id
|
||||||
|
WHERE (:keyword IS NULL OR b.business_no LIKE CONCAT('%', :keyword, '%')
|
||||||
|
OR c.company_name LIKE CONCAT('%', :keyword, '%'))
|
||||||
|
AND (:status IS NULL OR b.status = :status)
|
||||||
|
AND (:customer_id IS NULL OR b.customer_id = :customer_id)
|
||||||
|
AND (:owner_personnel_id IS NULL OR b.owner_personnel_id = :owner_personnel_id)
|
||||||
|
ORDER BY b.id DESC
|
||||||
|
LIMIT :limit OFFSET :offset;
|
||||||
|
|
||||||
|
-- 2. 汇总冗余对账(五列与明细/发货/回款实时聚合的一致性校验,T11 集成用例同口径)
|
||||||
|
SELECT b.id,
|
||||||
|
b.total_sale_amount,
|
||||||
|
COALESCE((SELECT SUM(i.sale_amount) FROM business_items i WHERE i.business_id = b.id), 0) AS live_sale,
|
||||||
|
b.total_ship_amount,
|
||||||
|
COALESCE((SELECT SUM(si.amount) FROM shipments s JOIN shipment_items si ON si.shipment_id = s.id
|
||||||
|
WHERE s.business_id = b.id), 0) AS live_ship,
|
||||||
|
b.total_received_amount,
|
||||||
|
COALESCE((SELECT SUM(r.amount) FROM receipts r WHERE r.business_id = b.id), 0) AS live_received,
|
||||||
|
b.total_freight_amount,
|
||||||
|
COALESCE((SELECT SUM(s.freight_amount) FROM shipments s WHERE s.business_id = b.id), 0) AS live_freight
|
||||||
|
FROM businesses b
|
||||||
|
WHERE b.total_sale_amount <> COALESCE((SELECT SUM(i.sale_amount) FROM business_items i WHERE i.business_id = b.id), 0)
|
||||||
|
OR b.total_ship_amount <> COALESCE((SELECT SUM(si.amount) FROM shipments s JOIN shipment_items si ON si.shipment_id = s.id
|
||||||
|
WHERE s.business_id = b.id), 0)
|
||||||
|
OR b.total_received_amount <> COALESCE((SELECT SUM(r.amount) FROM receipts r WHERE r.business_id = b.id), 0)
|
||||||
|
OR b.total_freight_amount <> COALESCE((SELECT SUM(s.freight_amount) FROM shipments s WHERE s.business_id = b.id), 0);
|
||||||
|
|
||||||
|
-- 3. 未指定发货批次的回款(M3 核算前置校验名单;列表页筛选项)
|
||||||
|
SELECT r.id, r.receipt_batch_no, b.business_no, r.receipt_date, r.amount, r.is_barter
|
||||||
|
FROM receipts r
|
||||||
|
JOIN businesses b ON b.id = r.business_id
|
||||||
|
WHERE r.shipment_id IS NULL
|
||||||
|
AND r.settlement_status = 0
|
||||||
|
ORDER BY r.receipt_date;
|
||||||
|
|
||||||
|
-- 4. 回款天数对账视图(SQL 版仅供人工核对;应用层以 formula.payment_days 为准)
|
||||||
|
SELECT r.id,
|
||||||
|
r.receipt_batch_no,
|
||||||
|
b.business_no,
|
||||||
|
s.shipment_no,
|
||||||
|
s.ship_date,
|
||||||
|
r.receipt_date,
|
||||||
|
DATEDIFF(r.receipt_date, s.ship_date) + 1 AS payment_days
|
||||||
|
FROM receipts r
|
||||||
|
JOIN businesses b ON b.id = r.business_id
|
||||||
|
LEFT JOIN shipments s ON s.id = r.shipment_id
|
||||||
|
WHERE r.shipment_id IS NOT NULL
|
||||||
|
ORDER BY r.receipt_date DESC;
|
||||||
|
|
||||||
|
-- 5. 按产品类别的毛利润汇总(毛利润视图的类别汇总口径;阈值需按 M1 参数快照逐日匹配,
|
||||||
|
-- 此处仅聚合源数据,折算在应用层以 formula.converted_profit 计算)
|
||||||
|
SELECT i.product_category,
|
||||||
|
SUM(i.sale_amount) AS sale_total,
|
||||||
|
SUM(i.cost_amount) AS cost_total,
|
||||||
|
SUM(i.sale_amount - COALESCE(i.cost_amount, 0)) AS profit_total
|
||||||
|
FROM business_items i
|
||||||
|
WHERE i.business_id = :business_id
|
||||||
|
GROUP BY i.product_category;
|
||||||
|
|
||||||
|
-- 6. 业务状态流转时间线(详情页;创建首条 old=new=1)
|
||||||
|
SELECT l.old_status, l.new_status, l.change_reason, l.operator_name, l.create_time
|
||||||
|
FROM business_status_logs l
|
||||||
|
WHERE l.business_id = :business_id
|
||||||
|
ORDER BY l.create_time, l.id;
|
||||||
|
|
||||||
|
-- 7. 发货单与明细(列表 + 行展开)
|
||||||
|
SELECT s.id, s.shipment_no, b.business_no, s.ship_date, s.freight_amount, s.total_amount
|
||||||
|
FROM shipments s
|
||||||
|
JOIN businesses b ON b.id = s.business_id
|
||||||
|
WHERE (:business_id IS NULL OR s.business_id = :business_id)
|
||||||
|
AND (:ship_date_from IS NULL OR s.ship_date >= :ship_date_from)
|
||||||
|
AND (:ship_date_to IS NULL OR s.ship_date <= :ship_date_to)
|
||||||
|
ORDER BY s.id DESC
|
||||||
|
LIMIT :limit OFFSET :offset;
|
||||||
|
|
||||||
|
SELECT si.product_name, si.spec_model, si.unit, si.quantity, si.sale_price, si.amount
|
||||||
|
FROM shipment_items si
|
||||||
|
WHERE si.shipment_id = :shipment_id
|
||||||
|
ORDER BY si.id;
|
||||||
|
|
||||||
|
-- 8. 评级等级判定(前端按 sort_order 升序首个 min_score ≤ 总分取等级;SQL 版供后端复用)
|
||||||
|
SELECT grade_name, min_score, max_score
|
||||||
|
FROM customer_rating_grades
|
||||||
|
WHERE min_score <= :total_score
|
||||||
|
ORDER BY sort_order
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 基础数据查询留档(M2 基础数据管理)
|
||||||
|
--
|
||||||
|
-- 设计要点:
|
||||||
|
-- - 人员团队关系用 manager_id 自引用展开(NULL=经理本人或未分配),
|
||||||
|
-- M3 经理 12.5% 聚合与 M4 部门汇总均按此关系,无独立团队表
|
||||||
|
-- - 归属变更留痕(ownership_change_logs)对客户/业务/人员 ID 为无外键的
|
||||||
|
-- 冗余固化:被引用方删除后记录仍须完整可读,join 不到时以固化值为准
|
||||||
|
-- - T+ 预留列(source_no 等)当前全部为空,同步模块(M4)上线后启用
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 1. 人员列表(含岗位名称与直属经理姓名;分页 + 关键字筛选)
|
||||||
|
SELECT p.id,
|
||||||
|
p.personnel_name,
|
||||||
|
p.phone,
|
||||||
|
p.hire_date,
|
||||||
|
p.is_probation,
|
||||||
|
p.salary_standard,
|
||||||
|
p.status,
|
||||||
|
br.role_name,
|
||||||
|
br.role_code,
|
||||||
|
m.personnel_name AS manager_name
|
||||||
|
FROM personnel p
|
||||||
|
JOIN business_roles br ON br.id = p.business_role_id
|
||||||
|
LEFT JOIN personnel m ON m.id = p.manager_id
|
||||||
|
WHERE (:keyword IS NULL OR p.personnel_name LIKE CONCAT('%', :keyword, '%'))
|
||||||
|
AND (:status IS NULL OR p.status = :status)
|
||||||
|
ORDER BY p.id DESC
|
||||||
|
LIMIT :limit OFFSET :offset;
|
||||||
|
|
||||||
|
-- 2. 某经理的团队成员(M3 经理 12.5% 聚合的取数口径)
|
||||||
|
SELECT p.id, p.personnel_name, p.business_role_id, p.status
|
||||||
|
FROM personnel p
|
||||||
|
WHERE p.manager_id = :manager_id
|
||||||
|
AND p.status = 1;
|
||||||
|
|
||||||
|
-- 3. 人员的辐射区域与拓展计划(编辑抽屉回显,随人员整表替换维护)
|
||||||
|
SELECT region_name, remark
|
||||||
|
FROM personnel_regions
|
||||||
|
WHERE personnel_id = :personnel_id
|
||||||
|
ORDER BY id;
|
||||||
|
|
||||||
|
SELECT plan_period, plan_content, target_desc, source_no
|
||||||
|
FROM personnel_plans
|
||||||
|
WHERE personnel_id = :personnel_id
|
||||||
|
ORDER BY id;
|
||||||
|
|
||||||
|
-- 4. 产品目录(名称/规格关键字 + 类别筛选;名称+规格为组合唯一键)
|
||||||
|
SELECT id, product_name, spec_model, unit, stock_quantity, sale_price,
|
||||||
|
product_category, status
|
||||||
|
FROM products
|
||||||
|
WHERE (:keyword IS NULL OR product_name LIKE CONCAT('%', :keyword, '%')
|
||||||
|
OR spec_model LIKE CONCAT('%', :keyword, '%'))
|
||||||
|
AND (:product_category IS NULL OR product_category = :product_category)
|
||||||
|
AND (:status IS NULL OR status = :status)
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT :limit OFFSET :offset;
|
||||||
|
|
||||||
|
-- 5. 客户列表(公司/负责人关键字 + 分类 + 归属人员筛选)
|
||||||
|
SELECT c.id, c.company_name, c.owner_name, c.customer_type, c.status, c.owner_personnel_id,
|
||||||
|
p.personnel_name AS owner_personnel_name
|
||||||
|
FROM customers c
|
||||||
|
LEFT JOIN personnel p ON p.id = c.owner_personnel_id
|
||||||
|
WHERE (:keyword IS NULL OR c.company_name LIKE CONCAT('%', :keyword, '%')
|
||||||
|
OR c.owner_name LIKE CONCAT('%', :keyword, '%'))
|
||||||
|
AND (:customer_type IS NULL OR c.customer_type = :customer_type)
|
||||||
|
AND (:owner_personnel_id IS NULL OR c.owner_personnel_id = :owner_personnel_id)
|
||||||
|
ORDER BY c.id DESC
|
||||||
|
LIMIT :limit OFFSET :offset;
|
||||||
|
|
||||||
|
-- 6. 归属变更留痕(客户或业务维度;join 人员出姓名,人员已删时显示固化 ID)
|
||||||
|
SELECT l.id,
|
||||||
|
l.owner_type,
|
||||||
|
l.customer_id,
|
||||||
|
l.business_id,
|
||||||
|
l.old_owner_personnel_id,
|
||||||
|
l.new_owner_personnel_id,
|
||||||
|
po.personnel_name AS old_owner_name,
|
||||||
|
pn.personnel_name AS new_owner_name,
|
||||||
|
l.change_reason,
|
||||||
|
l.operator_name,
|
||||||
|
l.create_time
|
||||||
|
FROM ownership_change_logs l
|
||||||
|
LEFT JOIN personnel po ON po.id = l.old_owner_personnel_id
|
||||||
|
LEFT JOIN personnel pn ON pn.id = l.new_owner_personnel_id
|
||||||
|
WHERE (:customer_id IS NULL OR l.customer_id = :customer_id)
|
||||||
|
AND (:business_id IS NULL OR l.business_id = :business_id)
|
||||||
|
ORDER BY l.create_time DESC, l.id DESC
|
||||||
|
LIMIT :limit OFFSET :offset;
|
||||||
|
|
||||||
|
-- 7. 归属变更留痕(幂等校验辅助:人员删除保护——被归属引用者不可物理删除)
|
||||||
|
SELECT COUNT(*) AS ref_count
|
||||||
|
FROM customers c
|
||||||
|
WHERE c.owner_personnel_id = :personnel_id
|
||||||
|
UNION ALL
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM personnel m
|
||||||
|
WHERE m.manager_id = :personnel_id;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 计提参数查询留档(M1 参数体系)
|
||||||
|
--
|
||||||
|
-- 时点化取值核心规则:按 (param_id, dimension_value) 分组,取
|
||||||
|
-- effective_date <= :biz_date 的 ORDER BY effective_date DESC, id DESC 首条
|
||||||
|
-- (同日多次变更取最新一条,不设唯一键以保证完整留痕)。
|
||||||
|
-- 参数总量约百行级,全量快照一次取回后在应用层按覆盖链
|
||||||
|
-- (角色维度 > 产品类别维度 > 默认行)选择,无需在 SQL 层做维度回退。
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 1. 全量快照:某业务日期下每个(参数, 维度值)的最新生效值
|
||||||
|
-- 窗口函数版(MySQL 8.0+),供 param_repository.resolve_values 使用
|
||||||
|
SELECT t.param_code, t.dimension_value, t.value_text, t.effective_date
|
||||||
|
FROM (
|
||||||
|
SELECT pd.param_code,
|
||||||
|
pv.dimension_value,
|
||||||
|
pv.value_text,
|
||||||
|
pv.effective_date,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY pv.param_id, pv.dimension_value
|
||||||
|
ORDER BY pv.effective_date DESC, pv.id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM param_values pv
|
||||||
|
JOIN param_definitions pd ON pd.id = pv.param_id
|
||||||
|
WHERE pv.effective_date <= :biz_date
|
||||||
|
AND pd.status = 1
|
||||||
|
) t
|
||||||
|
WHERE t.rn = 1;
|
||||||
|
|
||||||
|
-- 2. 单参数版本历史(变更留痕,供参数历史接口与导出)
|
||||||
|
SELECT pv.effective_date,
|
||||||
|
pv.dimension_value,
|
||||||
|
pv.old_value_text,
|
||||||
|
pv.value_text,
|
||||||
|
b.version_no,
|
||||||
|
b.operator_name,
|
||||||
|
b.create_time
|
||||||
|
FROM param_values pv
|
||||||
|
JOIN param_change_batches b ON b.id = pv.batch_id
|
||||||
|
WHERE pv.param_id = :param_id
|
||||||
|
ORDER BY pv.effective_date DESC, pv.id DESC;
|
||||||
|
|
||||||
|
-- 3. 区间变更条目(对比视图辅助:应用层对两日期各执行查询 1 后逐项 diff,
|
||||||
|
-- 本查询列出区间内实际发生过的变更,供变更记录分页与导出)
|
||||||
|
SELECT pd.param_code,
|
||||||
|
pd.param_name,
|
||||||
|
pv.dimension_value,
|
||||||
|
pv.old_value_text,
|
||||||
|
pv.value_text,
|
||||||
|
pv.effective_date,
|
||||||
|
b.version_no,
|
||||||
|
b.operator_name
|
||||||
|
FROM param_values pv
|
||||||
|
JOIN param_definitions pd ON pd.id = pv.param_id
|
||||||
|
JOIN param_change_batches b ON b.id = pv.batch_id
|
||||||
|
WHERE pv.effective_date > :from_date
|
||||||
|
AND pv.effective_date <= :to_date
|
||||||
|
ORDER BY pv.effective_date, pv.id;
|
||||||
Vendored
+45
@@ -0,0 +1,45 @@
|
|||||||
|
|
||||||
|
/*
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).echarts={})}(this,(function(t){"use strict";
|
||||||
|
/*! *****************************************************************************
|
||||||
|
Copyright (c) Microsoft Corporation.
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
***************************************************************************** */var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},e(t,n)};function n(t,n){if("function"!=typeof n&&null!==n)throw new TypeError("Class extends value "+String(n)+" is not a constructor or null");function i(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(i.prototype=n.prototype,new i)}var i=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},r=new function(){this.browser=new i,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(r.wxa=!0,r.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?r.worker=!0:"undefined"==typeof navigator||0===navigator.userAgent.indexOf("Node.js")?(r.node=!0,r.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11),e.domSupported="undefined"!=typeof document;var s=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in s||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in s)&&!("OTransition"in s),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}(navigator.userAgent,r);var o="sans-serif",a="12px "+o;var s,l,u=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n<t.length;n++){var i=String.fromCharCode(n+32),r=(t.charCodeAt(n)-20)/100;e[i]=r}return e}("007LLmW'55;N0500LLLLLLLLLL00NNNLzWW\\\\WQb\\0FWLg\\bWb\\WQ\\WrWWQ000CL5LLFLL0LL**F*gLLLL5F0LF\\FFF5.5N"),h={createCanvas:function(){return"undefined"!=typeof document&&document.createElement("canvas")},measureText:function(t,e){if(!s){var n=h.createCanvas();s=n&&n.getContext("2d")}if(s)return l!==e&&(l=s.font=e||a),s.measureText(t);t=t||"";var i=/((?:\d+)?\.?\d*)px/.exec(e=e||a),r=i&&+i[1]||12,o=0;if(e.indexOf("mono")>=0)o=r*t.length;else for(var c=0;c<t.length;c++){var p=u[t[c]];o+=null==p?r:p*r}return{width:o}},loadImage:function(t,e,n){var i=new Image;return i.onload=e,i.onerror=n,i.src=t,i}};function c(t){for(var e in h)t[e]&&(h[e]=t[e])}var p=V(["Function","RegExp","Date","Error","CanvasGradient","CanvasPattern","Image","Canvas"],(function(t,e){return t["[object "+e+"]"]=!0,t}),{}),d=V(["Int8","Uint8","Uint8Clamped","Int16","Uint16","Int32","Uint32","Float32","Float64"],(function(t,e){return t["[object "+e+"Array]"]=!0,t}),{}),f=Object.prototype.toString,g=Array.prototype,y=g.forEach,v=g.filter,m=g.slice,x=g.map,_=function(){}.constructor,b=_?_.prototype:null,w="__proto__",S=2311;function M(){return S++}function I(){for(var t=[],e=0;e<arguments.length;e++)t[e]=arguments[e];"undefined"!=typeof console&&console.error.apply(console,t)}function T(t){if(null==t||"object"!=typeof t)return t;var e=t,n=f.call(t);if("[object Array]"===n){if(!pt(t)){e=[];for(var i=0,r=t.length;i<r;i++)e[i]=T(t[i])}}else if(d[n]){if(!pt(t)){var o=t.constructor;if(o.from)e=o.from(t);else{e=new o(t.length);for(i=0,r=t.length;i<r;i++)e[i]=t[i]}}}else if(!p[n]&&!pt(t)&&!J(t))for(var a in e={},t)t.hasOwnProperty(a)&&a!==w&&(e[a]=T(t[a]));return e}function C(t,e,n){if(!q(e)||!q(t))return n?T(e):t;for(var i in e)if(e.hasOwnProperty(i)&&i!==w){var r=t[i],o=e[i];!q(o)||!q(r)||Y(o)||Y(r)||J(o)||J(r)||K(o)||K(r)||pt(o)||pt(r)?!n&&i in t||(t[i]=T(e[i])):C(r,o,n)}return t}function D(t,e){for(var n=t[0],i=1,r=t.length;i<r;i++)n=C(n,t[i],e);return n}function A(t,e){if(Object.assign)Object.assign(t,e);else for(var n in e)e.hasOwnProperty(n)&&n!==w&&(t[n]=e[n]);return t}function k(t,e,n){for(var i=G(e),r=0;r<i.length;r++){var o=i[r];(n?null!=e[o]:null==t[o])&&(t[o]=e[o])}return t}var L=h.createCanvas;function P(t,e){if(t){if(t.indexOf)return t.indexOf(e);for(var n=0,i=t.length;n<i;n++)if(t[n]===e)return n}return-1}function O(t,e){var n=t.prototype;function i(){}for(var r in i.prototype=e.prototype,t.prototype=new i,n)n.hasOwnProperty(r)&&(t.prototype[r]=n[r]);t.prototype.constructor=t,t.superClass=e}function R(t,e,n){if(t="prototype"in t?t.prototype:t,e="prototype"in e?e.prototype:e,Object.getOwnPropertyNames)for(var i=Object.getOwnPropertyNames(e),r=0;r<i.length;r++){var o=i[r];"constructor"!==o&&(n?null!=e[o]:null==t[o])&&(t[o]=e[o])}else k(t,e,n)}function N(t){retuLine truncated
|
||||||
|
/*!
|
||||||
|
* ZRender, a high performance 2d drawing library.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2013, Baidu Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* LICENSE
|
||||||
|
* https://github.com/ecomfe/zrender/blob/master/LICENSE.txt
|
||||||
|
*/
|
||||||
|
var Fr={},Gr={};var Wr,Hr=function(){function t(t,e,n){var i=this;this._sleepAfterStill=10,this._stillFrameAccum=0,this._needsRefresh=!0,this._needsRefreshHover=!0,this._darkMode=!1,n=n||{},this.dom=e,this.id=t;var o=new rn,a=n.renderer||"canvas";Fr[a]||(a=G(Fr)[0]),n.useDirtyRect=null!=n.useDirtyRect&&n.useDirtyRect;var s=new Fr[a](e,o,n,t),l=n.ssr||s.ssrOnly;this.storage=o,this.painter=s;var u,h=r.node||r.worker||l?null:new or(s.getViewportRoot(),s.root),c=n.useCoarsePointer;(null==c||"auto"===c?r.touchEventsSupported:!!c)&&(u=rt(n.pointerSize,44)),this.handler=new Ye(o,s,h,s.root,u),this.animation=new Wi({stage:{update:l?null:function(){return i._flush(!0)}}}),l||this.animation.start()}return t.prototype.add=function(t){!this._disposed&&t&&(this.storage.addRoot(t),t.addSelfToZr(this),this.refresh())},t.prototype.remove=function(t){!this._disposed&&t&&(this.storage.delRoot(t),t.removeSelfFromZr(this),this.refresh())},t.prototype.configLayer=function(t,e){this._disposed||(this.painter.configLayer&&this.painter.configLayer(t,e),this.refresh())},t.prototype.setBackgroundColor=function(t){this._disposed||(this.painter.setBackgroundColor&&this.painter.setBackgroundColor(t),this.refresh(),this._backgroundColor=t,this._darkMode=function(t){if(!t)return!1;if("string"==typeof t)return oi(t,1)<.4;if(t.colorStops){for(var e=t.colorStops,n=0,i=e.length,r=0;r<i;r++)n+=oi(e[r].color,1);return(n/=i)<.4}return!1}(t))},t.prototype.getBackgroundColor=function(){return this._backgroundColor},t.prototype.setDarkMode=function(t){this._darkMode=t},t.prototype.isDarkMode=function(){return this._darkMode},t.prototype.refreshImmediately=function(t){this._disposed||(t||this.animation.update(!0),this._needsRefresh=!1,this.painter.refresh(),this._needsRefresh=!1)},t.prototype.refresh=function(){this._disposed||(this._needsRefresh=!0,this.animation.start())},t.prototype.flush=function(){this._disposed||this._flush(!1)},t.prototype._flush=function(t){var e,n=Bi();this._needsRefresh&&(e=!0,this.refreshImmediately(t)),this._needsRefreshHover&&(e=!0,this.refreshHoverImmediately());var i=Bi();e?(this._stillFrameAccum=0,this.trigger("rendered",{elapsedTime:i-n})):this._sleepAfterStill>0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e<t.length;e++)t[e]instanceof Br&&t[e].removeSelfFromZr(this);this.storage.delAllRoots(),this.painter.clear()}},t.prototype.dispose=function(){var t;this._disposed||(this.animation.stop(),this.clear(),this.storage.dispose(),this.painter.dispose(),this.handler.dispose(),this.animation=this.storage=this.painter=this.handler=null,this._disposed=!0,t=this.id,delete Gr[t])},t}();function Yr(t,e){var n=new Hr(M(),t,e);return Gr[n.id]=n,n}function Xr(t,e){Fr[t]=e}function Ur(t){if("function"==typeof Wr)return Wr(t)}function Zr(t){Wr=t}var jr=Object.freeze({__proto__:null,init:Yr,dispose:function(t){t.dispose()},disposeAll:function(){for(var t in Gr)Gr.hasOwnProperty(t)&&Gr[t].dispose();Gr={}},getInstance:function(t){return Gr[t]},registerPainter:Xr,getElementSSRData:Ur,registerSSRDataGetter:Zr,version:"5.6.0"}),qr=1e-4;function Kr(t,e,n,i){var r=e[0],o=e[1],a=n[0],s=n[1],l=o-r,u=s-a;if(0===l)return 0===u?a:(a+s)/2;if(i)if(l>0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*u+a}function $r(t,e){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return U(t)?(n=t,n.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e:parseFloat(t):null==t?NaN:+t;var n}function Jr(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function Qr(t){return t.sort((funLine truncated
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,976 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>衍水工资系统 · 视觉方向预览</title>
|
||||||
|
<style>
|
||||||
|
/* ============================================================
|
||||||
|
设计体系 v3(质感版):中性灰白 · 靛蓝点缀 · 微质感
|
||||||
|
质感来源(而非装饰):
|
||||||
|
1) 表面微渐变 —— 1–2% 明度差,暗示受光方向
|
||||||
|
2) 半透明描边 —— 边界在不同底色上自然融合
|
||||||
|
3) 分层阴影 —— 近距实影 + 远距弥散影叠加
|
||||||
|
4) 内高光 / 内阴影 —— 浮起与凹陷的材质暗示
|
||||||
|
5) 极微噪点 —— 去除纯色的「塑料感」
|
||||||
|
6) 精确排版 —— 字距、等宽数字、行高的一致节律
|
||||||
|
============================================================ */
|
||||||
|
:root {
|
||||||
|
--ys-indigo: #2e4ba8;
|
||||||
|
--ys-indigo-hi: #3a58bc; /* 渐变亮端 */
|
||||||
|
--ys-indigo-deep: #243c8b;
|
||||||
|
--ys-indigo-wash: #edf1fa;
|
||||||
|
--ys-paper: #ffffff;
|
||||||
|
--ys-mist: #f4f5f8; /* 页面底 */
|
||||||
|
--ys-aside-1: #f2f4f8; /* 侧栏渐变起 */
|
||||||
|
--ys-aside-2: #ecedf3; /* 侧栏渐变止 */
|
||||||
|
--ys-ink: #1c2333;
|
||||||
|
--ys-ink-2: #5b6577;
|
||||||
|
--ys-ink-3: #9aa3b2;
|
||||||
|
--ys-line: rgba(28, 35, 51, 0.09); /* 半透明描边 */
|
||||||
|
--ys-line-soft: rgba(28, 35, 51, 0.065);
|
||||||
|
|
||||||
|
--ys-ok: #1e9e6a; --ys-ok-wash-1: #ecf8f2; --ys-ok-wash-2: #e4f3ec;
|
||||||
|
--ys-warn: #c98a1b; --ys-warn-wash-1: #fbf4e4; --ys-warn-wash-2: #f8efd9;
|
||||||
|
--ys-danger: #c94f4f; --ys-danger-wash: #fbeded;
|
||||||
|
|
||||||
|
--ys-radius: 10px;
|
||||||
|
--ys-radius-sm: 7px;
|
||||||
|
--ys-font: 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', -apple-system,
|
||||||
|
'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
--ys-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
|
|
||||||
|
/* 分层阴影:近距定义轮廓,远距弥散氛围 */
|
||||||
|
--ys-shadow-card: 0 1px 2px rgba(28, 35, 51, 0.04), 0 3px 10px rgba(28, 35, 51, 0.03);
|
||||||
|
--ys-shadow-raise: 0 1px 2px rgba(28, 35, 51, 0.05), 0 6px 18px rgba(28, 35, 51, 0.07);
|
||||||
|
|
||||||
|
/* 极微噪点:去除大色块的塑料感(内联 SVG,无外部依赖) */
|
||||||
|
--ys-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
|
||||||
|
/* 品牌字:思源黑体(现代几何黑体;实施时自托管 4 字子集,内网可用) */
|
||||||
|
--ys-font-brand: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: var(--ys-font);
|
||||||
|
color: var(--ys-ink);
|
||||||
|
background-color: var(--ys-mist);
|
||||||
|
/* 点阵网格:22px 间距的 1px 微点 —— 给背景「秩序与密度」,不再空旷 */
|
||||||
|
background-image: radial-gradient(rgba(28, 35, 51, 0.07) 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
/* 噪点覆盖层:全页 2.2% 强度,肉眼几乎无感,但色块不再是「纯色塑料」 */
|
||||||
|
body::after {
|
||||||
|
content: ''; position: fixed; inset: 0; z-index: 2147483647;
|
||||||
|
pointer-events: none; opacity: 0.022; background-image: var(--ys-noise);
|
||||||
|
}
|
||||||
|
.num { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.wrap { max-width: 1180px; margin: 0 auto; padding: 48px 32px 96px; }
|
||||||
|
.section { margin-top: 64px; }
|
||||||
|
.section > h2 {
|
||||||
|
font-size: 13px; font-weight: 500; letter-spacing: 0.08em;
|
||||||
|
color: var(--ys-ink-3); text-transform: uppercase;
|
||||||
|
display: flex; align-items: center; gap: 10px; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.section > h2::after { content: ''; height: 1px; flex: 1; background: var(--ys-line); }
|
||||||
|
|
||||||
|
/* 签名元素:水线 */
|
||||||
|
.waterline {
|
||||||
|
width: 56px; height: 2px; border-radius: 1px;
|
||||||
|
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 封面 ---------- */
|
||||||
|
.cover { padding: 24px 0 8px; }
|
||||||
|
.cover .brand {
|
||||||
|
font-family: var(--ys-font-brand);
|
||||||
|
font-size: 15px; font-weight: 500; letter-spacing: 0.32em; color: var(--ys-ink-2);
|
||||||
|
}
|
||||||
|
.cover h1 { font-size: 30px; font-weight: 600; margin-top: 18px; letter-spacing: -0.01em; }
|
||||||
|
.cover .sub { color: var(--ys-ink-2); margin-top: 10px; font-size: 15px; }
|
||||||
|
.cover .tags { display: flex; gap: 8px; margin-top: 22px; flex-wrap: wrap; }
|
||||||
|
.tag {
|
||||||
|
font-size: 12px; color: var(--ys-ink-2);
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fbfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: 999px; padding: 4px 12px;
|
||||||
|
box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 色板 ---------- */
|
||||||
|
.swatches { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
||||||
|
.swatch {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
overflow: hidden; box-shadow: var(--ys-shadow-card);
|
||||||
|
}
|
||||||
|
.swatch .chip { height: 76px; }
|
||||||
|
.swatch .meta { padding: 10px 14px 12px; }
|
||||||
|
.swatch .name { font-size: 13px; font-weight: 600; }
|
||||||
|
.swatch .hex { font-size: 12px; color: var(--ys-ink-2); margin-top: 2px; }
|
||||||
|
.swatch .use { font-size: 12px; color: var(--ys-ink-3); margin-top: 6px; line-height: 1.5; }
|
||||||
|
|
||||||
|
/* ---------- 字体 ---------- */
|
||||||
|
.type-sheet {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
padding: 26px 28px; box-shadow: var(--ys-shadow-card);
|
||||||
|
}
|
||||||
|
.type-row { display: flex; align-items: baseline; gap: 24px; padding: 12px 0; border-bottom: 1px dashed var(--ys-line-soft); }
|
||||||
|
.type-row:last-child { border-bottom: 0; }
|
||||||
|
.type-row .label { width: 150px; flex: none; font-size: 12px; color: var(--ys-ink-3); }
|
||||||
|
.t-page-title { font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
|
||||||
|
.t-card-title { font-size: 15px; font-weight: 600; }
|
||||||
|
.t-body { font-size: 14px; }
|
||||||
|
.t-th { font-size: 12px; font-weight: 500; letter-spacing: 0.04em; color: var(--ys-ink-2); }
|
||||||
|
.t-amount { font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.amount-demo {
|
||||||
|
margin-top: 18px;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
padding: 18px 28px; box-shadow: var(--ys-shadow-card);
|
||||||
|
}
|
||||||
|
.amount-demo table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
||||||
|
.amount-demo th {
|
||||||
|
text-align: right; font-size: 12px; font-weight: 500; letter-spacing: 0.04em;
|
||||||
|
color: var(--ys-ink-2); padding: 6px 0; border-bottom: 1px solid var(--ys-line);
|
||||||
|
}
|
||||||
|
.amount-demo th:first-child { text-align: left; }
|
||||||
|
.amount-demo td { text-align: right; padding: 9px 0; border-bottom: 1px solid var(--ys-line-soft); font-size: 13px; }
|
||||||
|
.amount-demo td:first-child { text-align: left; color: var(--ys-ink-2); }
|
||||||
|
.amount-demo tr:last-child td { border-bottom: 0; font-weight: 600; font-size: 15px; }
|
||||||
|
|
||||||
|
/* 品牌字候选(思源黑体已装于本机;实施时自托管子集) */
|
||||||
|
.brand-candidates { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-top: 14px; }
|
||||||
|
.bc {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
box-shadow: var(--ys-shadow-card); padding: 20px 24px; text-align: center;
|
||||||
|
}
|
||||||
|
.bc-cap { font-size: 11px; color: var(--ys-ink-3); margin-top: 12px; }
|
||||||
|
.bc-rec { color: var(--ys-indigo); font-weight: 500; margin-left: 4px; }
|
||||||
|
|
||||||
|
/* ---------- 组件 ---------- */
|
||||||
|
.comp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||||
|
.comp-card {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
padding: 22px 24px; box-shadow: var(--ys-shadow-card);
|
||||||
|
}
|
||||||
|
.comp-card h3 { font-size: 13px; font-weight: 600; color: var(--ys-ink-2); margin-bottom: 16px; letter-spacing: 0.02em; }
|
||||||
|
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 34px; padding: 0 16px; border-radius: var(--ys-radius-sm);
|
||||||
|
font-size: 13px; font-family: inherit; cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
/* 主按钮:微渐变 + 内高光 + 静置浅影 —— hover 抬升 1px、影加深 */
|
||||||
|
.btn-primary {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 1px 2px rgba(46, 75, 168, 0.22);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(180deg, #31509f, var(--ys-indigo-deep));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 4px 12px rgba(46, 75, 168, 0.3);
|
||||||
|
}
|
||||||
|
.btn-primary:active { transform: translateY(0); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18); }
|
||||||
|
.btn-default {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fbfcfe); color: var(--ys-ink);
|
||||||
|
border-color: var(--ys-line); box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
|
||||||
|
}
|
||||||
|
.btn-default:hover { color: var(--ys-indigo); border-color: rgba(46, 75, 168, 0.45); }
|
||||||
|
.btn-danger { background: linear-gradient(180deg, #ffffff, #fbfcfe); color: var(--ys-danger); border-color: rgba(201, 79, 79, 0.28); }
|
||||||
|
.btn-danger:hover { border-color: rgba(201, 79, 79, 0.6); }
|
||||||
|
.btn-text { background: none; color: var(--ys-indigo); padding: 0 6px; height: auto; }
|
||||||
|
.btn-text:hover { color: var(--ys-indigo-deep); }
|
||||||
|
.btn-ghost-hover { pointer-events: none; } /* 预览:静态展示 hover 态 */
|
||||||
|
|
||||||
|
.input {
|
||||||
|
height: 34px; padding: 0 12px; border-radius: var(--ys-radius-sm);
|
||||||
|
border: 1px solid rgba(28, 35, 51, 0.13); background: #fff;
|
||||||
|
font-size: 13px; font-family: inherit; color: var(--ys-ink); width: 190px;
|
||||||
|
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.035); /* 微凹陷:输入区的材质暗示 */
|
||||||
|
transition: border-color 150ms var(--ys-ease), box-shadow 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
.input::placeholder { color: var(--ys-ink-3); }
|
||||||
|
.input:focus, .input.focused {
|
||||||
|
outline: none; border-color: var(--ys-indigo);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.03), 0 0 0 3px rgba(46, 75, 168, 0.13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 10px; border-radius: 999px; white-space: nowrap; }
|
||||||
|
.pill::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
|
.pill-ok { color: var(--ys-ok); background: linear-gradient(180deg, var(--ys-ok-wash-1), var(--ys-ok-wash-2)); }
|
||||||
|
.pill-off { color: var(--ys-ink-2); background: linear-gradient(180deg, #f4f5f8, #eff1f5); }
|
||||||
|
.pill-warn { color: var(--ys-warn); background: linear-gradient(180deg, var(--ys-warn-wash-1), var(--ys-warn-wash-2)); }
|
||||||
|
|
||||||
|
.pager { display: flex; gap: 6px; align-items: center; font-size: 13px; color: var(--ys-ink-2); }
|
||||||
|
.pager .pg {
|
||||||
|
min-width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--ys-radius-sm); border: 1px solid var(--ys-line);
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fbfcfe); font-variant-numeric: tabular-nums;
|
||||||
|
box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
|
||||||
|
}
|
||||||
|
.pager .pg.cur {
|
||||||
|
background: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
|
||||||
|
border-color: transparent; color: #fff;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 1px 2px rgba(46, 75, 168, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 应用界面 ---------- */
|
||||||
|
.app-shot {
|
||||||
|
border: 1px solid var(--ys-line); border-radius: 14px; overflow: hidden;
|
||||||
|
background: var(--ys-mist); box-shadow: 0 12px 40px rgba(28, 35, 51, 0.1);
|
||||||
|
}
|
||||||
|
.app { display: grid; grid-template-columns: 220px 1fr; min-height: 620px; }
|
||||||
|
.aside {
|
||||||
|
background: linear-gradient(180deg, var(--ys-aside-1), var(--ys-aside-2));
|
||||||
|
border-right: 1px solid var(--ys-line);
|
||||||
|
padding: 20px 14px; display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.aside .logo { font-family: var(--ys-font-brand); font-size: 15px; font-weight: 500; letter-spacing: 0.3em; color: var(--ys-ink); padding: 0 10px; }
|
||||||
|
.aside .logo-sub { font-size: 11px; color: var(--ys-ink-3); letter-spacing: 0.06em; padding: 0 10px; margin-top: 4px; }
|
||||||
|
.menu-group { margin-top: 26px; }
|
||||||
|
.menu-label { font-size: 11px; letter-spacing: 0.1em; color: var(--ys-ink-3); padding: 0 10px; margin-bottom: 8px; }
|
||||||
|
.menu-item {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: var(--ys-radius-sm);
|
||||||
|
font-size: 13px; color: var(--ys-ink-2); position: relative; margin-bottom: 2px;
|
||||||
|
transition: all 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
.menu-item:hover { color: var(--ys-ink); background: rgba(255, 255, 255, 0.55); }
|
||||||
|
/* 当前项:白底「浮起」——微渐变 + 内高光 + 分层阴影 */
|
||||||
|
.menu-item.active {
|
||||||
|
color: var(--ys-indigo); font-weight: 500;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fbfcfe);
|
||||||
|
box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(28, 35, 51, 0.06), 0 4px 10px rgba(28, 35, 51, 0.06);
|
||||||
|
}
|
||||||
|
.menu-item.active::before {
|
||||||
|
content: ''; position: absolute; left: -14px; top: 8px; bottom: 8px; width: 2px;
|
||||||
|
border-radius: 1px; background: var(--ys-indigo);
|
||||||
|
}
|
||||||
|
.menu-item svg { width: 16px; height: 16px; flex: none; opacity: 0.7; }
|
||||||
|
.menu-item.active svg { opacity: 1; }
|
||||||
|
.aside-foot { margin-top: auto; padding: 0 10px 2px; }
|
||||||
|
.aside-foot-text { font-size: 11px; color: var(--ys-ink-3); letter-spacing: 0.06em; }
|
||||||
|
|
||||||
|
.main { display: flex; flex-direction: column; }
|
||||||
|
.topbar {
|
||||||
|
height: 52px;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fdfdfe);
|
||||||
|
border-bottom: 1px solid var(--ys-line);
|
||||||
|
box-shadow: 0 1px 0 rgba(28, 35, 51, 0.015);
|
||||||
|
display: flex; align-items: center; justify-content: space-between; padding: 0 22px;
|
||||||
|
}
|
||||||
|
.crumb { font-size: 13px; color: var(--ys-ink-3); }
|
||||||
|
.crumb b { color: var(--ys-ink); font-weight: 500; }
|
||||||
|
.topbar .who { display: flex; align-items: center; gap: 12px; font-size: 13px; color: var(--ys-ink-2); }
|
||||||
|
.topbar .who .logout { color: var(--ys-ink-3); }
|
||||||
|
.topbar .who .avatar {
|
||||||
|
width: 26px; height: 26px; border-radius: 50%;
|
||||||
|
background: linear-gradient(180deg, var(--ys-indigo-wash), #e4eaf7);
|
||||||
|
box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(28, 35, 51, 0.06);
|
||||||
|
color: var(--ys-indigo); font-size: 12px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 22px; flex: 1;
|
||||||
|
/* 顶部受光:白光 + 靛蓝微光叠加,内容区有「空气感」 */
|
||||||
|
background:
|
||||||
|
radial-gradient(1100px 320px at 50% -110px, rgba(46, 75, 168, 0.055), rgba(46, 75, 168, 0) 65%),
|
||||||
|
radial-gradient(900px 220px at 50% -80px, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0) 70%);
|
||||||
|
}
|
||||||
|
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 18px; }
|
||||||
|
.page-head h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
|
||||||
|
.page-head .desc { font-size: 12px; color: var(--ys-ink-3); margin-top: 4px; }
|
||||||
|
|
||||||
|
.filter {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
padding: 14px 16px; display: flex; gap: 10px; align-items: center; margin-bottom: 14px;
|
||||||
|
box-shadow: var(--ys-shadow-card);
|
||||||
|
}
|
||||||
|
.filter .spacer { flex: 1; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
box-shadow: var(--ys-shadow-card); overflow: hidden;
|
||||||
|
}
|
||||||
|
table.grid { width: 100%; border-collapse: collapse; }
|
||||||
|
table.grid th {
|
||||||
|
text-align: left; font-size: 12px; font-weight: 500; letter-spacing: 0.04em;
|
||||||
|
color: var(--ys-ink-2); background: linear-gradient(180deg, #fafbfc, #f5f6f9);
|
||||||
|
padding: 10px 16px; border-bottom: 1px solid var(--ys-line);
|
||||||
|
}
|
||||||
|
table.grid td { padding: 12px 16px; border-bottom: 1px solid var(--ys-line-soft); font-size: 13px; }
|
||||||
|
table.grid tr:last-child td { border-bottom: 0; }
|
||||||
|
table.grid tbody tr { transition: background 120ms var(--ys-ease); }
|
||||||
|
table.grid tbody tr:hover { background: #f7f9fd; }
|
||||||
|
.cell-code { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 12px; color: var(--ys-ink-2); }
|
||||||
|
.cell-muted { color: var(--ys-ink-2); }
|
||||||
|
.cell-actions a { color: var(--ys-indigo); font-size: 13px; margin-right: 12px; cursor: pointer; }
|
||||||
|
.cell-actions a.muted { color: var(--ys-ink-3); }
|
||||||
|
.card-foot { padding: 12px 16px; display: flex; justify-content: flex-end; border-top: 1px solid var(--ys-line); background: linear-gradient(180deg, #fdfdfe, #fbfcfe); }
|
||||||
|
|
||||||
|
/* ---------- 全局规范表 ---------- */
|
||||||
|
.spec-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||||
|
.spec-table td { padding: 7px 8px; border-bottom: 1px solid var(--ys-line-soft); color: var(--ys-ink-2); vertical-align: top; line-height: 1.6; }
|
||||||
|
.spec-table tr:last-child td { border-bottom: 0; }
|
||||||
|
.spec-table td:first-child { color: var(--ys-ink); font-weight: 500; white-space: nowrap; }
|
||||||
|
.spec-table td.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||||
|
.spec-table td.bad { color: var(--ys-ink-3); text-decoration: line-through; text-decoration-color: rgba(201, 79, 79, 0.35); }
|
||||||
|
.spec-table td.good { color: var(--ys-ink); }
|
||||||
|
|
||||||
|
/* ---------- 首页仪表盘(Bento Grid) ---------- */
|
||||||
|
.bento { display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: 102px; gap: 14px; }
|
||||||
|
.bento-card {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
box-shadow: var(--ys-shadow-card);
|
||||||
|
padding: 17px 20px; display: flex; flex-direction: column;
|
||||||
|
/* 微动效:入场交错上浮 + hover 抬升 2px(prefers-reduced-motion 下全部关闭) */
|
||||||
|
animation: rise 320ms var(--ys-ease) backwards;
|
||||||
|
transition: transform 180ms var(--ys-ease), box-shadow 180ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
.bento-card:hover { transform: translateY(-2px); box-shadow: var(--ys-shadow-raise); }
|
||||||
|
.b-head { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.b-icon { width: 15px; height: 15px; color: var(--ys-ink-3); opacity: 0.8; }
|
||||||
|
.bento-card:nth-child(1) { animation-delay: 0ms; }
|
||||||
|
.bento-card:nth-child(2) { animation-delay: 45ms; }
|
||||||
|
.bento-card:nth-child(3) { animation-delay: 90ms; }
|
||||||
|
.bento-card:nth-child(4) { animation-delay: 135ms; }
|
||||||
|
.bento-card:nth-child(5) { animation-delay: 180ms; }
|
||||||
|
.bento-card:nth-child(6) { animation-delay: 225ms; }
|
||||||
|
.bento-card:nth-child(7) { animation-delay: 270ms; }
|
||||||
|
.bento-card:nth-child(8) { animation-delay: 315ms; }
|
||||||
|
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bento-card, .login-card { animation: none; transition: none; }
|
||||||
|
}
|
||||||
|
.b-2x2 { grid-column: span 2; grid-row: span 2; }
|
||||||
|
.b-2x1 { grid-column: span 2; }
|
||||||
|
.b-eyebrow { font-size: 12px; font-weight: 500; letter-spacing: 0.04em; color: var(--ys-ink-2); }
|
||||||
|
.b-big { font-size: 30px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1.15; margin-top: auto; }
|
||||||
|
.b-big small { font-size: 13px; font-weight: 500; color: var(--ys-ink-3); margin-left: 4px; letter-spacing: 0; }
|
||||||
|
.b-num { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; line-height: 1.2; margin-top: auto; }
|
||||||
|
.b-num small { font-size: 12px; font-weight: 400; color: var(--ys-ink-3); margin-left: 3px; }
|
||||||
|
.b-sub { font-size: 11px; color: var(--ys-ink-3); margin-top: 3px; }
|
||||||
|
.b-up { color: var(--ys-ok); font-weight: 500; }
|
||||||
|
.b-todo { display: flex; align-items: baseline; gap: 8px; font-size: 13px; color: var(--ys-ink-2); }
|
||||||
|
.b-todo .cnt { color: var(--ys-indigo); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ---------- 登录页 ---------- */
|
||||||
|
.login-shot { border: 1px solid var(--ys-line); border-radius: 14px; overflow: hidden; box-shadow: 0 12px 40px rgba(28, 35, 51, 0.1); }
|
||||||
|
.login { display: grid; grid-template-columns: 460px 1fr; min-height: 520px; }
|
||||||
|
.login-brand {
|
||||||
|
background: linear-gradient(168deg, #2c4aa6 0%, #223a85 55%, #17275c 100%);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||||
|
padding: 46px 44px; display: flex; flex-direction: column; justify-content: space-between;
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.login-brand .brandmark { font-family: var(--ys-font-brand); font-size: 19px; font-weight: 500; letter-spacing: 0.36em; color: #fff; }
|
||||||
|
.login-brand .slogan { font-size: 15px; color: rgba(255, 255, 255, 0.82); margin-top: 14px; line-height: 1.9; }
|
||||||
|
.login-brand .foot { font-size: 12px; color: rgba(255, 255, 255, 0.42); letter-spacing: 0.08em; }
|
||||||
|
.ripple { position: absolute; inset: auto 0 0 0; height: 220px; pointer-events: none; }
|
||||||
|
.ripple i { display: block; height: 1px; margin-top: 26px; }
|
||||||
|
.ripple i:nth-child(1) { background: linear-gradient(90deg, rgba(255,255,255,0.36), rgba(255,255,255,0.02)); }
|
||||||
|
.ripple i:nth-child(2) { margin-top: 22px; width: 92%; background: linear-gradient(90deg, rgba(255,255,255,0.30), rgba(255,255,255,0.02)); }
|
||||||
|
.ripple i:nth-child(3) { margin-top: 18px; width: 84%; background: linear-gradient(90deg, rgba(255,255,255,0.25), rgba(255,255,255,0.02)); }
|
||||||
|
.ripple i:nth-child(4) { margin-top: 15px; width: 74%; background: linear-gradient(90deg, rgba(255,255,255,0.20), rgba(255,255,255,0.02)); }
|
||||||
|
.ripple i:nth-child(5) { margin-top: 12px; width: 62%; background: linear-gradient(90deg, rgba(255,255,255,0.15), rgba(255,255,255,0.02)); }
|
||||||
|
.ripple i:nth-child(6) { margin-top: 10px; width: 48%; background: linear-gradient(90deg, rgba(255,255,255,0.11), rgba(255,255,255,0.02)); }
|
||||||
|
.login-form {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fdfdff);
|
||||||
|
box-shadow: inset 0 1px 0 #fff;
|
||||||
|
padding: 46px 56px; display: flex; flex-direction: column; justify-content: center;
|
||||||
|
}
|
||||||
|
.login-form h2 { font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
|
||||||
|
.login-form .hint { font-size: 13px; color: var(--ys-ink-3); margin: 6px 0 26px; }
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label { display: block; font-size: 13px; color: var(--ys-ink-2); margin-bottom: 6px; }
|
||||||
|
.field .input { width: 100%; height: 38px; }
|
||||||
|
.captcha-row { display: flex; gap: 10px; }
|
||||||
|
.captcha-row .input { flex: 1; }
|
||||||
|
.captcha-box {
|
||||||
|
width: 104px; height: 38px; border: 1px solid var(--ys-line); border-radius: var(--ys-radius-sm);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: 'SFMono-Regular', Consolas, monospace; font-size: 17px; letter-spacing: 0.28em;
|
||||||
|
color: var(--ys-indigo); background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.04); user-select: none;
|
||||||
|
}
|
||||||
|
.login-btn { width: 100%; height: 38px; margin-top: 6px; font-size: 14px; }
|
||||||
|
|
||||||
|
/* 方案 B:居中卡片 */
|
||||||
|
.login-center {
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
min-height: 480px; display: flex; align-items: center; justify-content: center;
|
||||||
|
background-color: #f2f4f8;
|
||||||
|
background-image: radial-gradient(rgba(28, 35, 51, 0.07) 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
/* 顶部光晕:靛蓝的「天光」,柔和地洒在登录卡上 */
|
||||||
|
.login-center::before {
|
||||||
|
content: ''; position: absolute; top: -160px; left: 50%; transform: translateX(-50%);
|
||||||
|
width: 860px; height: 460px; pointer-events: none;
|
||||||
|
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.08), rgba(46, 75, 168, 0));
|
||||||
|
}
|
||||||
|
/* 背景涟漪:同心圆水波(「衍水」的品牌装饰,右上入画) */
|
||||||
|
.ripple-bg { position: absolute; top: -170px; right: -130px; width: 520px; height: 520px; pointer-events: none; }
|
||||||
|
.ripple-bg i {
|
||||||
|
position: absolute; inset: 0; border-radius: 50%;
|
||||||
|
border: 1px solid rgba(46, 75, 168, 0.09);
|
||||||
|
}
|
||||||
|
.ripple-bg i:nth-child(2) { inset: 65px; border-color: rgba(46, 75, 168, 0.07); }
|
||||||
|
.ripple-bg i:nth-child(3) { inset: 130px; border-color: rgba(46, 75, 168, 0.055); }
|
||||||
|
.ripple-bg i:nth-child(4) { inset: 195px; border: none; background: radial-gradient(circle, rgba(46, 75, 168, 0.05), rgba(46, 75, 168, 0.015)); }
|
||||||
|
.login-card {
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
animation: rise 320ms var(--ys-ease) backwards;
|
||||||
|
width: 400px;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: 14px;
|
||||||
|
box-shadow: 0 2px 4px rgba(28, 35, 51, 0.04), 0 16px 40px rgba(28, 35, 51, 0.1);
|
||||||
|
padding: 30px 36px 32px;
|
||||||
|
}
|
||||||
|
.lc-brand { text-align: center; }
|
||||||
|
.lc-brand-name { font-family: var(--ys-font-brand); font-size: 17px; font-weight: 500; letter-spacing: 0.34em; color: var(--ys-ink); }
|
||||||
|
.lc-slogan { font-size: 12.5px; color: var(--ys-ink-3); margin-top: 8px; letter-spacing: 0.02em; }
|
||||||
|
.lc-form-head { font-size: 15px; font-weight: 600; margin-top: 26px; }
|
||||||
|
.lc-form-hint { font-size: 12px; color: var(--ys-ink-3); margin: 4px 0 18px; }
|
||||||
|
.lc-foot { text-align: center; margin-top: 16px; font-size: 11px; color: var(--ys-ink-3); letter-spacing: 0.08em; }
|
||||||
|
.login-card .input { width: 100%; height: 38px; }
|
||||||
|
|
||||||
|
.note { margin-top: 18px; font-size: 13px; color: var(--ys-ink-2); line-height: 1.9; }
|
||||||
|
.note b { color: var(--ys-ink); font-weight: 600; }
|
||||||
|
.states { display: flex; gap: 14px; margin-top: 12px; }
|
||||||
|
.state { text-align: center; }
|
||||||
|
.state .cap { font-size: 11px; color: var(--ys-ink-3); margin-top: 6px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<!-- ============ 封面 ============ -->
|
||||||
|
<header class="cover" id="sec-cover">
|
||||||
|
<div class="brand">衍水集团</div>
|
||||||
|
<h1>营销人员工资管理系统 · 视觉方向</h1>
|
||||||
|
<div class="sub">企业级扁平 · 轻质感 · Bento 布局 · 品牌色点缀 · 微动效 · 精致文案</div>
|
||||||
|
<div class="waterline"></div>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">表面微渐变(受光)</span>
|
||||||
|
<span class="tag">半透明描边</span>
|
||||||
|
<span class="tag">分层阴影</span>
|
||||||
|
<span class="tag">内高光 / 内阴影</span>
|
||||||
|
<span class="tag">2% 噪点</span>
|
||||||
|
<span class="tag">等宽数字</span>
|
||||||
|
<span class="tag">120–180ms 轻动效</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ============ 色板 ============ -->
|
||||||
|
<section class="section" id="sec-palette">
|
||||||
|
<h2>01 · 色板与材质</h2>
|
||||||
|
<div class="swatches">
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:linear-gradient(180deg,#3a58bc,#2e4ba8)"></div>
|
||||||
|
<div class="meta"><div class="name">靛蓝</div><div class="hex num">#2E4BA8 ↑#3A58BC</div>
|
||||||
|
<div class="use">品牌色。按钮/链接/焦点/当前菜单,配微渐变与内高光</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:linear-gradient(180deg,#f2f4f8,#ecedf3)"></div>
|
||||||
|
<div class="meta"><div class="name">侧栏灰</div><div class="hex num">#F2F4F8 → #ECEDF3</div>
|
||||||
|
<div class="use">侧栏微渐变(自上而下微沉)</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:#f4f5f8"></div>
|
||||||
|
<div class="meta"><div class="name">雾灰</div><div class="hex num">#F4F5F8</div>
|
||||||
|
<div class="use">页面底</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:linear-gradient(180deg,#ffffff,#fcfcfe);border-bottom:1px solid #e3e6ec"></div>
|
||||||
|
<div class="meta"><div class="name">纸白</div><div class="hex num">#FFFFFF → #FCFCFE</div>
|
||||||
|
<div class="use">卡片/表格:顶白底微灰的「受光」渐变</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:#e4e7ec"></div>
|
||||||
|
<div class="meta"><div class="name">界线</div><div class="hex num">rgba(28,35,51,.09)</div>
|
||||||
|
<div class="use">1px 半透明描边:在不同底色上自然融合</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:#1c2333"></div>
|
||||||
|
<div class="meta"><div class="name">墨色</div><div class="hex num">#1C2333</div>
|
||||||
|
<div class="use">主文字(冷调近黑)</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:#5b6577"></div>
|
||||||
|
<div class="meta"><div class="name">次墨</div><div class="hex num">#5B6577</div>
|
||||||
|
<div class="use">次级文字、表头、说明</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:linear-gradient(90deg,#1e9e6a,#c98a1b,#c94f4f)"></div>
|
||||||
|
<div class="meta"><div class="name">语义色</div><div class="hex num">#1E9E6A · #C98A1B · #C94F4F</div>
|
||||||
|
<div class="use">成功/警示/危险,浅底微渐变 + 深字</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 字体与数字 ============ -->
|
||||||
|
<section class="section" id="sec-type">
|
||||||
|
<h2>02 · 字体与数字</h2>
|
||||||
|
<div class="type-sheet">
|
||||||
|
<div class="type-row"><span class="label">页面标题</span><span class="t-page-title">岗位角色</span><span class="label" style="width:auto">18px / 600 · 字距 −0.01em</span></div>
|
||||||
|
<div class="type-row"><span class="label">卡片标题</span><span class="t-card-title">计提参数 · 业务员个人户</span><span class="label" style="width:auto">15px / 600</span></div>
|
||||||
|
<div class="type-row"><span class="label">正文 / 表格</span><span class="t-body">回款天数 = 发货日首日起算,按自然日累计</span><span class="label" style="width:auto">14px / 400 · 13px / 400</span></div>
|
||||||
|
<div class="type-row"><span class="label">表头 / 标签</span><span class="t-th">岗位编码 公式模板 生效日期</span><span class="label" style="width:auto">12px / 500 · 字距 +0.04em</span></div>
|
||||||
|
<div class="type-row"><span class="label">品牌字</span><span style="font-weight:600;letter-spacing:.35em;font-size:15px">衍水集团</span><span class="label" style="width:auto">无衬线 600 · 大字距 · 仅品牌落款一处</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="amount-demo">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>回款日期</th><th>回款金额(元)</th><th>回款天数</th><th>计提比例</th><th>绩效金额(元)</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>2026-01-10</td><td>386,200.00</td><td>27</td><td>9.7‰</td><td>14,062.31</td></tr>
|
||||||
|
<tr><td>2026-02-18</td><td>1,240,000.00</td><td>361</td><td>4‰</td><td>19,840.00</td></tr>
|
||||||
|
<tr><td>合计</td><td>1,626,200.00</td><td>—</td><td>—</td><td>33,902.31</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="note">金额与比例一律<b>等宽数字(tabular-nums)右对齐</b>,合计行加粗放大半档——列对列、位对位,扫一眼就能核对。</div>
|
||||||
|
</div>
|
||||||
|
<div class="brand-candidates">
|
||||||
|
<div class="bc">
|
||||||
|
<div style="font-family:'Noto Sans SC',sans-serif;font-weight:500;letter-spacing:.32em;font-size:16px">衍水集团</div>
|
||||||
|
<div class="bc-cap">A · 思源黑体 500 · 0.32em <span class="bc-rec">当前应用</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="bc">
|
||||||
|
<div style="font-family:'Noto Sans SC',sans-serif;font-weight:300;letter-spacing:.42em;font-size:16px">衍水集团</div>
|
||||||
|
<div class="bc-cap">B · 思源黑体 300 细体 · 0.42em</div>
|
||||||
|
</div>
|
||||||
|
<div class="bc">
|
||||||
|
<div style="font-family:'Microsoft YaHei',sans-serif;font-weight:600;letter-spacing:.25em;font-size:16px">衍水集团</div>
|
||||||
|
<div class="bc-cap">C · 微软雅黑 600 · 0.25em</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 组件 ============ -->
|
||||||
|
<section class="section" id="sec-comp">
|
||||||
|
<h2>03 · 组件基调</h2>
|
||||||
|
<div class="comp-grid">
|
||||||
|
<div class="comp-card">
|
||||||
|
<h3>按钮 · 三态</h3>
|
||||||
|
<div class="states">
|
||||||
|
<div class="state"><button class="btn btn-primary btn-ghost-hover">新增岗位</button><div class="cap">静置</div></div>
|
||||||
|
<div class="state"><button class="btn btn-primary" style="background:linear-gradient(180deg,#31509f,#243c8b);transform:translateY(-1px);box-shadow:inset 0 1px 0 rgba(255,255,255,.18),0 4px 12px rgba(46,75,168,.3)">新增岗位</button><div class="cap">悬停:抬升 1px · 影加深</div></div>
|
||||||
|
<div class="state"><button class="btn btn-primary" style="box-shadow:inset 0 1px 2px rgba(0,0,0,.18)">新增岗位</button><div class="cap">按下:回落 · 内阴影</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="note">微渐变 + 内高光给按钮「实体感」;次级按钮一律白底细边框,无阴影污染。</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp-card">
|
||||||
|
<h3>输入框 · 状态</h3>
|
||||||
|
<div class="row">
|
||||||
|
<input class="input" placeholder="搜索岗位编码或名称" />
|
||||||
|
<input class="input focused" value="4/360" />
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:14px">
|
||||||
|
<span class="pill pill-ok">启用</span>
|
||||||
|
<span class="pill pill-off">禁用</span>
|
||||||
|
<span class="pill pill-warn">待生效</span>
|
||||||
|
</div>
|
||||||
|
<div class="note">输入框带微内阴影(凹陷暗示);聚焦 = 靛蓝描边 + 3px 柔光外环。</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp-card">
|
||||||
|
<h3>分页</h3>
|
||||||
|
<div class="pager">
|
||||||
|
<span class="pg">‹</span><span class="pg cur">1</span><span class="pg">2</span><span class="pg">3</span><span class="pg">›</span>
|
||||||
|
<span style="margin-left:10px">共 28 条 · 每页 10 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="note" style="margin-top:14px">当前页与主按钮同材质:微渐变 + 内高光。</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp-card">
|
||||||
|
<h3>动效原则</h3>
|
||||||
|
<div class="note">
|
||||||
|
过渡 120–180ms,<b>只做颜色、阴影与 1px 内的位移</b>;弹层淡入 + 4px 上移;
|
||||||
|
数字与表格不加动画;尊重 <b>prefers-reduced-motion</b>。
|
||||||
|
动效的目标是「顺滑可信」,不是表演。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 应用界面 ============ -->
|
||||||
|
<section class="section" id="sec-app">
|
||||||
|
<h2>04 · 应用界面(布局与列表页)</h2>
|
||||||
|
<div class="app-shot">
|
||||||
|
<div class="app">
|
||||||
|
<aside class="aside">
|
||||||
|
<div class="logo">衍水集团</div>
|
||||||
|
<div class="logo-sub">营销人员工资管理系统</div>
|
||||||
|
<div class="waterline" style="width:22px;margin:10px 10px 0;"></div>
|
||||||
|
<div class="menu-group">
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M2.5 7 8 2.5 13.5 7v6.5H10V9.5H6v4H2.5Z"/></svg>
|
||||||
|
首页
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-group">
|
||||||
|
<div class="menu-label">系统管理</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="5.5" r="2.5"/><path d="M3 13.5c0-2.5 2.2-4 5-4s5 1.5 5 4"/></svg>
|
||||||
|
用户管理
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M8 2 13 4v4c0 3-2 5-5 6-3-1-5-3-5-6V4Z"/></svg>
|
||||||
|
角色管理
|
||||||
|
</div>
|
||||||
|
<div class="menu-item active">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5"/><circle cx="6" cy="7.5" r="1.2"/><path d="M4.5 11c.3-1 1-1.5 1.5-1.5s1.2.5 1.5 1.5M9.5 6.5h2M9.5 9h2"/></svg>
|
||||||
|
岗位角色
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 5h10M3 11h10"/><circle cx="6" cy="5" r="1.6"/><circle cx="10" cy="11" r="1.6"/></svg>
|
||||||
|
计提参数
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4.5h8M4 8h8M4 11.5h5"/></svg>
|
||||||
|
操作日志
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="aside-foot">
|
||||||
|
<div class="waterline" style="width:22px;margin:0 0 8px;"></div>
|
||||||
|
<div class="aside-foot-text">内部系统 · v0.1</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="main">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="crumb">系统管理 <span>/</span> <b>岗位角色</b></div>
|
||||||
|
<div class="who"><span class="avatar">管</span><span>管理员</span><span class="logout">退出登录</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>岗位角色</h1>
|
||||||
|
<div class="desc">决定人员适用的计提公式模板与参数;新增岗位无需改代码</div>
|
||||||
|
<div class="waterline"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary">新增岗位</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter">
|
||||||
|
<input class="input" placeholder="岗位编码或名称" />
|
||||||
|
<input class="input" placeholder="状态:全部" style="width:130px" />
|
||||||
|
<button class="btn btn-primary">查询</button>
|
||||||
|
<button class="btn btn-default">重置</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<table class="grid">
|
||||||
|
<thead>
|
||||||
|
<tr><th>岗位编码</th><th style="width:110px">岗位名称</th><th>公式模板</th><th>说明</th><th style="width:90px">状态</th><th style="width:150px">操作</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="cell-code">salesperson</td><td>营销员</td>
|
||||||
|
<td class="cell-muted">业务员个人户 · 天数线性递减</td>
|
||||||
|
<td class="cell-muted">回款天数线性递减(360 天=6‰,361 天起 4‰)</td>
|
||||||
|
<td><span class="pill pill-ok">启用</span></td>
|
||||||
|
<td class="cell-actions"><a>编辑</a><a class="muted" title="内置岗位不可删除">删除</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="cell-code">sales_manager</td><td>营销经理</td>
|
||||||
|
<td class="cell-muted">营销经理 · 团队与全公司分成</td>
|
||||||
|
<td class="cell-muted">本团队 12.5% + 全公司 5%</td>
|
||||||
|
<td><span class="pill pill-ok">启用</span></td>
|
||||||
|
<td class="cell-actions"><a>编辑</a><a class="muted" title="内置岗位不可删除">删除</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="cell-code">info_officer</td><td>信息员</td>
|
||||||
|
<td class="cell-muted">信息员 · 销售额固定比例</td>
|
||||||
|
<td class="cell-muted">本人销售额 × 1‰</td>
|
||||||
|
<td><span class="pill pill-ok">启用</span></td>
|
||||||
|
<td class="cell-actions"><a>编辑</a><a class="muted" title="内置岗位不可删除">删除</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="cell-code">senior_sales</td><td>高级业务员</td>
|
||||||
|
<td class="cell-muted">业务员个人户 · 天数线性递减</td>
|
||||||
|
<td class="cell-muted">自定义岗位(新增岗位无需改代码)</td>
|
||||||
|
<td><span class="pill pill-off">禁用</span></td>
|
||||||
|
<td class="cell-actions"><a>编辑</a><a>删除</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="card-foot">
|
||||||
|
<div class="pager">
|
||||||
|
<span class="pg">‹</span><span class="pg cur">1</span><span class="pg">›</span>
|
||||||
|
<span style="margin-left:10px">共 4 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
质感的三层材料:<b>侧栏</b>灰渐变(沉)→ <b>内容底</b>雾灰 + 顶部受光 → <b>卡片</b>纸白微渐变 + 半透明描边 + 分层阴影。
|
||||||
|
当前项白底浮起,白色被灰托住,不再扎眼。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 首页仪表盘 ============ -->
|
||||||
|
<section class="section" id="sec-bento">
|
||||||
|
<h2>05 · 首页仪表盘(Bento Grid)</h2>
|
||||||
|
<div class="app-shot">
|
||||||
|
<div class="app">
|
||||||
|
<aside class="aside">
|
||||||
|
<div class="logo">衍水集团</div>
|
||||||
|
<div class="logo-sub">营销人员工资管理系统</div>
|
||||||
|
<div class="waterline" style="width:22px;margin:10px 10px 0;"></div>
|
||||||
|
<div class="menu-group">
|
||||||
|
<div class="menu-item active">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M2.5 7 8 2.5 13.5 7v6.5H10V9.5H6v4H2.5Z"/></svg>
|
||||||
|
首页
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-group">
|
||||||
|
<div class="menu-label">系统管理</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="5.5" r="2.5"/><path d="M3 13.5c0-2.5 2.2-4 5-4s5 1.5 5 4"/></svg>
|
||||||
|
用户管理
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M8 2 13 4v4c0 3-2 5-5 6-3-1-5-3-5-6V4Z"/></svg>
|
||||||
|
角色管理
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5"/><circle cx="6" cy="7.5" r="1.2"/><path d="M4.5 11c.3-1 1-1.5 1.5-1.5s1.2.5 1.5 1.5M9.5 6.5h2M9.5 9h2"/></svg>
|
||||||
|
岗位角色
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 5h10M3 11h10"/><circle cx="6" cy="5" r="1.6"/><circle cx="10" cy="11" r="1.6"/></svg>
|
||||||
|
计提参数
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4.5h8M4 8h8M4 11.5h5"/></svg>
|
||||||
|
操作日志
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="aside-foot">
|
||||||
|
<div class="waterline" style="width:22px;margin:0 0 8px;"></div>
|
||||||
|
<div class="aside-foot-text">内部系统 · v0.1</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="main">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="crumb"><b>仪表盘</b></div>
|
||||||
|
<div class="who"><span class="avatar">管</span><span>管理员</span><span class="logout">退出登录</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>仪表盘</h1>
|
||||||
|
<div class="desc">数据截至今日 06:00 · 12 笔回款待核算</div>
|
||||||
|
<div class="waterline"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bento">
|
||||||
|
<div class="bento-card b-2x2">
|
||||||
|
<div class="b-eyebrow">本月绩效总额</div>
|
||||||
|
<div class="b-big" style="color:var(--ys-indigo)">268,430.16<small>元</small></div>
|
||||||
|
<div class="b-sub">较上月 <span class="b-up">+12.4%</span> · 已核销 86 笔</div>
|
||||||
|
<div id="chartPerf" style="width:100%;height:40px;margin-top:12px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bento-card b-2x2">
|
||||||
|
<div class="b-eyebrow">近 6 个月回款</div>
|
||||||
|
<div class="b-sub" style="margin-top:2px">单位:万元</div>
|
||||||
|
<div id="chartRev" style="width:100%;height:78px;margin-top:auto"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bento-card b-2x1">
|
||||||
|
<div class="b-eyebrow">待办</div>
|
||||||
|
<div style="margin-top:auto;display:flex;flex-direction:column;gap:4px">
|
||||||
|
<div class="b-todo"><span class="cnt">12 笔</span>回款待核算</div>
|
||||||
|
<div class="b-todo"><span class="cnt">3 笔</span>业务归属待确认</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bento-card">
|
||||||
|
<div class="b-head"><div class="b-eyebrow">营销人员</div>
|
||||||
|
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="5.5" r="2.5"/><path d="M3 13.5c0-2.5 2.2-4 5-4s5 1.5 5 4"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="b-num">36<small>人</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="bento-card">
|
||||||
|
<div class="b-head"><div class="b-eyebrow">岗位角色</div>
|
||||||
|
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5"/><circle cx="6" cy="7.5" r="1.2"/><path d="M4.5 11c.3-1 1-1.5 1.5-1.5s1.2.5 1.5 1.5M9.5 6.5h2M9.5 9h2"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="b-num">3<small>个</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="bento-card b-2x1">
|
||||||
|
<div class="b-eyebrow">常用操作</div>
|
||||||
|
<div class="row" style="margin-top:auto">
|
||||||
|
<button class="btn btn-primary" style="height:30px;font-size:12px;padding:0 13px">录入回款</button>
|
||||||
|
<button class="btn btn-default" style="height:30px;font-size:12px;padding:0 13px">计提参数</button>
|
||||||
|
<button class="btn btn-default" style="height:30px;font-size:12px;padding:0 13px">岗位角色</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bento-card">
|
||||||
|
<div class="b-head"><div class="b-eyebrow">计提参数</div>
|
||||||
|
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 5h10M3 11h10"/><circle cx="6" cy="5" r="1.6"/><circle cx="10" cy="11" r="1.6"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="b-num">16<small>项</small></div>
|
||||||
|
<div class="b-sub">版本 P20260101-00</div>
|
||||||
|
</div>
|
||||||
|
<div class="bento-card">
|
||||||
|
<div class="b-head"><div class="b-eyebrow">T+ 同步</div>
|
||||||
|
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M13 8a5 5 0 1 1-1.5-3.5"/><path d="M13 2.5V5h-2.5"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="b-num" style="color:var(--ys-ok)">正常</div>
|
||||||
|
<div class="b-sub">每周一 06:00</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
<b>Bento Grid</b>:大小不一的卡片按重要性排布——绩效总额与回款趋势占 2×2 主角位,数字型指标 1×1 紧凑排列。
|
||||||
|
卡片入场为 <b>45ms 交错上浮(320ms)</b>,悬停抬升 2px、阴影加深——打开预览即可看到微动效。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 登录页 ============ -->
|
||||||
|
<section class="section" id="sec-login">
|
||||||
|
<h2>06 · 登录页(重做)</h2>
|
||||||
|
<div class="login-shot">
|
||||||
|
<div class="login-center">
|
||||||
|
<div class="ripple-bg"><i></i><i></i><i></i><i></i></div>
|
||||||
|
<div style="position:relative">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="lc-brand">
|
||||||
|
<div class="lc-brand-name">衍水集团</div>
|
||||||
|
<div class="lc-slogan">把每一笔回款,算成一份踏实。</div>
|
||||||
|
<div class="waterline" style="width:40px;margin:14px auto 0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="lc-form-head" style="margin-top:28px">欢迎回来</div>
|
||||||
|
<div class="lc-form-hint">请输入账号密码进入系统</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input class="input" placeholder="请输入用户名" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>密码</label>
|
||||||
|
<input class="input" type="password" placeholder="请输入密码" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>验证码</label>
|
||||||
|
<div class="captcha-row">
|
||||||
|
<input class="input" placeholder="请输入验证码" />
|
||||||
|
<div class="captcha-box">7K3P</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary login-btn">登 录</button>
|
||||||
|
</div>
|
||||||
|
<div class="lc-foot">© 衍水集团 · 内部系统</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
背景三层:<b>点阵网格</b>(秩序)+ <b>顶部靛蓝天光</b>(氛围)+ <b>右上涟漪同心圆</b>(「衍水」的品牌装饰);
|
||||||
|
卡片内品牌区承担门面——品牌字、标语、水线;表单以「欢迎回来」承接。装饰与文案都来自「衍水」本身。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 签名元素 ============ -->
|
||||||
|
<section class="section" id="sec-sign">
|
||||||
|
<h2>07 · 签名元素:水线</h2>
|
||||||
|
<div class="comp-card">
|
||||||
|
<div style="font-size:18px;font-weight:600;letter-spacing:-0.01em">计提参数</div>
|
||||||
|
<div class="waterline" style="width:56px"></div>
|
||||||
|
<div class="note" style="max-width:720px">
|
||||||
|
页面标题下的<b>渐隐靛蓝细线(56px,左实右虚)</b>——「衍水」之水的提纯,全站唯一的装饰性元素,
|
||||||
|
只在每个页面标题处出现一次。它与登录页的水纹层理同源:<b>品牌连续,而非推倒重来</b>。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 微动效与精致文案(全局规范) ============ -->
|
||||||
|
<section class="section" id="sec-motion">
|
||||||
|
<h2>08 · 微动效与精致文案(全局规范)</h2>
|
||||||
|
<div class="comp-grid">
|
||||||
|
<div class="comp-card">
|
||||||
|
<h3>微动效 · 全站统一</h3>
|
||||||
|
<table class="spec-table">
|
||||||
|
<tr><td>页面 / 卡片入场</td><td>淡入 + 上浮 6px,45ms 交错</td><td class="num">320ms</td></tr>
|
||||||
|
<tr><td>按钮悬停 / 按下</td><td>抬升 1px、影加深 / 回落内阴影</td><td class="num">150ms</td></tr>
|
||||||
|
<tr><td>卡片悬停</td><td>抬升 2px、阴影加深</td><td class="num">180ms</td></tr>
|
||||||
|
<tr><td>输入聚焦</td><td>靛蓝描边 + 3px 柔光外环</td><td class="num">150ms</td></tr>
|
||||||
|
<tr><td>弹层 / 抽屉</td><td>淡入 + 上移 4px</td><td class="num">160ms</td></tr>
|
||||||
|
<tr><td>表格行</td><td>底色渐显(无位移)</td><td class="num">120ms</td></tr>
|
||||||
|
<tr><td>数据刷新</td><td>数字不做滚动动画(核对场景,动即干扰)</td><td class="num">—</td></tr>
|
||||||
|
<tr><td>无障碍</td><td>prefers-reduced-motion 时全部关闭</td><td class="num">—</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="note">目标不是「有动画」,而是<b>顺滑可信</b>:每次操作都有轻微、及时、可预期的反馈。</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp-card">
|
||||||
|
<h3>精致文案 · 全站统一</h3>
|
||||||
|
<table class="spec-table">
|
||||||
|
<tr><td>按钮</td><td class="bad">提交</td><td class="good">保存变更 · 录入回款</td></tr>
|
||||||
|
<tr><td>空态</td><td class="bad">暂无数据</td><td class="good">还没有岗位角色,点「新增岗位」创建第一个</td></tr>
|
||||||
|
<tr><td>错误</td><td class="bad">操作失败</td><td class="good">岗位编码已存在,请换一个</td></tr>
|
||||||
|
<tr><td>危险确认</td><td class="bad">确定删除吗?</td><td class="good">删除后无法恢复,该岗位下的人员需重新归属</td></tr>
|
||||||
|
<tr><td>成功提示</td><td class="bad">操作成功</td><td class="good">岗位已创建 · 参数 11-01 起生效</td></tr>
|
||||||
|
<tr><td>页面描述</td><td class="bad">本页面用于管理</td><td class="good">决定人员适用的计提公式模板与参数;新增岗位无需改代码</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="note">说清楚<b>会发生什么</b>;动词开头、句子短;不道歉、不卖萌;<b>空态是邀请,错误是路标</b>。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script src="echarts.min.js"></script>
|
||||||
|
<script>
|
||||||
|
/* 仪表盘图表:ECharts 定制主题(圆角柱 / 无网格线 / 浅灰轴标签)——实施时前端引入 echarts 依赖 */
|
||||||
|
const chartFont = "'PingFang SC','Microsoft YaHei','Noto Sans SC',sans-serif";
|
||||||
|
const rev = echarts.init(document.getElementById('chartRev'), null, { renderer: 'svg' });
|
||||||
|
rev.setOption({
|
||||||
|
grid: { left: 0, right: 0, top: 6, bottom: 20, containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category', data: ['5月', '6月', '7月', '8月', '9月', '10月'],
|
||||||
|
axisLine: { show: false }, axisTick: { show: false },
|
||||||
|
axisLabel: { color: '#9aa3b2', fontSize: 9, margin: 10, fontFamily: chartFont }
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', show: false },
|
||||||
|
series: [{
|
||||||
|
type: 'bar', barWidth: 22, data: [34, 42, 30, 48, 38, 56],
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: [3, 3, 0, 0],
|
||||||
|
color: (p) => p.dataIndex === 5
|
||||||
|
? new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#3a58bc' }, { offset: 1, color: '#2e4ba8' }])
|
||||||
|
: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#d5def3' }, { offset: 1, color: '#e8edf8' }])
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
const perf = echarts.init(document.getElementById('chartPerf'), null, { renderer: 'svg' });
|
||||||
|
perf.setOption({
|
||||||
|
grid: { left: 0, right: 0, top: 6, bottom: 2 },
|
||||||
|
xAxis: { type: 'category', show: false, boundaryGap: false, data: [1, 2, 3, 4, 5, 6, 7] },
|
||||||
|
yAxis: { type: 'value', show: false, min: 0 },
|
||||||
|
series: [{
|
||||||
|
type: 'line', data: [8, 13, 10, 19, 23, 29, 33], smooth: 0.4, symbol: 'none',
|
||||||
|
lineStyle: { color: '#2e4ba8', width: 1.6 },
|
||||||
|
areaStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(46,75,168,.10)' }, { offset: 1, color: 'rgba(46,75,168,0)' }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# M1 开发计划:系统设置与参数体系(#28)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
《剩余开发计划》已定稿(`docs/superpowers/plans/2026-10-12-剩余开发计划.md`),M1 是首个工作块。本文档即 M1 的正式开发计划(2026-10-12 批准):涵盖设计(四表/参数/模板/接口)、任务分解(T1–T8 步骤级)、执行安排与验收标准;开工后按第二章逐任务执行。
|
||||||
|
|
||||||
|
**为什么先做 M1**:岗位角色被基础数据的人员管理引用(外键上游);参数快照与公式模板是绩效核算的输入契约。先立地基可避免后续模块加表、加外键、改表单的连锁返工;计算预览还可作为业务对账工具先行验收公式口径。
|
||||||
|
|
||||||
|
**#28 需求原文要点**:① 人员岗位角色(营销员/营销经理/信息员等)支持增删,新增岗位时在系统设置中绑定计提公式与参数,**无需改代码**;② 全部计提比例、系数、阈值、分成比例、起付比例统一在系统设置维护,**支持版本管理与留痕**。
|
||||||
|
|
||||||
|
## 现状(开工前已核查)
|
||||||
|
|
||||||
|
| 项 | 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| 分支 | `feat/system-params`(基于 `feature/system-management` HEAD;后者领先 main 5 提交、未合并——是否先合并 main 由用户决定,不阻塞开发) |
|
||||||
|
| 容器 | Docker Desktop 位于 `D:\work\app\Docker\Docker Desktop.exe`(非默认路径) |
|
||||||
|
| 工作区 | docs/ 下有用户侧整理改动(业务清单删除、v2 新增、superpowers 目录),与 M1 无关,不动 |
|
||||||
|
| 基线 | 301 用例全绿(系统管理交付时);开工先复跑确认 |
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
**做**:四张新表与种子(V3 迁移双轨);岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数与模板注册表;计算预览;前端两个页面(岗位角色、计提参数)。
|
||||||
|
|
||||||
|
**不做**:核算引擎与台账(M3);基础数据与业务数据(M2);参数 Redis 缓存(一期不做,保留键位);规则引擎(既有决策)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、技术设计
|
||||||
|
|
||||||
|
### 1.1 数据模型(四张新表,V3 迁移)
|
||||||
|
|
||||||
|
**`business_roles` 业务岗位角色表**(与系统登录角色 `roles` 严格区分,表注释写明)
|
||||||
|
|
||||||
|
| 列 | 类型 | 约束/说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | BIGINT UNSIGNED | PK AUTO_INCREMENT |
|
||||||
|
| role_code | VARCHAR(32) | NOT NULL,uk;创建后不可改 |
|
||||||
|
| role_name | VARCHAR(64) | NOT NULL |
|
||||||
|
| calc_template | VARCHAR(32) | NOT NULL,公式模板编码(以代码注册表为准) |
|
||||||
|
| description | VARCHAR(255) | NULL |
|
||||||
|
| sort_order | INT | NOT NULL DEFAULT 0 |
|
||||||
|
| status | TINYINT | NOT NULL DEFAULT 1(1 启用 0 禁用) |
|
||||||
|
| is_builtin | TINYINT | NOT NULL DEFAULT 0(1 内置禁删) |
|
||||||
|
| create_time / update_time | DATETIME | TimestampMixin(update 用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")`) |
|
||||||
|
|
||||||
|
**`param_definitions` 参数定义表**:`param_code` VARCHAR(64) uk;`param_name` VARCHAR(64);`param_group` VARCHAR(32);`value_type` VARCHAR(16)(DECIMAL/INT/BOOL/ENUM/TEXT);`unit` VARCHAR(16)(‰/%/元/天);`dimension_type` VARCHAR(32) DEFAULT 'NONE'(NONE/PRODUCT_CATEGORY/BUSINESS_ROLE);`options_text` VARCHAR(255)(枚举竖线分隔);`default_value`/`value_min`/`value_max` VARCHAR(64)(Fraction 可解析字符串);`description` VARCHAR(255);`sort_order` INT;`status` TINYINT;TimestampMixin。
|
||||||
|
|
||||||
|
**`param_change_batches` 参数变更批次表**:`version_no` VARCHAR(32) uk(`P{YYYYMMDD}-{当日序号}`);`effective_date` DATE NOT NULL idx;`change_type` TINYINT(1 立即 2 指定日期);`remark` VARCHAR(255);`operator_id` FK→users ON DELETE SET NULL + `operator_name` VARCHAR(64) 冗余固化;`item_count` INT DEFAULT 0;仅 `create_time`(append-only,不继承 TimestampMixin)。
|
||||||
|
|
||||||
|
**`param_values` 参数版本条目表(append-only)**:`param_id` FK→param_definitions RESTRICT idx;`dimension_value` VARCHAR(32) NOT NULL DEFAULT `''`(用空串不用 NULL);`effective_date` DATE NOT NULL idx;`value_text` VARCHAR(64) NOT NULL;`old_value_text` VARCHAR(64);`batch_id` FK→param_change_batches RESTRICT idx;仅 `create_time`。热路径索引 `idx_param_values_param_id_dimension_value_effective_date`。已生效条目禁改禁删;未生效批次可整批撤回。
|
||||||
|
|
||||||
|
**种子**(迁移 + init-dev 双写):3 个内置岗位(`salesperson 营销员`/TPL_SALESPERSON_DECAY、`sales_manager 营销经理`/TPL_MANAGER_SHARE、`info_officer 信息员`/TPL_INFO_SALES_FIXED)+ 16 项参数定义 + 版本条目 + 一条初始批次(`P20260101-00`)。
|
||||||
|
|
||||||
|
### 1.2 参数清单(16 项)
|
||||||
|
|
||||||
|
| param_code | 名称 | 类型/单位 | 维度 | 默认值 | 生效日期 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| salesperson.rate.start | 业务员起始计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 10 | 2026-02-17 |
|
||||||
|
| salesperson.rate.slope | 业务员日递减斜率 | DECIMAL ‰/天 | BUSINESS_ROLE | **4/360** | 2026-02-17 |
|
||||||
|
| salesperson.rate.floor | 业务员计提下限 | DECIMAL ‰ | BUSINESS_ROLE | 4 | 2026-02-17 |
|
||||||
|
| salesperson.days.boundary | 线性递减分界天数 | INT 天 | BUSINESS_ROLE | 360 | 2026-02-17 |
|
||||||
|
| manager.share.team | 经理本团队分成 | DECIMAL % | BUSINESS_ROLE | 12.5 | 2026-08-13 |
|
||||||
|
| manager.share.company | 经理全公司分成 | DECIMAL % | BUSINESS_ROLE | 5 | 2026-08-13 |
|
||||||
|
| enterprise.rate | 企业户计提比例 | DECIMAL ‰ | PRODUCT_CATEGORY | **2(02-17 起)/ 3(08-13 起)** | 双行 |
|
||||||
|
| info.rate | 信息员计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 1 | 2026-08-13 |
|
||||||
|
| prepay.rate | 预付款计提比例 | DECIMAL ‰ | NONE | 10 | 2026-01-01 |
|
||||||
|
| barter.discount | 以物抵顶折算比例 | DECIMAL % | NONE | 80 | 2026-01-01 |
|
||||||
|
| payment.start.ratio | 支付起付比例 | DECIMAL % | NONE | 60 | 2026-01-01 |
|
||||||
|
| payment.weight.mode | 支付权重应用方式 | ENUM FULL\|BALANCE | NONE | FULL | 2026-01-01 |
|
||||||
|
| product.threshold | 折算毛利润额阈值 | DECIMAL % | PRODUCT_CATEGORY | 市政水利 30 / 轨交 20 | 2026-02-17 |
|
||||||
|
| salary.basic.mode | 月工资基本工资口径 | ENUM INCLUDE\|DEDUCT\|EXCLUDE | BUSINESS_ROLE | 业务员 INCLUDE / 经理 DEDUCT | 2026-01-01 |
|
||||||
|
| probation.annual.threshold | 试用期年累计结算阈值 | DECIMAL 元 | NONE | 18000 | 2026-01-01 |
|
||||||
|
| probation.salary.basic | 试用期基本工资标准 | DECIMAL 元 | NONE | 3000 | 2026-01-01 |
|
||||||
|
|
||||||
|
**不设参数**(作废条款落地):经理个人户 8‰ 起始/斜率;协助 25% 分成;含运费口径开关。
|
||||||
|
|
||||||
|
**值校验规则**(`validate_and_normalize`):DECIMAL 用 `Fraction(text)` 解析(拒绝 `1/0`、非数字),并按 `value_min`/`value_max` 校验;INT 必须整数;BOOL 仅 0/1;ENUM 必须在 `options_text` 内;规范化去首尾空白、分数式原样存储。
|
||||||
|
|
||||||
|
### 1.3 公式模板与消费契约
|
||||||
|
|
||||||
|
**模板注册表**(`app/services/calc_templates.py`,代码内有限集合,一期不引入规则引擎的落地方式):
|
||||||
|
|
||||||
|
| 模板编码 | 形态 | 声明参数 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TPL_SALESPERSON_DECAY | 分段:≤分界 = 起始 − 天数×斜率;> 分界 = 下限 | salesperson.rate.* / days.boundary |
|
||||||
|
| TPL_MANAGER_SHARE | 聚合:∑本团队×12.5% + ∑全公司×5% | manager.share.* |
|
||||||
|
| TPL_ENTERPRISE_FIXED | 乘法:折算毛利润额 × 比例 | enterprise.rate |
|
||||||
|
| TPL_INFO_SALES_FIXED | 乘法:本人销售额 × 比例 | info.rate |
|
||||||
|
| TPL_PREPAY_FIXED | 乘法:回款额 × 比例 | prepay.rate |
|
||||||
|
|
||||||
|
修饰器(不绑角色,参数驱动):以物抵顶 ×80%、支付权重 R×R、起付 60%、试用期缓发。**绑定模型**:`business_roles.calc_template` → 模板;角色级覆盖经 `dimension_type=BUSINESS_ROLE` 条目;新增岗位 = 新增行 + 选模板(零代码)。
|
||||||
|
|
||||||
|
**消费接口契约**(M3 核算与预览共用):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/services/param_service.py
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParamSnapshot:
|
||||||
|
biz_date: date
|
||||||
|
def get(self, code, *, role_code=None, product_category=None) -> str: ... # 覆盖链:角色 > 产品类别 > 默认
|
||||||
|
def fraction(self, code, **dims) -> Fraction: ...
|
||||||
|
def integer(self, code, **dims) -> int: ...
|
||||||
|
def boolean(self, code, **dims) -> bool: ...
|
||||||
|
def text(self, code, **dims) -> str: ...
|
||||||
|
|
||||||
|
def resolve_snapshot(session, biz_date: date) -> ParamSnapshot
|
||||||
|
def submit_change(session, payload, *, operator, meta=None) -> ParamChangeBatchOut
|
||||||
|
def compare(session, *, from_date: date, to_date: date) -> list[ParamDiffOut]
|
||||||
|
|
||||||
|
# app/services/formula.py(纯函数,禁 float)
|
||||||
|
def payment_days(ship_date, receipt_date) -> int # 发货日首日=1
|
||||||
|
def salesperson_rate(days, *, start, slope, floor, boundary_days) -> Fraction
|
||||||
|
def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction # 不含运费;revenue=0 防除零
|
||||||
|
def converted_profit(revenue, rate, threshold) -> Decimal # 化简式:销售额 ×(毛利润率−阈值)
|
||||||
|
def performance_amount(base, rate) -> Decimal # 乘法方向(对照表 #3)
|
||||||
|
def apply_barter(amount, discount) -> Decimal
|
||||||
|
def payment_weight(r: int) -> Fraction # r×r
|
||||||
|
def start_payment_ok(received, revenue, ratio) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 API 设计(/api/v1/system/*,AdminUser)
|
||||||
|
|
||||||
|
**岗位角色**:`GET/POST /business-roles`、`GET/PUT/DELETE /business-roles/{id}`、`PUT /business-roles/{id}/status`、`GET /business-roles/meta/templates`(模板下拉,含声明参数说明)。
|
||||||
|
|
||||||
|
**计提参数**:`GET /params`(目录+当前快照,支持 `asOf`)、`GET /params/{param_code}/history`、`POST /params/changes`(提交变更批次)、`DELETE /params/changes/{batch_id}`(撤回未生效)、`GET /params/changes`(记录分页)、`GET /params/changes/{batch_id}`(明细)、`GET /params/compare`(两日期对比)、`GET /params/export`(Excel,`StreamingResponse` 非统一信封)、`POST /params/preview`(计算预览)。
|
||||||
|
|
||||||
|
**关键示例 1:提交变更**
|
||||||
|
|
||||||
|
```json
|
||||||
|
// POST /api/v1/system/params/changes
|
||||||
|
{
|
||||||
|
"changeType": 2,
|
||||||
|
"effectiveDate": "2026-11-01",
|
||||||
|
"remark": "四季度系数调整",
|
||||||
|
"items": [
|
||||||
|
{ "paramCode": "enterprise.rate", "dimensionValue": "", "value": "3" },
|
||||||
|
{ "paramCode": "product.threshold", "dimensionValue": "rail_transit", "value": "22" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// data:
|
||||||
|
{ "batchId": 12, "versionNo": "P20261012-01", "effectiveDate": "2026-11-01",
|
||||||
|
"changeType": 2, "itemCount": 2, "operatorName": "admin", "createTime": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键示例 2:计算预览**(示意值已验算)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// POST /api/v1/system/params/preview
|
||||||
|
{ "businessRole": "salesperson", "customerType": "personal",
|
||||||
|
"productCategory": "municipal_water", "shipDate": "2025-12-15",
|
||||||
|
"receiptDate": "2026-01-10", "revenue": "100000", "profit": "45000",
|
||||||
|
"receivedAmount": "60000", "barter": false, "probation": false }
|
||||||
|
// data:
|
||||||
|
{ "paymentDays": 27, "rate": "97/10000", "rateText": "9.7‰(10‰ − 27 天 × 4/360‰)",
|
||||||
|
"profitRate": "9/20", "convertedProfit": "15000", "performanceAmount": "145.5",
|
||||||
|
"startPaymentOk": true, "paymentWeightR": 60, "paymentWeight": "9/25",
|
||||||
|
"payableAmount": "52.38", "paramVersion": "P20260101-00", "paramEffectiveDate": "2026-02-17" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 前端设计
|
||||||
|
|
||||||
|
**岗位角色页**(`views/system/business-roles/index.vue`):列表(复用 `usePagedList`/`AppPagination`/`StatusTag`)+ 新增/编辑弹窗(模板下拉,选中后展示模板声明的参数清单与当前值)+ 启停/删除(内置岗位按钮禁用并 title 提示原因)。路由 meta `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`。
|
||||||
|
|
||||||
|
**计提参数页**(`views/system/params/index.vue`):按 `param_group` 分组展示(名称/当前值+单位/生效日期/口径说明);编辑后进入「待提交」区,顶部选生效方式(立即/指定日期)+ 备注;「提交变更」二次确认(列出每项 旧值→新值);版本管理抽屉(变更记录分页 + 批次明细 + 两日期对比 + Excel 导出下载);预览抽屉(表单 → 结果卡,注明「斜率精确值 4/360」)。路由 meta order 6。导出接口走 axios 实例取 blob(非统一信封,api 文件内注明)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、任务分解(T1–T8)
|
||||||
|
|
||||||
|
任务依赖:T1 → T2 → T3 → (T4 ∥ T5) → (T6 ∥ T7) → T8。每任务遵循「写测试 → 确认失败 → 实现 → 确认通过 → commit」。
|
||||||
|
|
||||||
|
### T1 迁移与模型(四表 + 种子)
|
||||||
|
|
||||||
|
**文件**:`app/models/business_role.py`、`app/models/param.py`(创建);`app/models/__init__.py`(改);`alembic/versions/xxxx_*.py`(autogenerate+种子);`db/migration/V3__add_business_roles_and_params.sql`、`db/init/init-dev.sql`(改)、`db/queries/计提参数.sql`(创建)。
|
||||||
|
|
||||||
|
- [ ] 写两个模型文件(参照 `app/models/role.py` 写法;`ParamChangeBatch`/`ParamValue` 不继承 TimestampMixin)
|
||||||
|
- [ ] 注册进 `models/__init__.py`(漏注册会让 autogenerate 生成删表迁移)
|
||||||
|
- [ ] `uv run alembic revision --autogenerate -m "新增岗位角色与计提参数四表"` → 人工检查命名/注释/ON UPDATE → 补种子(3 岗位/16 定义/版本条目/初始批次)
|
||||||
|
- [ ] `uv run alembic upgrade head` → `uv run alembic upgrade <V2_rev>:head --sql > db/migration/V3__*.sql`
|
||||||
|
- [ ] 同步 `db/init/init-dev.sql`(建表 + 种子 + alembic_version 更新)
|
||||||
|
- [ ] 写 `db/queries/计提参数.sql`(时点化解析与对比查询留档)
|
||||||
|
- [ ] 验证:`uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q` 全绿
|
||||||
|
- [ ] Commit:`feat(system): 新增岗位角色与计提参数四表及 V3 迁移`
|
||||||
|
|
||||||
|
### T2 参数注册表 + 公式纯函数
|
||||||
|
|
||||||
|
**文件**:`app/services/param_registry.py`、`calc_templates.py`、`formula.py`(创建);`app/core/constants.py`(改);`tests/unit/test_formula.py`、`test_param_registry.py`。
|
||||||
|
|
||||||
|
- [ ] 写失败测试(关键断言):`salesperson_rate(360,...) == Fraction(6,1000)`;`361 天 == Fraction(4,1000)`;`第 1 天 == Fraction(10) - Fraction(1,90)`;`payment_days(2025-12-15, 2026-01-10) == 27`;同日回款 == 1;`converted_profit(0,...) == 0`(防除零);负绩效不归零;`payment_weight(60) == Fraction(36,100)`;`parse_fraction("4/360") == Fraction(1,90)`;注册表 16 项且模板声明参数均存在
|
||||||
|
- [ ] 运行确认失败 → 实现三个文件 → 运行 `uv run pytest tests/unit -q` 全绿
|
||||||
|
- [ ] Commit:`feat(system): 计提公式模板注册表与精确分数计算函数`
|
||||||
|
|
||||||
|
### T3 参数仓储 + 取值服务(时点化解析)
|
||||||
|
|
||||||
|
**文件**:`app/repositories/param_repository.py`、`app/services/param_service.py`(解析部分);`tests/unit/test_param_snapshot.py`。
|
||||||
|
|
||||||
|
- [ ] 写失败测试:覆盖链(角色 > 产品类别 > 默认);未定义参数按注册表默认值兜底;同日多次变更取 id 最大
|
||||||
|
- [ ] 实现 `resolve_values(session, biz_date)`(一条 SQL 取全量快照)+ `ParamSnapshot`/`resolve_snapshot` → `uv run pytest tests/unit -q` 全绿
|
||||||
|
- [ ] Commit:`feat(system): 计提参数按业务日期时点化取值服务`
|
||||||
|
|
||||||
|
### T4 岗位角色 CRUD 后端(含审计与保护规则)
|
||||||
|
|
||||||
|
**文件**:`app/repositories/business_role_repository.py`、`app/schemas/business_role.py`、`app/services/business_role_service.py`、`app/api/v1/system/business_roles.py`(创建);`app/api/router.py`、`app/core/constants.py`(改)。整体参照 `roles` 模块模式。
|
||||||
|
|
||||||
|
- [ ] 实现(保护规则:内置禁删禁停、code 不可改、模板必须存在于注册表、人员引用计数钩子预留;写操作包 `audit_service.audit()`)
|
||||||
|
- [ ] 冒烟:新增自定义岗位 ✓、删除内置 → 403、非法模板 → 400
|
||||||
|
- [ ] Commit:`feat(system): 业务岗位角色管理接口与保护规则`
|
||||||
|
|
||||||
|
### T5 参数变更/历史/对比/导出/预览后端
|
||||||
|
|
||||||
|
**文件**:`app/schemas/param.py`、`app/api/v1/system/params.py`(创建);`param_service.py` 扩展、`app/api/router.py`、`constants.py`(改);`pyproject.toml`(`uv add openpyxl`)。
|
||||||
|
|
||||||
|
- [ ] 实现 9 个端点(提交/撤回/历史/分页/明细/对比/导出/预览/目录快照);撤回已生效批次 → 400;导出列含变更人/时间/项/旧值/新值/生效日期
|
||||||
|
- [ ] 冒烟链路:提交(指定未来日期)→ 当前快照不变 → 立即生效提交 → 快照变化 → 撤回未生效 → 回退 → 预览 360/361 → 导出可打开
|
||||||
|
- [ ] Commit:`feat(system): 计提参数版本管理、变更留痕与计算预览接口`
|
||||||
|
|
||||||
|
### T6 前端岗位角色页
|
||||||
|
|
||||||
|
**文件**:`src/api/businessRoles.ts`、`src/views/system/business-roles/index.vue`(创建);`router/index.ts`、`utils/constants.ts`(改)。
|
||||||
|
|
||||||
|
- [ ] 实现页面与路由 → `pnpm type-check && pnpm build` 通过
|
||||||
|
- [ ] Commit:`feat(system): 前端岗位角色管理页面`
|
||||||
|
|
||||||
|
### T7 前端参数配置页(版本管理 + 预览)
|
||||||
|
|
||||||
|
**文件**:`src/api/params.ts`、`src/views/system/params/index.vue`(创建);`router/index.ts`、`utils/constants.ts`(改)。
|
||||||
|
|
||||||
|
- [ ] 实现页面(分组表/生效方式/二次确认/版本抽屉/预览抽屉)→ `pnpm type-check` 通过
|
||||||
|
- [ ] e2e 链路:改参数 → 变更记录出现 → 预览值变化 → 导出可下载
|
||||||
|
- [ ] Commit:`feat(system): 前端计提参数配置与版本管理页面`
|
||||||
|
|
||||||
|
### T8 模块测试统一编写与文档
|
||||||
|
|
||||||
|
**文件**:`tests/unit/test_param_service.py`、`tests/integration/test_business_roles.py`、`test_param_management.py`(创建);`backend/tests/README.md`、`frontend/README.md`、`backend/README.md`、`frontend/e2e/*`(改)。
|
||||||
|
|
||||||
|
- [ ] 集成用例:参数精度全链路(注册表→种子→解析→预览一致);未来生效不泄漏;撤回回退;同日多次变更;审计留痕;权限 403;岗位保护规则
|
||||||
|
- [ ] 全量回归 `uv run pytest -q`(301 基线 + 新增全绿);e2e;三份 README 更新
|
||||||
|
- [ ] Commit:`test(system): 参数体系模块用例与测试记录`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、执行安排
|
||||||
|
|
||||||
|
- **分支**:`feat/system-params`(已创建);`feature/system-management` 是否先合并 main 由用户决定,不阻塞
|
||||||
|
- **环境**:启动 `D:\work\app\Docker\Docker Desktop.exe` → `docker compose -f docker/docker-compose.dev.yml up -d`;T2 纯函数开发不依赖容器
|
||||||
|
- **方式**:原生执行(当前会话逐任务推进);每任务后跑验证命令;T8 全量回归;收尾一轮 code review
|
||||||
|
- **注意**:Windows uvicorn `--reload` 偶发卡死——验证 reload 以「新 worker 的 startup complete」为准;开放问题 7 项(见下)按默认处理,不阻塞
|
||||||
|
|
||||||
|
## 四、验收标准(M1 完成的定义)
|
||||||
|
|
||||||
|
1. 管理员可新增岗位并绑定公式模板(无需改代码);内置岗位禁删禁停
|
||||||
|
2. 任一参数变更产生含变更人/时间/项/旧值/新值/生效日期的记录(批次 + 操作日志双保险)
|
||||||
|
3. 任意业务日期可解析出当时快照(08-12 企业户=2‰、08-13=3‰ 回溯正确)
|
||||||
|
4. 计算预览精确复现:360 天=6‰、361 天=4‰、12-15→01-10=27 天
|
||||||
|
5. `uv run pytest -q` 301 基线 + 新增全绿;`pnpm type-check && pnpm build` 通过;e2e 链路通过
|
||||||
|
6. `db/migration/V3__*.sql` 与 `db/init/init-dev.sql` 双轨同步(`test_schema_sync.py` 把关)
|
||||||
|
|
||||||
|
## 五、风险与开放问题
|
||||||
|
|
||||||
|
**风险(已定对策)**:参数缓存一期不做(保留 `ys:param.ver:*` 键位,未来按批次版本号精确失效);同日多次变更 UI 明示「按最新一条生效」;`4/360` 展示注明精确值;模板扩展摩擦一期接受(受控下拉);预览出参与需规 2.4.1 明细列同名(减少 M3 映射);openpyxl 在 T5 增补依赖。
|
||||||
|
|
||||||
|
**开放问题(不阻塞,按默认处理)**:① 第 1 天 10‰ vs 9.9889‰ → 按公式实现并参数化;② 企业户 2‰ 历史种子 → 已双种子;③ BALANCE 模式语义 → M3 前确认;④ `probation.salary.basic` 归属 → M2 时复核;⑤ 参数查询权限 → 暂 AdminUser;⑥ 导出列集合 → 以需规 2.3.1(四) 为准;⑦ 经理个人户业绩进入 12.5%+5% 的衔接 → **M3 开工前必须确认**。
|
||||||
|
|
||||||
|
## 六、验证方式
|
||||||
|
|
||||||
|
1. 按 T1–T8 的验证节点逐任务执行(见第二章各任务);T8 后全量回归 + e2e;
|
||||||
|
2. 验收标准 6 条逐条核对(见第四章);
|
||||||
|
3. 与 `ai-aux/AI_CONTEXT.md` 需求裁决节交叉核对口径无冲突。
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
# 剩余开发计划(路线图 + 首工作块实施计划)
|
||||||
|
|
||||||
|
> **面向 AI 代理的工作者:** 首工作块(M1)实施时使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现本计划第二章;步骤使用复选框(`- [ ]`)语法跟踪进度。第一章路线图供全局导航。
|
||||||
|
|
||||||
|
**目标:** 在需求裁决全部落定(2026-10-12)后,为剩余 5 大模块与 T+ 同步给出推进路线;并交付首个工作块「系统设置与参数体系(#28)」的可执行实施计划(任务级,含测试与验证)。
|
||||||
|
|
||||||
|
**架构:** 沿用现有六层(api → services → repositories → models,schemas 承载请求/响应,core 横切);新增「公式模板注册表(代码内有限集合)+ 参数按生效日期时点化存储 + 计算预览」作为核算模块的输入契约,不引入规则引擎。
|
||||||
|
|
||||||
|
**技术栈:** 后端 Python 3.12 + FastAPI + SQLAlchemy 2.0(同步)+ MySQL 8.0 + Redis 7;前端 Vue 3.5 + TS 5.9 + Vite 8 + Element Plus 2.14 + Pinia 4。
|
||||||
|
|
||||||
|
**规格:** `ai-aux/AI_CONTEXT.md`「需求裁决」节(权威口径);`docs/问题与解决方案对照表_v2.xlsx`(28 项裁决);`docs/2026.9.8修改方案.docx`(公式与生效日期)。需规(`docs/衍水绩效管理系统功能需规.docx`)中 4 类作废条款不再引用:26 天示例、含运费公式、协助 25%、经理个人户 8‰。
|
||||||
|
|
||||||
|
## 全局约束
|
||||||
|
|
||||||
|
- 表:小写蛇形复数、列小写蛇形、每列中文 COMMENT、主键 `id`、`uk_`/`idx_`/`fk_` 前缀;金额类 DECIMAL;时间列用 `TimestampMixin`(`ON UPDATE CURRENT_TIMESTAMP` 须 `text()` 显式写);append-only 表不继承 TimestampMixin(与 `login_logs` 先例一致)
|
||||||
|
- 迁移双轨:Alembic 生成迁移 → 导出 SQL 到 `db/migration/V{n}__*.sql`(按序编号)→ 同步 `db/init/init-dev.sql`;`tests/integration/test_schema_sync.py` 拦截漂移
|
||||||
|
- 六层红线:api 只参数绑定(写 `def` 非 `async def`)→ services 唯一事务边界 → repositories 只 `flush()`;统一响应 `{code,message,data}`,分页 `pageNum/pageSize` → `list/total/pageNum/pageSize`
|
||||||
|
- 写操作接入 `audit_service.audit()`;审计脱敏在格式化后执行(顺序不可倒)
|
||||||
|
- 金额全程 `Decimal`/`Fraction`,**禁 float**;`4/360` 按精确分数存储(`"4/360"`,禁 `0.011111`)
|
||||||
|
- Redis 键前缀 `ys:`,TTL 按业务语义;禁 `KEYS *` / `FLUSHALL` / `FLUSHDB`
|
||||||
|
- 前端:TS 锁 `~5.9.3`(不可 7.x);复用 `usePagedList`/`useConfirmAction`/`AppPagination`/`StatusTag`;视觉沿用 `--ys-*` token;新页面必须写 `meta.title`
|
||||||
|
- 测试:业务模块整体功能完成时统一编写用例;记录写入 `backend/tests/README.md` / `frontend/README.md`
|
||||||
|
- 提交:`feat(scope): 中文说明`;不直推 main/develop
|
||||||
|
|
||||||
|
## 审查重点(Review Focus)
|
||||||
|
|
||||||
|
以下输入/失败模式最可能伤到使用者,对应测试已钉进第二章各任务:
|
||||||
|
|
||||||
|
1. 畸形参数值(`"1/0"` 除零分数、非数字、超取值域)→ 保存必须 400 拒绝且不落库(T2/T5)
|
||||||
|
2. 指定未来生效的变更泄漏进当前快照 → 快照解析必须不含(T3/T5)
|
||||||
|
3. 撤回未生效批次后解析未回退 → 必须回退到撤回前值(T5)
|
||||||
|
4. 回款天数边界:同日回款 = 1 天;12-15 → 01-10 = 27 天;跨年连续(T2)
|
||||||
|
5. 计提比例边界:360 天 = 6‰、361 天 = 4‰、第 1 天 = 10 − 4/360 ≈ 9.9889‰(精确分数断言)(T2)
|
||||||
|
6. 毛利润额 = 0 → 折算额 0、绩效 0 不除零;负绩效按公式正常计算不归零(T2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一章 模块级路线图
|
||||||
|
|
||||||
|
### 1.1 推荐顺序与依赖
|
||||||
|
|
||||||
|
```text
|
||||||
|
① 系统设置与参数体系(#28) ← 地基:岗位角色 + 参数 + 公式模板
|
||||||
|
② 基础数据管理 ← 人员引用 ① 岗位角色(外键上游)
|
||||||
|
③ 业务数据管理 ← 引用 ② 人员/产品/客户
|
||||||
|
④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
|
||||||
|
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;⑥ 写入 ②③ 目标表,受 IT 字段映射制约
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 顺序论证(要点)
|
||||||
|
|
||||||
|
**① 先于基础数据:** 外键方向在上游(人员→岗位,先建避免迁移返工);参数取值契约是 ④ 的输入面,先定型让后续模块按契约编码;计算预览(需规 2.3.1 四)首块即可交给业务对账「360 天=6‰、361 天=4‰」,验收前移;参数清单提炼本身 = 28 项裁决逐条复核。阈值参数归 ① 有明文依据(#28「全部阈值统一在系统设置维护」)。
|
||||||
|
|
||||||
|
**⑥ 放最后:** 字段映射未定(对照表 #26 ⏳IT),放前部会把外部不确定性引入关键路径;目标表由 ②③ 定稿后同步才有意义;一期可手工录入闭环。**但预留前移**:②③ 表设计必须含外部唯一键列(`source_no`/`t_plus_id`)、同步批次时间列,并约定「同步不覆盖人工字段(指导价、归属、岗位)」。
|
||||||
|
|
||||||
|
**⑤ 可后置:** 纯消费端无下游,口径依赖真实核算数据校准;唯一约束是 ④ 设计台账时对照需规 2.4.1 明细列(合同编号/发货日/回款日/回款天数/产品类别/客户类型/人员岗位/计提比例/支付权重R/绩效金额)一次性建全。
|
||||||
|
|
||||||
|
### 1.3 各块范围与验收标准
|
||||||
|
|
||||||
|
| 块 | 范围界定 | 验收标准(一句话) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览 | 管理员可新增岗位并绑定模板;任一参数变更产生含变更人/时间/项/旧值/新值/生效日期的记录;任意业务日期可解析当时快照;预览精确复现 360 天=6‰、361 天=4‰;301 基线不回归 |
|
||||||
|
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更 | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
|
||||||
|
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器 | 一笔业务可多产品多批次发货、多笔回款;回款天数按「发货日首日=1」自动算;折算走精确分数;评级只出等级不影响金额 |
|
||||||
|
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕 | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成 |
|
||||||
|
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致 |
|
||||||
|
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
|
||||||
|
|
||||||
|
### 1.4 里程碑(交付批次)
|
||||||
|
|
||||||
|
| 批次 | 内容 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **M1** | ① 系统设置与参数体系 | 独立可交付;预览工具可作业务对账入口先行验收 |
|
||||||
|
| **M2** | ② + ③ | 合并交付:一次表结构评审把 T+ 预留列与报表字段清单同时定稿 |
|
||||||
|
| **M3** | ④ 绩效工资核算 | 核心钱袋子;测试重点在精度与参数回溯 |
|
||||||
|
| **M4** | ⑤ 报表 + ⑥ T+ 同步(并行) | ⑥ 受 IT 制约,谁先就绪谁先交 |
|
||||||
|
|
||||||
|
### 1.5 需求依据对照
|
||||||
|
|
||||||
|
| 块 | 依据 | 关联作废条款(不引用) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | 需规 2.3.1(四)/2.5.2/2.1.2;对照表 #28/#5/#8/#20–#24 | 经理个人户 8‰(不设参数);含运费口径;协助 25%(不设参数) |
|
||||||
|
| ② | 需规 2.1.1–2.1.3;对照表 #9/#19/#26 | 信息部负责人条款(信息员独立角色) |
|
||||||
|
| ③ | 需规 2.2.1–2.2.5;对照表 #1/#2/#12/#13/#14/#18/#25 | 26 天示例;含运费公式(2.2.4/2.3.3 整段) |
|
||||||
|
| ④ | 需规 2.3.1–2.3.3;对照表 #3/#4/#6/#7/#11/#15/#16/#17/#20–#24 | 协助 25%;经理 8‰;26 天示例;含运费公式 |
|
||||||
|
| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R) | — |
|
||||||
|
| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二章 首工作块「系统设置与参数体系(#28)」实施计划
|
||||||
|
|
||||||
|
### 2.1 关键设计取舍(已定)
|
||||||
|
|
||||||
|
1. **参数存储 = 参数定义 + 逐项版本条目(时点化 append-only)+ 变更批次表承载「版本」**;否决全量快照(改一项复制全量、单参数历史需遍历)与单行 JSON(无回溯、无字段校验);
|
||||||
|
2. 数值一律以 **Fraction 可解析字符串**存储(`"4/360"`,禁 `0.011111`),解析走 `Fraction(text)` 不经 float;
|
||||||
|
3. **不设日期唯一键**:同日多次修正是合法留痕,解析按 `effective_date DESC, id DESC` 取最新;
|
||||||
|
4. **一期不做参数 Redis 缓存**(算钱系统缓存失效即错钱;20 行级查询成本可忽略),保留 `ys:param:ver:*` 键位待实测后启用;
|
||||||
|
5. 维度覆盖链:**角色维度 > 产品类别维度 > 默认行**;`dimension_value` 用空串不用 NULL(MySQL 组合索引对 NULL 不去重);
|
||||||
|
6. append-only 表(`param_values`/`param_change_batches`)**不继承 TimestampMixin**(无 update_time,与 `login_logs` 先例一致)。
|
||||||
|
|
||||||
|
### 2.2 表设计(V3 迁移,四张新表)
|
||||||
|
|
||||||
|
**`business_roles` 业务岗位角色表**(与系统登录角色 `roles` 严格区分,表注释写明;这是 #28「人员岗位角色」的落点)
|
||||||
|
|
||||||
|
| 列 | 类型 | 约束/说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | BIGINT UNSIGNED | PK AUTO_INCREMENT |
|
||||||
|
| role_code | VARCHAR(32) | NOT NULL,uk;创建后不可改(同 roles 保护) |
|
||||||
|
| role_name | VARCHAR(64) | NOT NULL |
|
||||||
|
| calc_template | VARCHAR(32) | NOT NULL,计提公式模板编码(以代码注册表为准) |
|
||||||
|
| description | VARCHAR(255) | NULL |
|
||||||
|
| sort_order | INT | NOT NULL DEFAULT 0 |
|
||||||
|
| status | TINYINT | NOT NULL DEFAULT 1(1 启用 0 禁用) |
|
||||||
|
| is_builtin | TINYINT | NOT NULL DEFAULT 0(1 内置禁删) |
|
||||||
|
| create_time / update_time | DATETIME | TimestampMixin |
|
||||||
|
|
||||||
|
种子(迁移 + init-dev 双写):`salesperson 营销员`(TPL_SALESPERSON_DECAY)、`sales_manager 营销经理`(TPL_MANAGER_SHARE)、`info_officer 信息员`(TPL_INFO_SALES_FIXED),均内置。
|
||||||
|
|
||||||
|
**`param_definitions` 计提参数定义表**:`param_code` VARCHAR(64) uk;`param_name` VARCHAR(64);`param_group` VARCHAR(32);`value_type` VARCHAR(16)(DECIMAL/INT/BOOL/ENUM/TEXT);`unit` VARCHAR(16)(‰/%/元/天,可 NULL);`dimension_type` VARCHAR(32) NOT NULL DEFAULT 'NONE'(NONE/PRODUCT_CATEGORY/BUSINESS_ROLE);`options_text` VARCHAR(255)(枚举竖线分隔);`default_value` / `value_min` / `value_max` VARCHAR(64)(Fraction 可解析字符串);`description` VARCHAR(255);`sort_order` INT;`status` TINYINT;TimestampMixin。
|
||||||
|
|
||||||
|
**`param_change_batches` 参数变更批次表**:`version_no` VARCHAR(32) uk(如 `P20261012-01`);`effective_date` DATE NOT NULL idx;`change_type` TINYINT(1 立即 2 指定日期);`remark` VARCHAR(255);`operator_id` BIGINT UNSIGNED FK→users ON DELETE SET NULL + `operator_name` VARCHAR(64) 冗余固化;`item_count` INT NOT NULL DEFAULT 0;仅 `create_time`。
|
||||||
|
|
||||||
|
**`param_values` 参数版本条目表(append-only)**:`param_id` FK→param_definitions RESTRICT idx;`dimension_value` VARCHAR(32) NOT NULL DEFAULT `''`;`effective_date` DATE NOT NULL idx;`value_text` VARCHAR(64) NOT NULL;`old_value_text` VARCHAR(64) NULL;`batch_id` FK→param_change_batches RESTRICT idx;仅 `create_time`。索引 `idx_param_values_param_id_dimension_value_effective_date`(解析热路径)。已生效条目禁改禁删;未生效批次可整批撤回(删批次+条目)。解析 SQL 留存 `db/queries/计提参数.sql`。
|
||||||
|
|
||||||
|
### 2.3 参数清单(16 项,迁移种子)
|
||||||
|
|
||||||
|
| param_code | 名称 | 类型/单位 | 维度 | 默认值 | 依据 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| salesperson.rate.start | 业务员起始计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 10 | 需规 2.3.1(一)1 |
|
||||||
|
| salesperson.rate.slope | 业务员日递减斜率 | DECIMAL ‰/天 | BUSINESS_ROLE | **4/360** | 对照表 #4 |
|
||||||
|
| salesperson.rate.floor | 业务员计提下限 | DECIMAL ‰ | BUSINESS_ROLE | 4 | 对照表 #4 |
|
||||||
|
| salesperson.days.boundary | 线性递减分界天数 | INT 天 | BUSINESS_ROLE | 360 | 对照表 #4 |
|
||||||
|
| manager.share.team | 经理本团队分成 | DECIMAL % | BUSINESS_ROLE | 12.5 | #6/#15/#16 |
|
||||||
|
| manager.share.company | 经理全公司分成 | DECIMAL % | BUSINESS_ROLE | 5 | 同上 |
|
||||||
|
| enterprise.rate | 企业户计提比例 | DECIMAL ‰ | PRODUCT_CATEGORY | 3 | #20 |
|
||||||
|
| info.rate | 信息员计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 1 | #9 |
|
||||||
|
| prepay.rate | 预付款计提比例 | DECIMAL ‰ | NONE | 10 | #23 |
|
||||||
|
| barter.discount | 以物抵顶折算比例 | DECIMAL % | NONE | 80 | #24 |
|
||||||
|
| payment.start.ratio | 支付起付比例 | DECIMAL % | NONE | 60 | #21 |
|
||||||
|
| payment.weight.mode | 支付权重应用方式 | ENUM FULL\|BALANCE | NONE | FULL | #22 |
|
||||||
|
| product.threshold | 折算毛利润额阈值 | DECIMAL % | PRODUCT_CATEGORY | 市政水利 30 / 轨交 20 | #12、需规 2.1.2 |
|
||||||
|
| salary.basic.mode | 月工资基本工资口径 | ENUM INCLUDE\|DEDUCT\|EXCLUDE | BUSINESS_ROLE | 业务员 INCLUDE / 经理 DEDUCT | #8 |
|
||||||
|
| probation.annual.threshold | 试用期年累计结算阈值 | DECIMAL 元 | NONE | 18000 | #17 |
|
||||||
|
| probation.salary.basic | 试用期基本工资标准 | DECIMAL 元 | NONE | 3000 | 需规(归人员字段则撤,见开放问题) |
|
||||||
|
|
||||||
|
**不设参数**(作废条款的落地):经理个人户 8‰ 起始/斜率;协助 25% 分成;含运费口径开关。
|
||||||
|
|
||||||
|
**种子生效日期**(迁移与 init-dev 双写):`product.threshold`/`salesperson.*` = 2026-02-17;`enterprise.rate` = **2026-02-17 起 2‰ + 2026-08-13 起 3‰ 两行**;`manager.share.*`/`info.rate` = 2026-08-13;支付环节/预付款/以物抵顶/月工资口径 = 2026-01-01。
|
||||||
|
|
||||||
|
### 2.4 公式模板与角色绑定
|
||||||
|
|
||||||
|
**模板注册表**(`app/services/calc_templates.py`,代码内有限集合):
|
||||||
|
|
||||||
|
| 模板编码 | 名称 | 形态 | 声明参数 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| TPL_SALESPERSON_DECAY | 业务员个人户·天数线性递减 | 分段:≤分界 = 起始 − 天数×斜率;> 分界 = 下限 | salesperson.rate.* / days.boundary |
|
||||||
|
| TPL_MANAGER_SHARE | 经理·团队与全公司分成 | 聚合:∑本团队×12.5% + ∑全公司×5% | manager.share.* |
|
||||||
|
| TPL_ENTERPRISE_FIXED | 企业户·固定计提 | 乘法:折算毛利润额 × 比例 | enterprise.rate |
|
||||||
|
| TPL_INFO_SALES_FIXED | 信息员·销售额固定比例 | 乘法:本人销售额 × 比例 | info.rate |
|
||||||
|
| TPL_PREPAY_FIXED | 预付款·固定计提 | 乘法:回款额 × 比例 | prepay.rate |
|
||||||
|
|
||||||
|
修饰器(不绑角色,参数驱动):以物抵顶 ×80%、支付权重 R×R、起付 60%、试用期缓发。
|
||||||
|
|
||||||
|
**绑定模型**:`business_roles.calc_template` → 模板;角色级覆盖经 `dimension_type=BUSINESS_ROLE` 条目。新增岗位 = 新增行 + 选模板(零代码,满足 #28 ①)。
|
||||||
|
|
||||||
|
**消费接口契约**(④ 与预览共用,函数签名级):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/services/param_service.py
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParamSnapshot:
|
||||||
|
biz_date: date
|
||||||
|
def get(self, code, *, role_code=None, product_category=None) -> str: ... # 覆盖链解析后原始值
|
||||||
|
def fraction(self, code, **dims) -> Fraction: ...
|
||||||
|
def integer(self, code, **dims) -> int: ...
|
||||||
|
def boolean(self, code, **dims) -> bool: ...
|
||||||
|
def text(self, code, **dims) -> str: ...
|
||||||
|
|
||||||
|
def resolve_snapshot(session, biz_date: date) -> ParamSnapshot
|
||||||
|
def submit_change(session, payload, *, operator, meta=None) -> ParamChangeBatchOut
|
||||||
|
def compare(session, *, from_date: date, to_date: date) -> list[ParamDiffOut]
|
||||||
|
|
||||||
|
# app/services/formula.py(纯函数,预览与 ④ 共用;禁 float)
|
||||||
|
def payment_days(ship_date, receipt_date) -> int # 发货日首日=1
|
||||||
|
def salesperson_rate(days, *, start, slope, floor, boundary_days) -> Fraction
|
||||||
|
def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction # 不含运费;revenue=0 防除零
|
||||||
|
def converted_profit(revenue, rate, threshold) -> Decimal # 化简式:销售额 ×(毛利润率−阈值)
|
||||||
|
def performance_amount(base, rate) -> Decimal # 乘法方向(对照表 #3)
|
||||||
|
def apply_barter(amount, discount) -> Decimal
|
||||||
|
def payment_weight(r: int) -> Fraction # r×r
|
||||||
|
def start_payment_ok(received, revenue, ratio) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 文件清单
|
||||||
|
|
||||||
|
#### 后端
|
||||||
|
|
||||||
|
| 层 | 文件 | 内容 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| models | `app/models/business_role.py`、`app/models/param.py`、`app/models/__init__.py`(改) | 四张表;**必须注册进 `__init__`**(漏注册会让 autogenerate 生成删表迁移) |
|
||||||
|
| repositories | `app/repositories/business_role_repository.py`、`param_repository.py` | 只 flush;`resolve_values(session, biz_date)` 一条 SQL 取全量快照;批次撤回用 `for_update` 锁行 |
|
||||||
|
| schemas | `app/schemas/business_role.py`、`param.py` | Out/CreateIn/UpdateIn/StatusIn/Query、TemplateOptionOut;ParamDefinitionOut/SnapshotOut/ChangeIn/Out/BatchOut/BatchDetailOut/DiffOut/PreviewIn/Out |
|
||||||
|
| services | `app/services/param_registry.py`、`calc_templates.py`、`formula.py`、`business_role_service.py`、`param_service.py` | 参数目录注册表(种子唯一来源 + 运行期校验);模板注册表;纯函数;岗位 CRUD+保护规则;参数提交/历史/对比/预览/导出 |
|
||||||
|
| api | `app/api/v1/system/business_roles.py`、`params.py`、`app/api/router.py`(改) | 挂载 `/api/v1/system/business-roles`、`/api/v1/system/params`(AdminUser) |
|
||||||
|
| core | `app/core/constants.py`(改) | `MODULE_BUSINESS_ROLE`/`MODULE_PARAM`、内置岗位编码、产品类别码 |
|
||||||
|
| 迁移 | `alembic/versions/xxxx_*.py`、`db/migration/V3__add_business_roles_and_params.sql`、`db/init/init-dev.sql`(改)、`db/queries/计提参数.sql` | 四表 + 种子;双轨同步 |
|
||||||
|
| 依赖 | `backend/pyproject.toml`(改) | 增补 `openpyxl`(参数导出用;`uv add openpyxl`) |
|
||||||
|
|
||||||
|
**接口集合**:business-roles 列表/新增/详情/编辑/删除/启停 + `meta/templates` 模板下拉;params 目录快照(`asOf`)、单参数历史、变更批次提交/撤回/分页/明细、双日期对比、Excel 导出(`StreamingResponse`,非统一信封)、计算预览。写操作全走 `audit_service.audit()`;变更批次表与操作日志互为双保险。
|
||||||
|
|
||||||
|
#### 前端
|
||||||
|
|
||||||
|
| 类型 | 文件 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| api | `src/api/businessRoles.ts`、`src/api/params.ts` | params 的导出接口直接用 axios 实例(响应为 blob,非统一信封,文件内注明例外) |
|
||||||
|
| 页面 | `src/views/system/business-roles/index.vue` | 列表+表单+启停+删除(保护提示)+模板选择(展示模板声明参数) |
|
||||||
|
| 页面 | `src/views/system/params/index.vue` | 按 param_group 分组;生效方式(立即/指定日期)+备注;保存二次确认;版本管理抽屉(记录/明细/对比/导出);预览抽屉 |
|
||||||
|
| 路由 | `src/router/index.ts`(改) | `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`、`{ title: '计提参数', …, order: 6 }` |
|
||||||
|
| 常量 | `src/utils/constants.ts`(改) | MODULE_OPTIONS 增补;参数分组/单位展示映射 |
|
||||||
|
| 复用 | `usePagedList`/`useConfirmAction`/`AppPagination`/`StatusTag`/`--ys-*` | 不新增组件 |
|
||||||
|
|
||||||
|
### 2.6 任务序列(步骤级)
|
||||||
|
|
||||||
|
任务依赖:T1 → T2 → T3 → (T4 ∥ T5) → (T6 ∥ T7) → T8。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T1:迁移与模型(四张新表 + 种子)
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`backend/app/models/business_role.py`、`backend/app/models/param.py`
|
||||||
|
- 修改:`backend/app/models/__init__.py`
|
||||||
|
- 创建:`backend/alembic/versions/xxxx_add_business_roles_and_params.py`(autogenerate 后人工补种子)
|
||||||
|
- 创建:`db/migration/V3__add_business_roles_and_params.sql`
|
||||||
|
- 修改:`db/init/init-dev.sql`
|
||||||
|
- 创建:`db/queries/计提参数.sql`
|
||||||
|
|
||||||
|
- [ ] **步骤 1:写两个模型文件**
|
||||||
|
|
||||||
|
按 2.2 字段设计实现 `BusinessRole`、`ParamDefinition`、`ParamChangeBatch`、`ParamValue`。写法参照 `backend/app/models/role.py`(`Mapped`/`mapped_column`、`mysql_table_args("中文表注释")`、`BIGINT_UNSIGNED`、`TINYINT_TYPE`);`ParamChangeBatch`/`ParamValue` 不继承 `TimestampMixin`,仅手写 `create_time`(`server_default=text("CURRENT_TIMESTAMP")`);外键目标类型必须与 `users.id` 一致(`BIGINT_UNSIGNED`)。
|
||||||
|
|
||||||
|
- [ ] **步骤 2:注册进 `models/__init__.py`**
|
||||||
|
|
||||||
|
四个类加入导入与 `__all__`(漏注册会让 autogenerate 把新表当成待删除)。
|
||||||
|
|
||||||
|
- [ ] **步骤 3:生成迁移并补种子**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run alembic revision --autogenerate -m "新增岗位角色与计提参数四表"
|
||||||
|
```
|
||||||
|
|
||||||
|
人工检查:表/列中文 COMMENT、`uk_`/`idx_`/`fk_` 命名、`ON UPDATE CURRENT_TIMESTAMP` 子句;然后按 2.2/2.3 用 `op.bulk_insert` 或 `op.execute` 补种子:3 个内置岗位、16 项参数定义、版本条目(`enterprise.rate` 两行:2026-02-17=2、2026-08-13=3;其余按 2.3 生效日期表;维度行展开——`product.threshold` 两行市政/轨交、`salary.basic.mode` 两行业务员/经理)。种子条目的 `batch_id` 需要一条「初始版本」批次(`version_no` 如 `P20260101-00`、`remark` 初始版本)。
|
||||||
|
|
||||||
|
- [ ] **步骤 4:应用迁移并导出 SQL**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run alembic upgrade head
|
||||||
|
uv run alembic upgrade <V2_revision>:head --sql > ../db/migration/V3__add_business_roles_and_params.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
(V2 revision 从 `alembic/versions/` 中操作日志迁移文件读取。)
|
||||||
|
|
||||||
|
- [ ] **步骤 5:同步 `db/init/init-dev.sql`**
|
||||||
|
|
||||||
|
追加四表 DDL(与迁移产出一致)+ 种子 INSERT + 更新末尾 `alembic_version` 为 V3 revision id。
|
||||||
|
|
||||||
|
- [ ] **步骤 6:写 `db/queries/计提参数.sql`**
|
||||||
|
|
||||||
|
留存解析查询(时点化取值:`WHERE effective_date <= :biz_date ORDER BY effective_date DESC, id DESC` 取每组最新)与对比查询。
|
||||||
|
|
||||||
|
- [ ] **步骤 7:验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:全部 PASS(schema 双轨无漂移、新表规范断言通过)。
|
||||||
|
|
||||||
|
- [ ] **步骤 8:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/models backend/alembic db/init/init-dev.sql db/migration db/queries
|
||||||
|
git commit -m "feat(system): 新增岗位角色与计提参数四表及 V3 迁移"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T2:参数注册表 + 公式纯函数
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`backend/app/services/param_registry.py`、`backend/app/services/calc_templates.py`、`backend/app/services/formula.py`
|
||||||
|
- 修改:`backend/app/core/constants.py`(内置岗位编码、产品类别码常量)
|
||||||
|
- 测试:`backend/tests/unit/test_formula.py`、`backend/tests/unit/test_param_registry.py`
|
||||||
|
|
||||||
|
- [ ] **步骤 1:编写失败的测试(公式,算钱核心)**
|
||||||
|
|
||||||
|
代表性用例(全部精确分数断言,禁近似):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_业务员360天_比例恰为6‰():
|
||||||
|
rate = salesperson_rate(360, start=Fraction(10), slope=Fraction(4, 360),
|
||||||
|
floor=Fraction(4), boundary_days=360)
|
||||||
|
assert rate == Fraction(6, 1000)
|
||||||
|
|
||||||
|
def test_业务员361天_固定下限4‰_分界线():
|
||||||
|
rate = salesperson_rate(361, start=Fraction(10), slope=Fraction(4, 360),
|
||||||
|
floor=Fraction(4), boundary_days=360)
|
||||||
|
assert rate == Fraction(4, 1000)
|
||||||
|
|
||||||
|
def test_业务员第1天_按公式为899比90000_精确分数():
|
||||||
|
rate = salesperson_rate(1, start=Fraction(10), slope=Fraction(4, 360),
|
||||||
|
floor=Fraction(4), boundary_days=360)
|
||||||
|
assert rate == Fraction(10) - Fraction(1, 90) # 899/90 ‰
|
||||||
|
|
||||||
|
def test_回款天数_发货日首日为第1天_12月15日到1月10日为27天():
|
||||||
|
assert payment_days(date(2025, 12, 15), date(2026, 1, 10)) == 27
|
||||||
|
|
||||||
|
def test_回款天数_同日回款为1天():
|
||||||
|
d = date(2026, 1, 1)
|
||||||
|
assert payment_days(d, d) == 1
|
||||||
|
|
||||||
|
def test_毛利润额为零_折算额与绩效均为零_不除零():
|
||||||
|
assert converted_profit(Decimal("0"), Fraction(0), Fraction(3, 10)) == Decimal("0")
|
||||||
|
|
||||||
|
def test_毛利润率低于阈值_折算为负_绩效负值不归零():
|
||||||
|
# 销售额 100、毛利润率 10%、阈值 30% → 折算 -20
|
||||||
|
assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(3, 10)) == Decimal("-20")
|
||||||
|
assert performance_amount(Decimal("-20"), Fraction(6, 1000)) == Decimal("-0.12")
|
||||||
|
|
||||||
|
def test_支付权重_R为60时权重0点36():
|
||||||
|
assert payment_weight(60) == Fraction(36, 100)
|
||||||
|
|
||||||
|
def test_注册表_16项_编码唯一_必填字段非空():
|
||||||
|
assert len(PARAM_REGISTRY) == 16
|
||||||
|
assert len({s.code for s in PARAM_REGISTRY.values()}) == 16
|
||||||
|
# 每项 name/group/value_type 非空
|
||||||
|
|
||||||
|
def test_解析分数_4除以360_按Fraction不丢精度():
|
||||||
|
assert parse_fraction("4/360") == Fraction(1, 90)
|
||||||
|
|
||||||
|
def test_模板注册表_五个模板_声明参数均在注册表内():
|
||||||
|
# TEMPLATES 的 params 引用必须全部存在于 PARAM_REGISTRY(跨表一致性)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **步骤 2:运行确认失败**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run pytest tests/unit/test_formula.py tests/unit/test_param_registry.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:FAIL(`ModuleNotFoundError` / `NameError`)。
|
||||||
|
|
||||||
|
- [ ] **步骤 3:实现三个 service 文件**
|
||||||
|
|
||||||
|
`formula.py`:8 个纯函数(签名见 2.4 契约)。要点:`salesperson_rate` 中 `days <= boundary_days` 用线性式、否则取 `floor`;`converted_profit` 用化简式 `revenue × (rate − threshold)`;`performance_amount` 为乘法;`payment_days` 为 `(receipt_date - ship_date).days + 1`。
|
||||||
|
|
||||||
|
`param_registry.py`:`ParamSpec` 冻结 dataclass + `PARAM_REGISTRY`(16 项,值与 2.3 种子一致)+ `get_spec`(未注册编码抛 500 级异常,属代码 bug)+ `parse_fraction`(`Fraction(text)`,捕获 `ZeroDivisionError`/`ValueError` → 400)+ `validate_and_normalize`(类型/取值域/枚举校验,返回规范串)。
|
||||||
|
|
||||||
|
`calc_templates.py`:`TemplateSpec` + `TEMPLATES`(5 个模板,见 2.4)+ `get_template`/`required_params`。
|
||||||
|
|
||||||
|
- [ ] **步骤 4:运行确认通过**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run pytest tests/unit -q
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:全部 PASS(含既有单元基线)。
|
||||||
|
|
||||||
|
- [ ] **步骤 5:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/services/formula.py backend/app/services/param_registry.py backend/app/services/calc_templates.py backend/app/core/constants.py backend/tests/unit/test_formula.py backend/tests/unit/test_param_registry.py
|
||||||
|
git commit -m "feat(system): 计提公式模板注册表与精确分数计算函数"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T3:参数仓储 + 取值服务(时点化解析)
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`backend/app/repositories/param_repository.py`
|
||||||
|
- 创建:`backend/app/services/param_service.py`(先实现 `ParamSnapshot`/`resolve_snapshot` 与覆盖链选择)
|
||||||
|
- 测试:`backend/tests/unit/test_param_snapshot.py`(覆盖链纯逻辑;真实 DB 解析在 T8 统一集成测试)
|
||||||
|
|
||||||
|
- [ ] **步骤 1:编写失败的测试(覆盖链与兜底)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_覆盖链_角色维度优先于产品类别与默认():
|
||||||
|
# rows: (code, dimension) 含 ('enterprise.rate', ''), ('enterprise.rate', 'municipal_water')
|
||||||
|
# 取 role_code=None, product_category='municipal_water' → 返回产品类别行
|
||||||
|
|
||||||
|
def test_未定义参数_按注册表默认值兜底():
|
||||||
|
# 某参数无任何版本条目 → snapshot.fraction(code) == 注册表 default_value 的 Fraction
|
||||||
|
|
||||||
|
def test_同日多次变更_取id最大的一条():
|
||||||
|
# 同日两行 → 取 id 大者
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **步骤 2:运行确认失败**(`uv run pytest tests/unit/test_param_snapshot.py -q`,预期 FAIL)
|
||||||
|
|
||||||
|
- [ ] **步骤 3:实现**
|
||||||
|
|
||||||
|
`param_repository.py`:`resolve_values(session, biz_date)` 一条 SQL 取全量快照(按 `(param_id, dimension_value)` 分组取 `effective_date DESC, id DESC` 首行;可用窗口函数或子查询,SQL 留存 `db/queries/计提参数.sql`);批次 CRUD 与 `for_update` 加锁函数(签名参照 2.5 与设计报告)。
|
||||||
|
|
||||||
|
`param_service.py`:`ParamSnapshot`(frozen dataclass,持有解析后 `dict[(code, dim)] → value` 与 `biz_date`)+ `resolve_snapshot(session, biz_date)`(读定义 + 读条目 + 合并注册表默认值);`get/fraction/integer/boolean/text` 五类取值(覆盖链:role_code 维度 > product_category 维度 > 默认行;`fraction` 走 `parse_fraction`)。
|
||||||
|
|
||||||
|
- [ ] **步骤 4:运行确认通过**(`uv run pytest tests/unit -q`,预期 PASS)
|
||||||
|
|
||||||
|
- [ ] **步骤 5:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/repositories/param_repository.py backend/app/services/param_service.py backend/tests/unit/test_param_snapshot.py
|
||||||
|
git commit -m "feat(system): 计提参数按业务日期时点化取值服务"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T4:岗位角色 CRUD 后端(含审计与保护规则)
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`backend/app/repositories/business_role_repository.py`、`backend/app/schemas/business_role.py`、`backend/app/services/business_role_service.py`、`backend/app/api/v1/system/business_roles.py`
|
||||||
|
- 修改:`backend/app/api/router.py`、`backend/app/core/constants.py`(MODULE_BUSINESS_ROLE)
|
||||||
|
|
||||||
|
- [ ] **步骤 1:实现四层文件**
|
||||||
|
|
||||||
|
整体参照 `roles` 模块(`role_repository.py`/`role_service.py`/`api/v1/system/roles.py`)的写法与保护规则模式。接口:列表(分页+关键字+状态)、新增、详情、编辑、删除、启停、`GET /meta/templates` 模板下拉。保护规则:`is_builtin` 禁删禁停;`role_code` 创建后不可改;`calc_template` 必须存在于 `TEMPLATES`(否则 400);删除前检查人员引用(② 落地前以引用计数钩子预留,接入后生效);写操作全部包 `audit_service.audit()`。
|
||||||
|
|
||||||
|
- [ ] **步骤 2:冒烟验证(容器 + 后端在跑)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run uvicorn app.main:app --reload --port 8000
|
||||||
|
# 另一终端:curl 登录取 token 后依次打 7 个端点;重点验证:
|
||||||
|
# - 新增自定义岗位(绑 TPL_ENTERPRISE_FIXED)→ 201/200
|
||||||
|
# - 删除内置 salesperson → 403;删除自定义岗位 → 成功
|
||||||
|
# - calc_template 传不存在的编码 → 400
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **步骤 3:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/repositories/business_role_repository.py backend/app/schemas/business_role.py backend/app/services/business_role_service.py backend/app/api/v1/system/business_roles.py backend/app/api/router.py backend/app/core/constants.py
|
||||||
|
git commit -m "feat(system): 业务岗位角色管理接口与保护规则"
|
||||||
|
```
|
||||||
|
|
||||||
|
(模块级正式用例按项目约定在 T8 统一编写。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T5:参数变更/历史/对比/导出/预览后端
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`backend/app/schemas/param.py`、`backend/app/api/v1/system/params.py`
|
||||||
|
- 修改:`backend/app/services/param_service.py`(提交/撤回/历史/对比/预览/导出)、`backend/app/api/router.py`、`backend/app/core/constants.py`(MODULE_PARAM)、`backend/pyproject.toml`(`uv add openpyxl`)
|
||||||
|
|
||||||
|
- [ ] **步骤 1:加依赖**:`cd backend && uv add openpyxl`(参数导出用)
|
||||||
|
|
||||||
|
- [ ] **步骤 2:实现 schemas 与 service 扩展**
|
||||||
|
|
||||||
|
`submit_change`:事务内校验全部 items(`validate_and_normalize`)→ 建批次(`version_no` 规则 `P{YYYYMMDD}-{当日序号}`)→ 逐项写 `param_values`(`old_value_text` 取当前生效值)→ `item_count` → commit。`withdraw_batch`:已生效(`effective_date <= today`)禁撤(400);`for_update` 锁批次行后删条目与批次。`compare`:两日期各取快照后逐项 diff。`preview`:入参(产品类别/客户类型/岗位/发货日/回款日/回款金额/销售额/毛利润额/是否以物抵顶/是否试用期)→ 输出(回款天数/计提比例/折算毛利润额/单笔绩效/起付判定/R×R 结果/所用参数生效版本)。`export_changes`:openpyxl 流式写变更记录(列:变更人/时间/参数/旧值/新值/生效日期/批次号)。
|
||||||
|
|
||||||
|
- [ ] **步骤 3:实现 api 层**
|
||||||
|
|
||||||
|
9 个端点(目录快照/历史/提交/撤回/批次分页/批次明细/对比/导出/预览);写操作包审计;导出返回 `StreamingResponse`(非统一信封,前端 api 层注明)。
|
||||||
|
|
||||||
|
- [ ] **步骤 4:冒烟验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 提交一次变更(3 项参数,指定未来日期)→ 当前快照不变
|
||||||
|
# 到该日期(或提交立即生效)→ 快照变化、历史出现记录
|
||||||
|
# 撤回未生效批次 → 快照回退;撤回已生效批次 → 400
|
||||||
|
# 预览:360 天/361 天边界、12-15→01-10=27 天精确复现
|
||||||
|
# 导出 → xlsx 可打开且列齐全
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **步骤 5:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/schemas/param.py backend/app/services/param_service.py backend/app/api/v1/system/params.py backend/app/api/router.py backend/app/core/constants.py backend/pyproject.toml backend/uv.lock
|
||||||
|
git commit -m "feat(system): 计提参数版本管理、变更留痕与计算预览接口"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T6:前端岗位角色页
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`frontend/src/api/businessRoles.ts`、`frontend/src/views/system/business-roles/index.vue`
|
||||||
|
- 修改:`frontend/src/router/index.ts`、`frontend/src/utils/constants.ts`
|
||||||
|
|
||||||
|
- [ ] **步骤 1:api 封装 + 页面**
|
||||||
|
|
||||||
|
页面整体复用用户管理页模式(`usePagedList` + `useConfirmAction` + `AppPagination` + `StatusTag`);表单含模板下拉(选中后展示模板声明的参数清单与当前值);内置岗位删除/停用按钮禁用并给 title 提示;路由 meta `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`。
|
||||||
|
|
||||||
|
- [ ] **步骤 2:验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && pnpm type-check && pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:类型检查与构建通过。
|
||||||
|
|
||||||
|
- [ ] **步骤 3:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/api/businessRoles.ts frontend/src/views/system/business-roles frontend/src/router/index.ts frontend/src/utils/constants.ts
|
||||||
|
git commit -m "feat(system): 前端岗位角色管理页面"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T7:前端参数配置页(含版本管理抽屉与预览)
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`frontend/src/api/params.ts`、`frontend/src/views/system/params/index.vue`
|
||||||
|
- 修改:`frontend/src/router/index.ts`、`frontend/src/utils/constants.ts`
|
||||||
|
|
||||||
|
- [ ] **步骤 1:api 封装**(导出接口用 axios 实例取 blob,注明非统一信封例外)
|
||||||
|
|
||||||
|
- [ ] **步骤 2:页面实现**
|
||||||
|
|
||||||
|
按 `param_group` 分组展示参数(名称/当前值+单位/生效日期/口径说明);顶部「生效方式(立即/指定日期)+ 生效日期 + 备注」;保存前二次确认(列出改动项与旧值→新值);版本管理抽屉(变更记录分页 + 批次明细 + 两日期对比 + 导出下载);预览抽屉(表单 → 结果卡,注明「斜率精确值 4/360」);路由 meta order 6。
|
||||||
|
|
||||||
|
- [ ] **步骤 3:验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && pnpm type-check
|
||||||
|
# e2e(需容器+前后端在跑):改一个参数 → 变更记录出现 → 预览值变化 → 导出可下载
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **步骤 4:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/api/params.ts frontend/src/views/system/params frontend/src/router/index.ts frontend/src/utils/constants.ts
|
||||||
|
git commit -m "feat(system): 前端计提参数配置与版本管理页面"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T8:模块测试统一编写与文档更新
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`backend/tests/unit/test_param_service.py`(可 fakeredis 化的部分)、`backend/tests/integration/test_business_roles.py`、`backend/tests/integration/test_param_management.py`
|
||||||
|
- 修改:`backend/tests/README.md`、`frontend/README.md`、`backend/README.md`(接口清单)、`frontend/e2e/system_management.py` 或新增 `frontend/e2e/param_management.py`
|
||||||
|
|
||||||
|
- [ ] **步骤 1:编写集成用例**(覆盖审查重点 1/2/3)
|
||||||
|
|
||||||
|
参数精度专项(Fraction/1/90 全链路:注册表 → 种子 → 解析 → 预览一致);未来生效不泄漏;撤回回退;同日多次变更取最新;审计留痕(成功与失败);权限(普通用户 403);岗位保护规则(内置禁删、模板校验)。
|
||||||
|
|
||||||
|
- [ ] **步骤 2:全量回归**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:301 基线 + 新增用例全绿。
|
||||||
|
|
||||||
|
- [ ] **步骤 3:e2e 与文档**
|
||||||
|
|
||||||
|
前端 e2e 步骤(菜单出现两个新页、改参数→记录→预览链路);更新三份 README(用例清单/运行记录/发现缺陷/待补用例、接口清单)。
|
||||||
|
|
||||||
|
- [ ] **步骤 4:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/tests frontend/e2e backend/README.md frontend/README.md
|
||||||
|
git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.7 风险与开放问题
|
||||||
|
|
||||||
|
#### 风险(已定对策)
|
||||||
|
|
||||||
|
1. 参数缓存:一期不做(见 2.1-4);若未来启用必须按批次版本号精确失效,禁通配删除;
|
||||||
|
2. 同日多次变更:UI 明示「同日多次变更按最新一条生效」;
|
||||||
|
3. `4/360` 展示:存储分数、展示格式化(预览页注明「精确值 4/360,非截断 0.011111」);
|
||||||
|
4. 模板扩展摩擦:新公式形态仍需加模板(一期接受,与「不引入规则引擎」一致);岗位表单把模板做成带说明的受控下拉;
|
||||||
|
5. 报表字段前移:预览出参尽量与需规 2.4.1 明细列同名,减少 ④ 映射工作;
|
||||||
|
6. 参数导出用 openpyxl:已在 T5 增补依赖(AI_CONTEXT 已认可方向)。
|
||||||
|
|
||||||
|
#### 开放问题(不阻塞首块开工,逐项有默认处理)
|
||||||
|
|
||||||
|
| # | 问题 | 默认处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 第 1 天计提:需规图示「第 1 天 10‰」vs 公式 `10 − 4/360 ≈ 9.9889‰` | 按公式实现(上限为参考);起始值参数化,业务如需 10‰ 可在系统设置调整 |
|
||||||
|
| 2 | 企业户 02-17 前历史(2‰ 是否回溯) | 已按双种子(02-17=2‰、08-13=3‰)实现;如无需回溯可撤首行 |
|
||||||
|
| 3 | `payment.weight.mode` BALANCE 模式语义(余额口径) | 首块只存枚举;语义在 ④ 开工前确认 |
|
||||||
|
| 4 | `probation.salary.basic`(3000 元)是参数还是人员工资标准字段 | 暂列参数;② 开工时如归人员字段则移除 |
|
||||||
|
| 5 | 参数查询权限(普通用户可否只读快照) | 建议「查看可、修改仅管理员」,实现为 AdminUser 时可放宽——待确认 |
|
||||||
|
| 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 |
|
||||||
|
| 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) |
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
|
||||||
|
2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose);
|
||||||
|
3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测);
|
||||||
|
4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量 301+ 用例与 e2e 全绿。
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# M2 实施计划:基础数据管理(②)+ 业务数据管理(③)
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
- 需求 28 项裁决已落定(2026-10-12),M1(系统设置与参数体系)已完成并推送 `feat/system-params`。按剩余开发计划,M2 = ②基础数据 + ③业务数据 **合并交付**,一次表结构评审定稿 T+ 预留列与报表字段清单(即本计划 A 节)。
|
||||||
|
- 目标:三类基础数据(人员/产品/客户)+ 四类业务数据(业务/发货/回款/评级)全流程可维护、可留痕,为 M3 绩效核算供数。
|
||||||
|
- 设计调研已完成并抽查核对(`models/base.py` 命名约定、`formula.py` 纯函数、`test_models.py` 白名单、迁移现状 V3 均与设计一致)。
|
||||||
|
|
||||||
|
## 开工前处置
|
||||||
|
|
||||||
|
1. 当前 `feat/system-params` 工作区有未提交内容(2 个已删除 docs 文件、若干未跟踪的测试/文档)——先提交或与用户确认处置。
|
||||||
|
2. 从 `feat/system-params` 切出 `feat/base-business-data`(M2 外键依赖 M1 的 `business_roles`/参数体系)。
|
||||||
|
|
||||||
|
## A. 表结构(V4 基础数据 6 表 + V5 业务数据 7 表)
|
||||||
|
|
||||||
|
公共约定(照 `models/base.py` 现状):表名小写蛇形复数;`NAMING_CONVENTION` 自动产出 `uk_/idx_/fk_`;`mysql_table_args("中文表注释")`;可编辑表继承 `TimestampMixin`;**append-only 留痕表不继承**(仅手写 `create_time`,`login_logs` 先例)。金额 `DECIMAL(14,2)`、单价 `(14,4)`,schema 入出参一律 `str`,服务层 `Decimal`/`Fraction`。
|
||||||
|
|
||||||
|
### T+ 预留四列(覆盖 6 张主表:personnel/products/customers/businesses/shipments/receipts)
|
||||||
|
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `source_no` | VARCHAR(64) NULL | T+ 原始单据号,普通索引(幂等去重锚点;唯一键待 IT 定键后小迁移升级) |
|
||||||
|
| `t_plus_id` | VARCHAR(64) NULL | T+ 内部标识 |
|
||||||
|
| `sync_batch_no` | VARCHAR(32) NULL | 同步批次号(M4 写入) |
|
||||||
|
| `synced_at` | DATETIME NULL | 最近同步时间 |
|
||||||
|
|
||||||
|
全部可空、不设唯一键(对照表 #26 键语义未定)。`personnel_plans` 仅 `source_no` 单列;明细行表不加(随主表同步)。**「同步不覆盖人工字段」写入表注释**:`business_items.guided_price`、各归属列、`personnel.business_role_id`/`salary_standard`、`customers.customer_type`、`businesses.status`。
|
||||||
|
|
||||||
|
### V4 六表
|
||||||
|
|
||||||
|
1. **`personnel`** 人员表:`personnel_name`、`business_role_id`(FK→business_roles RESTRICT,同步不覆盖)、`manager_id`(FK→personnel 自引用,NULL=经理本人/未分配,支撑「本团队业务员」)、`phone`、`hire_date`、`is_probation`(试用期照算不发放,裁决 #17)、`salary_standard DECIMAL(12,2)`、`status`(1在职 0离职)、T+4 列、`remark`。不与 `users` 关联(一期营销人员不登录)。
|
||||||
|
2. **`personnel_regions`** 产品辐射区域:`personnel_id` FK、`region_name`、`remark`(1:N 子表,随人员维护)。
|
||||||
|
3. **`personnel_plans`** 市场拓展计划:`personnel_id` FK、`plan_period`、`plan_content`、`target_desc`、`source_no`。
|
||||||
|
4. **`products`** 产品表:`product_name`、`spec_model NOT NULL DEFAULT ''`(空串参与唯一键,对齐 MySQL 组合索引 NULL 不去重先例)、`unit`、`stock_quantity`(T+ 语义待确认,原样存储)、`sale_price`、`product_category`(`municipal_water`/`rail_transit`,**与 M1 `product.threshold` 参数维度同一值域常量 `PRODUCT_CATEGORIES`**,阈值由 M1 参数承担不建列)、`status`、T+4 列、`remark`。`uk_products_name_spec(product_name, spec_model)`。
|
||||||
|
5. **`customers`** 客户表:`company_name`、`owner_name`(客户负责人**人名字符串**,非系统人员引用)、`contact_person`、`contact_phone`、`address`、`industry`、`customer_type`(`personal` 个人户 / `manager_personal` 经理个人户 / `enterprise` 企业户,计提规则匹配锚点,同步不覆盖)、`owner_personnel_id` FK(归属,变更不回溯,裁决 #19)、T+4 列、`remark`。`uk_customers_company_owner(company_name, owner_name)`。
|
||||||
|
6. **`ownership_change_logs`** 归属变更留痕(**append-only**):`owner_type`(1客户 2业务)、`customer_id`/`business_id` 可空 FK、`old/new_owner_personnel_id`、`old/new_assistant_personnel_id`(冗余固化防人员删除后不可读)、`change_reason`、`operator_id`(FK→users SET NULL)、`operator_name`、`create_time`。独立表而非 JSON:可分页/join 人员/导出,与 M1 `param_values` 同一哲学。
|
||||||
|
|
||||||
|
### V5 七表
|
||||||
|
|
||||||
|
7. **`businesses`** 业务表:`business_no`(自动生成 `YW{YYYYMMDD}-{4位序号}`,即报表「合同编号」,uk)、`customer_id` FK、`business_date`、`status`(1执行中 2已完成 3已终止,终态不可变更)、`owner_personnel_id`(可空=待指定,M3 核算前置校验非空)、`assistant_personnel_id`(一期核算不使用,裁决 #15 剔除协助 25%,仅记录事实)、`is_special`/`special_note`、**五个汇总冗余列** `total_sale_amount`/`total_cost_amount`/`total_ship_amount`/`total_received_amount`/`total_freight_amount`(服务层事务内单点重算,禁手工直改;运费不计毛利润口径,裁决 #2)、T+4 列、`remark`。
|
||||||
|
8. **`business_items`** 业务产品明细:`business_id` FK、`product_id` 可空 FK(可空=自由录入,配合「数据提取」)、`product_name`/`spec_model`/`unit` 冗余固化(防产品改名影响历史)、`product_category`(**行级折算锚点**,混合业务按产品逐条算,裁决 #14)、`quantity`、`sale_price`、`sale_amount`(服务层按 数量×单价 固化)、`cost_amount`(可空,来源待 T+ 确认)、`guided_price`(人工录入,同步不覆盖)、`remark`。**不建** `profit_amount`/`profit_rate`/`converted_profit` 落库列——派生值落库会在成本调整/参数改版后漂移,M2 以只读毛利润视图出数,M3 台账才是固化点。
|
||||||
|
9. **`business_status_logs`** 状态流转留痕(**append-only**):`business_id` FK、`old_status`、`new_status`、`change_reason`、`operator_id`/`operator_name`、`create_time`。
|
||||||
|
10. **`shipments`** 发货单:`shipment_no`(`SH{YYYYMMDD}-{序号}`,uk)、`business_id` FK、`ship_date`(**回款天数基准日,发货日首日=第 1 天**,裁决 #1)、`freight_amount`(仅记录展示)、`total_amount`(明细汇总冗余)、T+4 列、`remark`。
|
||||||
|
11. **`shipment_items`** 发货明细:`shipment_id` FK、`product_id` 可空、`product_name`/`spec_model`/`unit` 冗余、`quantity`、`sale_price`、`amount`。
|
||||||
|
12. **`receipts`** 回款表:`receipt_batch_no`(`HK{YYYYMMDD}-{序号}`,uk)、`business_id` FK、`shipment_id` **可空** FK(**手动指定发货批次**,裁决 #18;须与 business_id 同属一个业务,服务层强校验;指定后回款天数方可计算)、`receipt_date`(M3 按此日匹配参数版本)、`amount`(≥0,一期禁负数)、`is_barter`(以物抵顶标记,M3 按 80% 折算,M2 仅标记)、`settlement_status`(0未核算 1已核算,**M3 防重锚点**,预置避免 M3 反向迁移)、T+4 列、`remark`。
|
||||||
|
13. **`customer_rating_grades`** 评级等级配置:`grade_name` uk、`min_score`/`max_score DECIMAL(5,1)`、`sort_order`(判定=按序首个 min_score ≤ 总分,避免区间边界歧义)、`remark`。种子 4 条(迁移 + init-dev 双写):A 优秀 90-100、B 良好 75-89、C 合格 60-74、D 待改进 0-59。评分项固定 5 项 × 20 分为前端常量(#25:仅打分工具,不影响绩效金额)。
|
||||||
|
|
||||||
|
### 编号生成规则(`core/constants.py` 集中前缀常量)
|
||||||
|
|
||||||
|
`{前缀}{YYYYMMDD}-{当日4位序号}`,序号按自然日重置。服务层 `SELECT MAX(no) ... FOR UPDATE` 后 +1;撞 uk(1062)兜底重试一次后仍失败返回 400(沿用 M1 code-review 修复过的并发兜底模式)。
|
||||||
|
|
||||||
|
### 客户分类 × M1 参数对应(M3 消费契约)
|
||||||
|
|
||||||
|
| customer_type | 核算规则 | M1 参数 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `personal` | `10‰ − 天数×4/360‰`,361 天起 4‰ | `salesperson.rate.*`/`days.boundary` |
|
||||||
|
| `manager_personal` | 归经理本人,12.5%+5% 结构 | `manager.share.*` |
|
||||||
|
| `enterprise` | 固定计提(2‰/3‰ 按日期) | `enterprise.rate`(与产品类别交叉) |
|
||||||
|
|
||||||
|
## B. 接口与前端
|
||||||
|
|
||||||
|
- 新增 `app/api/v1/base/`(`/api/v1/base`,人员/产品/客户)与 `app/api/v1/business/`(`/api/v1/business`,业务/发货/回款/评级),与 `system/*` 平级。约 30 个端点。
|
||||||
|
- **权限**:全部登录即可(`get_current_user`,不套 `AdminUser`)——M2 是业务操作(需规 2.5.1「普通用户」),M1 的 `AdminUser` 仅用于 system/*。写操作全部 `audit_service.audit()` 留痕。
|
||||||
|
- 关键端点:人员 CRUD+区域/计划全量替换(**clear 后立即 flush**,`replace_roles` 先例坑);产品 CRUD+`POST /products/extract`(业务明细未入库产品按名称+规格批量建产品,幂等);客户归属变更(事务内更新+插留痕);业务 CRUD(明细行数组、汇总重算 `_recalc_totals`、状态机 1→2/3 终态 400、毛利润视图 `GET /businesses/{id}/gross-profit?asOf=`);发货/回款 CRUD(回款跨业务指定批次 400、已核算禁改禁删);评级等级 CRUD(区间不重叠校验)。
|
||||||
|
- 关键校验:删除保护三类(被引用→400 引导离职/停用/改终止);金额 ≥0;`quantity>0`;销售额 0 防除零(`formula.profit_rate` 已内置)。
|
||||||
|
- 前端 7 页(复用 `usePagedList`/`useConfirmAction`/`AppPagination`/`StatusTag`/`--ys-*` token,不新增基础组件):`/base/personnel`(编辑抽屉内子表编辑区域/计划)、`/base/products`(类别下拉展示当前阈值、提取产品按钮)、`/base/customers`(归属变更二次确认文案含「不回溯历史」)、`/business/businesses`(明细行编辑器、详情抽屉含状态时间线/毛利润卡片)、`/business/shipments`、`/business/receipts`(选业务级联加载该业务发货单,可清空)、`/business/rating`(5 项评分实时出等级+映射维护抽屉,标注「仅打分工具」)。菜单新增「基础数据」「业务数据」两组,均不设 `requiresAdmin`。api 封装 7 个文件(蛇形字段直出,同 `businessRoles.ts` 模式)。金额展示字符串直出,禁 float 格式化。
|
||||||
|
|
||||||
|
## C. 任务分解(T1–T15)
|
||||||
|
|
||||||
|
依赖链:`T1 → (T2∥T3∥T4) → T5 → T6 → T7 → T8 → (T9∥T10) → T11 → (T12∥T13∥T14) → T15`。每任务「实现 → 测试/冒烟 → commit(`feat(scope): 中文说明`)」。
|
||||||
|
|
||||||
|
| # | 任务 | 要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T1 | V4 迁移 + 基础数据模型 | `models/personnel.py`/`product.py`/`customer.py`;**注册进 `models/__init__.py`(漏注册会生成删表迁移)**;`test_models.py` EXPECTED_TABLES +6;autogenerate → 人工核对 → 导出 `db/migration/V4__*.sql`;同步 `init-dev.sql`(含 alembic_version 标记);`db/queries/基础数据.sql` 留档 |
|
||||||
|
| T2 | 人员管理后端 | schemas/repositories/services/api 四层;岗位存在且启用、manager 非自己且在职、删除保护→提示改离职、区域/计划全量替换 clear 后立即 flush |
|
||||||
|
| T3 | 产品管理后端 | 类别校验复用 `PRODUCT_CATEGORIES`;1062→400;删除保护;`extract` 幂等 |
|
||||||
|
| T4 | 客户管理后端 | 公司+负责人唯一;归属变更(更新+留痕同事务);留痕分页查询 |
|
||||||
|
| T5 | 基础数据集成测试(统一编写点) | `test_personnel.py`/`test_products.py`/`test_customers.py`:唯一冲突、删除保护、留痕内容、未登录 401、审计双记录、非法枚举;更新 `backend/tests/README.md` |
|
||||||
|
| T6 | V5 迁移 + 业务数据模型 | `models/business.py`/`shipment.py`/`receipt.py`/`rating.py`;EXPECTED_TABLES +7;等级种子 4 条双写;`db/migration/V5__*.sql` + `init-dev.sql` + `db/queries/业务数据.sql` |
|
||||||
|
| T7 | 业务管理后端 | 编号生成;明细全量替换+五项汇总重算;状态机终态 400;归属留痕;毛利润视图(`resolve_snapshot` + `formula.profit_rate`/`converted_profit`,asOf 参数) |
|
||||||
|
| T8 | 发货管理后端 | 金额/运费汇总回写业务冗余列;终态业务禁新发货;被回款指定禁删 |
|
||||||
|
| T9 | 回款管理后端 | 批次号;**shipment_id 同业务强校验**;已核算禁改禁删;合计回写;DTO 出 `paymentDays`(`formula.payment_days`) |
|
||||||
|
| T10 | 评级等级配置后端 | 区间不重叠/0≤min≤max≤100 校验;名称唯一 |
|
||||||
|
| T11 | 业务数据集成测试(统一编写点) | 编号格式与序号递增、终态拦截、三处汇总冗余一致性、跨业务 400、已核算禁改、毛利润视图(27 天回款天数/防除零/取参)、审计、401;**全量 pytest 回归** |
|
||||||
|
| T12 | 前端基础数据三页 | 人员/产品/客户;归属变更确认文案含「不回溯历史」;`pnpm type-check && pnpm build` |
|
||||||
|
| T13 | 前端业务/发货/回款三页 | 明细行编辑器、详情抽屉、回款级联指定批次、金额字符串直出 |
|
||||||
|
| T14 | 前端评级计算器页 | 5 项评分→实时等级;映射维护抽屉;「仅打分工具」标注 |
|
||||||
|
| T15 | e2e 与文档收尾 | `e2e/base_data.py`/`business_data.py` 全链路(建数据→业务→发货→回款→回款天数→毛利润→评级);全量回归(后端基线 406+ 新增全绿、前端构建、e2e);三份 README 更新 |
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
- T1/T6:`uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q` 全绿(双轨零漂移是硬关口)。
|
||||||
|
- T5/T11:模块集成用例统一编写并执行;T11 后 `uv run pytest -q` 全量回归。
|
||||||
|
- T12–T14:`pnpm type-check && pnpm build`。
|
||||||
|
- T15:后端全量 pytest + 前端构建 + 两个 e2e 脚本全过;`ai-aux/AI_CONTEXT.md` 历史任务摘要由主会话收尾时更新。
|
||||||
|
|
||||||
|
## 风险与开放问题(默认处理,不阻塞开工)
|
||||||
|
|
||||||
|
1. 毛利润成本来源待 T+:`cost_amount` 可空+人工可录先行;视图中成本缺失的行标注「无法计算」。
|
||||||
|
2. 人员条目 T+ 边界:一期手工 CRUD 完整可用,同步以 `source_no` 幂等、不覆盖人工字段。
|
||||||
|
3. 拓展计划粒度:自由条目容器(期间+内容+指标说明)。
|
||||||
|
4. 业务编号前缀:一期代码常量 YW/SH/HK,如需配置走 M1 参数体系。
|
||||||
|
5. 终态回退:一期不可变更(400);如需重开再加 2→1 规则。
|
||||||
|
6. 负数回款/红字:一期禁负金额;冲销需求出现时 M3 前补设计。
|
||||||
|
7. 回款未指定批次:允许保存,列表提供筛选,M3 核算前置校验非空。
|
||||||
|
8. T+ 幂等键:`source_no` 普通索引先行,IT 定键后小迁移升级。
|
||||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user