Author SHA1 Message Date
liuwenxin d0726487e9 merge: 合并 main 至 docs/tplus-field-mapping(V12 审批版本号批次收口前同步) 2026-10-22 13:32:30 +08:00
liuwenxinandClaude Code a84fe9b316 chore: 忽略嵌套目录下的 e2e 失败截图与临时文档目录
- `frontend/e2e/*.png` 改为 `frontend/e2e/**/*.png`:e2e 脚本按相对路径
  写 failure.png,在不同 CWD 下会落到 frontend/e2e/e2e/
- 忽略 .tmpdocx/(AI 生成文档的脚本与预览截图)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:31:57 +08:00
liuwenxinandClaude Code 94e2957359 docs: T+ 清单升至 v1.7 并清理过期版本文件
- 新增 docs/T+对接数据需求清单_v1.7_2026-10-20.xlsx(v1.6 可读性改造
  的进一步结构重构:结论总览首屏、业务列前置、技术列后置分区)
- 保留 v1.6 供对照;删除已被替代的 T+ 清单 2026-10-15/v1.4/v1.5、
  待澄清问题清单 2026-10-15(两份)、待确认事项排序表 2026-10-16
  (两份)与 2026-10-13 阶段报告(均有 10-19/10-20 后继文档)
- 新增部署硬件需求(txt 源文与 docx 说明)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:31:57 +08:00
liuwenxinandClaude Code ebc9595de2 feat(approval): 审批详情展示内容采集时间
- 快照单显示「内容采集时间(送审版本)」;live 单显示「打开详情时实时
  读取(非送审版本)」,让复核者明确看到的是哪个时点的内容
- live 分支不展示 new Date() 生成的时刻:渲染函数里的时间非响应式,
  且 toISOString 为 UTC 会差 8 小时

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:31:19 +08:00
liuwenxinandClaude Code 9964281c14 fix(approval): 版本号唯一键兜底 + 取号改当前读防并发重复
- V12 迁移:先重排存量重复 (order_type, ref_id) 分组的 version_no
  (V11 把存量一律置 1,而 V11 前重提是新建行),再加
  uk_approval_orders_order_type_ref_version;只重排真有重复的分组,
  避免改动前端已展示过的历史版本号
- _next_version 改锁读(select version_no ... FOR UPDATE,最大值在
  Python 侧算):请求级鉴权的普通 SELECT 已把 REPEATABLE READ 快照
  锚定在请求开始时刻,原 MAX 快照读在并发下会算出同一版本号;
  聚合 + FOR UPDATE 为 MySQL 明确禁止,故不锁聚合结果
- 编辑与月结提交路径把 1062 收敛为 400「提交冲突(版本号生成重复),
  请重试」,其余完整性错误原样上抛;submit_order 内不 catch/rollback
- 驳回清空方案的过时注释改为 V11 起的 append-only 语义(置历史不删行)
- init-dev.sql 同步唯一键与 alembic_version 7f4f7b918bc4,导出 V12 SQL
- 测试:集成 +5(锁读 SQL 形态、两会话快照回归、唯一键 1062、冲突 400、
  正常递增)+ 单元 +1;工作台假审批单改用不同 ref_id 适配唯一键
- 全量回归 897 passed;ruff 与前端 type-check 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:31:01 +08:00
liuwenxinandClaude Code 694d21d1fd test(e2e): 二次分配流程断言改为取实际方案并补审批内容校验
审批详情的送审内容断言不再硬编码参与人姓名(下拉取人员列表前两项,
未必是脚本刚建的 t_e2e 员):改为经业务详情接口取回实际保存的方案,
逐参与人断言姓名与比例出现在审批抽屉中。顺带修掉原脚本清理环节的
隐性缺陷——开关开启时业务有在途审批单,界面删除被拦截而脚本无断言,
数据从未真正清理;现脚本内显式关闭审批开关并保证结束还原为 1。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 14:25:39 +08:00
liuwenxinandClaude Code 4c32a556de feat(approval): 审批单内容快照与二次分配方案版本化
补齐 code-review 遗留 2 项:审批单此前只固化 ref_id/refer_no 指针,复核者看到
的是业务当前内容;二次分配方案在编辑替换与驳回清空时一律物理删除,方案事实
无处回溯。

- 审批单加 version_no 与 snapshot_json(信封 schema_version/order_type/
  captured_at/data);二次分配、月结、调整单三类提交均固化送审内容,开关关闭
  的自动通过路径同样写快照;详情按 snapshot/live/missing 三态回显,历史单
  只读回退当前内容并显式告警,不回填历史快照
- 二次分配方案行改 append-only 版本化(version_no/is_active,唯一键扩为
  business+version+personnel):编辑替换与驳回清空只置历史,核算与详情默认
  只取生效行,业务详情新增 allocation_history
- 送审内容未变不重提、方案未变不新增版本:判据统一为送审内容,两个
  version_no 语义解耦(方案版本=内容第几版,审批单版本=第几次送审)
- 审批单列表、工作台待办、审批超时预警三处 defer 快照列,避免整列拉取
- 月结提交取行加 for_update,防并发提交算出重复的审批单版本号
- 修复审批类型筛选上界 le=2(调整单无法筛选)、快照金额把空值写成 0.00
- V11 迁移三件套同步(Alembic / db/migration / init-dev.sql)

测试:单元 +16(方案内容比较 10、审批入参 6)、集成 +12;全量 891 passed。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 18:30:51 +08:00
83 changed files with 2554 additions and 3014 deletions

No files matched your search

+6 -2
View File
@@ -45,8 +45,9 @@ htmlcov/
.mypy_cache/
.ruff_cache/
.tox/
# 端到端测试产出的截图
frontend/e2e/*.png
# 端到端测试产出的截图(脚本以相对路径 e2e/failure.png 落盘,在不同 CWD
# 下会落到 frontend/e2e/ 或 frontend/e2e/e2e/,用 ** 一并覆盖)
frontend/e2e/**/*.png
# 临时文件
*.tmp
@@ -70,3 +71,6 @@ docs/**/*.rar
docs/T_12.3数据字典/
# macOS 压缩包残留元数据
__MACOSX/
# AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库
.tmpdocx/
+16
View File
@@ -221,6 +221,22 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
### 审批快照与方案版本化(2026-10-20 定稿,改动前先读)
- **审批单带版本号与内容快照**:`approval_orders.version_no` 按 `(order_type, ref_id)` 从 1 递增,重提先撤回旧在途单、但**旧单快照不覆盖**;`snapshot_json` 统一信封 `{schema_version, order_type, captured_at, data}`,`data` 由 `approval_service.{allocation,settlement,adjustment}_content` 组装(Decimal 转 str、日期转 ISO),开关关闭的自动通过路径同样写快照。审批期间业务再改,复核者看到的仍是送审版本。
- **两个 `version_no` 语义不同,别混用**:`business_secondary_allocations.version_no` = 方案内容第几版,**仅方案内容(参与人+比例)变化时 +1**;`approval_orders.version_no` = 同一对象第几次送审,**仅送审内容变化时 +1**。改备注不动金额 → 两者都不动;改金额 → 只有审批单版本号动;改方案比例 → 两者都动。
- **送审内容未变不重提**:编辑业务时若**存在在途审批单**且其快照 `data` 与本次送审内容逐键相等(`approval_service.pending_matches_snapshot`,只比 `data` 不比 `captured_at`),则保留原单不撤回不重建;否则撤旧提新。方案内容未变时**不新增方案版本行**(`business_service._plan_changed` 按「人员+比例」集合比较,与行序无关)。
- **快照的覆盖范围是「方案 + 钱 + 归属」**(业务编号/日期、客户、负责与协助人、销售额、成本额、方案参与人与比例、合计与余额),**不含产品明细行**。因此只改产品构成而金额不变时不会重提——分摊依据(谁分、分多少、基于多少钱)未变;产品明细由复核者按业务编号回业务页查看。
- **列表不加载快照列**:快照可达上百 KB,审批单列表、工作台待办、工作台审批超时预警三处查询一律 `defer(ApprovalOrder.snapshot_json)`(与操作日志列表排除 `request_data` 同一理由,参考 `operation_log_repository.list_logs`);详情走 `get_order` 按需读取。
- **详情内容来源三态**:`content_source=snapshot`(送审快照)/ `live`(V11 前的历史单体无快照,只读回退当前内容并显式告警)/ `missing`(引用对象不存在且无快照)。**禁止回填历史单快照**——回填等于伪造提交时内容。
- **二次分配方案 append-only**:`business_secondary_allocations` 行不物理删除,编辑全量替换与驳回清空只把旧行 `is_active=0`;唯一键为 `(business_id, version_no, personnel_id)`,同一版本内参与人唯一、跨版本可重复。`business_repository.list_allocations(active_only=True)` 是核算与详情回显的唯一取数口径;业务详情 `allocation_history` 返回全部版本供回溯。
- **月结提交要锁行**:`settlement_service.submit` 取月结行必须 `for_update=True`——并发提交会让两次 `submit_order` 算出同一个审批单 `version_no`,送审次序失真。
- **审批单 `version_no` 有唯一键兜底(V12)**:`uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`——同一对象的第 N 次送审在库里有唯一一行。ORM 侧约束名必须与迁移 DDL 严格一致,否则 autogenerate 误判。
- **取版本号必须是「当前读」(`approval_service._next_version`)**,两个禁忌:(a) 不能用普通 `MAX` 查询——请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻,父行锁只串行化写入时刻、**不刷新快照**,MAX 会读到旧值;(b) 不能写成 `select(func.max(...)).with_for_update()`——MySQL 官方明确「Locking clauses are not permitted in queries with aggregate functions or GROUP BY」,聚合上的 FOR UPDATE 语义无保证。正解:锁读该 ref 的现有版本行(`select(version_no)...with_for_update()`),最大值 Python 侧算(同 `no_generator` 套路)。
- **版本号冲突的收敛口径**:`submit_order` 内不 catch、不 rollback(红线)。create 路径由外层编号重试循环整体重来(`business_service._NO_RETRY`);编辑/月结提交路径由审计上下文统一回滚,再把 1062 翻译为 400「提交冲突(版本号生成重复),请重试」(同 `param_service` 版本号冲突模板)。唯一键是最后一道防线,不是取号正确性的前提。
- **迁移前先修存量**:V11 把存量行 `version_no` 一律置 1,而 V11 前每次重提都新建行——同一 `(order_type, ref_id)` 存在多行 version_no=1,直接加唯一键必报 1062。V12 先重排「组内有重复」的分组(按 id 升序 1..n),其余分组不动(全量重排会改掉前端已展示过的历史版本号)。
- **新增审批对象类型**须同时提供快照组装函数并接入 `_content_of` 的 live 回退分支,不要在各业务服务内联 JSON 结构。
### 工作台口径(M4 ⑧,改动前先读)
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
@@ -0,0 +1,71 @@
"""v12 审批单版本号唯一键
Revision ID: 7f4f7b918bc4
Revises: f3a8c2d61b47
Create Date: 2026-10-21 10:15:00.000000
给 ``approval_orders`` 加 ``(order_type, ref_id, version_no)`` 唯一键,兜住
「同一引用对象的第几次送审」在并发下算重。
**加键前必须先重排存量**:V11 迁移用 ``ADD COLUMN version_no INT NOT NULL
DEFAULT 1`` 把存量行一律置 1,而 V11 之前的每次重提都是**新建行**(撤回
只改旧单 status,行不删)。于是任何有 V11 前审批历史的库里,同一
``(order_type, ref_id)`` 存在多行且 ``version_no`` 全为 1——直接
``ADD CONSTRAINT`` 必报 1062,迁移会停在半途。
重排只针对「组内有重复」的分组(``COUNT(*) > COUNT(DISTINCT version_no)``),
按 id 升序重排为 1..n;其余分组一个字节都不动——全量重排会把前端已展示
过的历史版本号(``V{{ version_no }}``)改掉,属于用户可见的历史数据篡改。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "7f4f7b918bc4"
down_revision: str | Sequence[str] | None = "f3a8c2d61b47"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_UK = "uk_approval_orders_order_type_ref_version"
def upgrade() -> None:
"""先重排存量重复的版本号,再加唯一键。"""
# 重排:只动「组内有重复版本号」的分组,按 id 升序编 1..n。
# 用派生表内联而非临时表——同一张临时表被 JOIN 两侧引用会报 1137
# "Can't reopen table"。
op.execute(
"UPDATE approval_orders ao "
"JOIN (SELECT id, ROW_NUMBER() OVER ("
" PARTITION BY order_type, ref_id ORDER BY id) AS rn "
" FROM approval_orders "
" WHERE (order_type, ref_id) IN ("
" SELECT order_type, ref_id FROM approval_orders "
" GROUP BY order_type, ref_id "
" HAVING COUNT(*) > COUNT(DISTINCT version_no))) t "
"ON ao.id = t.id "
"SET ao.version_no = t.rn"
)
# 幂等 guard:MySQL DDL 隐式提交,若加键成功而版本号未落盘,
# 重跑会报 1091(键已存在)。写法沿用 V9 先例。
op.execute(
"SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
"WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'approval_orders' "
f"AND CONSTRAINT_NAME = '{_UK}')"
)
op.execute(
"SET @ddl := IF(@uk_exists = 0, "
f"'ALTER TABLE approval_orders ADD CONSTRAINT {_UK} "
"UNIQUE (order_type, ref_id, version_no)', 'DO 0')"
)
op.execute("PREPARE stmt FROM @ddl")
op.execute("EXECUTE stmt")
op.execute("DEALLOCATE PREPARE stmt")
def downgrade() -> None:
"""删唯一键。唯一键本质是索引,DROP INDEX 不校验数据,存量是否已有
重复 version_no 与删索引无关;重排结果也不回滚(版本号保持 1..n 连续)。"""
op.execute(f"ALTER TABLE approval_orders DROP INDEX {_UK}")
@@ -1,212 +0,0 @@
"""V11 客户评级落库与站点信息
Revision ID: c4e2a86f9b13
Revises: 90653814a421
Create Date: 2026-10-19 10:00:00.000000
与上游 V7–V10(台账/月结/调整单/口径/唯一键/预警参数)合并时重编号为 V11:
本分支三个迁移(原 V7 客户评级 + V8 公司档案精简 + V9 图片版本号)从未发布,
压成单条,最终结构 = 评级表 + 最终版站点/公司信息字段(不含中途增删的
副标题/信用代码/地址/电话/邮箱)。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "c4e2a86f9b13"
down_revision: str | Sequence[str] | None = "90653814a421"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移(双主题一次交付)。
- 客户评级弹窗化:评分每客户落库(customer_ratings,一行一客户快照)
- 公司信息补充与精简:标语/页脚/备案号/站点标题/站点图标 + 图片版本号
"""
op.create_table(
"customer_ratings",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"customer_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="客户ID(唯一:一客户一份当前评级)",
),
sa.Column(
"score_ship_amount",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="发货额评分(0-20)",
),
sa.Column(
"score_receipt_rate",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="回款率评分(0-20)",
),
sa.Column(
"score_profit_rate",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="毛利率评分(0-20)",
),
sa.Column(
"score_contract_cooperation",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="合同履约配合能力评分(0-20)",
),
sa.Column(
"score_site_construction",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="现场规范施工能力评分(0-20)",
),
sa.Column(
"total_score",
sa.Numeric(precision=5, scale=1),
nullable=False,
comment="总分(服务端重算,五项之和 0-100)",
),
sa.Column(
"grade_name",
sa.String(length=32),
nullable=True,
comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"scorer_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="评级操作人ID(账号删除后置空)",
),
sa.Column(
"scorer_name",
sa.String(length=64),
nullable=False,
comment="评级操作人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["customer_id"],
["customers.id"],
name=op.f("fk_customer_ratings_customer_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["scorer_id"],
["users.id"],
name=op.f("fk_customer_ratings_scorer_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_ratings")),
sa.UniqueConstraint("customer_id", name=op.f("uk_customer_ratings_customer_id")),
comment="客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.add_column(
"system_settings",
sa.Column(
"site_title",
sa.String(length=64),
nullable=True,
comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)",
),
)
op.add_column(
"system_settings",
sa.Column(
"brand_slogan",
sa.String(length=64),
nullable=True,
comment="品牌标语(登录页与品牌预览;null=内置默认)",
),
)
op.add_column(
"system_settings",
sa.Column(
"footer_text",
sa.String(length=64),
nullable=True,
comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)",
),
)
op.add_column(
"system_settings",
sa.Column(
"icp_no",
sa.String(length=32),
nullable=True,
comment="ICP 备案号(登录页页脚,空=不显示)",
),
)
op.add_column(
"system_settings",
sa.Column(
"favicon_path",
sa.String(length=255),
nullable=True,
comment="站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)",
),
)
op.add_column(
"system_settings",
sa.Column(
"logo_version",
sa.Integer(),
server_default="0",
nullable=False,
comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)",
),
)
op.add_column(
"system_settings",
sa.Column(
"favicon_version",
sa.Integer(),
server_default="0",
nullable=False,
comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)",
),
)
def downgrade() -> None:
"""回滚迁移:先删评级表(引用 customers/users),再逆序移除站点信息列。"""
op.drop_table("customer_ratings")
op.drop_column("system_settings", "favicon_version")
op.drop_column("system_settings", "logo_version")
op.drop_column("system_settings", "favicon_path")
op.drop_column("system_settings", "icp_no")
op.drop_column("system_settings", "footer_text")
op.drop_column("system_settings", "brand_slogan")
op.drop_column("system_settings", "site_title")
@@ -0,0 +1,95 @@
"""v11 审批快照与方案版本化
Revision ID: f3a8c2d61b47
Revises: 90653814a421
Create Date: 2026-10-20 10:30:00.000000
补齐 code-review 遗留 2 项:
- 审批单加 ``version_no`` 与 ``snapshot_json``:重提产生新版本,送审内容
在提交时固化,审批期间业务再改不影响复核依据;
- 二次分配方案行改 append-only 版本化:加 ``version_no`` / ``is_active``,
唯一键由 (business_id, personnel_id) 扩为
(business_id, version_no, personnel_id),编辑替换与驳回清空只置历史、
不再物理删除。
存量数据均落 ``version_no=1`` / ``is_active=1``:升级前的行就是当前生效
版本,语义不变。审批单存量快照为空,详情接口按「当前内容回退 + 显式
标注」处理,不做历史回填(回填会伪造提交时内容)。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f3a8c2d61b47"
down_revision: str | Sequence[str] | None = "90653814a421"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""加审批快照列/索引与方案版本列,替换唯一键。"""
op.execute(
"ALTER TABLE approval_orders "
"ADD COLUMN version_no INT NOT NULL DEFAULT 1 "
"COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' "
"AFTER refer_no"
)
op.execute(
"ALTER TABLE approval_orders "
"ADD COLUMN snapshot_json JSON NULL "
"COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' "
"AFTER version_no"
)
op.execute(
"CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"ADD COLUMN version_no INT NOT NULL DEFAULT 1 "
"COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' "
"AFTER business_id"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"ADD COLUMN is_active TINYINT NOT NULL DEFAULT 1 "
"COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' "
"AFTER ratio"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"DROP INDEX uk_business_secondary_allocations_business_personnel, "
"ADD CONSTRAINT uk_business_secondary_allocations_business_version_personnel "
"UNIQUE (business_id, version_no, personnel_id)"
)
op.execute(
"CREATE INDEX idx_business_secondary_allocations_business_active "
"ON business_secondary_allocations (business_id, is_active)"
)
op.execute(
"ALTER TABLE business_secondary_allocations COMMENT = "
"'业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)'"
)
def downgrade() -> None:
"""回退结构(降级前须确认同业务同一参与人只存在于一个版本)。"""
op.execute(
"DROP INDEX idx_business_secondary_allocations_business_active "
"ON business_secondary_allocations"
)
op.execute(
"ALTER TABLE business_secondary_allocations "
"DROP INDEX uk_business_secondary_allocations_business_version_personnel, "
"ADD CONSTRAINT uk_business_secondary_allocations_business_personnel "
"UNIQUE (business_id, personnel_id)"
)
op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN is_active")
op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN version_no")
op.execute("DROP INDEX idx_approval_orders_type_ref ON approval_orders")
op.execute("ALTER TABLE approval_orders DROP COLUMN snapshot_json")
op.execute("ALTER TABLE approval_orders DROP COLUMN version_no")
+1 -44
View File
@@ -24,8 +24,7 @@ from app.schemas.customer import (
CustomerUpdateIn,
OwnershipChangeLogOut,
)
from app.schemas.rating import CustomerRatingOut, CustomerRatingSaveIn
from app.services import customer_service, rating_service
from app.services import customer_service
router = APIRouter(prefix="/customers", tags=["客户管理"])
@@ -141,45 +140,3 @@ def list_ownership_logs(
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
"""查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。"""
return ok(customer_service.list_ownership_logs(session, customer_id, query))
@router.get(
"/{customer_id}/rating",
response_model=ApiResponse[CustomerRatingOut],
summary="客户当前评级",
)
def get_customer_rating(
session: DbSession,
_: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[CustomerRatingOut]:
"""查某客户当前评级。
未评级返回 ``data: null``(这是正常态而非错误);客户不存在才 404。
"""
return ok(rating_service.get_customer_rating(session, customer_id))
@router.put(
"/{customer_id}/rating",
response_model=ApiResponse[CustomerRatingOut],
summary="保存客户评级",
)
def save_customer_rating(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerRatingSaveIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerRatingOut]:
"""保存客户评级(首次创建、再次保存整体覆盖)。
五项分值 0-20 由服务层在审计上下文内校验,总分与等级由服务端重算;
等级按保存时点的映射快照固化,等级表后续增删改不影响该客户结论。
"""
return ok(
rating_service.save_customer_rating(
session, customer_id, payload, operator=current_user, meta=meta
),
message="评级已保存",
)
+9 -42
View File
@@ -1,11 +1,11 @@
"""系统设置接口(公司信息 + 站点信息)。
"""系统设置接口(公司信息)。
`GET /public`、`GET /logo/public`、`GET /favicon/public` **无鉴权**:登录页
渲染公司名/logo、浏览器加载标签页图标时尚无令牌。响应经
`SettingPublicOut` 白名单收敛(见 schemas/setting.py),仅暴露展示必需字段。
`GET /public` 与 `GET /logo/public` **无鉴权**:登录页渲染公司名/logo 时
尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py),
仅暴露两个非敏感展示字段。
`/logo/public` 与 `/favicon/public` 返回文件流而非统一信封——与参数导出
同一例外口径(二进制响应套 JSON 信封无意义)。
`/logo/public` 返回文件流而非统一信封——与参数导出同一例外口径
(二进制响应套 JSON 信封无意义)。
"""
from fastapi import APIRouter, Response, UploadFile
@@ -20,7 +20,6 @@ from app.services import system_setting_service as service
router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"])
_LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"}
_FAVICON_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg", "ico": "image/x-icon"}
@router.get("", response_model=ApiResponse[SettingOut], summary="公司信息")
@@ -29,14 +28,14 @@ def get_settings(session: DbSession, _: AdminUser) -> ApiResponse[SettingOut]:
return ok(service.to_out(service.get_setting(session)))
@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司信息")
@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司名称")
def update_settings(
payload: SettingUpdateIn,
session: DbSession,
current_user: AdminUser,
meta: RequestMetaDep,
) -> ApiResponse[SettingOut]:
"""更新公司信息(部分更新;审计留痕)。"""
"""更新公司名称(审计留痕)。"""
return ok(
service.update_company(session, payload, operator=current_user, meta=meta),
message="已保存",
@@ -56,7 +55,7 @@ def upload_logo(
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
大小先用 `file.size` 拦截,避免超限文件整体读进内存。
"""
if file.size is not None and file.size > service.ASSET_MAX_BYTES:
if file.size is not None and file.size > service.LOGO_MAX_BYTES:
raise BadRequestError("logo 文件不能超过 2MB")
content = file.file.read()
ext = (file.filename or "").rsplit(".", 1)[-1]
@@ -66,27 +65,6 @@ def upload_logo(
)
@router.post("/favicon", response_model=ApiResponse[SettingOut], summary="上传站点图标")
def upload_favicon(
file: UploadFile,
session: DbSession,
current_user: AdminUser,
meta: RequestMetaDep,
) -> ApiResponse[SettingOut]:
"""上传站点图标(png/jpg/ico ≤2MB;固定名覆盖,旧扩展名文件清理)。
同步 `def` 与 logo 上传同口径:磁盘写与审计提交均为阻塞操作。
"""
if file.size is not None and file.size > service.ASSET_MAX_BYTES:
raise BadRequestError("站点图标文件不能超过 2MB")
content = file.file.read()
ext = (file.filename or "").rsplit(".", 1)[-1]
return ok(
service.save_favicon(session, content, ext, operator=current_user, meta=meta),
message="站点图标已更新",
)
@router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)")
def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]:
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
@@ -103,14 +81,3 @@ def get_logo_public(session: DbSession) -> FileResponse:
media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"),
headers={"Cache-Control": "no-cache"},
)
@router.get("/favicon/public", summary="站点图标(匿名)")
def get_favicon_public(session: DbSession) -> FileResponse:
"""站点图标文件流(无鉴权)。no-cache:图标替换后由版本参数破缓存兜底。"""
path, ext = service.read_favicon(session)
return FileResponse(
path,
media_type=_FAVICON_MEDIA_TYPE.get(ext, "application/octet-stream"),
headers={"Cache-Control": "no-cache"},
)
-2
View File
@@ -14,7 +14,6 @@ from app.models.base import Base, TimestampMixin
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
from app.models.business_role import BusinessRole
from app.models.customer import Customer, OwnershipChangeLog
from app.models.customer_rating import CustomerRating
from app.models.login_log import LoginLog
from app.models.operation_log import OperationLog
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
@@ -39,7 +38,6 @@ __all__ = [
"BusinessRole",
"BusinessStatusLog",
"Customer",
"CustomerRating",
"CustomerRatingGrade",
"LoginLog",
"MonthlySettlement",
+31 -1
View File
@@ -15,7 +15,16 @@ ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, text
from sqlalchemy import (
JSON,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
@@ -54,7 +63,17 @@ class ApprovalOrder(Base, TimestampMixin):
__tablename__ = "approval_orders"
__table_args__ = mysql_table_args(
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
# 同一引用对象的第 N 次送审在库里有唯一一行:并发提单算重版本号时
# 由 DB 兜住(应用层取号走锁读,见 approval_service._next_version)
UniqueConstraint(
"order_type",
"ref_id",
"version_no",
name="uk_approval_orders_order_type_ref_version",
),
Index("idx_approval_orders_type_status", "order_type", "status"),
# 版本号按 (order_type, ref_id) 递增,须有对应索引避免全表 max 扫描
Index("idx_approval_orders_type_ref", "order_type", "ref_id"),
)
id: Mapped[int] = mapped_column(
@@ -76,6 +95,17 @@ class ApprovalOrder(Base, TimestampMixin):
refer_no: Mapped[str] = mapped_column(
String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)"
)
version_no: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="1",
comment="提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖;"
"同一对象内唯一,由uk_approval_orders_order_type_ref_version强制)",
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON,
comment="提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)",
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
+38 -7
View File
@@ -15,7 +15,17 @@
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
from sqlalchemy import (
Date,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
@@ -42,27 +52,36 @@ class SecondaryAllocation(Base, TimestampMixin):
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
与比例,响应以 hint 提示合计/余额,不做强制校验
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
- **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按
核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表
- **版本行 append-only**:编辑全量替换与驳回清空都把旧行置
``is_active=0`` 而非物理删除,历史方案可回溯;当前生效行由
``is_active=1`` 唯一标识。核算仍按「当前生效行」取快照
(``performance_ledgers.snapshot_json.alloc_plan`` 固化),版本化只
补齐「方案事实留存」,不改变核算口径
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id),
同一合同的同一参与人仅一行,重复提交在服务层聚合。
冗余固化,人离职后方案回显仍有名。uk 锁
(business_id, version_no, personnel_id),同一版本内同一参与人仅一行,
重复提交在服务层聚合;跨版本允许同一人再次出现。
"""
__tablename__ = "business_secondary_allocations"
__table_args__ = mysql_table_args(
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
"业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
"业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)",
UniqueConstraint(
"business_id",
"version_no",
"personnel_id",
name="uk_business_secondary_allocations_business_personnel",
name="uk_business_secondary_allocations_business_version_personnel",
),
Index("idx_business_secondary_allocations_business_id", "business_id"),
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
# 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1
Index(
"idx_business_secondary_allocations_business_active", "business_id", "is_active"
),
)
id: Mapped[int] = mapped_column(
@@ -74,6 +93,12 @@ class SecondaryAllocation(Base, TimestampMixin):
nullable=False,
comment="业务ID(businesses.id;业务删除随删)",
)
version_no: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="1",
comment="方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)",
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
@@ -88,6 +113,12 @@ class SecondaryAllocation(Base, TimestampMixin):
nullable=False,
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
)
is_active: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)",
)
class Business(Base, TimestampMixin, TPlusSyncMixin):
-77
View File
@@ -1,77 +0,0 @@
"""客户评级模型(需求 2.2.5)。
评级从「纯计算器不落库」升级为**每客户一行落库**(2026-10-19 确认):
客户管理页要罗列评级列并按其排序,评分必须可回溯。设计要点:
- ``customer_id`` 唯一键 + RESTRICT 外键:一客户一份当前评级(快照语义,
非流水),删除客户由服务层先删评级行再删主行(RESTRICT 兜底并发窗口)
- ``grade_name`` 为**快照固化、无外键**:等级映射表仍允许自由增删清空
(rating_service 的既有前提),删等级不能伤及已评级客户的历史结论 ——
与归属留痕「ID/人名固化、不回溯」同一论证口径;等级未命中时为 NULL
- ``scorer_id`` 外键 SET NULL + ``scorer_name`` 冗余固化:账号删除后
记录仍可读(同 ownership_change_logs 的操作人口径)
- 分值列 DECIMAL(5,1):UI 整数 0-20,DECIMAL 留十进制余地;总分由
**服务端重算**,不采信前端回传
"""
from decimal import Decimal
from sqlalchemy import ForeignKey, Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class CustomerRating(Base, TimestampMixin):
"""客户评级表:每客户一行(uk_customer_ratings_customer_id)。
五项各 0-20 分、总分 = 五项之和(0-100),判定规则同等级映射表
(按 sort_order 升序首个 min_score ≤ 总分,见 rating_service.resolve_grade)。
"""
__tablename__ = "customer_ratings"
__table_args__ = mysql_table_args(
"客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)",
UniqueConstraint("customer_id", name="uk_customer_ratings_customer_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
customer_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("customers.id", ondelete="RESTRICT"),
nullable=False,
comment="客户ID(唯一:一客户一份当前评级)",
)
score_ship_amount: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="发货额评分(0-20)"
)
score_receipt_rate: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="回款率评分(0-20)"
)
score_profit_rate: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="毛利率评分(0-20)"
)
score_contract_cooperation: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="合同履约配合能力评分(0-20)"
)
score_site_construction: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="现场规范施工能力评分(0-20)"
)
total_score: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, comment="总分(服务端重算,五项之和 0-100)"
)
grade_name: Mapped[str | None] = mapped_column(
String(32),
comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
scorer_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="评级操作人ID(账号删除后置空)",
)
scorer_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="评级操作人用户名(冗余固化)"
)
+4 -5
View File
@@ -1,10 +1,9 @@
"""客户评级等级配置模型(需求 2.2.5)。
评级定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分自 2026-10-19
起每客户落库于 customer_ratings(快照固化等级名),本表承载可配置的等级
**映射关系**。判定规则:按 sort_order 升序取第一个 min_score ≤ 总分的
等级 —— 单边判定避免区间边界(如 75 分)在「≥75 且 ≤89」与「>74 且 <75」
两套写法间的重叠/遗漏歧义;max_score 仅展示。
评级计算器定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分记录
不落库,等级**映射关系**可配置由本表承载。判定规则:按 sort_order 升序取
第一个 min_score ≤ 总分的等级 —— 单边判定避免区间边界(如 75 分)在
「≥75 且 ≤89」与「>74 且 <75」两套写法间的重叠/遗漏歧义;max_score 仅展示。
"""
from decimal import Decimal
+9 -45
View File
@@ -1,67 +1,31 @@
"""系统设置模型:公司信息 + 站点信息单例行。
"""系统设置模型:公司信息单例行。
与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期
时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列
value_text 为 String(64) 装不下;故独立单行表。logo/favicon 只存相对路径——
文件在 `backend/uploads/{logo,favicon}/`,独立于数据库备份体积且可单独替换
(见 system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底)。
2026-10-20 精简(用户确认):企业档案字段(信用代码/地址/电话/邮箱)与
系统副标题删除,新增**站点标题**(浏览器标签页/侧边栏副标题)与**站点图标**
(浏览器标签页 favicon,独立于登录页 logo)。
value_text 为 String(64) 装不下;故独立单行表。logo 只存相对路径——文件在
`backend/uploads/logo/`,独立于数据库备份体积且可单独替换(见
system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底,upsert 维护)。
"""
from sqlalchemy import Integer, String
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class SystemSetting(Base, TimestampMixin):
"""系统设置表:公司信息 + 站点信息,全系统单行 id=1。
brand_slogan/footer_text/icp_no/site_title 均可空:null 表示前端回退
内置默认值,存量行为零变化。
"""
"""系统设置表:本期仅公司信息(公司名称 / logo 路径),全系统单行 id=1。"""
__tablename__ = "system_settings"
__table_args__ = mysql_table_args("系统设置表(公司信息与站点信息,全系统单行)")
__table_args__ = mysql_table_args("系统设置表(公司信息,全系统单行)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
company_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="公司名称(登录页/侧边栏/浏览器标题展示)"
String(64), nullable=False, comment="公司名称(登录页与导出页眉展示)"
)
logo_path: Mapped[str | None] = mapped_column(
String(255),
comment="公司 logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
)
logo_version: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="0",
comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)",
)
site_title: Mapped[str | None] = mapped_column(
String(64), comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)"
)
brand_slogan: Mapped[str | None] = mapped_column(
String(64), comment="品牌标语(登录页与品牌预览;null=内置默认)"
)
footer_text: Mapped[str | None] = mapped_column(
String(64), comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)"
)
icp_no: Mapped[str | None] = mapped_column(
String(32), comment="ICP 备案号(登录页页脚,空=不显示)"
)
favicon_path: Mapped[str | None] = mapped_column(
String(255),
comment="站点图标相对路径(文件存 uploads/favicon/,null=跟随公司 logo / 内置 favicon.svg)",
)
favicon_version: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="0",
comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)",
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
)
+49 -17
View File
@@ -7,7 +7,7 @@ models/customer.py 定义)。
from datetime import date
from sqlalchemy import Row, Select, delete, exists, func, or_, select
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update
from sqlalchemy.orm import Session, aliased
from app.models import (
@@ -199,43 +199,75 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem])
session.flush()
def list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]:
"""二次分配方案(详情回显;空列表=未设置方案)。"""
def list_allocations(
session: Session, business_id: int, *, active_only: bool = True
) -> list[SecondaryAllocation]:
"""二次分配方案(详情回显与核算取数;默认仅当前生效版本)。"""
stmt = select(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
if active_only:
stmt = stmt.where(SecondaryAllocation.is_active == 1)
return list(
session.execute(
select(SecondaryAllocation)
.where(SecondaryAllocation.business_id == business_id)
.order_by(SecondaryAllocation.id)
stmt.order_by(SecondaryAllocation.version_no, SecondaryAllocation.id)
).scalars()
)
def list_allocation_versions(session: Session, business_id: int) -> list[SecondaryAllocation]:
"""方案全部版本行(含历史;业务详情「方案历史」用,按版本正序)。"""
return list_allocations(session, business_id, active_only=False)
def replace_allocations(
session: Session, business_id: int, rows: list[SecondaryAllocation]
) -> None:
"""全量替换二次分配方案(不 commit,事务边界在服务层)。
"""全量替换二次分配方案:旧生效行置历史,新行按下一版本号插入。
本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的
防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。
不 commit(事务边界在服务层)。版本行 append-only——编辑与清空都不
物理删除,驳回后仍可回溯被否决方案的参与人与比例。空 ``rows`` 表示
只清空生效态(不产生新版本)。
"""
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
deactivate_allocations(session, business_id)
if not rows:
return
next_version = (
session.execute(
select(func.coalesce(func.max(SecondaryAllocation.version_no), 0)).where(
SecondaryAllocation.business_id == business_id
)
).scalar_one()
+ 1
)
session.flush()
for row in rows:
row.business_id = business_id
row.version_no = next_version
row.is_active = 1
session.add(row)
session.flush()
def delete_allocations(session: Session, business_id: int) -> None:
"""清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。
def deactivate_allocations(session: Session, business_id: int) -> None:
"""把业务的当前生效方案置为历史(审批驳回/编辑替换时调用;行保留)。
被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到);
方案事实由审批单留痕与操作日志 request_data 承载。
被否决的方案不得以「当前生效行」身份继续被核算取到;``is_active=0``
后核算侧自然读不到,而历史版本仍可在业务详情回溯。不 commit。
显式声明 ``synchronize_session="fetch"``:Core UPDATE 默认走 auto 策略
(优先 evaluate,本例的条件恰好能在 Python 侧求值,故实测也会刷新在途
对象)。显式写 fetch 是为了不把「在途对象被刷新」这件事押在 evaluate
能否解析条件上——条件一旦复杂化,auto 会退化为不刷新,同一会话里已
加载的方案行就会停留在 ``is_active=1`` 的过期内存值(查询虽有
is_active 过滤,但行对象本身仍是「生效态」,被序列化或参与判断就是
错的)。
"""
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
update(SecondaryAllocation)
.where(
SecondaryAllocation.business_id == business_id,
SecondaryAllocation.is_active == 1,
)
.values(is_active=0)
.execution_options(synchronize_session="fetch")
)
session.flush()
@@ -1,33 +0,0 @@
"""客户评级数据访问(每客户一行)。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import CustomerRating
# ==================== 读取 ====================
def get_by_customer_id(session: Session, customer_id: int) -> CustomerRating | None:
"""查某客户的当前评级(uk_customer_ratings_customer_id 保证至多一行)。"""
stmt = select(CustomerRating).where(CustomerRating.customer_id == customer_id)
return session.execute(stmt).scalar_one_or_none()
# ==================== 写入 ====================
def create(session: Session, rating: CustomerRating) -> CustomerRating:
"""新增评级行。只 flush 以取得自增 id。"""
session.add(rating)
session.flush()
return rating
def delete(session: Session, rating: CustomerRating) -> None:
"""删除评级行(客户物理删除前的联动清理,调用方先校验业务引用)。"""
session.delete(rating)
session.flush()
+10 -39
View File
@@ -6,7 +6,7 @@
from sqlalchemy import ColumnElement, Row, Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import Customer, CustomerRating, OwnershipChangeLog, Personnel
from app.models 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
@@ -31,7 +31,7 @@ def count_business_refs(session: Session, customer_id: int) -> int:
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()
@@ -53,14 +53,12 @@ def list_customers(
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
order_field: str | None = None,
order_type: str | None = None,
) -> list[Row]:
"""分页查询客户,行内容为 (客户, 归属人员姓名, 评级总分, 评级等级)。
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
归属人员/评级行均用 outerjoin:关联行不可考(人员被物理删除、客户
未评级)不应让客户行整个查不出来(同 personnel_repository 韧性口径)。
默认排序按 id 倒序:新增的靠前符合列表页习惯;评级排序见 ``_apply_order``。
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
"""
stmt = _apply_filters(
_base_select(),
@@ -68,8 +66,7 @@ def list_customers(
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
stmt = _apply_order(stmt, order_field=order_field, order_type=order_type)
stmt = stmt.offset(offset).limit(limit)
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).all())
@@ -123,38 +120,12 @@ def count_ownership_logs(session: Session, *, customer_id: int) -> int:
def _base_select() -> Select:
"""客户主表外联归属人员姓名与评级快照(等级名/总分,列表评级列免 N+1)。"""
return (
select(
Customer,
Personnel.personnel_name,
CustomerRating.total_score,
CustomerRating.grade_name,
)
.outerjoin(Personnel, Customer.owner_personnel_id == Personnel.id)
.outerjoin(CustomerRating, CustomerRating.customer_id == Customer.id)
"""客户主表外联归属人员姓名。"""
return select(Customer, Personnel.personnel_name).outerjoin(
Personnel, Customer.owner_personnel_id == Personnel.id
)
def _apply_order(stmt: Select, *, order_field: str | None, order_type: str | None) -> Select:
"""应用列表排序。默认 id 倒序;order_field=rating 按评级总分排序。
等级由总分判定、与总分同向,故按总分即等价按等级排序。MySQL 没有
``NULLS LAST`` 语法,用布尔键 ``total_score IS NULL`` 升序模拟 ——
未评级行恒在末尾,正序/倒序一致;同分以 id 倒序兜底保证次序稳定。
非法字段/方向不落到 SQL(schema Literal 已在入口拦 400),此处静默
回退默认排序作为纵深防御。
"""
if order_field == "rating" and order_type in ("asc", "desc"):
score_order = (
CustomerRating.total_score.asc()
if order_type == "asc"
else CustomerRating.total_score.desc()
)
return stmt.order_by(CustomerRating.total_score.is_(None), score_order, Customer.id.desc())
return stmt.order_by(Customer.id.desc())
def _apply_filters(
stmt: Select,
*,
@@ -8,7 +8,7 @@
from datetime import date, datetime
from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, defer
from app.models import (
ApprovalOrder,
@@ -113,12 +113,15 @@ def list_pending_approvals(
"""待处理审批单(按提交时间升序——最久的排前面)。
``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。
排除 snapshot_json 列(快照可达上百 KB,待办只取单号/类型/提交人/时间)。
"""
if not statuses:
return []
return list(
session.execute(
select(ApprovalOrder)
.options(defer(ApprovalOrder.snapshot_json))
.where(ApprovalOrder.status.in_(statuses))
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
.limit(limit)
@@ -210,10 +213,14 @@ def count_overdue_shipments(session: Session, *, cutoff: date) -> int:
def list_overdue_approvals(
session: Session, *, cutoff: datetime, limit: int
) -> list[ApprovalOrder]:
"""提交后超过阈值仍未处理的审批单(审批超时规则)。"""
"""提交后超过阈值仍未处理的审批单(审批超时规则)。
排除 snapshot_json 列(同待办:预警只用单号/类型/提交人/时间)。
"""
return list(
session.execute(
select(ApprovalOrder)
.options(defer(ApprovalOrder.snapshot_json))
.where(
ApprovalOrder.status.in_(_PENDING_STATUSES),
ApprovalOrder.create_time < cutoff,
@@ -32,9 +32,8 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
"""按主键查询。
等级表对客户评级无**外键**引用(评级行快照固化等级名),无需
``for_update`` 锁行 —— product_repository 锁行防的是并发引用悬空,
此处不存在该风险。
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
"""
return session.get(CustomerRatingGrade, grade_id)
@@ -61,6 +60,6 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
def delete(session: Session, grade: CustomerRatingGrade) -> None:
"""物理删除等级。等级表对客户评级无外键引用,调用方无需引用保护校验。"""
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
session.delete(grade)
session.flush()
@@ -7,7 +7,16 @@ from app.models import MonthlySettlement, SettlementItem
from app.schemas.settlement import SettlementQuery
def get_by_id(session: Session, settlement_id: int) -> MonthlySettlement | None:
def get_by_id(
session: Session, settlement_id: int, *, for_update: bool = False
) -> MonthlySettlement | None:
"""月结行;``for_update`` 供提交审批路径加行锁用。
提交要算审批单版本号(同一月结的第几次送审),并发提交会让两次
算出同一个版本号;锁住月结行使并发提交串行化。
"""
if for_update:
return session.get(MonthlySettlement, settlement_id, with_for_update=True)
return session.get(MonthlySettlement, settlement_id)
+15 -2
View File
@@ -1,6 +1,7 @@
"""审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。"""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -31,6 +32,7 @@ class ApprovalOrderOut(BaseModel):
order_type: int
ref_id: int
refer_no: str
version_no: int
status: int
submitter_id: int | None = None
submitter_name: str
@@ -38,9 +40,20 @@ class ApprovalOrderOut(BaseModel):
class ApprovalOrderDetailOut(ApprovalOrderOut):
"""审批单详情:附节点时间线。"""
"""审批单详情:附节点时间线与审批内容。
``content_source`` 语义:``snapshot``=提交时快照(可信);
``live``=历史单无快照、回退读当前业务内容(仅供参考);
``missing``=引用对象已不存在且无快照。
"""
steps: list[ApprovalStepOut] = Field(default_factory=list)
snapshot: dict[str, Any] | None = Field(
default=None, description="提交时内容快照(信封:schema_version/order_type/captured_at/data)"
)
content_source: Literal["snapshot", "live", "missing"] = Field(
default="missing", description="审批内容来源:提交时快照/当前内容回退/缺失"
)
class ApprovalQuery(PageQuery):
@@ -48,7 +61,7 @@ class ApprovalQuery(PageQuery):
keyword: str | None = Field(default=None, max_length=32, description="审批单号/引用单号,模糊")
status: int | None = Field(default=None, ge=1, le=5, description="状态筛选")
order_type: int | None = Field(default=None, ge=1, le=2, description="对象类型筛选")
order_type: int | None = Field(default=None, ge=1, le=3, description="对象类型筛选")
class ApprovalActionIn(BaseModel):
+12 -1
View File
@@ -180,6 +180,14 @@ class SecondaryAllocationOut(BaseModel):
ratio: str
class AllocationVersionOut(SecondaryAllocationOut):
"""方案版本行(含历史;业务详情抽屉回溯用)。"""
version_no: int
is_active: int = Field(description="1当前生效 0历史版本")
create_time: datetime
def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
"""同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示
(让 400 文案说「重复参与人」而不是裸唯一键冲突)。"""
@@ -191,13 +199,16 @@ def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
class BusinessDetailOut(BusinessOut):
"""业务详情:附产品明细行与二次分配方案。"""
"""业务详情:附产品明细行、当前方案与方案版本历史。"""
items: list[BusinessItemOut] = Field(default_factory=list)
secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list)
allocation_hint: str | None = Field(
default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)"
)
allocation_history: list[AllocationVersionOut] = Field(
default_factory=list, description="方案全部版本(含历史;被驳回方案亦保留)"
)
class BusinessCreateIn(BaseModel):
-15
View File
@@ -1,7 +1,6 @@
"""客户管理的请求/响应模型(需求 2.1.3)。"""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -14,10 +13,6 @@ class CustomerOut(BaseModel):
owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的
脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕
「ID 固化、姓名随联表」同一口径。
rating_total / rating_grade_name 由 LEFT JOIN customer_ratings 带出
(列表评级列免 N+1):未评级时均为 null;总分按分值红线以字符串出参,
等级为保存时点的快照固化值(见 models/customer_rating.py)。
"""
model_config = ConfigDict(from_attributes=True)
@@ -33,8 +28,6 @@ class CustomerOut(BaseModel):
owner_personnel_id: int | None = None
owner_personnel_name: str | None = None
remark: str | None = None
rating_total: str | None = None
rating_grade_name: str | None = None
class CustomerCreateIn(BaseModel):
@@ -104,14 +97,6 @@ class CustomerQuery(PageQuery):
)
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
# 排序白名单(全项目首个列表排序参数):非法值由 Literal 拦下、经全局
# 异常处理器转 400 —— 不把排序字段透传给 SQL,避免注入面
order_field: Literal["rating"] | None = Field(
default=None, description="排序字段白名单:rating=客户评级总分(等级由总分判定、同向)"
)
order_type: Literal["asc", "desc"] | None = Field(
default=None, description="排序方向:asc 正序 / desc 倒序(未评级恒在末尾)"
)
class CustomerOwnershipIn(BaseModel):
-52
View File
@@ -77,55 +77,3 @@ class RatingGradeUpdateIn(RatingGradeCreateIn):
前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。
"""
class CustomerRatingOut(BaseModel):
"""客户评级响应(每客户一行;总分与分值按红线以字符串出参)。
grade_name 是保存时点按等级映射**快照固化**的值(无外键):等级表后续
增删改不影响已保存的评级结论;为 NULL 表示保存时未命中任何等级
(总分低于全部下限或等级表被清空)。
"""
customer_id: int
score_ship_amount: str
score_receipt_rate: str
score_profit_rate: str
score_contract_cooperation: str
score_site_construction: str
total_score: str
grade_name: str | None = None
remark: str | None = None
scorer_name: str
create_time: datetime
update_time: datetime
class CustomerRatingSaveIn(BaseModel):
"""保存客户评级(每客户一行,整体替换语义)。
五项分值为 0-20 的数字字符串(schema 只拦格式;0≤分≤20 与总分等级
判定在 service 层审计上下文内完成 —— 失败的 400 同样留痕)。总分由
服务端按五项重算,**不采信前端回传的总分**。
"""
model_config = ConfigDict(str_strip_whitespace=True)
score_ship_amount: str = Field(description="发货额评分(0-20 数字字符串)")
score_receipt_rate: str = Field(description="回款率评分(0-20 数字字符串)")
score_profit_rate: str = Field(description="毛利率评分(0-20 数字字符串)")
score_contract_cooperation: str = Field(description="合同履约配合能力评分(0-20 数字字符串)")
score_site_construction: str = Field(description="现场规范施工能力评分(0-20 数字字符串)")
remark: str | None = Field(default=None, max_length=255, description="备注")
@field_validator(
"score_ship_amount",
"score_receipt_rate",
"score_profit_rate",
"score_contract_cooperation",
"score_site_construction",
)
@classmethod
def _check_score(cls, value: str) -> str:
"""单项分值须为有限数字(值域 0-20 由 service 层判定)。"""
return _parse_score(value, label="评分")
+5 -30
View File
@@ -1,59 +1,34 @@
"""系统设置(公司信息 + 站点信息)请求/响应模型。
"""系统设置(公司信息)请求/响应模型。
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
顺着复用链泄漏到匿名端点。
2026-10-20 精简(用户确认):删除系统副标题与企业档案字段(信用代码/
地址/电话/邮箱);新增站点标题(浏览器标签页/侧边栏副标题)与站点图标
(浏览器标签页 favicon)。public 端点只需登录页/标签页展示字段,两者
恰好同集合,但仍保留独立 schema 作为白名单防线。
"""
from pydantic import BaseModel, ConfigDict, Field
class SettingOut(BaseModel):
"""公司信息与站点信息(管理端)。"""
"""公司信息(管理端)。"""
company_name: str
logo_url: str | None = None
site_title: str | None = None
brand_slogan: str | None = None
footer_text: str | None = None
icp_no: str | None = None
favicon_url: str | None = None
class SettingPublicOut(BaseModel):
"""公司信息与站点信息(匿名端点白名单):登录页/浏览器标签页展示所需字段。"""
"""公司信息(匿名端点白名单):登录页展示所需的两个字段。"""
company_name: str
logo_url: str | None = None
site_title: str | None = None
brand_slogan: str | None = None
footer_text: str | None = None
icp_no: str | None = None
favicon_url: str | None = None
class SettingUpdateIn(BaseModel):
"""公司信息与站点信息更新(**部分更新**语义)。
"""公司信息更新。
str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1
后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。
仅提交的字段生效(``exclude_unset``),未提交或显式 null 的字段保持
不变;可选文本传空串等价清空(落库为 null、前端回退内置默认)。
company_name 若提交必须非空(DB 非空约束)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
company_name: str | None = Field(
default=None, min_length=1, max_length=64, description="公司名称(提交则须非空)"
)
site_title: str | None = Field(default=None, max_length=64, description="站点标题")
brand_slogan: str | None = Field(default=None, max_length=64, description="品牌标语")
footer_text: str | None = Field(default=None, max_length=64, description="页脚版权文本")
icp_no: str | None = Field(default=None, max_length=32, description="ICP 备案号")
company_name: str = Field(min_length=1, max_length=64, description="公司名称")
+209 -11
View File
@@ -20,10 +20,11 @@
import logging
from collections.abc import Iterable
from datetime import date
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, defer
from app.core.constants import (
ACTION_APPROVE,
@@ -36,7 +37,7 @@ from app.core.constants import (
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import ApprovalOrder, ApprovalStep, User
from app.models import ApprovalOrder, ApprovalStep, Business, User
from app.models.approval import (
APPROVAL_ACTION_APPROVE,
APPROVAL_ACTION_AUTO_APPROVE,
@@ -113,6 +114,7 @@ def submit_order(
ref_id: int,
refer_no: str,
submitter: User,
snapshot: dict | None = None,
) -> ApprovalOrder:
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
@@ -122,6 +124,10 @@ def submit_order(
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
``snapshot`` 是「提交时」的业务内容(由调用方组装,Decimal 已转 str);
落库时包一层信封并固化采集时间——审批期间业务再改,复核者看到的仍是
送审内容。开关关闭的自动通过路径同样写快照,留痕不走样。
"""
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
@@ -135,6 +141,17 @@ def submit_order(
order_type=order_type,
ref_id=ref_id,
refer_no=refer_no,
version_no=_next_version(session, order_type, ref_id),
snapshot_json=(
{
"schema_version": 1,
"order_type": order_type,
"captured_at": datetime.now().isoformat(timespec="seconds"),
"data": snapshot,
}
if snapshot is not None
else None
),
submitter_id=submitter.id,
submitter_name=submitter.username,
)
@@ -165,6 +182,35 @@ def submit_order(
return order
def pending_matches_snapshot(
session: Session, *, order_type: int, ref_id: int, snapshot: dict | None
) -> bool:
"""在途审批单的送审内容是否与给定快照一致(用于「内容未变不重提」)。
只比较快照信封里的 ``data`` 部分——``captured_at`` 每次采集都不同,
参与比较会让判断恒为不一致。V11 之前的历史单没有快照,一律视为不一致
(保守重提:宁可多提一次,不能漏审)。
"""
if snapshot is None:
return False
pending = (
session.execute(
select(ApprovalOrder).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
ApprovalOrder.status.in_(_PENDING_STATUSES),
)
)
.scalars()
.all()
)
for order in pending:
stored = order.snapshot_json or {}
if stored.get("data") == snapshot:
return True
return False
def withdraw_pending_orders(
session: Session, *, order_type: int, ref_id: int, operator: User
) -> int:
@@ -250,15 +296,15 @@ def act(
operator_name=operator.username,
)
)
# 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以
# 「当前生效行」身份留在表里、被 M3 核算快照算进工资;方案事实由
# 审批单留痕与操作日志 request_data 承载(分摊语义落定后如需保留
# 历史版本,在 ④ 设计时演进)。审批单的清理由调用方(act 内联)
# 委托 business_repository 完成 —— service 层互调、不触 commit。
# 驳回=方案未获批准:把该业务的生效方案行置历史(is_active=0,
# **不删行**——V11 起 append-only 版本化),防止被否决的方案以
# 「当前生效行」身份被 M3 核算快照算进工资;历史方案仍可在业务
# 详情 allocation_history 回溯。委托 business_repository 完成
# —— service 层互调、不触 commit。
if rejected and order.order_type == 1:
from app.repositories import business_repository
business_repository.delete_allocations(session, order.ref_id)
business_repository.deactivate_allocations(session, order.ref_id)
# 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿;
# 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。
@@ -299,11 +345,18 @@ def act(
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
"""分页查询审批单(准入由 api 层依赖判定)。"""
"""分页查询审批单(准入由 api 层依赖判定)。
排除 snapshot_json 列(月结快照含按人明细,可达数十~上百 KB):列表出参
本就不含快照,整列加载会让翻页负担随快照体积线性放大;详情走
`get_order` 按需读取(与操作日志列表排除 request_data 同一理由,
见 operation_log_repository.list_logs)。
"""
filters = _order_filters(query)
rows = (
session.execute(
select(ApprovalOrder)
.options(defer(ApprovalOrder.snapshot_json))
.where(*filters)
.order_by(ApprovalOrder.id.desc())
.offset(query.offset)
@@ -360,8 +413,152 @@ def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operato
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id)
def _next_version(session: Session, order_type: int, ref_id: int) -> int:
"""同一对象类型+引用对象的下一个提交版本号(从 1 递增)。
**必须用锁读(当前读),不能用普通 MAX 查询**:普通 SELECT 在
REPEATABLE READ 下是快照读,读到的是本事务首次一致读的快照;请求级
鉴权(deps.get_current_user 与业务共用同一 DbSession)那次 SELECT 已把
快照锚定在请求开始时刻,此后即使父行锁等到并发事务提交,MAX 仍停在
旧快照上——两个请求会算出同一个 version_no。锁读是当前读,永远读最新
已提交数据。
**不能写成 select(func.max(...)).with_for_update()**:MySQL 官方明确
「Locking clauses are not permitted in queries with aggregate functions
or GROUP BY」,聚合结果上的 FOR UPDATE 语义无保证。改锁「该 ref 的现有
版本行」,最大值在 Python 侧算——同 no_generator 的既有套路。只取
version_no 单列,不把 snapshot_json 拉进内存。
"""
versions = (
session.execute(
select(ApprovalOrder.version_no)
.where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
)
.with_for_update()
)
.scalars()
.all()
)
return (max(versions) if versions else 0) + 1
def allocation_content(session: Session, business: Business) -> dict:
"""二次分配方案的审批内容(提单快照与历史单回退共用同一结构)。
业务主行已存在但客户联查落空(客户被删等异常)时返回空字典——调用方
以 ``or None`` 归一,避免生成「有快照但内容为空」的信封。
"""
from app.repositories import business_repository
row = business_repository.get_with_names(session, business.id)
if row is None:
return {}
_, customer_name, owner_name, assistant_name = row
allocations = business_repository.list_allocations(session, business.id)
total = sum((a.ratio for a in allocations), Decimal("0"))
return {
"business_no": business.business_no,
"business_date": business.business_date.isoformat() if business.business_date else None,
"customer_name": customer_name,
"owner_personnel_name": owner_name,
"assistant_personnel_name": assistant_name,
"sale_amount": _amount_text(business.total_sale_amount),
"cost_amount": _amount_text(business.total_cost_amount),
"allocations": [
{
"personnel_id": a.personnel_id,
"personnel_name": a.personnel_name,
"ratio": str(a.ratio),
}
for a in allocations
],
"allocation_total": str(total.quantize(Decimal("0.01"))),
"allocation_balance": str((Decimal("100") - total).quantize(Decimal("0.01"))),
}
def _amount_text(value: Decimal | None) -> str | None:
"""金额文本化(Decimal 全程不转 float;None 保留为空,不伪造 0.00)。"""
return str(value) if value is not None else None
def settlement_content(session: Session, settlement_id: int) -> dict | None:
"""月结审批内容(汇总 + 按人明细;提单快照与历史单回退共用)。"""
from app.models import MonthlySettlement
from app.repositories import settlement_repository
settlement = session.get(MonthlySettlement, settlement_id)
if settlement is None:
return None
items = settlement_repository.list_items(session, settlement_id)
return {
"settlement_month": settlement.settlement_month,
"total_performance": _amount_text(settlement.total_performance),
"total_payable": _amount_text(settlement.total_payable),
"person_count": settlement.person_count,
"items": [
{
"personnel_name": item.personnel_name,
"role_code": item.role_code,
"calc_type": item.calc_type,
"performance_sum": str(item.performance_sum),
"payable_sum": str(item.payable_sum),
"basic_amount": _amount_text(item.basic_amount),
"salary_mode": item.salary_mode,
"net_amount": _amount_text(item.net_amount),
"is_probation": item.is_probation,
}
for item in items
],
}
def adjustment_content(session: Session, adjustment_id: int) -> dict | None:
"""调整单审批内容(提单快照与历史单回退共用)。"""
from app.models import AdjustmentOrder
adjustment = session.get(AdjustmentOrder, adjustment_id)
if adjustment is None:
return None
return {
"order_no": adjustment.order_no,
"settlement_month": adjustment.settlement_month,
"personnel_name": adjustment.personnel_name,
"direction": adjustment.direction,
"amount": str(adjustment.amount),
"reason": adjustment.reason,
}
def _content_of(session: Session, order: ApprovalOrder) -> tuple[dict | None, str]:
"""审批内容出参:优先提交时快照,历史单回退当前内容并显式标注来源。"""
if order.snapshot_json:
return order.snapshot_json, "snapshot"
if order.order_type == 1:
business = session.get(Business, order.ref_id)
data = allocation_content(session, business) if business is not None else None
elif order.order_type == 2:
data = settlement_content(session, order.ref_id)
elif order.order_type == 3:
data = adjustment_content(session, order.ref_id)
else:
data = None
if not data:
return None, "missing"
return {
"schema_version": 1,
"order_type": order.order_type,
"captured_at": None,
"data": data,
}, "live"
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
"""出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。"""
"""出参组装(含 steps 时间线与审批内容)。调用方须保证处于 commit 之前。"""
steps = (
session.execute(
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
@@ -371,6 +568,7 @@ def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut
)
detail = ApprovalOrderDetailOut.model_validate(order)
detail.steps = [ApprovalStepOut.model_validate(s) for s in steps]
detail.snapshot, detail.content_source = _content_of(session, order)
return detail
+65 -16
View File
@@ -54,6 +54,7 @@ from app.models.business import (
from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import business_repository, customer_repository, personnel_repository
from app.schemas.business import (
AllocationVersionOut,
BusinessCreateIn,
BusinessDetailOut,
BusinessItemIn,
@@ -281,6 +282,17 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
for a in allocations
]
out.allocation_hint = _allocation_hint(allocations)
out.allocation_history = [
AllocationVersionOut(
personnel_id=a.personnel_id,
personnel_name=a.personnel_name,
ratio=str(a.ratio),
version_no=a.version_no,
is_active=a.is_active,
create_time=a.create_time,
)
for a in business_repository.list_allocation_versions(session, business_id)
]
return out
@@ -335,6 +347,20 @@ def _build_allocation_rows(
return rows
def _plan_changed(old_rows: list[SecondaryAllocation], new_rows: list[SecondaryAllocation]) -> bool:
"""方案内容(参与人 + 比例)是否真的变了。
比较口径是**集合**(人员 ID 与其比例的组合),与提交顺序无关——送审
快照同样按库内生效行的顺序生成,若按顺序比较,用户只是把行拖了个
序就会被判成「变了」,进而无谓地新增版本与重提审批单。
"""
def _key(row: SecondaryAllocation) -> tuple[int, Decimal]:
return (row.personnel_id, Decimal(row.ratio).quantize(Decimal("0.01")))
return {_key(r) for r in old_rows} != {_key(r) for r in new_rows}
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
@@ -433,6 +459,7 @@ def create_business(
ref_id=business.id,
refer_no=business.business_no,
submitter=operator,
snapshot=approval_service.allocation_content(session, business) or None,
)
customer = customer_repository.get_by_id(session, business.customer_id)
out = _to_out((business, customer.company_name if customer else None, None, None))
@@ -507,28 +534,51 @@ def update_business(
business_repository.replace_items(
session, business_id, _build_item_rows(business_id, payload.items)
)
# 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
# 前置场景(月结在 ④,快照固化后按同一哲学隔离)
old_ids = {
a.personnel_id for a in business_repository.list_allocations(session, business_id)
}
business_repository.replace_allocations(
# 方案:内容真的变了才动版本行(内容未变不新增版本,避免「只改备注」
# 也堆出一个方案版本);空数组=清空=把生效行置历史
old_allocations = business_repository.list_allocations(session, business_id)
new_rows = _build_allocation_rows(
session,
business_id,
_build_allocation_rows(
session, business_id, payload.secondary_allocations, old_ids=old_ids
),
payload.secondary_allocations,
old_ids={a.personnel_id for a in old_allocations},
)
# 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回
# 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批
if not payload.secondary_allocations:
business_repository.replace_allocations(session, business_id, [])
elif _plan_changed(old_allocations, new_rows):
business_repository.replace_allocations(session, business_id, new_rows)
# 汇总先重算:送审快照里的销售额/成本额必须反映本次编辑后的值,
# 放到提单之后会固化成上一版本的金额
_recalc_totals(session, business)
# 提单/撤单与方案同步生命周期:
# - 方案为空 → 仅撤回在途单(旧在途单挂着已不存在的方案继续走审批)
# - 送审内容与在途单一致 → 保留原单,不撤回不重建(避免反复保存
# 刷出「撤回+新建」的审批留痕噪音)
# - 其余 → 提新单(内含撤旧建新)
if payload.secondary_allocations:
approval_service.submit_order(
snapshot = approval_service.allocation_content(session, business) or None
if not approval_service.pending_matches_snapshot(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
refer_no=business.business_no,
submitter=operator,
)
snapshot=snapshot,
):
try:
approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
refer_no=business.business_no,
submitter=operator,
snapshot=snapshot,
)
except IntegrityError as exc:
# version_no 唯一键的并发冲突(取号是锁读后此项理论不可达,
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062
# 翻译成可重试的业务提示,其余完整性错误原样上抛
if _is_duplicate_entry(exc):
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
raise
else:
approval_service.withdraw_pending_orders(
session,
@@ -536,7 +586,6 @@ def update_business(
ref_id=business_id,
operator=operator,
)
_recalc_totals(session, business)
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
out = _to_out((business, customer.company_name, owner_name, assistant_name))
+11 -38
View File
@@ -31,7 +31,7 @@ from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageQuery, PageResult
from app.models import Customer, OwnershipChangeLog, Personnel, User
from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import customer_rating_repository, customer_repository, personnel_repository
from app.repositories import customer_repository, personnel_repository
from app.schemas.customer import (
CustomerCreateIn,
CustomerOut,
@@ -46,17 +46,8 @@ from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
def _to_out(
customer: Customer,
owner_name: str | None,
rating_total: object | None = None,
rating_grade: str | None = None,
) -> CustomerOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。
rating_total 由 LEFT JOIN 带出(Decimal 或 None),按分值红线转字符串
出参;新建客户/未评级客户不传,保持 null。
"""
def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
return CustomerOut(
id=customer.id,
company_name=customer.company_name,
@@ -69,8 +60,6 @@ def _to_out(
owner_personnel_id=customer.owner_personnel_id,
owner_personnel_name=owner_name,
remark=customer.remark,
rating_total=str(rating_total) if rating_total is not None else None,
rating_grade_name=rating_grade,
)
@@ -117,7 +106,7 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
"""分页查询客户(附归属人员姓名与评级快照;支持评级列排序)。"""
"""分页查询客户(附归属人员姓名)。"""
rows = customer_repository.list_customers(
session,
offset=query.offset,
@@ -125,8 +114,6 @@ def list_customers(session: Session, query: CustomerQuery) -> PageResult[Custome
keyword=query.keyword,
customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id,
order_field=query.order_field,
order_type=query.order_type,
)
total = customer_repository.count_customers(
session,
@@ -134,12 +121,8 @@ def list_customers(session: Session, query: CustomerQuery) -> PageResult[Custome
customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id,
)
records = [
_to_out(customer, owner_name, rating_total, rating_grade)
for customer, owner_name, rating_total, rating_grade in rows
]
return PageResult[CustomerOut](
records=records,
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
@@ -154,8 +137,8 @@ def get_customer(session: Session, customer_id: int) -> CustomerOut:
row = customer_repository.get_with_owner_name(session, customer_id)
if row is None:
raise NotFoundError("客户不存在")
customer, owner_name, rating_total, rating_grade = row
return _to_out(customer, owner_name, rating_total, rating_grade)
customer, owner_name = row
return _to_out(customer, owner_name)
def create_customer(
@@ -258,7 +241,7 @@ def update_customer(
_ensure_customer_type(payload.customer_type)
if row is None:
raise NotFoundError("客户不存在")
customer, owner_name, rating_total, rating_grade = row
customer, owner_name = row
duplicate = customer_repository.get_by_company_owner(
session, payload.company_name, payload.owner_name
@@ -277,7 +260,7 @@ def update_customer(
try:
# 响应在 commit 之前构造(同 user_service 顶部说明)
out = _to_out(customer, owner_name, rating_total, rating_grade)
out = _to_out(customer, owner_name)
session.commit()
except IntegrityError as exc:
# 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写
@@ -325,11 +308,6 @@ def delete_customer(
company_name = customer.company_name # 固化:对象随删除失效
try:
# 评级行 FK RESTRICT:先删评级行再删客户主行(同事务一次 commit;
# 评级是附属快照,不构成「有关联业务」的删除阻碍)
rating = customer_rating_repository.get_by_customer_id(session, customer_id)
if rating is not None:
customer_rating_repository.delete(session, rating)
customer_repository.delete(session, customer)
session.commit()
except IntegrityError as exc:
@@ -384,7 +362,7 @@ def change_ownership(
):
if row is None:
raise NotFoundError("客户不存在")
customer, _, rating_total, rating_grade = row
customer, _ = row
if payload.new_owner_personnel_id is not None:
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
@@ -407,12 +385,7 @@ def change_ownership(
),
)
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
out = _to_out(
customer,
new_person.personnel_name if new_person else None,
rating_total,
rating_grade,
)
out = _to_out(customer, new_person.personnel_name if new_person else None)
session.commit()
logger.info(
"调整客户归属 | customer_id=%s | old=%s | new=%s",
+15 -187
View File
@@ -1,14 +1,12 @@
"""客户评级业务编排(等级映射配置 + 每客户评级落库)。
"""客户评级等级配置业务编排。
评级定位(对照表 #25):**不影响绩效金额**。自 2026-10-19 起评分每客户
落库(customer_ratings,一行一客户快照),等级映射仍是本服务承载的
**判定表**,判定规则为按 sort_order 升序取首个 min_score ≤ 总分的等级,
max_score 仅展示。两条硬约束(违反会算错等级):
评级计算器定位(对照表 #25):仅打分工具、不影响绩效金额,评分记录不落库
——本表承载的等级映射是**判定表**,判定规则为按 sort_order 升序取首个
min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反会算错等级):
- 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串
到 Decimal 的转换与落库;值域校验(等级区间 0≤min≤max≤100、单项评分
0≤分≤20)在**审计上下文内**完成,失败的 400 同样留痕(2026-10-13 定稿
口径)
到 Decimal 的转换与落库;0≤min≤max≤100 的区间校验在**审计上下文内**
完成,失败的 400 同样留痕(2026-10-13 定稿口径)
- 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表
失效。判定为**闭区间两两相交**:任一其他等级满足
``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。
@@ -16,9 +14,9 @@ max_score 仅展示。两条硬约束(违反会算错等级):
相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order
靠前的等级,相邻无歧义
删除允许清空:等级表对客户评级**无外键引用**(评级快照固化等级名、不
回指本表),物理删除无悬空风险,也不伤及已评级客户的历史结论;全部删空
后新评级只记总分、等级为 NULL(前端已对应提示),故不强制保留任何一条。
删除允许清空:等级表无下游引用(评分记录不落库,判定只读本表现值、不
回溯历史快照),物理删除无悬空风险;全部删空后计算器只显示总分、不出
等级(前端已对应提示),故不强制保留任何一条。
"""
import logging
@@ -35,15 +33,9 @@ from app.core.constants import (
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import CustomerRating, CustomerRatingGrade, User
from app.repositories import customer_rating_repository, customer_repository, rating_repository
from app.schemas.rating import (
CustomerRatingOut,
CustomerRatingSaveIn,
RatingGradeCreateIn,
RatingGradeOut,
RatingGradeUpdateIn,
)
from app.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
@@ -52,8 +44,6 @@ logger = logging.getLogger(__name__)
# 分值域上下界(Decimal 常量,禁用 float 参与比较)
_SCORE_FLOOR = Decimal("0")
_SCORE_CEIL = Decimal("100")
# 单项评分上界(五项各 0-20,总分 0-100)
_ITEM_SCORE_CEIL = Decimal("20")
def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut:
@@ -262,11 +252,10 @@ def delete_grade(
) -> None:
"""物理删除评级等级。
等级表对客户评级**无外键引用** —— 评级行快照固化等级名(见
``models/customer_rating.py`` 的论证),判定只读本表现值,故可物理删除
且无需锁行防并发引用;删除等级不改变已评级客户的历史结论。
等级表无下游引用 —— 评级是纯计算器不落评分记录(对照表 #25),判定
只读本表现值、不回溯历史快照,故可物理删除且无需锁行防并发引用。
**允许清空**:全部删空后新评级只记总分、等级为 NULL(前端已对应提示),
**允许清空**:全部删空后计算器只显示总分、不出等级(前端已对应提示),
不强制保留任何一条。
:raises NotFoundError: 评级等级不存在
@@ -289,164 +278,3 @@ def delete_grade(
rating_repository.delete(session, grade)
session.commit()
logger.info("删除评级等级 | grade_name=%s", grade_name)
# ==================== 客户评级(每客户一行,落库) ====================
def resolve_grade(
grades: list[CustomerRatingGrade], total_score: Decimal
) -> CustomerRatingGrade | None:
"""等级判定纯函数:按 sort_order 升序取首个 min_score ≤ 总分的等级。
与前端打分卡的实时判定同规则同序(前端按数值比较,此处 Decimal 比较
——同一规则的两端实现,测试锁定边界行为一致)。无命中(总分低于全部
下限或等级表为空)返回 None,由调用方决定展示与落库(快照记 NULL)。
"""
# id 兜底让同序等级次序稳定(纯函数也接受未持久化对象:id 可能为 None)
for grade in sorted(grades, key=lambda g: (g.sort_order, g.id or 0)):
if grade.min_score <= total_score:
return grade
return None
def _to_rating_out(rating: CustomerRating) -> CustomerRatingOut:
"""客户评级行转响应模型。分值 Decimal → str(分值红线出参口径)。"""
return CustomerRatingOut(
customer_id=rating.customer_id,
score_ship_amount=str(rating.score_ship_amount),
score_receipt_rate=str(rating.score_receipt_rate),
score_profit_rate=str(rating.score_profit_rate),
score_contract_cooperation=str(rating.score_contract_cooperation),
score_site_construction=str(rating.score_site_construction),
total_score=str(rating.total_score),
grade_name=rating.grade_name,
remark=rating.remark,
scorer_name=rating.scorer_name,
create_time=rating.create_time,
update_time=rating.update_time,
)
def _ensure_item_scores(scores: list[Decimal]) -> None:
"""单项评分值域:0 ≤ 分 ≤ 20(总分由五项求和自然落在 0-100)。
须在审计上下文内调用,失败也要留痕(同 _ensure_score_range 口径)。
"""
for score in scores:
if not (_SCORE_FLOOR <= score <= _ITEM_SCORE_CEIL):
raise BadRequestError("单项评分须在 0~20 之间")
def get_customer_rating(session: Session, customer_id: int) -> CustomerRatingOut | None:
"""查某客户当前评级;未评级返回 None(接口信封 data 为 null)。
「未评级」是正常态而非错误,不用 404 表达;客户不存在才是 NotFound。
:raises NotFoundError: 客户不存在
"""
customer = customer_repository.get_by_id(session, customer_id)
if customer is None:
raise NotFoundError("客户不存在")
rating = customer_rating_repository.get_by_customer_id(session, customer_id)
return _to_rating_out(rating) if rating is not None else None
def save_customer_rating(
session: Session,
customer_id: int,
payload: CustomerRatingSaveIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CustomerRatingOut:
"""保存客户评级(upsert:首次创建,再次保存整体覆盖)。
总分与等级**服务端重算**:总分按五项求和(不采信前端回传),等级按
保存时点的等级映射判定后**快照固化**进评级行 —— 等级表后续增删改
不影响该行(见 ``models/customer_rating.py``)。
:raises NotFoundError: 客户不存在
:raises BadRequestError: 单项评分须在 0~20 之间(审计上下文内校验,留痕)
"""
# 只读预取在审计上下文之前:客户不存在(NotFound 失败)也能留下可读记录,
# 摘要需要客户名
customer = customer_repository.get_by_id(session, customer_id)
existing = customer_rating_repository.get_by_customer_id(session, customer_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_RATING,
action=ACTION_CREATE if existing is None else ACTION_UPDATE,
summary=f"评定客户「{customer.company_name if customer else f'#{customer_id}'}」评级",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 值域校验在审计上下文内:分值非法的 400 也要留痕(同等级配置口径)
item_scores = [
_to_decimal(payload.score_ship_amount),
_to_decimal(payload.score_receipt_rate),
_to_decimal(payload.score_profit_rate),
_to_decimal(payload.score_contract_cooperation),
_to_decimal(payload.score_site_construction),
]
_ensure_item_scores(item_scores)
if customer is None:
raise NotFoundError("客户不存在")
total_score = sum(item_scores, _SCORE_FLOOR)
grade = resolve_grade(rating_repository.list_all(session), total_score)
def _apply(row: CustomerRating) -> None:
"""把五项分值/总分/等级快照写入评级行(创建与并发兜底共用)。"""
(
row.score_ship_amount,
row.score_receipt_rate,
row.score_profit_rate,
row.score_contract_cooperation,
row.score_site_construction,
) = item_scores
row.total_score = total_score
row.grade_name = grade.grade_name if grade is not None else None
row.remark = payload.remark
row.scorer_id = operator.id
row.scorer_name = operator.username
try:
if existing is None:
rating = CustomerRating(
customer_id=customer_id,
scorer_id=operator.id,
scorer_name=operator.username,
)
_apply(rating)
customer_rating_repository.create(session, rating)
else:
_apply(existing)
rating = existing
out = _to_rating_out(rating)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发为同一客户首次评级会撞
# uk_customer_ratings_customer_id,回滚后转为更新兜底
session.rollback()
if not is_duplicate_entry(exc):
raise
raced = customer_rating_repository.get_by_customer_id(session, customer_id)
if raced is None:
# 1062 但行不在:数据态异常,交全局 IntegrityError 兜底
raise
_apply(raced)
rating = raced
out = _to_rating_out(rating)
session.commit()
logger.info(
"保存客户评级 | customer_id=%s | total=%s | grade=%s",
customer_id,
out.total_score,
out.grade_name,
)
return out
+22 -8
View File
@@ -13,11 +13,13 @@ from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
MODULE_SETTLEMENT,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import (
MonthlySettlement,
@@ -248,19 +250,30 @@ def submit(
),
meta=meta,
):
settlement = settlement_repository.get_by_id(session, settlement_id)
settlement = settlement_repository.get_by_id(session, settlement_id, for_update=True)
if settlement is None:
raise NotFoundError("月结不存在")
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
raise BadRequestError("仅草稿或已驳回状态可提交")
order = approval_service.submit_order(
session,
order_type=_SETTLEMENT_ORDER_TYPE,
ref_id=settlement.id,
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
submitter=operator,
)
try:
order = approval_service.submit_order(
session,
order_type=_SETTLEMENT_ORDER_TYPE,
ref_id=settlement.id,
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
submitter=operator,
# 月结明细在生成时已固化;提交时快照让复核者看到的是送审版本,
# 而不是审批期间若发生重生成后的内容
snapshot=approval_service.settlement_content(session, settlement.id),
)
except IntegrityError as exc:
# version_no 唯一键的并发冲突(提交前已锁月结行,此项理论不可达,
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062 翻译成
# 可重试的业务提示,其余完整性错误原样上抛
if is_duplicate_entry(exc):
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
raise
settlement.approval_order_no = order.order_no
if order.status == SETTLEMENT_STATUS_APPROVED:
mark_locked(session, settlement, operator=operator)
@@ -573,6 +586,7 @@ def _wire_adjustment_approval(session: Session, order, operator: User) -> None:
ref_id=order.id,
refer_no=order.order_no,
submitter=operator,
snapshot=approval_service.adjustment_content(session, order.id),
)
order.status = (
ADJUSTMENT_STATUS_EFFECTIVE
+43 -136
View File
@@ -1,17 +1,12 @@
"""系统设置(公司信息 + 站点信息)业务编排。
"""系统设置(公司信息)业务编排。
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo/favicon 只存**相对
路径**(uploads/logo、uploads/favicon 下固定名覆盖),文件不入库的理由:
图片进数据库会让每次读取与备份放大传输,且无法独立替换;DB 只持有指针。
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo 只存**相对路径**
(uploads/logo/ 下固定名覆盖),文件不入库的理由:图片进数据库会让每次
读取与备份放大传输,且无法独立替换;DB 只持有一行指针。
图像安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
logo 安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与
覆盖任意文件。
logo 与 favicon 相互独立(2026-10-20):
- logo 用于登录页品牌位;
- favicon 用于浏览器标签页图标,未上传时前端使用内置 favicon.svg
(不再跟随 logo——公司 logo 是横版,作标签页图标常被裁切)。
"""
import logging
@@ -29,26 +24,15 @@ from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
# magic bytes:PNG 8 字节签名;JPEG 以 SOI 起始;ICO 以 00 00 01 00 起始
# magic bytes:PNG 固定 8 字节签名;JPEG 以 SOI + APP0/JFIF 起始
_MAGIC = {
"png": b"\x89PNG\r\n\x1a\n",
"jpg": b"\xff\xd8\xff",
"ico": b"\x00\x00\x01\x00",
}
# 允许的后缀集合:logo 沿用 png/jpg(页眉/登录页展示);favicon 额外接受 ico
LOGO_EXTS = ("png", "jpg")
FAVICON_EXTS = ("png", "jpg", "ico")
ASSET_MAX_BYTES = 2 * 1024 * 1024 # 2MB:展示尺寸上限,防上传滥用
LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2MB:页眉/登录页展示尺寸上限,防上传滥用
SETTINGS_ID = 1 # 全系统单行主键
# 公司档案中的可选文本字段:提交空串等价清空(落库 null,前端回退内置默认)
_OPTIONAL_TEXT_FIELDS = ("site_title", "brand_slogan", "footer_text", "icp_no")
# 资源种类 → 中文名(错误提示与审计摘要用)
_ASSET_LABELS = {"logo": "logo", "favicon": "站点图标"}
_ASSET_EXTS = {"logo": LOGO_EXTS, "favicon": FAVICON_EXTS}
def get_setting(session: Session) -> SystemSetting:
"""取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。"""
@@ -61,15 +45,10 @@ def get_setting(session: Session) -> SystemSetting:
def to_out(setting: SystemSetting) -> SettingOut:
"""管理端出参(图片地址带更新时间版本参数,替换后浏览器不再用旧缓存)。"""
"""管理端出参(logo_url 带更新时间版本参数,替换图片后浏览器不再用旧缓存)。"""
return SettingOut(
company_name=setting.company_name,
logo_url=_asset_url(setting, "logo"),
site_title=setting.site_title,
brand_slogan=setting.brand_slogan,
footer_text=setting.footer_text,
icp_no=setting.icp_no,
favicon_url=_asset_url(setting, "favicon"),
logo_url=_logo_url(setting),
)
@@ -77,26 +56,15 @@ def to_public_out(setting: SystemSetting) -> SettingPublicOut:
"""匿名端点出参(白名单字段,独立 schema 见模块说明)。"""
return SettingPublicOut(
company_name=setting.company_name,
logo_url=_asset_url(setting, "logo"),
site_title=setting.site_title,
brand_slogan=setting.brand_slogan,
footer_text=setting.footer_text,
icp_no=setting.icp_no,
favicon_url=_asset_url(setting, "favicon"),
logo_url=_logo_url(setting),
)
def _asset_url(setting: SystemSetting, kind: str) -> str | None:
"""资源 URL(kind=logo/favicon);未上传返回 None。
版本号取**上传计数器**而非 update_time:同扩展名替换时固定文件名不变
(logo_path 值不变),MySQL 的 ON UPDATE 不会刷新 update_time,用它做
?v= 会导致浏览器一直用旧图(2026-10-20 用户反馈的换图不刷新问题)。
"""
if not getattr(setting, f"{kind}_path"):
def _logo_url(setting: SystemSetting) -> str | None:
if not setting.logo_path:
return None
version = getattr(setting, f"{kind}_version") or 0
return f"/api/v1/system/settings/{kind}/public?v={version}"
version = int(setting.update_time.timestamp()) if setting.update_time else 0
return f"/api/v1/system/settings/logo/public?v={version}"
def update_company(
@@ -106,34 +74,20 @@ def update_company(
operator: User,
meta: RequestMeta | None = None,
) -> SettingOut:
"""更新公司信息与站点信息(部分更新 + 审计留痕)。
仅提交的字段生效(``exclude_unset``:未提交/显式 null 保持原值),
便于「只改名称」「只改站点标题」等场景不覆盖其他字段;可选文本传
空串等价清空(落库 null)。
"""
changes = payload.model_dump(exclude_unset=True, exclude_none=True)
summary = "修改公司信息"
if "company_name" in changes:
summary = f"修改公司信息(公司名称「{changes['company_name']}」)"
"""更新公司名称(审计留痕)。"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SYSTEM_SETTINGS,
action=ACTION_UPDATE,
summary=summary,
request_data=changes or None,
summary=f"修改公司名称为「{payload.company_name}」",
request_data=payload.model_dump(),
),
meta=meta,
):
setting = get_setting(session)
if "company_name" in changes:
# schema 已保证提交值非空(min_length=1)
setting.company_name = changes["company_name"]
for field in _OPTIONAL_TEXT_FIELDS:
if field in changes:
setattr(setting, field, changes[field].strip() or None)
setting.company_name = payload.company_name.strip()
session.commit()
return to_out(setting)
@@ -146,39 +100,17 @@ def save_logo(
operator: User,
meta: RequestMeta | None = None,
) -> SettingOut:
"""上传公司 logo(登录页品牌位;png/jpg ≤2MB)。"""
return _save_asset(session, content, ext, kind="logo", operator=operator, meta=meta)
def save_favicon(
session: Session,
content: bytes,
ext: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettingOut:
"""上传站点图标(浏览器标签页;png/jpg/ico ≤2MB)。"""
return _save_asset(session, content, ext, kind="favicon", operator=operator, meta=meta)
def _save_asset(
session: Session,
content: bytes,
ext: str,
*,
kind: str,
operator: User,
meta: RequestMeta | None,
) -> SettingOut:
"""校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
"""上传 logo:校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。
"""
label = _ASSET_LABELS[kind]
allowed = _ASSET_EXTS[kind]
ext = ext.lower()
_validate_upload(content, ext, allowed=allowed, label=label)
if ext not in _MAGIC:
raise BadRequestError("logo 仅支持 png/jpg 格式")
if len(content) > LOGO_MAX_BYTES:
raise BadRequestError("logo 文件不能超过 2MB")
if not content.startswith(_MAGIC[ext]):
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
with audit_service.audit(
session,
@@ -186,79 +118,54 @@ def _save_asset(
record=AuditRecord(
module=MODULE_SYSTEM_SETTINGS,
action=ACTION_UPDATE,
summary=f"更新{label}",
summary="更新公司 logo",
request_data={"ext": ext, "size": len(content)},
),
meta=meta,
):
setting = get_setting(session)
old_path = getattr(setting, f"{kind}_path")
old_path = setting.logo_path
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
# 由写入覆盖,无需也不会误删新文件
target = _asset_dir(kind) / f"{kind}.{ext}"
target = _logo_dir() / f"logo.{ext}"
target.write_bytes(content)
setattr(setting, f"{kind}_path", f"{kind}/{kind}.{ext}")
# 版本号每次上传必增:即使同扩展名覆盖(路径不变)也能破浏览器缓存
setattr(setting, f"{kind}_version", (getattr(setting, f"{kind}_version") or 0) + 1)
setting.logo_path = f"logo/logo.{ext}"
session.commit()
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
if old_ext and old_ext != ext and old_ext in allowed:
if old_ext and old_ext != ext:
try:
_cleanup_old_asset(kind, old_ext)
_cleanup_old_logo(old_ext)
except OSError:
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
# 成功的更新会被记成失败审计并 500 —— 审计与事实相反
logger.warning(
"旧%s清理失败(不影响本次更新)| ext=%s", label, old_ext, exc_info=True
)
logger.info("更新%s | size=%s ext=%s", label, len(content), ext)
# 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反
logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True)
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
return to_out(setting)
def read_logo(session: Session) -> tuple[Path, str]:
"""按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。"""
return _read_asset(session, "logo")
def read_favicon(session: Session) -> tuple[Path, str]:
"""按库中记录定位站点图标文件,返回 (路径, 扩展名)。未上传抛 404。"""
return _read_asset(session, "favicon")
def _read_asset(session: Session, kind: str) -> tuple[Path, str]:
label = _ASSET_LABELS[kind]
setting = get_setting(session)
path_str = getattr(setting, f"{kind}_path")
if not path_str:
raise NotFoundError(f"尚未上传{label}")
path = get_settings().upload_dir / path_str
if not setting.logo_path:
raise NotFoundError("尚未上传 logo")
path = get_settings().upload_dir / setting.logo_path
if not path.is_file():
raise NotFoundError(f"{label}文件不存在,请重新上传")
raise NotFoundError("logo 文件不存在,请重新上传")
return path, path.suffix.lstrip(".")
def _validate_upload(content: bytes, ext: str, *, allowed: tuple[str, ...], label: str) -> None:
"""后缀白名单 + magic bytes + 大小三重校验(同口径供 logo/favicon 复用)。"""
if ext not in allowed:
raise BadRequestError(f"{label}仅支持 {'/'.join(allowed)} 格式")
if len(content) > ASSET_MAX_BYTES:
raise BadRequestError(f"{label}文件不能超过 2MB")
if not content.startswith(_MAGIC[ext]):
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
def _asset_dir(kind: str) -> Path:
directory = get_settings().upload_dir / kind
def _logo_dir() -> Path:
directory = get_settings().upload_dir / "logo"
directory.mkdir(parents=True, exist_ok=True)
return directory
def _cleanup_old_asset(kind: str, old_ext: str) -> None:
if old_ext in _ASSET_EXTS[kind]:
old_file = _asset_dir(kind) / f"{kind}.{old_ext}"
def _cleanup_old_logo(old_ext: str) -> None:
if old_ext in _MAGIC:
old_file = _logo_dir() / f"logo.{old_ext}"
if old_file.exists():
old_file.unlink(missing_ok=True)
+30
View File
@@ -159,6 +159,25 @@ uv run pytest tests/integration -q # 仅集成
**实施中发现并修复**:`alembic upgrade --sql` 在 Windows 控制台按 GBK 输出,重定向生成的 `V10__*.sql` 被 `test_schema_sync` 按 UTF-8 读取时抛 `UnicodeDecodeError`——导出命令须加 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1`(已写入 backend/README)。
### code-review 遗留 2 项(2026-10-20 完成:方案版本化 / 审批内容快照)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_approval_flow.py`(扩) | **提交时快照固化**(version_no=1、content_source=snapshot、方案比例/合计/余额与业务编号);编辑方案为 80% 后新单 V2、旧单快照仍 50.00 且旧在途单撤回;**历史单无快照回退当前内容并标 live**(captured_at=null);引用对象缺失且无快照 → missing;**方案版本化**:编辑后 history=[(1,0,50.00),(2,1,80.00)]、生效行仅 80.00;驳回后生效行为空但历史行保留(is_active=0) | +5 |
| `tests/unit/test_approval_schema.py` | 对象类型筛选 1/2/3 均可、0/4 拒绝(修复原上界 `le=2` 导致调整单审批单无法筛选);不传类型表示全部;驳回必须填意见、通过可不填 | 6 |
| `tests/unit/test_approval_helpers.py` | **方案内容比较**(`business_service._plan_changed`):完全相同/行序不同判未变、比例变/人员增减/同比例换人判已变、比例写法 `50` 与 `50.00` 等价、空方案对照 | 10 |
| `tests/integration/test_secondary_allocation.py`(扩) | **同一会话内旧方案行随置历史同步刷新**(Core UPDATE 的会话同步策略退化为不刷新时,行对象会停留在 is_active=1 的过期内存值) | +1 |
| `tests/integration/test_approval_flow.py`(扩) | **送审内容未变不重提**:方案与金额都没动 → 方案不新增版本、审批单不重提(旧单仍待财务复核);**只改金额** → 方案版本不变但审批单 version_no+1 且新快照金额为改后值;**改方案比例** → 方案版本+1、审批单 version_no+1 且旧单撤回;**无在途单时保存仍提新单**(保守规则);`pending_matches_snapshot` 四态(一致/不一致/无在途单/快照为 None) | +5 |
### 审批单版本号并发防线(2026-10-21 完成:V12 唯一键 + 锁读取号)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_approval_version.py`(新) | **取号读最新已提交值**(两会话法:s1 锚定快照 + s2 提交 version_no=2 → s1 必须取到 3,守住「快照读 vs 当前读」根因);**取号查询带 FOR UPDATE 且不含聚合**(事件钩子抓真实 SQL——MySQL 官方不允许聚合函数携带锁子句);**唯一键生效**(重复 (order_type, ref_id, version_no) 直插撞 1062);**编辑路径冲突收敛为 400** 且业务数据不写坏(monkeypatch `_next_version` 恒返回 1);**正常路径版本号仍递增**(1→2→3) | +5 |
| `tests/unit/test_models.py`(扩) | 审批单唯一键覆盖 (order_type, ref_id, version_no) 三列且约束名与迁移 DDL 一致 | +1 |
运行(2026-10-22,容器恢复后):`tests/integration/test_approval_version.py` + `test_dashboard.py` → **16 passed**;全量 `uv run pytest -q` → **897 passed(12:02)**;`uv run ruff check .` 绿、前端 `pnpm type-check` 绿。首次全量跑出两处**测试代码**适配(非产品缺陷):① 工作台 `_insert_approval` 用同一 `(order_type=2, ref_id=0)` 连插两张单,撞 V12 新唯一键 → 每张单改给不同 `ref_id`;② 新用例跨模块引用 `switched_on` 夹具(pytest 夹具只在定义模块与 conftest 可见,报 fixture not found)→ 用 `_set_switch` 本地重建。
## 三、运行记录
| 日期 | 范围 | 用例数 | 结果 | 备注 |
@@ -184,6 +203,8 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-19 | **code-review 修复回归** | 全量 862 | ✅ 862 passed(9:36) | **M4 工作台 + 报表 3 必须 + 8 建议修复**:普通用户落地页/守卫(报表集成 27、工作台集成 11、报表单元 17,新增 10 例);部门汇总 5% 按计入次数累计并与月结 `total_payable` 对拍、聚合行舍入差;工作台待办按角色过滤、逾期命中与阈值生效、终止业务排除、`warnings.total` 截断、趋势跨月归属。ruff 绿;type-check + build 绿 |
| 2026-10-20 | e2e 全链路 | — | ✅ login 8 / system 13 / role_home 4 / report 12 / calc_settlement 8 | 重启后端加载新代码后实跑:普通用户落地页与受限页回退、报表四页/导出/打印、核算与月结链路全过。`report_flow` 回款统计改为展开目标桶后按本次业务编号定位明细,不依赖桶累计值,可重复运行 |
| 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 |
| 2026-10-20 | code-review 遗留 2 项 | 全量 875 | ✅ 875 passed(12:13) | **方案版本化 + 审批内容快照**(V11 迁移):审批单 `version_no`/`snapshot_json`,方案行 `is_active` 版本化;编辑/驳回不再物理删方案;历史单无快照时 live 回退并显式标注。新增集成 7 + 单元 6;定点审批/月结/核算/业务/报表全绿;ruff 与 type-check/build 绿 |
| 2026-10-20 | 遗留项自评审收口 | 全量 891 | ✅ 891 passed(10:23) | **列表 defer 快照列**(审批列表/工作台待办/超时预警)+ **送审内容未变不重提、方案未变不新增版本**(两个 version_no 语义解耦)+ **月结提交加行锁**。新增单元 10 + 集成 5;单元 525、集成定点 96 全绿 |
| 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 |
| 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
@@ -197,6 +218,15 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-19 | 经理个人户成员行取该人全部绩效 | 团队基数只含个人户行,成员行却含普通行——**基数≠成员合计,同一笔业绩在成员行与未分配重复** | 普通行与个人户分行记账;经理成员行只取个人户金额,普通行留在上级团队/未分配 |
| 2026-10-19 | 导出取数与超限校验在审计上下文之外 | 超限 400 与取数异常**不留失败日志**,与 M4 计划 T12「导出 400/失败审计」不符 | `_export_payload` 移入 `with audit`;静态标题映射(AuditRecord frozen) |
| 2026-10-19 | 月份只校验长度 | `2026-13` 传到 `calendar.monthrange` 抛 ValueError → **500**;导出手工构造模型抛裸 ValidationError 同样 500 | 新增 `core/month.py`(格式+01-12+年份);四类 query、月结、调整单接入;导出 ValidationError 转 400 |
| 2026-10-20 | 审批类型筛选上界写死 2 | `ApprovalQuery.order_type` 为 `le=2`:调整单(order_type=3)审批单在列表无法筛选,只能翻全量 | 上界改 3;单元用例锁定 1/2/3 与越界拒绝 |
| 2026-10-20 | 编辑业务时送审快照取旧汇总 | `update_business` 中 `_recalc_totals` 在提单之后,快照里的销售额/成本额会固化上一版本金额 | 汇总重算移到提单之前;快照用例断言送审内容与本次编辑后方案一致 |
| 2026-10-20 | 快照金额把「空」写成 0.00 | 业务尚无成本时 `total_cost_amount=None` 被文本化成 `0.00`,复核者会把「无成本」误读成「成本为零」 | `_amount_text(None)` 保留为空,前端显示「—」 |
| 2026-10-20 | 每次保存带方案的业务都刷出版本与审批留痕 | 只改备注、方案一字未动,也会「置历史+新增版本+撤回旧单+提新单」,方案历史与审批留痕被噪音淹没,两个 `version_no` 语义混同 | 判据统一为**送审内容**:方案未变不新增版本行;在途单送审内容一致则不重提(`pending_matches_snapshot`,只比 `data`) |
| 2026-10-20 | 三处列表查询整列拉快照 | 审批单列表、工作台待办、审批超时预警都用 `select(ApprovalOrder)` 取全列,快照 JSON 可达上百 KB,翻页与工作台负担随快照体积线性放大 | 三处加 `defer(ApprovalOrder.snapshot_json)`(沿用操作日志列表排除 `request_data` 的既有模式) |
| 2026-10-20 | 月结提交未锁行 | 并发/双击提交同一月结时两次 `_next_version` 可能算出同一 `version_no`,送审次序失真 | `settlement_repository.get_by_id` 加 `for_update` 参数,提交路径锁行 |
| 2026-10-21 | 审批单 `version_no` 无唯一约束 | 应用层取号即使正确,并发下 DB 层无兜底;且唯一键是「一个对象至多一张在途单」红线之外版本语义的最后防线 | V12 迁移加 `uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`;加键前先重排存量重复分组(V11 把存量一律置 1 而 V11 前重提是新建行,直接加键必报 1062) |
| 2026-10-21 | `_next_version` 是快照读 | 请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已锚定 REPEATABLE READ 快照——父行锁只串行化写入时刻、**不刷新快照**,并发提单的 MAX 停在请求开始时刻 → 版本号重复 | 取号改锁读(`select(version_no)...with_for_update()`,当前读),最大值 Python 侧算;**不能用聚合 + FOR UPDATE**——MySQL 官方不允许聚合函数携带锁子句 |
| 2026-10-21 | `act()` 驳回清空方案的注释与行为相反 | 注释写「清空该业务的二次分配行…如需保留历史版本,在 ④ 设计时演进」,而 V11 已实现 append-only(只置 `is_active=0` 行不删)——误导后来者按旧语义改代码 | 注释改为「置历史不删行,历史方案可回溯」 |
| 2026-10-19 | 前端报表五处状态缺陷 | 打印超限判定恒假(静默截断)、部门下钻 query 未读取、回款多桶共用一个 drill 状态(面板串行)、summary 在请求序号守卫前写入(合计串号)、e2e 全页行数选择器必失败 | 分别修正判定/读取 query/按桶独立状态+数组参数/`onData` 回调/限定主桶表;补 e2e 多桶展开、下钻、截断、部门打印用例 |
| 2026-10-19 | 工作台权限收紧后守卫仍回退 `/dashboard` | 普通用户登录/直连受限页被反复重定向到同一受限页 → **无法进入系统**(导航被判定无限重定向) | `router/access.ts` 统一准入与落地页解析(工作台 → 业务数据 → 回款);登录回跳经 `canAccessPath` 校验;菜单/面包屑复用同一判定 |
| 2026-10-19 | 部门汇总应发只计一次全公司 5% | 月结每位有聚合行的经理各计一次 5%,签字稿少计 N-1 份;**导出/打印金额小于月结应发** | 团队行增 `has_aggregate/aggregate_amount`(优先冻结聚合行);对账改按聚合额求和,新增「聚合行舍入差」吸收分列 q2 尾差;补「与 `total_payable` 对拍」用例 |
+22 -97
View File
@@ -21,6 +21,7 @@
import uuid
from decimal import Decimal
from pathlib import Path
import pytest
from sqlalchemy import delete, select, update
@@ -36,7 +37,6 @@ from app.models import (
BusinessRole,
BusinessStatusLog,
Customer,
CustomerRating,
CustomerRatingGrade,
MonthlySettlement,
OperationLog,
@@ -164,10 +164,6 @@ def _purge_test_data() -> None:
session.execute(
delete(OwnershipChangeLog).where(OwnershipChangeLog.customer_id.in_(customer_ids))
)
# 评级行 FK RESTRICT:必须先于客户删除(V7 起客户评级每客户一行)
session.execute(
delete(CustomerRating).where(CustomerRating.customer_id.in_(customer_ids))
)
session.execute(delete(Customer).where(Customer.id.in_(customer_ids)))
# 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条
session.execute(
@@ -194,103 +190,32 @@ def _purge_test_data() -> None:
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
# 开关参数恢复种子基线:测试提交的变更批次由 t_ 前缀测试账号创建,
# 按**批次操作人**删除本轮的参数条目与批次,解析后回退到种子值(开关=0)。
# 不用 id 阈值:种子条目的主键在不同环境的漂移库中并不相同(历史库
# 可能补过种子行),按操作人识别测试产物与环境数据分布无关。
test_batch_ids = list(
session.execute(
select(ParamChangeBatch.id).where(ParamChangeBatch.operator_name.like("t\\_%"))
).scalars()
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 28
# (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另
# 插入两个审批开关的 10-19 生效行 id 23/24,V10 插入预警规则参数
# id 26/27/28,均属种子),删新条目与新批次后解析回退到种子值
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 28)).scalars())
if stray_values:
stray_batches = {v.batch_id for v in stray_values}
session.execute(delete(ParamValue).where(ParamValue.id > 28))
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
session.execute(
update(SystemSetting)
.where(SystemSetting.id == 1)
.values(company_name="衍水集团", logo_path=None)
)
if test_batch_ids:
session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(test_batch_ids)))
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(test_batch_ids)))
session.commit()
# ==================== 系统设置与上传文件的快照还原 ====================
#
# 系统设置是**真实单行数据**,logo/favicon 是真实上传文件,没有 t_ 前缀可供
# 兜底清理。测试(尤其 logo 上传用例)会改公司名、覆盖/删除真实图片 ——
# 必须在用例结束后还原,否则跑一次测试就会破坏开发环境的品牌与 logo。
_ASSET_KINDS = ("logo", "favicon")
def _snapshot_company_info() -> dict | None:
"""快照系统设置单行(无行时返回 None)。"""
factory = get_session_factory()
with factory() as session:
row = session.get(SystemSetting, 1)
if row is None:
return None
return {column.name: getattr(row, column.name) for column in SystemSetting.__table__.columns}
def _restore_company_info(snapshot: dict | None) -> None:
if snapshot is None:
return
factory = get_session_factory()
with factory() as session:
row = session.get(SystemSetting, 1)
if row is None:
return
for key, value in snapshot.items():
if key != "update_time": # 更新时间由数据库 ON UPDATE 维护
setattr(row, key, value)
session.commit()
def _snapshot_assets() -> dict[str, dict[str, bytes]]:
"""快照上传目录里的真实图片(logo/favicon)。"""
from app.core.config import get_settings
root = get_settings().upload_dir
snapshot: dict[str, dict[str, bytes]] = {}
for kind in _ASSET_KINDS:
directory = root / kind
snapshot[kind] = (
{path.name: path.read_bytes() for path in directory.glob("*") if path.is_file()}
if directory.is_dir()
else {}
)
return snapshot
def _restore_assets(snapshot: dict[str, dict[str, bytes]]) -> None:
"""还原上传目录:删除测试新增文件、回写被测试覆盖的原文件。"""
from app.core.config import get_settings
root = get_settings().upload_dir
for kind, files in snapshot.items():
directory = root / kind
directory.mkdir(parents=True, exist_ok=True)
for path in directory.glob("*"):
if path.is_file() and path.name not in files:
path.unlink(missing_ok=True)
for name, content in files.items():
(directory / name).write_bytes(content)
logo_dir = Path(__file__).resolve().parents[2] / "uploads" / "logo"
if logo_dir.is_dir():
for f in logo_dir.iterdir():
f.unlink(missing_ok=True)
@pytest.fixture(autouse=True)
def _preserve_company_info():
"""每个用例前快照系统设置与上传图片,结束后还原(见上方说明)。"""
info = _snapshot_company_info()
assets = _snapshot_assets()
yield
# 先还原文件:数据库路径随后回指同一批文件
_restore_assets(assets)
_restore_company_info(info)
@pytest.fixture(autouse=True)
def _purge_after_each_test(_preserve_company_info):
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。
依赖 ``_preserve_company_info``:保证快照在该夹具之前建立、在该夹具
清理**之后**还原(夹具 teardown 为逆序)。
"""
def _purge_after_each_test():
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。"""
yield
_purge_test_data()
@@ -388,3 +388,332 @@ class TestModuleAccess:
user = make_user([code])
user["headers"] = auth_headers(user)
assert client.get(_APPROVALS, headers=user["headers"]).status_code == 200
class TestContentSnapshot:
"""审批单内容快照与版本号(code-review 遗留②)。
守的是:复核者看到的是**送审时**的方案,而不是审批期间被改过的当前
方案;历史单(升级前无快照)必须显式标注「当前内容回退」,不能把
当前内容冒充送审内容。
"""
def test_提交时快照固化_再改方案不回写旧单(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
assert first["version_no"] == 1
assert first["content_source"] == "snapshot"
snapshot = first["snapshot"]
assert snapshot["schema_version"] == 1
assert snapshot["order_type"] == 1
assert snapshot["captured_at"], "快照须固化采集时间"
assert snapshot["data"]["business_no"] == data["business_no"]
assert snapshot["data"]["allocations"][0]["ratio"] == "50.00"
assert snapshot["data"]["allocation_total"] == "50.00"
assert snapshot["data"]["allocation_balance"] == "50.00"
# 编辑方案为 80%:旧在途单撤回,新单 V2 快照取新值;旧单快照不变
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = business_payload(
detail["customer_id"],
secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
second = _latest_order(client, finance["headers"], data["business_no"])
assert second["version_no"] == 2
assert second["snapshot"]["data"]["allocations"][0]["ratio"] == "80.00"
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
assert old["status"] == 5, "重新提交须撤回旧在途单"
assert old["snapshot"]["data"]["allocations"][0]["ratio"] == "50.00"
def test_历史单无快照_回退当前内容并标注(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
"""升级前的审批单没有快照列:回退读当前内容,但来源必须标 live。"""
from app.core.db import get_session_factory
from app.models import ApprovalOrder
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
factory = get_session_factory()
with factory() as session:
row = session.get(ApprovalOrder, order["id"])
assert row is not None
row.snapshot_json = None
session.commit()
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()["data"]
assert detail["content_source"] == "live"
assert detail["snapshot"]["captured_at"] is None
assert detail["snapshot"]["data"]["business_no"] == data["business_no"]
def test_引用对象缺失且无快照_内容缺失(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
from app.core.db import get_session_factory
from app.models import ApprovalOrder
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
factory = get_session_factory()
with factory() as session:
row = session.get(ApprovalOrder, order["id"])
assert row is not None
row.snapshot_json = None
row.ref_id = 999_999_999
session.commit()
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()["data"]
assert detail["content_source"] == "missing"
assert detail["snapshot"] is None
def _payload_from_detail(detail: dict, **overrides: object) -> dict:
"""按业务详情原样重建提交载荷(明细行照抄,用于「内容未变」的场景)。"""
items = [
{
"product_name": it["product_name"],
"product_category": it["product_category"],
"quantity": it["quantity"],
"sale_price": it["sale_price"],
}
for it in detail["items"]
]
body = business_payload(
detail["customer_id"],
items=items,
secondary_allocations=detail["secondary_allocations"],
)
body.update(overrides)
return body
class TestNoRedundantResubmit:
"""送审内容未变不重提、方案未变不新增版本(版本与留痕降噪)。
守的是:反复保存同一份业务(只改备注)不应刷出一串方案版本与
「撤回+新建」的审批留痕;而内容真变了(金额或方案)必须重提,
否则审批人看到的是过期内容。
"""
def test_方案与金额均未变_不新增版本也不重提(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
assert first["status"] == 1
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(detail)
payload["remark"] = "只改了备注"
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
# 方案版本不增
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
(1, 1)
], "方案内容未变不应新增版本行"
# 审批单不重提:仍是原来那一张、仍待财务复核
rows = client.get(
_APPROVALS,
params={"keyword": data["business_no"], "pageSize": 10},
headers=finance["headers"],
).json()["data"]["list"]
assert len(rows) == 1, f"送审内容未变不应重提,实得 {len(rows)} 张单"
assert rows[0]["id"] == first["id"]
assert rows[0]["status"] == 1, "在途单不应被撤回"
assert rows[0]["version_no"] == 1
def test_只改金额不改方案_方案版本不变但审批单重提(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
"""金额变了 → 送审内容变了 → 必须重提;但方案本身没变 → 不新增版本。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(detail)
payload["items"][0]["sale_price"] = "200.00"
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
(1, 1)
], "方案未变不应新增版本行"
second = _latest_order(client, finance["headers"], data["business_no"])
assert second["version_no"] == 2, "金额变了必须重提"
assert second["snapshot"]["data"]["sale_amount"] == "20000.00"
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
assert old["status"] == 5
def test_改方案比例_方案版本与审批单版本同时递增(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(
detail, secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}]
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
(1, 0),
(2, 1),
], "方案变了应新增版本并把旧行置历史"
second = _latest_order(client, finance["headers"], data["business_no"])
assert second["version_no"] == 2
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
assert old["status"] == 5
def test_无在途单时保存仍提新单(
self, client: TestClient, admin: dict, finance: dict, leader: dict, switched_on
) -> None:
"""保守规则:只有「存在在途单且内容一致」才不重提。
方案已审批通过后再保存,仍按原语义重新送审。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
client.post(
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
)
client.post(
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=leader["headers"]
)
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(detail)
payload["remark"] = "已通过后再保存"
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
rows = client.get(
_APPROVALS,
params={"keyword": data["business_no"], "pageSize": 10},
headers=finance["headers"],
).json()["data"]["list"]
assert len(rows) == 2, "无在途单时保存应重新送审"
assert max(r["version_no"] for r in rows) == 2
class TestPendingSnapshotMatch:
"""在途单送审内容一致性判定(approval_service.pending_matches_snapshot)。"""
def test_三种情形(self, client: TestClient, admin: dict, finance: dict, switched_on) -> None:
from app.core.db import get_session_factory
from app.services import approval_service
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
snapshot = order["snapshot"]["data"]
factory = get_session_factory()
with factory() as session:
# 1) 内容一致 → True
assert (
approval_service.pending_matches_snapshot(
session,
order_type=1,
ref_id=data["id"],
snapshot=snapshot,
)
is True
)
# 2) 内容不同 → False
assert (
approval_service.pending_matches_snapshot(
session, order_type=1, ref_id=data["id"], snapshot={"allocations": []}
)
is False
)
# 3) 无在途单 → False(引用对象换成别的)
assert (
approval_service.pending_matches_snapshot(
session, order_type=1, ref_id=999_999_999, snapshot=snapshot
)
is False
)
# 4) 快照为 None → False
assert (
approval_service.pending_matches_snapshot(
session, order_type=1, ref_id=data["id"], snapshot=None
)
is False
)
class TestAllocationVersions:
"""二次分配方案版本化(code-review 遗留①)。
守的是:编辑替换与驳回清空只把旧行置历史(is_active=0),不物理删除,
方案参与人与比例可回溯;生效行始终唯一,核算取数不受历史行干扰。
"""
def test_编辑方案产生新版本_旧版本保留(
self, client: TestClient, admin: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in detail["allocation_history"]] == [
(1, 1)
]
payload = business_payload(
detail["customer_id"],
secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
history = [(h["version_no"], h["is_active"], h["ratio"]) for h in detail["allocation_history"]]
assert history == [(1, 0, "50.00"), (2, 1, "80.00")]
assert [a["ratio"] for a in detail["secondary_allocations"]] == ["80.00"]
def test_驳回后生效行为空_历史行保留(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 2, "opinion": "方案不合理"},
headers=finance["headers"],
)
assert resp.status_code == 200, resp.text
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert detail["secondary_allocations"] == [], "被驳回方案不得留在生效行"
assert detail["allocation_hint"] is None
history = detail["allocation_history"]
assert len(history) == 1
assert history[0]["version_no"] == 1
assert history[0]["is_active"] == 0
assert history[0]["ratio"] == "50.00"
@@ -0,0 +1,183 @@
"""审批单版本号并发防线(V12 唯一键 + 锁读取号)。
守的是:
- **取号必须读最新已提交值**:请求级鉴权与业务共用同一 DbSession,鉴权
的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻;父行锁只
串行化写入时刻、不刷新快照——取号若是快照读,并发提单会算出同一个
version_no(两会话法做行为级回归锁,不依赖真多线程)
- **取号查询带 FOR UPDATE 且不用聚合**:MySQL 官方不允许聚合函数携带
锁子句(Locking clauses are not permitted in queries with aggregate
functions or GROUP BY),聚合上的 FOR UPDATE 语义无保证
- **DB 层唯一键生效**:即使应用层取号缺陷被绕过,重复
(order_type, ref_id, version_no) 也必须在库上被拒(1062)
- **冲突收敛为 400**:编辑路径撞唯一键 → 审计上下文回滚 → 业务提示
可重试,业务数据不得写坏
- **正常路径版本号仍递增**:锁读改写不破坏既有 1→2→3 行为
"""
import uuid
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import event, text
from app.core.db import get_engine, get_session_factory
from app.core.db_errors import is_duplicate_entry
from app.models import ApprovalOrder
from tests.integration.factories import business_payload
from tests.integration.test_approval_flow import (
_BUSINESSES,
_create_with_plan,
_latest_order,
_person,
_set_switch,
)
pytestmark = pytest.mark.integration
@pytest.fixture
def switched_on(client: TestClient, admin: dict) -> None:
"""把二次分配审批开关置 1(用例结束由 conftest 恢复种子基线)。
pytest 夹具只在定义它的模块与 conftest 中可见,跨模块直接引用会报
``fixture 'switched_on' not found``;这里用 ``_set_switch`` 本地重建。
"""
_set_switch(client, admin["headers"], "1")
class TestNextVersionLockRead:
"""取号查询的锁读口径(真实 SQL 形态 + 行为级快照回归)。"""
def test_取号查询带FOR_UPDATE_不含聚合(self, client: TestClient, admin: dict) -> None:
"""抓 _next_version 真实发出的 SQL(比本地重造语句再编译更贴真):
必须含 FOR UPDATE、不含 MAX( —— 聚合上的锁子句 MySQL 不保证语义。"""
captured: list[str] = []
def _spy(_conn, _cursor, statement, *_rest):
if "approval_orders" in statement and "version_no" in statement:
captured.append(statement)
engine = get_engine()
event.listen(engine, "before_cursor_execute", _spy)
try:
p = _person(client, admin["headers"])
_create_with_plan(client, admin["headers"], p["id"]) # 触发一次真实提单
finally:
event.remove(engine, "before_cursor_execute", _spy)
assert captured, "未捕获到取号查询"
assert any("FOR UPDATE" in sql.upper() for sql in captured), captured
assert not any("MAX(" in sql.upper() for sql in captured), captured
def test_版本号读最新已提交值_不受事务旧快照影响(self) -> None:
"""两会话法(无需线程):s1 先锚定快照 → s2 提交新版本 →
s1 取号必须读到 3 而非旧快照里的 2——守住「快照读 vs 当前读」根因。"""
from app.services import approval_service
factory = get_session_factory()
with factory() as s1:
# 锚定快照(模拟请求级鉴权读先于业务写发生)
s1.execute(text("SELECT order_no FROM approval_orders LIMIT 1")).all()
with factory() as s2:
order = ApprovalOrder(
order_no=f"AP-{uuid.uuid4().hex[:12]}",
order_type=1,
ref_id=987654321,
refer_no="YW-SNAPSHOT-TEST",
version_no=2,
status=5,
submitter_name="t_snapshot_probe",
)
s2.add(order)
s2.commit()
# s1 的普通读仍停在旧快照(该分组在快照里不存在)
stale = s1.execute(
text(
"SELECT COALESCE(MAX(version_no),0) FROM approval_orders "
"WHERE order_type=1 AND ref_id=987654321"
)
).scalar_one()
assert stale == 0, "前置失效:快照读应停在锚定时刻,先修隔离级别再跑本用例"
assert approval_service._next_version(s1, 1, 987654321) == 3, (
"取号必须读到最新已提交值(2+1=3),读到 2 说明退化为快照读"
)
s1.rollback()
def test_唯一键生效_重复版本号被拒(self) -> None:
"""重复 (order_type, ref_id, version_no) 直插必须撞 1062——
应用层取号即使被绕过,DB 也是最后一道防线。"""
from sqlalchemy.exc import IntegrityError
factory = get_session_factory()
with factory() as s:
common = {
"order_type": 1,
"ref_id": 987654322,
"refer_no": "YW-UK-TEST",
"status": 5,
"submitter_name": "t_uk_probe",
}
s.add(ApprovalOrder(order_no=f"AP-{uuid.uuid4().hex[:12]}", version_no=1, **common))
s.flush()
s.add(ApprovalOrder(order_no=f"AP-{uuid.uuid4().hex[:12]}", version_no=1, **common))
with pytest.raises(IntegrityError) as exc_info:
s.flush()
assert is_duplicate_entry(exc_info.value), "应为唯一键冲突(1062)而非其他完整性错误"
s.rollback()
def test_正常路径版本号仍递增(self, client: TestClient, admin: dict, switched_on) -> None:
"""常规编辑两次:送审版本号 1→2→3——锁读改写不得破坏既有行为。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, admin["headers"], data["business_no"])
assert first["version_no"] == 1
for ratio in ("60.00", "70.00"):
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()[
"data"
]
payload = business_payload(
detail["customer_id"],
secondary_allocations=[{"personnel_id": p["id"], "ratio": ratio}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
third = _latest_order(client, admin["headers"], data["business_no"])
assert third["version_no"] == 3
class TestConflictConvergence:
"""编辑路径撞版本号唯一键 → 收敛为可重试的 400,业务数据不写坏。"""
def test_编辑路径版本号冲突_收敛为400(
self, client: TestClient, admin: dict, switched_on, monkeypatch
) -> None:
from app.services import approval_service
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
before = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
# 模拟取号读到过期快照:恒返回 1(库中 V1 已存在)→ 撞唯一键
monkeypatch.setattr(approval_service, "_next_version", lambda *a, **k: 1)
payload = business_payload(
before["customer_id"],
secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 400, resp.text
assert "版本号生成重复" in resp.json()["message"]
monkeypatch.undo()
# 业务数据不得写坏:整个事务回滚,方案仍是 50.00(V1 生效行)
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [a["ratio"] for a in after["secondary_allocations"]] == ["50.00"]
assert [h["version_no"] for h in after["allocation_history"]] == [1]
-102
View File
@@ -11,7 +11,6 @@ append-only 完整性;「公司名称+负责人」组合唯一缺失会放行
"""
import uuid
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
@@ -377,104 +376,3 @@ class TestAudit:
latest = logs[0]
assert customer["company_name"] in latest.summary, "摘要应含客户名"
assert "→" in latest.summary, "摘要应体现新旧归属走向"
# ==================== 客户评级列(V11) ====================
_RATING_KEYS = (
"score_ship_amount",
"score_receipt_rate",
"score_profit_rate",
"score_contract_cooperation",
"score_site_construction",
)
def _rate(client: TestClient, headers: dict, customer_id: int, scores: list[str]) -> dict:
"""保存客户评级(五项 0-20),返回响应 data。"""
resp = client.put(
f"{_CUSTOMERS}/{customer_id}/rating",
json=dict(zip(_RATING_KEYS, scores, strict=True)),
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _list_by_keyword(client: TestClient, headers: dict, keyword: str, **params) -> list[dict]:
query: dict = {"keyword": keyword, "pageNum": 1, "pageSize": 50, **params}
resp = client.get(_CUSTOMERS, params=query, headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]["list"]
class TestRatingColumn:
"""客户评级列:列表带出评级快照,列头排序走服务端(未评级恒在末尾)。
列表是服务端分页,排序必须在 SQL 层完成 —— 前端只传
``order_field=rating`` + ``order_type``;MySQL 无 NULLS LAST,
未评级行用布尔键模拟,正序/倒序都在末尾。
"""
def _seed_three(self, client: TestClient, headers: dict) -> tuple[str, dict, dict, dict]:
"""建三名同批次客户:90 分(A)、0 分(D)、未评级。"""
token = uuid.uuid4().hex[:8]
keyword = f"t_公司_{token}"
high = _create_customer(client, headers, company_name=f"{keyword}_甲")
low = _create_customer(client, headers, company_name=f"{keyword}_乙")
none = _create_customer(client, headers, company_name=f"{keyword}_丙")
_rate(client, headers, high["id"], ["18"] * 5)
_rate(client, headers, low["id"], ["0"] * 5)
return keyword, high, low, none
def test_列表带出评级字段(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
keyword, high, low, none = self._seed_three(client, headers)
rows = _list_by_keyword(client, headers, keyword)
by_id = {row["id"]: row for row in rows}
assert Decimal(by_id[high["id"]]["rating_total"]) == Decimal("90")
assert by_id[high["id"]]["rating_grade_name"] == "A"
assert Decimal(by_id[low["id"]]["rating_total"]) == Decimal("0")
assert by_id[low["id"]]["rating_grade_name"] == "D"
assert by_id[none["id"]]["rating_total"] is None
assert by_id[none["id"]]["rating_grade_name"] is None
def test_评级列正序倒序_未评级恒末尾(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
keyword, high, low, none = self._seed_three(client, headers)
desc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="desc")
assert [row["id"] for row in desc] == [high["id"], low["id"], none["id"]]
asc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="asc")
assert [row["id"] for row in asc] == [low["id"], high["id"], none["id"]]
def test_非法排序字段_400(self, client: TestClient, normal: dict) -> None:
"""排序字段是白名单(Literal):不透传给 SQL,非法值 400。"""
resp = client.get(
_CUSTOMERS,
params={"order_field": "company_name", "order_type": "asc"},
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
def test_非法排序方向_400(self, client: TestClient, normal: dict) -> None:
resp = client.get(
_CUSTOMERS,
params={"order_field": "rating", "order_type": "sideways"},
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
def test_删除客户联动删评级行(self, client: TestClient, normal: dict) -> None:
"""评级行 FK RESTRICT:删除客户须先清评级行,否则 500。"""
headers = normal["headers"]
customer = _create_customer(client, headers)
_rate(client, headers, customer["id"], ["20"] * 5)
resp = client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers)
assert resp.status_code == 200, resp.text
assert (
client.get(f"{_CUSTOMERS}/{customer['id']}/rating", headers=headers).status_code == 404
)
+8 -2
View File
@@ -250,7 +250,13 @@ def leader(make_user, auth_headers) -> dict:
def _insert_approval(status: int) -> None:
"""直插在途审批单(避免依赖两次流转,专注工作台过滤口径)。"""
"""直插在途审批单(避免依赖两次流转,专注工作台过滤口径)。
``ref_id`` 仅作对象分组锚点、本用例不校验:**每张单必须给不同的
ref_id**——V12 的 ``uk_approval_orders_order_type_ref_version`` 要求
(order_type, ref_id, version_no) 唯一,同一假对象连插两张 version_no=1
会撞 1062。
"""
from app.core.db import get_session_factory
from app.models import ApprovalOrder
@@ -260,7 +266,7 @@ def _insert_approval(status: int) -> None:
ApprovalOrder(
order_no=f"AP{uuid.uuid4().hex[:12]}",
order_type=2,
ref_id=0,
ref_id=status,
refer_no="t_工作台",
status=status,
submitter_name="t_admin",
+4 -184
View File
@@ -21,17 +21,15 @@ from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import func, select
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 CustomerRating, CustomerRatingGrade, OperationLog
from app.services.rating_service import resolve_grade
from app.models import OperationLog
pytestmark = pytest.mark.integration
_GRADES = "/api/v1/business/rating-grades"
_CUSTOMERS = "/api/v1/base/customers"
# 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1))
_SEED = [
@@ -79,41 +77,6 @@ def _names(client: TestClient, headers: dict) -> list[str]:
return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]]
def _create_customer(client: TestClient, headers: dict) -> dict:
"""建一名 t_ 前缀测试客户(评级落库的被引用方,conftest 兜底清理)。"""
resp = client.post(
_CUSTOMERS,
json={
"company_name": f"t_评级客户_{uuid.uuid4().hex[:8]}",
"owner_name": "t_老板",
"customer_type": "personal",
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
_RATING_KEYS = (
"score_ship_amount",
"score_receipt_rate",
"score_profit_rate",
"score_contract_cooperation",
"score_site_construction",
)
def _rating_url(customer_id: int) -> str:
return f"{_CUSTOMERS}/{customer_id}/rating"
def _rating_payload(scores: list[str], remark: str | None = None) -> dict:
body: dict = dict(zip(_RATING_KEYS, scores, strict=True))
if remark is not None:
body["remark"] = remark
return body
def _logs_of(username: str) -> list[OperationLog]:
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
factory = get_session_factory()
@@ -295,8 +258,8 @@ class TestEdit:
class TestDelete:
"""删除允许清空(客户评级快照固化等级名、无外键引用)—— 删除后判定表
立即反映,二次删除 404,已评级客户的历史等级快照不受影响。"""
"""删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表
立即反映,二次删除 404。"""
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
@@ -350,146 +313,3 @@ class TestAudit:
]
assert failed, "失败操作未留痕"
assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0]
class TestResolveGrade:
"""判定纯函数边界:与前端打分卡同规则(sort_order 升序首个 min_score ≤ 总分)。
单边判定让边界分归 sort_order 靠前的等级:90→A、75→B、60→C、59.9→D;
等级表为空或总分低于全部下限时返回 None(落库等级为 NULL)。
"""
@staticmethod
def _seed_grades() -> list[CustomerRatingGrade]:
return [
CustomerRatingGrade(
grade_name=name,
min_score=Decimal(min_score),
max_score=Decimal(max_score),
sort_order=order,
)
for name, min_score, max_score, order in _SEED
]
def test_边界分归高档(self) -> None:
grades = self._seed_grades()
assert resolve_grade(grades, Decimal("90")).grade_name == "A"
assert resolve_grade(grades, Decimal("75")).grade_name == "B"
assert resolve_grade(grades, Decimal("60")).grade_name == "C"
def test_下限下方归最低档(self) -> None:
assert resolve_grade(self._seed_grades(), Decimal("59.9")).grade_name == "D"
def test_等级表为空_未命中(self) -> None:
assert resolve_grade([], Decimal("80")) is None
def test_低于全部下限_未命中(self) -> None:
grades = [
CustomerRatingGrade(
grade_name="A",
min_score=Decimal("90"),
max_score=Decimal("100"),
sort_order=1,
)
]
assert resolve_grade(grades, Decimal("50")) is None
class TestCustomerRating:
"""客户评级落库(V11):每客户一行、服务端重算总分与等级、等级快照固化。
五项各 0-20 → 总分 0-100;等级按保存时点判定并写入 grade_name(无外键)。
未评级返回 data=null(正常态);分数越界 400 且审计留痕。
"""
def test_未评级返回null(self, client: TestClient, normal: dict) -> None:
customer = _create_customer(client, normal["headers"])
resp = client.get(_rating_url(customer["id"]), headers=normal["headers"])
assert resp.status_code == 200, resp.text
assert resp.json()["data"] is None
def test_保存评级_服务端重算总分等级并留审计(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
put = client.put(
_rating_url(customer["id"]),
json=_rating_payload(["18", "20", "19", "17", "16"], remark="t_评级依据"),
headers=headers,
)
assert put.status_code == 200, put.text
data = put.json()["data"]
assert Decimal(data["total_score"]) == Decimal("90")
assert data["grade_name"] == "A"
assert data["remark"] == "t_评级依据"
assert data["scorer_name"] == normal["username"]
# 回读一致(列表评级列与弹窗回显同源)
got = client.get(_rating_url(customer["id"]), headers=headers).json()["data"]
assert Decimal(got["total_score"]) == Decimal("90")
assert got["grade_name"] == "A"
assert Decimal(got["score_ship_amount"]) == Decimal("18")
logs = [log for log in _logs_of(normal["username"]) if log.module == MODULE_RATING]
assert logs, "保存客户评级未留操作日志"
assert logs[0].success == 1
assert customer["company_name"] in logs[0].summary
assert "评级" in logs[0].summary
def test_重复保存覆盖同一客户仅一行(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
first = client.put(
_rating_url(customer["id"]), json=_rating_payload(["0"] * 5), headers=headers
)
assert first.status_code == 200, first.text
assert first.json()["data"]["grade_name"] == "D"
second = client.put(
_rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers
)
assert second.status_code == 200, second.text
assert Decimal(second.json()["data"]["total_score"]) == Decimal("100")
assert second.json()["data"]["grade_name"] == "A"
factory = get_session_factory()
with factory() as session:
count = session.execute(
select(func.count())
.select_from(CustomerRating)
.where(CustomerRating.customer_id == customer["id"])
).scalar_one()
assert count == 1, "同一客户出现多行评级(upsert 失效)"
def test_单项分数越界_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
for bad in (["21", "0", "0", "0", "0"], ["0", "-1", "0", "0", "0"]):
resp = client.put(
_rating_url(customer["id"]), json=_rating_payload(bad), headers=headers
)
assert resp.status_code == 400, resp.text
assert "0~20" in resp.json()["message"], resp.text
def test_客户不存在_404(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
assert client.get(_rating_url(999_999_999), headers=headers).status_code == 404
put = client.put(_rating_url(999_999_999), json=_rating_payload(["0"] * 5), headers=headers)
assert put.status_code == 404, put.text
def test_等级表清空后保存_等级为null(self, client: TestClient, normal: dict) -> None:
"""等级映射允许清空(快照设计前提):清空后评分照常保存,仅等级为 NULL。"""
headers = normal["headers"]
for name in ("A", "B", "C", "D"):
assert (
client.delete(
f"{_GRADES}/{_seed_id(client, headers, name)}", headers=headers
).status_code
== 200
)
customer = _create_customer(client, headers)
data = client.put(
_rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers
).json()["data"]
assert Decimal(data["total_score"]) == Decimal("100")
assert data["grade_name"] is None
@@ -247,6 +247,36 @@ class TestGuards:
class TestDeleteProtection:
"""RESTRICT 外键:被方案引用的人员禁删,方案随业务删除而清。"""
def test_同一会话内旧方案行同步为历史态(self, client: TestClient, admin: dict) -> None:
"""守「置历史后同一会话内的旧行必须同步刷新」这一行为:Core UPDATE
走的是 SQLAlchemy 的会话同步策略,条件复杂化时可能退化为不刷新,
届时行对象会停留在 is_active=1 的过期内存值(库中实际已置历史),
被序列化或参与判断就会呈现「生效态」的假象。"""
from app.core.db import get_session_factory
from app.models import SecondaryAllocation
from app.repositories import business_repository
p = _person(client, admin["headers"])
data = _create_with_allocations(
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
)
factory = get_session_factory()
with factory() as session:
loaded = business_repository.list_allocations(session, data["id"])
assert loaded and all(a.is_active == 1 for a in loaded)
business_repository.deactivate_allocations(session, data["id"])
# 同一 session 内的行对象必须已是历史态,不能停留在过期内存值
assert all(a.is_active == 0 for a in loaded), "旧行未随置历史同步刷新"
# 库中同样已置历史
rows = (
session.query(SecondaryAllocation)
.filter(SecondaryAllocation.business_id == data["id"])
.all()
)
assert rows and all(r.is_active == 0 for r in rows)
session.rollback()
def test_被方案引用的人员禁删(self, client: TestClient, admin: dict) -> None:
p = _person(client, admin["headers"])
data = _create_with_allocations(
+1 -123
View File
@@ -23,7 +23,6 @@ _SETTINGS = "/api/v1/system/settings"
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
_JPG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32
_ICO_BYTES = b"\x00\x00\x01\x00" + b"\x00" * 32
_TXT_BYTES = b"not an image at all"
@@ -32,10 +31,6 @@ def client() -> TestClient:
return TestClient(app)
# 系统设置单行与上传文件的快照/还原已上移到 integration/conftest.py,
# 对所有集成用例生效(不再只保护本模块)。
class TestCompanyInfo:
"""公司名称读取与更新。"""
@@ -89,30 +84,10 @@ class TestPublicEndpoint:
resp = client.get(f"{_SETTINGS}/public")
assert resp.status_code == 200
data = resp.json()["data"]
assert set(data) == {
"company_name",
"logo_url",
"site_title",
"brand_slogan",
"footer_text",
"icp_no",
"favicon_url",
}, "public 端点字段越界"
assert set(data) == {"company_name", "logo_url"}, "public 端点字段越界"
assert data["company_name"] == name
assert resp.headers.get("cache-control") is not None
def test_已删除的档案字段不再返回(self, client: TestClient) -> None:
"""V11 精简:副标题/信用代码/地址/电话/邮箱已从模型与契约中删除。"""
data = client.get(f"{_SETTINGS}/public").json()["data"]
for field in (
"system_subtitle",
"credit_code",
"company_address",
"contact_phone",
"contact_email",
):
assert field not in data, f"已删除字段仍出现在匿名端点:{field}"
def test_写操作匿名不可用(self, client: TestClient) -> None:
assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in {
401,
@@ -120,41 +95,6 @@ class TestPublicEndpoint:
}
class TestCompanyProfile:
"""站点标题/品牌信息(V11):部分更新语义与空串清空。"""
def test_部分更新_未提交字段保持不变(self, client: TestClient, admin: dict) -> None:
name = f"站点公司_{uuid.uuid4().hex[:6]}"
client.put(_SETTINGS, json={"company_name": name}, headers=admin["headers"])
put = client.put(_SETTINGS, json={"site_title": "测试站点标题"}, headers=admin["headers"])
assert put.status_code == 200, put.text
assert put.json()["data"]["site_title"] == "测试站点标题"
# 未提交的 company_name 保持不变(部分更新语义)
assert put.json()["data"]["company_name"] == name
got = client.get(_SETTINGS, headers=admin["headers"]).json()["data"]
assert got["company_name"] == name
assert got["site_title"] == "测试站点标题"
def test_可选文本传空串_清空为null(self, client: TestClient, admin: dict) -> None:
client.put(
_SETTINGS,
json={"brand_slogan": "t_临时标语", "icp_no": "t_备案号"},
headers=admin["headers"],
)
put = client.put(
_SETTINGS, json={"brand_slogan": "", "icp_no": ""}, headers=admin["headers"]
)
assert put.status_code == 200, put.text
assert put.json()["data"]["brand_slogan"] is None
assert put.json()["data"]["icp_no"] is None
def test_超长字段被拒(self, client: TestClient, admin: dict) -> None:
resp = client.put(_SETTINGS, json={"site_title": "x" * 65}, headers=admin["headers"])
assert resp.status_code == 400, resp.text
class TestLogoUpload:
"""logo 上传校验链。"""
@@ -195,65 +135,3 @@ class TestLogoUpload:
files={"file": ("logo.png", _PNG_BYTES, "application/octet-stream")},
)
assert resp.status_code == 401
def test_同扩展名替换_url版本号变化(self, client: TestClient, admin: dict) -> None:
"""同扩展名覆盖时路径与 update_time 都不变 —— 版本号必须递增,否则浏览器用旧图。"""
first = self._upload(client, admin, "logo.png", _PNG_BYTES).json()["data"]["logo_url"]
second = self._upload(client, admin, "logo.png", _PNG_BYTES).json()["data"]["logo_url"]
assert first and second
assert first != second, "同扩展名替换后图片 URL 未变化,浏览器会继续使用旧缓存"
class TestFaviconUpload:
"""站点图标上传校验链(独立于 logo;favicon 额外接受 ico)。"""
def _upload(self, client: TestClient, admin: dict, filename: str, content: bytes):
return client.post(
f"{_SETTINGS}/favicon",
files={"file": (filename, content, "application/octet-stream")},
headers=admin["headers"],
)
def test_合法ico上传成功且回读(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "favicon.ico", _ICO_BYTES)
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
assert out["favicon_url"] and "/favicon/public" in out["favicon_url"]
img = client.get(f"{_SETTINGS}/favicon/public")
assert img.status_code == 200
assert img.headers["content-type"] == "image/x-icon"
def test_合法png上传成功(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "favicon.png", _PNG_BYTES)
assert resp.status_code == 200, resp.text
assert "/favicon/public" in resp.json()["data"]["favicon_url"]
def test_伪造扩展名被magic_bytes拦截(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "fake.ico", _TXT_BYTES)
assert resp.status_code == 400, resp.text
assert "不符" in resp.json()["message"]
def test_同扩展名替换_url版本号变化(self, client: TestClient, admin: dict) -> None:
first = self._upload(client, admin, "favicon.png", _PNG_BYTES).json()["data"]["favicon_url"]
second = self._upload(client, admin, "favicon.png", _PNG_BYTES).json()["data"][
"favicon_url"
]
assert first and second
assert first != second, "同扩展名替换后站点图标 URL 未变化"
def test_非法格式被拒(self, client: TestClient, admin: dict) -> None:
assert self._upload(client, admin, "x.gif", _PNG_BYTES).status_code == 400
def test_超限被拒(self, client: TestClient, admin: dict) -> None:
big = _PNG_BYTES + b"\x00" * (2 * 1024 * 1024)
resp = self._upload(client, admin, "big.png", big)
assert resp.status_code == 400
assert "2MB" in resp.json()["message"]
def test_匿名上传被拒(self, client: TestClient) -> None:
resp = client.post(
f"{_SETTINGS}/favicon",
files={"file": ("favicon.png", _PNG_BYTES, "application/octet-stream")},
)
assert resp.status_code == 401
@@ -229,6 +229,19 @@ class TestSubmitAndLock:
assert out["approval_order_no"] and out["approval_order_no"].startswith("AP")
assert out["locked_at"] is not None
# 自动通过路径同样固化送审快照(月结头 + 按人明细)
order = client.get(
"/api/v1/system/approvals",
params={"keyword": out["approval_order_no"], "pageSize": 1},
headers=admin["headers"],
).json()["data"]["list"][0]
detail = client.get(
f"/api/v1/system/approvals/{order['id']}", headers=admin["headers"]
).json()["data"]
assert detail["content_source"] == "snapshot"
assert detail["snapshot"]["data"]["settlement_month"] == "2026-10"
assert detail["snapshot"]["data"]["items"], "月结快照须含按人明细"
def test_锁定后回款禁改(self, client: TestClient, admin: dict) -> None:
from sqlalchemy import select
@@ -603,6 +616,21 @@ class TestAdjustment:
out = resp.json()["data"]
assert out["status"] == 2 # 已生效
# 调整单送审快照:复核者能看到金额/方向/说明,而非只有调整单号
order = client.get(
"/api/v1/system/approvals",
params={"keyword": out["order_no"], "pageSize": 1},
headers=admin["headers"],
).json()["data"]["list"][0]
assert order["order_type"] == 3
detail = client.get(
f"/api/v1/system/approvals/{order['id']}", headers=admin["headers"]
).json()["data"]
assert detail["content_source"] == "snapshot"
assert detail["snapshot"]["data"]["amount"] == "500.00"
assert detail["snapshot"]["data"]["direction"] == 1
assert detail["snapshot"]["data"]["reason"] == "补发差额"
def test_金额恒正校验(self, client: TestClient, admin: dict) -> None:
"""守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。"""
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
@@ -0,0 +1,72 @@
"""审批与方案版本化的纯函数单元测试(秒级,无需容器)。
守的是「方案内容是否真的变了」这一判定(``business_service._plan_changed``):
它决定要不要新增一个方案版本行、要不要重提审批单。判错的后果是双向的——
判成「变了」会刷出版本与审批留痕噪音,判成「没变」会让改过的方案
沿用旧版本号,版本语义失真。
比较口径是**集合**(人员 ID + 其比例),与提交顺序无关:用户只是把行
拖了个序,不该被算成方案变了。
"""
from decimal import Decimal
import pytest
from app.models.business import SecondaryAllocation
from app.services.business_service import _plan_changed
def _row(personnel_id: int, ratio: str) -> SecondaryAllocation:
"""构造一个未入库的方案行(ORM 实例化不触数据库)。"""
return SecondaryAllocation(
business_id=1,
version_no=1,
personnel_id=personnel_id,
personnel_name=f"t_人员{personnel_id}",
ratio=Decimal(ratio),
is_active=1,
)
class TestPlanChanged:
"""方案内容比较。"""
def test_完全相同_判未变(self) -> None:
old = [_row(1, "50.00"), _row(2, "30.00")]
new = [_row(1, "50.00"), _row(2, "30.00")]
assert _plan_changed(old, new) is False
def test_顺序不同_判未变(self) -> None:
"""用户只是把行拖了个序 —— 送审快照按库内生效行顺序生成,
按顺序比较会把这种编辑误判成「方案变了」。"""
old = [_row(1, "50.00"), _row(2, "30.00")]
new = [_row(2, "30.00"), _row(1, "50.00")]
assert _plan_changed(old, new) is False
def test_比例变化_判已变(self) -> None:
old = [_row(1, "50.00")]
new = [_row(1, "80.00")]
assert _plan_changed(old, new) is True
def test_参与人增减_判已变(self) -> None:
old = [_row(1, "50.00")]
assert _plan_changed(old, [_row(1, "50.00"), _row(2, "30.00")]) is True
assert _plan_changed([_row(1, "50.00"), _row(2, "30.00")], [_row(1, "50.00")]) is True
def test_同一人的比例从一人换成另一人_判已变(self) -> None:
"""参与人不同但比例相同:人员 ID 是比较键的一部分,不能只看比例。"""
old = [_row(1, "50.00")]
new = [_row(2, "50.00")]
assert _plan_changed(old, new) is True
@pytest.mark.parametrize("written", ["50", "50.0", "50.00"])
def test_比例写法不同_判未变(self, written: str) -> None:
"""比例入参是字符串('50' 与 '50.00' 都合法),数值相同即未变。"""
assert _plan_changed([_row(1, "50.00")], [_row(1, written)]) is False
def test_空方案与空方案_判未变(self) -> None:
assert _plan_changed([], []) is False
def test_空方案与有方案_判已变(self) -> None:
assert _plan_changed([], [_row(1, "50.00")]) is True
@@ -0,0 +1,38 @@
"""审批管理入参校验(schema 层,秒级)。
守的是:对象类型筛选必须覆盖三类审批对象(1 二次分配方案 / 2 月度结算 /
3 调整单)——早期上界写死在 2,调整单审批单在列表里无法筛选;越界值在
参数绑定阶段即拒绝。驳回必须带意见(空驳无从追溯)。
"""
import pytest
from pydantic import ValidationError
from app.schemas.approval import ApprovalActionIn, ApprovalQuery
class TestQueryOrderType:
"""对象类型筛选边界。"""
@pytest.mark.parametrize("value", [1, 2, 3])
def test_三类对象均可筛选(self, value: int) -> None:
assert ApprovalQuery(order_type=value).order_type == value
@pytest.mark.parametrize("value", [0, 4])
def test_越界类型被拒(self, value: int) -> None:
with pytest.raises(ValidationError):
ApprovalQuery(order_type=value)
def test_不传类型表示全部(self) -> None:
assert ApprovalQuery().order_type is None
class TestActionOpinion:
"""节点动作的驳回意见要求。"""
def test_驳回必须填意见(self) -> None:
with pytest.raises(ValidationError):
ApprovalActionIn(action=2)
def test_通过可不填意见(self) -> None:
assert ApprovalActionIn(action=1).opinion is None
+14 -1
View File
@@ -40,7 +40,6 @@ EXPECTED_TABLES = {
"shipment_items",
"receipts",
"customer_rating_grades",
"customer_ratings",
"performance_ledgers",
"monthly_settlements",
"settlement_items",
@@ -97,6 +96,20 @@ class TestNamingConvention:
for fk in table.foreign_keys:
assert fk.constraint.name.startswith(f"fk_{table.name}_"), fk.constraint.name
def test_审批单版本号唯一键覆盖三列(self) -> None:
"""并发提单算重版本号时由 DB 兜底(应用层取号走锁读,见
approval_service._next_version);约束名必须与迁移 DDL 严格一致,
否则 alembic autogenerate 会误判成「约束缺失/多余」。"""
table = Base.metadata.tables["approval_orders"]
uks = [c for c in table.constraints if c.__class__.__name__ == "UniqueConstraint"]
hit = [
c
for c in uks
if {col.name for col in c.columns} == {"order_type", "ref_id", "version_no"}
]
assert hit, "审批单缺 (order_type, ref_id, version_no) 唯一键"
assert hit[0].name == "uk_approval_orders_order_type_ref_version"
class TestComments:
"""规范红线要求中文 COMMENT。"""
+15 -40
View File
@@ -23,7 +23,6 @@ DROP TABLE IF EXISTS `monthly_settlements`;
DROP TABLE IF EXISTS `approval_steps`;
DROP TABLE IF EXISTS `approval_orders`;
DROP TABLE IF EXISTS `business_secondary_allocations`;
DROP TABLE IF EXISTS `customer_ratings`;
DROP TABLE IF EXISTS `system_settings`;
DROP TABLE IF EXISTS `param_values`;
DROP TABLE IF EXISTS `param_change_batches`;
@@ -696,50 +695,17 @@ INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order
('C', 60, 74, 3, '合格'),
('D', 0, 59, 4, '待改进');
-- ---------------------------------------------------------------------------
-- 客户评级落库(V11 迁移;每客户一行,等级名为快照固化无外键)
-- ---------------------------------------------------------------------------
CREATE TABLE customer_ratings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(唯一:一客户一份当前评级)',
score_ship_amount NUMERIC(5, 1) NOT NULL COMMENT '发货额评分(0-20)' DEFAULT '0',
score_receipt_rate NUMERIC(5, 1) NOT NULL COMMENT '回款率评分(0-20)' DEFAULT '0',
score_profit_rate NUMERIC(5, 1) NOT NULL COMMENT '毛利率评分(0-20)' DEFAULT '0',
score_contract_cooperation NUMERIC(5, 1) NOT NULL COMMENT '合同履约配合能力评分(0-20)' DEFAULT '0',
score_site_construction NUMERIC(5, 1) NOT NULL COMMENT '现场规范施工能力评分(0-20)' DEFAULT '0',
total_score NUMERIC(5, 1) NOT NULL COMMENT '总分(服务端重算,五项之和 0-100)',
grade_name VARCHAR(32) COMMENT '评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)',
remark VARCHAR(255) COMMENT '备注',
scorer_id BIGINT UNSIGNED COMMENT '评级操作人ID(账号删除后置空)',
scorer_name VARCHAR(64) 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_customer_ratings PRIMARY KEY (id),
CONSTRAINT fk_customer_ratings_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
CONSTRAINT fk_customer_ratings_scorer_id FOREIGN KEY(scorer_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT uk_customer_ratings_customer_id UNIQUE (customer_id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)' COLLATE utf8mb4_general_ci;
-- ---------------------------------------------------------------------------
-- 系统设置/二次分配/审批(V6 迁移;公司信息单行;分摊计算待澄清另批)
-- V11 迁移:增加品牌标语/页脚/备案号/站点标题/站点图标与图片版本号
-- (公司名称/logo/标语/页脚/备案号 + 站点信息,企业档案字段方案已废弃未发布)
-- ---------------------------------------------------------------------------
CREATE TABLE system_settings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页/侧边栏/浏览器标题展示)',
logo_path VARCHAR(255) COMMENT '公司 logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)',
logo_version INT NOT NULL COMMENT 'logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)' DEFAULT '0',
site_title VARCHAR(64) COMMENT '站点标题(浏览器标签页与侧边栏副标题;null=内置默认)',
brand_slogan VARCHAR(64) COMMENT '品牌标语(登录页与品牌预览;null=内置默认)',
footer_text VARCHAR(64) COMMENT '页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)',
icp_no VARCHAR(32) COMMENT 'ICP 备案号(登录页页脚,空=不显示)',
favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=跟随公司 logo / 内置 favicon.svg)',
favicon_version INT NOT NULL COMMENT '站点图标版本号(每次上传 +1;图片 URL 用它破缓存)' DEFAULT '0',
company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页与导出页眉展示)',
logo_path VARCHAR(255) COMMENT 'logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_system_settings PRIMARY KEY (id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息与站点信息,全系统单行)' COLLATE utf8mb4_general_ci;
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息,全系统单行)' COLLATE utf8mb4_general_ci;
CREATE TABLE approval_orders (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
@@ -747,6 +713,8 @@ CREATE TABLE approval_orders (
order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算(预留)',
ref_id BIGINT UNSIGNED NOT NULL COMMENT '引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)',
refer_no VARCHAR(32) NOT NULL COMMENT '引用对象单号(冗余固化,列表页免联查)',
version_no INT NOT NULL COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' DEFAULT '1',
snapshot_json JSON COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)',
status TINYINT NOT NULL COMMENT '审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回' DEFAULT '1',
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
@@ -754,11 +722,14 @@ CREATE TABLE approval_orders (
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_approval_orders PRIMARY KEY (id),
CONSTRAINT uk_approval_orders_order_no UNIQUE (order_no),
CONSTRAINT uk_approval_orders_order_type_ref_version UNIQUE (order_type, ref_id, version_no),
CONSTRAINT fk_approval_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_approval_orders_type_status ON approval_orders (order_type, status);
CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id);
CREATE TABLE approval_steps (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)',
@@ -778,21 +749,25 @@ CREATE INDEX idx_approval_steps_order_id ON approval_steps (order_id);
CREATE TABLE business_secondary_allocations (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;业务删除随删)',
version_no INT NOT NULL COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' DEFAULT '1',
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '参与业务员ID(personnel.id;被方案引用禁删)',
personnel_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)',
ratio DECIMAL(5, 2) NOT NULL COMMENT '分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)',
is_active TINYINT NOT NULL COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置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_business_secondary_allocations PRIMARY KEY (id),
CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id),
CONSTRAINT uk_business_secondary_allocations_business_version_personnel UNIQUE (business_id, version_no, personnel_id),
CONSTRAINT fk_business_secondary_allocations_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondary_allocations (business_id);
CREATE INDEX idx_business_secondary_allocations_personnel_id ON business_secondary_allocations (personnel_id);
CREATE INDEX idx_business_secondary_allocations_business_active ON business_secondary_allocations (business_id, is_active);
-- ---------------------------------------------------------------------------
-- 绩效核算台账/月结/调整单(V7 迁移;append-only 版本行;通过即锁定走调整单)
-- ---------------------------------------------------------------------------
@@ -916,4 +891,4 @@ CREATE TABLE alembic_version (
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
INSERT INTO alembic_version (version_num) VALUES ('c4e2a86f9b13');
INSERT INTO alembic_version (version_num) VALUES ('7f4f7b918bc4');
@@ -1,38 +0,0 @@
-- Running upgrade 90653814a421 -> c4e2a86f9b13
CREATE TABLE customer_ratings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(唯一:一客户一份当前评级)',
score_ship_amount NUMERIC(5, 1) NOT NULL COMMENT '发货额评分(0-20)' DEFAULT '0',
score_receipt_rate NUMERIC(5, 1) NOT NULL COMMENT '回款率评分(0-20)' DEFAULT '0',
score_profit_rate NUMERIC(5, 1) NOT NULL COMMENT '毛利率评分(0-20)' DEFAULT '0',
score_contract_cooperation NUMERIC(5, 1) NOT NULL COMMENT '合同履约配合能力评分(0-20)' DEFAULT '0',
score_site_construction NUMERIC(5, 1) NOT NULL COMMENT '现场规范施工能力评分(0-20)' DEFAULT '0',
total_score NUMERIC(5, 1) NOT NULL COMMENT '总分(服务端重算,五项之和 0-100)',
grade_name VARCHAR(32) COMMENT '评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)',
remark VARCHAR(255) COMMENT '备注',
scorer_id BIGINT UNSIGNED COMMENT '评级操作人ID(账号删除后置空)',
scorer_name VARCHAR(64) 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_customer_ratings PRIMARY KEY (id),
CONSTRAINT fk_customer_ratings_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
CONSTRAINT fk_customer_ratings_scorer_id FOREIGN KEY(scorer_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT uk_customer_ratings_customer_id UNIQUE (customer_id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)' COLLATE utf8mb4_general_ci;
ALTER TABLE system_settings ADD COLUMN site_title VARCHAR(64) COMMENT '站点标题(浏览器标签页与侧边栏副标题;null=内置默认)';
ALTER TABLE system_settings ADD COLUMN brand_slogan VARCHAR(64) COMMENT '品牌标语(登录页与品牌预览;null=内置默认)';
ALTER TABLE system_settings ADD COLUMN footer_text VARCHAR(64) COMMENT '页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)';
ALTER TABLE system_settings ADD COLUMN icp_no VARCHAR(32) COMMENT 'ICP 备案号(登录页页脚,空=不显示)';
ALTER TABLE system_settings ADD COLUMN favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)';
ALTER TABLE system_settings ADD COLUMN logo_version INT NOT NULL COMMENT 'logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)' DEFAULT '0';
ALTER TABLE system_settings ADD COLUMN favicon_version INT NOT NULL COMMENT '站点图标版本号(每次上传 +1;图片 URL 用它破缓存)' DEFAULT '0';
UPDATE alembic_version SET version_num='c4e2a86f9b13' WHERE alembic_version.version_num = '90653814a421';
@@ -0,0 +1,19 @@
-- Running upgrade 90653814a421 -> f3a8c2d61b47
ALTER TABLE approval_orders ADD COLUMN version_no INT NOT NULL DEFAULT 1 COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' AFTER refer_no;
ALTER TABLE approval_orders ADD COLUMN snapshot_json JSON NULL COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' AFTER version_no;
CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id);
ALTER TABLE business_secondary_allocations ADD COLUMN version_no INT NOT NULL DEFAULT 1 COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' AFTER business_id;
ALTER TABLE business_secondary_allocations ADD COLUMN is_active TINYINT NOT NULL DEFAULT 1 COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' AFTER ratio;
ALTER TABLE business_secondary_allocations DROP INDEX uk_business_secondary_allocations_business_personnel, ADD CONSTRAINT uk_business_secondary_allocations_business_version_personnel UNIQUE (business_id, version_no, personnel_id);
CREATE INDEX idx_business_secondary_allocations_business_active ON business_secondary_allocations (business_id, is_active);
ALTER TABLE business_secondary_allocations COMMENT = '业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)';
UPDATE alembic_version SET version_num='f3a8c2d61b47' WHERE alembic_version.version_num = '90653814a421';
@@ -0,0 +1,16 @@
-- Running upgrade f3a8c2d61b47 -> 7f4f7b918bc4
UPDATE approval_orders ao JOIN (SELECT id, ROW_NUMBER() OVER ( PARTITION BY order_type, ref_id ORDER BY id) AS rn FROM approval_orders WHERE (order_type, ref_id) IN ( SELECT order_type, ref_id FROM approval_orders GROUP BY order_type, ref_id HAVING COUNT(*) > COUNT(DISTINCT version_no))) t ON ao.id = t.id SET ao.version_no = t.rn;
SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'approval_orders' AND CONSTRAINT_NAME = 'uk_approval_orders_order_type_ref_version');
SET @ddl := IF(@uk_exists = 0, 'ALTER TABLE approval_orders ADD CONSTRAINT uk_approval_orders_order_type_ref_version UNIQUE (order_type, ref_id, version_no)', 'DO 0');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE alembic_version SET version_num='7f4f7b918bc4' WHERE alembic_version.version_num = 'f3a8c2d61b47';
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -4,7 +4,7 @@
> 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。
> 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。
> **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。
> **2026-10-20 更新**:开发计划 3.3 已回填(3.3.1),SRS V2.1 已交付;二次分摊已按开发侧暂定口径实现(#1–#3 答复仍空白)。
> **2026-10-20 更新**:开发计划 3.3 已回填(3.3.1),SRS V2.1 已交付;二次分摊已按开发侧暂定口径实现(#1–#3 答复仍空白);code-review 遗留 2 项(方案版本承载、审批单内容快照)已随 V11 迁移收口。
## 一、立即待办(需项目内处理)
@@ -88,7 +88,7 @@
| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;二次分摊已按开发侧暂定口径实现(ccfd206),业务答复回填已完成(计划 3.3.1,2026-10-20);**余**:#1–#3/#20 答复与 #4/#6/#12 补值 | #20(含税口径)、#1–#3 答复后按同粒度推进 |
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 |
| M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 |
| code-review 遗留 2 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 |
| code-review 遗留 2 项 | ✅ **已完成(2026-10-20,V11)**:方案行 append-only 版本化(is_active/version_no);审批单加版本号与 JSON 内容快照,详情按 snapshot/live/missing 展示 | — |
| 杂项收口(低优) | 超长函数收敛:calc_receipt 与 settlement_service(生成/聚合/调整单)已拆(2026-10-19);剩 preview/change_ownership/create_receipt/update_receipt 等;散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 |
## 五、文档待办(低优)
+9
View File
@@ -33,6 +33,15 @@
| 人员识别 | 「负责营销人员以姓名为唯一识别」 | 改为「以人员编码(工号)为唯一识别、姓名辅助核对」——原表述与「编码为业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误,请确认 |
| 回款↔发货批次 | 「由 T+ 收款分配关系指定,人工辅助」 | 改为「**系统人工指定**(对照表 #18 定稿),T+ 收款分配关系仅作辅助」——主次相反疑漏改,请确认 |
### D. 2026-10-20 新增:需业务定口径(2 项)
> 随「审批单内容快照 + 二次分配方案版本化」收口带出,开发侧已按现行默认实现,答复后按答复调整。
| # | 事项 | 当前默认(开发侧已实现) | 需确认 |
| --- | --- | --- | --- |
| 25 | **业务硬删除时,方案版本历史是否保留?** | 随业务一并删除(与既有「终态审批单随业务删除一并清理」规则一致)——业务被删后,方案参与人与比例的版本痕迹也不复存在 | 是否需要保留(如留归档表/只留审批单快照),还是维持一并删除 |
| 26 | **离职的历史参与人重新加入方案,是否允许?** | 按新参与人处理:曾参与过但当前不在生效方案里的人,若已离职,重新加入会被「离职人员禁入」拦截 | 是否应放行(把「历史参与过」视为豁免),还是维持按现任职校验 |
### 已按默认执行、无需回复的项(供核阅)
\#7 月结审批链默认开启、#8 人工确认回款可手动关闭、#9 调整单同链且跨月需授权、#10 风险预警参数化、#11 待办范围(待审批+同步失败+回款未指定批次)、#13 支付环节 5 默认值、#15 一期仅 FULL 模式、#17 参数查看放开/修改限管理员、#18 企业户 2‰ 回溯、#21 退货当期冲减已结算走调整单、#22 预收/保证金/代收款暂不计入、#23 按到账金额、#24 负绩效照算不归零。
+241
View File
@@ -0,0 +1,241 @@
衍水集团营销人员工资管理系统
部署硬件需求说明
================================================================
版本:v1.0
日期:2026-10-21
适用:内部系统部署(数十人并发,无公网访问)
依据:仓库实际运行架构(docker/docker-compose.dev.yml、backend/pyproject.toml、
backend/app/core/db.py、docs/备份恢复手册.md、ai-aux/AI_CONTEXT.md)
================================================================
一、结论摘要(先看这一节)
================================================================
推荐配置:2 核 CPU / 8 GB 内存 / 100 GB SSD(Linux 单机)
最低配置:2 核 CPU / 4 GB 内存 / 100 GB SSD
演示配置:1 核 CPU / 2 GB 内存 / 40 GB SSD(仅限试用,不适合正式使用)
一句话:内部系统的瓶颈不在 CPU,而在「内存余量」和「磁盘容量」。
内存从 4 GB 升到 8 GB 的成本极低,却能一次性消除以后所有内存不足的排查;
磁盘不要按当前数据量买,要按「备份 + 恢复演练」的双倍空间买。
================================================================
二、负载前提(为什么是这个配置)
================================================================
以下事实全部来自本仓库,不是通用模板:
1) 内部系统、数十人并发
出处:ai-aux/AI_CONTEXT.md:101「并发极低(内部系统数十人)」
含义:CPU 核心数不是瓶颈,2 核足以支撑全部并发请求。
2) 全栈同步架构 + 单 worker,重活走线程池
出处:CLAUDE.md(分层约定)、backend/app/core/db.py:29
含义:一个请求占用一个线程;绩效批量核算、Excel 导出会短暂吃满一个核,
因此要留出至少 1 个空闲核给后端,避免请求排队。
3) MySQL 8.0 + Redis 7 双容器,Redis 开启 AOF
出处:docker/docker-compose.dev.yml:11、:44
含义:MySQL 是内存消耗大户(默认配置下性能很差,必须调参);
Redis 只存验证码、Token 黑名单、登录失败计数,用量极小(<50 MB)。
4) 备份策略:每日全量 gz + binlog 增量,各自保留 30 天
出处:docs/备份恢复手册.md:11
含义:磁盘需按「业务数据 + 索引 + 30 天 binlog + 30 份全量备份」计算。
5) 恢复演练需恢复到独立临时库 payroll_drill_*
出处:docs/备份恢复手册.md:45
含义:演练瞬间需要约「2 倍数据库体积」的可用空间。
这是最容易被低估的一项,磁盘卡着下限买会在第一次演练时爆盘。
6) 报表导出用 openpyxl 全量载入内存
出处:backend/app/services/report_export.py:18
含义:导出大报表时后端进程内存峰值会翻倍,必须在内存预算里留余量。
================================================================
三、正式生产推荐配置(内部系统,数十人)
================================================================
项目 规格 说明
----------------------------------------------------------------
CPU 2 核(推荐 4 核) 2 核给容器与 MySQL,
1 核给 uvicorn(workers=2 上限),
1 核余量给系统与备份任务。
内存 8 GB(最低 4 GB) 8 GB 才有余量承受
「导出 + 核算 + 备份」同时发生。
磁盘 100 GB SSD(推荐 200 GB) 必须是 SSD。HDD 上 MySQL 的
ON UPDATE CURRENT_TIMESTAMP
高频写入会明显拖慢。
网络 内网千兆 无需公网 IP、域名、SSL 证书、CDN。
操作系统 Linux 推荐 Ubuntu 22.04 LTS / Rocky 9。
(Ubuntu 22.04 / Rocky 9) 备份、恢复、巡检脚本全部是 .sh
(scripts/ 目录),Linux 为原生路径。
若必须用 Windows Server,需 2022 +
WSL2 或 Git Bash 运行脚本。
================================================================
四、内存分配明细(8 GB 的具体切法)
================================================================
组件 建议占用 关键设置与说明
----------------------------------------------------------------
MySQL 8.0 3.5 GB innodb_buffer_pool_size = 2G
★ 默认仅 128 MB,不调会大量走磁盘,
MySQL 会成为整机最慢的一环。
后端 uvicorn 1.5 GB workers = 2
报表导出时单 worker 峰值可达 600 MB ~ 1 GB
(openpyxl 全量载入内存)。
Redis 7 256 MB maxmemory 256mb
实际用量 < 50 MB,设置上限只为兜底防失控。
Nginx + 系统 1 GB 静态资源(前端 dist)+ 反向代理 /api。
余量 1.75 GB mysqldump 备份进程、binlog 压缩、系统缓存。
----------------------------------------------------------------
4 GB 内存版(最低配置)的分配:
MySQL 1.5~2 GB(innodb_buffer_pool_size = 1G)
后端 0.8~1 GB
Redis 128 MB(maxmemory 128mb)
Docker + Nginx + 系统 0.6 GB
余量 ~0.6 GB
★ 这是「刚好够用」,一旦「导出报表」与「每日备份」撞在同一时刻,
OOM Killer 会优先杀 MySQL。这就是推荐 8 GB 的原因。
================================================================
五、磁盘空间算式(100 GB 的来历)
================================================================
项目 估算占用
----------------------------------------------------------------
业务数据(数十人,5 年) ≈ 1 GB
索引 + binlog 30 天(ROW 格式) ≈ 5 GB
全量备份 30 份(gz,约数据量 20%) ≈ 6 GB
恢复演练临时库(峰值 = 2 倍数据量) ≈ 2 GB
系统 + Docker 镜像 + 应用日志 ≈ 25 GB
----------------------------------------------------------------
合计 ≈ 39 GB
结论:100 GB 可支撑 2~3 年不需扩容。
按 docs/备份恢复手册.md:87 的 80% 磁盘水位告警,100 GB 有充足缓冲。
================================================================
六、内部部署必须调整的四项(现有配置存在隐患)
================================================================
1) 数据库端口必须收敛,不能监听所有网卡
现状:docker/docker-compose.dev.yml:27 为 "3306:3306",
:48 为 "6379:6379",等价于绑定 0.0.0.0。
风险:内网任何一台办公电脑都能直连 MySQL 试 root 密码。
改法(二选一):
A. 只允许宿主机访问
ports:
- "127.0.0.1:3306:3306"
- "127.0.0.1:6379:6379"
B. 后端与数据库在同一 compose 网络内时,直接删掉这两行 ports,
容器之间通过服务名互访,宿主机只暴露 80/443。
2) 前端必须构建成静态文件 + Nginx 反向代理
禁止把 vite dev 服务器当正式环境用:
开发服务器会暴露全部源码,且不提供任何访问控制。
正式做法:pnpm build 产出 frontend/dist,由 Nginx 托管,
/api 反向代理到后端 8000 端口。
3) 备份必须异地留一份
内部系统最常见的「数据全丢」场景就是单机故障。
在 scripts/backup_full.sh 之后追加一步 rsync 到 NAS 或公司文件服务器。
4) 提前预留反向代理层
不接公网就不需要 WAF / CDN / SSL。
但如果公司后续有统一域名单点登录(SSO)规划,
Nginx 反代层要提前留好,避免到时大改架构。
================================================================
七、三档速查表
================================================================
场景 CPU 内存 磁盘 备注
--------------------------------------------------------------------------------
试用 / 演示(< 5 人) 1 核 2 GB 40 GB SSD 仅跑小数据量;
导出大报表会 OOM
正式内部(数十人) 2 核 4 GB(建议 8 GB)100 GB SSD 推荐方案
【推荐】
内部 + 冗余 4 核 ×2 8 GB ×2 200 GB SSD ×2 含从库与监控;
(从库 + 监控) 工资系统若需
审计级可靠性
值得投入
--------------------------------------------------------------------------------
================================================================
八、可选加速项(非必须)
================================================================
1) 图表库已本地化
ECharts 静态文件已放在 docs/design/echarts.min.js,
前端自托管即可,不依赖外网 CDN,符合「内网加载不可靠」的既定决策。
2) 日志盘分离
若开启审计日志长期留存,把 backend/logs/ 挂载到独立磁盘,
避免日志写满系统盘导致 MySQL 无法写入。
3) 备份异地化
本机备份 + 定时 rsync 到 NAS 或对象存储,构成「本地 + 异地」两份。
================================================================
九、生产环境仍需完成的事项(本文件之外)
================================================================
1) 生成 docker/docker-compose.prod.yml:
- 端口收敛到 127.0.0.1
- MySQL innodb_buffer_pool_size 等参数调优
- 容器 mem_limit / cpus 资源限制
- Nginx 反向代理静态前端 + /api
2) 后端启动参数由开发模式改为生产模式:
- 去掉 --reload(生产环境必须去掉)
- 设置 workers=2
3) backend/.env 中 APP_SECRET_KEY 必须换成强随机值,
MYSQL_PASSWORD / REDIS_PASSWORD 两处(docker/.env 与 backend/.env)保持一致。
4) 首次部署执行 alembic upgrade head,并创建管理员账号:
uv run python -m app.cli create-admin --username admin
5) 配置每日定时任务:全量备份 + 增量归档(见 docs/备份恢复手册.md)
================================================================
十、附:数据来源索引
================================================================
ai-aux/AI_CONTEXT.md:101 并发规模判断(数十人)
docker/docker-compose.dev.yml:11 MySQL 8.0 容器
docker/docker-compose.dev.yml:27 MySQL 端口映射(需收敛)
docker/docker-compose.dev.yml:44 Redis 7 容器与 AOF
docker/docker-compose.dev.yml:48 Redis 端口映射(需收敛)
backend/app/core/db.py:29 同步引擎与连接池配置
backend/app/services/report_export.py:18 报表导出(内存峰值来源)
docs/备份恢复手册.md:11 备份策略
docs/备份恢复手册.md:45 恢复演练临时库
docs/备份恢复手册.md:87 磁盘水位 80% 告警阈值
scripts/ 备份 / 恢复 / 巡检脚本(.sh)
================================================================
(文件结束)
Binary file not shown.
-154
View File
@@ -1,154 +0,0 @@
# 衍水集团营销人员工资管理系统项目阶段报告
- **报告日期**:2026-10-13
- **报告范围**:需求澄清、技术底座、系统管理、M1 参数体系、M2 当前进展与后续计划
- **项目分支**:`feat/base-business-data`
- **阶段结论**:项目已从“需求与底座建设”进入 **M2 基础数据管理开发期**;M1 已形成可验收闭环,M2 数据模型与迁移初稿已落地,但接口、页面与模块级测试尚未完成。
## 一、3P 阶段摘要
### Progress(进展)
已完成需求裁决、开发环境、数据库基础设计、登录鉴权、系统管理、前端框架与 M1 系统设置参数体系。M1 覆盖岗位角色、公式模板、16 项参数种子、时点化取值、版本留痕、参数预览与 Excel 导出;最近一次全量回归为后端 406 个用例与 28 项端到端检查通过。当前 M2 已完成人员、区域、计划、产品、客户、归属留痕六类表的模型与 V4 迁移初稿。
### Plans(计划)
短期优先完成 M2:在既有六表基础上补齐仓储、服务、接口、前端页面、审计与模块测试,并完成双轨迁移一致性验证。随后进入业务数据管理,为绩效核算提供业务单据与回款数据输入。报表与 T+ 同步继续后置,待业务数据模型稳定和 IT 字段映射确认后实施。
### Problems(问题)
当前无业务需求阻塞,但 M2 代码尚未提交,需防止结构设计与后续接口实现长期分叉。T+ 字段映射、幂等唯一键与失败重试策略仍待 IT 确认;经理个人户业绩进入 12.5%+5% 的衔接口径需在核算模块开工前确认。原需求文档存在已裁决作废条款,对外引用前需修订或明确以对照表 v2 为准。
## 二、阶段定位与完成度
| 维度 | 当前状态 | 说明 |
| --- | --- | --- |
| 项目阶段 | 开发期 | M1 已交付,M2 进行中 |
| 整体完成度 | 14% | 管理口径;M2 尚未达到可交付节点,仅按结构设计进展小幅上调 |
| 交付主线 | M2 基础数据管理 | 六表结构与 T+ 预留已形成初稿 |
| 质量状态 | M1 回归通过 | 后端 406 用例、e2e 登录 8 项 / 系统管理 13 项 / 参数 7 项通过 |
| 主要风险 | 外部对接与口径追溯 | T+ 映射未定;需规含作废条款 |
> 说明:整体完成度用于管理沟通,不等同于代码行占比。M2 仅完成数据结构层,接口、页面和模块级测试未完成,因此不能标记为模块交付。
## 三、已完成成果
### 1. 需求与执行口径
- 业务方已回执《问题与解决方案对照表_v2.xlsx》,28 项争议全部落定。
- 2026-10-12 后,需求以对照表 v2 与《2026.9.8修改方案.docx》的裁决口径为准。
- 关键口径已固化:回款天数自然日累计且发货日计第 1 天;毛利润率不含运费;业务员计提按 1~360 天线性递减、361 天及以上固定 4‰;企业户 3‰;信息员按销售额 1‰;参数按回款到账日时点取值。
- 支付环节的起付比例、支付权重、预付款、以物抵顶均按可配置参数预留。
### 2. 技术与运行底座
- 后端六层结构建立:`api → services → repositories → models`,`schemas` 承载请求响应,`core` 处理横切能力。
- 统一响应、全局异常、结构化日志、审计脱敏、配置安全校验已落地。
- 本地环境基于 Docker Compose 编排 MySQL 8.0 与 Redis 7,使用命名卷持久化、业务账号最小权限、utf8mb4 字符集。
- 迁移采用 Alembic 生成并导出 `db/migration/V{n}__*.sql` 的双轨方式,兼顾开发迭代与生产脚本留存。
### 3. 登录鉴权与系统管理
- 登录链路包含图形验证码、JWT、登录失败限流、Token 黑名单与登录日志。
- 系统管理包含用户、角色、操作日志与日志查询,前后端闭环。
- 审计机制记录成功与失败操作,敏感字段按键名结构化脱敏,日志列表与详情分段加载。
### 4. M1 系统设置与参数体系
- 建立岗位角色、参数定义、变更批次、参数值四表。
- 公式模板以代码内有限集合注册,参数按生效日期时点化存储,避免用当前值重算历史。
- 提供 16 个管理端点,支持参数分组、版本管理、批次撤回、两日期对比、计算预览与 xlsx 导出。
- 前端交付岗位角色配置页、计提参数配置页、Bento 仪表盘与统一视觉体系。
- Code review 发现的 15 项问题已全部修复,并完成回归。
## 四、当前进行中工作
当前分支 `feat/base-business-data` 正在推进 M2 基础数据管理。已形成的结构设计包括:
| 数据对象 | 设计要点 | 后续消费方 |
| --- | --- | --- |
| `personnel` | 人员档案、岗位角色、直属经理、试用期、工资标准与状态 | 基础数据、业务归属、绩效核算 |
| `personnel_regions` | 人员负责的产品辐射区域,1:N 子表 | 基础数据维护 |
| `personnel_plans` | 市场拓展计划与指标说明,先做自由条目容器 | T+ 字段映射确认后细化 |
| `products` | 产品名称与规格唯一,类别锚定折算阈值参数 | 业务数据与绩效核算 |
| `customers` | 公司名称加负责人唯一,客户分类作为计提规则锚点 | 业务数据与绩效核算 |
| `ownership_change_logs` | 客户与业务归属变更留痕,append-only,不回溯历史 | 审计与核算追溯 |
设计上已为 T+ 同步预留 `source_no`、`t_plus_id`、`sync_batch_no`、`synced_at`。由于 IT 尚未确认字段映射与幂等键,这些列暂为可空并仅对 `source_no` 建普通索引,待键语义确定后再以小迁移升级唯一约束。
**当前边界**:以上属于模型、Alembic 迁移、生产 SQL 与开发初始化脚本层面的初稿;M2 的 CRUD API、服务事务、前端页面、权限审计和完整测试仍待实现。相关文件当前未提交,不能视为已交付功能。
## 五、下一步计划
| 优先级 | 工作项 | 验收标准 |
| --- | --- | --- |
| P0 | 完成 M2 六表接口与服务 | 列表、详情、创建、更新、停用 / 删除保护可用;事务与审计完整 |
| P0 | 完成 M2 前端页面 | 人员、产品、客户管理页面复用现有分页与确认交互,类型检查通过 |
| P0 | 补齐 M2 测试与迁移一致性检查 | 单元测试、集成测试、schema 同步测试通过 |
| P1 | 提交并评审 M2 | 提交信息符合规范,不直推共享主干 |
| P1 | 启动业务数据管理设计 | 明确业务、发货、回款、批次匹配与核销状态模型 |
| P1 | 确认核算前置口径 | 经理个人户业绩是否计入 12.5% 团队基数 |
| P2 | T+ 对接专项 | 字段映射、唯一键、去重、同步日志与失败重试策略由 IT 确认 |
## 六、风险与应对
| 风险 | 影响 | 当前应对 |
| --- | --- | --- |
| M2 结构初稿长期未提交 | 后续接口实现与表结构评审结论容易分叉 | 尽快完成最小可测闭环后提交,评审后再扩展 |
| T+ 字段映射未定 | 同步模块无法最终定幂等键与字段清洗规则 | 目标表预留同步字段;同步模块后置;不覆盖人工维护字段 |
| 需求文档存在作废条款 | 新成员或外部方按旧需规理解会产生口径冲突 | 以对照表 v2 与 9.8 修改方案为准;后续修订需规或加显著说明 |
| 经理个人户衔接口径待确认 | 影响绩效台账归属字段与经理绩效基数 | 在核算模块开工前作为必须裁决项跟踪 |
| 绩效金额精度风险 | 浮点误差会直接造成算错钱 | 金额使用 `Decimal`,比例斜率使用 `Fraction`;关键边界已有纯函数测试 |
## 七、技术选型理由
### 1. 后端:Python 3.12 + FastAPI
| 选型 | 理由 | 放弃的替代方案 |
| --- | --- | --- |
| Python 3.12 | `Decimal` 与 `Fraction` 能精确处理金额、千分比和 `4/360` 斜率;数据处理、Excel、后续 T+ 清洗生态成熟;版本锁定 3.12 避免新版本第三方轮子覆盖不稳 | Python 3.14:生态轮子覆盖尚不完备,兼容风险高 |
| FastAPI | 自动 OpenAPI、Pydantic 参数校验、依赖注入和统一异常模型适合内部管理系统;显式路由便于把算钱逻辑限定在 service 层 | Django:Admin 对 T+ 来源的数据 CRUD 复用价值有限,隐式 ORM 行为不利于工资核算追溯 |
| 同步 SQLAlchemy 2.0 | 项目并发压力低;绩效批量核算、Excel 导出和数据清洗是同步阻塞任务,同步路由由 FastAPI 放入线程池,避免阻塞事件循环 | 异步 SQLAlchemy:增加复杂度但没有对应吞吐收益 |
### 2. 数据与缓存:MySQL 8.0 + Redis 7
| 选型 | 理由 | 放弃的替代方案 |
| --- | --- | --- |
| MySQL 8.0 + InnoDB + utf8mb4 | 事务、外键、约束与审计留痕可靠;团队与运维熟悉度高;`DECIMAL` 支撑金额精度;utf8mb4 覆盖中文与特殊字符 | Excel / 文件库:无法承载并发、事务、权限与审计;NoSQL:缺少关系约束,不利薪酬追溯 |
| Alembic 双轨迁移 | 开发期可自动生成演进,生产保留按序 SQL 脚本,符合审计与发布要求 | 仅手写 SQL:模型与库结构易漂移;仅 Alembic:不满足生产脚本留存约定 |
| Redis 7 | 验证码、登录限流、Token 黑名单都需要带 TTL 的短生命周期状态;`GETDEL` 支撑验证码一次性消费 | 仅 MySQL:一次性状态表会增加清理与原子性复杂度 |
### 3. 前端:Vue 3 + TypeScript + Vite + Element Plus + Pinia
| 选型 | 理由 | 放弃的替代方案 |
| --- | --- | --- |
| Vue 3 + TypeScript | 组件生态成熟,类型约束能降低表单和接口字段错误;项目已沉淀路由、状态、分页与确认交互 | 新换框架:无业务收益,徒增迁移成本 |
| Vite | 启动与热更新快,开发体验好;生产构建链路简单 | Webpack:配置与维护成本更高 |
| Element Plus | 表格、表单、抽屉、分页、日期选择等管理端组件齐全,可快速交付后台页面 | 自研组件库:一期交付效率低 |
| Pinia | 轻量状态管理,适合登录态、用户信息与模块状态 | 大型全局状态方案:当前规模过度设计 |
### 4. 工程与质量:uv + pnpm + Docker Compose + pytest + Playwright
| 选型 | 理由 |
| --- | --- |
| uv | 锁定 Python 与依赖版本,安装速度快,避免“本机能跑、别处失败” |
| pnpm | 依赖硬链接节省空间,lockfile 保证前端可重复安装 |
| Docker Compose | 一条命令拉起 MySQL 与 Redis,版本固定、环境一致,不依赖开发者本机散装数据库 |
| pytest + fakeredis + Playwright | 单元、集成、端到端分层验证;fakeredis 让验证码和限流测试无需容器,浏览器测试覆盖真实登录与管理链路 |
### 5. 业务计算架构:公式模板 + 参数版本,而非规则引擎
一期公式骨架固定,变化的是比例、阈值、生效日期和岗位绑定,因此使用“代码内有限公式模板 + 参数时点化版本”足够,并且可测试、可审计、排错直观。引入规则引擎会增加公式调试、权限、留痕与回归成本,却不能消除业务口径澄清本身。若未来业务要求公式可由管理员自由编写,再评估规则引擎或表达式引擎,并保持计算入口兼容。
## 八、报告结论
项目基础能力和 M1 参数地基已经稳定,当前最短路径是完成 M2 基础数据管理的可测闭环,再进入业务数据管理。技术上应继续坚持“关系数据库约束 + 精确数值类型 + 时点化参数 + 审计留痕”的路线,不在 T+ 映射未定时提前锁死同步唯一键。管理上需重点跟踪 M2 提交、核算前口径确认和 T+ 对接三项。
## 附录:信息来源
- 项目入口与运行说明:[README.md](../README.md)
- 剩余开发路线:[2026-10-12-剩余开发计划.md](superpowers/plans/2026-10-12-剩余开发计划.md)
- 需求裁决:`docs/问题与解决方案对照表_v2.xlsx`
- 修改方案:`docs/2026.9.8修改方案.docx`
- 后端测试记录:[backend/tests/README.md](../backend/tests/README.md)
- 前端约定与测试:[frontend/README.md](../frontend/README.md)
+2 -2
View File
@@ -13,7 +13,7 @@ M4 两块(⑤ 报表统计、⑧ 工作台)已完成并通过 code-review
### Plans(计划)
下一步优先解析 T+12.3 数据字典,形成六类单据字段映射草案并自答 IT 清单中可自答部分;其次补齐 code-review 遗留的方案版本承载与审批单快照;需规 V2.2 修订与对照表回填择期进行。
下一步优先解析 T+12.3 数据字典,形成六类单据字段映射草案并自答 IT 清单中可自答部分;~~其次补齐 code-review 遗留的方案版本承载与审批单快照~~(已于 2026-10-20 完成,V11);需规 V2.2 修订与对照表回填择期进行。
### Problems(问题)
@@ -61,7 +61,7 @@ M4 两块(⑤ 报表统计、⑧ 工作台)已完成并通过 code-review
## 五、下一步计划
1. **T+ 同步前置**:解析 `docs/T_12.3数据字典/`,产出人员/产品/业务/发货/回款/客户六类字段映射草案,自答 IT 18 项中可从数据字典确认的部分。
2. **code-review 遗留 2 项**:方案生效版本承载、审批单内容快照(orders 加方案 JSON 快照列 + 前端详情展示)。
2. ~~**code-review 遗留 2 项**:方案生效版本承载、审批单内容快照(orders 加方案 JSON 快照列 + 前端详情展示)。~~ ✅ 已完成(2026-10-20,V11 迁移):方案行版本化 + 审批单版本号/内容快照 + 前端展示。
3. **需规 V2.2**:修订 26 天示例、含运费公式、协助 25% 等过时条款,回填 2026-10-15 补充需求;对照表 v2 收编 24 项答复。
4. **清理类**:5 处超长函数、散参数 DTO、`useDropdownOptions` 余两页、前端菜单图标。
5. **待外部输入**:业务方催办、审批权限清单、IT 18 项答复。
+113 -6
View File
@@ -5,8 +5,12 @@
并填比例 → 提交成功
- 详情/编辑回显:再次打开编辑抽屉,二次分配两行仍在(全量替换语义)
- 合计提示:两行比例非满额时展示「余额…(归属待业务确认)」
- 审批内容:审批详情展示**提交时**的送审方案(参与人 + 比例)
- 数据清理:删除本次创建的业务(t_ 前缀客户/业务,走界面删除)
审批开关在脚本内显式关闭(结束还原默认 1):既不依赖种子默认值,也让本次
业务不留在途审批单——业务删除保护在途单,开关开启时删不掉。
运行前置(缺一不可):
docker compose up -d # MySQL + Redis
cd backend && uv run uvicorn app.main:app --port 8000
@@ -14,8 +18,12 @@
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/secondary_allocation_flow.py
"""
import json
import os
import time
import urllib.error
import urllib.request
from urllib.parse import quote
from playwright.sync_api import Page, sync_playwright
@@ -32,9 +40,50 @@ from common import (
)
ADMIN = "admin"
API_BASE = "http://127.0.0.1:8000"
SUFFIX = f"{int(time.time())}"
console_errors: list[str] = []
_ALLOCATION_SWITCH = "approval.secondary_allocation.enabled"
def _api(method: str, path: str, token: str, body: dict | None = None) -> dict:
"""直连后端接口(仅用于开关预置;浏览器交互一律走页面)。"""
request = urllib.request.Request(
f"{API_BASE}{path}",
method=method,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
data=json.dumps(body).encode() if body is not None else None,
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
payload = json.loads(response.read().decode())
except urllib.error.HTTPError as exc:
detail = exc.read().decode()[:200]
raise E2EFailure(f"API {method} {path} HTTP {exc.code}:{detail}") from exc
if payload.get("code") != 200:
raise E2EFailure(f"API {method} {path} 业务失败:{payload.get('message')}")
return payload["data"]
def _set_allocation_switch(token: str, value: str) -> None:
"""显式控制二次分配审批开关(用例不依赖种子默认值;结束还原默认 1)。"""
_api(
"POST",
"/api/v1/system/params/changes",
token,
{
"change_type": 1,
"remark": f"e2e secondary_allocation_flow 设置二次分配审批开关={value}",
"items": [
{"param_code": _ALLOCATION_SWITCH, "dimension_value": "", "value": value}
],
},
)
def step_navigate_to_business(page: Page, admin_password: str) -> None:
login(page, ADMIN, admin_password)
@@ -95,6 +144,7 @@ def step_create_personnel(page: Page) -> None:
def step_create_business_with_allocation(page: Page, customer_name: str) -> None:
"""新增带二次分配方案的业务(参与人取人员下拉前两项,比例 30% + 40%)。"""
# 客户弹窗已关(上一段已关闭);直接路由直达业务管理,避免菜单点击
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
page.wait_for_timeout(600)
@@ -170,6 +220,51 @@ def step_verify_allocation_echo(page: Page) -> None:
page.wait_for_timeout(400)
def _fetch_plan(token: str, customer_name: str) -> tuple[str, list[dict]]:
"""取回刚创建业务的编号与实际方案(接口取数,不从列表 DOM 刮编号)。
断言要以**业务实际存下的方案**为准:参与人下拉取的是人员列表的前两
项,未必是脚本刚建的 t_e2e 员,硬编码姓名会把断言写死成脆弱假设。
"""
rows = _api(
"GET",
f"/api/v1/business/businesses?keyword={quote(customer_name)}&pageSize=20",
token,
)["list"]
target = next((r for r in rows if r.get("customer_name") == customer_name), None)
if target is None:
raise E2EFailure(f"业务列表中未找到客户 {customer_name} 的业务")
detail = _api("GET", f"/api/v1/business/businesses/{target['id']}", token)
return target["business_no"], detail.get("secondary_allocations") or []
def step_verify_approval_snapshot(page: Page, business_no: str, plan: list[dict]) -> None:
"""审批详情:展示提交时的送审方案(参与人 + 比例 + 合计余额)。"""
page.goto(f"{BASE_URL}/system/approvals", wait_until="networkidle")
page.wait_for_timeout(800)
page.locator('.filters:visible input[placeholder="审批单号 / 业务编号"]').fill(business_no)
page.locator('.filters:visible button:has-text("查询")').first.click()
page.wait_for_timeout(800)
rows = page.locator(".el-table__body .el-table__row:visible")
if rows.count() == 0:
fail("审批内容", f"未找到业务 {business_no} 的审批单")
rows.first.locator("button:has-text('详情')").click()
page.wait_for_timeout(1000)
drawer = page.locator(".el-drawer:visible").last
drawer.wait_for(state="visible")
text = drawer.inner_text()
if "审批内容" not in text:
fail("审批内容", "审批详情未展示「审批内容」区块")
for row in plan:
if row["personnel_name"] not in text:
fail("审批内容", f"送审内容缺少参与人 {row['personnel_name']}")
if f"{row['ratio']}%" not in text:
fail("审批内容", f"送审内容缺少参与人 {row['personnel_name']} 的比例 {row['ratio']}%")
print(
f"✓ 5. 审批内容 审批详情展示送审方案({len(plan)} 名参与人及其比例)"
)
def step_cleanup(page: Page) -> None:
"""删除本次创建的业务与其客户(t_ 前缀,走界面删除)。"""
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
@@ -184,7 +279,7 @@ def step_cleanup(page: Page) -> None:
# 删除确认框按钮文案为「删除」(useConfirmAction confirmButtonText)
confirm_message_box(page, "删除")
page.wait_for_timeout(600)
print("✓ 5. 数据清理 已删除本次创建的 t_ 业务")
print("✓ 6. 数据清理 已删除本次创建的 t_ 业务")
def run(page: Page) -> None:
@@ -194,13 +289,25 @@ def run(page: Page) -> None:
fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)")
step_navigate_to_business(page, admin_password)
customer_name = step_create_customer_via_ui(page)
token = page.evaluate("localStorage.getItem('ys_token')")
if not token:
fail("API 预置", "localStorage 中未取到 ys_token")
# 显式关闭二次分配审批开关(结束还原默认 1):自动通过留痕,既不依赖
# 种子默认值,也让本次业务不留“在途审批单”——业务删除保护在途单,
# 开关开启时该业务在界面上删不掉
_set_allocation_switch(token, "0")
try:
step_create_personnel(page)
step_create_business_with_allocation(page, customer_name)
step_verify_allocation_echo(page)
customer_name = step_create_customer_via_ui(page)
try:
step_create_personnel(page)
step_create_business_with_allocation(page, customer_name)
business_no, plan = _fetch_plan(token, customer_name)
step_verify_allocation_echo(page)
step_verify_approval_snapshot(page, business_no, plan)
finally:
step_cleanup(page)
finally:
step_cleanup(page)
_set_allocation_switch(token, "1")
if console_errors:
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
-2
View File
@@ -3,8 +3,6 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 默认站点图标:未配置公司 logo 时使用(配置后由 settings store 动态替换) -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>衍水集团营销人员工资管理系统</title>
</head>
<body>
-18
View File
@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#4a66c4" />
<stop offset="1" stop-color="#2e4ba8" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#brand)" />
<text
x="32"
y="43"
text-anchor="middle"
font-family="'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif"
font-size="32"
font-weight="600"
fill="#ffffff"
>衍</text>
</svg>

Before

Width:  |  Height:  |  Size: 532 B

-26
View File
@@ -1,31 +1,5 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { useSettingsStore } from '@/store/settings'
// 根组件只承载路由出口;布局由各页面自行决定
const route = useRoute()
const settingsStore = useSettingsStore()
/**
* 浏览器标题 =「页面名 - 公司名 系统副标题」,跟随公司信息设置动态变化。
*
* 用 watch 而非 router.afterEach:品牌信息是异步载入的(匿名端点),
* afterEach 执行时只能取到当时的旧值;watch 在公司信息载入/设置页保存
* 后自动重算标题,无需等下一次路由跳转。
*/
watch(
() => [route.meta.title, settingsStore.brandTitle] as const,
() => {
const title = route.meta.title as string | undefined
document.title = title ? `${title} - ${settingsStore.brandTitle}` : settingsStore.brandTitle
},
{ immediate: true },
)
// 品牌信息全局只载入一次(登录页/布局另有调用,内部去重)
void settingsStore.ensureLoaded()
</script>
<template>
+64 -1
View File
@@ -39,6 +39,65 @@ export function stepNodeLabel(node: number): string {
return ({ 0: '撤回', 1: '财务复核', 2: '领导审核' } as const)[node] ?? `节点${node}`
}
/** 审批对象类型:1二次分配方案 2月度结算 3调整单 */
export function orderTypeLabel(orderType: number): string {
return (
({ 1: '二次分配方案', 2: '月度结算', 3: '调整单' } as const)[orderType] ??
`类型${orderType}`
)
}
/** 二次分配方案送审内容(snapshot.data 按 order_type=1 解释) */
export interface ApprovalAllocationData {
business_no: string
business_date: string | null
customer_name: string | null
owner_personnel_name: string | null
assistant_personnel_name: string | null
sale_amount: string | null
cost_amount: string | null
allocations: { personnel_id: number; personnel_name: string; ratio: string }[]
allocation_total: string
allocation_balance: string
}
/** 月结送审内容(snapshot.data 按 order_type=2 解释) */
export interface ApprovalSettlementData {
settlement_month: string
total_performance: string | null
total_payable: string | null
person_count: number
items: {
personnel_name: string
role_code: string
calc_type: number
performance_sum: string
payable_sum: string
basic_amount: string | null
salary_mode: string | null
net_amount: string | null
is_probation: number
}[]
}
/** 调整单送审内容(snapshot.data 按 order_type=3 解释) */
export interface ApprovalAdjustmentData {
order_no: string
settlement_month: string
personnel_name: string
direction: number
amount: string
reason: string
}
/** 审批内容信封:snapshot=提交时快照;live=历史单回退当前内容;missing=缺失 */
export interface ApprovalSnapshot {
schema_version: number
order_type: number
captured_at: string | null
data: Record<string, unknown>
}
export interface ApprovalStep {
id: number
node: number
@@ -52,10 +111,12 @@ export interface ApprovalStep {
export interface ApprovalOrder {
id: number
order_no: string
/** 1二次分配方案 2月度结算(预留) */
/** 1二次分配方案 2月度结算 3调整单 */
order_type: number
ref_id: number
refer_no: string
/** 同一引用对象的第几次提交(重提产生新版本) */
version_no: number
status: number
submitter_id: number | null
submitter_name: string
@@ -64,6 +125,8 @@ export interface ApprovalOrder {
export interface ApprovalOrderDetail extends ApprovalOrder {
steps: ApprovalStep[]
snapshot: ApprovalSnapshot | null
content_source: 'snapshot' | 'live' | 'missing'
}
export interface ApprovalQueryParams {
+12 -1
View File
@@ -58,12 +58,23 @@ export interface SecondaryAllocationRow {
ratio: string
}
/** 业务详情:附产品明细行与二次分配方案(编辑弹窗一次回显) */
/** 方案版本行(append-only;编辑替换/驳回清空后旧行仍保留为历史) */
export interface AllocationVersionRow extends SecondaryAllocationRow {
/** 方案版本号(同一业务从 1 递增) */
version_no: number
/** 1 当前生效 0 历史版本 */
is_active: number
create_time: string
}
/** 业务详情:附产品明细行、当前方案与方案版本历史(编辑弹窗一次回显) */
export interface BusinessDetail extends BusinessItem {
items: BusinessItemRow[]
secondary_allocations: SecondaryAllocationRow[]
/** 方案合计/余额提示(余额归属待澄清 #2,仅展示) */
allocation_hint: string | null
/** 方案全部版本(含历史;被驳回方案亦保留) */
allocation_history: AllocationVersionRow[]
}
export interface BusinessQueryParams {
-8
View File
@@ -21,10 +21,6 @@ export interface CustomerItem {
owner_personnel_id: number | null
owner_personnel_name: string | null
remark: string | null
/** 评级总分(字符串直出,未评级为 null;等级由总分判定、同向) */
rating_total: string | null
/** 评级等级快照(保存时点固化;未评级/未命中为 null) */
rating_grade_name: string | null
}
export interface CustomerQueryParams {
@@ -34,10 +30,6 @@ export interface CustomerQueryParams {
keyword?: string
customer_type?: string
owner_personnel_id?: number
/** 排序字段白名单:rating = 客户评级总分(后端校验,非法值 400) */
order_field?: 'rating'
/** 排序方向;未评级恒在末尾(后端 NULLS LAST 模拟) */
order_type?: 'asc' | 'desc'
}
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */
-20
View File
@@ -23,26 +23,6 @@ export interface ParamCatalogItem {
values: ParamDimensionValue[]
}
/**
* 枚举参数值 → 中文(展示层翻译;库与接口仍存英文码,审计口径不变)。
*
* 业界惯例(RuoYi 字典标签同款):映射单一出处,避免页内复制漂移;
* 未知码原样回显 —— 后端新增枚举项而前端未跟上时不会显示成空。
*/
export const PARAM_ENUM_LABELS: Record<string, string> = {
FULL: '当月全额',
BALANCE: '余额',
INCLUDE: '含基本工资',
DEDUCT: '扣除基本工资',
EXCLUDE: '不计基本工资',
'0': '关',
'1': '开',
}
export function paramEnumLabel(code: string): string {
return PARAM_ENUM_LABELS[code] ?? code
}
/** 参数目录(asOf 缺省为今日) */
export function getCatalog(asOf?: string): Promise<ParamCatalogItem[]> {
return request({ url: '/v1/system/params', method: 'get', params: asOf ? { as_of: asOf } : {} })
+1 -51
View File
@@ -54,57 +54,7 @@ export function updateRatingGrade(
return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'put', data })
}
/**
* 删除评级等级(物理删除;客户评级行快照固化等级名、无外键引用,删等级
* 不伤已评级客户的历史结论;允许清空,新评级等级记为 null)。
*/
/** 删除评级等级(物理删除,等级表无下游引用,允许清空后由列表空态提示) */
export function deleteRatingGrade(gradeId: number): Promise<null> {
return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'delete' })
}
// ==================== 客户评级(每客户一行,落库) ====================
/**
* 客户当前评级(对应后端 `CustomerRatingOut`)。
*
* 分值按红线以字符串直出(比较前显式 parseFloat);total_score 由服务端
* 按五项重算。grade_name 是保存时点的等级**快照**(无外键),可能为 null
* (总分未命中任何等级 / 当时等级表为空)。
*/
export interface CustomerRatingItem {
customer_id: number
score_ship_amount: string
score_receipt_rate: string
score_profit_rate: string
score_contract_cooperation: string
score_site_construction: string
total_score: string
grade_name: string | null
remark: string | null
scorer_name: string
create_time: string
update_time: string
}
/** 保存评级入参:五项 0-20 数字字符串;总分与等级由服务端重算 */
export interface CustomerRatingParams {
score_ship_amount: string
score_receipt_rate: string
score_profit_rate: string
score_contract_cooperation: string
score_site_construction: string
remark?: string
}
/** 查某客户当前评级;未评级时 data 为 null(正常态,不是错误) */
export function getCustomerRating(customerId: number): Promise<CustomerRatingItem | null> {
return request({ url: `/v1/base/customers/${customerId}/rating`, method: 'get' })
}
/** 保存客户评级(upsert:首次创建、再次保存整体覆盖) */
export function saveCustomerRating(
customerId: number,
data: CustomerRatingParams,
): Promise<CustomerRatingItem> {
return request({ url: `/v1/base/customers/${customerId}/rating`, method: 'put', data })
}
+17 -44
View File
@@ -1,64 +1,37 @@
import { request } from './request'
import service, { request } from './request'
/**
* 公司信息与站点信息(管理端)。
*
* 2026-10-20 精简:删除系统副标题与企业档案字段(信用代码/地址/电话/邮箱),
* 新增站点标题(浏览器标签页/侧边栏副标题)与站点图标(标签页 favicon)。
* 展示字段全部可空:null 表示未配置,前端回退内置默认值。
*/
/** 公司信息(管理端) */
export interface CompanySettings {
company_name: string
/** 登录页品牌位 logo;未上传时为 null */
/** 未上传时为 null */
logo_url: string | null
site_title: string | null
brand_slogan: string | null
footer_text: string | null
icp_no: string | null
/** 浏览器标签页图标;未上传时为 null(前端用内置 favicon.svg) */
favicon_url: string | null
}
/** 匿名端点出参(登录页/浏览器标签页消费;与后端 SettingPublicOut 白名单一致) */
export type PublicSettings = CompanySettings
/**
* 更新入参:**部分更新**语义 —— 仅携带的字段生效,未携带字段保持不变。
* 可选文本传空串等价清空(后端落库 null、前端回退默认)。
*/
export interface SettingsUpdatePayload {
company_name?: string
site_title?: string
brand_slogan?: string
footer_text?: string
icp_no?: string
/** 匿名端点出参(登录页消费;与后端 SettingPublicOut 白名单一致) */
export interface PublicSettings {
company_name: string
logo_url: string | null
}
export function getSettings(): Promise<CompanySettings> {
return request({ url: '/v1/system/settings', method: 'get' })
}
export function updateSettings(payload: SettingsUpdatePayload): Promise<CompanySettings> {
return request({ url: '/v1/system/settings', method: 'put', data: payload })
export function updateSettings(companyName: string): Promise<CompanySettings> {
return request({
url: '/v1/system/settings',
method: 'put',
data: { company_name: companyName },
})
}
/**
* 图片上传走独立的 FormData(multipart,非 JSON)。
*
* 经统一 request 封装解包信封(响应形如 {code,message,data});Content-Type
* 交给浏览器/axios 为 FormData 自动生成(含 boundary),不手工设置。
*/
/** logo 上传走独立的 FormData(multipart,非 JSON) */
export function uploadLogo(file: File): Promise<CompanySettings> {
const form = new FormData()
form.append('file', file)
return request({ url: '/v1/system/settings/logo', method: 'post', data: form })
}
/** 站点图标上传(浏览器标签页;png/jpg/ico ≤2MB) */
export function uploadFavicon(file: File): Promise<CompanySettings> {
const form = new FormData()
form.append('file', file)
return request({ url: '/v1/system/settings/favicon', method: 'post', data: form })
return service.post('/v1/system/settings/logo', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
/** 登录页消费:无令牌也可调用(后端白名单端点)。
+5 -12
View File
@@ -1,23 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppMenu from '@/components/layout/AppMenu.vue'
import { firstAccessiblePath } from '@/router/access'
import { useSettingsStore } from '@/store/settings'
import { useUserStore } from '@/store/user'
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const settingsStore = useSettingsStore()
// 品牌区(名称/副标题/logo)来自公司信息设置;App 已全局载入一次,这里兜底
onMounted(() => {
void settingsStore.ensureLoaded()
})
const drawerVisible = ref(false)
@@ -51,8 +44,8 @@ async function handleLogout(): Promise<void> {
<el-container class="app-layout">
<el-aside class="app-aside" width="220px">
<div class="app-brand">
<span class="app-brand__name">{{ settingsStore.companyName }}</span>
<span class="app-brand__sub">{{ settingsStore.siteTitle }}</span>
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
<span class="app-brand__line"></span>
</div>
<AppMenu class="app-aside__menu" />
@@ -106,8 +99,8 @@ async function handleLogout(): Promise<void> {
:with-header="false"
>
<div class="app-brand">
<span class="app-brand__name">{{ settingsStore.companyName }}</span>
<span class="app-brand__sub">{{ settingsStore.siteTitle }}</span>
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
<span class="app-brand__line"></span>
</div>
<AppMenu />
+12 -4
View File
@@ -143,8 +143,14 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/business/receipts/index.vue'),
meta: { title: '回款管理', group: '业务数据', order: 15 },
},
// 客户评级已改为「客户管理」页行内弹窗(2026-10-19):删除独立页面与
// 菜单入口(菜单由路由表派生,删路由即删菜单);旧 URL 落入兜底重定向
{
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
// 登录用户即可用,不设 requiresAdmin(区别于系统管理组)
path: '/business/rating',
name: 'business-rating',
component: () => import('@/views/business/rating/index.vue'),
meta: { title: '客户评级计算器', group: '业务数据', order: 16 },
},
{
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
@@ -276,7 +282,9 @@ router.beforeEach(async (to) => {
return true
})
// 浏览器标题在 App.vue 中由「当前页 + 公司品牌」响应式计算
// (品牌异步载入/管理端改名后要自动更新,afterEach 只能取到旧值)
router.afterEach((to) => {
const title = to.meta.title as string | undefined
document.title = title ? `${title} - ${import.meta.env.VITE_APP_TITLE}` : import.meta.env.VITE_APP_TITLE
})
export default router
-125
View File
@@ -1,125 +0,0 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { getPublicSettings } from '@/api/settings'
import { setFavicon } from '@/utils/favicon'
/**
* 品牌与站点信息全局状态。
*
* 数据来自匿名 public 端点(登录页无令牌也需要),设置未配置/接口失败时
* 回退内置默认值 —— 与改造前硬编码的品牌文案一致,存量行为零变化。
*
* 两个关键设计:
* - **乐观更新**:管理端保存/上传后直接用回包调 ``apply``,而不是重新请求
* public 端点 —— 匿名端点带 300s 浏览器缓存,管理员改完自己刷新会先看到
* 旧值(与登录页历史口径一致)
* - **站点图标优先使用自定义图标,未配置时跟随公司 logo**(2026-10-20 用户
* 确认):换 logo 后标签页图标随之变化;两者都没有时才用内置 SVG
*/
export const DEFAULT_COMPANY_NAME = '衍水集团'
export const DEFAULT_SITE_TITLE = '营销人员工资管理系统'
export const DEFAULT_BRAND_SLOGAN = '把每一笔回款,算成一份踏实。'
const DEFAULT_FAVICON = '/favicon.svg'
/** 品牌/站点相关字段(public 白名单 / 管理端出参的公共子集) */
interface BrandInfo {
company_name?: string
logo_url?: string | null
site_title?: string | null
brand_slogan?: string | null
footer_text?: string | null
icp_no?: string | null
favicon_url?: string | null
}
export const useSettingsStore = defineStore('settings', () => {
const companyName = ref(DEFAULT_COMPANY_NAME)
const siteTitle = ref(DEFAULT_SITE_TITLE)
const brandSlogan = ref(DEFAULT_BRAND_SLOGAN)
const footerText = ref<string | null>(null)
const icpNo = ref<string | null>(null)
const logoUrl = ref<string | null>(null)
const faviconUrl = ref<string | null>(null)
/** public 端点是否已成功载入(失败不置位,下次挂载自然重试) */
const loaded = ref(false)
let pending: Promise<void> | null = null
/** 浏览器标题后缀:公司名 + 站点标题(站点标题为空则只有公司名) */
const brandTitle = computed(() =>
siteTitle.value ? `${companyName.value} ${siteTitle.value}` : companyName.value,
)
/** 登录页页脚:未配置版权文本时用「© 公司名 · 内部系统」 */
const footerLine = computed(() => footerText.value || `© ${companyName.value} · 内部系统`)
/**
* 生效的站点图标:自定义图标 → 公司 logo → 内置 SVG。
*
* 未单独上传站点图标时跟随 logo —— 管理员换 logo,浏览器标签页图标同步变化。
*/
const effectiveFavicon = computed(
() => faviconUrl.value ?? logoUrl.value ?? DEFAULT_FAVICON,
)
/**
* 应用品牌/站点信息(public 回包与设置页保存回包共用)。
*
* 可选字段为 null/空串表示**清空**(回退默认);undefined 表示回包未
* 携带该字段(保持不变)。company_name 为必填,空值忽略。
*/
function apply(info: BrandInfo): void {
if (info.company_name) companyName.value = info.company_name
if (info.logo_url !== undefined) logoUrl.value = info.logo_url
if (info.favicon_url !== undefined) faviconUrl.value = info.favicon_url
if (info.site_title !== undefined) {
siteTitle.value = info.site_title || DEFAULT_SITE_TITLE
}
if (info.brand_slogan !== undefined) {
brandSlogan.value = info.brand_slogan || DEFAULT_BRAND_SLOGAN
}
if (info.footer_text !== undefined) footerText.value = info.footer_text || null
if (info.icp_no !== undefined) icpNo.value = info.icp_no || null
loaded.value = true
}
/**
* 确保品牌/站点信息已载入(App/布局/登录页启动时调用;并发去重)。
*
* 失败静默回退内置默认值 —— 品牌不是关键路径,不能阻断登录或业务页面。
*/
function ensureLoaded(): Promise<void> {
if (loaded.value) return Promise.resolve()
if (pending) return pending
pending = (async () => {
try {
apply(await getPublicSettings())
} catch {
// 静默:保持内置默认品牌(登录页必须可用)
} finally {
pending = null
}
})()
return pending
}
// favicon 跟随生效图标(自定义 → logo → 内置);换图由 ?v= 版本参数破缓存
watch(effectiveFavicon, (url) => setFavicon(url), { immediate: true })
return {
companyName,
siteTitle,
brandSlogan,
footerText,
icpNo,
logoUrl,
faviconUrl,
effectiveFavicon,
loaded,
brandTitle,
footerLine,
apply,
ensureLoaded,
}
})
-21
View File
@@ -1,21 +0,0 @@
/**
* 动态站点图标。
*
* 手写同款实现(参照 vueuse `useFavicon`):修改已有 `<link rel="icon">`
* 的 href 即可让浏览器重新加载图标;没有 link 时补建一个。项目为保持
* 直接依赖克制(5 个)未引入 @vueuse/core,故按同口径手写。
*
* 缓存:logo 地址自带 `?v=更新时间戳` 版本参数,换图后不会被浏览器缓存拖住。
*/
const FAVICON_SELECTOR = 'link[rel~="icon"]'
export function setFavicon(href: string): void {
if (typeof document === 'undefined') return
let link = document.querySelector<HTMLLinkElement>(FAVICON_SELECTOR)
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = href
}
+13 -159
View File
@@ -1,17 +1,15 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules, type TableInstance } from 'element-plus'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as customerApi from '@/api/customers'
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
import * as personnelApi from '@/api/personnel'
import { listRatingGrades } from '@/api/ratings'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format'
import { handledError } from '@/utils/error'
import CustomerRatingDialog from './CustomerRatingDialog.vue'
/**
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
@@ -22,9 +20,6 @@ import CustomerRatingDialog from './CustomerRatingDialog.vue'
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
* (对照表 #19 的业务口径)
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
* - 客户评级列(2026-10-19):每客户一行快照落库;评级配置走行内弹窗
* (原独立评级页删除);列头支持正序/倒序 —— 列表是服务端分页,
* 排序必须带参数回后端(`order_field=rating`),且未评级恒在末尾
*/
const { confirmAndRun } = useConfirmAction()
@@ -36,10 +31,6 @@ const filters = reactive({
owner_personnel_id: undefined as number | undefined,
})
/** 评级列排序方向(undefined = 默认 id 倒序;后端白名单只接受 rating) */
const ratingOrder = ref<'asc' | 'desc' | undefined>(undefined)
const tableRef = ref<TableInstance>()
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<CustomerItem, customerApi.CustomerQueryParams>(
customerApi.listCustomers,
@@ -47,58 +38,16 @@ const { records, total, pageNum, pageSize, loading, load, search, onPageChange,
keyword: filters.keyword,
customer_type: filters.customer_type,
owner_personnel_id: filters.owner_personnel_id,
order_field: ratingOrder.value ? 'rating' : undefined,
order_type: ratingOrder.value,
}),
)
/** 评级列头排序:custom 模式只触发事件,排序交给后端(服务端分页必须如此) */
function onRatingSortChange({
prop,
order,
}: {
prop: string
order: 'ascending' | 'descending' | null
}): void {
if (prop !== 'rating_total') return
ratingOrder.value = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : undefined
search()
}
function resetFilters(): void {
filters.keyword = ''
filters.customer_type = undefined
filters.owner_personnel_id = undefined
ratingOrder.value = undefined
tableRef.value?.clearSort()
search()
}
// ==================== 评级列展示 ====================
/** 等级名按判定位次(sort_order 升序)排列,用于 tag 语义色映射 */
const gradeNames = ref<string[]>([])
async function loadGradeNames(): Promise<void> {
try {
const grades = await listRatingGrades()
gradeNames.value = grades.map((grade) => grade.grade_name)
} catch {
// 配色不是关键路径:失败时所有等级走默认 info 色,列表照常可用
gradeNames.value = []
}
}
const RATING_TAG_TYPES = ['success', 'primary', 'warning', 'danger'] as const
/** 第 1 档 success → 第 2 档 primary → 第 3 档 warning → 其余 danger;未知等级 info */
function ratingTagType(gradeName: string | null): (typeof RATING_TAG_TYPES)[number] | 'info' {
if (!gradeName) return 'info'
const index = gradeNames.value.indexOf(gradeName)
if (index === -1) return 'info'
return RATING_TAG_TYPES[Math.min(index, RATING_TAG_TYPES.length - 1)]
}
// ==================== 归属人员下拉数据源 ====================
interface PersonnelOption {
@@ -353,7 +302,7 @@ function ownerTransition(row: OwnershipLogItem): string {
async function handleDelete(row: CustomerItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除客户「${row.company_name}」?删除为物理删除、不可恢复;归属变更留痕与评级记录将一并失去展示主体。`,
confirmText: `确定删除客户「${row.company_name}」?删除为物理删除、不可恢复;归属变更留痕将一并失去展示主体。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '客户已删除',
@@ -362,29 +311,12 @@ async function handleDelete(row: CustomerItem): Promise<void> {
})
}
// ==================== 客户评级弹窗 ====================
const ratingVisible = ref(false)
const ratingTarget = ref<CustomerItem | null>(null)
function openRating(row: CustomerItem): void {
ratingTarget.value = row
ratingVisible.value = true
}
/** 弹窗内保存评级/调整等级映射后:刷新列表行与 tag 配色 */
async function handleRatingSaved(): Promise<void> {
await load()
void loadGradeNames()
}
// ==================== 初始化 ====================
onMounted(() => {
// 三个请求各自容错(内部已兜底),互不拖累
// 两个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadOwnerOptions()
void loadGradeNames()
})
</script>
@@ -437,54 +369,16 @@ onMounted(() => {
<el-button type="primary" @click="openCreate">新增客户</el-button>
</div>
<el-table
ref="tableRef"
v-loading="loading"
:data="records"
stripe
@sort-change="onRatingSortChange"
>
<!-- 列宽约定(2026-10-20 用户确认):8 列**等宽平分** —— 所有列共用同一
min-width,el-table 按 min-width 比例分配剩余宽度,等值即等宽;
全列居中后相邻两列的间距处处一致。不设 width/fixed:固定列会打破
等分(min-width 与 width 混排是按比例瓜分的根源)。窄屏下列宽收缩,
操作按钮自然换行,优先保证间距一致 -->
<el-table-column
prop="company_name"
label="公司名称"
min-width="140"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="rating_total"
label="客户评级"
min-width="140"
align="center"
sortable="custom"
>
<template #default="{ row }">
<template v-if="row.rating_total !== null">
<el-tag
:type="ratingTagType(row.rating_grade_name)"
size="small"
disable-transitions
>
{{ row.rating_grade_name ?? '未定级' }}
</el-tag>
<span class="rating-score">{{ row.rating_total }} 分</span>
</template>
<span v-else class="rating-none">未评级</span>
</template>
</el-table-column>
<el-table-column prop="owner_name" label="负责人" min-width="140" align="center" />
<el-table-column prop="contact_person" label="联系人" min-width="140" align="center">
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="company_name" label="公司名称" min-width="180" />
<el-table-column prop="owner_name" label="负责人" min-width="90" />
<el-table-column prop="contact_person" label="联系人" min-width="90">
<template #default="{ row }">{{ row.contact_person ?? '—' }}</template>
</el-table-column>
<el-table-column prop="contact_phone" label="电话" min-width="140" align="center">
<el-table-column prop="contact_phone" label="电话" min-width="120">
<template #default="{ row }">{{ row.contact_phone ?? '—' }}</template>
</el-table-column>
<el-table-column label="分类" min-width="140" align="center">
<el-table-column label="分类" width="110" align="center">
<template #default="{ row }">
<el-tag
:type="
@@ -501,23 +395,18 @@ onMounted(() => {
</el-tag>
</template>
</el-table-column>
<el-table-column label="归属人员" min-width="140" align="center">
<el-table-column label="归属人员" min-width="110">
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
</el-table-column>
<el-table-column label="操作" min-width="140" align="center">
<el-table-column label="操作" width="260" fixed="right" align="center">
<template #default="{ row }">
<!-- 五项按钮单行均分:两端对齐让相邻按钮间距处处相等;
「配置客户评级」最长(6 字),在等宽列内无法再做 5 等分不换行,
故用等间距平分整列宽度 -->
<div class="ops-actions">
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openRating(row)">
配置客户评级
</el-button>
<el-button link type="primary" size="small" @click="openOwnership(row)">
归属变更
</el-button>
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
@@ -641,14 +530,6 @@ onMounted(() => {
</template>
</el-dialog>
<!-- 客户评级(打分 + 等级映射设置双 Tab;原独立评级页已删除) -->
<CustomerRatingDialog
v-model="ratingVisible"
:customer-id="ratingTarget?.id ?? 0"
:customer-name="ratingTarget?.company_name ?? ''"
@saved="handleRatingSaved"
/>
<!-- 归属变更记录(分页留痕,按变更时间倒序) -->
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
<el-table v-loading="logsLoading" :data="logRecords" stripe size="small">
@@ -683,31 +564,4 @@ onMounted(() => {
font-size: 12px;
line-height: 1.7;
}
/* 操作列:五个按钮单行、等间距平分整列(两端对齐);
去掉 Element Plus 相邻按钮的默认 12px 外边距,间距完全由 flex 控制 */
.ops-actions {
display: flex;
justify-content: space-between;
align-items: center;
white-space: nowrap;
}
.ops-actions :deep(.el-button) {
margin: 0;
padding: 0;
}
/* 评级列:等级 tag + 总分灰字;未评级灰字 */
.rating-score {
margin-left: 6px;
font-size: 12px;
color: var(--ys-ink-3);
font-variant-numeric: tabular-nums;
}
.rating-none {
font-size: 12px;
color: var(--ys-ink-3);
}
</style>
@@ -1141,6 +1141,43 @@ onMounted(() => {
</el-table>
</div>
<div class="sub-section">
<div class="sub-section-title">二次分配方案(含历史版本)</div>
<el-table
v-if="detail && (detail.allocation_history?.length ?? 0) > 0"
:data="detail.allocation_history"
stripe
size="small"
>
<el-table-column label="版本" width="70" align="center">
<template #default="{ row }">V{{ row.version_no }}</template>
</el-table-column>
<el-table-column prop="personnel_name" label="参与业务员" min-width="140" />
<el-table-column label="分配比例" width="110" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.ratio }}%</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag
v-if="row.is_active === 1"
type="success"
size="small"
disable-transitions
>
生效
</el-tag>
<el-tag v-else type="info" size="small" disable-transitions>历史</el-tag>
</template>
</el-table-column>
<el-table-column label="录入时间" min-width="150">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
</el-table>
<div v-else class="cell-muted">未设置二次分配 —— 全部绩效归负责营销人员</div>
</div>
<div class="sub-section">
<div class="sub-section-title">状态流转</div>
<el-timeline v-if="statusLogs.length > 0" class="status-timeline">
@@ -1,13 +1,11 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
createRatingGrade,
deleteRatingGrade,
getCustomerRating,
listRatingGrades,
saveCustomerRating as saveCustomerRatingApi,
updateRatingGrade,
type RatingGradeItem,
type RatingGradeParams,
@@ -16,29 +14,13 @@ import { useConfirmAction } from '@/composables/useConfirmAction'
import { handledError } from '@/utils/error'
/**
* 客户评级弹窗(2026-10-19 由独立「客户评级计算器」页面迁移而来)。
* 客户评级计算器(需求 2.2.5 / 对照表 #25)。
*
* 双 Tab 设计避免三层弹层:
* - Tab1「客户评级」:五项打分 → 总分/等级实时判定 → 保存落库(每客户一行,
* 等级按保存时点的映射快照固化);口径提示「评分不参与绩效金额计算」
* (对照表 #25)常驻
* - Tab2「等级映射设置」:等级表增删改(原页面抽屉整体迁入),判定规则与
* 打分区共用同一份列表,配置变更后实时生效
*
* 判定与后端 ``rating_service.resolve_grade`` 同规则:按 sort_order 升序取
* 首个 min_score ≤ 总分的等级;无命中显示未命中提示、保存后等级为 null。
* 评级计算器仅是**打分工具**:五项打分求总分、按等级映射实时判定等级,
* 评分记录不落库、不参与绩效金额计算,因此页面没有保存动作(仅重置)。
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
* 重载判定用列表——判定与抽屉表格共用同一份数据。
*/
const props = defineProps<{
/** 目标客户(未打开弹窗时可为 0,仅打开后读取) */
customerId: number
customerName: string
}>()
const visible = defineModel<boolean>({ required: true })
const emit = defineEmits<{ saved: [] }>()
const activeTab = ref<'score' | 'grades'>('score')
// ==================== 等级判定(纯函数) ====================
@@ -47,9 +29,10 @@ const activeTab = ref<'score' | 'grades'>('score')
*
* 后端 min_score 是字符串直出(分值红线),比较前必须 parseFloat 转数值,
* 禁止字符串直接比大小("90.0" 这类字典序比较会得出错误结果)。
* 无命中(总分低于全部下限)返回 null,由调用方展示未命中提示。
*/
function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGradeItem | null {
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id)
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order)
for (const grade of ordered) {
if (totalScore >= parseFloat(grade.min_score)) {
return grade
@@ -58,7 +41,7 @@ function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGrad
return null
}
// ==================== 评分计算 ====================
// ==================== 评分计算(纯前端实时计算) ====================
/** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */
const SCORE_ITEMS = [
@@ -84,49 +67,21 @@ const totalScore = computed(() =>
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
)
/** 无保存动作(不落库),重置即把五项清零 */
function resetScores(): void {
for (const item of SCORE_ITEMS) {
scores[item.key] = 0
}
}
const remark = ref('')
/** 已存在的评级(用于回显打分与「上次评级」信息) */
const hasExisting = ref(false)
const existingInfo = ref<string | null>(null)
const saving = ref(false)
async function loadRating(): Promise<void> {
try {
const rating = await getCustomerRating(props.customerId)
if (!rating) {
hasExisting.value = false
existingInfo.value = null
resetScores()
remark.value = ''
return
}
hasExisting.value = true
// 后端字符串直出,回填 parseFloat;保存时再 String() 转回
scores.ship_amount = parseFloat(rating.score_ship_amount)
scores.receipt_rate = parseFloat(rating.score_receipt_rate)
scores.profit_rate = parseFloat(rating.score_profit_rate)
scores.contract_cooperation = parseFloat(rating.score_contract_cooperation)
scores.site_construction = parseFloat(rating.score_site_construction)
remark.value = rating.remark ?? ''
existingInfo.value = `上次由 ${rating.scorer_name} 评定于 ${rating.update_time.replace('T', ' ').slice(0, 16)}`
} catch (error) {
handledError(error)
}
}
// ==================== 等级映射(判定用列表 + 设置表共用) ====================
// ==================== 等级映射(判定用列表) ====================
const { confirmAndRun } = useConfirmAction()
const grades = ref<RatingGradeItem[]>([])
const gradesLoading = ref(false)
/** 判定与抽屉表格共用同一份全量列表,配置变更后重载一次即同时生效 */
async function loadGrades(): Promise<void> {
gradesLoading.value = true
try {
@@ -140,47 +95,17 @@ async function loadGrades(): Promise<void> {
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
// ==================== 打开即初始化 ====================
// ==================== 等级映射设置抽屉 ====================
async function init(): Promise<void> {
activeTab.value = 'score'
hasExisting.value = false
existingInfo.value = null
resetScores()
remark.value = ''
if (!props.customerId) return
await Promise.all([loadGrades(), loadRating()])
const drawerVisible = ref(false)
/** 打开时重新拉取,避免展示陈旧数据 */
function openDrawer(): void {
drawerVisible.value = true
void loadGrades()
}
watch(visible, (open) => {
if (open) void init()
})
// ==================== 保存评级 ====================
async function handleSave(): Promise<void> {
if (!props.customerId) return
saving.value = true
try {
await saveCustomerRatingApi(props.customerId, {
score_ship_amount: String(scores.ship_amount ?? 0),
score_receipt_rate: String(scores.receipt_rate ?? 0),
score_profit_rate: String(scores.profit_rate ?? 0),
score_contract_cooperation: String(scores.contract_cooperation ?? 0),
score_site_construction: String(scores.site_construction ?? 0),
remark: remark.value.trim() || undefined,
})
ElMessage.success('评级已保存')
visible.value = false
emit('saved')
} catch (error) {
handledError(error)
} finally {
saving.value = false
}
}
// ==================== 新增 / 编辑等级(二层弹窗) ====================
// ==================== 新增 / 编辑等级弹窗 ====================
const editVisible = ref(false)
/** null = 新增,否则为编辑目标 id */
@@ -225,6 +150,7 @@ function editFormError(minScore: number | undefined, maxScore: number | undefine
}
async function saveGrade(): Promise<void> {
// 局部变量收窄类型:reactive 属性在异步流程中不保证保持窄化
const minScore = editForm.min_score
const maxScore = editForm.max_score
const error = editFormError(minScore, maxScore)
@@ -239,7 +165,7 @@ async function saveGrade(): Promise<void> {
max_score: String(maxScore),
sort_order: editForm.sort_order ?? 0,
}
// 后端为全量替换语义;remark 本弹窗不维护,编辑时原样带回避免误清
// 后端为全量替换语义;remark 本页不维护,编辑时原样带回避免误清
if (editingId.value !== null) {
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
}
@@ -254,7 +180,6 @@ async function saveGrade(): Promise<void> {
editVisible.value = false
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
await loadGrades()
emit('saved')
} catch (requestError) {
handledError(requestError)
} finally {
@@ -262,134 +187,116 @@ async function saveGrade(): Promise<void> {
}
}
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级,已评级客户快照不受影响) */
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级) */
function removeGrade(row: RatingGradeItem): void {
void confirmAndRun({
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后新评级在该分数区间将无法映射等级,已评级客户的等级快照不受影响。`,
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后该分数区间将无法映射等级。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '评级等级已删除',
run: () => deleteRatingGrade(row.id),
after: async () => {
await loadGrades()
emit('saved')
},
after: () => loadGrades(),
})
}
onMounted(() => {
void loadGrades()
})
</script>
<template>
<el-dialog
v-model="visible"
:title="`客户评级 · ${customerName}`"
width="680px"
destroy-on-close
>
<div class="rating-page">
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
<el-alert
class="rating-alert"
title="评级为评分工具,评分结果不参与绩效金额计算"
class="page-alert"
title="本页为评分计算工具,评分结果不参与绩效金额计算"
type="warning"
:closable="false"
show-icon
/>
<el-tabs v-model="activeTab">
<el-tab-pane label="客户评级" name="score">
<div class="calc-grid">
<div class="calc-form">
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
<span class="score-label">{{ item.label }}</span>
<el-input-number
v-model="scores[item.key]"
:min="0"
:max="20"
:step="1"
controls-position="right"
/>
<span class="score-unit">分</span>
</div>
<p v-if="existingInfo" class="existing-hint">{{ existingInfo }}</p>
</div>
<div class="calc-result">
<div>
<span class="result-label">总分</span>
<b class="total-num">{{ totalScore }}</b>
<span class="total-max">/ 100</span>
</div>
<div>
<span class="result-label">评级等级</span>
<template v-if="grades.length === 0">
<div class="grade-name">—</div>
<div class="grade-hint">未配置等级映射,保存后等级为空</div>
</template>
<template v-else-if="matchedGrade">
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
<div class="grade-hint">
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
</div>
</template>
<template v-else>
<div class="grade-name">—</div>
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
</template>
</div>
</div>
</div>
<el-form label-width="70px" class="remark-form" @submit.prevent>
<el-form-item label="备注">
<el-input
v-model="remark"
maxlength="255"
placeholder="选填,如评级依据说明"
clearable
/>
</el-form-item>
</el-form>
<div class="score-tip">
五项各 0-20 分,总分 0-100;保存后等级按当前映射快照固化,
之后调整等级映射不影响该客户已保存的等级。
</div>
<div class="dialog-actions">
<!-- 评分计算区:无保存按钮,纯前端实时计算 -->
<el-card shadow="never">
<div class="page-toolbar">
<span class="page-desc">五项各 0-20 分,总分 0-100;评级等级按配置的下限实时判定。</span>
<span>
<el-button @click="resetScores">重置</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ hasExisting ? '更新评级' : '保存评级' }}
</el-button>
</div>
</el-tab-pane>
<el-button type="primary" @click="openDrawer">等级映射设置</el-button>
</span>
</div>
<el-tab-pane label="等级映射设置" name="grades">
<div class="drawer-hint">
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
<div class="calc-grid">
<div class="calc-form">
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
<span class="score-label">{{ item.label }}</span>
<el-input-number
v-model="scores[item.key]"
:min="0"
:max="20"
:step="1"
controls-position="right"
/>
<span class="score-unit">分</span>
</div>
</div>
<div class="grades-toolbar">
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
</div>
<el-table v-loading="gradesLoading" :data="grades" size="small">
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
<el-table-column prop="max_score" label="上限" width="90" align="center" />
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
<el-table-column label="操作" width="110" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
<div class="calc-result">
<div>
<span class="result-label">总分</span>
<b class="total-num">{{ totalScore }}</b>
<span class="total-max">/ 100</span>
</div>
<div>
<span class="result-label">评级等级</span>
<template v-if="grades.length === 0">
<div class="grade-name">—</div>
<div class="grade-hint">未配置等级映射</div>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<template v-else-if="matchedGrade">
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
<div class="grade-hint">
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
</div>
</template>
<template v-else>
<div class="grade-name">—</div>
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
</template>
</div>
</div>
</div>
</el-card>
<!-- 新增 / 编辑等级(二层弹窗;append-to-body 避免嵌套弹层层级与裁剪问题) -->
<!-- 等级映射设置抽屉 -->
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px">
<div class="drawer-hint">
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
</div>
<div class="drawer-toolbar">
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
</div>
<el-table v-loading="gradesLoading" :data="grades" size="small">
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
<el-table-column prop="max_score" label="上限" width="90" align="center" />
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
<el-table-column label="操作" width="110" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-drawer>
<!-- 新增 / 编辑等级 -->
<el-dialog
v-model="editVisible"
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
width="440px"
append-to-body
destroy-on-close
@closed="editingId = null"
>
@@ -435,36 +342,49 @@ function removeGrade(row: RatingGradeItem): void {
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
</template>
</el-dialog>
</el-dialog>
</div>
</template>
<style scoped>
.rating-alert {
margin-bottom: 6px;
.page-alert {
margin-bottom: 14px;
}
.page-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
}
.page-desc {
font-size: 13px;
color: var(--ys-ink-2);
}
.calc-grid {
display: flex;
gap: 24px;
gap: 28px;
align-items: stretch;
flex-wrap: wrap;
margin-top: 8px;
}
.calc-form {
flex: 1 1 320px;
min-width: 260px;
flex: 1 1 380px;
min-width: 280px;
}
.score-row {
display: flex;
align-items: center;
gap: 12px;
line-height: 2.6;
line-height: 2.7;
}
.score-label {
width: 132px;
width: 140px;
font-size: 13px;
color: var(--ys-ink);
text-align: right;
@@ -475,24 +395,17 @@ function removeGrade(row: RatingGradeItem): void {
color: var(--ys-ink-3);
}
.existing-hint {
margin: 8px 0 0;
font-size: 12px;
text-align: right;
color: var(--ys-ink-3);
}
.calc-result {
flex: 0 0 220px;
min-width: 200px;
flex: 0 0 250px;
min-width: 220px;
border: 1px solid var(--ys-line);
border-radius: 10px;
padding: 16px 18px;
padding: 18px 20px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
display: flex;
flex-direction: column;
justify-content: center;
gap: 16px;
gap: 18px;
}
.result-label {
@@ -503,7 +416,7 @@ function removeGrade(row: RatingGradeItem): void {
}
.total-num {
font-size: 36px;
font-size: 40px;
color: var(--ys-indigo);
font-variant-numeric: tabular-nums;
margin-right: 4px;
@@ -516,7 +429,7 @@ function removeGrade(row: RatingGradeItem): void {
.grade-name {
font-family: var(--ys-font-brand);
font-size: 26px;
font-size: 28px;
font-weight: 600;
color: var(--ys-ink);
line-height: 1.3;
@@ -529,25 +442,6 @@ function removeGrade(row: RatingGradeItem): void {
margin-top: 4px;
}
.remark-form {
margin-top: 10px;
}
.score-tip {
font-size: 12px;
color: var(--ys-ink-3);
background: var(--ys-mist);
border-radius: 6px;
padding: 8px 12px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
.drawer-hint {
font-size: 12px;
color: var(--ys-ink-3);
@@ -557,7 +451,7 @@ function removeGrade(row: RatingGradeItem): void {
margin-bottom: 12px;
}
.grades-toolbar {
.drawer-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
@@ -577,6 +471,7 @@ function removeGrade(row: RatingGradeItem): void {
padding: 8px 12px;
}
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
@media (max-width: 768px) {
.score-label {
width: auto;
+31 -16
View File
@@ -5,13 +5,11 @@ import { useRoute, useRouter } from 'vue-router'
import { getCaptcha } from '@/api/auth'
import { canAccessPath, firstAccessiblePath } from '@/router/access'
import { useSettingsStore } from '@/store/settings'
import { useUserStore } from '@/store/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const settingsStore = useSettingsStore()
const formRef = ref<FormInstance>()
const submitting = ref(false)
@@ -26,6 +24,32 @@ const form = reactive({
const captchaId = ref('')
const captchaImage = ref('')
/** 品牌名:默认衍水集团,公司信息设置页改了以后登录页同步生效(public 端点) */
const brandName = ref('衍水集团')
const brandLogo = ref<string | null>(null)
async function loadBrand(): Promise<void> {
try {
// 登录页必须拿到最新公司名(保存后立即生效):public 端点带 300s
// 响应缓存,axios 可能命中浏览器缓存副本。这里用带时间戳的原生
// fetch 绕过磁盘缓存,手动解包统一信封(与 request 拦截器同口径)。
const resp = await fetch(
`/api/v1/system/settings/public?_=${Date.now()}`,
{ cache: 'no-store' },
)
if (!resp.ok) throw new Error(String(resp.status))
const body = (await resp.json()) as {
code: number
data: { company_name?: string; logo_url?: string | null }
}
const data = body.data
if (data.company_name) brandName.value = data.company_name
brandLogo.value = data.logo_url ?? null
} catch {
// 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录
}
}
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
@@ -84,8 +108,7 @@ async function handleSubmit(): Promise<void> {
onMounted(() => {
refreshCaptcha()
// 品牌信息失败静默回退内置默认值(不阻断登录;内部去重/缓存)
void settingsStore.ensureLoaded()
loadBrand()
})
</script>
@@ -99,14 +122,9 @@ onMounted(() => {
<div class="login-wrap">
<div class="login-card">
<div class="card-brand">
<img
v-if="settingsStore.logoUrl"
:src="settingsStore.logoUrl"
alt=""
class="card-brand__logo"
/>
<div class="card-brand__name">{{ settingsStore.companyName }}</div>
<p class="card-brand__slogan">{{ settingsStore.brandSlogan }}</p>
<img v-if="brandLogo" :src="brandLogo" alt="" class="card-brand__logo" />
<div class="card-brand__name">{{ brandName }}</div>
<p class="card-brand__slogan">把每一笔回款,算成一份踏实。</p>
<span class="waterline"></span>
</div>
@@ -171,10 +189,7 @@ onMounted(() => {
</el-form>
</div>
<p class="login-foot">
{{ settingsStore.footerLine
}}<template v-if="settingsStore.icpNo"> · {{ settingsStore.icpNo }}</template>
</p>
<p class="login-foot">© {{ brandName }} · 内部系统</p>
</div>
</div>
</template>
+192 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
@@ -8,10 +8,14 @@ import {
approvalStatusTagType,
getApproval,
listApprovals,
orderTypeLabel,
stepActionLabel,
stepNodeLabel,
type ApprovalAdjustmentData,
type ApprovalAllocationData,
type ApprovalOrder,
type ApprovalOrderDetail,
type ApprovalSettlementData,
} from '@/api/approvals'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
@@ -78,6 +82,26 @@ async function openDetail(row: ApprovalOrder): Promise<void> {
}
}
// ==================== 审批内容(按对象类型解释快照 data) ====================
const allocationData = computed<ApprovalAllocationData | null>(() =>
detail.value?.order_type === 1 && detail.value.snapshot
? (detail.value.snapshot.data as unknown as ApprovalAllocationData)
: null,
)
const settlementData = computed<ApprovalSettlementData | null>(() =>
detail.value?.order_type === 2 && detail.value.snapshot
? (detail.value.snapshot.data as unknown as ApprovalSettlementData)
: null,
)
const adjustmentData = computed<ApprovalAdjustmentData | null>(() =>
detail.value?.order_type === 3 && detail.value.snapshot
? (detail.value.snapshot.data as unknown as ApprovalAdjustmentData)
: null,
)
// ==================== 节点动作 ====================
const actingId = ref<number | null>(null)
@@ -154,6 +178,8 @@ async function submitAction(): Promise<void> {
<el-form-item label="类型">
<el-select v-model="filters.order_type" placeholder="全部" clearable style="width: 150px">
<el-option label="二次分配方案" :value="1" />
<el-option label="月度结算" :value="2" />
<el-option label="调整单" :value="3" />
</el-select>
</el-form-item>
<el-form-item>
@@ -165,11 +191,12 @@ async function submitAction(): Promise<void> {
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="order_no" label="审批单号" min-width="150" />
<el-table-column label="类型" width="120">
<template #default="{ row }">
{{ row.order_type === 1 ? '二次分配方案' : `类型${row.order_type}` }}
</template>
<template #default="{ row }">{{ orderTypeLabel(row.order_type) }}</template>
</el-table-column>
<el-table-column prop="refer_no" label="业务编号" min-width="140" />
<el-table-column label="版本" width="70" align="center">
<template #default="{ row }">V{{ row.version_no }}</template>
</el-table-column>
<el-table-column prop="refer_no" label="引用单号" min-width="140" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="approvalStatusTagType(row.status)" size="small" disable-transitions>
@@ -196,7 +223,7 @@ async function submitAction(): Promise<void> {
@size-change="onSizeChange"
/>
<el-drawer v-model="detailVisible" title="审批详情" size="480px">
<el-drawer v-model="detailVisible" title="审批详情" size="720px">
<div v-loading="detailLoading">
<template v-if="detail">
<div class="detail-head">
@@ -210,11 +237,129 @@ async function submitAction(): Promise<void> {
</el-tag>
</div>
<div class="detail-meta">
<span>业务编号:{{ detail.refer_no }}</span>
<span>类型:{{ orderTypeLabel(detail.order_type) }} · 版本 V{{ detail.version_no }}</span>
<span>引用单号:{{ detail.refer_no }}</span>
<span>提交人:{{ detail.submitter_name }}</span>
<span>提交时间:{{ formatDateTime(detail.create_time) }}</span>
</div>
<!-- 审批内容:优先提交时快照;历史单缺快照时读当前并显式标注 -->
<div class="content-section">
<div class="content-title">审批内容</div>
<!-- 采集时间让复核者明确「看到的是哪个时点的内容」:快照=送审时刻;
live=打开详情时的当前内容(不做时刻展示——渲染函数里的
new Date() 非响应式且 toISOString 是 UTC,会差 8 小时) -->
<div v-if="detail.content_source === 'snapshot'" class="content-captured">
内容采集时间:{{ formatDateTime(detail.snapshot?.captured_at) }}(送审版本)
</div>
<div v-else-if="detail.content_source === 'live'" class="content-captured">
内容读取时间:打开详情时实时读取(非送审版本)
</div>
<el-alert
v-if="detail.content_source === 'live'"
type="warning"
:closable="false"
show-icon
title="该单为历史单,未留提交时快照;以下为当前业务内容,仅供参考,不等同送审版本。"
/>
<el-alert
v-else-if="detail.content_source === 'missing'"
type="info"
:closable="false"
show-icon
title="送审内容缺失:引用对象已不存在且未留快照,仅可查看节点留痕。"
/>
<template v-if="allocationData">
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="业务编号">
{{ allocationData.business_no }}
</el-descriptions-item>
<el-descriptions-item label="业务日期">
{{ allocationData.business_date ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="客户名称">
{{ allocationData.customer_name ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="负责人员">
{{ allocationData.owner_personnel_name ?? '(待指定)' }}
</el-descriptions-item>
<el-descriptions-item label="销售额(元)">
{{ allocationData.sale_amount ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="成本(元)">
{{ allocationData.cost_amount ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<el-table :data="allocationData.allocations" stripe size="small" class="content-table">
<el-table-column prop="personnel_name" label="参与业务员" min-width="160" />
<el-table-column label="分配比例" width="120" align="right">
<template #default="{ row }">{{ row.ratio }}%</template>
</el-table-column>
<template #empty>
<span class="content-empty">未设置二次分配 —— 全部绩效归负责营销人员</span>
</template>
</el-table>
<div class="content-total">
合计 {{ allocationData.allocation_total }}%,余额
{{ allocationData.allocation_balance }}%(归合同负责营销人员)
</div>
</template>
<template v-else-if="settlementData">
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="结算月份">
{{ settlementData.settlement_month }}
</el-descriptions-item>
<el-descriptions-item label="结算人数">
{{ settlementData.person_count }}
</el-descriptions-item>
<el-descriptions-item label="绩效合计(元)">
{{ settlementData.total_performance ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="应付合计(元)">
{{ settlementData.total_payable ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<el-table :data="settlementData.items" stripe size="small" class="content-table">
<el-table-column prop="personnel_name" label="人员" min-width="110" />
<el-table-column prop="role_code" label="岗位" width="110" />
<el-table-column label="绩效(元)" width="110" align="right">
<template #default="{ row }">{{ row.performance_sum }}</template>
</el-table-column>
<el-table-column label="应付(元)" width="110" align="right">
<template #default="{ row }">{{ row.payable_sum }}</template>
</el-table-column>
<el-table-column label="净工资(元)" width="120" align="right">
<template #default="{ row }">{{ row.net_amount ?? '—' }}</template>
</el-table-column>
</el-table>
</template>
<template v-else-if="adjustmentData">
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="调整单号">
{{ adjustmentData.order_no }}
</el-descriptions-item>
<el-descriptions-item label="调整月份">
{{ adjustmentData.settlement_month }}
</el-descriptions-item>
<el-descriptions-item label="调整人员">
{{ adjustmentData.personnel_name }}
</el-descriptions-item>
<el-descriptions-item label="方向">
{{ adjustmentData.direction === 1 ? '补发' : '扣减' }}
</el-descriptions-item>
<el-descriptions-item label="金额(元)">
{{ adjustmentData.amount }}
</el-descriptions-item>
<el-descriptions-item label="扣补说明">
{{ adjustmentData.reason }}
</el-descriptions-item>
</el-descriptions>
</template>
</div>
<!-- 签名元素:竖排节点时间线(财务复核 → 领导审核的处理链) -->
<div class="timeline">
<div
@@ -315,6 +460,46 @@ async function submitAction(): Promise<void> {
color: var(--ys-ink-3);
}
.content-section {
margin-top: 12px;
padding: 12px;
background: #f7f8fa;
border: 1px solid var(--ys-line);
border-radius: 8px;
}
.content-title {
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: var(--ys-ink);
}
.content-captured {
margin-bottom: 10px;
font-size: 12px;
color: var(--ys-ink-2);
}
.content-desc {
margin-bottom: 10px;
}
.content-table {
margin-top: 8px;
}
.content-total {
margin-top: 8px;
font-size: 12px;
color: var(--ys-ink-2);
}
.content-empty {
font-size: 12px;
color: var(--ys-ink-3);
}
/* 竖排时间线:左侧轨道 + 节点圆点,动作色区分人工/自动/驳回 */
.timeline {
margin-top: 14px;
+24 -63
View File
@@ -4,7 +4,6 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import * as businessRoleApi from '@/api/businessRoles'
import * as paramsApi from '@/api/params'
import { paramEnumLabel } from '@/api/params'
import type {
ParamBatch,
ParamBatchDetail,
@@ -44,10 +43,9 @@ async function loadCatalog(): Promise<void> {
catalogLoading.value = true
try {
catalog.value = await paramsApi.getCatalog()
// 目录加载后填充映射:预览「所用参数」按编码取中文名;值翻译按编码查类型
// 预览「所用参数」按编码取中文名;目录加载后填充映射
for (const item of catalog.value) {
PARAM_NAME_BY_CODE[item.param_code] = item.param_name
PARAM_VALUE_TYPES[item.param_code] = item.value_type
}
} catch (error) {
handledError(error)
@@ -85,24 +83,20 @@ function paramNameByCode(code: string): string {
return PARAM_NAME_BY_CODE[code] ?? code
}
/** 参数编码 → 值类型(目录加载后填充;未知参数不翻译,避免误译数值) */
const PARAM_VALUE_TYPES: Record<string, string> = {}
/**
* 按参数编码翻译枚举值(旧值/新值等宽表用)。
*
* **必须先判参数类型**:枚举映射含 0=关/1=开,若无差别套用,值为 0 或 1
* 的普通数值参数会被误译成「关/开」。非枚举(或目录尚未加载)原样返回。
*/
function enumValueLabel(paramCode: string, value: string | null | undefined): string {
if (value === null || value === undefined) return ''
return PARAM_VALUE_TYPES[paramCode] === 'ENUM' ? paramEnumLabel(value) : value
/** 枚举值 → 中文(FULL/BALANCE、INCLUDE/DEDUCT/EXCLUDE、0/1 开关) */
const ENUM_VALUE_LABELS: Record<string, string> = {
FULL: '当月全额',
BALANCE: '余额',
INCLUDE: '含基本工资',
DEDUCT: '扣除基本工资',
EXCLUDE: '不计基本工资',
'0': '关',
'1': '开',
}
/** ENUM 下拉选项文案:中文主显 + 括号保留原码(当月全额(FULL)),便于与审计记录对上 */
function enumOptionLabel(option: string): string {
const label = paramEnumLabel(option)
return label === option ? option : `${label}(${option})`
function valueLabel(value: string | null | undefined): string {
if (value === null || value === undefined) return ''
return ENUM_VALUE_LABELS[value] ?? value
}
/** 带单位的值展示 */
@@ -473,14 +467,7 @@ onMounted(() => {
<template v-if="item.values.length">
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
<b class="num">
{{ item.value_type === 'ENUM' ? paramEnumLabel(v.value_text) : formatValue(item, v.value_text) }}
</b>
<!-- 灰色小码:与库内/审计记录里的原始码对上;未知码(中文缺映射)不重复显示 -->
<span
v-if="item.value_type === 'ENUM' && paramEnumLabel(v.value_text) !== v.value_text"
class="enum-code"
>{{ v.value_text }}</span>
<b class="num">{{ formatValue(item, v.value_text) }}</b>
<span class="eff">{{ v.effective_date }} 起</span>
<el-tag
v-if="pendingOf(item, v.dimension_value)"
@@ -488,7 +475,7 @@ onMounted(() => {
type="warning"
disable-transitions
>
待改为 {{ enumValueLabel(item.param_code, pendingOf(item, v.dimension_value)!.new_value) }}
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
</el-tag>
<el-button
v-if="isAdmin"
@@ -540,12 +527,10 @@ onMounted(() => {
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column>
<el-table-column label="旧值" min-width="110">
<template #default="{ row }">{{ enumValueLabel(row.item.param_code, row.old_value) || '—' }}</template>
<template #default="{ row }">{{ row.old_value || '—' }}</template>
</el-table-column>
<el-table-column label="新值" min-width="110">
<template #default="{ row }">
<b class="num">{{ enumValueLabel(row.item.param_code, row.new_value) }}</b>
</template>
<template #default="{ row }"><b class="num">{{ row.new_value }}</b></template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
@@ -593,9 +578,7 @@ onMounted(() => {
</el-select>
</el-form-item>
<el-form-item label="当前值">
<span class="num">
{{ enumValueLabel(editTarget.item.param_code, currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value ?? '—') }}
</span>
<span class="num">{{ currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value ?? '—' }}</span>
<span v-if="editTarget.item.unit" style="color: var(--ys-ink-3)"> {{ editTarget.item.unit }}</span>
</el-form-item>
<el-form-item label="新值">
@@ -604,12 +587,7 @@ onMounted(() => {
v-model="editValue"
style="width: 100%"
>
<el-option
v-for="opt in editTarget.item.options"
:key="opt"
:label="enumOptionLabel(opt)"
:value="opt"
/>
<el-option v-for="opt in editTarget.item.options" :key="opt" :label="opt" :value="opt" />
</el-select>
<el-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" />
</el-form-item>
@@ -654,15 +632,11 @@ onMounted(() => {
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column>
<el-table-column label="旧值" min-width="90">
<template #default="{ row }">
{{ enumValueLabel(row.param_code, row.old_value) || '—' }}
</template>
<template #default="{ row }">{{ row.old_value ?? '—' }}</template>
</el-table-column>
<el-table-column label="新值" min-width="90">
<template #default="{ row }">
<span :class="{ changed: row.changed }">
{{ enumValueLabel(row.param_code, row.new_value) || '—' }}
</span>
<span :class="{ changed: row.changed }">{{ row.new_value ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="变化" width="70" align="center">
@@ -684,10 +658,7 @@ onMounted(() => {
<div v-for="item in detailCache[row.id].items" :key="item.param_code + item.dimension_value" class="batch-item">
<span class="param-name">{{ item.param_name }}</span>
<span class="dim-label">{{ dimensionLabel(item.dimension_value) }}</span>
<span class="num">
{{ enumValueLabel(item.param_code, item.old_value_text) || '—' }} →
<b>{{ enumValueLabel(item.param_code, item.value_text) }}</b>
</span>
<span class="num">{{ item.old_value_text ?? '—' }} → <b>{{ item.value_text }}</b></span>
</div>
</template>
<el-button v-else link size="small" @click="detailOf(row).catch(() => {})">
@@ -736,10 +707,7 @@ onMounted(() => {
</el-table-column>
<el-table-column label="旧值 → 新值" min-width="130">
<template #default="{ row }">
<span class="num">
{{ enumValueLabel(historyItem?.param_code ?? '', row.old_value_text) || '—' }} →
{{ enumValueLabel(historyItem?.param_code ?? '', row.value_text) }}
</span>
<span class="num">{{ row.old_value_text ?? '—' }} → {{ row.value_text }}</span>
</template>
</el-table-column>
<el-table-column prop="version_no" label="版本" width="115" />
@@ -831,7 +799,7 @@ onMounted(() => {
<div class="pr-used">
<div class="drawer-title">所用参数(按回款到账日取值)</div>
<div v-for="u in previewResult.params_used" :key="u.param_code" class="pr-used-row">
{{ paramNameByCode(u.param_code) }}({{ u.param_code }})= {{ enumValueLabel(u.param_code, u.value_text) }}
{{ paramNameByCode(u.param_code) }}({{ u.param_code }})= {{ valueLabel(u.value_text) || u.value_text }}
<span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
</div>
</div>
@@ -907,13 +875,6 @@ onMounted(() => {
font-variant-numeric: tabular-nums;
}
/* 枚举值旁的灰色原始码(如「当月全额 FULL」的 FULL),与审计记录对账用 */
.enum-code {
font-size: 11px;
font-weight: 400;
color: var(--ys-ink-3);
}
.eff {
font-size: 12px;
color: var(--ys-ink-3);
+77 -416
View File
@@ -1,85 +1,42 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import type { UploadRawFile } from 'element-plus'
import * as settingsApi from '@/api/settings'
import type { CompanySettings } from '@/api/settings'
import { useSettingsStore } from '@/store/settings'
import { handledError } from '@/utils/error'
/**
* 公司信息与站点信息设置(2026-10-20 定稿)。
* 公司信息设置(2026-10-15 补充需求:公司名称、logo)。
*
* 页面按「左侧设置 → 右侧预览」成对排列,四张卡两行:
* - 第一行:登录页信息(名称/标语/页脚/备案号/logo)→ 登录页预览
* - 第二行:网页站点信息(站点标题/站点图标)→ 浏览器标签页预览
* 签名元素是右侧「登录页实景预览」:品牌名与 logo 改动即时反映在一张
* 缩比登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
* logo 校验(png/jpg ≤2MB)与后端三重校验同口径,前端先拦一道。
*
* 两张设置卡**各自独立保存**(后端为部分更新语义):
* 改登录页信息不必触碰站点标题,反之亦然;保存后把回包写入 settings store,
* 侧边栏/浏览器标题/favicon/登录页立即同步(不重新请求 public 端点 ——
* 匿名端点带 300s 浏览器缓存)。
* 进入页面即加载当前设置(设置页即所见即所得,无需手动刷新)。
*/
const route = useRoute()
const settingsStore = useSettingsStore()
const formRef = ref<FormInstance>()
const submitting = ref(false)
// ==================== 登录页信息 ====================
const loginFormRef = ref<FormInstance>()
const savingLogin = ref(false)
const loginForm = reactive({
const form = reactive({
company_name: '',
brand_slogan: '',
footer_text: '',
icp_no: '',
})
const loginRules: FormRules = {
const rules: FormRules = {
company_name: [
{ required: true, message: '请输入公司名称', trigger: 'blur' },
{ max: 64, message: '公司名称最长 64 个字符', trigger: 'blur' },
],
brand_slogan: [{ max: 64, message: '标语最长 64 个字符', trigger: 'blur' }],
footer_text: [{ max: 64, message: '页脚文本最长 64 个字符', trigger: 'blur' }],
icp_no: [{ max: 32, message: '备案号最长 32 个字符', trigger: 'blur' }],
}
// ==================== 网页站点信息 ====================
const siteFormRef = ref<FormInstance>()
const savingSite = ref(false)
const siteForm = reactive({
site_title: '',
})
const siteRules: FormRules = {
site_title: [{ max: 64, message: '站点标题最长 64 个字符', trigger: 'blur' }],
}
// ==================== 图片资源 ====================
const logoUrl = ref<string | null>(null)
const faviconUrl = ref<string | null>(null)
const uploadingLogo = ref(false)
const uploadingFavicon = ref(false)
const MAX_IMAGE_BYTES = 2 * 1024 * 1024
/**
* 生效的站点图标:自定义图标 → 公司 logo → 内置 SVG。
* 与 settings store 同口径 —— 未单独上传图标时,换 logo 标签页图标跟着变。
*/
const effectiveFavicon = computed(() => faviconUrl.value ?? logoUrl.value ?? '/favicon.svg')
const uploading = ref(false)
async function loadSettings(): Promise<void> {
try {
const data = await settingsApi.getSettings()
fillAll(data)
// 管理端打开设置页即用最新值校准全局品牌/站点信息
settingsStore.apply(data)
form.company_name = data.company_name
logoUrl.value = data.logo_url
} catch (error) {
handledError(error)
}
@@ -87,309 +44,118 @@ async function loadSettings(): Promise<void> {
loadSettings()
function fillAll(data: CompanySettings): void {
loginForm.company_name = data.company_name
loginForm.brand_slogan = data.brand_slogan ?? ''
loginForm.footer_text = data.footer_text ?? ''
loginForm.icp_no = data.icp_no ?? ''
siteForm.site_title = data.site_title ?? ''
logoUrl.value = data.logo_url
faviconUrl.value = data.favicon_url
}
/** 保存登录页信息(只提交本卡字段,不触碰站点标题) */
async function saveLoginInfo(): Promise<void> {
const valid = await loginFormRef.value?.validate().catch(() => false)
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
savingLogin.value = true
submitting.value = true
try {
const data = await settingsApi.updateSettings({
company_name: loginForm.company_name.trim(),
brand_slogan: loginForm.brand_slogan.trim(),
footer_text: loginForm.footer_text.trim(),
icp_no: loginForm.icp_no.trim(),
})
// 只回填本卡字段:另一张卡未保存的编辑不被覆盖
loginForm.company_name = data.company_name
loginForm.brand_slogan = data.brand_slogan ?? ''
loginForm.footer_text = data.footer_text ?? ''
loginForm.icp_no = data.icp_no ?? ''
logoUrl.value = data.logo_url
faviconUrl.value = data.favicon_url
settingsStore.apply(data)
ElMessage.success('登录页信息已保存')
const data = await settingsApi.updateSettings(form.company_name.trim())
form.company_name = data.company_name
ElMessage.success('已保存')
} catch (error) {
handledError(error)
} finally {
savingLogin.value = false
submitting.value = false
}
}
/** 保存网页站点信息(只提交站点标题;图标由上传接口即时保存) */
async function saveSiteInfo(): Promise<void> {
const valid = await siteFormRef.value?.validate().catch(() => false)
if (!valid) return
savingSite.value = true
try {
const data = await settingsApi.updateSettings({
site_title: siteForm.site_title.trim(),
})
siteForm.site_title = data.site_title ?? ''
logoUrl.value = data.logo_url
faviconUrl.value = data.favicon_url
settingsStore.apply(data)
ElMessage.success('站点信息已保存')
} catch (error) {
handledError(error)
} finally {
savingSite.value = false
}
}
// ==================== 图片上传(logo / 站点图标) ====================
function handleLogoUpload(options: { file: UploadRawFile }): Promise<void> {
const file = options.file
if (!['image/png', 'image/jpeg'].includes(file.type)) {
function beforeUpload(file: UploadRawFile): boolean {
const okType = ['image/png', 'image/jpeg'].includes(file.type)
if (!okType) {
ElMessage.warning('logo 仅支持 png / jpg 格式')
return Promise.resolve()
return false
}
if (file.size > MAX_IMAGE_BYTES) {
if (file.size > 2 * 1024 * 1024) {
ElMessage.warning('logo 文件不能超过 2MB')
return Promise.resolve()
return false
}
uploadingLogo.value = true
return settingsApi
.uploadLogo(file)
.then((data) => {
logoUrl.value = data.logo_url
faviconUrl.value = data.favicon_url
settingsStore.apply(data)
ElMessage.success('logo 已更新')
})
.catch(handledError)
.finally(() => {
uploadingLogo.value = false
})
return true
}
/** 浏览器对 .ico 的 MIME 常为空,改用扩展名判断(后端仍有 magic bytes 兜底) */
function handleFaviconUpload(options: { file: UploadRawFile }): Promise<void> {
const file = options.file
if (!/\.(png|jpe?g|ico)$/i.test(file.name)) {
ElMessage.warning('站点图标仅支持 png / jpg / ico 格式')
return Promise.resolve()
async function handleUpload(options: { file: UploadRawFile }): Promise<void> {
uploading.value = true
try {
const data = await settingsApi.uploadLogo(options.file)
logoUrl.value = data.logo_url
ElMessage.success('logo 已更新')
} catch (error) {
handledError(error)
} finally {
uploading.value = false
}
if (file.size > MAX_IMAGE_BYTES) {
ElMessage.warning('站点图标文件不能超过 2MB')
return Promise.resolve()
}
uploadingFavicon.value = true
return settingsApi
.uploadFavicon(file)
.then((data) => {
faviconUrl.value = data.favicon_url
logoUrl.value = data.logo_url
settingsStore.apply(data)
ElMessage.success('站点图标已更新')
})
.catch(handledError)
.finally(() => {
uploadingFavicon.value = false
})
}
// ==================== 预览 ====================
/** 当前页面名(本页路由标题为「公司信息」),用于标签页标题预览 */
const previewPageTitle = computed(() => (route.meta.title as string | undefined) ?? '公司信息')
const previewName = computed(() => loginForm.company_name.trim() || '衍水集团')
const previewSlogan = computed(
() => loginForm.brand_slogan.trim() || '把每一笔回款,算成一份踏实。',
)
const previewFooter = computed(
() => loginForm.footer_text.trim() || `© ${previewName.value} · 内部系统`,
)
const previewIcp = computed(() => loginForm.icp_no.trim())
const previewSiteTitle = computed(
() => siteForm.site_title.trim() || '营销人员工资管理系统',
)
const previewTabTitle = computed(
() => `${previewPageTitle.value} - ${previewName.value} ${previewSiteTitle.value}`,
)
/** 预览品牌名:跟随输入(空时回退占位,与登录页默认一致) */
const previewName = computed(() => form.company_name.trim() || '衍水集团')
</script>
<template>
<div class="page">
<div class="settings-grid">
<!-- 左一:登录页信息 -->
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
label-width="110px"
class="settings-form"
@submit.prevent
>
<el-card shadow="never" class="settings-card">
<template #header>
<span class="card-title">登录页信息</span>
</template>
<el-card shadow="never" class="settings-card">
<template #header>
<span class="card-title">公司信息</span>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="88px"
@submit.prevent
>
<el-form-item label="公司名称" prop="company_name">
<el-input
v-model="loginForm.company_name"
v-model="form.company_name"
maxlength="64"
placeholder="展示在登录页、侧边栏与浏览器标题"
clearable
/>
</el-form-item>
<el-form-item label="品牌标语" prop="brand_slogan">
<el-input
v-model="loginForm.brand_slogan"
maxlength="64"
placeholder="留空使用默认:把每一笔回款,算成一份踏实。"
clearable
/>
</el-form-item>
<el-form-item label="页脚版权" prop="footer_text">
<el-input
v-model="loginForm.footer_text"
maxlength="64"
placeholder="留空自动生成:© 公司名 · 内部系统"
clearable
/>
</el-form-item>
<el-form-item label="ICP 备案号" prop="icp_no">
<el-input
v-model="loginForm.icp_no"
maxlength="32"
placeholder="选填,展示在登录页页脚"
placeholder="展示在登录页与导出页眉"
clearable
/>
</el-form-item>
<el-form-item label="logo">
<div class="asset-field">
<div class="logo-field">
<el-upload
class="logo-uploader"
:show-file-list="false"
:http-request="handleLogoUpload"
:http-request="handleUpload"
:before-upload="beforeUpload"
accept="image/png,image/jpeg"
>
<div v-loading="uploadingLogo" class="asset-box" title="点击上传 png / jpg(≤2MB)">
<div v-loading="uploading" class="logo-box" title="点击上传 png / jpg(≤2MB)">
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
<span v-else class="asset-plus">+</span>
<span v-else class="logo-plus">+</span>
</div>
</el-upload>
<p class="asset-hint">
png / jpg,不超过 2MB;<br />展示于登录页品牌位。
</p>
<p class="logo-hint">png / jpg,不超过 2MB;<br />展示于登录页品牌位与导出页眉。</p>
</div>
</el-form-item>
<div class="card-actions">
<el-button type="primary" :loading="savingLogin" @click="saveLoginInfo">
保存
</el-button>
</div>
</el-card>
</el-form>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 右一:登录页预览(由左侧「登录页信息」控制) -->
<el-card shadow="never" class="settings-card preview-card">
<!-- 签名元素:登录页实景预览(缩比登录卡,品牌名/logo 即时反映) -->
<el-card shadow="never" class="preview-card">
<template #header>
<span class="card-title">登录页预览</span>
<span class="preview-note">对应左侧「登录页信息」</span>
<span class="preview-note">保存后对所有人生效</span>
</template>
<div class="login-mock">
<div class="mock-brand">
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
<div class="mock-name">{{ previewName }}</div>
<p class="mock-slogan">{{ previewSlogan }}</p>
<p class="mock-slogan">把每一笔回款,算成一份踏实。</p>
<span class="mock-waterline"></span>
</div>
<div class="mock-field"></div>
<div class="mock-field"></div>
<div class="mock-submit">登 录</div>
</div>
<p class="mock-foot">
{{ previewFooter }}<template v-if="previewIcp"> · {{ previewIcp }}</template>
</p>
<p class="preview-caption">登录页品牌位实景(示意,非交互)</p>
</el-card>
<!-- 左二:网页站点信息 -->
<el-form
ref="siteFormRef"
:model="siteForm"
:rules="siteRules"
label-width="110px"
class="settings-form"
@submit.prevent
>
<el-card shadow="never" class="settings-card">
<template #header>
<span class="card-title">网页站点信息</span>
</template>
<el-form-item label="站点标题" prop="site_title">
<el-input
v-model="siteForm.site_title"
maxlength="64"
placeholder="留空使用默认:营销人员工资管理系统"
clearable
/>
</el-form-item>
<el-form-item label="站点图标">
<div class="asset-field">
<el-upload
:show-file-list="false"
:http-request="handleFaviconUpload"
accept="image/png,image/jpeg,image/x-icon,image/vnd.microsoft.icon,.ico"
>
<div
v-loading="uploadingFavicon"
class="asset-box asset-box--small"
title="点击上传 png / jpg / ico(≤2MB)"
>
<!-- 未自定义时显示公司 logo(跟随 logo 变化) -->
<img :src="effectiveFavicon" alt="站点图标" />
</div>
</el-upload>
<p class="asset-hint">
png / jpg / ico,不超过 2MB;<br />
未上传时跟随公司 logo,展示于浏览器标签页。
</p>
</div>
</el-form-item>
<div class="card-actions">
<el-button type="primary" :loading="savingSite" @click="saveSiteInfo">保存</el-button>
</div>
</el-card>
</el-form>
<!-- 右二:浏览器标签页预览(由左侧「网页站点信息」控制) -->
<el-card shadow="never" class="settings-card preview-card tab-preview-card">
<template #header>
<span class="card-title">浏览器标签页预览</span>
<span class="preview-note">对应左侧「网页站点信息」</span>
</template>
<div class="tab-bar">
<div class="tab-bar__tab">
<img :src="effectiveFavicon" alt="" class="tab-bar__icon" />
<span class="tab-bar__title">{{ previewTabTitle }}</span>
<span class="tab-bar__close">×</span>
</div>
<span class="tab-bar__plus">+</span>
</div>
<p class="preview-caption">浏览器标签页实景(标题与图标)</p>
</el-card>
</div>
</div>
</template>
@@ -397,41 +163,9 @@ const previewTabTitle = computed(
<style scoped>
.settings-grid {
display: grid;
grid-template-columns: minmax(420px, 560px) minmax(300px, 380px);
grid-template-columns: minmax(360px, 460px) minmax(300px, 380px);
gap: 16px;
/* 同一行左右卡片等高:右侧预览卡拉伸到与左侧设置卡相同的底边 */
align-items: stretch;
}
.settings-form {
display: flex;
min-width: 0;
}
.settings-form :deep(.el-card) {
width: 100%;
}
/* 预览卡铺满行高,内容区可纵向伸展 */
.preview-card,
.tab-preview-card {
display: flex;
flex-direction: column;
}
.preview-card :deep(.el-card__body),
.tab-preview-card :deep(.el-card__body) {
display: flex;
flex: 1;
flex-direction: column;
}
/* 登录页实景卡随可用高度放大:两个模拟输入框吃掉多余高度,
整张登录卡纵向撑满预览卡,底边与左侧「登录页信息」对齐 */
.login-mock {
display: flex;
flex: 1;
flex-direction: column;
align-items: start;
}
.card-title {
@@ -444,19 +178,13 @@ const previewTabTitle = computed(
color: var(--ys-ink-3);
}
.card-actions {
display: flex;
justify-content: flex-end;
margin-top: 4px;
}
.asset-field {
.logo-field {
display: flex;
align-items: flex-start;
gap: 14px;
}
.asset-box {
.logo-box {
display: flex;
align-items: center;
justify-content: center;
@@ -470,29 +198,24 @@ const previewTabTitle = computed(
transition: border-color 150ms ease;
}
.asset-box--small {
width: 64px;
height: 64px;
}
.asset-box:hover {
.logo-box:hover {
border-color: var(--ys-indigo);
}
.asset-box img {
.logo-box img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.asset-plus {
.logo-plus {
font-size: 26px;
font-weight: 300;
line-height: 1;
color: var(--ys-ink-3);
}
.asset-hint {
.logo-hint {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.7;
@@ -545,8 +268,7 @@ const previewTabTitle = computed(
}
.mock-field {
flex: 1;
min-height: 32px;
height: 32px;
margin-top: 12px;
background: #f4f5f8;
border: 1px solid rgba(28, 35, 51, 0.07);
@@ -565,74 +287,13 @@ const previewTabTitle = computed(
border-radius: 6px;
}
.mock-foot {
margin: 12px 0 0;
font-size: 11px;
text-align: center;
color: var(--ys-ink-3);
}
.preview-caption {
margin: 8px 0 0;
margin: 10px 0 0;
font-size: 11px;
text-align: center;
color: var(--ys-ink-3);
}
/* ── 浏览器标签页预览(示意;与登录页预览分成两张卡,对应左侧第二张卡) ── */
.tab-preview-card :deep(.el-card__body) {
/* 行高由左侧「网页站点信息」决定:标签页示意在卡内垂直居中 */
justify-content: center;
}
.tab-bar {
display: flex;
align-items: flex-end;
gap: 6px;
padding: 10px 12px 0;
background: #e9ebf1;
border-radius: 10px;
}
.tab-bar__tab {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 9px 12px;
background: #ffffff;
border-radius: 8px 8px 0 0;
box-shadow: 0 -1px 2px rgba(28, 35, 51, 0.06);
}
.tab-bar__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
object-fit: contain;
}
.tab-bar__title {
overflow: hidden;
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--ys-ink-2);
}
.tab-bar__close {
flex-shrink: 0;
font-size: 12px;
line-height: 1;
color: var(--ys-ink-3);
}
.tab-bar__plus {
padding: 0 6px 10px;
font-size: 14px;
color: var(--ys-ink-3);
}
@media (max-width: 900px) {
.settings-grid {
grid-template-columns: 1fr;