diff --git a/.gitignore b/.gitignore index 6f27c66..e54ba25 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,10 @@ out/ # 日志 *.log -logs/ +# 后端运行日志目录(core/logging.py 创建)。 +# 不要写成裸 `logs/` —— 那会匹配任意层级的同名目录,曾把 +# frontend/src/views/system/logs 整个吞掉(文件进不了版本库) +/backend/logs/ # IDE .idea/ @@ -54,3 +57,10 @@ frontend/e2e/*.png # 备份 backup/ +.claude +CLAUDE.md + +# 运行期上传物(公司 logo 等):与构建产物隔离,不入版本库 +backend/uploads/ +# 数据库备份导出目录(⑨ 备份脚本产物) +db/backups/ diff --git a/README.md b/README.md index dd5d4df..fbbbbbf 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。 -> 当前进度:登录鉴权链路已跑通(验证码 / JWT / 限流 / 登出黑名单 / 登录日志)。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。 +> 当前进度:登录鉴权与系统管理(用户/角色/操作日志/日志查询)已完成,前端具备应用布局框架。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。 ## 快速开始 @@ -81,7 +81,8 @@ pnpm type-check # 类型检查 pnpm build # 类型检查 + 生产构建 # 浏览器端到端(需容器、后端、前端都在运行) -python e2e/login_flow.py +python e2e/login_flow.py # 登录链路 8 项 +python e2e/system_management.py # 系统管理 13 项 ``` ## 目录结构 diff --git a/backend/README.md b/backend/README.md index 4300b80..d840224 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,11 +1,13 @@ # 后端 -> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。当前实现登录鉴权与健康检查,业务模块待需求澄清后开发。 +> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。已实现登录鉴权、系统管理(用户/角色/操作日志/日志查询)与 M1 参数体系(岗位角色管理、计提参数版本化/留痕/对比/预览/导出,2026-10-13);业务模块(基础/业务数据、核算、报表)按路线图推进。 ## 职责 - 提供 REST 接口(统一前缀 `/api`) - 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志 +- 系统管理(需求 2.5.1):用户管理、角色管理、岗位角色(#28 可扩展+公式模板绑定)、操作日志(自动审计)、操作与登录日志查询 +- 计提参数体系(需求 #28):16 项参数目录、按生效日期版本化(回款到账日取值)、变更批次留痕/对比/导出、计算预览 - 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步 ## 技术栈与版本 @@ -53,12 +55,80 @@ app/ | POST | `/api/v1/auth/logout` | 是 | 令牌入黑名单,立即失效 | | GET | `/api/v1/auth/me` | 是 | 当前用户与角色 | +### 系统管理接口(需求 2.5.1,全部要求管理员权限) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET / POST | `/api/v1/system/users` | 用户列表(分页/筛选)/ 新增用户 | +| GET / PUT / DELETE | `/api/v1/system/users/{id}` | 用户详情 / 编辑(含角色分配)/ 删除(物理删除) | +| PUT | `/api/v1/system/users/{id}/password` | 重置密码(提升令牌版本,旧令牌立即失效) | +| PUT | `/api/v1/system/users/{id}/status` | 启用 / 禁用(禁用同样提升令牌版本) | +| GET | `/api/v1/system/users/meta/roles` | 角色下拉选项(仅启用角色) | +| GET / POST | `/api/v1/system/roles` | 角色列表(含停用与绑定用户数)/ 新增角色 | +| PUT / DELETE | `/api/v1/system/roles/{id}` | 编辑(不含编码)/ 删除(内置与有绑定的角色受保护) | +| PUT | `/api/v1/system/roles/{id}/status` | 启用 / 禁用(内置 admin 禁停) | +| GET | `/api/v1/system/operation-logs` | 操作日志查询(操作人/模块/操作/结果/时间范围;**不含 request_data** 大字段) | +| GET | `/api/v1/system/operation-logs/{id}` | 操作日志详情(含已脱敏的请求参数,展开行懒加载) | +| GET | `/api/v1/system/login-logs` | 登录日志查询(用户名/IP/结果/时间范围) | + +### 基础数据接口(需求 2.1,登录即可 —— 业务操作不要求管理员) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET / POST | `/api/v1/base/personnel` | 人员列表(姓名模糊/状态/岗位筛选,附岗位与经理名)/ 新增(岗位须启用;经理非自己且在职) | +| GET / PUT / DELETE | `/api/v1/base/personnel/{id}` | 详情(附区域与计划)/ 编辑 / 删除(被团队成员或客户归属引用 → 400 引导改离职) | +| PUT | `/api/v1/base/personnel/{id}/status` | 在职 / 离职切换 | +| PUT | `/api/v1/base/personnel/{id}/regions` | 辐射区域全量替换(空列表即清空) | +| PUT | `/api/v1/base/personnel/{id}/plans` | 市场拓展计划全量替换 | +| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) | +| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) | +| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 | +| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) | +| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 | +| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) | +| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) | + +### 业务数据接口(需求 2.2,登录即可) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET / POST | `/api/v1/business/businesses` | 业务列表(编号/公司名模糊、状态/客户/归属/日期筛选)/ 创建(编号 `YW{日期}-{序号}` 自动生成;明细 ≥1 行;销售额服务层固化) | +| GET / PUT / DELETE | `/api/v1/business/businesses/{id}` | 详情(附明细)/ 编辑(明细全量替换 + 五项汇总重算;**归属不在此端点**)/ 删除(有发货或回款 → 400 引导改已终止) | +| PUT | `/api/v1/business/businesses/{id}/status` | 状态流转(1→2/3;终态不可再变更;终止必填原因;写状态留痕) | +| PUT | `/api/v1/business/businesses/{id}/ownership` | 负责/协助归属变更(同事务写留痕,owner_type=2) | +| GET | `/api/v1/business/businesses/{id}/ownership-logs` | 业务归属变更留痕分页 | +| GET | `/api/v1/business/businesses/{id}/status-logs` | 状态流转时间线(创建写首条 old=new=1) | +| GET | `/api/v1/business/businesses/{id}/gross-profit` | **毛利润数据视图**(只读实时计算,不落库:行级率/折算额 + 类别汇总;不含运费 #2;阈值按 `as_of` 取参数快照;成本缺失行标注无法计算) | +| GET / POST | `/api/v1/business/shipments` | 发货单列表 / 创建(编号 `SH…`;业务须执行中;行金额服务层固化;业务级发货/运费汇总回写) | +| GET / PUT / DELETE | `/api/v1/business/shipments/{id}` | 详情 / 编辑(终态业务禁改)/ 删除(被回款批次指定 → 400) | +| GET / POST | `/api/v1/business/receipts` | 回款列表(含 **payment_days 派生**:发货日首日=第 1 天 #1)/ 新增(编号 `HK…`;**shipment_id 同业务强校验**;业务须执行中) | +| GET / PUT / DELETE | `/api/v1/business/receipts/{id}` | 详情 / 编辑 / 删除(**已核算回款禁改禁删**——M3 防重锚点) | +| GET / POST | `/api/v1/business/rating-grades` | 评级等级全量列表(sort_order 升序)/ 新增(区间值域 + **与既有等级重叠 → 400**) | +| PUT / DELETE | `/api/v1/business/rating-grades/{id}` | 编辑(重叠判定)/ 删除(无下游引用,允许清空) | + +全部写操作自动写入操作日志(成功与失败都记,机制见下)。 + 统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。 状态码:400 参数错误 / 401 未登录 / 403 无权限 / 404 不存在 / 500 服务端异常。 **登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」, 若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。 +### 报表统计接口(M4 ⑤,限管理员/财务/领导) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/api/v1/reports/personal-detail` | 个人绩效工资明细表(FR-029:人员/月份/客户类型/产品类别/岗位筛选;响应带 `summary` 全筛选合计) | +| GET | `/api/v1/reports/department-summary` | 部门绩效工资汇总表(FR-030:月份必填;团队基数与月结聚合行同源;12.5% 与 5% 分列;含成员内联明细、未分配桶、月结冻结对照额) | +| GET | `/api/v1/reports/business-stats` | 业务统计表(FR-031:发货/回款/绩效为业务累计值,不随业务日期筛选变化) | +| GET | `/api/v1/reports/payment-stats` | 回款情况统计表(FR-031:8 档天数区间恒在,空桶为 0) | +| GET | `/api/v1/reports/payment-detail` | 回款区间下钻(`bucket` 必填,未注册编码 400) | +| GET | `/api/v1/reports/payment-day-buckets` | 区间档位元数据(前端下拉唯一来源,禁止前端硬编码) | +| GET | `/api/v1/reports/{code}/export` | Excel 导出(文件流非统一信封;含公司页眉/财务复核栏/领导签字栏;超 2 万行 400;行为入操作日志) | + +准入 `require_roles(admin, finance, leader)`(FR-030/031;FR-029 的「营销后勤授权范围内」 +待业务权限清单 #6)。前端菜单同层过滤(路由 `allowedRoles`)。 + ## 常用命令 ```bash @@ -118,6 +188,39 @@ Redis 黑名单只负责「用户主动登出」。 bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是静默截断)。 中文一个字符占 3 字节,24 个汉字即到顶——`max_length=20` 这类字符数校验拦不住。 +### 操作日志:审计上下文 + 独立提交 + +写操作统一经 `services/audit_service.py` 的 `audit()` 上下文管理器留痕(用法见 `api/v1/system/users.py`): + +- 调用形态为 DTO:`audit(session, operator=..., record=AuditRecord(module, action, summary, request_data), meta=...)`;落库条目为 `OperationLogEntry` +- 正常退出记成功;抛异常**先 `session.rollback()` 再**记失败(success=0 + 中文错误原因),随后原异常继续传播 +- 日志**独立 commit**,整体兜底不外抛 —— 方向是「审计可丢,业务不可断」;反向理解:业务失败回滚不会带走失败记录,而那正是审计最需要的 +- **操作人标量在进入上下文时固化**:失败路径先 rollback 会使 ORM 对象过期,之后再读 id/username 会触发额外查询(账号并发被删时 ObjectDeletedError 还会被兜底吞掉) +- 请求元信息(ip / user-agent / method / path)经路由层的 `RequestMetaDep` 传入 service +- **commit 之后的任何查询/ORM 读取都要前置**(提到 commit 前或用已固化的局部变量):业务已提交却因后置查询抛错会被记成「失败」,产生与事实相反的审计 +- 文本列按 `models/operation_log.py` 的长度常量截断;`request_data` 先经**结构化脱敏**(递归按键名整值抹除 password/token 等,不依赖文本模式匹配)再经 `core.logging.scrub` 文本兜底,最后按 **UTF-8 字节边界**截断(encode 带 `errors="replace"` 兜底孤立代理字符)—— 超长/编码异常都会在 MySQL 或 encode 处抛出,被兜底吞掉后表现为「日志莫名消失」 +- **日志列表与详情两段式**:`operation-logs` 列表排除 `request_data`(TEXT 大字段,随列表返回会让翻页负担随表增长),展开详情走 `GET /operation-logs/{id}` 懒加载 +- 模块/操作词表在 `core/constants.py`(`MODULE_*` / `ACTION_*`),前端 `utils/constants.ts` 与其对齐 + +**角色管理保护规则**(后端是唯一防线,前端按钮禁用只是体验):admin 角色禁删禁停;有用户绑定的角色禁删(`user_roles` 外键是 CASCADE;删除前走 `SELECT ... FOR UPDATE` 锁行,堵住并发绑定窗口);`role_code` 创建后不可改;**停用角色不可再分配**(`_resolve_roles` 校验 status,停用只影响新分配、不影响既有授权)。 + +### M2 业务数据的四个关键约定(2026-10-13 定稿,改动前先读) + +- **单据编号**(`services/no_generator.py`):`{前缀}{YYYYMMDD}-{当日4位序号}`,业务 YW / 发货 SH / 回款 HK(`core/constants.py` 的 `NO_PREFIX_*`)。`SELECT MAX ... FOR UPDATE` 锁当日区间防并发重号;残余竞态以唯一键 1062 冲突**重试一次**兜底(无限重试会掩盖死锁)。序号超 4 位按实际位数输出(VARCHAR(32) 余量足够)。 +- **五项汇总冗余**(`businesses.total_*`):明细/发货/回款的任何写路径统一收口到 `business_service._recalc_totals` 单点重算,禁止各处手写 SUM 回写;发货与回款服务写操作前**必须 `get_by_id(for_update=True)` 锁业务行**(防两模块并发写同一业务的丢失更新)。毛利润率/折算额**不落库**——它们是明细金额 + 时点化阈值的派生值,落库会在成本调整或参数改版后漂移;视图接口(`/gross-profit`)实时算,M3 核算台账才是法定固化点。 +- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。 +- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。 + +### 报表口径(M4 ⑤,改动前先读) + +- **数据源固定单笔生效行**:四张报表的明细一律 `performance_ledgers.is_active=1 AND calc_type=1`;经理聚合行(`calc_type=2`)**只在部门汇总作冻结对照**(`frozen_amount`)单读。聚合行 `receipt_id` 为 NULL、`payment_days` 为 NULL,混入明细会让合同编号/天数列失真,业务统计的绩效也会被整团队聚合额污染。 +- **调整单并入**:仅 `status=2`(生效),按 `(settlement_month, personnel_id)` 并入;`direction=1` 补发记 +、`2` 扣减记 −(金额恒正)。待审批/驳回/撤回的金额绝不能进报表——`report_repository.list_month_adjustments` 与调整单管理页的 `list_adjustments` 是两个口径。 +- **回款金额按回款单去重**:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 `DISTINCT receipt_id` 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。 +- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块),**12.5% 与 5% 分列**——两项之和才是聚合行金额。 +- **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。 +- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。 +- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;集成测试逐项断言。 + ## 常见问题 **起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。 diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako index 1101630..c743402 100644 --- a/backend/alembic/script.py.mako +++ b/backend/alembic/script.py.mako @@ -5,7 +5,7 @@ Revises: ${down_revision | comma,n} Create Date: ${create_date} """ -from typing import Sequence, Union +from collections.abc import Sequence from alembic import op import sqlalchemy as sa @@ -13,16 +13,22 @@ ${imports if imports else ""} # revision identifiers, used by Alembic. revision: str = ${repr(up_revision)} -down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} +down_revision: str | Sequence[str] | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} def upgrade() -> None: - """Upgrade schema.""" + """应用迁移。""" ${upgrades if upgrades else "pass"} def downgrade() -> None: - """Downgrade schema.""" + """回滚迁移。 + + **注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与 + `op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引, + 单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。 + **整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。 + """ ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/3bca400421c6_v7_核算台账_月结_调整单四表与调整单开关种子.py b/backend/alembic/versions/3bca400421c6_v7_核算台账_月结_调整单四表与调整单开关种子.py new file mode 100644 index 0000000..2fc7341 --- /dev/null +++ b/backend/alembic/versions/3bca400421c6_v7_核算台账_月结_调整单四表与调整单开关种子.py @@ -0,0 +1,632 @@ +"""V7 核算台账/月结/调整单四表与调整单开关种子 + +Revision ID: 3bca400421c6 +Revises: 79bcc5aa98d4 +Create Date: 2026-10-16 15:54:53.000890 + +""" + +from collections.abc import Sequence +from datetime import date + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "3bca400421c6" +down_revision: str | Sequence[str] | None = "79bcc5aa98d4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """应用迁移。""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "monthly_settlements", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "settlement_month", + sa.String(length=7), + nullable=False, + comment="结算月份(YYYY-MM;全表唯一)", + ), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)", + ), + sa.Column( + "total_performance", + sa.Numeric(precision=14, scale=2), + nullable=True, + comment="绩效金额合计(可负)", + ), + sa.Column( + "total_payable", + sa.Numeric(precision=14, scale=2), + nullable=True, + comment="应付绩效合计(可负)", + ), + sa.Column( + "person_count", mysql.TINYINT(), server_default="0", nullable=False, comment="结算人数" + ), + sa.Column( + "snapshot_json", + sa.JSON(), + nullable=True, + comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)", + ), + sa.Column( + "approval_order_no", + sa.String(length=32), + nullable=True, + comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)", + ), + sa.Column( + "submitter_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="提交人ID(账号删除后置空)", + ), + sa.Column( + "submitter_name", + sa.String(length=64), + nullable=False, + comment="提交人用户名(冗余固化)", + ), + sa.Column("locked_at", sa.DateTime(), nullable=True, comment="锁定时间(status=3 时回填)"), + sa.Column( + "locked_by", sa.String(length=64), nullable=True, comment="锁定操作人(冗余固化)" + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint( + ["submitter_id"], + ["users.id"], + name=op.f("fk_monthly_settlements_submitter_id"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_monthly_settlements")), + sa.UniqueConstraint( + "settlement_month", name=op.f("uk_monthly_settlements_settlement_month") + ), + comment="月度结算表(按月一行;通过即锁定,锁定后走调整单)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_table( + "adjustment_orders", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "order_no", + sa.String(length=32), + nullable=False, + comment="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}", + ), + sa.Column( + "settlement_month", + sa.String(length=7), + nullable=False, + comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)", + ), + sa.Column( + "personnel_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="调整人员ID(personnel.id)", + ), + sa.Column( + "personnel_name", + sa.String(length=64), + nullable=False, + comment="调整人员姓名(冗余固化)", + ), + sa.Column("direction", mysql.TINYINT(), nullable=False, comment="方向:1补发 2扣减"), + sa.Column( + "amount", + sa.Numeric(precision=14, scale=2), + nullable=False, + comment="调整金额(恒正,方向表达语义)", + ), + sa.Column( + "reason", + sa.String(length=255), + nullable=False, + comment="扣补说明(必填,审批与追溯锚点)", + ), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="状态:1待审批 2已生效 3已驳回 4已撤回", + ), + sa.Column( + "submitter_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="提交人ID(账号删除后置空)", + ), + sa.Column( + "submitter_name", + sa.String(length=64), + nullable=False, + comment="提交人用户名(冗余固化)", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint( + ["personnel_id"], + ["personnel.id"], + name=op.f("fk_adjustment_orders_personnel_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["submitter_id"], + ["users.id"], + name=op.f("fk_adjustment_orders_submitter_id"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_adjustment_orders")), + sa.UniqueConstraint("order_no", name=op.f("uk_adjustment_orders_order_no")), + comment="调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_table( + "settlement_items", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "settlement_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="月结ID(monthly_settlements.id;头删明细随删)", + ), + sa.Column( + "personnel_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="结算人员ID(personnel.id)", + ), + sa.Column( + "personnel_name", + sa.String(length=64), + nullable=False, + comment="结算人员姓名(冗余固化)", + ), + sa.Column( + "role_code", sa.String(length=32), nullable=False, comment="岗位编码(主岗,聚合锚点)" + ), + sa.Column( + "calc_type", + mysql.TINYINT(), + nullable=False, + comment="核算类型:1单笔回款 2经理团队聚合", + ), + sa.Column( + "performance_sum", + sa.Numeric(precision=14, scale=2), + nullable=False, + comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)", + ), + sa.Column( + "payable_sum", + sa.Numeric(precision=14, scale=2), + nullable=False, + comment="应付绩效合计(可负)", + ), + sa.Column( + "basic_amount", + sa.Numeric(precision=14, scale=2), + nullable=True, + comment="月工资标准(personnel.salary_standard 当月快照)", + ), + sa.Column( + "salary_mode", + sa.String(length=16), + nullable=True, + comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE", + ), + sa.Column( + "net_amount", + sa.Numeric(precision=14, scale=2), + nullable=True, + comment="净工资 = 绩效±基本工资(按口径;试用期 0)", + ), + sa.Column( + "is_probation", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否", + ), + sa.Column( + "probation_note", + sa.String(length=255), + nullable=True, + comment="试用期说明(年累计超阈值部分年底一次性结算)", + ), + sa.Column( + "snapshot_json", sa.JSON(), nullable=True, comment="个人明细快照(台账行引用列表)" + ), + sa.ForeignKeyConstraint( + ["personnel_id"], + ["personnel.id"], + name=op.f("fk_settlement_items_personnel_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["settlement_id"], + ["monthly_settlements.id"], + name=op.f("fk_settlement_items_settlement_id"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_settlement_items")), + comment="月结明细表(按人固化;支持负数工资;锁定后只读)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_table( + "performance_ledgers", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "receipt_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="回款ID(receipts.id;聚合行可为 NULL)", + ), + sa.Column( + "calc_version", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="核算版本(重算+1,旧行 is_active=0)", + ), + sa.Column( + "is_active", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)", + ), + sa.Column( + "calc_type", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="核算类型:1单笔回款 2经理团队聚合(月度)", + ), + sa.Column( + "business_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)", + ), + sa.Column( + "business_no", + sa.String(length=32), + nullable=False, + comment="业务编号(冗余固化,列表免联查)", + ), + sa.Column( + "personnel_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="归属营销人员ID(personnel.id;核算归属锚点)", + ), + sa.Column( + "personnel_name", + sa.String(length=64), + nullable=False, + comment="归属营销人员姓名(冗余固化)", + ), + sa.Column( + "role_code", + sa.String(length=32), + nullable=False, + comment="岗位编码(business_roles.role_code,计提公式锚点)", + ), + sa.Column( + "customer_type", + sa.String(length=32), + nullable=False, + comment="客户分类:personal/manager_personal/enterprise", + ), + sa.Column( + "product_category", + sa.String(length=32), + nullable=False, + comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)", + ), + sa.Column("ship_date", sa.Date(), nullable=True, comment="发货日期(回款天数基准)"), + sa.Column( + "receipt_date", + sa.Date(), + nullable=False, + comment="回款日期(参数版本匹配日,裁决 #11)", + ), + sa.Column( + "payment_days", + sa.Integer(), + nullable=True, + comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)", + ), + sa.Column( + "rate_text", + sa.String(length=32), + nullable=False, + comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)", + ), + sa.Column( + "converted_profit", + sa.Numeric(precision=14, scale=2), + nullable=True, + comment="折算毛利润额(可负;混合业务为各行汇总)", + ), + sa.Column( + "performance_amount", + sa.Numeric(precision=14, scale=2), + nullable=True, + comment="绩效金额(可负,裁决:负绩效照算不归零)", + ), + sa.Column( + "barter_applied", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="以物抵顶折算:1已按 80% 折算 0否", + ), + sa.Column( + "weight_r", + sa.Integer(), + nullable=True, + comment="支付权重 R(回款占销售额百分比向下取整)", + ), + sa.Column( + "payment_weight", + sa.Numeric(precision=7, scale=6), + nullable=True, + comment="支付权重系数 R×R(展示用)", + ), + sa.Column( + "payable_amount", + sa.Numeric(precision=14, scale=2), + nullable=True, + comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)", + ), + sa.Column( + "settlement_month", + sa.String(length=7), + nullable=False, + comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)", + ), + sa.Column( + "is_probation", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="试用期:1照算不发放 0否", + ), + sa.Column( + "missing_cost", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否", + ), + sa.Column( + "snapshot_json", + sa.JSON(), + nullable=True, + comment="计算快照(所用参数版本与行级折算明细,重算可回溯)", + ), + sa.Column( + "settlement_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="所属月结ID(月结生成时回填;NULL=未入结)", + ), + sa.Column( + "calc_reason", + sa.String(length=255), + nullable=True, + comment="核算/重算原因(重算必填留痕)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint( + ["personnel_id"], + ["personnel.id"], + name=op.f("fk_performance_ledgers_personnel_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["receipt_id"], + ["receipts.id"], + name=op.f("fk_performance_ledgers_receipt_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["settlement_id"], + ["monthly_settlements.id"], + name=op.f("fk_performance_ledgers_settlement_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_performance_ledgers")), + sa.UniqueConstraint( + "receipt_id", "calc_version", name="uk_performance_ledgers_receipt_id_calc_version" + ), + comment="绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + "idx_performance_ledgers_business_id", "performance_ledgers", ["business_id"], unique=False + ) + op.create_index( + "idx_performance_ledgers_month_active", + "performance_ledgers", + ["settlement_month", "is_active"], + unique=False, + ) + op.create_index( + "idx_performance_ledgers_month_personnel_type", + "performance_ledgers", + ["settlement_month", "personnel_id", "calc_type"], + unique=False, + ) + op.create_index( + "idx_performance_ledgers_personnel_id", + "performance_ledgers", + ["personnel_id"], + unique=False, + ) + op.alter_column( + "approval_orders", + "order_type", + existing_type=mysql.TINYINT(), + comment="审批对象类型:1二次分配方案 2月度结算 3调整单", + existing_comment="审批对象类型:1二次分配方案 2月度结算(预留)", + existing_nullable=False, + ) + # ### end Alembic commands ### + + # ---- 种子:调整单审批开关(V6 写法先例;definitions id 19 / values id 22)---- + op.bulk_insert( + sa.table( + "param_definitions", + sa.column("id", sa.BigInteger), + sa.column("param_code", sa.String), + sa.column("param_name", sa.String), + sa.column("param_group", sa.String), + sa.column("value_type", sa.String), + sa.column("unit", sa.String), + sa.column("dimension_type", sa.String), + sa.column("options_text", sa.String), + sa.column("default_value", sa.String), + sa.column("value_min", sa.String), + sa.column("value_max", sa.String), + sa.column("description", sa.String), + sa.column("sort_order", sa.Integer), + sa.column("status", sa.Integer), + ), + [ + { + "id": 19, + "param_code": "approval.adjustment.enabled", + "param_name": "调整单审批开关", + "param_group": "审批开关", + "value_type": "BOOL", + "unit": None, + "dimension_type": "NONE", + "options_text": None, + "default_value": "0", + "value_min": None, + "value_max": None, + "description": ( + "0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。" + "调整单是已锁定月份补发/扣减的唯一通道" + ), + "sort_order": 19, + "status": 1, + } + ], + ) + op.bulk_insert( + sa.table( + "param_values", + sa.column("id", sa.BigInteger), + sa.column("param_id", sa.BigInteger), + sa.column("dimension_value", sa.String), + sa.column("effective_date", sa.Date), + sa.column("value_text", sa.String), + sa.column("batch_id", sa.BigInteger), + ), + [ + { + "id": 22, + "param_id": 19, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "0", + "batch_id": 1, + } + ], + ) + + +def downgrade() -> None: + """回滚迁移。 + + 整表回滚只保留 `op.drop_table`(V1/V3/V6 先例:外键唯一支撑索引单独 + drop 会报 MySQL 1553)。行级种子(param_definitions 19 / param_values 22) + 的宿主表由 V3 建、本迁移只插行,须显式 DELETE 否则重放 upgrade 撞唯一键。 + 删除顺序:先删引用方(ledger/items),再删被引用方(头表/调整单)。 + """ + op.drop_table("performance_ledgers") + op.drop_table("settlement_items") + op.drop_table("adjustment_orders") + op.drop_table("monthly_settlements") + op.alter_column( + "approval_orders", + "order_type", + existing_type=mysql.TINYINT(), + comment="审批对象类型:1二次分配方案 2月度结算(预留)", + existing_comment="审批对象类型:1二次分配方案 2月度结算 3调整单", + existing_nullable=False, + ) + op.execute("DELETE FROM param_values WHERE id = 22") + op.execute("DELETE FROM param_definitions WHERE id = 19") diff --git a/backend/alembic/versions/6165691326d4_init_rbac_and_login_logs.py b/backend/alembic/versions/6165691326d4_init_rbac_and_login_logs.py index 44db76a..db6cf8c 100644 --- a/backend/alembic/versions/6165691326d4_init_rbac_and_login_logs.py +++ b/backend/alembic/versions/6165691326d4_init_rbac_and_login_logs.py @@ -337,17 +337,19 @@ def upgrade() -> None: def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index("idx_user_roles_role_id", table_name="user_roles") + """回滚迁移。 + + 只保留 drop_table —— DROP TABLE 会连带删除索引与外键。 + 原 autogenerate 输出里逐个 drop_index 会失败:idx_user_roles_role_id 与 + idx_role_permissions_permission_id 分别是外键的唯一支撑索引, + MySQL 拒绝单独删除(errno 1553),回滚会中断在半途。 + + 删除顺序遵循外键依赖:先子表(user_roles / role_permissions / login_logs), + 再父表(users / roles / permissions)。 + """ op.drop_table("user_roles") - op.drop_index("idx_role_permissions_permission_id", table_name="role_permissions") op.drop_table("role_permissions") - op.drop_index("idx_login_logs_username", table_name="login_logs") - op.drop_index("idx_login_logs_create_time", table_name="login_logs") op.drop_table("login_logs") - op.drop_index(op.f("idx_users_phone"), table_name="users") op.drop_table("users") op.drop_table("roles") op.drop_table("permissions") - # ### end Alembic commands ### diff --git a/backend/alembic/versions/79bcc5aa98d4_v6_系统设置_二次分配_审批表与角色参数种子.py b/backend/alembic/versions/79bcc5aa98d4_v6_系统设置_二次分配_审批表与角色参数种子.py new file mode 100644 index 0000000..f42752d --- /dev/null +++ b/backend/alembic/versions/79bcc5aa98d4_v6_系统设置_二次分配_审批表与角色参数种子.py @@ -0,0 +1,441 @@ +"""V6 系统设置/二次分配/审批表与角色参数种子 + +Revision ID: 79bcc5aa98d4 +Revises: 8efc189b131f +Create Date: 2026-10-15 15:42:13.467447 + +""" + +from collections.abc import Sequence +from datetime import date + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "79bcc5aa98d4" +down_revision: str | Sequence[str] | None = "8efc189b131f" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """应用迁移。""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "system_settings", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "company_name", + sa.String(length=64), + nullable=False, + comment="公司名称(登录页与导出页眉展示)", + ), + sa.Column( + "logo_path", + sa.String(length=255), + nullable=True, + comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_system_settings")), + comment="系统设置表(公司信息,全系统单行)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_table( + "approval_orders", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "order_no", + sa.String(length=32), + nullable=False, + comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}", + ), + sa.Column( + "order_type", + mysql.TINYINT(), + nullable=False, + comment="审批对象类型:1二次分配方案 2月度结算(预留)", + ), + sa.Column( + "ref_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)", + ), + sa.Column( + "refer_no", + sa.String(length=32), + nullable=False, + comment="引用对象单号(冗余固化,列表页免联查)", + ), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回", + ), + sa.Column( + "submitter_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="提交人ID(账号删除后置空)", + ), + sa.Column( + "submitter_name", + sa.String(length=64), + nullable=False, + comment="提交人用户名(冗余固化)", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint( + ["submitter_id"], + ["users.id"], + name=op.f("fk_approval_orders_submitter_id"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_orders")), + sa.UniqueConstraint("order_no", name=op.f("uk_approval_orders_order_no")), + comment="审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + "idx_approval_orders_type_status", "approval_orders", ["order_type", "status"], unique=False + ) + op.create_table( + "approval_steps", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "order_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="审批单ID(approval_orders.id;单删记录随删)", + ), + sa.Column( + "node", + mysql.TINYINT(), + nullable=False, + comment="审批节点:0撤回留痕 1财务复核 2领导审核", + ), + sa.Column( + "action", mysql.TINYINT(), nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回" + ), + sa.Column( + "opinion", sa.String(length=255), nullable=True, comment="审批意见(驳回时填写)" + ), + sa.Column( + "operator_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="操作人ID(自动通过/系统动作置空)", + ), + sa.Column( + "operator_name", + sa.String(length=64), + nullable=False, + comment="操作人用户名(冗余固化;系统动作记「系统」)", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.ForeignKeyConstraint( + ["operator_id"], + ["users.id"], + name=op.f("fk_approval_steps_operator_id"), + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["order_id"], + ["approval_orders.id"], + name=op.f("fk_approval_steps_order_id"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_steps")), + comment="审批节点记录表(append-only;自动通过/撤回均有留痕)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index("idx_approval_steps_order_id", "approval_steps", ["order_id"], unique=False) + op.create_table( + "business_secondary_allocations", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "business_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="业务ID(businesses.id;业务删除随删)", + ), + sa.Column( + "personnel_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="参与业务员ID(personnel.id;被方案引用禁删)", + ), + sa.Column( + "personnel_name", + sa.String(length=64), + nullable=False, + comment="参与业务员姓名(冗余固化,离职后回显有名)", + ), + sa.Column( + "ratio", + sa.Numeric(precision=5, scale=2), + nullable=False, + comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint( + ["business_id"], + ["businesses.id"], + name=op.f("fk_business_secondary_allocations_business_id"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["personnel_id"], + ["personnel.id"], + name=op.f("fk_business_secondary_allocations_personnel_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_business_secondary_allocations")), + sa.UniqueConstraint( + "business_id", + "personnel_id", + name="uk_business_secondary_allocations_business_personnel", + ), + comment="业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + "idx_business_secondary_allocations_business_id", + "business_secondary_allocations", + ["business_id"], + unique=False, + ) + op.create_index( + "idx_business_secondary_allocations_personnel_id", + "business_secondary_allocations", + ["personnel_id"], + unique=False, + ) + # ### end Alembic commands ### + + # ---- 种子数据(生产与开发同源;写法沿用 V1/V3 迁移先例)---- + + # 三个审批职能登录角色(2026-10-15 复核审批补充需求;roles 表无 is_builtin + # 列,内置保护由 role_service 的 BUILTIN_ROLE_CODES 白名单承担) + op.bulk_insert( + sa.table( + "roles", + sa.column("role_code", sa.String), + sa.column("role_name", sa.String), + sa.column("description", sa.String), + sa.column("sort_order", sa.Integer), + ), + [ + { + "role_code": "marketing_ops", + "role_name": "营销后勤", + "description": "提交与录入(审批链第一节点:月结/二次分配提交)", + "sort_order": 3, + }, + { + "role_code": "finance", + "role_name": "财务", + "description": "复核(审批链第二节点)", + "sort_order": 4, + }, + { + "role_code": "leader", + "role_name": "领导", + "description": "审核(审批链第三节点;全览只读)", + "sort_order": 5, + }, + ], + ) + + # 审批开关参数定义(param_definitions id 沿用 V3 种子的显式 id 方案接续 17/18) + op.bulk_insert( + sa.table( + "param_definitions", + sa.column("id", sa.BigInteger), + sa.column("param_code", sa.String), + sa.column("param_name", sa.String), + sa.column("param_group", sa.String), + sa.column("value_type", sa.String), + sa.column("unit", sa.String), + sa.column("dimension_type", sa.String), + sa.column("options_text", sa.String), + sa.column("default_value", sa.String), + sa.column("value_min", sa.String), + sa.column("value_max", sa.String), + sa.column("description", sa.String), + sa.column("sort_order", sa.Integer), + sa.column("status", sa.Integer), + ), + [ + { + "id": 17, + "param_code": "approval.secondary_allocation.enabled", + "param_name": "二次分配审批开关", + "param_group": "审批开关", + "value_type": "BOOL", + "unit": None, + "dimension_type": "NONE", + "options_text": None, + "default_value": "0", + "value_min": None, + "value_max": None, + "description": ( + "0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);" + "1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)" + ), + "sort_order": 17, + "status": 1, + }, + { + "id": 18, + "param_code": "approval.monthly_settlement.enabled", + "param_name": "月度结算审批开关", + "param_group": "审批开关", + "value_type": "BOOL", + "unit": None, + "dimension_type": "NONE", + "options_text": None, + "default_value": "0", + "value_min": None, + "value_max": None, + "description": ( + "预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);" + "0=结算生成即生效,1=走财务复核→领导审核审批流" + ), + "sort_order": 18, + "status": 1, + }, + ], + ) + + # 审批开关联动批次条目(batch_id=1 即 V3 的「初始版本」批次;BOOL 无维度行, + # 生效日期 2026-01-01 对齐其他支付环节种子) + op.bulk_insert( + sa.table( + "param_values", + sa.column("id", sa.BigInteger), + sa.column("param_id", sa.BigInteger), + sa.column("dimension_value", sa.String), + sa.column("effective_date", sa.Date), + sa.column("value_text", sa.String), + sa.column("batch_id", sa.BigInteger), + ), + [ + { + "id": 20, + "param_id": 17, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "0", + "batch_id": 1, + }, + { + "id": 21, + "param_id": 18, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "0", + "batch_id": 1, + }, + ], + ) + + # 公司信息单行(system_settings 恒为一行,id=1 由迁移保底) + op.bulk_insert( + sa.table( + "system_settings", + sa.column("id", sa.BigInteger), + sa.column("company_name", sa.String), + sa.column("logo_path", sa.String), + ), + [ + {"id": 1, "company_name": "衍水集团", "logo_path": None}, + ], + ) + + +def downgrade() -> None: + """回滚迁移。 + + 整表回滚只保留 `op.drop_table` —— DROP TABLE 会连带删除索引与外键。 + autogenerate 原样输出的逐个 `op.drop_index` 会失败:idx_approval_steps_order_id + 与 idx_business_secondary_allocations_personnel_id 分别是外键的唯一支撑索引, + 单独删除被 MySQL 拒绝(errno 1553),回滚会中断在半途。 + 删除顺序:先删引用方(approval_steps/二次分配),再删被引用方。 + 行级种子反向清理:roles/param_definitions/param_values 三表由 V1/V3 建、 + 本迁移只插行(roles 三角色、参数 17/18 及其版本条目),drop_table + 不会带走,须显式 DELETE,否则重放 upgrade 时唯一键冲突。 + """ + op.drop_table("business_secondary_allocations") + op.drop_table("approval_steps") + op.drop_table("approval_orders") + op.drop_table("system_settings") + op.execute("DELETE FROM param_values WHERE id IN (20, 21)") + op.execute("DELETE FROM param_definitions WHERE id IN (17, 18)") + op.execute("DELETE FROM roles WHERE role_code IN ('marketing_ops', 'finance', 'leader')") diff --git a/backend/alembic/versions/8efc189b131f_add_business_data_tables.py b/backend/alembic/versions/8efc189b131f_add_business_data_tables.py new file mode 100644 index 0000000..42c6ce1 --- /dev/null +++ b/backend/alembic/versions/8efc189b131f_add_business_data_tables.py @@ -0,0 +1,696 @@ +"""业务数据管理七表:业务/业务明细/状态留痕/发货单/发货明细/回款/评级等级配置(M2) + +- businesses:业务编号自动生成(报表 2.4.1「合同编号」),五项汇总冗余列 + 由服务层事务内维护;毛利润相关值不落库(派生值,M3 台账才是固化点) +- business_items:行级 product_category 为折算锚点(对照表 #14),指导价 + 人工录入、T+ 同步不覆盖 +- business_status_logs:append-only,创建时写首条(old=new=1) +- shipments/shipment_items:发货日为回款天数基准日(对照表 #1) +- receipts:手动指定发货批次(#18);settlement_status 为 M3 核算防重锚点 +- customer_rating_grades:等级映射可配置,种子四条随迁移初始化 + +Revision ID: 8efc189b131f +Revises: e1133a7c670a +Create Date: 2026-10-13 11:14:17.545320 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "8efc189b131f" +down_revision: str | Sequence[str] | None = "e1133a7c670a" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# 评级等级种子(与 init-dev.sql 双写一致;口径见模型 docstring) +_GRADE_SEED = [ + {"grade_name": "A", "min_score": 90, "max_score": 100, "sort_order": 1, "remark": "优秀"}, + {"grade_name": "B", "min_score": 75, "max_score": 89, "sort_order": 2, "remark": "良好"}, + {"grade_name": "C", "min_score": 60, "max_score": 74, "sort_order": 3, "remark": "合格"}, + {"grade_name": "D", "min_score": 0, "max_score": 59, "sort_order": 4, "remark": "待改进"}, +] + + +def upgrade() -> None: + """应用迁移。""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "customer_rating_grades", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "grade_name", sa.String(length=32), nullable=False, comment="等级名称(如 A/优秀)" + ), + sa.Column( + "min_score", + sa.Numeric(precision=5, scale=1), + nullable=False, + comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分", + ), + sa.Column( + "max_score", + sa.Numeric(precision=5, scale=1), + nullable=False, + comment="总分上限(展示用)", + ), + sa.Column( + "sort_order", + sa.Integer(), + server_default="0", + nullable=False, + comment="排序(判定顺序,升序)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="说明"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_rating_grades")), + sa.UniqueConstraint("grade_name", name=op.f("uk_customer_rating_grades_grade_name")), + comment="客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_table( + "business_status_logs", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "business_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="业务ID(固化,无外键;删除保护由服务层承担)", + ), + sa.Column( + "old_status", mysql.TINYINT(), nullable=False, comment="变更前状态(创建首条为 1)" + ), + sa.Column("new_status", mysql.TINYINT(), nullable=False, comment="变更后状态"), + sa.Column( + "change_reason", sa.String(length=255), nullable=True, comment="变更原因(终止时必填)" + ), + sa.Column( + "operator_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="操作人ID(账号删除后置空)", + ), + sa.Column( + "operator_name", + sa.String(length=64), + nullable=False, + comment="操作人用户名(冗余固化)", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.ForeignKeyConstraint( + ["operator_id"], + ["users.id"], + name=op.f("fk_business_status_logs_operator_id"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_business_status_logs")), + comment="业务状态变更记录表(append-only)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + "idx_business_status_logs_business_id", + "business_status_logs", + ["business_id"], + unique=False, + ) + op.create_index( + "idx_business_status_logs_create_time", + "business_status_logs", + ["create_time"], + unique=False, + ) + op.create_table( + "businesses", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "business_no", + sa.String(length=32), + nullable=False, + comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)", + ), + sa.Column( + "customer_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="客户ID(customers.id)", + ), + sa.Column("business_date", sa.Date(), nullable=True, comment="业务日期(T+ 同步口径)"), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)", + ), + sa.Column( + "owner_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)", + ), + sa.Column( + "assistant_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)", + ), + sa.Column( + "is_special", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="特殊业务标记:1是 0否(公司参与等)", + ), + sa.Column("special_note", sa.String(length=255), nullable=True, comment="特殊业务说明"), + sa.Column( + "total_sale_amount", + sa.Numeric(precision=14, scale=2), + server_default="0", + nullable=True, + comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)", + ), + sa.Column( + "total_cost_amount", + sa.Numeric(precision=14, scale=2), + server_default="0", + nullable=True, + comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)", + ), + sa.Column( + "total_ship_amount", + sa.Numeric(precision=14, scale=2), + server_default="0", + nullable=True, + comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)", + ), + sa.Column( + "total_received_amount", + sa.Numeric(precision=14, scale=2), + server_default="0", + nullable=True, + comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)", + ), + sa.Column( + "total_freight_amount", + sa.Numeric(precision=14, scale=2), + server_default="0", + nullable=True, + comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.Column( + "source_no", + sa.String(length=64), + nullable=True, + comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", + ), + sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), + sa.Column( + "sync_batch_no", + sa.String(length=32), + nullable=True, + comment="同步批次号(同步模块写入)", + ), + sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), + sa.ForeignKeyConstraint( + ["assistant_personnel_id"], + ["personnel.id"], + name=op.f("fk_businesses_assistant_personnel_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["customer_id"], + ["customers.id"], + name=op.f("fk_businesses_customer_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["owner_personnel_id"], + ["personnel.id"], + name=op.f("fk_businesses_owner_personnel_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_businesses")), + sa.UniqueConstraint("business_no", name=op.f("uk_businesses_business_no")), + comment="业务表(业务编号自动生成;T+同步不覆盖业务状态)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index("idx_businesses_business_date", "businesses", ["business_date"], unique=False) + op.create_index("idx_businesses_customer_id", "businesses", ["customer_id"], unique=False) + op.create_index( + "idx_businesses_owner_personnel_id", "businesses", ["owner_personnel_id"], unique=False + ) + op.create_index(op.f("idx_businesses_source_no"), "businesses", ["source_no"], unique=False) + op.create_index("idx_businesses_status", "businesses", ["status"], unique=False) + op.create_table( + "business_items", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "business_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="业务ID(businesses.id)", + ), + sa.Column( + "product_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="产品ID(products.id;NULL=自由录入,未入产品库)", + ), + sa.Column( + "product_name", + sa.String(length=128), + nullable=False, + comment="产品名称(引用产品时冗余固化)", + ), + sa.Column( + "spec_model", + sa.String(length=128), + server_default="", + nullable=False, + comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)", + ), + sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"), + sa.Column( + "product_category", + sa.String(length=32), + nullable=False, + comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)", + ), + sa.Column( + "quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="数量(>0)" + ), + sa.Column( + "sale_price", + sa.Numeric(precision=14, scale=4), + nullable=True, + comment="销售单价(元;T+ 同步或人工)", + ), + sa.Column( + "sale_amount", + sa.Numeric(precision=14, scale=2), + nullable=False, + comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)", + ), + sa.Column( + "cost_amount", + sa.Numeric(precision=14, scale=2), + nullable=True, + comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)", + ), + sa.Column( + "guided_price", + sa.Numeric(precision=14, scale=4), + nullable=True, + comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint( + ["business_id"], + ["businesses.id"], + name=op.f("fk_business_items_business_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["product_id"], + ["products.id"], + name=op.f("fk_business_items_product_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_business_items")), + comment="业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + "idx_business_items_business_id", "business_items", ["business_id"], unique=False + ) + op.create_index( + "idx_business_items_product_category", "business_items", ["product_category"], unique=False + ) + op.create_index("idx_business_items_product_id", "business_items", ["product_id"], unique=False) + op.create_table( + "shipments", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "shipment_no", + sa.String(length=32), + nullable=False, + comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)", + ), + sa.Column( + "business_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="业务ID(businesses.id;终态业务禁止新发货)", + ), + sa.Column( + "ship_date", + sa.Date(), + nullable=False, + comment="发货日期(回款天数基准日,发货日首日=第 1 天)", + ), + sa.Column( + "freight_amount", + sa.Numeric(precision=14, scale=2), + server_default="0", + nullable=False, + comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)", + ), + sa.Column( + "total_amount", + sa.Numeric(precision=14, scale=2), + server_default="0", + nullable=False, + comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.Column( + "source_no", + sa.String(length=64), + nullable=True, + comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", + ), + sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), + sa.Column( + "sync_batch_no", + sa.String(length=32), + nullable=True, + comment="同步批次号(同步模块写入)", + ), + sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), + sa.ForeignKeyConstraint( + ["business_id"], + ["businesses.id"], + name=op.f("fk_shipments_business_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_shipments")), + sa.UniqueConstraint("shipment_no", name=op.f("uk_shipments_shipment_no")), + comment="发货单表(发货日为回款天数基准日,发货日首日计第 1 天)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index("idx_shipments_business_id", "shipments", ["business_id"], unique=False) + op.create_index("idx_shipments_ship_date", "shipments", ["ship_date"], unique=False) + op.create_index(op.f("idx_shipments_source_no"), "shipments", ["source_no"], unique=False) + op.create_table( + "receipts", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "receipt_batch_no", + sa.String(length=32), + nullable=False, + comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)", + ), + sa.Column( + "business_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="业务ID(businesses.id)", + ), + sa.Column( + "shipment_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)", + ), + sa.Column( + "receipt_date", + sa.Date(), + nullable=False, + comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)", + ), + sa.Column( + "amount", + sa.Numeric(precision=14, scale=2), + nullable=False, + comment="回款金额(≥0;一期禁负数/红字)", + ), + sa.Column( + "is_barter", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)", + ), + sa.Column( + "settlement_status", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.Column( + "source_no", + sa.String(length=64), + nullable=True, + comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", + ), + sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), + sa.Column( + "sync_batch_no", + sa.String(length=32), + nullable=True, + comment="同步批次号(同步模块写入)", + ), + sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), + sa.ForeignKeyConstraint( + ["business_id"], + ["businesses.id"], + name=op.f("fk_receipts_business_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["shipment_id"], + ["shipments.id"], + name=op.f("fk_receipts_shipment_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_receipts")), + sa.UniqueConstraint("receipt_batch_no", name=op.f("uk_receipts_receipt_batch_no")), + comment="回款表(手动指定发货批次;已核算回款禁改禁删)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index("idx_receipts_business_id", "receipts", ["business_id"], unique=False) + op.create_index("idx_receipts_receipt_date", "receipts", ["receipt_date"], unique=False) + op.create_index( + "idx_receipts_settlement_status", "receipts", ["settlement_status"], unique=False + ) + op.create_index("idx_receipts_shipment_id", "receipts", ["shipment_id"], unique=False) + op.create_index(op.f("idx_receipts_source_no"), "receipts", ["source_no"], unique=False) + op.create_table( + "shipment_items", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "shipment_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="发货单ID(shipments.id)", + ), + sa.Column( + "product_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="产品ID(products.id;NULL=自由录入)", + ), + sa.Column( + "product_name", + sa.String(length=128), + nullable=False, + comment="产品名称(引用产品时冗余固化)", + ), + sa.Column( + "spec_model", + sa.String(length=128), + server_default="", + nullable=False, + comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)", + ), + sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"), + sa.Column( + "quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="发货数量(>0)" + ), + sa.Column( + "sale_price", + sa.Numeric(precision=14, scale=4), + nullable=True, + comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)", + ), + sa.Column( + "amount", + sa.Numeric(precision=14, scale=2), + nullable=False, + comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint( + ["product_id"], + ["products.id"], + name=op.f("fk_shipment_items_product_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["shipment_id"], + ["shipments.id"], + name=op.f("fk_shipment_items_shipment_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_shipment_items")), + comment="发货产品明细表", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index("idx_shipment_items_product_id", "shipment_items", ["product_id"], unique=False) + op.create_index( + "idx_shipment_items_shipment_id", "shipment_items", ["shipment_id"], unique=False + ) + # ### end Alembic commands ### + # 评级等级种子(等级映射可配置;双轨:init-dev.sql 同步写入) + op.bulk_insert( + sa.table( + "customer_rating_grades", + sa.column("grade_name", sa.String), + sa.column("min_score", sa.Numeric), + sa.column("max_score", sa.Numeric), + sa.column("sort_order", sa.Integer), + sa.column("remark", sa.String), + ), + _GRADE_SEED, + ) + + +def downgrade() -> None: + """回滚迁移。 + + **注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与 + `op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引, + 单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。 + **整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。 + """ + # 整表回滚只保留 drop_table(DROP TABLE 连带删除索引与外键); + # 逐条 drop_index 会先于 drop_table 摘掉外键支撑索引而报 MySQL 1553 + op.drop_table("shipment_items") + op.drop_table("receipts") + op.drop_table("shipments") + op.drop_table("business_items") + op.drop_table("businesses") + op.drop_table("business_status_logs") + op.drop_table("customer_rating_grades") diff --git a/backend/alembic/versions/a45a8087831b_add_operation_logs.py b/backend/alembic/versions/a45a8087831b_add_operation_logs.py new file mode 100644 index 0000000..5c3936e --- /dev/null +++ b/backend/alembic/versions/a45a8087831b_add_operation_logs.py @@ -0,0 +1,109 @@ +"""新增操作日志表 + +Revision ID: a45a8087831b +Revises: 6165691326d4 +Create Date: 2026-10-10 10:06:17.435697 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "a45a8087831b" +down_revision: str | Sequence[str] | None = "6165691326d4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """应用迁移。""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "operation_logs", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "user_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="操作人ID(账号删除后置空)", + ), + sa.Column( + "username", + sa.String(length=64), + nullable=False, + comment="操作人用户名(冗余存储便于追溯)", + ), + sa.Column("module", sa.String(length=64), nullable=False, comment="所属模块,如 用户管理"), + sa.Column( + "action", sa.String(length=32), nullable=False, comment="操作类型:新增/修改/删除/导出" + ), + sa.Column( + "summary", sa.String(length=255), nullable=False, comment="操作摘要(中文,供人阅读)" + ), + sa.Column("method", sa.String(length=8), nullable=True, comment="HTTP 方法"), + sa.Column("path", sa.String(length=255), nullable=True, comment="请求路径"), + sa.Column("ip", sa.String(length=45), nullable=True, comment="客户端IP(45位兼容IPv6)"), + sa.Column( + "user_agent", + sa.String(length=255), + nullable=True, + comment="客户端User-Agent(截断存储)", + ), + sa.Column("request_data", sa.Text(), nullable=True, comment="请求参数(已脱敏)"), + sa.Column( + "success", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="是否成功:1成功 0失败", + ), + sa.Column( + "error_message", + sa.String(length=500), + nullable=True, + comment="失败原因(写入方须截断)", + ), + sa.Column("duration_ms", sa.Integer(), nullable=True, comment="处理耗时(毫秒)"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], name=op.f("fk_operation_logs_user_id"), ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_operation_logs")), + comment="操作日志表", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + "idx_operation_logs_create_time", "operation_logs", ["create_time"], unique=False + ) + op.create_index("idx_operation_logs_module", "operation_logs", ["module"], unique=False) + op.create_index("idx_operation_logs_user_id", "operation_logs", ["user_id"], unique=False) + op.create_index("idx_operation_logs_username", "operation_logs", ["username"], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """回滚迁移。 + + **注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与 + `op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引, + 单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。 + **整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。 + """ + # 整表回滚只保留 drop_table —— DROP TABLE 会连带删除索引与外键。 + # 逐个 drop_index 会失败:idx_operation_logs_user_id 是外键 + # fk_operation_logs_user_id 的唯一支撑索引,MySQL 拒绝删除(errno 1553)。 + op.drop_table("operation_logs") diff --git a/backend/alembic/versions/ba0217f5091b_v9_二次分配唯一键补齐.py b/backend/alembic/versions/ba0217f5091b_v9_二次分配唯一键补齐.py new file mode 100644 index 0000000..d005922 --- /dev/null +++ b/backend/alembic/versions/ba0217f5091b_v9_二次分配唯一键补齐.py @@ -0,0 +1,51 @@ +"""V9:business_secondary_allocations 唯一键补齐(存量库自愈)。 + +背景:uk(business_id, personnel_id) 是随「就地修改已发布的 V6 迁移」加入的 +(V6 建表语句里已含、硬编码名);**旧 V6 已应用的存量库 upgrade 到当前 +head 时不会重放 V6**,拿不到该约束,V8 也未补加——两条升级路径的库结构 +漂移(test_schema_sync 只比对表/列集合,约束级漂移拦不住)。 + +本迁移是**幂等条件式**:约束已存在(新库 / init-dev 重置库 / 已重建的 +开发库)时跳过,不存在(旧 V6 升级库)时补加,避免重复约束名 errno 1826。 +语句写成裸 SQL 而非 op.create_unique_constraint——后者在 --sql 离线导出 +时会无条件输出 DDL,无法携带存在性判断。 + +注意:补键时若表内已存在重复 (business_id, personnel_id) 数据,ADD +CONSTRAINT 会失败(有意如此:先由人工清理重复数据再升级,不做静默去重)。 +""" + +from alembic import op + +revision: str = "ba0217f5091b" +down_revision: str | None = "c7d2e5a81f30" +branch_labels = None +depends_on = None + +_UK_NAME = "uk_business_secondary_allocations_business_personnel" + + +def upgrade() -> None: + """条件式补唯一键(见模块 docstring 的背景与幂等说明)。""" + op.execute( + "SET @uk_exists := (" + "SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS " + "WHERE CONSTRAINT_SCHEMA = DATABASE() " + "AND TABLE_NAME = 'business_secondary_allocations' " + f"AND CONSTRAINT_NAME = '{_UK_NAME}'" + ")" + ) + op.execute( + "SET @ddl := IF(@uk_exists = 0, " + f"'ALTER TABLE business_secondary_allocations ADD CONSTRAINT {_UK_NAME} " + "UNIQUE (business_id, personnel_id)', " + "'DO 0')" + ) + op.execute("PREPARE stmt FROM @ddl") + op.execute("EXECUTE stmt") + op.execute("DEALLOCATE PREPARE stmt") + + +def downgrade() -> None: + """有意不撤键:新库由 V6 建表即带该约束、并非本迁移首创,撤掉会让 + 降级路径产生新的漂移;确需撤销请人工评估后执行 DROP INDEX。 + """ diff --git a/backend/alembic/versions/bfbda9c3191d_add_business_roles_and_params.py b/backend/alembic/versions/bfbda9c3191d_add_business_roles_and_params.py new file mode 100644 index 0000000..a9bccbe --- /dev/null +++ b/backend/alembic/versions/bfbda9c3191d_add_business_roles_and_params.py @@ -0,0 +1,810 @@ +"""新增岗位角色与计提参数四表 + +Revision ID: bfbda9c3191d +Revises: a45a8087831b +Create Date: 2026-10-12 16:01:48.760620 + +""" + +from collections.abc import Sequence +from datetime import date + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "bfbda9c3191d" +down_revision: str | Sequence[str] | None = "a45a8087831b" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """应用迁移。""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "business_roles", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "role_code", + sa.String(length=32), + nullable=False, + comment="岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改", + ), + sa.Column("role_name", sa.String(length=64), nullable=False, comment="岗位名称"), + sa.Column( + "calc_template", + sa.String(length=32), + nullable=False, + comment="计提公式模板编码(以代码内模板注册表为准)", + ), + sa.Column("description", sa.String(length=255), nullable=True, comment="岗位说明"), + sa.Column( + "sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序" + ), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="状态:1启用 0禁用", + ), + sa.Column( + "is_builtin", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="是否内置岗位:1内置(禁删禁停) 0自定义", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_business_roles")), + sa.UniqueConstraint("role_code", name=op.f("uk_business_roles_role_code")), + comment="业务岗位角色表(与登录角色roles区分,决定计提公式与参数)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_table( + "param_definitions", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "param_code", + sa.String(length=64), + nullable=False, + comment="参数编码,如 salesperson.rate.slope", + ), + sa.Column("param_name", sa.String(length=64), nullable=False, comment="参数名称"), + sa.Column( + "param_group", + sa.String(length=32), + nullable=False, + comment="参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径", + ), + sa.Column( + "value_type", + sa.String(length=16), + nullable=False, + comment="值类型:DECIMAL/INT/BOOL/ENUM/TEXT", + ), + sa.Column( + "unit", sa.String(length=16), nullable=True, comment="展示单位:‰/%/元/天,无单位为空" + ), + sa.Column( + "dimension_type", + sa.String(length=32), + server_default="NONE", + nullable=False, + comment="维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE", + ), + sa.Column( + "options_text", + sa.String(length=255), + nullable=True, + comment="枚举选项(竖线分隔),仅 ENUM 使用", + ), + sa.Column( + "default_value", + sa.String(length=64), + nullable=True, + comment="系统默认值(Fraction 可解析字符串)", + ), + sa.Column( + "value_min", sa.String(length=64), nullable=True, comment="取值下限(含),校验用" + ), + sa.Column( + "value_max", sa.String(length=64), nullable=True, comment="取值上限(含),校验用" + ), + sa.Column( + "description", sa.String(length=255), nullable=True, comment="口径说明(配置页展示)" + ), + sa.Column( + "sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序" + ), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="状态:1启用 0停用", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_param_definitions")), + sa.UniqueConstraint("param_code", name=op.f("uk_param_definitions_param_code")), + comment="计提参数定义表", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_table( + "param_change_batches", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "version_no", sa.String(length=32), nullable=False, comment="版本号,如 P20261012-01" + ), + sa.Column("effective_date", sa.Date(), nullable=False, comment="生效日期(业务日期口径)"), + sa.Column( + "change_type", + mysql.TINYINT(), + nullable=False, + comment="生效方式:1立即生效 2指定日期生效", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="变更说明"), + sa.Column( + "operator_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="操作人ID(账号删除后置空)", + ), + sa.Column( + "operator_name", + sa.String(length=64), + nullable=False, + comment="操作人用户名(冗余固化)", + ), + sa.Column( + "item_count", sa.Integer(), server_default="0", nullable=False, comment="变更参数项数" + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.ForeignKeyConstraint( + ["operator_id"], + ["users.id"], + name=op.f("fk_param_change_batches_operator_id"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_param_change_batches")), + sa.UniqueConstraint("version_no", name=op.f("uk_param_change_batches_version_no")), + comment="参数变更批次表", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_table( + "param_values", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "param_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="参数定义ID(param_definitions.id)", + ), + sa.Column( + "dimension_value", + sa.String(length=32), + server_default=sa.text("''"), + nullable=False, + comment="维度取值:NONE维度为空串;产品类别/岗位编码为具体值", + ), + sa.Column( + "effective_date", sa.Date(), nullable=False, comment="生效日期(按业务日期时点化取值)" + ), + sa.Column( + "value_text", + sa.String(length=64), + nullable=False, + comment="参数值(Fraction 可解析字符串/枚举码/开关)", + ), + sa.Column( + "old_value_text", sa.String(length=64), nullable=True, comment="变更前值(留痕)" + ), + sa.Column( + "batch_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="变更批次ID(param_change_batches.id)", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.ForeignKeyConstraint( + ["batch_id"], + ["param_change_batches.id"], + name=op.f("fk_param_values_batch_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["param_id"], + ["param_definitions.id"], + name=op.f("fk_param_values_param_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_param_values")), + comment="参数版本条目表(append-only,按生效日期时点化取值)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index("idx_param_values_batch_id", "param_values", ["batch_id"], unique=False) + op.create_index( + "idx_param_values_effective_date", "param_values", ["effective_date"], unique=False + ) + op.create_index( + "idx_param_values_param_id_dimension_value_effective_date", + "param_values", + ["param_id", "dimension_value", "effective_date"], + unique=False, + ) + # ### end Alembic commands ### + + # ---- 种子数据(生产与开发同源;口径依据 2026-10-12 业务裁决对照表)---- + # 显式指定 id:四张表均为本迁移新建、应用时必为空表,id 稳定可控; + # param_values 按 param_id 引用定义,无法依赖自增(bulk_insert 取不回自增值)。 + op.bulk_insert( + sa.table( + "business_roles", + sa.column("id", sa.BigInteger), + sa.column("role_code", sa.String), + sa.column("role_name", sa.String), + sa.column("calc_template", sa.String), + sa.column("description", sa.String), + sa.column("sort_order", sa.Integer), + sa.column("is_builtin", sa.Integer), + ), + [ + { + "id": 1, + "role_code": "salesperson", + "role_name": "营销员", + "calc_template": "TPL_SALESPERSON_DECAY", + "description": "业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)", + "sort_order": 1, + "is_builtin": 1, + }, + { + "id": 2, + "role_code": "sales_manager", + "role_name": "营销经理", + "calc_template": "TPL_MANAGER_SHARE", + "description": "经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%", + "sort_order": 2, + "is_builtin": 1, + }, + { + "id": 3, + "role_code": "info_officer", + "role_name": "信息员", + "calc_template": "TPL_INFO_SALES_FIXED", + "description": "信息员绩效:本人销售额×1‰", + "sort_order": 3, + "is_builtin": 1, + }, + ], + ) + + op.bulk_insert( + sa.table( + "param_change_batches", + sa.column("id", sa.BigInteger), + sa.column("version_no", sa.String), + sa.column("effective_date", sa.Date), + sa.column("change_type", sa.Integer), + sa.column("remark", sa.String), + sa.column("operator_name", sa.String), + sa.column("item_count", sa.Integer), + ), + [ + { + "id": 1, + "version_no": "P20260101-00", + "effective_date": date(2026, 1, 1), + "change_type": 1, + "remark": "初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)", + "operator_name": "system", + "item_count": 19, + } + ], + ) + + op.bulk_insert( + sa.table( + "param_definitions", + sa.column("id", sa.BigInteger), + sa.column("param_code", sa.String), + sa.column("param_name", sa.String), + sa.column("param_group", sa.String), + sa.column("value_type", sa.String), + sa.column("unit", sa.String), + sa.column("dimension_type", sa.String), + sa.column("options_text", sa.String), + sa.column("default_value", sa.String), + sa.column("value_min", sa.String), + sa.column("value_max", sa.String), + sa.column("description", sa.String), + sa.column("sort_order", sa.Integer), + ), + [ + { + "id": 1, + "param_code": "salesperson.rate.start", + "param_name": "业务员起始计提比例", + "param_group": "业务员个人户", + "value_type": "DECIMAL", + "unit": "‰", + "dimension_type": "BUSINESS_ROLE", + "options_text": None, + "default_value": "10", + "value_min": "0", + "value_max": None, + "description": "线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)", + "sort_order": 1, + }, + { + "id": 2, + "param_code": "salesperson.rate.slope", + "param_name": "业务员日递减斜率", + "param_group": "业务员个人户", + "value_type": "DECIMAL", + "unit": "‰/天", + "dimension_type": "BUSINESS_ROLE", + "options_text": None, + "default_value": "4/360", + "value_min": "0", + "value_max": None, + "description": "精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储", + "sort_order": 2, + }, + { + "id": 3, + "param_code": "salesperson.rate.floor", + "param_name": "业务员计提下限", + "param_group": "业务员个人户", + "value_type": "DECIMAL", + "unit": "‰", + "dimension_type": "BUSINESS_ROLE", + "options_text": None, + "default_value": "4", + "value_min": "0", + "value_max": None, + "description": "回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)", + "sort_order": 3, + }, + { + "id": 4, + "param_code": "salesperson.days.boundary", + "param_name": "线性递减分界天数", + "param_group": "业务员个人户", + "value_type": "INT", + "unit": "天", + "dimension_type": "BUSINESS_ROLE", + "options_text": None, + "default_value": "360", + "value_min": "0", + "value_max": None, + "description": "≤分界天数按公式线性递减(360 天=6‰);超过则取下限", + "sort_order": 4, + }, + { + "id": 5, + "param_code": "manager.share.team", + "param_name": "经理本团队分成比例", + "param_group": "经理分成", + "value_type": "DECIMAL", + "unit": "%", + "dimension_type": "BUSINESS_ROLE", + "options_text": None, + "default_value": "12.5", + "value_min": "0", + "value_max": "100", + "description": "∑本人带领业务员绩效 × 此比例(限本团队)", + "sort_order": 5, + }, + { + "id": 6, + "param_code": "manager.share.company", + "param_name": "经理全公司分成比例", + "param_group": "经理分成", + "value_type": "DECIMAL", + "unit": "%", + "dimension_type": "BUSINESS_ROLE", + "options_text": None, + "default_value": "5", + "value_min": "0", + "value_max": "100", + "description": "∑全部业务员绩效 × 此比例(全公司)", + "sort_order": 6, + }, + { + "id": 7, + "param_code": "enterprise.rate", + "param_name": "企业户计提比例", + "param_group": "企业户", + "value_type": "DECIMAL", + "unit": "‰", + "dimension_type": "PRODUCT_CATEGORY", + "options_text": None, + "default_value": "3", + "value_min": "0", + "value_max": None, + "description": "所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰", + "sort_order": 7, + }, + { + "id": 8, + "param_code": "info.rate", + "param_name": "信息员计提比例", + "param_group": "信息员", + "value_type": "DECIMAL", + "unit": "‰", + "dimension_type": "BUSINESS_ROLE", + "options_text": None, + "default_value": "1", + "value_min": "0", + "value_max": None, + "description": "信息员绩效 = 本人销售额 × 此比例", + "sort_order": 8, + }, + { + "id": 9, + "param_code": "prepay.rate", + "param_name": "预付款计提比例", + "param_group": "支付环节", + "value_type": "DECIMAL", + "unit": "‰", + "dimension_type": "NONE", + "options_text": None, + "default_value": "10", + "value_min": "0", + "value_max": None, + "description": "预付款统一按此比例计提", + "sort_order": 9, + }, + { + "id": 10, + "param_code": "barter.discount", + "param_name": "以物抵顶折算比例", + "param_group": "支付环节", + "value_type": "DECIMAL", + "unit": "%", + "dimension_type": "NONE", + "options_text": None, + "default_value": "80", + "value_min": "0", + "value_max": "100", + "description": "以物抵顶回款绩效按应发绩效的此比例折算", + "sort_order": 10, + }, + { + "id": 11, + "param_code": "payment.start.ratio", + "param_name": "支付起付比例", + "param_group": "支付环节", + "value_type": "DECIMAL", + "unit": "%", + "dimension_type": "NONE", + "options_text": None, + "default_value": "60", + "value_min": "0", + "value_max": "100", + "description": "单项业务回款额达销售额(不含运费)此比例后起付;达标前累计", + "sort_order": 11, + }, + { + "id": 12, + "param_code": "payment.weight.mode", + "param_name": "支付权重应用方式", + "param_group": "支付环节", + "value_type": "ENUM", + "unit": None, + "dimension_type": "NONE", + "options_text": "FULL|BALANCE", + "default_value": "FULL", + "value_min": None, + "value_max": None, + "description": "FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)", + "sort_order": 12, + }, + { + "id": 13, + "param_code": "product.threshold", + "param_name": "折算毛利润额阈值", + "param_group": "产品阈值", + "value_type": "DECIMAL", + "unit": "%", + "dimension_type": "PRODUCT_CATEGORY", + "options_text": None, + "default_value": "30", + "value_min": "0", + "value_max": "100", + "description": "折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%", + "sort_order": 13, + }, + { + "id": 14, + "param_code": "salary.basic.mode", + "param_name": "月工资基本工资口径", + "param_group": "月工资口径", + "value_type": "ENUM", + "unit": None, + "dimension_type": "BUSINESS_ROLE", + "options_text": "INCLUDE|DEDUCT|EXCLUDE", + "default_value": "INCLUDE", + "value_min": None, + "value_max": None, + "description": "INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计", + "sort_order": 14, + }, + { + "id": 15, + "param_code": "probation.annual.threshold", + "param_name": "试用期年累计结算阈值", + "param_group": "月工资口径", + "value_type": "DECIMAL", + "unit": "元", + "dimension_type": "NONE", + "options_text": None, + "default_value": "18000", + "value_min": "0", + "value_max": None, + "description": "试用期年累计绩效超过此值部分年底一次性结算", + "sort_order": 15, + }, + { + "id": 16, + "param_code": "probation.salary.basic", + "param_name": "试用期基本工资标准", + "param_group": "月工资口径", + "value_type": "DECIMAL", + "unit": "元", + "dimension_type": "NONE", + "options_text": None, + "default_value": "3000", + "value_min": "0", + "value_max": None, + "description": "试用期仅发基本工资、不发放绩效(归属待 M2 复核)", + "sort_order": 16, + }, + ], + ) + + op.bulk_insert( + sa.table( + "param_values", + sa.column("id", sa.BigInteger), + sa.column("param_id", sa.BigInteger), + sa.column("dimension_value", sa.String), + sa.column("effective_date", sa.Date), + sa.column("value_text", sa.String), + sa.column("batch_id", sa.BigInteger), + ), + [ + # 业务员个人户(2026-02-17 折算公式执行日起) + { + "id": 1, + "param_id": 1, + "dimension_value": "salesperson", + "effective_date": date(2026, 2, 17), + "value_text": "10", + "batch_id": 1, + }, + { + "id": 2, + "param_id": 2, + "dimension_value": "salesperson", + "effective_date": date(2026, 2, 17), + "value_text": "4/360", + "batch_id": 1, + }, + { + "id": 3, + "param_id": 3, + "dimension_value": "salesperson", + "effective_date": date(2026, 2, 17), + "value_text": "4", + "batch_id": 1, + }, + { + "id": 4, + "param_id": 4, + "dimension_value": "salesperson", + "effective_date": date(2026, 2, 17), + "value_text": "360", + "batch_id": 1, + }, + # 经理与信息员(2026-08-13 执行日起) + { + "id": 5, + "param_id": 5, + "dimension_value": "sales_manager", + "effective_date": date(2026, 8, 13), + "value_text": "12.5", + "batch_id": 1, + }, + { + "id": 6, + "param_id": 6, + "dimension_value": "sales_manager", + "effective_date": date(2026, 8, 13), + "value_text": "5", + "batch_id": 1, + }, + { + "id": 7, + "param_id": 7, + "dimension_value": "", + "effective_date": date(2026, 2, 17), + "value_text": "2", + "batch_id": 1, + }, + { + "id": 8, + "param_id": 7, + "dimension_value": "", + "effective_date": date(2026, 8, 13), + "value_text": "3", + "batch_id": 1, + }, + { + "id": 9, + "param_id": 8, + "dimension_value": "info_officer", + "effective_date": date(2026, 8, 13), + "value_text": "1", + "batch_id": 1, + }, + # 支付环节与通用口径(需规默认值,无历史冲突) + { + "id": 10, + "param_id": 9, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "10", + "batch_id": 1, + }, + { + "id": 11, + "param_id": 10, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "80", + "batch_id": 1, + }, + { + "id": 12, + "param_id": 11, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "60", + "batch_id": 1, + }, + { + "id": 13, + "param_id": 12, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "FULL", + "batch_id": 1, + }, + # 产品阈值(按产品类别维度) + { + "id": 14, + "param_id": 13, + "dimension_value": "municipal_water", + "effective_date": date(2026, 2, 17), + "value_text": "30", + "batch_id": 1, + }, + { + "id": 15, + "param_id": 13, + "dimension_value": "rail_transit", + "effective_date": date(2026, 2, 17), + "value_text": "20", + "batch_id": 1, + }, + # 月工资口径(按岗位维度) + { + "id": 16, + "param_id": 14, + "dimension_value": "salesperson", + "effective_date": date(2026, 1, 1), + "value_text": "INCLUDE", + "batch_id": 1, + }, + { + "id": 17, + "param_id": 14, + "dimension_value": "sales_manager", + "effective_date": date(2026, 1, 1), + "value_text": "DEDUCT", + "batch_id": 1, + }, + { + "id": 18, + "param_id": 15, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "18000", + "batch_id": 1, + }, + { + "id": 19, + "param_id": 16, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "3000", + "batch_id": 1, + }, + ], + ) + + +def downgrade() -> None: + """回滚迁移。 + + **注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与 + `op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引, + 单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。 + **整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。 + """ + # 整表回滚只保留 drop_table —— DROP TABLE 会连带删除索引与外键。 + # 原 autogenerate 输出的逐个 drop_index 会失败:组合索引最左列 param_id 与 + # idx_param_values_batch_id 分别是两个外键的唯一支撑索引(MySQL errno 1553)。 + # 删除顺序:先删引用方 param_values,再删被引用方。 + op.drop_table("param_values") + op.drop_table("param_change_batches") + op.drop_table("param_definitions") + op.drop_table("business_roles") diff --git a/backend/alembic/versions/c7d2e5a81f30_v8_业务答复口径落实_审批开关默认_试用期参数_经理基数开关_台账分摊唯一键.py b/backend/alembic/versions/c7d2e5a81f30_v8_业务答复口径落实_审批开关默认_试用期参数_经理基数开关_台账分摊唯一键.py new file mode 100644 index 0000000..aa019c2 --- /dev/null +++ b/backend/alembic/versions/c7d2e5a81f30_v8_业务答复口径落实_审批开关默认_试用期参数_经理基数开关_台账分摊唯一键.py @@ -0,0 +1,193 @@ +"""V8:业务答复口径落实(2026-10-19 回执)。 + +四件事,全部源于《待澄清问题清单》2026-10-19 答复: + +1. 审批开关默认值翻转(#5/#7 答复「默认开启」):param_definitions 的 + default_value 更新为 '1';param_values **插入生效日 2026-10-19 的新行** + 而非改历史行——参数体系时点化(按业务日期回溯),直接改种子行会篡改 + 10-19 之前的口径历史。 +2. 撤销试用期统一工资参数(#16 答复「按人员档案逐人维护」):删除 + probation.salary.basic 的定义与版本行;月结基本工资已直接读 + personnel.salary_standard(该参数自 M3 ④ 实现起即无业务消费点)。 +3. 新增经理个人户聚合基数开关(#12 答复「作为参数配置,手动调整是否 + 纳入」):manager.personal.included,默认 0=不计入(维持暂口径)。 +4. 绩效台账唯一键加 personnel_id(二次分配分摊铺路,澄清 #1 答复 + 「分配绩效」):分摊后同一回款同版本按人各落一行(负责人余额行 + + 参与人比例行),uk(receipt_id, calc_version) 会撞;扩为三列后聚合行 + (receipt_id NULL)依旧不受 MySQL 唯一索引约束。 + +迁移 downgrade 只保留 drop_table/行级种子反向 DELETE(V6 教训:逐个 +drop_index 会撞外键支撑索引 errno 1553)。 +""" + +from datetime import date + +import sqlalchemy as sa +from alembic import op + +revision: str = "c7d2e5a81f30" +down_revision: str | None = "3bca400421c6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。""" + # 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭) + op.execute( + "UPDATE param_definitions SET default_value = '1' " + "WHERE id IN (17, 18)" + ) + # 时点化生效行:插新行不改历史(10-19 前仍按 0 解析) + op.bulk_insert( + sa.table( + "param_values", + sa.column("id", sa.BigInteger), + sa.column("param_id", sa.BigInteger), + sa.column("dimension_value", sa.String), + sa.column("effective_date", sa.Date), + sa.column("value_text", sa.String), + sa.column("batch_id", sa.BigInteger), + ), + [ + { + "id": 23, + "param_id": 17, + "dimension_value": "", + "effective_date": date(2026, 10, 19), + "value_text": "1", + "batch_id": 1, + }, + { + "id": 24, + "param_id": 18, + "dimension_value": "", + "effective_date": date(2026, 10, 19), + "value_text": "1", + "batch_id": 1, + }, + ], + ) + + # 2. 撤销试用期统一工资参数(#16:按人员档案 personnel.salary_standard 逐人维护) + op.execute("DELETE FROM param_values WHERE id = 19") + op.execute("DELETE FROM param_definitions WHERE id = 16") + + # 3. 经理个人户聚合基数开关(#12:参数化,默认不计入) + op.bulk_insert( + sa.table( + "param_definitions", + sa.column("id", sa.BigInteger), + sa.column("param_code", sa.String), + sa.column("param_name", sa.String), + sa.column("param_group", sa.String), + sa.column("value_type", sa.String), + sa.column("unit", sa.String), + sa.column("dimension_type", sa.String), + sa.column("options_text", sa.String), + sa.column("default_value", sa.String), + sa.column("value_min", sa.String), + sa.column("value_max", sa.String), + sa.column("description", sa.String), + sa.column("sort_order", sa.Integer), + sa.column("status", sa.Integer), + ), + [ + { + "id": 20, + "param_code": "manager.personal.included", + "param_name": "经理个人户计入聚合基数", + "param_group": "经理分成", + "value_type": "BOOL", + "unit": None, + "dimension_type": "NONE", + "options_text": None, + "default_value": "0", + "value_min": None, + "value_max": None, + "description": ( + "经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 " + "业务答复 2026-10-19:作为参数配置、手动调整是否纳入):" + "0=不计入(默认,暂口径) 1=计入" + ), + "sort_order": 20, + "status": 1, + } + ], + ) + op.bulk_insert( + sa.table( + "param_values", + sa.column("id", sa.BigInteger), + sa.column("param_id", sa.BigInteger), + sa.column("dimension_value", sa.String), + sa.column("effective_date", sa.Date), + sa.column("value_text", sa.String), + sa.column("batch_id", sa.BigInteger), + ), + [ + { + "id": 25, + "param_id": 20, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "0", + "batch_id": 1, + } + ], + ) + + # 4. 台账唯一键扩为三列(分摊拆行:同回款同版本按人一行) + # 顺序要紧:旧 uk 是 fk_performance_ledgers_receipt_id 的唯一支撑索引, + # 先删会撞 errno 1553(外键无支撑索引);先建新键(receipt_id 仍居最左) + # 再做替换,两键短暂并存无冲突。 + op.create_unique_constraint( + "uk_performance_ledgers_receipt_id_calc_version_personnel", + "performance_ledgers", + ["receipt_id", "calc_version", "personnel_id"], + ) + op.drop_constraint( + "uk_performance_ledgers_receipt_id_calc_version", + "performance_ledgers", + type_="unique", + ) + + +def downgrade() -> None: + """反向:唯一键复原 + 种子行级回退(顺序与 upgrade 相反)。 + + 试用期参数(definitions 16 / values 19)的原始种子在 V3,按 V6 downgrade + 的行级反向惯例显式重插,保证可重放。 + """ + # 与 upgrade 同因的顺序:先建回旧键再删新键(旧键支撑 receipts 外键) + op.create_unique_constraint( + "uk_performance_ledgers_receipt_id_calc_version", + "performance_ledgers", + ["receipt_id", "calc_version"], + ) + op.drop_constraint( + "uk_performance_ledgers_receipt_id_calc_version_personnel", + "performance_ledgers", + type_="unique", + ) + + op.execute("DELETE FROM param_values WHERE id = 25") + op.execute("DELETE FROM param_definitions WHERE id = 20") + + op.execute( + "INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, " + "unit, dimension_type, options_text, default_value, value_min, value_max, " + "description, sort_order, status) VALUES " + "(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', " + "'元', 'NONE', NULL, '3000', '0', NULL, " + "'试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16, 1)" + ) + op.execute( + "INSERT INTO param_values (id, param_id, dimension_value, effective_date, " + "value_text, batch_id) VALUES (19, 16, '', '2026-01-01', '3000', 1)" + ) + + op.execute("DELETE FROM param_values WHERE id IN (23, 24)") + op.execute( + "UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)" + ) diff --git a/backend/alembic/versions/e1133a7c670a_add_base_data_tables.py b/backend/alembic/versions/e1133a7c670a_add_base_data_tables.py new file mode 100644 index 0000000..7a81083 --- /dev/null +++ b/backend/alembic/versions/e1133a7c670a_add_base_data_tables.py @@ -0,0 +1,490 @@ +"""基础数据管理六表:人员/辐射区域/拓展计划/产品/客户/归属变更留痕(M2) + +- personnel / personnel_regions / personnel_plans:人员档案及其 1:N 子表 +- products:产品主数据(类别锚定折算阈值参数) +- customers:客户档案(公司名称+负责人为业务唯一标识) +- ownership_change_logs:归属变更留痕(append-only,不回溯历史) +- 六张表的 T+ 同步预留列(source_no 等)全部可空、不设唯一键(对照表 #26) + +Revision ID: e1133a7c670a +Revises: bfbda9c3191d +Create Date: 2026-10-13 09:36:02.219248 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "e1133a7c670a" +down_revision: str | Sequence[str] | None = "bfbda9c3191d" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """应用迁移。""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "products", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column("product_name", sa.String(length=128), nullable=False, comment="产品名称"), + sa.Column( + "spec_model", + sa.String(length=128), + server_default="", + nullable=False, + comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)", + ), + sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"), + sa.Column( + "stock_quantity", + sa.Numeric(precision=14, scale=2), + nullable=True, + comment="数量(T+ 同步原样存储,语义待 IT 确认)", + ), + sa.Column( + "sale_price", + sa.Numeric(precision=14, scale=4), + nullable=True, + comment="销售单价(元,T+ 同步)", + ), + sa.Column( + "product_category", + sa.String(length=32), + nullable=False, + comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)", + ), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="状态:1启用 0停用", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.Column( + "source_no", + sa.String(length=64), + nullable=True, + comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", + ), + sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), + sa.Column( + "sync_batch_no", + sa.String(length=32), + nullable=True, + comment="同步批次号(同步模块写入)", + ), + sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_products")), + sa.UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"), + comment="产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index(op.f("idx_products_source_no"), "products", ["source_no"], unique=False) + op.create_table( + "ownership_change_logs", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "owner_type", mysql.TINYINT(), nullable=False, comment="归属类型:1客户归属 2业务归属" + ), + sa.Column( + "customer_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="客户ID(固化,owner_type=1 时非空)", + ), + sa.Column( + "business_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="业务ID(固化,owner_type=2 时非空)", + ), + sa.Column( + "old_owner_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="变更前负责营销人员ID(固化)", + ), + sa.Column( + "new_owner_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="变更后负责营销人员ID(固化)", + ), + sa.Column( + "old_assistant_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="变更前协助营销人员ID(固化,仅业务归属使用)", + ), + sa.Column( + "new_assistant_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="变更后协助营销人员ID(固化,仅业务归属使用)", + ), + sa.Column("change_reason", sa.String(length=255), nullable=True, comment="变更原因"), + sa.Column( + "operator_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="操作人ID(账号删除后置空)", + ), + sa.Column( + "operator_name", + sa.String(length=64), + nullable=False, + comment="操作人用户名(冗余固化)", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.ForeignKeyConstraint( + ["operator_id"], + ["users.id"], + name=op.f("fk_ownership_change_logs_operator_id"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_ownership_change_logs")), + comment="归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + op.f("idx_ownership_change_logs_customer_id"), + "ownership_change_logs", + ["customer_id"], + unique=False, + ) + op.create_index( + op.f("idx_ownership_change_logs_business_id"), + "ownership_change_logs", + ["business_id"], + unique=False, + ) + op.create_index( + op.f("idx_ownership_change_logs_create_time"), + "ownership_change_logs", + ["create_time"], + unique=False, + ) + op.create_table( + "personnel", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column("personnel_name", sa.String(length=64), nullable=False, comment="姓名"), + sa.Column( + "business_role_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)", + ), + sa.Column( + "manager_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)", + ), + sa.Column("phone", sa.String(length=20), nullable=True, comment="联系电话"), + sa.Column("hire_date", sa.Date(), nullable=True, comment="入职日期"), + sa.Column( + "is_probation", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="是否试用期:1是 0否(试用期绩效照算不发放)", + ), + sa.Column( + "salary_standard", + sa.Numeric(precision=12, scale=2), + nullable=True, + comment="月工资标准(元;T+同步不覆盖)", + ), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.Column( + "source_no", + sa.String(length=64), + nullable=True, + comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", + ), + sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), + sa.Column( + "sync_batch_no", + sa.String(length=32), + nullable=True, + comment="同步批次号(同步模块写入)", + ), + sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), + sa.ForeignKeyConstraint( + ["business_role_id"], + ["business_roles.id"], + name=op.f("fk_personnel_business_role_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["manager_id"], + ["personnel.id"], + name=op.f("fk_personnel_manager_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel")), + comment="营销人员表(T+同步不覆盖岗位与工资标准)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + op.f("idx_personnel_business_role_id"), "personnel", ["business_role_id"], unique=False + ) + op.create_index(op.f("idx_personnel_manager_id"), "personnel", ["manager_id"], unique=False) + op.create_index(op.f("idx_personnel_source_no"), "personnel", ["source_no"], unique=False) + op.create_table( + "customers", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column("company_name", sa.String(length=128), nullable=False, comment="公司名称"), + sa.Column( + "owner_name", + sa.String(length=64), + nullable=False, + comment="客户负责人姓名(客户公司负责人的人名,非系统人员)", + ), + sa.Column("contact_person", sa.String(length=64), nullable=True, comment="联系人"), + sa.Column("contact_phone", sa.String(length=20), nullable=True, comment="联系电话"), + sa.Column("address", sa.String(length=255), nullable=True, comment="地址"), + sa.Column("industry", sa.String(length=64), nullable=True, comment="所属行业(自由文本)"), + sa.Column( + "customer_type", + sa.String(length=32), + server_default="personal", + nullable=False, + comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)", + ), + sa.Column( + "owner_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.Column( + "source_no", + sa.String(length=64), + nullable=True, + comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", + ), + sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), + sa.Column( + "sync_batch_no", + sa.String(length=32), + nullable=True, + comment="同步批次号(同步模块写入)", + ), + sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), + sa.ForeignKeyConstraint( + ["owner_personnel_id"], + ["personnel.id"], + name=op.f("fk_customers_owner_personnel_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_customers")), + sa.UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"), + comment="客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + op.f("idx_customers_customer_type"), "customers", ["customer_type"], unique=False + ) + op.create_index( + op.f("idx_customers_owner_personnel_id"), "customers", ["owner_personnel_id"], unique=False + ) + op.create_index(op.f("idx_customers_source_no"), "customers", ["source_no"], unique=False) + op.create_table( + "personnel_plans", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "personnel_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="人员ID(personnel.id)", + ), + sa.Column( + "plan_period", + sa.String(length=32), + nullable=False, + comment="计划期间(如 2026Q1、2026-05)", + ), + sa.Column("plan_content", sa.String(length=500), nullable=False, comment="计划内容"), + sa.Column( + "target_desc", sa.String(length=255), nullable=True, comment="指标说明(文字描述)" + ), + sa.Column( + "source_no", sa.String(length=64), nullable=True, comment="T+ 原始条目号(预留)" + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint( + ["personnel_id"], + ["personnel.id"], + name=op.f("fk_personnel_plans_personnel_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_plans")), + comment="市场拓展计划表", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + op.f("idx_personnel_plans_personnel_id"), "personnel_plans", ["personnel_id"], unique=False + ) + op.create_table( + "personnel_regions", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "personnel_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="人员ID(personnel.id)", + ), + sa.Column( + "region_name", + sa.String(length=128), + nullable=False, + comment="区域名称(产品辐射地理范围)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint( + ["personnel_id"], + ["personnel.id"], + name=op.f("fk_personnel_regions_personnel_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_regions")), + comment="产品辐射区域表", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + op.f("idx_personnel_regions_personnel_id"), + "personnel_regions", + ["personnel_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """回滚迁移。 + + **注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与 + `op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引, + 单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。 + **整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。 + """ + op.drop_table("personnel_regions") + op.drop_table("personnel_plans") + op.drop_table("customers") + op.drop_table("personnel") + op.drop_table("ownership_change_logs") + op.drop_table("products") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 6115b33..527742e 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -2,16 +2,18 @@ from typing import Annotated -from fastapi import Depends +from fastapi import Depends, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session +from app.core.constants import ROLE_ADMIN from app.core.db import get_db -from app.core.exceptions import AuthError +from app.core.exceptions import AuthError, ForbiddenError from app.core.security import decode_token from app.models import User from app.repositories import user_repository from app.services import token_blacklist +from app.services.audit_service import RequestMeta # auto_error=False:令牌缺失时由我们自己抛 AuthError, # 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"} @@ -21,6 +23,29 @@ BearerCredentials = Annotated[HTTPAuthorizationCredentials | None, Depends(_bear DbSession = Annotated[Session, Depends(get_db)] +def client_ip(request: Request) -> str | None: + """取客户端 IP。 + + 当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址, + 届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头 + 由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。 + """ + return request.client.host if request.client else None + + +def get_request_meta(request: Request) -> RequestMeta: + """提取 HTTP 请求元信息,供操作日志审计使用。""" + return RequestMeta( + ip=client_ip(request), + user_agent=request.headers.get("user-agent"), + method=request.method, + path=request.url.path, + ) + + +RequestMetaDep = Annotated[RequestMeta, Depends(get_request_meta)] + + def get_raw_token(credentials: BearerCredentials) -> str: """取原始令牌字符串。登出时需要它来解析 jti。""" if credentials is None or not credentials.credentials: @@ -71,3 +96,46 @@ def _is_digits(value: object) -> bool: 令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。 """ return isinstance(value, str) and value.isdigit() + + +def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> User: + """要求当前用户具备管理员角色。 + + 需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个 + 角色,故权限判定用**角色粗粒度**即可,不必引入权限码表 —— `permissions` + 与 `role_permissions` 表已建好,等业务方给出权限清单后再启用。 + + :raises ForbiddenError: 已登录但不具备管理员角色(403) + """ + if ROLE_ADMIN not in user_repository.get_role_codes(current_user): + raise ForbiddenError("需要管理员权限") + return current_user + + +AdminUser = Annotated[User, Depends(require_admin)] + +# 仅要求登录态、不限角色(#17 答复 2026-10-19:薪酬敏感配置「查看可、修改仅 +# 管理员」——参数目录/历史/预览等只读端点用此依赖) +LoginUser = Annotated[User, Depends(get_current_user)] + + +def require_roles(*role_codes: str, message: str = "无权访问审批管理"): + """要求当前用户具备任一指定角色(依赖工厂)。 + + 与 require_admin 的差别:admin 不是天然放行——传入集合里显式含 + ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由 + service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。 + + ``message`` 允许各模块替换 403 文案(默认值保持审批模块原文案, + 避免既有调用与测试变动)。 + + :raises ForbiddenError: 已登录但不具备任一指定角色(403) + """ + + def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User: + codes = set(user_repository.get_role_codes(current_user)) + if not codes & set(role_codes): + raise ForbiddenError(message) + return current_user + + return _checker diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 692fa8a..0191053 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -8,6 +8,23 @@ from fastapi import APIRouter from app.api import health from app.api.v1 import auth +from app.api.v1.base import customers as base_customers +from app.api.v1.base import personnel as base_personnel +from app.api.v1.base import products as base_products +from app.api.v1.business import businesses as business_businesses +from app.api.v1.business import ratings as business_ratings +from app.api.v1.business import receipts as business_receipts +from app.api.v1.business import shipments as business_shipments +from app.api.v1.calc import router as calc_router +from app.api.v1.reports import router as reports_router +from app.api.v1.settlements import router as settlements_router +from app.api.v1.system import approvals as system_approvals +from app.api.v1.system import business_roles as system_business_roles +from app.api.v1.system import logs as system_logs +from app.api.v1.system import params as system_params +from app.api.v1.system import roles as system_roles +from app.api.v1.system import settings as system_settings +from app.api.v1.system import users as system_users api_router = APIRouter() @@ -17,4 +34,39 @@ api_router.include_router(health.router) # 业务接口:/api/v1/* v1_router = APIRouter(prefix="/v1") v1_router.include_router(auth.router) + +# 系统管理(需求 2.5.1):/api/v1/system/* +system_router = APIRouter(prefix="/system") +system_router.include_router(system_users.router) +system_router.include_router(system_roles.router) +system_router.include_router(system_business_roles.router) +system_router.include_router(system_params.router) +system_router.include_router(system_settings.router) +system_router.include_router(system_approvals.router) +system_router.include_router(system_logs.operation_router) +system_router.include_router(system_logs.login_router) +v1_router.include_router(system_router) + +# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问 +base_router = APIRouter(prefix="/base") +base_router.include_router(base_personnel.router) +base_router.include_router(base_products.router) +base_router.include_router(base_customers.router) +v1_router.include_router(base_router) + +# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问 +business_router = APIRouter(prefix="/business") +business_router.include_router(business_businesses.router) +business_router.include_router(business_shipments.router) +business_router.include_router(business_receipts.router) +business_router.include_router(business_ratings.router) +v1_router.include_router(business_router) + +# 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤 +v1_router.include_router(calc_router) +# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤 +v1_router.include_router(settlements_router) +# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导 +v1_router.include_router(reports_router) + api_router.include_router(v1_router) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 62150c4..07aced3 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -4,7 +4,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Request -from app.api.deps import DbSession, get_current_user, get_raw_token +from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token from app.core.response import ApiResponse, ok from app.models import User from app.repositories.user_repository import get_role_codes @@ -14,16 +14,6 @@ from app.services import auth_service, captcha_service router = APIRouter(prefix="/auth", tags=["认证"]) -def _client_ip(request: Request) -> str | None: - """取客户端 IP。 - - 当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址, - 届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头 - 由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。 - """ - return request.client.host if request.client else None - - @router.get("/captcha", response_model=ApiResponse[CaptchaOut], summary="获取图形验证码") def get_captcha() -> ApiResponse[CaptchaOut]: """签发图形验证码。无需认证。 @@ -52,7 +42,7 @@ def login(payload: LoginIn, request: Request, session: DbSession) -> ApiResponse password=payload.password, captcha_id=payload.captcha_id, captcha_code=payload.captcha_code, - ip=_client_ip(request), + ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) return ok(result, message="登录成功") diff --git a/backend/app/api/v1/base/__init__.py b/backend/app/api/v1/base/__init__.py new file mode 100644 index 0000000..f64505d --- /dev/null +++ b/backend/app/api/v1/base/__init__.py @@ -0,0 +1,8 @@ +"""基础数据管理接口(需求 2.1)。 + +挂载于 `/api/v1/base/*`,含人员管理、辐射区域、拓展计划等基础数据的维护入口。 + +与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问 +(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息, +由 service 层写入操作日志。 +""" diff --git a/backend/app/api/v1/base/customers.py b/backend/app/api/v1/base/customers.py new file mode 100644 index 0000000..26b6d67 --- /dev/null +++ b/backend/app/api/v1/base/customers.py @@ -0,0 +1,142 @@ +"""客户管理接口(需求 2.1.3)。 + +归属调整有专属端点 ``PUT /{id}/ownership``:它是唯一写归属留痕的入口, +编辑端点的 schema 不含归属字段 —— 防止出现「改了归属却无留痕」的旁路 +(原因详见 `schemas.customer.CustomerUpdateIn`)。 + +与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问 +(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息, +由 service 层写入操作日志。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, PageQuery, PageResult, ok +from app.models import User +from app.schemas.customer import ( + CustomerCreateIn, + CustomerOut, + CustomerOwnershipIn, + CustomerQuery, + CustomerUpdateIn, + OwnershipChangeLogOut, +) +from app.services import customer_service + +router = APIRouter(prefix="/customers", tags=["客户管理"]) + +# 基础数据不要求管理员(见模块 docstring),登录用户即可操作 +CurrentUser = Annotated[User, Depends(get_current_user)] + + +@router.get("", response_model=ApiResponse[PageResult[CustomerOut]], summary="客户列表") +def list_customers( + session: DbSession, + _: CurrentUser, + query: Annotated[CustomerQuery, Query()], +) -> ApiResponse[PageResult[CustomerOut]]: + """分页查询客户(keyword 对公司名称/客户负责人模糊匹配,附归属人员姓名)。""" + return ok(customer_service.list_customers(session, query)) + + +@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户") +def create_customer( + session: DbSession, + current_user: CurrentUser, + payload: CustomerCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[CustomerOut]: + """新增客户。客户分类须在注册表内;「公司名称+负责人」组合唯一。""" + return ok( + customer_service.create_customer(session, payload, operator=current_user, meta=meta), + message="客户已创建", + ) + + +@router.get("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="客户详情") +def get_customer( + session: DbSession, + _: CurrentUser, + customer_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[CustomerOut]: + """按主键查询客户详情。""" + return ok(customer_service.get_customer(session, customer_id)) + + +@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户") +def update_customer( + session: DbSession, + current_user: CurrentUser, + customer_id: Annotated[int, Path(ge=1)], + payload: CustomerUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[CustomerOut]: + """编辑客户档案。 + + **不含归属** —— 归属调整统一走 ``PUT /{id}/ownership``;编辑表单即使 + 提交 ownerPersonnelId 也会被忽略,归属以归属端点为准。 + """ + return ok( + customer_service.update_customer( + session, customer_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户") +def delete_customer( + session: DbSession, + current_user: CurrentUser, + customer_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除客户(物理删除)。 + + 已存在关联业务时返回 400(先处理相关业务单据)。 + """ + customer_service.delete_customer(session, customer_id, operator=current_user, meta=meta) + return ok(message="客户已删除") + + +@router.put( + "/{customer_id}/ownership", + response_model=ApiResponse[CustomerOut], + summary="归属变更", +) +def change_ownership( + session: DbSession, + current_user: CurrentUser, + customer_id: Annotated[int, Path(ge=1)], + payload: CustomerOwnershipIn, + meta: RequestMetaDep, +) -> ApiResponse[CustomerOut]: + """调整客户归属(唯一写归属留痕的入口)。 + + 只影响变更后核算,不回溯历史(对照表 #19);新旧归属人员ID任一为 null + 表示清空归属。新旧归属相同报「归属未变化」。 + """ + return ok( + customer_service.change_ownership( + session, customer_id, payload, operator=current_user, meta=meta + ), + message="归属已调整", + ) + + +@router.get( + "/{customer_id}/ownership-logs", + response_model=ApiResponse[PageResult[OwnershipChangeLogOut]], + summary="归属变更留痕", +) +def list_ownership_logs( + session: DbSession, + _: CurrentUser, + customer_id: Annotated[int, Path(ge=1)], + query: Annotated[PageQuery, Query()], +) -> ApiResponse[PageResult[OwnershipChangeLogOut]]: + """查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。""" + return ok(customer_service.list_ownership_logs(session, customer_id, query)) diff --git a/backend/app/api/v1/base/personnel.py b/backend/app/api/v1/base/personnel.py new file mode 100644 index 0000000..2210514 --- /dev/null +++ b/backend/app/api/v1/base/personnel.py @@ -0,0 +1,143 @@ +"""人员管理接口(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。 + +基础数据属业务操作,**登录即可访问**(不要求管理员,对照系统管理模块)。 +校验规则(岗位/经理合法性、删除保护、子表全量替换的防御写法)见 +`personnel_service` 模块 docstring —— 前端提示之外,后端校验是唯一防线。 + +写操作经 `RequestMetaDep` 带入请求元信息由 service 层写入操作日志。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, PageResult, ok +from app.models import User +from app.schemas.personnel import ( + PersonnelCreateIn, + PersonnelDetailOut, + PersonnelOut, + PersonnelQuery, + PersonnelStatusIn, + PlanReplaceIn, + RegionReplaceIn, +) +from app.services import personnel_service + +# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser) +CurrentUser = Annotated[User, Depends(get_current_user)] + +router = APIRouter(prefix="/personnel", tags=["人员管理"]) + + +@router.get("", response_model=ApiResponse[PageResult[PersonnelOut]], summary="人员列表") +def list_personnel( + session: DbSession, + _: CurrentUser, + query: Annotated[PersonnelQuery, Query()], +) -> ApiResponse[PageResult[PersonnelOut]]: + """分页查询人员(含离职)。姓名模糊匹配,可按状态与岗位筛选。""" + return ok(personnel_service.list_personnel(session, query)) + + +@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员") +def create_personnel( + session: DbSession, + current_user: CurrentUser, + payload: PersonnelCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[PersonnelOut]: + """新增人员,默认在职。岗位须存在且启用;直属经理不能是自己且须在职。""" + return ok( + personnel_service.create_personnel(session, payload, operator=current_user, meta=meta), + message="人员已创建", + ) + + +@router.get("/{personnel_id}", response_model=ApiResponse[PersonnelDetailOut], summary="人员详情") +def get_personnel( + session: DbSession, + _: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[PersonnelDetailOut]: + """人员详情,附辐射区域与拓展计划,供编辑抽屉一次回显。""" + return ok(personnel_service.get_detail(session, personnel_id)) + + +@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员") +def update_personnel( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + payload: PersonnelCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[PersonnelOut]: + """编辑人员(全量提交,不含状态)。校验规则同新增。""" + return ok( + personnel_service.update_personnel( + session, personnel_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员") +def delete_personnel( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除人员。被团队成员或客户归属引用时禁删,应改为离职。""" + personnel_service.delete_personnel(session, personnel_id, operator=current_user, meta=meta) + return ok(message="人员已删除") + + +@router.put( + "/{personnel_id}/status", + response_model=ApiResponse[PersonnelOut], + summary="设为在职/离职", +) +def set_status( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + payload: PersonnelStatusIn, + meta: RequestMetaDep, +) -> ApiResponse[PersonnelOut]: + """在职/离职切换。离职人员不可再被业务与客户归属引用。""" + result = personnel_service.set_status( + session, personnel_id, payload.status, operator=current_user, meta=meta + ) + return ok(result, message="已设为在职" if payload.status == 1 else "已设为离职") + + +@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域") +def replace_regions( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + payload: RegionReplaceIn, + meta: RequestMetaDep, +) -> ApiResponse[None]: + """全量替换辐射区域,空列表即清空。""" + personnel_service.replace_regions( + session, personnel_id, payload, operator=current_user, meta=meta + ) + return ok(message="已保存") + + +@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划") +def replace_plans( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + payload: PlanReplaceIn, + meta: RequestMetaDep, +) -> ApiResponse[None]: + """全量替换拓展计划,空列表即清空。""" + personnel_service.replace_plans( + session, personnel_id, payload, operator=current_user, meta=meta + ) + return ok(message="已保存") diff --git a/backend/app/api/v1/base/products.py b/backend/app/api/v1/base/products.py new file mode 100644 index 0000000..4255248 --- /dev/null +++ b/backend/app/api/v1/base/products.py @@ -0,0 +1,112 @@ +"""产品管理接口(需求 2.1.2:产品主数据维护)。 + +产品是折算阈值的行级锚点(product_category 决定 30%/20% 折算阈值), +类别值域由后端校验 —— 前端下拉只是展示约束,后端校验是唯一防线。 + +**数据提取(POST /products/extract)暂不提供**:它依赖业务明细表 +(V5 迁移),待业务管理任务完成后补充。 + +基础数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser); +写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, PageResult, ok +from app.models import User +from app.schemas.product import ( + ProductCreateIn, + ProductOut, + ProductQuery, + ProductStatusIn, + ProductUpdateIn, +) +from app.services import product_service + +CurrentUser = Annotated[User, Depends(get_current_user)] + +router = APIRouter(prefix="/products", tags=["产品管理"]) + + +@router.get("", response_model=ApiResponse[PageResult[ProductOut]], summary="产品列表") +def list_products( + session: DbSession, + _: CurrentUser, + query: Annotated[ProductQuery, Query()], +) -> ApiResponse[PageResult[ProductOut]]: + """分页查询产品(含停用)。keyword 对名称或规格模糊匹配。""" + return ok(product_service.list_products(session, query)) + + +@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品") +def create_product( + session: DbSession, + current_user: CurrentUser, + payload: ProductCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[ProductOut]: + """新增产品。类别须在合法值域内;新产品默认启用。""" + return ok( + product_service.create_product(session, payload, operator=current_user, meta=meta), + message="产品已创建", + ) + + +@router.get("/{product_id}", response_model=ApiResponse[ProductOut], summary="产品详情") +def get_product( + session: DbSession, + _: CurrentUser, + product_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[ProductOut]: + """按主键查询产品详情。""" + return ok(product_service.get_product(session, product_id)) + + +@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品") +def update_product( + session: DbSession, + current_user: CurrentUser, + product_id: Annotated[int, Path(ge=1)], + payload: ProductUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[ProductOut]: + """编辑产品基本信息(全量替换,不含状态;启用/停用走独立端点)。""" + return ok( + product_service.update_product( + session, product_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品") +def delete_product( + session: DbSession, + current_user: CurrentUser, + product_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """物理删除产品(仅限误录数据,常规下架用停用)。 + + 被业务明细/发货明细引用时返回 400(引导改走停用)。 + """ + product_service.delete_product(session, product_id, operator=current_user, meta=meta) + return ok(message="产品已删除") + + +@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品") +def set_status( + session: DbSession, + current_user: CurrentUser, + product_id: Annotated[int, Path(ge=1)], + payload: ProductStatusIn, + meta: RequestMetaDep, +) -> ApiResponse[ProductOut]: + """启用或停用产品(1启用 0停用)。""" + result = product_service.set_status( + session, product_id, payload.status, operator=current_user, meta=meta + ) + return ok(result, message="已启用" if payload.status == 1 else "已停用") diff --git a/backend/app/api/v1/business/__init__.py b/backend/app/api/v1/business/__init__.py new file mode 100644 index 0000000..91b1dc7 --- /dev/null +++ b/backend/app/api/v1/business/__init__.py @@ -0,0 +1,8 @@ +"""业务数据管理接口(需求 2.2)。 + +挂载于 `/api/v1/business/*`,含业务单据维护、客户评级等级配置等业务数据入口。 + +与 `api/v1/system` 的权限分工:业务数据是**业务操作**,登录即可访问 +(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息, +由 service 层写入操作日志。 +""" diff --git a/backend/app/api/v1/business/businesses.py b/backend/app/api/v1/business/businesses.py new file mode 100644 index 0000000..440babd --- /dev/null +++ b/backend/app/api/v1/business/businesses.py @@ -0,0 +1,177 @@ +"""业务管理接口(需求 2.2.1/2.2.4)。 + +校验规则(编号生成、状态机、归属留痕、汇总重算)见 `business_service` +模块 docstring;基础数据属业务操作,登录即可访问。 +""" + +from datetime import date +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, PageQuery, PageResult, ok +from app.models import User +from app.schemas.business import ( + BusinessCreateIn, + BusinessDetailOut, + BusinessOut, + BusinessOwnershipIn, + BusinessQuery, + BusinessStatusIn, + BusinessStatusLogOut, + BusinessUpdateIn, + GrossProfitOut, + OwnershipChangeLogOut, +) +from app.services import business_service + +# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser) +CurrentUser = Annotated[User, Depends(get_current_user)] + +router = APIRouter(prefix="/businesses", tags=["业务管理"]) + + +@router.get("", response_model=ApiResponse[PageResult[BusinessOut]], summary="业务列表") +def list_businesses( + session: DbSession, + _: CurrentUser, + query: Annotated[BusinessQuery, Query()], +) -> ApiResponse[PageResult[BusinessOut]]: + """分页查询业务。编号/公司名称模糊,可按状态/客户/归属人员/日期范围筛选。""" + return ok(business_service.list_businesses(session, query)) + + +@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务") +def create_business( + session: DbSession, + current_user: CurrentUser, + payload: BusinessCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[BusinessOut]: + """创建业务 + 明细行(≥1)。业务编号自动生成;创建写首条状态留痕。""" + return ok( + business_service.create_business(session, payload, operator=current_user, meta=meta), + message="业务已创建", + ) + + +@router.get("/{business_id}", response_model=ApiResponse[BusinessDetailOut], summary="业务详情") +def get_business( + session: DbSession, + _: CurrentUser, + business_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[BusinessDetailOut]: + """业务详情,附产品明细行,供编辑抽屉一次回显。""" + return ok(business_service.get_detail(session, business_id)) + + +@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务") +def update_business( + session: DbSession, + current_user: CurrentUser, + business_id: Annotated[int, Path(ge=1)], + payload: BusinessUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[BusinessOut]: + """编辑业务(明细全量替换并重算汇总)。归属不在此端点调整,走 /ownership。""" + return ok( + business_service.update_business( + session, business_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务") +def delete_business( + session: DbSession, + current_user: CurrentUser, + business_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除业务。有发货或回款记录时禁删,应改为已终止。""" + business_service.delete_business(session, business_id, operator=current_user, meta=meta) + return ok(message="业务已删除") + + +@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转") +def change_status( + session: DbSession, + current_user: CurrentUser, + business_id: Annotated[int, Path(ge=1)], + payload: BusinessStatusIn, + meta: RequestMetaDep, +) -> ApiResponse[BusinessOut]: + """状态流转:执行中→已完成/已终止;终态不可再变更;终止必填原因。""" + return ok( + business_service.change_status( + session, business_id, payload, operator=current_user, meta=meta + ), + message="状态已变更", + ) + + +@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属") +def change_ownership( + session: DbSession, + current_user: CurrentUser, + business_id: Annotated[int, Path(ge=1)], + payload: BusinessOwnershipIn, + meta: RequestMetaDep, +) -> ApiResponse[BusinessOut]: + """调整负责/协助营销人员,同事务写归属变更留痕(不回溯历史)。""" + return ok( + business_service.change_ownership( + session, business_id, payload, operator=current_user, meta=meta + ), + message="归属已调整", + ) + + +@router.get( + "/{business_id}/ownership-logs", + response_model=ApiResponse[PageResult[OwnershipChangeLogOut]], + summary="归属变更记录", +) +def list_ownership_logs( + session: DbSession, + _: CurrentUser, + business_id: Annotated[int, Path(ge=1)], + query: Annotated[PageQuery, Query()], +) -> ApiResponse[PageResult[OwnershipChangeLogOut]]: + """某业务的归属变更留痕(分页,按时间倒序)。""" + return ok(business_service.list_ownership_logs(session, business_id, query)) + + +@router.get( + "/{business_id}/status-logs", + response_model=ApiResponse[PageResult[BusinessStatusLogOut]], + summary="状态流转记录", +) +def list_status_logs( + session: DbSession, + _: CurrentUser, + business_id: Annotated[int, Path(ge=1)], + query: Annotated[PageQuery, Query()], +) -> ApiResponse[PageResult[BusinessStatusLogOut]]: + """某业务的状态流转时间线(分页,按时间正序,从创建起展示)。""" + return ok(business_service.list_status_logs(session, business_id, query)) + + +@router.get( + "/{business_id}/gross-profit", + response_model=ApiResponse[GrossProfitOut], + summary="毛利润数据视图", +) +def gross_profit( + session: DbSession, + _: CurrentUser, + business_id: Annotated[int, Path(ge=1)], + as_of: Annotated[date | None, Query()] = None, +) -> ApiResponse[GrossProfitOut]: + """按产品逐行实时计算毛利润与折算额(不含运费口径,对照表 #2)。 + + 阈值按 asOf(默认今日)取参数快照;M3 核算实际按回款到账日取参。 + """ + return ok(business_service.gross_profit(session, business_id, as_of)) diff --git a/backend/app/api/v1/business/ratings.py b/backend/app/api/v1/business/ratings.py new file mode 100644 index 0000000..540ab4f --- /dev/null +++ b/backend/app/api/v1/business/ratings.py @@ -0,0 +1,74 @@ +"""客户评级等级配置接口(需求 2.2.5)。 + +评级计算器的等级映射判定表:判定按 sort_order 升序取首个 min_score ≤ 总分 +的等级,max_score 仅展示。区间合法(0≤min≤max≤100)与互不重叠由后端校验 +—— 前端表单只是展示约束,后端校验是唯一防线。 + +列表不分页全量返回:等级映射是判定表,数量个位数,分页无意义。 +业务数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser); +写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, ok +from app.models import User +from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn +from app.services import rating_service + +CurrentUser = Annotated[User, Depends(get_current_user)] + +router = APIRouter(prefix="/rating-grades", tags=["客户评级"]) + + +@router.get("", response_model=ApiResponse[list[RatingGradeOut]], summary="评级等级列表") +def list_grades( + session: DbSession, + _: CurrentUser, +) -> ApiResponse[list[RatingGradeOut]]: + """全量等级列表,按判定顺序(sort_order 升序)返回,不分页。""" + return ok(rating_service.list_grades(session)) + + +@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级") +def create_grade( + session: DbSession, + current_user: CurrentUser, + payload: RatingGradeCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[RatingGradeOut]: + """新增评级等级。区间须 0≤min≤max≤100 且与既有等级不重叠。""" + return ok( + rating_service.create_grade(session, payload, operator=current_user, meta=meta), + message="评级等级已创建", + ) + + +@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级") +def update_grade( + session: DbSession, + current_user: CurrentUser, + grade_id: Annotated[int, Path(ge=1)], + payload: RatingGradeUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[RatingGradeOut]: + """编辑评级等级(全量替换)。区间须合法且与其他等级不重叠。""" + return ok( + rating_service.update_grade(session, grade_id, payload, operator=current_user, meta=meta), + message="已保存", + ) + + +@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级") +def delete_grade( + session: DbSession, + current_user: CurrentUser, + grade_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """物理删除评级等级(等级表无下游引用,允许清空,理由见 service 说明)。""" + rating_service.delete_grade(session, grade_id, operator=current_user, meta=meta) + return ok(message="评级等级已删除") diff --git a/backend/app/api/v1/business/receipts.py b/backend/app/api/v1/business/receipts.py new file mode 100644 index 0000000..7b5dc71 --- /dev/null +++ b/backend/app/api/v1/business/receipts.py @@ -0,0 +1,89 @@ +"""回款管理接口(需求 2.2.3)。 + +回款按业务登记到账款项,批次号自动生成(HK 前缀)。校验规则(发货 +批次同业务强校验、已核算禁改禁删、汇总重算)见 `receipt_service` 模块 +docstring;基础数据属业务操作,登录即可访问。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, PageResult, ok +from app.models import User +from app.schemas.receipt import ( + ReceiptCreateIn, + ReceiptOut, + ReceiptQuery, + ReceiptUpdateIn, +) +from app.services import receipt_service + +# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser) +CurrentUser = Annotated[User, Depends(get_current_user)] + +router = APIRouter(prefix="/receipts", tags=["回款管理"]) + + +@router.get("", response_model=ApiResponse[PageResult[ReceiptOut]], summary="回款列表") +def list_receipts( + session: DbSession, + _: CurrentUser, + query: Annotated[ReceiptQuery, Query()], +) -> ApiResponse[PageResult[ReceiptOut]]: + """分页查询回款。附业务号/发货单号与回款天数派生值,可按业务/日期/抵顶/核算状态筛选。""" + return ok(receipt_service.list_receipts(session, query)) + + +@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款") +def create_receipt( + session: DbSession, + current_user: CurrentUser, + payload: ReceiptCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[ReceiptOut]: + """新增回款。批次号自动生成;发货批次须与本回款同属一个业务;业务须执行中。""" + return ok( + receipt_service.create_receipt(session, payload, operator=current_user, meta=meta), + message="回款已登记", + ) + + +@router.get("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="回款详情") +def get_receipt( + session: DbSession, + _: CurrentUser, + receipt_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[ReceiptOut]: + """回款详情(含回款天数派生值,未指定发货批次时为空)。""" + return ok(receipt_service.get_detail(session, receipt_id)) + + +@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款") +def update_receipt( + session: DbSession, + current_user: CurrentUser, + receipt_id: Annotated[int, Path(ge=1)], + payload: ReceiptUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[ReceiptOut]: + """编辑回款(全量替换并重算合计)。已核算回款禁止修改;发货批次重新校验同业务。""" + return ok( + receipt_service.update_receipt( + session, receipt_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款") +def delete_receipt( + session: DbSession, + current_user: CurrentUser, + receipt_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除回款。已核算回款禁止删除;业务已完成或已终止时禁删。""" + receipt_service.delete_receipt(session, receipt_id, operator=current_user, meta=meta) + return ok(message="回款已删除") diff --git a/backend/app/api/v1/business/shipments.py b/backend/app/api/v1/business/shipments.py new file mode 100644 index 0000000..bd0680c --- /dev/null +++ b/backend/app/api/v1/business/shipments.py @@ -0,0 +1,89 @@ +"""发货管理接口(需求 2.2.2)。 + +校验规则(状态门槛、行金额固化、双重汇总回写、删除保护)见 +`shipment_service` 模块 docstring;业务数据属业务操作,登录即可访问。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, PageResult, ok +from app.models import User +from app.schemas.shipment import ( + ShipmentCreateIn, + ShipmentDetailOut, + ShipmentOut, + ShipmentQuery, + ShipmentUpdateIn, +) +from app.services import shipment_service + +# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser) +CurrentUser = Annotated[User, Depends(get_current_user)] + +router = APIRouter(prefix="/shipments", tags=["发货管理"]) + + +@router.get("", response_model=ApiResponse[PageResult[ShipmentOut]], summary="发货单列表") +def list_shipments( + session: DbSession, + _: CurrentUser, + query: Annotated[ShipmentQuery, Query()], +) -> ApiResponse[PageResult[ShipmentOut]]: + """分页查询发货单(附业务编号),可按业务/发货日期范围筛选。""" + return ok(shipment_service.list_shipments(session, query)) + + +@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单") +def create_shipment( + session: DbSession, + current_user: CurrentUser, + payload: ShipmentCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[ShipmentOut]: + """创建发货单(明细 ≥1)。业务须处于执行中;锁业务行后生成 SH 编号。""" + return ok( + shipment_service.create_shipment(session, payload, operator=current_user, meta=meta), + message="发货单已创建", + ) + + +@router.get("/{shipment_id}", response_model=ApiResponse[ShipmentDetailOut], summary="发货单详情") +def get_shipment( + session: DbSession, + _: CurrentUser, + shipment_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[ShipmentDetailOut]: + """发货单详情,附产品明细行,供编辑抽屉一次回显。""" + return ok(shipment_service.get_detail(session, shipment_id)) + + +@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单") +def update_shipment( + session: DbSession, + current_user: CurrentUser, + shipment_id: Annotated[int, Path(ge=1)], + payload: ShipmentUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[ShipmentOut]: + """编辑发货单(明细全量替换并重算汇总)。终态业务的发货单禁改。""" + return ok( + shipment_service.update_shipment( + session, shipment_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单") +def delete_shipment( + session: DbSession, + current_user: CurrentUser, + shipment_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除发货单(连带明细行并回写业务汇总)。被回款批次指定时禁删。""" + shipment_service.delete_shipment(session, shipment_id, operator=current_user, meta=meta) + return ok(message="发货单已删除") diff --git a/backend/app/api/v1/calc.py b/backend/app/api/v1/calc.py new file mode 100644 index 0000000..3948b44 --- /dev/null +++ b/backend/app/api/v1/calc.py @@ -0,0 +1,107 @@ +"""绩效核算接口(M3 ④):台账查询与单笔核算触发。 + +核算动作 = 把回款固化为台账行(每笔回款仅一次;重算走独立入口并授权)。 +准入:读=登录即可;写(核算/重算/批量)=管理员与营销后勤 +(权限清单待澄清 #6,答复后细化)。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query +from pydantic import BaseModel, Field + +from app.api.deps import DbSession, RequestMetaDep, require_roles +from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS +from app.core.response import ApiResponse, PageResult, ok +from app.models import User +from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery +from app.services import calc_service + +router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"]) + +_CalcWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))] +# 重算是授权动作:管理员/财务(营销后勤不可重算) +_RecalcUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE))] + + +@router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账") +def list_ledger( + session: DbSession, + query: Annotated[LedgerQuery, Query()], +) -> ApiResponse[PageResult[LedgerOut]]: + """分页查询绩效核算台账(默认仅生效行,可筛选历史版本)。""" + return ok(calc_service.list_ledger_page(session, query)) + + +@router.get("/ledger/{ledger_id}", response_model=ApiResponse[LedgerOut], summary="台账详情") +def get_ledger( + session: DbSession, + ledger_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[LedgerOut]: + """台账行详情(含计算快照)。""" + return ok(calc_service.get_ledger(session, ledger_id)) + + +@router.post( + "/receipts/{receipt_id}/calc", + response_model=ApiResponse[CalcReceiptOut], + summary="核算回款", +) +def calc_receipt( + receipt_id: Annotated[int, Path(ge=1)], + session: DbSession, + current_user: _CalcWriter, + meta: RequestMetaDep, +) -> ApiResponse[CalcReceiptOut]: + """把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。""" + return ok( + calc_service.calc_receipt(session, receipt_id, operator=current_user, meta=meta), + message="核算完成", + ) + + +class RecalcIn(BaseModel): + """重算入参:原因必填(重算授权留痕)。""" + + reason: str = Field(min_length=1, max_length=255, description="重算原因") + + +class BatchIn(BaseModel): + """批量核算入参。""" + + receipt_ids: list[int] = Field(min_length=1, description="回款 ID 列表") + + +@router.post( + "/receipts/{receipt_id}/recalc", + response_model=ApiResponse[CalcReceiptOut], + summary="重算回款", +) +def recalc_receipt( + receipt_id: Annotated[int, Path(ge=1)], + payload: RecalcIn, + session: DbSession, + current_user: _RecalcUser, + meta: RequestMetaDep, +) -> ApiResponse[CalcReceiptOut]: + """重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。""" + return ok( + calc_service.recalc_receipt( + session, receipt_id, payload.reason, operator=current_user, meta=meta + ), + message="重算完成", + ) + + +@router.post("/batch", response_model=ApiResponse[list[dict]], summary="批量核算") +def batch_calc( + payload: BatchIn, + session: DbSession, + current_user: _CalcWriter, + meta: RequestMetaDep, +) -> ApiResponse[list[dict]]: + """批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。""" + return ok( + calc_service.batch_calc(session, payload.receipt_ids, operator=current_user, meta=meta), + message="批量核算完成", + ) diff --git a/backend/app/api/v1/reports.py b/backend/app/api/v1/reports.py new file mode 100644 index 0000000..35dc384 --- /dev/null +++ b/backend/app/api/v1/reports.py @@ -0,0 +1,178 @@ +"""报表统计接口(M4 ⑤):四张报表只读查询 + Excel 导出。 + +准入:管理员/财务/领导(FR-030/031 明确;FR-029 的营销后勤「授权范围内」 +待权限清单 #6,暂不开放)。前后端双层:前端 allowedRoles 控菜单,本层 +require_roles 兜底。 + +导出返回文件流(非统一信封),沿用参数导出先例:服务端文件名为 ASCII +(HTTP 头按 latin-1 编码,中文名会 UnicodeEncodeError),中文名由前端 +``link.download`` 指定。 +""" + +from datetime import date +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, Path, Query +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from app.api.deps import DbSession, RequestMetaDep, require_roles +from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER +from app.core.response import ApiResponse, ok +from app.models import User +from app.schemas.report import ( + BusinessStatsQuery, + BusinessStatsRow, + DepartmentSummaryOut, + DepartmentSummaryQuery, + PaymentDetailQuery, + PaymentDetailRow, + PaymentStatsOut, + PaymentStatsQuery, + PersonalDetailQuery, + PersonalDetailRow, + ReportPage, +) +from app.services import report_service + +router = APIRouter(prefix="/reports", tags=["绩效工资 · 报表统计"]) + +_ReportViewer = Annotated[ + User, + Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问报表统计")), +] + + +@router.get( + "/personal-detail", + response_model=ApiResponse[ReportPage[PersonalDetailRow]], + summary="个人绩效工资明细表", +) +def personal_detail( + session: DbSession, + _: _ReportViewer, + query: Annotated[PersonalDetailQuery, Query()], +) -> ApiResponse[ReportPage[PersonalDetailRow]]: + """按人员/月份/客户类型/产品类别/岗位查明细(口径与台账一致)。""" + return ok(report_service.list_personal_detail(session, query)) + + +@router.get( + "/department-summary", + response_model=ApiResponse[DepartmentSummaryOut], + summary="部门绩效工资汇总表", +) +def department_summary( + session: DbSession, + _: _ReportViewer, + query: Annotated[DepartmentSummaryQuery, Query()], +) -> ApiResponse[DepartmentSummaryOut]: + """按经理团队/月份汇总(团队基数与月结聚合行同源)。""" + return ok(report_service.department_summary(session, query)) + + +@router.get( + "/business-stats", + response_model=ApiResponse[ReportPage[BusinessStatsRow]], + summary="业务统计表", +) +def business_stats( + session: DbSession, + _: _ReportViewer, + query: Annotated[BusinessStatsQuery, Query()], +) -> ApiResponse[ReportPage[BusinessStatsRow]]: + """按业务查询发货、回款、绩效(回款/绩效为业务累计值)。""" + return ok(report_service.business_stats(session, query)) + + +@router.get( + "/payment-stats", + response_model=ApiResponse[PaymentStatsOut], + summary="回款情况统计表", +) +def payment_stats( + session: DbSession, + _: _ReportViewer, + query: Annotated[PaymentStatsQuery, Query()], +) -> ApiResponse[PaymentStatsOut]: + """按回款天数区间/客户类型/产品类别统计(空桶恒在且为 0)。""" + return ok(report_service.payment_stats(session, query)) + + +@router.get( + "/payment-detail", + response_model=ApiResponse[ReportPage[PaymentDetailRow]], + summary="回款区间下钻", +) +def payment_detail( + session: DbSession, + _: _ReportViewer, + query: Annotated[PaymentDetailQuery, Query()], +) -> ApiResponse[ReportPage[PaymentDetailRow]]: + """区间桶下钻到单笔台账行(含回款金额与批次号)。""" + return ok(report_service.payment_detail(session, query)) + + +@router.get( + "/payment-day-buckets", response_model=ApiResponse[list[dict]], summary="回款天数区间档位" +) +def payment_day_buckets(_: _ReportViewer) -> ApiResponse[list[dict]]: + """区间档位元数据(前端渲染下拉/表头,禁止前端硬编码档位)。""" + return ok(report_service.get_payment_day_buckets()) + + +class ExportQuery(BaseModel): + """导出通用筛选参数(按报表编码取用相关字段,未用字段忽略)。""" + + settlement_month: str | None = None + personnel_id: int | None = None + customer_type: str | None = None + product_category: str | None = None + role_code: str | None = None + manager_id: int | None = None + keyword: str | None = None + customer_id: int | None = None + owner_personnel_id: int | None = None + status: int | None = None + date_from: date | None = None + date_to: date | None = None + + +@router.get("/{code}/export", summary="导出报表 Excel") +def export_report( + code: Annotated[ + Literal["personal-detail", "department-summary", "business-stats", "payment-stats"], + Path(description="报表编码"), + ], + session: DbSession, + current_user: _ReportViewer, + meta: RequestMetaDep, + query: Annotated[ExportQuery, Query()], +) -> StreamingResponse: + """导出 xlsx(含公司页眉/财务复核栏/领导签字栏),行为入操作日志。""" + content, filename = report_service.export_report( + session, + code, + _build_export_query(code, query), + operator=current_user, + meta=meta, + ) + return StreamingResponse( + iter([content]), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": (f'attachment; filename="{filename}_{date.today():%Y%m%d}.xlsx"') + }, + ) + + +def _build_export_query(code: str, query: ExportQuery): + """导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。""" + data = query.model_dump() + if code == "personal-detail": + return PersonalDetailQuery(**data) + if code == "department-summary": + return DepartmentSummaryQuery(**data) + if code == "business-stats": + return BusinessStatsQuery(**data) + return PaymentStatsQuery(**data) diff --git a/backend/app/api/v1/settlements.py b/backend/app/api/v1/settlements.py new file mode 100644 index 0000000..427795a --- /dev/null +++ b/backend/app/api/v1/settlements.py @@ -0,0 +1,99 @@ +"""月度结算接口(M3 ④):生成/提交审批/锁定与调整单。 + +准入:读=登录即可;写(生成/提交/调整单)=管理员与营销后勤 +(权限清单待澄清 #6,答复后细化)。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, require_roles +from app.core.constants import ROLE_ADMIN, ROLE_MARKETING_OPS +from app.core.response import ApiResponse, PageResult, ok +from app.models import User +from app.schemas.settlement import ( + AdjustmentCreateIn, + SettlementDetailOut, + SettlementGenerateIn, + SettlementOut, + SettlementQuery, +) +from app.services import settlement_service + +router = APIRouter(prefix="/settlements", tags=["绩效工资 · 月度结算"]) + +_SettlementWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))] + + +@router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照") +def generate( + payload: SettlementGenerateIn, + session: DbSession, + current_user: _SettlementWriter, + meta: RequestMetaDep, +) -> ApiResponse[SettlementDetailOut]: + """按确认回款口径生成指定月份的结算快照(草稿)。""" + return ok( + settlement_service.generate(session, payload.month, operator=current_user, meta=meta), + message="月结已生成", + ) + + +@router.get("", response_model=ApiResponse[PageResult[SettlementOut]], summary="月结列表") +def list_settlements( + session: DbSession, + query: Annotated[SettlementQuery, Query()], +) -> ApiResponse[PageResult[SettlementOut]]: + """分页查询月结(按月份倒序)。""" + return ok(settlement_service.list_page(session, query)) + + +@router.get("/{settlement_id}", response_model=ApiResponse[SettlementDetailOut], summary="月结详情") +def get_settlement( + session: DbSession, + settlement_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[SettlementDetailOut]: + """月结详情(含按人明细)。""" + return ok(settlement_service.get_detail(session, settlement_id)) + + +@router.post( + "/{settlement_id}/submit", + response_model=ApiResponse[SettlementDetailOut], + summary="提交审批", +) +def submit( + settlement_id: Annotated[int, Path(ge=1)], + session: DbSession, + current_user: _SettlementWriter, + meta: RequestMetaDep, +) -> ApiResponse[SettlementDetailOut]: + """提交审批:开关关闭=自动通过并锁定;开启=待财务复核。""" + return ok( + settlement_service.submit(session, settlement_id, operator=current_user, meta=meta), + message="已提交", + ) + + +# ==================== 调整单(已锁定月份的补发/扣减) ==================== + + +@router.post("/adjustments", response_model=ApiResponse[dict], summary="新建调整单") +def create_adjustment( + payload: AdjustmentCreateIn, + session: DbSession, + current_user: _SettlementWriter, + meta: RequestMetaDep, +) -> ApiResponse[dict]: + """新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。""" + return ok( + settlement_service.create_adjustment(session, payload, operator=current_user, meta=meta), + message="调整单已创建", + ) + + +@router.get("/adjustments", response_model=ApiResponse[list[dict]], summary="调整单列表") +def list_adjustments(session: DbSession) -> ApiResponse[list[dict]]: + """调整单列表(一期不分页,内部系统量级有限)。""" + return ok(settlement_service.list_adjustments(session)) diff --git a/backend/app/api/v1/system/__init__.py b/backend/app/api/v1/system/__init__.py new file mode 100644 index 0000000..add9495 --- /dev/null +++ b/backend/app/api/v1/system/__init__.py @@ -0,0 +1,7 @@ +"""系统管理路由(需求 2.5.1)。 + +含用户管理、角色管理、日志查询(操作日志 + 登录日志)四部分,全部要求管理员权限。 + +与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录, +以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。 +""" diff --git a/backend/app/api/v1/system/approvals.py b/backend/app/api/v1/system/approvals.py new file mode 100644 index 0000000..c1cf1f5 --- /dev/null +++ b/backend/app/api/v1/system/approvals.py @@ -0,0 +1,67 @@ +"""审批管理接口(2026-10-15 复核审批补充需求)。 + +准入:admin/财务/领导可见(营销后勤暂不可见——权限清单待澄清 #6, +答复后放开为「提交人可见本人单据」)。节点级准入由 service 按单据 +状态二次校验(status=1 仅财务、status=2 仅领导,admin 不代办)。 +提单不经本模块:由业务写路径(创建/编辑带二次分配方案)自动触发。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, require_roles +from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER +from app.core.response import ApiResponse, PageResult, ok +from app.models import User +from app.schemas.approval import ( + ApprovalActionIn, + ApprovalOrderDetailOut, + ApprovalOrderOut, + ApprovalQuery, +) +from app.services import approval_service + +router = APIRouter(prefix="/approvals", tags=["系统管理 · 审批管理"]) + +# 模块准入:可见集合(节点准入在 service 层按状态二次判定) +_ApprovalUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER))] + + +@router.get("", response_model=ApiResponse[PageResult[ApprovalOrderOut]], summary="审批单列表") +def list_orders( + session: DbSession, + _: _ApprovalUser, + query: Annotated[ApprovalQuery, Query()], +) -> ApiResponse[PageResult[ApprovalOrderOut]]: + """分页查询审批单。""" + return ok(approval_service.list_orders(session, query)) + + +@router.get("/{order_id}", response_model=ApiResponse[ApprovalOrderDetailOut], summary="审批详情") +def get_order( + session: DbSession, + _: _ApprovalUser, + order_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[ApprovalOrderDetailOut]: + """审批单详情(含节点时间线)。""" + return ok(approval_service.get_order(session, order_id)) + + +@router.post( + "/{order_id}/action", + response_model=ApiResponse[ApprovalOrderDetailOut], + summary="节点动作(通过/驳回)", +) +def act_on_order( + payload: ApprovalActionIn, + session: DbSession, + current_user: _ApprovalUser, + meta: RequestMetaDep, + order_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[ApprovalOrderDetailOut]: + """财务复核 / 领导审核;一驳全驳,驳回须填意见。""" + return ok( + approval_service.act(session, order_id, payload, operator=current_user, meta=meta), + message="已提交", + ) diff --git a/backend/app/api/v1/system/business_roles.py b/backend/app/api/v1/system/business_roles.py new file mode 100644 index 0000000..62e9b96 --- /dev/null +++ b/backend/app/api/v1/system/business_roles.py @@ -0,0 +1,122 @@ +"""业务岗位角色管理接口(需求 #28:岗位可扩展 + 绑定公式模板)。 + +保护规则(内置岗位禁删禁停、编码不可改、模板须在注册表内)见 +`business_role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。 + +全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service +层写入操作日志。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Path, Query + +from app.api.deps import AdminUser, DbSession, RequestMetaDep +from app.core.response import ApiResponse, PageResult, ok +from app.schemas.business_role import ( + BusinessRoleCreateIn, + BusinessRoleOut, + BusinessRoleQuery, + BusinessRoleStatusIn, + BusinessRoleUpdateIn, + TemplateOptionOut, +) +from app.services import business_role_service + +router = APIRouter(prefix="/business-roles", tags=["系统管理 · 岗位角色"]) + + +@router.get("", response_model=ApiResponse[PageResult[BusinessRoleOut]], summary="岗位列表") +def list_roles( + session: DbSession, + _: AdminUser, + query: Annotated[BusinessRoleQuery, Query()], +) -> ApiResponse[PageResult[BusinessRoleOut]]: + """分页查询业务岗位角色(含停用)。""" + return ok(business_role_service.list_roles(session, query)) + + +@router.post("", response_model=ApiResponse[BusinessRoleOut], summary="新增岗位") +def create_role( + session: DbSession, + current_user: AdminUser, + payload: BusinessRoleCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[BusinessRoleOut]: + """新增岗位。编码全局唯一、创建后不可修改;模板须在注册表内;新岗位默认启用。""" + return ok( + business_role_service.create_role(session, payload, operator=current_user, meta=meta), + message="岗位已创建", + ) + + +@router.get( + "/meta/templates", + response_model=ApiResponse[list[TemplateOptionOut]], + summary="公式模板下拉", +) +def list_template_options(session: DbSession, _: AdminUser) -> ApiResponse[list[TemplateOptionOut]]: + """取公式模板下拉(含声明参数,供岗位表单展示)。 + + 定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。 + """ + return ok(business_role_service.list_template_options()) + + +@router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情") +def get_role( + session: DbSession, + _: AdminUser, + role_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[BusinessRoleOut]: + """按主键查询岗位详情。""" + return ok(business_role_service.get_role(session, role_id)) + + +@router.put("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="编辑岗位") +def update_role( + session: DbSession, + current_user: AdminUser, + role_id: Annotated[int, Path(ge=1)], + payload: BusinessRoleUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[BusinessRoleOut]: + """编辑岗位名称/模板/说明/排序。 + + **不含 role_code** —— 编码是参数维度值与模板绑定的锚点。 + """ + return ok( + business_role_service.update_role( + session, role_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.put( + "/{role_id}/status", response_model=ApiResponse[BusinessRoleOut], summary="启用/禁用岗位" +) +def set_status( + session: DbSession, + current_user: AdminUser, + role_id: Annotated[int, Path(ge=1)], + payload: BusinessRoleStatusIn, + meta: RequestMetaDep, +) -> ApiResponse[BusinessRoleOut]: + """启用或禁用岗位。内置岗位不可停用。""" + result = business_role_service.set_status( + session, role_id, payload.status, operator=current_user, meta=meta + ) + return ok(result, message="已启用" if payload.status == 1 else "已禁用") + + +@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除岗位") +def delete_role( + session: DbSession, + current_user: AdminUser, + role_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除岗位。内置岗位不可删除;有人员引用的岗位不可删除。""" + business_role_service.delete_role(session, role_id, operator=current_user, meta=meta) + return ok(message="岗位已删除") diff --git a/backend/app/api/v1/system/logs.py b/backend/app/api/v1/system/logs.py new file mode 100644 index 0000000..74b673c --- /dev/null +++ b/backend/app/api/v1/system/logs.py @@ -0,0 +1,67 @@ +"""日志查询接口(需求 2.5.1:支持日志查询)。 + +操作日志与登录日志是两个独立端点:两表字段与筛选项不同(前者含模块/操作/ +请求详情,后者含失败原因),合并会让 `action` 等参数产生二义;前端正好是 +两个 tab。操作日志另有单条详情(列表为性能排除 `request_data`)。 + +均要求管理员权限;只读查询不写审计(读操作记日志只会淹没审计表)。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Path, Query + +from app.api.deps import AdminUser, DbSession +from app.core.response import ApiResponse, PageResult, ok +from app.schemas.log import ( + LoginLogOut, + LoginLogQuery, + OperationLogDetailOut, + OperationLogOut, + OperationLogQuery, +) +from app.services import log_service + +operation_router = APIRouter(prefix="/operation-logs", tags=["系统管理 · 日志"]) +login_router = APIRouter(prefix="/login-logs", tags=["系统管理 · 日志"]) + + +@operation_router.get( + "", response_model=ApiResponse[PageResult[OperationLogOut]], summary="操作日志" +) +def list_operation_logs( + session: DbSession, + _: AdminUser, + query: Annotated[OperationLogQuery, Query()], +) -> ApiResponse[PageResult[OperationLogOut]]: + """分页查询操作日志,支持按操作人/模块/操作类型/结果/时间范围筛选。 + + 按 id 倒序(时间倒序的稳定实现)。列表**不含** `request_data`(大字段), + 展开详情请调 `GET /operation-logs/{log_id}`。 + """ + return ok(log_service.list_operation_logs(session, query)) + + +@operation_router.get( + "/{log_id}", response_model=ApiResponse[OperationLogDetailOut], summary="操作日志详情" +) +def get_operation_log( + session: DbSession, + _: AdminUser, + log_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[OperationLogDetailOut]: + """查询单条操作日志详情(含已脱敏的请求参数)。 + + 前端展开日志行时按 id 按需加载 —— 避免列表翻页携带大字段。 + """ + return ok(log_service.get_operation_log(session, log_id)) + + +@login_router.get("", response_model=ApiResponse[PageResult[LoginLogOut]], summary="登录日志") +def list_login_logs( + session: DbSession, + _: AdminUser, + query: Annotated[LoginLogQuery, Query()], +) -> ApiResponse[PageResult[LoginLogOut]]: + """分页查询登录日志,支持按用户名/IP/结果/时间范围筛选。""" + return ok(log_service.list_login_logs(session, query)) diff --git a/backend/app/api/v1/system/params.py b/backend/app/api/v1/system/params.py new file mode 100644 index 0000000..3ceb2b1 --- /dev/null +++ b/backend/app/api/v1/system/params.py @@ -0,0 +1,148 @@ +"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。 + +权限分层(澄清 #17 业务答复 2026-10-19「查看可、修改仅管理员」):只读 +端点(目录/变更记录/批次明细/对比/导出/预览/历史)要求登录即可;写操作 +(提交/撤回)仅管理员。写操作经 `RequestMetaDep` 带入请求元信息写入操作 +日志,变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。 +导出为文件流(非统一信封),前端以 blob 接收。 +""" + +from datetime import date +from typing import Annotated + +from fastapi import APIRouter, Path, Query +from fastapi.responses import StreamingResponse + +from app.api.deps import AdminUser, DbSession, LoginUser, RequestMetaDep +from app.core.response import ApiResponse, PageResult, ok +from app.schemas.param import ( + ParamBatchDetailOut, + ParamBatchOut, + ParamBatchQuery, + ParamCatalogItemOut, + ParamChangeBatchOut, + ParamChangeIn, + ParamDiffOut, + ParamHistoryOut, + ParamHistoryQuery, + ParamPreviewIn, + ParamPreviewOut, +) +from app.services import param_service + +router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"]) + + +@router.get( + "", response_model=ApiResponse[list[ParamCatalogItemOut]], summary="参数目录(含当前生效值)" +) +def get_catalog( + session: DbSession, + _: LoginUser, + as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None, +) -> ApiResponse[list[ParamCatalogItemOut]]: + """全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。""" + return ok(param_service.get_catalog(session, as_of=as_of)) + + +@router.post("/changes", response_model=ApiResponse[ParamChangeBatchOut], summary="提交变更批次") +def submit_change( + session: DbSession, + current_user: AdminUser, + payload: ParamChangeIn, + meta: RequestMetaDep, +) -> ApiResponse[ParamChangeBatchOut]: + """提交一批参数变更(1~50 项),返回版本号;立即生效或指定日期生效。""" + return ok( + param_service.submit_change(session, payload, operator=current_user, meta=meta), + message="变更已提交", + ) + + +@router.get( + "/changes", response_model=ApiResponse[PageResult[ParamBatchOut]], summary="变更记录分页" +) +def list_changes( + session: DbSession, + _: LoginUser, + query: Annotated[ParamBatchQuery, Query()], +) -> ApiResponse[PageResult[ParamBatchOut]]: + """按生效日期区间分页查询变更批次(创建时间倒序)。""" + return ok(param_service.list_batches(session, query)) + + +@router.get( + "/changes/{batch_id}", response_model=ApiResponse[ParamBatchDetailOut], summary="批次明细" +) +def get_batch( + session: DbSession, + _: LoginUser, + batch_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[ParamBatchDetailOut]: + """批次头 + 逐项旧值 → 新值。""" + return ok(param_service.get_batch_detail(session, batch_id)) + + +@router.delete("/changes/{batch_id}", response_model=ApiResponse[None], summary="撤回未生效批次") +def withdraw_batch( + session: DbSession, + current_user: AdminUser, + batch_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """撤回尚未生效的批次;已生效批次不可撤回。""" + param_service.withdraw_batch(session, batch_id, operator=current_user, meta=meta) + return ok(message="批次已撤回") + + +@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比") +def compare( + session: DbSession, + _: LoginUser, + from_date: Annotated[date, Query(description="起始业务日期(含)")], + to_date: Annotated[date, Query(description="截止业务日期(含)")], +) -> ApiResponse[list[ParamDiffOut]]: + """两个业务日期的全量参数快照逐项对比,changed 标记是否变化。""" + return ok(param_service.compare(session, from_date=from_date, to_date=to_date)) + + +@router.get("/export", summary="导出变更记录 Excel") +def export_changes( + session: DbSession, + _: LoginUser, + start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None, + end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None, +) -> StreamingResponse: + """变更记录导出为 Excel 文件流(非统一信封,前端以 blob 接收)。""" + content = param_service.export_changes(session, start_date=start_date, end_date=end_date) + filename = f"param_changes_{date.today():%Y%m%d}.xlsx" + return StreamingResponse( + iter([content]), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览") +def preview( + session: DbSession, + _: LoginUser, + payload: ParamPreviewIn, +) -> ApiResponse[ParamPreviewOut]: + """输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。""" + return ok(param_service.preview(session, payload)) + + +@router.get( + "/{param_code}/history", + response_model=ApiResponse[PageResult[ParamHistoryOut]], + summary="参数版本历史", +) +def get_history( + session: DbSession, + _: LoginUser, + param_code: Annotated[str, Path(max_length=64)], + query: Annotated[ParamHistoryQuery, Query()], +) -> ApiResponse[PageResult[ParamHistoryOut]]: + """单参数的版本历史(含版本号、变更人与旧值 → 新值),最新在前。""" + return ok(param_service.get_history(session, param_code, query)) diff --git a/backend/app/api/v1/system/roles.py b/backend/app/api/v1/system/roles.py new file mode 100644 index 0000000..cfbbbc1 --- /dev/null +++ b/backend/app/api/v1/system/roles.py @@ -0,0 +1,100 @@ +"""角色管理接口(自主完善模块:需求 2.5.1 仅定义 admin / user 两个角色)。 + +保护规则(admin 禁删禁停、有绑定的角色禁删、role_code 不可改)见 +`role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。 + +全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service +层写入操作日志。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Path, Query + +from app.api.deps import AdminUser, DbSession, RequestMetaDep +from app.core.response import ApiResponse, PageResult, ok +from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleStatusIn, RoleUpdateIn +from app.services import role_service + +router = APIRouter(prefix="/roles", tags=["系统管理 · 角色"]) + + +@router.get("", response_model=ApiResponse[PageResult[RoleManageOut]], summary="角色列表") +def list_roles( + session: DbSession, + _: AdminUser, + query: Annotated[RoleQuery, Query()], +) -> ApiResponse[PageResult[RoleManageOut]]: + """分页查询角色。 + + 与用户表单的角色下拉口径不同:**本列表含停用角色**(下拉只给启用项)。 + 每行附带 `user_count`(绑定用户数),供前端提示删除保护。 + """ + return ok(role_service.list_roles(session, query)) + + +@router.post("", response_model=ApiResponse[RoleManageOut], summary="新增角色") +def create_role( + session: DbSession, + current_user: AdminUser, + payload: RoleCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[RoleManageOut]: + """新增角色。编码全局唯一、创建后不可修改;新角色默认启用。""" + return ok( + role_service.create_role(session, payload, operator=current_user, meta=meta), + message="角色已创建", + ) + + +@router.put("/{role_id}", response_model=ApiResponse[RoleManageOut], summary="编辑角色") +def update_role( + session: DbSession, + current_user: AdminUser, + role_id: Annotated[int, Path(ge=1)], + payload: RoleUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[RoleManageOut]: + """编辑角色名称/说明/排序。 + + **不含 role_code** —— 编码是授权判定(`ROLE_ADMIN` 常量)的锚点。 + """ + return ok( + role_service.update_role(session, role_id, payload, operator=current_user, meta=meta), + message="已保存", + ) + + +@router.put("/{role_id}/status", response_model=ApiResponse[RoleManageOut], summary="启用/禁用角色") +def set_status( + session: DbSession, + current_user: AdminUser, + role_id: Annotated[int, Path(ge=1)], + payload: RoleStatusIn, + meta: RequestMetaDep, +) -> ApiResponse[RoleManageOut]: + """启用或禁用角色。 + + 停用**不解除**用户绑定、也不影响既有授权(授权判定只看编码、不看状态); + 内置管理员角色不可停用。 + """ + result = role_service.set_status( + session, role_id, payload.status, operator=current_user, meta=meta + ) + return ok(result, message="已启用" if payload.status == 1 else "已禁用") + + +@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除角色") +def delete_role( + session: DbSession, + current_user: AdminUser, + role_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除角色。 + + 内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)—— + `user_roles` 外键是 CASCADE,后端拦截是防止静默剥离权限的唯一防线。 + """ + role_service.delete_role(session, role_id, operator=current_user, meta=meta) + return ok(message="角色已删除") diff --git a/backend/app/api/v1/system/settings.py b/backend/app/api/v1/system/settings.py new file mode 100644 index 0000000..ba71442 --- /dev/null +++ b/backend/app/api/v1/system/settings.py @@ -0,0 +1,83 @@ +"""系统设置接口(公司信息)。 + +`GET /public` 与 `GET /logo/public` **无鉴权**:登录页渲染公司名/logo 时 +尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py), +仅暴露两个非敏感展示字段。 + +`/logo/public` 返回文件流而非统一信封——与参数导出同一例外口径 +(二进制响应套 JSON 信封无意义)。 +""" + +from fastapi import APIRouter, Response, UploadFile +from fastapi.responses import FileResponse + +from app.api.deps import AdminUser, DbSession, RequestMetaDep +from app.core.exceptions import BadRequestError +from app.core.response import ApiResponse, ok +from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn +from app.services import system_setting_service as service + +router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"]) + +_LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"} + + +@router.get("", response_model=ApiResponse[SettingOut], summary="公司信息") +def get_settings(session: DbSession, _: AdminUser) -> ApiResponse[SettingOut]: + """取公司信息(管理端)。""" + return ok(service.to_out(service.get_setting(session))) + + +@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司名称") +def update_settings( + payload: SettingUpdateIn, + session: DbSession, + current_user: AdminUser, + meta: RequestMetaDep, +) -> ApiResponse[SettingOut]: + """更新公司名称(审计留痕)。""" + return ok( + service.update_company(session, payload, operator=current_user, meta=meta), + message="已保存", + ) + + +@router.post("/logo", response_model=ApiResponse[SettingOut], summary="上传 logo") +def upload_logo( + file: UploadFile, + session: DbSession, + current_user: AdminUser, + meta: RequestMetaDep, +) -> ApiResponse[SettingOut]: + """上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。 + + 同步 `def`:读文件、磁盘写与审计提交都是阻塞操作,async 路由里会让 + 它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。 + 大小先用 `file.size` 拦截,避免超限文件整体读进内存。 + """ + if file.size is not None and file.size > service.LOGO_MAX_BYTES: + raise BadRequestError("logo 文件不能超过 2MB") + content = file.file.read() + ext = (file.filename or "").rsplit(".", 1)[-1] + return ok( + service.save_logo(session, content, ext, operator=current_user, meta=meta), + message="logo 已更新", + ) + + +@router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)") +def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]: + """公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。""" + response.headers["Cache-Control"] = "public, max-age=300" + return ok(service.to_public_out(service.get_setting(session))) + + +@router.get("/logo/public", summary="logo 图片(匿名)") +def get_logo_public(session: DbSession) -> FileResponse: + """logo 文件流(无鉴权)。no-cache:图片替换后由版本参数破缓存兜底。""" + path, ext = service.read_logo(session) + return FileResponse( + path, + media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"), + headers={"Cache-Control": "no-cache"}, + ) diff --git a/backend/app/api/v1/system/users.py b/backend/app/api/v1/system/users.py new file mode 100644 index 0000000..ab71157 --- /dev/null +++ b/backend/app/api/v1/system/users.py @@ -0,0 +1,146 @@ +"""用户管理接口(需求 2.5.1:用户账号管理)。 + +全部要求管理员权限 —— 按需求「系统管理员(全部权限)、普通用户(业务操作)」, +用户管理属管理员专属。 + +删除、禁用、改角色三处都有**自我保护**:不允许对当前登录账号执行, +否则管理员误操作会立刻失去全部管理权限且无法自行恢复。 + +全部写操作经 `RequestMetaDep` 带入请求元信息后由 service 层写入操作日志 +(成功与失败都记),操作人为当前登录管理员。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Path, Query + +from app.api.deps import AdminUser, DbSession, RequestMetaDep +from app.core.response import ApiResponse, PageResult, ok +from app.schemas.role import RoleOut +from app.schemas.user import ( + PasswordResetIn, + UserCreateIn, + UserOut, + UserQuery, + UserStatusIn, + UserUpdateIn, +) +from app.services import user_service + +router = APIRouter(prefix="/users", tags=["系统管理 · 用户"]) + + +@router.get("", response_model=ApiResponse[PageResult[UserOut]], summary="用户列表") +def list_users( + session: DbSession, + _: AdminUser, + query: Annotated[UserQuery, Query()], +) -> ApiResponse[PageResult[UserOut]]: + """分页查询用户,支持按用户名/姓名模糊、状态与角色筛选。""" + return ok(user_service.list_users(session, query)) + + +@router.get("/{user_id}", response_model=ApiResponse[UserOut], summary="用户详情") +def get_user( + session: DbSession, + _: AdminUser, + user_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[UserOut]: + """查询用户详情。""" + return ok(user_service.get_user(session, user_id)) + + +@router.post("", response_model=ApiResponse[UserOut], summary="新增用户") +def create_user( + session: DbSession, + current_user: AdminUser, + payload: UserCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[UserOut]: + """新增用户。用户名须唯一,至少指定一个角色。""" + return ok( + user_service.create_user(session, payload, operator=current_user, meta=meta), + message="用户已创建", + ) + + +@router.put("/{user_id}", response_model=ApiResponse[UserOut], summary="编辑用户") +def update_user( + session: DbSession, + current_user: AdminUser, + user_id: Annotated[int, Path(ge=1)], + payload: UserUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[UserOut]: + """编辑用户的基本信息与角色。 + + **不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口。 + """ + return ok( + user_service.update_user(session, user_id, payload, operator=current_user, meta=meta), + message="已保存", + ) + + +@router.delete("/{user_id}", response_model=ApiResponse[None], summary="删除用户") +def delete_user( + session: DbSession, + current_user: AdminUser, + user_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除用户。 + + 物理删除。历史操作日志通过 `user_id` 置空 + 冗余用户名保留可追溯性。 + 业务上更推荐用「禁用」代替删除 —— 禁用保留完整审计线索。 + """ + user_service.delete_user(session, user_id, operator=current_user, meta=meta) + return ok(message="用户已删除") + + +@router.put("/{user_id}/password", response_model=ApiResponse[None], summary="重置密码") +def reset_password( + session: DbSession, + current_user: AdminUser, + user_id: Annotated[int, Path(ge=1)], + payload: PasswordResetIn, + meta: RequestMetaDep, +) -> ApiResponse[None]: + """重置用户密码。 + + 会同时提升该用户的令牌版本,使其**已签发的令牌立即失效** —— + 否则改密后旧令牌在有效期内仍可用。 + """ + user_service.reset_password( + session, user_id, payload.new_password, operator=current_user, meta=meta + ) + return ok(message="密码已重置,该用户需重新登录") + + +@router.put("/{user_id}/status", response_model=ApiResponse[UserOut], summary="启用/禁用用户") +def set_status( + session: DbSession, + current_user: AdminUser, + user_id: Annotated[int, Path(ge=1)], + payload: UserStatusIn, + meta: RequestMetaDep, +) -> ApiResponse[UserOut]: + """启用或禁用用户。 + + 禁用会提升令牌版本使已签发令牌立即失效,否则被禁用者仍可凭旧令牌 + 继续访问直到过期(最长 2 小时)。 + """ + result = user_service.set_status( + session, user_id, payload.status, operator=current_user, meta=meta + ) + return ok(result, message="已启用" if payload.status == 1 else "已禁用") + + +@router.get("/meta/roles", response_model=ApiResponse[list[RoleOut]], summary="角色下拉选项") +def list_roles(session: DbSession, _: AdminUser) -> ApiResponse[list[RoleOut]]: + """取全部启用角色,供用户表单的角色选择使用。 + + 路径放在 `/users/meta/roles` 而非 `/roles`,是为了避免与 + `/{user_id}` 的路由匹配冲突(`roles` 会被当成 user_id 解析而报 422)。 + """ + return ok(user_service.list_roles(session)) diff --git a/backend/app/core/captcha_image.py b/backend/app/core/captcha_image.py index eb7c631..d6e0cd2 100644 --- a/backend/app/core/captcha_image.py +++ b/backend/app/core/captcha_image.py @@ -27,11 +27,12 @@ _WIDTH, _HEIGHT = 120, 40 _FONT_SIZE = 25 _PADDING = 8 -# 与前端配色呼应:墨色字符、青灰干扰、素纸底 -_INK = (15, 54, 70) -_INTERFERENCE = (126, 156, 170) -_SPECKLE = (168, 183, 192) -_PAPER = (247, 246, 242) +# 与前端 v3 配色呼应(--ys-ink / --ys-ink-2 / --ys-ink-3 / --ys-paper): +# 墨色字符、次级墨干扰、辅助墨噪点、纸白底 +_INK = (28, 35, 51) # #1c2333 +_INTERFERENCE = (91, 101, 119) # #5b6577 +_SPECKLE = (154, 163, 178) # #9aa3b2 +_PAPER = (255, 255, 255) # #ffffff _SPECKLE_COUNT = 46 _INTERFERENCE_LINES = 2 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9a930d7..1b05053 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -92,6 +92,15 @@ class Settings(BaseSettings): auth = f":{self.redis_password}@" if self.redis_password else "" return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}" + @property + def upload_dir(self) -> Path: + """上传文件根目录(公司 logo 等):backend/uploads/。 + + 不叫 static/ 是刻意的:静态目录将来可能整体交给前端构建产物, + 运行期上传物必须与之隔离;该目录不入版本库(.gitignore)。 + """ + return BACKEND_DIR / "uploads" + @lru_cache def get_settings() -> Settings: diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index 5074c0d..b085763 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -25,6 +25,19 @@ TOKEN_TYPE_BEARER = "Bearer" # ===== 角色编码(与 db/migration 初始化数据保持一致)===== ROLE_ADMIN = "admin" ROLE_USER = "user" +# 2026-10-15 复核审批补充需求新增职能登录角色:营销后勤(提交+录入)、 +# 财务(复核)、领导(审核)。roles 表无 is_builtin 列,内置保护用代码 +# 白名单判定(role_service 两处内置校验改查此集合); +# permissions/role_permissions 细粒度激活待业务权限清单(待澄清 #6)。 +ROLE_MARKETING_OPS = "marketing_ops" +ROLE_FINANCE = "finance" +ROLE_LEADER = "leader" +BUILTIN_ROLE_CODES = { + ROLE_ADMIN, + ROLE_MARKETING_OPS, + ROLE_FINANCE, + ROLE_LEADER, +} # ===== 密码策略 ===== # bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」, @@ -36,3 +49,61 @@ PASSWORD_MAX_BYTES = 72 # 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率 CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" CAPTCHA_LENGTH = 4 + +# ===== 业务岗位编码(与 db/migration 种子一致,business_roles.role_code)===== +BUSINESS_ROLE_SALESPERSON = "salesperson" +BUSINESS_ROLE_SALES_MANAGER = "sales_manager" +BUSINESS_ROLE_INFO_OFFICER = "info_officer" + +# ===== 产品类别码(与 param_values.dimension_value 口径一致)===== +PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water" +PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit" +PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT} + +# ===== 客户分类码(customers.customer_type,计提规则匹配锚点)===== +CUSTOMER_TYPE_PERSONAL = "personal" +CUSTOMER_TYPE_MANAGER_PERSONAL = "manager_personal" +CUSTOMER_TYPE_ENTERPRISE = "enterprise" +CUSTOMER_TYPES = { + CUSTOMER_TYPE_PERSONAL, + CUSTOMER_TYPE_MANAGER_PERSONAL, + CUSTOMER_TYPE_ENTERPRISE, +} + +# ===== 操作日志词表(operation_logs 的 module / action 取值)===== +# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致 +MODULE_USER = "用户管理" +MODULE_ROLE = "角色管理" +MODULE_BUSINESS_ROLE = "岗位角色" +MODULE_PARAM = "计提参数" +MODULE_PERSONNEL = "人员管理" +MODULE_PRODUCT = "产品管理" +MODULE_CUSTOMER = "客户管理" +MODULE_BUSINESS = "业务管理" +MODULE_SHIPMENT = "发货管理" +MODULE_RECEIPT = "回款管理" +MODULE_RATING = "客户评级" +MODULE_SYSTEM_SETTINGS = "系统设置" +MODULE_APPROVAL = "审批管理" +MODULE_CALC = "绩效核算" +MODULE_SETTLEMENT = "月度结算" +MODULE_REPORT = "报表统计" + +# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)===== +# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组 +NO_PREFIX_BUSINESS = "YW" +NO_PREFIX_SHIPMENT = "SH" +NO_PREFIX_RECEIPT = "HK" +NO_PREFIX_PARAM = "P" +NO_PREFIX_APPROVAL = "AP" +NO_PREFIX_ADJUSTMENT = "TZ" + +ACTION_CREATE = "新增" +ACTION_UPDATE = "修改" +ACTION_DELETE = "删除" +ACTION_RESET_PASSWORD = "重置密码" +ACTION_ENABLE = "启用" +ACTION_DISABLE = "禁用" +ACTION_APPROVE = "审批通过" +ACTION_REJECT = "审批驳回" +ACTION_EXPORT = "导出" diff --git a/backend/app/core/db_errors.py b/backend/app/core/db_errors.py new file mode 100644 index 0000000..682fdd7 --- /dev/null +++ b/backend/app/core/db_errors.py @@ -0,0 +1,18 @@ +"""数据库完整性错误的统一识别与翻译口径。 + +判断依据:pymysql 原生异常 ``orig.args`` 首位是 MySQL 错误码 —— 只有 +1062(ER_DUP_ENTRY,唯一键冲突)是「已存在」语义,可安全转 400 业务 +提示;其余完整性错误(外键 RESTRICT、数据截断等)原样上抛,由全局 +异常处理器收敛 500(程序缺陷或数据异常,均不应伪装成用户错误)。 +""" + +from sqlalchemy.exc import IntegrityError + +# MySQL 唯一键冲突错误码(pymysql 原生异常 args 首位) +ERR_DUP_ENTRY = 1062 + + +def is_duplicate_entry(exc: IntegrityError) -> bool: + """判断 IntegrityError 是否为 MySQL 唯一键冲突(错误码 1062)。""" + args = getattr(exc.orig, "args", ()) + return bool(args) and args[0] == ERR_DUP_ENTRY diff --git a/backend/app/core/exception_handlers.py b/backend/app/core/exception_handlers.py index 78fc7bb..0436def 100644 --- a/backend/app/core/exception_handlers.py +++ b/backend/app/core/exception_handlers.py @@ -12,6 +12,7 @@ import logging from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse +from sqlalchemy.exc import IntegrityError from starlette.exceptions import HTTPException as StarletteHTTPException from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR @@ -60,6 +61,19 @@ async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSON return _build_response(exc.status_code, message) +async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONResponse: + """完整性错误全局兜底为 400。 + + service 层已判别的 1062/引用冲突会自行翻译为业务提示,不会走到这里; + 本 handler 拦截的是「新增父表后某 service 漏补引用保护」一类的遗漏 + (e2e 撞见一个补一个的模式无法保证全覆盖):FK RESTRICT 等数据完整性 + 冲突对用户表现为「这行数据被别处引用/冲突」,转 400 比 500 更贴近事实。 + 完整异常记录日志供排查;不在此处 rollback —— 会话由请求级依赖统一关闭。 + """ + logger.error("数据库完整性冲突(未在 service 层翻译)", exc_info=exc) + return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝") + + async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse: """兜底处理未预期异常。 @@ -77,4 +91,5 @@ def register_exception_handlers(app: FastAPI) -> None: app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type] app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type] app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type] + app.add_exception_handler(IntegrityError, handle_integrity_error) # type: ignore[arg-type] app.add_exception_handler(Exception, handle_unexpected_exception) diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py index c26f18b..65c1e63 100644 --- a/backend/app/core/logging.py +++ b/backend/app/core/logging.py @@ -29,8 +29,8 @@ _SENSITIVE_RULES: tuple[tuple[re.Pattern[str], str], ...] = ( ), # Authorization: Bearer xxx (re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"), - # 手机号:138****8888 - (re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"), + # 手机号:138****8888(兼容带空格/横线分隔的写法,如 138 0013 8000) + (re.compile(r"\b(1[3-9]\d)[-\s]?(\d{4})[-\s]?(\d{4})\b"), r"\1****\3"), # 18 位身份证:保留前 6 位与后 4 位 (re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"), ) diff --git a/backend/app/core/report_meta.py b/backend/app/core/report_meta.py new file mode 100644 index 0000000..b7bc8d6 --- /dev/null +++ b/backend/app/core/report_meta.py @@ -0,0 +1,76 @@ +"""报表统计元数据(区间桶、中文标签、导出上限)——叶子模块。 + +刻意不导入 app 内任何模块:report_service 与 report_export 都要消费这里的 +定义,若它反向依赖 service 层会形成循环导入;也不走参数体系——FR-031 +「区间可配置」一期以代码常量落地(参数化需注册表 + 迁移 + init-dev + +registry 测试四处同改,业务未提诉求前不做),配置缝收在 +get_payment_day_buckets 单函数,未来在线可配时只改该函数。 +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class DayBucket: + """回款天数区间档位:min/max 均为 None 表示「无回款天数」兜底桶。""" + + code: str + label: str + min_days: int | None + max_days: int | None + + +# 8 档区间(催办清单 R2 默认口径)。两个兜底桶必须保留:payment_days 可空 +# (回款未指定发货批次)且可为负(先款后货照实负值),缺了它们这些行会 +# 静默消失、金额对不上。 +PAYMENT_DAY_BUCKETS: tuple[DayBucket, ...] = ( + DayBucket("le0", "≤0 天(先款后货)", None, 0), + DayBucket("1-30", "1-30 天", 1, 30), + DayBucket("31-60", "31-60 天", 31, 60), + DayBucket("61-90", "61-90 天", 61, 90), + DayBucket("91-180", "91-180 天", 91, 180), + DayBucket("181-360", "181-360 天", 181, 360), + DayBucket("361+", "361 天以上", 361, None), + DayBucket("none", "无回款天数", None, None), +) + +CUSTOMER_TYPE_LABELS: dict[str, str] = { + "personal": "个人户", + "manager_personal": "经理个人户", + "enterprise": "企业户", +} + +# mixed 不在 core.constants.PRODUCT_CATEGORIES(那只是可维护的档案类别), +# 但台账行可以是 mixed(混合业务行级明细入 snapshot_json),报表必须能标它 +PRODUCT_CATEGORY_LABELS: dict[str, str] = { + "municipal_water": "市政水利", + "rail_transit": "轨道交通", + "mixed": "混合业务", +} + +REPORT_TEMPLATE_VERSION = "V1.0" +# 导出超限即拒绝(需求「大数据量分批/超限提示」二选一取后者):普通 +# Workbook 全量载入内存,2 万行约数十 MB,超过该量级必须引导按月导出 +REPORT_EXPORT_MAX_ROWS = 20000 + + +def get_payment_day_buckets() -> tuple[DayBucket, ...]: + """区间档位唯一出口:前端下拉与分桶都以本函数返回为准,禁止硬编码。 + + 未来「区间在线可配」时改此处读参数即可,调用方与前端零改动。 + """ + return PAYMENT_DAY_BUCKETS + + +def bucket_of(payment_days: int | None) -> DayBucket: + """把单笔回款天数落入对应区间桶;未匹配任何区间按「无回款天数」兜底。""" + if payment_days is None: + return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none") + for bucket in PAYMENT_DAY_BUCKETS: + if bucket.code == "none": + continue + lower_ok = bucket.min_days is None or payment_days >= bucket.min_days + upper_ok = bucket.max_days is None or payment_days <= bucket.max_days + if lower_ok and upper_ok: + return bucket + return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 821725f..dd647d5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,17 +9,57 @@ `Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。 """ +from app.models.approval import ApprovalOrder, ApprovalStep from app.models.base import Base, TimestampMixin +from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation +from app.models.business_role import BusinessRole +from app.models.customer import Customer, OwnershipChangeLog from app.models.login_log import LoginLog +from app.models.operation_log import OperationLog +from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue +from app.models.performance_ledger import PerformanceLedger +from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion +from app.models.product import Product +from app.models.rating import CustomerRatingGrade +from app.models.receipt import Receipt from app.models.role import Permission, Role, RolePermission +from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem +from app.models.shipment import Shipment, ShipmentItem +from app.models.system_setting import SystemSetting from app.models.user import User, UserRole __all__ = [ + "AdjustmentOrder", + "ApprovalOrder", + "ApprovalStep", "Base", + "Business", + "BusinessItem", + "BusinessRole", + "BusinessStatusLog", + "Customer", + "CustomerRatingGrade", "LoginLog", + "MonthlySettlement", + "OperationLog", + "OwnershipChangeLog", + "ParamChangeBatch", + "ParamDefinition", + "ParamValue", + "PerformanceLedger", "Permission", + "Personnel", + "PersonnelPlan", + "PersonnelRegion", + "Product", + "Receipt", "Role", "RolePermission", + "SecondaryAllocation", + "SettlementItem", + "Shipment", + "ShipmentItem", + "SystemSetting", "TimestampMixin", "User", "UserRole", diff --git a/backend/app/models/approval.py b/backend/app/models/approval.py new file mode 100644 index 0000000..0a1d142 --- /dev/null +++ b/backend/app/models/approval.py @@ -0,0 +1,133 @@ +"""审批模型:审批单与节点记录(2026-10-15 复核审批补充需求)。 + +三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数体系开关驱动 +(`approval.*.enabled` BOOL):关闭=提交即生成「自动通过」节点留痕, +留痕不断链(开发计划 3.2(三));开启=按节点人工审核,一驳全驳。 + +依赖说明:节点准入按登录角色编码判定(ROLE_FINANCE/ROLE_LEADER), +不动 `permissions`/`role_permissions` 闲置表 —— 细粒度权限清单待业务方 +答复(待澄清 #6)后再激活。 + +ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=2 +(月结预留)将指向 ④ 尚未落地的表,外键无法统一指向;单据删除保护由 +服务层承担(详见 approval_service)。 +""" + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args + +# 审批单状态(approval_orders.status;流转机见 approval_service) +APPROVAL_STATUS_PENDING_FINANCE = 1 # 待财务复核 +APPROVAL_STATUS_PENDING_LEADER = 2 # 待领导审核 +APPROVAL_STATUS_APPROVED = 3 # 已通过(终态) +APPROVAL_STATUS_REJECTED = 4 # 已驳回(终态) +APPROVAL_STATUS_WITHDRAWN = 5 # 已撤回(被引用方重新提交时旧在途单注销) + +# 节点(approval_steps.node;0 仅用于撤回留痕) +APPROVAL_NODE_FINANCE = 1 +APPROVAL_NODE_LEADER = 2 +APPROVAL_NODE_WITHDRAW = 0 +APPROVAL_NODES = { + APPROVAL_STATUS_PENDING_FINANCE: APPROVAL_NODE_FINANCE, + APPROVAL_STATUS_PENDING_LEADER: APPROVAL_NODE_LEADER, +} + +# 动作(approval_steps.action) +APPROVAL_ACTION_APPROVE = 1 # 人工通过 +APPROVAL_ACTION_REJECT = 2 # 人工驳回 +APPROVAL_ACTION_AUTO_APPROVE = 3 # 系统自动通过(开关关闭时逐节点留痕) +APPROVAL_ACTION_WITHDRAW = 4 # 撤回(业务重新提交时旧在途单注销) + +# 审批对象类型(approval_orders.order_type) +APPROVAL_TYPE_SECONDARY_ALLOCATION = 1 +APPROVAL_TYPE_MONTHLY_SETTLEMENT = 2 +APPROVAL_TYPE_ADJUSTMENT = 3 + + +class ApprovalOrder(Base, TimestampMixin): + """审批单:一张待审对象一条审批流(状态可流转,含 update_time)。""" + + __tablename__ = "approval_orders" + __table_args__ = mysql_table_args( + "审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)", + Index("idx_approval_orders_type_status", "order_type", "status"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + order_no: Mapped[str] = mapped_column( + String(32), + nullable=False, + unique=True, + comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}", + ) + order_type: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, comment="审批对象类型:1二次分配方案 2月度结算 3调整单" + ) + ref_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)", + ) + refer_no: Mapped[str] = mapped_column( + String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)" + ) + status: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="1", + comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回", + ) + submitter_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("users.id", ondelete="SET NULL"), + comment="提交人ID(账号删除后置空)", + ) + submitter_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="提交人用户名(冗余固化)" + ) + + +class ApprovalStep(Base): + """审批节点记录表(append-only):每次动作只增不改,构成时间线。 + + 不继承 TimestampMixin(留痕无更新语义,同 business_status_logs 先例)。 + """ + + __tablename__ = "approval_steps" + __table_args__ = mysql_table_args( + "审批节点记录表(append-only;自动通过/撤回均有留痕)", + Index("idx_approval_steps_order_id", "order_id"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + order_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("approval_orders.id", ondelete="CASCADE"), + nullable=False, + comment="审批单ID(approval_orders.id;单删记录随删)", + ) + node: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, comment="审批节点:0撤回留痕 1财务复核 2领导审核" + ) + action: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回" + ) + opinion: Mapped[str | None] = mapped_column(String(255), comment="审批意见(驳回时填写)") + operator_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("users.id", ondelete="SET NULL"), + comment="操作人ID(自动通过/系统动作置空)", + ) + operator_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="操作人用户名(冗余固化;系统动作记「系统」)" + ) + create_time: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间" + ) diff --git a/backend/app/models/base.py b/backend/app/models/base.py index 59f932e..0ee13ad 100644 --- a/backend/app/models/base.py +++ b/backend/app/models/base.py @@ -7,7 +7,7 @@ from datetime import datetime -from sqlalchemy import DateTime, MetaData, text +from sqlalchemy import DateTime, MetaData, String, text from sqlalchemy.dialects.mysql import BIGINT, TINYINT from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -28,7 +28,14 @@ BIGINT_UNSIGNED = BIGINT(unsigned=True) # 状态类字段统一用 TINYINT(0/1 或少量枚举值) TINYINT_TYPE = TINYINT -__all__ = ["BIGINT_UNSIGNED", "TINYINT_TYPE", "Base", "TimestampMixin", "mysql_table_args"] +__all__ = [ + "BIGINT_UNSIGNED", + "TINYINT_TYPE", + "Base", + "TPlusSyncMixin", + "TimestampMixin", + "mysql_table_args", +] class Base(DeclarativeBase): @@ -62,6 +69,30 @@ class TimestampMixin: ) +class TPlusSyncMixin: + """T+(用友)数据同步预留列,六张业务主表共用。 + + 对照表 #26:T+ 对接的字段映射与幂等键语义未定(IT 牵头),故四列全部 + 可空、不设唯一键 —— 键语义未定就锁死唯一约束,对接时大概率返工; + source_no 先建普通索引承担查询与去重,IT 定键后以小迁移升级唯一约束。 + + 同步守则(M4 同步模块必须遵守):同步只写 T+ 侧字段与本组列, + **不覆盖人工维护字段**(岗位/工资标准/归属/客户分类/业务状态等, + 各表注释已标注)。同步频率:一周一次定时 + 手动触发(对照表 #10)。 + """ + + source_no: Mapped[str | None] = mapped_column( + String(64), + index=True, + comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", + ) + t_plus_id: Mapped[str | None] = mapped_column(String(64), comment="T+ 系统内部标识") + sync_batch_no: Mapped[str | None] = mapped_column( + String(32), comment="同步批次号(同步模块写入)" + ) + synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间") + + def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]: """构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。 diff --git a/backend/app/models/business.py b/backend/app/models/business.py new file mode 100644 index 0000000..8b54d4f --- /dev/null +++ b/backend/app/models/business.py @@ -0,0 +1,275 @@ +"""业务数据模型:业务单、产品明细与状态流转留痕。 + +- 汇总冗余列(total_* 五列)由服务层在明细/发货/回款增删改时**事务内单点 + 重算**,禁手工直改 —— 列表页与 M3 起付判定直接读冗余列,避免每次 JOIN + 聚合;代价是所有写路径必须重算(business_service 统一收口) +- 毛利润相关值(毛利润额/毛利润率/折算毛利润额)**不落库**:它们是 + sale_amount/cost_amount + 时点化阈值参数的派生值,落库会在成本调整或 + 参数改版后漂移出「假快照」;M2 以只读毛利润视图实时计算(复用 + formula.profit_rate/converted_profit),M3 核算台账才是法定固化点 +- product_category 存在明细行级:混合业务按产品逐条计算(对照表 #14) +- 运费仅记录展示,不计入毛利润口径(对照表 #2);协助营销人员一期核算 + 不使用(对照表 #15 剔除协助 25% 分成),字段保留仅记录事实(需规 2.2.1) +""" + +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import ( + BIGINT_UNSIGNED, + TINYINT_TYPE, + Base, + TimestampMixin, + TPlusSyncMixin, + mysql_table_args, +) + +# 业务状态(businesses.status,流转规则见 business_service:终态不可变更) +BUSINESS_STATUS_ACTIVE = 1 +BUSINESS_STATUS_DONE = 2 +BUSINESS_STATUS_TERMINATED = 3 + + +class SecondaryAllocation(Base, TimestampMixin): + """二次分配方案明细表(2026-10-15 补充需求「二次分配方案」)。 + + 口径(AI_CONTEXT 待澄清 #1/#2 未答复前的边界): + - 只出现在合同刚建立时;修改在首次月结前留痕放行,月结后只读(月结在 ④, + 终态门槛先行承担「修改不影响已结算月份」) + - 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人 + 与比例,响应以 hint 提示合计/余额,不做强制校验 + - T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护 + - **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按 + 核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表 + + personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录 + 不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name + 冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id), + 同一合同的同一参与人仅一行,重复提交在服务层聚合。 + """ + + __tablename__ = "business_secondary_allocations" + __table_args__ = mysql_table_args( + # 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」, + # 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例) + "业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)", + UniqueConstraint( + "business_id", + "personnel_id", + name="uk_business_secondary_allocations_business_personnel", + ), + Index("idx_business_secondary_allocations_business_id", "business_id"), + Index("idx_business_secondary_allocations_personnel_id", "personnel_id"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + business_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("businesses.id", ondelete="CASCADE"), + nullable=False, + comment="业务ID(businesses.id;业务删除随删)", + ) + personnel_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("personnel.id", ondelete="RESTRICT"), + nullable=False, + comment="参与业务员ID(personnel.id;被方案引用禁删)", + ) + personnel_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="参与业务员姓名(冗余固化,离职后回显有名)" + ) + ratio: Mapped[Decimal] = mapped_column( + Numeric(5, 2), + nullable=False, + comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)", + ) + + +class Business(Base, TimestampMixin, TPlusSyncMixin): + """业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。""" + + __tablename__ = "businesses" + __table_args__ = mysql_table_args( + "业务表(业务编号自动生成;T+同步不覆盖业务状态)", + Index("idx_businesses_customer_id", "customer_id"), + Index("idx_businesses_owner_personnel_id", "owner_personnel_id"), + Index("idx_businesses_status", "status"), + Index("idx_businesses_business_date", "business_date"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + business_no: Mapped[str] = mapped_column( + String(32), + nullable=False, + unique=True, + comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)", + ) + customer_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("customers.id", ondelete="RESTRICT"), + nullable=False, + comment="客户ID(customers.id)", + ) + business_date: Mapped[date | None] = mapped_column(Date, comment="业务日期(T+ 同步口径)") + status: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="1", + comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)", + ) + owner_personnel_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("personnel.id", ondelete="RESTRICT"), + comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)", + ) + assistant_personnel_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("personnel.id", ondelete="RESTRICT"), + comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)", + ) + is_special: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="特殊业务标记:1是 0否(公司参与等)", + ) + special_note: Mapped[str | None] = mapped_column(String(255), comment="特殊业务说明") + total_sale_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), + server_default="0", + comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)", + ) + total_cost_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), + server_default="0", + comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)", + ) + total_ship_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), + server_default="0", + comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)", + ) + total_received_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), + server_default="0", + comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)", + ) + total_freight_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), + server_default="0", + comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)", + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + + +class BusinessItem(Base, TimestampMixin): + """业务产品明细表(需求 2.2.1/2.2.4):毛利润的行级源数据。 + + product_id 可空=自由录入产品(配合「数据提取」一键入产品库); + 产品名称/规格冗余固化,防产品改名影响历史业务。 + sale_amount 由服务层按 数量×单价 计算固化,不信任前端传值。 + cost_amount 来源待 T+ 对接确认(可空,人工可录先行)。 + """ + + __tablename__ = "business_items" + __table_args__ = mysql_table_args( + "业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)", + Index("idx_business_items_business_id", "business_id"), + Index("idx_business_items_product_id", "product_id"), + Index("idx_business_items_product_category", "product_category"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + business_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("businesses.id", ondelete="RESTRICT"), + nullable=False, + comment="业务ID(businesses.id)", + ) + product_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("products.id", ondelete="RESTRICT"), + comment="产品ID(products.id;NULL=自由录入,未入产品库)", + ) + product_name: Mapped[str] = mapped_column( + String(128), nullable=False, comment="产品名称(引用产品时冗余固化)" + ) + spec_model: Mapped[str] = mapped_column( + String(128), + nullable=False, + server_default="''", + comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)", + ) + unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位") + product_category: Mapped[str] = mapped_column( + String(32), + nullable=False, + comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)", + ) + quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)") + sale_price: Mapped[Decimal | None] = mapped_column( + Numeric(14, 4), comment="销售单价(元;T+ 同步或人工)" + ) + sale_amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), + nullable=False, + comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)", + ) + cost_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), + comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)", + ) + guided_price: Mapped[Decimal | None] = mapped_column( + Numeric(14, 4), comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)" + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + + +class BusinessStatusLog(Base): + """业务状态变更记录表(append-only,需求 2.2.1「支持状态变更记录」)。 + + 不继承 TimestampMixin(留痕无更新语义);business_id 为无外键的固化 ID + (businesses 建表在本表之后的迁移批次内 —— 实为同批,但留痕统一口径: + 被引用方删除后记录仍完整可读,删除保护由服务层检查承担)。 + 首条记录在业务创建时写入(old=new=1 执行中),详情页时间线从创建起展示。 + """ + + __tablename__ = "business_status_logs" + __table_args__ = mysql_table_args( + "业务状态变更记录表(append-only)", + Index("idx_business_status_logs_business_id", "business_id"), + Index("idx_business_status_logs_create_time", "create_time"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + business_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + comment="业务ID(固化,无外键;删除保护由服务层承担)", + ) + old_status: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, comment="变更前状态(创建首条为 1)" + ) + new_status: Mapped[int] = mapped_column(TINYINT_TYPE, nullable=False, comment="变更后状态") + change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因(终止时必填)") + operator_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("users.id", ondelete="SET NULL"), + comment="操作人ID(账号删除后置空)", + ) + operator_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="操作人用户名(冗余固化)" + ) + create_time: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间" + ) diff --git a/backend/app/models/business_role.py b/backend/app/models/business_role.py new file mode 100644 index 0000000..3076010 --- /dev/null +++ b/backend/app/models/business_role.py @@ -0,0 +1,46 @@ +"""业务岗位角色模型。 + +与系统登录角色(`roles`,权限维度)严格区分:本表是**业务岗位**维度, +决定人员适用的计提公式模板与参数。需求 #28 要求岗位角色可增删、 +新增岗位时在系统设置中绑定公式与参数(无需改代码),故模板编码 +存于本表、公式实现留在代码内注册表(一期不引入规则引擎)。 +""" + +from sqlalchemy import Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args + + +class BusinessRole(Base, TimestampMixin): + """业务岗位角色表。内置三个岗位(营销员/营销经理/信息员)随迁移初始化。""" + + __tablename__ = "business_roles" + __table_args__ = mysql_table_args("业务岗位角色表(与登录角色roles区分,决定计提公式与参数)") + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + role_code: Mapped[str] = mapped_column( + String(32), + nullable=False, + unique=True, + comment="岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改", + ) + role_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="岗位名称") + calc_template: Mapped[str] = mapped_column( + String(32), nullable=False, comment="计提公式模板编码(以代码内模板注册表为准)" + ) + description: Mapped[str | None] = mapped_column(String(255), comment="岗位说明") + sort_order: Mapped[int] = mapped_column( + Integer, nullable=False, server_default="0", comment="显示排序" + ) + status: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用" + ) + is_builtin: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="是否内置岗位:1内置(禁删禁停) 0自定义", + ) diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py new file mode 100644 index 0000000..693cb7b --- /dev/null +++ b/backend/app/models/customer.py @@ -0,0 +1,121 @@ +"""客户模型:客户档案与归属变更留痕。 + +- 「公司名称 + 负责人」是业务唯一标识(需规 2.1.3);负责人是客户公司的 + 负责人**人名**(字符串),不是系统内的营销人员 —— 系统内归属另用 + owner_personnel_id 外键表达,两者语义不同 +- 归属变更只改主表当前值 + 插一条留痕,**不回溯历史**(对照表 #19): + 历史核算结果在 M3 台账已固化,无需 effective_date 时点化设计 +- 留痕表(OwnershipChangeLog)对客户/业务/人员 ID 均为**无外键的冗余固化**: + 留痕是历史记录,被引用方被删除后记录仍须完整可读;RESTRICT 外键反而会 + 把「删除客户」逼成 500。业务实体删除保护由服务层检查承担 +""" + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, String, UniqueConstraint, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import ( + BIGINT_UNSIGNED, + TINYINT_TYPE, + Base, + TimestampMixin, + TPlusSyncMixin, + mysql_table_args, +) + +# 归属类型(ownership_change_logs.owner_type) +OWNER_TYPE_CUSTOMER = 1 +OWNER_TYPE_BUSINESS = 2 + + +class Customer(Base, TimestampMixin, TPlusSyncMixin): + """客户表(需求 2.1.3)。customer_type 是计提规则匹配锚点(M3 消费)。""" + + __tablename__ = "customers" + __table_args__ = mysql_table_args( + "客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)", + UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + company_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="公司名称") + owner_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="客户负责人姓名(客户公司负责人的人名,非系统人员)" + ) + contact_person: Mapped[str | None] = mapped_column(String(64), comment="联系人") + contact_phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话") + address: Mapped[str | None] = mapped_column(String(255), comment="地址") + industry: Mapped[str | None] = mapped_column(String(64), comment="所属行业(自由文本)") + customer_type: Mapped[str] = mapped_column( + String(32), + nullable=False, + server_default="personal", + index=True, + comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)", + ) + owner_personnel_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("personnel.id", ondelete="RESTRICT"), + index=True, + comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)", + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + + +class OwnershipChangeLog(Base): + """归属变更记录表(append-only,需求 2.1.3 + 对照表 #19)。 + + owner_type 区分客户归属/业务归属:1 → customer_id 非空,2 → business_id + 非空,由应用层校验(无外键,见模块 docstring 的固化论证)。 + 前后值冗余固化(无外键):人员被删除后留痕仍可读。 + """ + + __tablename__ = "ownership_change_logs" + __table_args__ = mysql_table_args( + "归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)", + Index("idx_ownership_change_logs_customer_id", "customer_id"), + Index("idx_ownership_change_logs_business_id", "business_id"), + Index("idx_ownership_change_logs_create_time", "create_time"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + owner_type: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, comment="归属类型:1客户归属 2业务归属" + ) + customer_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + comment="客户ID(固化,owner_type=1 时非空)", + ) + business_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + comment="业务ID(固化,owner_type=2 时非空)", + ) + old_owner_personnel_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, comment="变更前负责营销人员ID(固化)" + ) + new_owner_personnel_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, comment="变更后负责营销人员ID(固化)" + ) + old_assistant_personnel_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, comment="变更前协助营销人员ID(固化,仅业务归属使用)" + ) + new_assistant_personnel_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, comment="变更后协助营销人员ID(固化,仅业务归属使用)" + ) + change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因") + operator_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("users.id", ondelete="SET NULL"), + comment="操作人ID(账号删除后置空)", + ) + operator_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="操作人用户名(冗余固化)" + ) + create_time: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间" + ) diff --git a/backend/app/models/operation_log.py b/backend/app/models/operation_log.py new file mode 100644 index 0000000..23a15ff --- /dev/null +++ b/backend/app/models/operation_log.py @@ -0,0 +1,99 @@ +"""操作日志模型。 + +需求 2.5.1 要求「记录用户登录、操作行为及计提参数/系数表变更,支持日志查询」。 +登录行为已在 `login_logs` 中单独记录(登录失败也要留痕,且不依赖认证上下文); +本表记录**已认证用户的操作行为**,两者分工明确、互不重叠。 + +不继承 TimestampMixin:日志是追加写、不可变的,没有"更新时间"语义。 +""" + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, mysql_table_args + +# 各文本列的上限。**写入方必须按此截断**,否则 MySQL 严格模式抛 1406 +# (Data too long),而按"审计失败不得影响业务"的约定该异常会被吞掉 —— +# 结果是整条日志静默丢失。截断值集中定义于此,供 repository 层引用。 +USERNAME_MAX_LEN = 64 +MODULE_MAX_LEN = 64 +ACTION_MAX_LEN = 32 +SUMMARY_MAX_LEN = 255 +METHOD_MAX_LEN = 8 +PATH_MAX_LEN = 255 +IP_MAX_LEN = 45 +USER_AGENT_MAX_LEN = 255 +ERROR_MESSAGE_MAX_LEN = 500 +# request_data 是 MySQL TEXT(上限 65535 字节)。脱敏后的 JSON 可能超限 +# (如批量操作提交数千个 ID),超限同样会抛 1406,故写入方需按字节截断。 +REQUEST_DATA_MAX_BYTES = 60000 + + +class OperationLog(Base): + """操作日志表。 + + 记录原则与 `login_logs` 一致: + - **请求参数必须脱敏后入库**(复用 `core.logging.scrub`),日志是最容易泄漏 + 密码与令牌的地方 + - 用户名冗余存储而非只存 user_id —— 账号被删除后仍能追溯操作者, + 故 `username` 单独建索引(与 `login_logs` 保持一致) + - 写入失败不得影响业务(审计可丢,业务不可断) + """ + + __tablename__ = "operation_logs" + __table_args__ = mysql_table_args( + "操作日志表", + Index("idx_operation_logs_user_id", "user_id"), + # 账号删除后 user_id 被置空,只能按用户名追溯,故该列必须有索引, + # 否则日志表增长后查询退化为全表扫描 + Index("idx_operation_logs_username", "username"), + Index("idx_operation_logs_module", "module"), + Index("idx_operation_logs_create_time", "create_time"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + user_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("users.id", ondelete="SET NULL"), + comment="操作人ID(账号删除后置空)", + ) + username: Mapped[str] = mapped_column( + String(USERNAME_MAX_LEN), nullable=False, comment="操作人用户名(冗余存储便于追溯)" + ) + module: Mapped[str] = mapped_column( + String(MODULE_MAX_LEN), nullable=False, comment="所属模块,如 用户管理" + ) + action: Mapped[str] = mapped_column( + String(ACTION_MAX_LEN), nullable=False, comment="操作类型:新增/修改/删除/导出" + ) + summary: Mapped[str] = mapped_column( + String(SUMMARY_MAX_LEN), nullable=False, comment="操作摘要(中文,供人阅读)" + ) + + method: Mapped[str | None] = mapped_column(String(METHOD_MAX_LEN), comment="HTTP 方法") + path: Mapped[str | None] = mapped_column(String(PATH_MAX_LEN), comment="请求路径") + ip: Mapped[str | None] = mapped_column(String(IP_MAX_LEN), comment="客户端IP(45位兼容IPv6)") + user_agent: Mapped[str | None] = mapped_column( + String(USER_AGENT_MAX_LEN), comment="客户端User-Agent(截断存储)" + ) + # 请求参数,**入库前已脱敏**;长文本故用 TEXT + request_data: Mapped[str | None] = mapped_column(Text, comment="请求参数(已脱敏)") + + # 默认 0 而非 1:与 login_logs 保持一致,且方向更保守 —— + # 写入方漏传时记为「失败」,管理员会去核查;反之漏传记为「成功」则是 + # 审计漏报,且无声无息。写入方应显式传值(见 repository 层)。 + success: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, server_default="0", comment="是否成功:1成功 0失败" + ) + error_message: Mapped[str | None] = mapped_column( + String(ERROR_MESSAGE_MAX_LEN), comment="失败原因(写入方须截断)" + ) + duration_ms: Mapped[int | None] = mapped_column(Integer, comment="处理耗时(毫秒)") + + create_time: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间" + ) diff --git a/backend/app/models/param.py b/backend/app/models/param.py new file mode 100644 index 0000000..2404742 --- /dev/null +++ b/backend/app/models/param.py @@ -0,0 +1,159 @@ +"""计提参数模型:定义、变更批次与版本条目。 + +存储设计(M1 评审定稿): +- 参数值按**生效日期时点化**存储(逐项版本条目,append-only),核算按业务 + 日期回溯取值(对照表 #11:按回款到账日匹配参数版本),禁读「当前值」算历史 +- 「版本」由变更批次承载:一次保存 = 一个批次 = 一条变更记录;条目逐项留痕 + (旧值/新值),满足 #28 版本管理与留痕(需规 2.3.1 四) +- 值一律为 Fraction 可解析字符串(如 "4/360",禁 0.011111),解析不经 float +- **不设 (param_id, dimension_value, effective_date) 唯一键**:同日多次修正 + 是合法留痕,解析取 (effective_date, id) 最新一条;唯一键会把同日二次变更 + 逼成覆盖写、丢失中间值 +- 批次与条目均为 append-only(仅 create_time、无 update_time,同 login_logs + 先例):带 ON UPDATE 的列会让「未改也被刷新」造成留痕假象 +""" + +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args + + +class ParamDefinition(Base, TimestampMixin): + """计提参数定义表。目录元数据(类型/单位/维度/取值域),随迁移种子初始化。""" + + __tablename__ = "param_definitions" + __table_args__ = mysql_table_args("计提参数定义表") + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + param_code: Mapped[str] = mapped_column( + String(64), nullable=False, unique=True, comment="参数编码,如 salesperson.rate.slope" + ) + param_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="参数名称") + param_group: Mapped[str] = mapped_column( + String(32), + nullable=False, + comment="参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径", + ) + value_type: Mapped[str] = mapped_column( + String(16), nullable=False, comment="值类型:DECIMAL/INT/BOOL/ENUM/TEXT" + ) + unit: Mapped[str | None] = mapped_column(String(16), comment="展示单位:‰/%/元/天,无单位为空") + dimension_type: Mapped[str] = mapped_column( + String(32), + nullable=False, + server_default="NONE", + comment="维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE", + ) + options_text: Mapped[str | None] = mapped_column( + String(255), comment="枚举选项(竖线分隔),仅 ENUM 使用" + ) + default_value: Mapped[str | None] = mapped_column( + String(64), comment="系统默认值(Fraction 可解析字符串)" + ) + value_min: Mapped[str | None] = mapped_column(String(64), comment="取值下限(含),校验用") + value_max: Mapped[str | None] = mapped_column(String(64), comment="取值上限(含),校验用") + description: Mapped[str | None] = mapped_column(String(255), comment="口径说明(配置页展示)") + sort_order: Mapped[int] = mapped_column( + Integer, nullable=False, server_default="0", comment="显示排序" + ) + status: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用" + ) + + +class ParamChangeBatch(Base): + """参数变更批次表(append-only)。 + + 一次提交 = 一个批次;版本号(如 P20261012-01)供对比与追溯引用。 + 未生效批次可整批撤回(删批次与条目);已生效条目禁改禁删。 + """ + + __tablename__ = "param_change_batches" + __table_args__ = mysql_table_args("参数变更批次表") + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + version_no: Mapped[str] = mapped_column( + String(32), nullable=False, unique=True, comment="版本号,如 P20261012-01" + ) + effective_date: Mapped[date] = mapped_column( + Date, nullable=False, comment="生效日期(业务日期口径)" + ) + change_type: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, comment="生效方式:1立即生效 2指定日期生效" + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="变更说明") + operator_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("users.id", ondelete="SET NULL"), + comment="操作人ID(账号删除后置空)", + ) + operator_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="操作人用户名(冗余固化)" + ) + item_count: Mapped[int] = mapped_column( + Integer, nullable=False, server_default="0", comment="变更参数项数" + ) + create_time: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间" + ) + + +class ParamValue(Base): + """参数版本条目表(append-only)。 + + 解析规则:按 (param_id, dimension_value) 取 effective_date <= 业务日期 的 + ORDER BY effective_date DESC, id DESC 首条(同日多次变更取最新)。 + dimension_value 用空串而非 NULL:MySQL 组合索引对 NULL 不去重,空串语义更稳。 + """ + + __tablename__ = "param_values" + __table_args__ = mysql_table_args( + "参数版本条目表(append-only,按生效日期时点化取值)", + Index( + "idx_param_values_param_id_dimension_value_effective_date", + "param_id", + "dimension_value", + "effective_date", + ), + Index("idx_param_values_effective_date", "effective_date"), + Index("idx_param_values_batch_id", "batch_id"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + param_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("param_definitions.id", ondelete="RESTRICT"), + nullable=False, + comment="参数定义ID(param_definitions.id)", + ) + dimension_value: Mapped[str] = mapped_column( + String(32), + nullable=False, + server_default=text("''"), + comment="维度取值:NONE维度为空串;产品类别/岗位编码为具体值", + ) + effective_date: Mapped[date] = mapped_column( + Date, nullable=False, comment="生效日期(按业务日期时点化取值)" + ) + value_text: Mapped[str] = mapped_column( + String(64), nullable=False, comment="参数值(Fraction 可解析字符串/枚举码/开关)" + ) + old_value_text: Mapped[str | None] = mapped_column(String(64), comment="变更前值(留痕)") + batch_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("param_change_batches.id", ondelete="RESTRICT"), + nullable=False, + comment="变更批次ID(param_change_batches.id)", + ) + create_time: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间" + ) diff --git a/backend/app/models/performance_ledger.py b/backend/app/models/performance_ledger.py new file mode 100644 index 0000000..d080e57 --- /dev/null +++ b/backend/app/models/performance_ledger.py @@ -0,0 +1,181 @@ +"""绩效核算台账模型(M3 ④:每笔回款仅核算一次的固化点)。 + +**append-only 版本行**:重算 = 插入 calc_version+1 的新行,旧行仅把 +is_active 翻 0 —— 这是全表唯一的受控 UPDATE(模型注释与 test_models +均已声明例外),行内容永不改写;历史版本可回溯(重算授权留痕的需求口径)。 + +防重三层:服务层 CAS(UPDATE ... WHERE settlement_status=0,rowcount=0 +即 400)+ FOR UPDATE 行锁 + 库级 uk(receipt_id, calc_version, personnel_id) +兜底。uk 含 personnel_id:二次分配分摊(澄清 #1 答复 2026-10-19,分配 +绩效——按方案比例拆给参与人、余额归合同负责人)后同一回款同版本按人 +各落一行,拆分仍是「一次核算」的组成部分;聚合行 receipt_id 为 NULL, +MySQL 唯一索引对 NULL 不去重,天然不约束。 + +经理聚合行(calc_type=2,receipt_id 为 NULL):月度批处理时按「本团队 +业务员绩效 ×12.5% + 全公司 ×5%」落一行;其唯一性由月结生成的 +settlement_month 串行化 + 服务层保证(MySQL 无部分唯一索引,docstring +声明即可)。 + +#12 答复(2026-10-19):经理个人户业绩是否计入聚合基数改为参数 +``manager.personal.included``(默认 0=不计入)——消费点在 settlement_service +的聚合函数。 +""" + +from datetime import date +from decimal import Decimal + +from sqlalchemy import ( + JSON, + Date, + ForeignKey, + Index, + Numeric, + String, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args + +# 核算类型(performance_ledgers.calc_type) +CALC_TYPE_RECEIPT = 1 # 单笔回款核算 +CALC_TYPE_MANAGER_AGGREGATE = 2 # 经理团队聚合(月度批处理) + + +class PerformanceLedger(Base, TimestampMixin): + """绩效核算台账表:核算事实的固化点,报表 2.4.1 的数据源。""" + + __tablename__ = "performance_ledgers" + __table_args__ = mysql_table_args( + "绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)", + # 库级防重兜底:同一回款同版本按人仅一行(分摊拆行后负责人+参与人 + # 各占一行;服务层 CAS+行锁之外的第二道闸)。聚合行 receipt_id 为 + # NULL,MySQL 唯一索引对 NULL 不去重,天然不约束聚合行 + UniqueConstraint( + "receipt_id", + "calc_version", + "personnel_id", + name="uk_performance_ledgers_receipt_id_calc_version_personnel", + ), + Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"), + Index( + "idx_performance_ledgers_month_personnel_type", + "settlement_month", + "personnel_id", + "calc_type", + ), + Index("idx_performance_ledgers_personnel_id", "personnel_id"), + Index("idx_performance_ledgers_business_id", "business_id"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + receipt_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("receipts.id", ondelete="RESTRICT"), + comment="回款ID(receipts.id;聚合行可为 NULL)", + ) + calc_version: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="1", + comment="核算版本(重算+1,旧行 is_active=0)", + ) + is_active: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="1", + comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)", + ) + calc_type: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="1", + comment="核算类型:1单笔回款 2经理团队聚合(月度)", + ) + business_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)", + ) + business_no: Mapped[str] = mapped_column( + String(32), nullable=False, comment="业务编号(冗余固化,列表免联查)" + ) + personnel_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("personnel.id", ondelete="RESTRICT"), + nullable=False, + comment="归属营销人员ID(personnel.id;核算归属锚点)", + ) + personnel_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="归属营销人员姓名(冗余固化)" + ) + role_code: Mapped[str] = mapped_column( + String(32), nullable=False, comment="岗位编码(business_roles.role_code,计提公式锚点)" + ) + customer_type: Mapped[str] = mapped_column( + String(32), nullable=False, comment="客户分类:personal/manager_personal/enterprise" + ) + product_category: Mapped[str] = mapped_column( + String(32), + nullable=False, + comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)", + ) + ship_date: Mapped[date | None] = mapped_column(Date, comment="发货日期(回款天数基准)") + receipt_date: Mapped[date] = mapped_column( + Date, nullable=False, comment="回款日期(参数版本匹配日,裁决 #11)" + ) + payment_days: Mapped[int | None] = mapped_column( + comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)" + ) + rate_text: Mapped[str] = mapped_column( + String(32), + nullable=False, + comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)", + ) + converted_profit: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), comment="折算毛利润额(可负;混合业务为各行汇总)" + ) + performance_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), comment="绩效金额(可负,裁决:负绩效照算不归零)" + ) + barter_applied: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="以物抵顶折算:1已按 80% 折算 0否", + ) + weight_r: Mapped[int | None] = mapped_column(comment="支付权重 R(回款占销售额百分比向下取整)") + payment_weight: Mapped[Decimal | None] = mapped_column( + Numeric(7, 6), comment="支付权重系数 R×R(展示用)" + ) + payable_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)" + ) + settlement_month: Mapped[str] = mapped_column( + String(7), nullable=False, comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)" + ) + is_probation: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="试用期:1照算不发放 0否", + ) + missing_cost: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否", + ) + snapshot_json: Mapped[dict | None] = mapped_column( + JSON, comment="计算快照(所用参数版本与行级折算明细,重算可回溯)" + ) + settlement_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("monthly_settlements.id", ondelete="RESTRICT"), + comment="所属月结ID(月结生成时回填;NULL=未入结)", + ) + calc_reason: Mapped[str | None] = mapped_column( + String(255), comment="核算/重算原因(重算必填留痕)" + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") diff --git a/backend/app/models/personnel.py b/backend/app/models/personnel.py new file mode 100644 index 0000000..6b7e89d --- /dev/null +++ b/backend/app/models/personnel.py @@ -0,0 +1,127 @@ +"""营销人员模型:人员档案、产品辐射区域与市场拓展计划。 + +- 岗位(business_role_id)决定计提公式模板,与工资标准同属「同步不覆盖」的 + 人工维护字段(对照表 #26 落地口径,表注释标注供 M4 同步模块遵守) +- manager_id 自引用表达「经理带团队」:NULL=经理本人或未分配;M3 经理 + 12.5% 聚合、M4 部门汇总均按该关系展开,无需独立团队表 +- 一期营销人员不登录系统,不与 users 关联;若后续开放自助查工资再加列 +""" + +from datetime import date +from decimal import Decimal + +from sqlalchemy import Date, ForeignKey, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import ( + BIGINT_UNSIGNED, + TINYINT_TYPE, + Base, + TimestampMixin, + TPlusSyncMixin, + mysql_table_args, +) + +# personnel.status 的在职值(对照 BUSINESS_STATUS_* 集中在 models/business.py +# 的先例:状态语义单点导出,业务/客户两侧的归属校验共用,防魔数各自漂移) +PERSONNEL_STATUS_ACTIVE = 1 + + +class Personnel(Base, TimestampMixin, TPlusSyncMixin): + """营销人员表(需求 2.1.1)。 + + 表名 personnel 为集合名词(单复数同形),本身即复数语义。 + 试用期人员绩效照算不发放(对照表 #17),is_probation 供 M3 区分。 + """ + + __tablename__ = "personnel" + __table_args__ = mysql_table_args("营销人员表(T+同步不覆盖岗位与工资标准)") + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + personnel_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="姓名") + business_role_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("business_roles.id", ondelete="RESTRICT"), + nullable=False, + index=True, + comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)", + ) + manager_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("personnel.id", ondelete="RESTRICT"), + index=True, + comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)", + ) + phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话") + hire_date: Mapped[date | None] = mapped_column(Date, comment="入职日期") + is_probation: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="是否试用期:1是 0否(试用期绩效照算不发放)", + ) + salary_standard: Mapped[Decimal | None] = mapped_column( + Numeric(12, 2), comment="月工资标准(元;T+同步不覆盖)" + ) + status: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="1", + comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)", + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + + +class PersonnelRegion(Base, TimestampMixin): + """产品辐射区域表(需求 2.1.1):人员负责的产品辐射地理范围,1:N 子表。 + + 随人员编辑整表替换维护,无独立管理诉求,故不加 T+ 预留列。 + """ + + __tablename__ = "personnel_regions" + __table_args__ = mysql_table_args("产品辐射区域表") + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + personnel_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("personnel.id", ondelete="RESTRICT"), + nullable=False, + index=True, + comment="人员ID(personnel.id)", + ) + region_name: Mapped[str] = mapped_column( + String(128), nullable=False, comment="区域名称(产品辐射地理范围)" + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + + +class PersonnelPlan(Base, TimestampMixin): + """市场拓展计划表(需求 2.1.1):计划期间 + 计划内容 + 指标说明的自由条目。 + + 结构化指标粒度待 T+ 字段映射确认,先以自由条目容器落地;条目最终由 + T+ 同步确定,故预留 source_no 单列(无索引:查询按 personnel_id 走)。 + """ + + __tablename__ = "personnel_plans" + __table_args__ = mysql_table_args("市场拓展计划表") + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + personnel_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("personnel.id", ondelete="RESTRICT"), + nullable=False, + index=True, + comment="人员ID(personnel.id)", + ) + plan_period: Mapped[str] = mapped_column( + String(32), nullable=False, comment="计划期间(如 2026Q1、2026-05)" + ) + plan_content: Mapped[str] = mapped_column(String(500), nullable=False, comment="计划内容") + target_desc: Mapped[str | None] = mapped_column(String(255), comment="指标说明(文字描述)") + source_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 原始条目号(预留)") diff --git a/backend/app/models/product.py b/backend/app/models/product.py new file mode 100644 index 0000000..31f7aba --- /dev/null +++ b/backend/app/models/product.py @@ -0,0 +1,58 @@ +"""产品模型:产品主数据。 + +产品类别(product_category)是**折算阈值的行级锚点**:混合业务按产品逐条 +计算(对照表 #14),阈值本身由 M1 参数 `product.threshold` 按 PRODUCT_CATEGORY +维度承担(#28「全部阈值统一在系统设置维护」),本表不建阈值列 —— 两处维护 +必然漂移。类别值域与 M1 参数维度共用 PRODUCT_CATEGORIES 常量。 +""" + +from decimal import Decimal + +from sqlalchemy import Numeric, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import ( + BIGINT_UNSIGNED, + TINYINT_TYPE, + Base, + TimestampMixin, + TPlusSyncMixin, + mysql_table_args, +) + + +class Product(Base, TimestampMixin, TPlusSyncMixin): + """产品表(需求 2.1.2)。停用(status=0)为主,不物理删除。""" + + __tablename__ = "products" + __table_args__ = mysql_table_args( + "产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)", + UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + product_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="产品名称") + spec_model: Mapped[str] = mapped_column( + String(128), + nullable=False, + server_default="''", + comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)", + ) + unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位") + stock_quantity: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), comment="数量(T+ 同步原样存储,语义待 IT 确认)" + ) + sale_price: Mapped[Decimal | None] = mapped_column( + Numeric(14, 4), comment="销售单价(元,T+ 同步)" + ) + product_category: Mapped[str] = mapped_column( + String(32), + nullable=False, + comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)", + ) + status: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用" + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") diff --git a/backend/app/models/rating.py b/backend/app/models/rating.py new file mode 100644 index 0000000..f0310c3 --- /dev/null +++ b/backend/app/models/rating.py @@ -0,0 +1,46 @@ +"""客户评级等级配置模型(需求 2.2.5)。 + +评级计算器定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分记录 +不落库,等级**映射关系**可配置由本表承载。判定规则:按 sort_order 升序取 +第一个 min_score ≤ 总分的等级 —— 单边判定避免区间边界(如 75 分)在 +「≥75 且 ≤89」与「>74 且 <75」两套写法间的重叠/遗漏歧义;max_score 仅展示。 +""" + +from decimal import Decimal + +from sqlalchemy import Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args + + +class CustomerRatingGrade(Base, TimestampMixin): + """客户评级等级配置表(需求 2.2.5「等级对应关系可配置」)。 + + 种子四条随迁移初始化:A 优秀 90-100 / B 良好 75-89 / C 合格 60-74 / + D 待改进 0-59。区间不重叠与 0≤min≤max≤100 由应用层校验。 + """ + + __tablename__ = "customer_rating_grades" + __table_args__ = mysql_table_args( + "客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)" + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + grade_name: Mapped[str] = mapped_column( + String(32), nullable=False, unique=True, comment="等级名称(如 A/优秀)" + ) + min_score: Mapped[Decimal] = mapped_column( + Numeric(5, 1), + nullable=False, + comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分", + ) + max_score: Mapped[Decimal] = mapped_column( + Numeric(5, 1), nullable=False, comment="总分上限(展示用)" + ) + sort_order: Mapped[int] = mapped_column( + Integer, nullable=False, server_default="0", comment="排序(判定顺序,升序)" + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="说明") diff --git a/backend/app/models/receipt.py b/backend/app/models/receipt.py new file mode 100644 index 0000000..5930083 --- /dev/null +++ b/backend/app/models/receipt.py @@ -0,0 +1,83 @@ +"""回款模型:回款记录与批次追溯。 + +- shipment_id 为**手动指定**的发货批次(对照表 #18):可空=暂未指定; + 必须与 business_id 同属一个业务(服务层强校验,无数据库级约束可表达 + 跨表同属关系);指定后回款天数方可由 formula.payment_days 派生 +- settlement_status 是 **M3 核算防重锚点**(需规 2.3.3「每笔回款仅核算一次, + 台账记录核销状态」):预置于本表避免 M3 反向迁移改 M2 表;M3 台账建立后 + 由核算服务写入,M2 仅维护 0(未核算) +- receipt_date 是 M3 参数版本匹配日(对照表 #11:按回款到账日取时点参数) +- 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开 +""" + +from datetime import date +from decimal import Decimal + +from sqlalchemy import Date, ForeignKey, Index, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import ( + BIGINT_UNSIGNED, + TINYINT_TYPE, + Base, + TimestampMixin, + TPlusSyncMixin, + mysql_table_args, +) + +# 绩效核算状态(receipts.settlement_status) +SETTLEMENT_PENDING = 0 +SETTLEMENT_DONE = 1 + + +class Receipt(Base, TimestampMixin, TPlusSyncMixin): + """回款表(需求 2.2.3):按业务登记到账款项,批次号系统内唯一。""" + + __tablename__ = "receipts" + __table_args__ = mysql_table_args( + "回款表(手动指定发货批次;已核算回款禁改禁删)", + Index("idx_receipts_business_id", "business_id"), + Index("idx_receipts_receipt_date", "receipt_date"), + Index("idx_receipts_settlement_status", "settlement_status"), + Index("idx_receipts_shipment_id", "shipment_id"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + receipt_batch_no: Mapped[str] = mapped_column( + String(32), + nullable=False, + unique=True, + comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)", + ) + business_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("businesses.id", ondelete="RESTRICT"), + nullable=False, + comment="业务ID(businesses.id)", + ) + shipment_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("shipments.id", ondelete="RESTRICT"), + comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)", + ) + receipt_date: Mapped[date] = mapped_column( + Date, nullable=False, comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)" + ) + amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), nullable=False, comment="回款金额(≥0;一期禁负数/红字)" + ) + is_barter: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)", + ) + settlement_status: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)", + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") diff --git a/backend/app/models/settlement.py b/backend/app/models/settlement.py new file mode 100644 index 0000000..c3832fb --- /dev/null +++ b/backend/app/models/settlement.py @@ -0,0 +1,197 @@ +"""月度结算与调整单模型(M3 ④:月结审批报表补充需求)。 + +- 月度结算按「确认回款」和计算快照生成;**通过即锁定**(status=3 已审定 + 即终态,锁定后源数据改动只产生提示与后续调整) +- settlement_items 明细按人固化(可负=负数工资),锁定后无 UPDATE 路径 + (重生成 = 草稿/驳回态删旧插新) +- 调整单是已锁定月份的唯一调整通道(补发/扣减,走审批;生效后不回写 + 已锁定月结——append-only,报表汇总并入) +""" + +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import JSON, DateTime, ForeignKey, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args + +# 月结状态(monthly_settlements.status;通过即锁定——两态合一) +SETTLEMENT_STATUS_DRAFT = 1 # 草稿(可重新生成/提交) +SETTLEMENT_STATUS_PENDING = 2 # 待审批(提交后) +SETTLEMENT_STATUS_APPROVED = 3 # 已审定(终态=已锁定) +SETTLEMENT_STATUS_REJECTED = 4 # 已驳回(回草稿可重提) + +# 调整单状态(adjustment_orders.status) +ADJUSTMENT_STATUS_PENDING = 1 # 待审批 +ADJUSTMENT_STATUS_EFFECTIVE = 2 # 已生效 +ADJUSTMENT_STATUS_REJECTED = 3 # 已驳回 +ADJUSTMENT_STATUS_WITHDRAWN = 4 # 已撤回 + +# 调整方向(adjustment_orders.direction) +ADJUSTMENT_DIRECTION_EXTRA = 1 # 补发 +ADJUSTMENT_DIRECTION_DEDUCT = 2 # 扣减 + + +class MonthlySettlement(Base, TimestampMixin): + """月度结算表:按月一行(settlement_month 唯一),快照 + 审批 + 锁定。""" + + __tablename__ = "monthly_settlements" + __table_args__ = mysql_table_args("月度结算表(按月一行;通过即锁定,锁定后走调整单)") + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + settlement_month: Mapped[str] = mapped_column( + String(7), nullable=False, unique=True, comment="结算月份(YYYY-MM;全表唯一)" + ) + status: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="1", + comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)", + ) + total_performance: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), comment="绩效金额合计(可负)" + ) + total_payable: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), comment="应付绩效合计(可负)" + ) + person_count: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, server_default="0", comment="结算人数" + ) + snapshot_json: Mapped[dict | None] = mapped_column( + JSON, comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)" + ) + approval_order_no: Mapped[str | None] = mapped_column( + String(32), comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)" + ) + submitter_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("users.id", ondelete="SET NULL"), + comment="提交人ID(账号删除后置空)", + ) + submitter_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="提交人用户名(冗余固化)" + ) + locked_at: Mapped[datetime | None] = mapped_column( + DateTime, comment="锁定时间(status=3 时回填)" + ) + locked_by: Mapped[str | None] = mapped_column(String(64), comment="锁定操作人(冗余固化)") + + +class SettlementItem(Base): + """月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。""" + + __tablename__ = "settlement_items" + __table_args__ = mysql_table_args( + "月结明细表(按人固化;支持负数工资;锁定后只读)", + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + settlement_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("monthly_settlements.id", ondelete="CASCADE"), + nullable=False, + comment="月结ID(monthly_settlements.id;头删明细随删)", + ) + personnel_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("personnel.id", ondelete="RESTRICT"), + nullable=False, + comment="结算人员ID(personnel.id)", + ) + personnel_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="结算人员姓名(冗余固化)" + ) + role_code: Mapped[str] = mapped_column( + String(32), nullable=False, comment="岗位编码(主岗,聚合锚点)" + ) + calc_type: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, comment="核算类型:1单笔回款 2经理团队聚合" + ) + performance_sum: Mapped[Decimal] = mapped_column( + Numeric(14, 2), + nullable=False, + comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)", + ) + payable_sum: Mapped[Decimal] = mapped_column( + Numeric(14, 2), nullable=False, comment="应付绩效合计(可负)" + ) + basic_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), comment="月工资标准(personnel.salary_standard 当月快照)" + ) + salary_mode: Mapped[str | None] = mapped_column( + String(16), comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE" + ) + net_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), comment="净工资 = 绩效±基本工资(按口径;试用期 0)" + ) + is_probation: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否", + ) + probation_note: Mapped[str | None] = mapped_column( + String(255), comment="试用期说明(年累计超阈值部分年底一次性结算)" + ) + snapshot_json: Mapped[dict | None] = mapped_column( + JSON, comment="个人明细快照(台账行引用列表)" + ) + + +class AdjustmentOrder(Base, TimestampMixin): + """调整单:已锁定月份的唯一调整通道(补发/扣减,走审批;不回写月结)。""" + + __tablename__ = "adjustment_orders" + __table_args__ = mysql_table_args( + "调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)", + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + order_no: Mapped[str] = mapped_column( + String(32), + nullable=False, + unique=True, + comment="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}", + ) + settlement_month: Mapped[str] = mapped_column( + String(7), nullable=False, comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)" + ) + personnel_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("personnel.id", ondelete="RESTRICT"), + nullable=False, + comment="调整人员ID(personnel.id)", + ) + personnel_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="调整人员姓名(冗余固化)" + ) + direction: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, comment="方向:1补发 2扣减" + ) + amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), nullable=False, comment="调整金额(恒正,方向表达语义)" + ) + reason: Mapped[str] = mapped_column( + String(255), nullable=False, comment="扣补说明(必填,审批与追溯锚点)" + ) + status: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="1", + comment="状态:1待审批 2已生效 3已驳回 4已撤回", + ) + submitter_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("users.id", ondelete="SET NULL"), + comment="提交人ID(账号删除后置空)", + ) + submitter_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="提交人用户名(冗余固化)" + ) diff --git a/backend/app/models/shipment.py b/backend/app/models/shipment.py new file mode 100644 index 0000000..89c466c --- /dev/null +++ b/backend/app/models/shipment.py @@ -0,0 +1,115 @@ +"""发货模型:发货单与发货产品明细。 + +- ship_date 是**回款天数计算基准日**(对照表 #1:发货日首日计第 1 天), + 回款指定本发货单后由 formula.payment_days 派生回款天数,不在本表落库 +- 运费仅记录展示,不计入毛利润口径(对照表 #2) +- total_amount 为明细行汇总冗余,服务层事务内维护 +""" + +from datetime import date +from decimal import Decimal + +from sqlalchemy import Date, ForeignKey, Index, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import ( + BIGINT_UNSIGNED, + Base, + TimestampMixin, + TPlusSyncMixin, + mysql_table_args, +) + + +class Shipment(Base, TimestampMixin, TPlusSyncMixin): + """发货单表(需求 2.2.2):每笔发货对应一个业务,支持分批发货。""" + + __tablename__ = "shipments" + __table_args__ = mysql_table_args( + "发货单表(发货日为回款天数基准日,发货日首日计第 1 天)", + Index("idx_shipments_business_id", "business_id"), + Index("idx_shipments_ship_date", "ship_date"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + shipment_no: Mapped[str] = mapped_column( + String(32), + nullable=False, + unique=True, + comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)", + ) + business_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("businesses.id", ondelete="RESTRICT"), + nullable=False, + comment="业务ID(businesses.id;终态业务禁止新发货)", + ) + ship_date: Mapped[date] = mapped_column( + Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)" + ) + freight_amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), + nullable=False, + server_default="0", + comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)", + ) + total_amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), + nullable=False, + server_default="0", + comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)", + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + + +class ShipmentItem(Base, TimestampMixin): + """发货产品明细表(需求 2.2.2):记录发货的产品名称、规格、数量等。 + + 价格口径两可(T+ 供价或人工录入,待 IT 确认):sale_price 可空人工补, + amount 由服务层按 数量×单价 计算固化。 + """ + + __tablename__ = "shipment_items" + __table_args__ = mysql_table_args( + "发货产品明细表", + Index("idx_shipment_items_shipment_id", "shipment_id"), + Index("idx_shipment_items_product_id", "product_id"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + shipment_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("shipments.id", ondelete="RESTRICT"), + nullable=False, + comment="发货单ID(shipments.id)", + ) + product_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("products.id", ondelete="RESTRICT"), + comment="产品ID(products.id;NULL=自由录入)", + ) + product_name: Mapped[str] = mapped_column( + String(128), nullable=False, comment="产品名称(引用产品时冗余固化)" + ) + spec_model: Mapped[str] = mapped_column( + String(128), + nullable=False, + server_default="''", + comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)", + ) + unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位") + quantity: Mapped[Decimal] = mapped_column( + Numeric(14, 2), nullable=False, comment="发货数量(>0)" + ) + sale_price: Mapped[Decimal | None] = mapped_column( + Numeric(14, 4), comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)" + ) + amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), + nullable=False, + comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)", + ) diff --git a/backend/app/models/system_setting.py b/backend/app/models/system_setting.py new file mode 100644 index 0000000..334e8aa --- /dev/null +++ b/backend/app/models/system_setting.py @@ -0,0 +1,31 @@ +"""系统设置模型:公司信息单例行。 + +与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期 +时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列 +value_text 为 String(64) 装不下;故独立单行表。logo 只存相对路径——文件在 +`backend/uploads/logo/`,独立于数据库备份体积且可单独替换(见 +system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底,upsert 维护)。 +""" + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args + + +class SystemSetting(Base, TimestampMixin): + """系统设置表:本期仅公司信息(公司名称 / logo 路径),全系统单行 id=1。""" + + __tablename__ = "system_settings" + __table_args__ = mysql_table_args("系统设置表(公司信息,全系统单行)") + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + company_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="公司名称(登录页与导出页眉展示)" + ) + logo_path: Mapped[str | None] = mapped_column( + String(255), + comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)", + ) diff --git a/backend/app/repositories/business_repository.py b/backend/app/repositories/business_repository.py new file mode 100644 index 0000000..acc358d --- /dev/null +++ b/backend/app/repositories/business_repository.py @@ -0,0 +1,299 @@ +"""业务数据仓储:业务单、明细、状态留痕与归属留痕。 + +只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。 +归属留痕复用 ownership_change_logs(owner_type=2 为业务维度,常量在 +models/customer.py 定义)。 +""" + +from datetime import date + +from sqlalchemy import Row, Select, delete, exists, func, or_, select +from sqlalchemy.orm import Session, aliased + +from app.models import ( + Business, + BusinessItem, + BusinessStatusLog, + Customer, + OwnershipChangeLog, + Personnel, + Receipt, + SecondaryAllocation, + Shipment, +) +from app.models.customer import OWNER_TYPE_BUSINESS +from app.repositories import ownership_log_repository + + +def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None: + """按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。""" + stmt = select(Business).where(Business.id == business_id) + if for_update: + stmt = stmt.with_for_update() + return session.execute(stmt).scalars().first() + + +def has_docs(session: Session, business_id: int) -> bool: + """业务名下是否已有发货单或回款(换客户/删除保护的引用检查)。 + + 单条 OR exists 一次往返;新增挂靠业务的单据类型时在此补充条件, + 编辑与删除两处共用同一判定,防「delete 补了检查、update 漏补」的分叉。 + """ + stmt = select( + or_( + exists().where(Shipment.business_id == business_id), + exists().where(Receipt.business_id == business_id), + ) + ) + return bool(session.execute(stmt).scalar()) + + +def create(session: Session, business: Business) -> None: + """新增业务行(不 commit,flush 让自增 id 就位)。""" + session.add(business) + session.flush() + + +def get_with_names( + session: Session, business_id: int, *, for_update: bool = False +) -> tuple[Business, str, str | None, str | None] | None: + """业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。 + + for_update 供编辑/状态流转/归属变更使用:与发货/回款写路径的 + get_by_id(for_update=True) 同一锁口径,编辑事务与单据写事务互斥, + 「有发货禁换客户」的快照读校验不再被并发新增旁路。 + """ + owner = aliased(Personnel) + assistant = aliased(Personnel) + stmt = ( + select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name) + .join(Customer, Customer.id == Business.customer_id) + .outerjoin(owner, owner.id == Business.owner_personnel_id) + .outerjoin(assistant, assistant.id == Business.assistant_personnel_id) + .where(Business.id == business_id) + ) + if for_update: + stmt = stmt.with_for_update() + return session.execute(stmt).first() + + +def _apply_filters( + stmt: Select, + *, + keyword: str | None, + status: int | None, + customer_id: int | None, + owner_personnel_id: int | None, + date_from: date | None, + date_to: date | None, +) -> Select: + """业务列表的筛选条件(调用方须已 join Customer)。""" + if keyword: + # 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy + # 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合) + stmt = stmt.where( + or_( + Business.business_no.contains(keyword, autoescape=True), + Customer.company_name.contains(keyword, autoescape=True), + ) + ) + if status is not None: + stmt = stmt.where(Business.status == status) + if customer_id is not None: + stmt = stmt.where(Business.customer_id == customer_id) + if owner_personnel_id is not None: + stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id) + if date_from is not None: + stmt = stmt.where(Business.business_date >= date_from) + if date_to is not None: + stmt = stmt.where(Business.business_date <= date_to) + return stmt + + +def list_businesses( + session: Session, + *, + offset: int, + limit: int, + keyword: str | None, + status: int | None, + customer_id: int | None, + owner_personnel_id: int | None, + date_from: date | None, + date_to: date | None, +) -> list[tuple[Business, str, str | None, str | None]]: + """分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。""" + owner = aliased(Personnel) + assistant = aliased(Personnel) + stmt = select( + Business, Customer.company_name, owner.personnel_name, assistant.personnel_name + ).join(Customer, Customer.id == Business.customer_id) + stmt = _apply_filters( + stmt, + keyword=keyword, + status=status, + customer_id=customer_id, + owner_personnel_id=owner_personnel_id, + date_from=date_from, + date_to=date_to, + ) + stmt = ( + stmt.outerjoin(owner, owner.id == Business.owner_personnel_id) + .outerjoin(assistant, assistant.id == Business.assistant_personnel_id) + .order_by(Business.id.desc()) + .offset(offset) + .limit(limit) + ) + return list(session.execute(stmt).all()) + + +def count_businesses( + session: Session, + *, + keyword: str | None, + status: int | None, + customer_id: int | None, + owner_personnel_id: int | None, + date_from: date | None, + date_to: date | None, +) -> int: + stmt = ( + select(func.count()) + .select_from(Business) + .join(Customer, Customer.id == Business.customer_id) + ) + stmt = _apply_filters( + stmt, + keyword=keyword, + status=status, + customer_id=customer_id, + owner_personnel_id=owner_personnel_id, + date_from=date_from, + date_to=date_to, + ) + return int(session.execute(stmt).scalar_one()) + + +def list_items(session: Session, business_id: int) -> list[BusinessItem]: + """业务明细行(编辑回显与汇总重算共用)。""" + return list( + session.execute( + select(BusinessItem) + .where(BusinessItem.business_id == business_id) + .order_by(BusinessItem.id) + ).scalars() + ) + + +def replace_items(session: Session, business_id: int, rows: list[BusinessItem]) -> None: + """全量替换明细:先删、立即 flush、再插入(replace_roles 先例的防御写法)。 + + 同一 flush 中 SQLAlchemy 先处理 INSERT 后处理 DELETE,先删后插不落库 + 会在有唯一键的表上撞键;business_items 虽无唯一键,统一按此写法防御。 + """ + session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id)) + session.flush() + for row in rows: + row.business_id = business_id + session.add(row) + session.flush() + + +def list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]: + """二次分配方案(详情回显;空列表=未设置方案)。""" + return list( + session.execute( + select(SecondaryAllocation) + .where(SecondaryAllocation.business_id == business_id) + .order_by(SecondaryAllocation.id) + ).scalars() + ) + + +def replace_allocations( + session: Session, business_id: int, rows: list[SecondaryAllocation] +) -> None: + """全量替换二次分配方案(不 commit,事务边界在服务层)。 + + 本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的 + 防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。 + """ + session.execute( + delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id) + ) + session.flush() + for row in rows: + row.business_id = business_id + session.add(row) + session.flush() + + +def delete_allocations(session: Session, business_id: int) -> None: + """清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。 + + 被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到); + 方案事实由审批单留痕与操作日志 request_data 承载。 + """ + session.execute( + delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id) + ) + session.flush() + + +def add_status_log(session: Session, log: BusinessStatusLog) -> None: + """追加状态留痕(不 commit)。""" + session.add(log) + session.flush() + + +def list_status_logs( + session: Session, business_id: int, *, offset: int, limit: int +) -> list[BusinessStatusLog]: + """状态流转时间线(详情页),按时间正序(时间线从创建起展示)。""" + return list( + session.execute( + select(BusinessStatusLog) + .where(BusinessStatusLog.business_id == business_id) + .order_by(BusinessStatusLog.create_time, BusinessStatusLog.id) + .offset(offset) + .limit(limit) + ).scalars() + ) + + +def count_status_logs(session: Session, business_id: int) -> int: + return int( + session.execute( + select(func.count()) + .select_from(BusinessStatusLog) + .where(BusinessStatusLog.business_id == business_id) + ).scalar_one() + ) + + +def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None: + """追加归属变更留痕(共享实现,不 commit)。""" + ownership_log_repository.add(session, log) + + +def list_ownership_logs( + session: Session, business_id: int, *, offset: int, limit: int +) -> list[Row]: + """某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。""" + return ownership_log_repository.list_by_ref( + session, + owner_type=OWNER_TYPE_BUSINESS, + ref_column=OwnershipChangeLog.business_id, + ref_id=business_id, + offset=offset, + limit=limit, + ) + + +def count_ownership_logs(session: Session, business_id: int) -> int: + return ownership_log_repository.count_by_ref( + session, + owner_type=OWNER_TYPE_BUSINESS, + ref_column=OwnershipChangeLog.business_id, + ref_id=business_id, + ) diff --git a/backend/app/repositories/business_role_repository.py b/backend/app/repositories/business_role_repository.py new file mode 100644 index 0000000..a45f7af --- /dev/null +++ b/backend/app/repositories/business_role_repository.py @@ -0,0 +1,102 @@ +"""业务岗位角色数据访问。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 +""" + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session + +from app.models import BusinessRole + +# ==================== 读取 ==================== + + +def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> BusinessRole | None: + """按主键查询。删除校验走 ``for_update`` 锁行(同 role_repository 的理由)。""" + if for_update: + stmt = select(BusinessRole).where(BusinessRole.id == role_id).with_for_update() + return session.execute(stmt).scalar_one_or_none() + return session.get(BusinessRole, role_id) + + +def get_by_code(session: Session, role_code: str) -> BusinessRole | None: + """按编码查询岗位。编码是参数维度值(dimension_value)的锚点。""" + stmt = select(BusinessRole).where(BusinessRole.role_code == role_code) + return session.execute(stmt).scalar_one_or_none() + + +def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, BusinessRole]: + """按编码批量取岗位(参数批次校验段用,消除逐条 N+1)。 + + 返回 {编码: 岗位}。MySQL ci 校对下编码大小写变形也能命中, + 键取库里的规范 role_code,调用方据此把维度值归一为规范值。 + """ + if not role_codes: + return {} + rows = session.execute( + select(BusinessRole).where(BusinessRole.role_code.in_(role_codes)) + ).scalars() + return {role.role_code: role for role in rows} + + +def list_roles( + session: Session, + *, + offset: int, + limit: int, + keyword: str | None = None, + status: int | None = None, +) -> list[BusinessRole]: + """分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。""" + stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status) + stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit) + return list(session.execute(stmt).scalars()) + + +def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int: + """统计符合条件的岗位总数,供分页使用。""" + stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status) + return int(session.execute(stmt).scalar_one()) + + +def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select: + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。 + + 关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线, + 不转义时 ``_`` 会当单字符通配执行。 + """ + if keyword: + stmt = stmt.where( + or_( + BusinessRole.role_code.contains(keyword, autoescape=True), + BusinessRole.role_name.contains(keyword, autoescape=True), + ) + ) + if status is not None: + stmt = stmt.where(BusinessRole.status == status) + return stmt + + +def count_personnel_by_role(session: Session, role_ids: list[int]) -> dict[int, int]: + """批量统计各岗位下的人员数(删除保护的依据)。 + + ② 基础数据管理落地 personnel 表后在此实现真实计数(一次 GROUP BY, + 禁止列表循环内逐行查询);当前无人员表,恒返回空,删除校验对空字典放行。 + """ + return {} + + +# ==================== 写入 ==================== + + +def create(session: Session, role: BusinessRole) -> BusinessRole: + """新增岗位。只 flush 以取得自增 id。""" + session.add(role) + session.flush() + return role + + +def delete(session: Session, role: BusinessRole) -> None: + """删除岗位。调用方须先完成保护规则校验。""" + session.delete(role) + session.flush() diff --git a/backend/app/repositories/customer_repository.py b/backend/app/repositories/customer_repository.py new file mode 100644 index 0000000..9e10511 --- /dev/null +++ b/backend/app/repositories/customer_repository.py @@ -0,0 +1,178 @@ +"""客户数据访问(含归属变更留痕)。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 +""" + +from sqlalchemy import ColumnElement, Row, Select, func, or_, select +from sqlalchemy.orm import Session + +from app.models import Customer, OwnershipChangeLog, Personnel +from app.models.customer import OWNER_TYPE_CUSTOMER +from app.repositories import ownership_log_repository +from app.repositories.query_utils import fuzzy + +# ==================== 读取 ==================== + + +def get_by_id(session: Session, customer_id: int) -> Customer | None: + """按主键查询客户。""" + return session.get(Customer, customer_id) + + +def count_business_refs(session: Session, customer_id: int) -> int: + """被业务单据引用的次数(删除保护;业务表就位后补齐的检查)。""" + from app.models import Business + + return int( + session.execute( + select(func.count()).select_from(Business).where(Business.customer_id == customer_id) + ).scalar_one() + ) + + +def get_with_owner_name(session: Session, customer_id: int) -> Row | None: + """查单个客户并带出归属人员姓名(详情、编辑回显与归属变更共用)。""" + stmt = _base_select().where(Customer.id == customer_id) + return session.execute(stmt).first() + + +def get_by_company_owner(session: Session, company_name: str, owner_name: str) -> Customer | None: + """按「公司名称 + 负责人」查客户(uk_customers_company_owner 的先查后写兜底)。""" + stmt = select(Customer).where( + Customer.company_name == company_name, + Customer.owner_name == owner_name, + ) + return session.execute(stmt).scalar_one_or_none() + + +def list_customers( + session: Session, + *, + offset: int, + limit: int, + keyword: str | None = None, + customer_type: str | None = None, + owner_personnel_id: int | None = None, +) -> list[Row]: + """分页查询客户,行内容为 (客户, 归属人员姓名)。 + + 归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据) + 不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。 + 排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。 + """ + stmt = _apply_filters( + _base_select(), + keyword=keyword, + customer_type=customer_type, + owner_personnel_id=owner_personnel_id, + ) + stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit) + return list(session.execute(stmt).all()) + + +def count_customers( + session: Session, + *, + keyword: str | None = None, + customer_type: str | None = None, + owner_personnel_id: int | None = None, +) -> int: + """统计符合条件的客户总数,供分页使用。 + + 筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由 + ``_apply_filters`` 单点保证)。 + """ + stmt = _apply_filters( + select(func.count(Customer.id)), + keyword=keyword, + customer_type=customer_type, + owner_personnel_id=owner_personnel_id, + ) + return int(session.execute(stmt).scalar_one()) + + +def list_ownership_logs( + session: Session, + *, + customer_id: int, + offset: int, + limit: int, +) -> list[Row]: + """查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。""" + return ownership_log_repository.list_by_ref( + session, + owner_type=OWNER_TYPE_CUSTOMER, + ref_column=OwnershipChangeLog.customer_id, + ref_id=customer_id, + offset=offset, + limit=limit, + ) + + +def count_ownership_logs(session: Session, *, customer_id: int) -> int: + """统计某客户的归属留痕总数,供分页使用。""" + return ownership_log_repository.count_by_ref( + session, + owner_type=OWNER_TYPE_CUSTOMER, + ref_column=OwnershipChangeLog.customer_id, + ref_id=customer_id, + ) + + +def _base_select() -> Select: + """客户主表外联归属人员姓名。""" + return select(Customer, Personnel.personnel_name).outerjoin( + Personnel, Customer.owner_personnel_id == Personnel.id + ) + + +def _apply_filters( + stmt: Select, + *, + keyword: str | None, + customer_type: str | None, + owner_personnel_id: int | None, +) -> Select: + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。""" + if keyword: + stmt = stmt.where(_keyword_clause(keyword)) + if customer_type: + stmt = stmt.where(Customer.customer_type == customer_type) + if owner_personnel_id is not None: + stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id) + return stmt + + +def _keyword_clause(keyword: str) -> ColumnElement[bool]: + """关键词条件:公司名称或客户负责人两列 OR 模糊。 + + 借 ``query_utils.fuzzy`` 在空 select 上生成单列条件后取 whereclause 做 + OR 合并 —— LIKE 通配符转义口径单点维护;两处手写 ``contains`` 迟早有 + 一处漏掉 ``autoescape``(人名含下划线是常态,不转义会当单字符通配执行)。 + """ + probe = select(Customer.id) + return or_( + fuzzy(probe, Customer.company_name, keyword).whereclause, + fuzzy(probe, Customer.owner_name, keyword).whereclause, + ) + + +# ==================== 写入 ==================== + + +def create(session: Session, customer: Customer) -> Customer: + """新增客户。只 flush 以取得自增 id。""" + session.add(customer) + session.flush() + return customer + + +def delete(session: Session, customer: Customer) -> None: + """删除客户。调用方须先完成引用保护校验。""" + session.delete(customer) + session.flush() + + +def add_ownership_log(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog: + """写入一条归属变更留痕(共享实现)。只 flush 不 commit,与主表更新同事务。""" + return ownership_log_repository.add(session, log) diff --git a/backend/app/repositories/login_log_repository.py b/backend/app/repositories/login_log_repository.py index 5c230bc..122e9e6 100644 --- a/backend/app/repositories/login_log_repository.py +++ b/backend/app/repositories/login_log_repository.py @@ -1,8 +1,10 @@ """登录日志数据访问。""" +from sqlalchemy import Select, func, select from sqlalchemy.orm import Session from app.models import LoginLog +from app.repositories.query_utils import LogFilters, apply_log_filters # user_agent 列宽,超长需截断否则插入报错 _USER_AGENT_MAX_LEN = 255 @@ -36,3 +38,40 @@ def add( ) ) session.flush() + + +def list_logs( + session: Session, + *, + offset: int, + limit: int, + filters: LogFilters, + ip: str | None = None, +) -> list[LoginLog]: + """分页查询登录日志。 + + **按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序 + 不确定,翻页会重复或遗漏(与用户列表同一理由)。 + """ + stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip) + stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit) + return list(session.execute(stmt).scalars()) + + +def count_logs( + session: Session, + *, + filters: LogFilters, + ip: str | None = None, +) -> int: + """统计符合条件的登录日志总数,供分页使用。""" + stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip) + return int(session.execute(stmt).scalar_one()) + + +def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select: + """应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。""" + stmt = apply_log_filters(stmt, LoginLog, filters) + if ip: + stmt = stmt.where(LoginLog.ip == ip) + return stmt diff --git a/backend/app/repositories/operation_log_repository.py b/backend/app/repositories/operation_log_repository.py new file mode 100644 index 0000000..20b74c3 --- /dev/null +++ b/backend/app/repositories/operation_log_repository.py @@ -0,0 +1,214 @@ +"""操作日志数据访问。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 + +写入前处理(截断 / 脱敏 / 序列化)集中在本层,与 `login_log_repository` +的做法一致:模型定义的长度常量在这里落实,调用方只需传业务值。 +""" + +import json +from dataclasses import dataclass + +from sqlalchemy import Select, func, select +from sqlalchemy.orm import Session, defer + +from app.core.logging import scrub +from app.models import OperationLog +from app.models.operation_log import ( + ACTION_MAX_LEN, + ERROR_MESSAGE_MAX_LEN, + IP_MAX_LEN, + METHOD_MAX_LEN, + MODULE_MAX_LEN, + PATH_MAX_LEN, + REQUEST_DATA_MAX_BYTES, + SUMMARY_MAX_LEN, + USER_AGENT_MAX_LEN, + USERNAME_MAX_LEN, +) +from app.repositories.query_utils import LogFilters, apply_log_filters + +# 键名(不区分大小写)含以下子串的字段,其值一律整体抹除。 +# 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 —— +# 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、 +# 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。 +# phone/mobile/id_card 系 CLAUDE.md「日志禁输出手机号/身份证」红线的键名口径 —— +# 带分隔符的号码会穿过文本正则,必须在这里按整值抹除 +_SENSITIVE_KEY_PARTS = ( + "password", + "passwd", + "pwd", + "secret", + "token", + "app_key", + "access_key", + "phone", + "mobile", + "id_card", + "idcard", +) + +_MASK = "***" + + +@dataclass(frozen=True, slots=True) +class OperationLogEntry: + """一条待写入的操作日志(全字段 DTO)。 + + 替代 14 个平铺参数:字段若在 audit() / _safe_write() / add() 三处 + 重复展开,增删字段时任何一侧漏改都只表现为审计数据不完整、不报错 + (success 默认 0 的语义使漏传尤其隐蔽)。 + """ + + user_id: int | None + username: str + module: str + action: str + summary: str + success: bool + method: str | None = None + path: str | None = None + ip: str | None = None + user_agent: str | None = None + request_data: dict | None = None + error_message: str | None = None + duration_ms: int | None = None + + +def add(session: Session, entry: OperationLogEntry) -> None: + """写入一条操作日志。只 flush 不 commit。 + + 所有文本列按模型定义的长度常量截断 —— 超长在 MySQL 严格模式下抛 1406, + 而「审计失败不影响业务」的约定会把该异常吞掉,结果是整条日志**静默丢失**, + 事后极难排查。截断是唯一防线,必须在此层无条件执行。 + """ + session.add( + OperationLog( + user_id=entry.user_id, + username=entry.username[:USERNAME_MAX_LEN], + module=entry.module[:MODULE_MAX_LEN], + action=entry.action[:ACTION_MAX_LEN], + summary=entry.summary[:SUMMARY_MAX_LEN], + method=_clip(entry.method, METHOD_MAX_LEN), + path=_clip(entry.path, PATH_MAX_LEN), + ip=_clip(entry.ip, IP_MAX_LEN), + user_agent=_clip(entry.user_agent, USER_AGENT_MAX_LEN), + request_data=_serialize_request_data(entry.request_data), + success=1 if entry.success else 0, + error_message=_clip(entry.error_message, ERROR_MESSAGE_MAX_LEN), + duration_ms=entry.duration_ms, + ) + ) + session.flush() + + +def get_by_id(session: Session, log_id: int) -> OperationLog | None: + """按主键查询单条日志(详情端点用,含 request_data)。不存在返回 None。""" + return session.get(OperationLog, log_id) + + +def list_logs( + session: Session, + *, + offset: int, + limit: int, + filters: LogFilters, + module: str | None = None, + action: str | None = None, +) -> list[OperationLog]: + """分页查询操作日志。 + + **按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序 + 不确定,翻页会重复或遗漏(与用户列表同一理由)。 + + **排除 request_data 列**(TEXT,单行最大 60000 字节):列表页默认不 + 展示它,全列加载会让翻页负担随日志表增长线性放大;详情走 `get_by_id` + 按需读取。 + """ + stmt = _apply_filters( + select(OperationLog).options(defer(OperationLog.request_data)), + filters=filters, + module=module, + action=action, + ) + stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit) + return list(session.execute(stmt).scalars()) + + +def count_logs( + session: Session, + *, + filters: LogFilters, + module: str | None = None, + action: str | None = None, +) -> int: + """统计符合条件的日志总数,供分页使用。""" + stmt = _apply_filters( + select(func.count(OperationLog.id)), filters=filters, module=module, action=action + ) + return int(session.execute(stmt).scalar_one()) + + +def _apply_filters( + stmt: Select, + *, + filters: LogFilters, + module: str | None, + action: str | None, +) -> Select: + """应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。""" + stmt = apply_log_filters(stmt, OperationLog, filters) + if module: + stmt = stmt.where(OperationLog.module == module) + if action: + stmt = stmt.where(OperationLog.action == action) + return stmt + + +def _clip(text: str | None, limit: int) -> str | None: + """按字符数截断(长度常量与数据库列宽均按字符定义)。""" + return text[:limit] if text else None + + +def _is_sensitive_key(key: object) -> bool: + name = str(key).lower() + return any(part in name for part in _SENSITIVE_KEY_PARTS) + + +def _sanitize_sensitive(value: object) -> object: + """递归抹除请求参数中的敏感字段值(dict 按键名整体替换、list 逐项递归)。""" + if isinstance(value, dict): + return { + key: _MASK if _is_sensitive_key(key) else _sanitize_sensitive(item) + for key, item in value.items() + } + if isinstance(value, list): + return [_sanitize_sensitive(item) for item in value] + return value + + +def _serialize_request_data(data: dict | None) -> str | None: + """请求参数 → JSON 文本:两段脱敏 → 字节边界截断。 + + 脱敏分两层,各司其职: + + - **结构化抹除**(`_sanitize_sensitive`):按键名整体置 ``***``, + 不依赖文本模式匹配 —— 密码含空格、引号、前导空白等形态都不受影响 + - `core.logging.scrub` 作文本兜底:覆盖手机号、Bearer 等无法按键名 + 判断的值 + + 截断必须在 **UTF-8 字节边界** 进行:request_data 列是 TEXT(65535 字节 + 上限),而 request_data 上限 60000 字节也是按字节定的;若按字符切片, + 中文场景下字符数与字节数相差 3 倍同样可能超限;先编码再按字节切片 + 则可能把多字节字符切成两半产生非法序列。故顺序为:编码 → 字节切片 → + 宽容解码丢弃残片。encode 用 ``errors="replace"`` 兜底孤立代理字符 —— + 请求 JSON 里的 ``\\ud800`` 转义会解析为孤立代理,直接 encode 会抛 + UnicodeEncodeError,被审计兜底逻辑吞掉后该请求的记录**静默丢失**。 + """ + if not data: + return None + text = scrub(json.dumps(_sanitize_sensitive(data), ensure_ascii=False, default=str)) + encoded = text.encode("utf-8", errors="replace") + if len(encoded) <= REQUEST_DATA_MAX_BYTES: + return text + return encoded[:REQUEST_DATA_MAX_BYTES].decode("utf-8", errors="ignore") diff --git a/backend/app/repositories/ownership_log_repository.py b/backend/app/repositories/ownership_log_repository.py new file mode 100644 index 0000000..c5bb7ab --- /dev/null +++ b/backend/app/repositories/ownership_log_repository.py @@ -0,0 +1,56 @@ +"""归属变更留痕的共享数据访问(客户/业务两维度共用)。 + +owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧各持一套逐行 +同构的 list/count/add,排序口径与 join 兜底的调整须同步两处;收口后 +「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。 +""" + +from sqlalchemy import Row, func, select +from sqlalchemy.orm import Session, aliased + +from app.models import OwnershipChangeLog, Personnel + + +def list_by_ref( + session: Session, + *, + owner_type: int, + ref_column, + ref_id: int, + offset: int, + limit: int, +) -> list[Row]: + """查某引用对象的归属留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。 + + 人员删除后 join 出 None,留痕固化 ID 兜底可读。按 (create_time, id) + 双键倒序:create_time 是秒级精度,同一秒内多条排序不确定,id 兜底 + 保证翻页稳定(与操作日志同一理由)。 + """ + old_person = aliased(Personnel) + new_person = aliased(Personnel) + stmt = ( + select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name) + .outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id) + .outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id) + .where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id) + .order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc()) + .offset(offset) + .limit(limit) + ) + return list(session.execute(stmt).all()) + + +def count_by_ref(session: Session, *, owner_type: int, ref_column, ref_id: int) -> int: + """统计某引用对象的归属留痕总数,供分页使用。""" + stmt = select(func.count(OwnershipChangeLog.id)).where( + OwnershipChangeLog.owner_type == owner_type, + ref_column == ref_id, + ) + return int(session.execute(stmt).scalar_one()) + + +def add(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog: + """写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。""" + session.add(log) + session.flush() + return log diff --git a/backend/app/repositories/param_repository.py b/backend/app/repositories/param_repository.py new file mode 100644 index 0000000..270b9e8 --- /dev/null +++ b/backend/app/repositories/param_repository.py @@ -0,0 +1,242 @@ +"""计提参数数据访问。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 +时点化解析查询(窗口函数版)留存 db/queries/计提参数.sql。 +""" + +from datetime import date + +from sqlalchemy import Select, delete, func, select +from sqlalchemy.orm import Session + +from app.models import ParamChangeBatch, ParamDefinition, ParamValue + +# ==================== 读取 ==================== + + +def resolve_values(session: Session, biz_date: date) -> list[tuple[str, str, str, date]]: + """时点化解析:每个 (param_id, dimension_value) 取生效日期 <= 业务日期的最新条目。 + + 排序 (effective_date DESC, id DESC) 保证同日多次变更取最新一条 + (不设日期唯一键,同日修正是合法留痕)。仅返回启用状态的参数定义。 + 返回 (param_code, dimension_value, value_text, effective_date)。 + """ + row_number = ( + func.row_number() + .over( + partition_by=(ParamValue.param_id, ParamValue.dimension_value), + order_by=(ParamValue.effective_date.desc(), ParamValue.id.desc()), + ) + .label("rn") + ) + subq = ( + select( + ParamDefinition.param_code.label("param_code"), + ParamValue.dimension_value.label("dimension_value"), + ParamValue.value_text.label("value_text"), + ParamValue.effective_date.label("effective_date"), + row_number, + ) + .join(ParamDefinition, ParamDefinition.id == ParamValue.param_id) + .where(ParamValue.effective_date <= biz_date, ParamDefinition.status == 1) + .subquery() + ) + stmt = select( + subq.c.param_code, subq.c.dimension_value, subq.c.value_text, subq.c.effective_date + ).where(subq.c.rn == 1) + return [ + (row.param_code, row.dimension_value, row.value_text, row.effective_date) + for row in session.execute(stmt) + ] + + +def latest_value_before( + session: Session, param_id: int, dimension_value: str, biz_date: date +) -> ParamValue | None: + """取某参数某维度在业务日期(含)之前的最新条目 —— 提交变更时记录「旧值」。""" + stmt = ( + select(ParamValue) + .where( + ParamValue.param_id == param_id, + ParamValue.dimension_value == dimension_value, + ParamValue.effective_date <= biz_date, + ) + .order_by(ParamValue.effective_date.desc(), ParamValue.id.desc()) + .limit(1) + ) + return session.execute(stmt).scalar_one_or_none() + + +def list_definitions(session: Session, *, status: int | None = None) -> list[ParamDefinition]: + """参数定义列表(目录)。""" + stmt = select(ParamDefinition).order_by(ParamDefinition.sort_order, ParamDefinition.id) + if status is not None: + stmt = stmt.where(ParamDefinition.status == status) + return list(session.execute(stmt).scalars()) + + +def get_definition_by_code(session: Session, param_code: str) -> ParamDefinition | None: + """按编码取参数定义。""" + stmt = select(ParamDefinition).where(ParamDefinition.param_code == param_code) + return session.execute(stmt).scalar_one_or_none() + + +def list_definitions_by_codes( + session: Session, param_codes: list[str] +) -> dict[str, ParamDefinition]: + """按编码批量取参数定义(提交批次的校验段用,消除逐条 N+1)。 + + 返回 {编码: 定义};未定义的编码不在结果中,由调用方判缺失。 + """ + if not param_codes: + return {} + rows = session.execute( + select(ParamDefinition).where(ParamDefinition.param_code.in_(param_codes)) + ).scalars() + return {definition.param_code: definition for definition in rows} + + +def list_history( + session: Session, + param_id: int, + *, + offset: int, + limit: int, + dimension_value: str | None = None, +) -> list[tuple[ParamValue, ParamChangeBatch]]: + """单参数的版本历史(含批次信息),最新在前。""" + stmt = _history_stmt(session, param_id, dimension_value=dimension_value) + return [(value, batch) for value, batch in session.execute(stmt.offset(offset).limit(limit))] + + +def count_history(session: Session, param_id: int, *, dimension_value: str | None = None) -> int: + """统计单参数的历史条目数。""" + stmt = select(func.count()).select_from( + _history_stmt(session, param_id, dimension_value=dimension_value).subquery() + ) + return int(session.execute(stmt).scalar_one()) + + +def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select: + """历史查询基语句:条目 join 批次(版本号/操作人),最新在前。""" + stmt = ( + select(ParamValue, ParamChangeBatch) + .join(ParamChangeBatch, ParamChangeBatch.id == ParamValue.batch_id) + .where(ParamValue.param_id == param_id) + .order_by(ParamValue.effective_date.desc(), ParamValue.id.desc()) + ) + if dimension_value is not None: + stmt = stmt.where(ParamValue.dimension_value == dimension_value) + return stmt + + +# ==================== 批次 ==================== + + +def create_batch(session: Session, batch: ParamChangeBatch) -> ParamChangeBatch: + """新增变更批次。只 flush 以取得自增 id。""" + session.add(batch) + session.flush() + return batch + + +def get_batch_by_id( + session: Session, batch_id: int, *, for_update: bool = False +) -> ParamChangeBatch | None: + """按主键取批次。撤回校验走 ``for_update`` 锁行(防并发双撤)。""" + if for_update: + stmt = select(ParamChangeBatch).where(ParamChangeBatch.id == batch_id).with_for_update() + return session.execute(stmt).scalar_one_or_none() + return session.get(ParamChangeBatch, batch_id) + + +def list_batches( + session: Session, + *, + offset: int, + limit: int, + start_date: date | None = None, + end_date: date | None = None, +) -> list[ParamChangeBatch]: + """分页查询变更批次(按创建时间倒序)。""" + stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date) + return list( + session.execute( + stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc()) + .offset(offset) + .limit(limit) + ).scalars() + ) + + +def count_batches( + session: Session, *, start_date: date | None = None, end_date: date | None = None +) -> int: + """统计符合条件的批次总数。""" + stmt = _batch_filters( + select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date + ) + return int(session.execute(stmt).scalar_one()) + + +def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select: + """批次筛选(生效日期区间,含边界)。列表与计数共用。""" + if start_date is not None: + stmt = stmt.where(ParamChangeBatch.effective_date >= start_date) + if end_date is not None: + stmt = stmt.where(ParamChangeBatch.effective_date <= end_date) + return stmt + + +def list_values_by_batch( + session: Session, batch_id: int +) -> list[tuple[ParamValue, ParamDefinition]]: + """批次内全部条目(join 参数定义取编码/名称),按 id 排序。""" + stmt = ( + select(ParamValue, ParamDefinition) + .join(ParamDefinition, ParamDefinition.id == ParamValue.param_id) + .where(ParamValue.batch_id == batch_id) + .order_by(ParamValue.id) + ) + return [(value, definition) for value, definition in session.execute(stmt)] + + +def delete_values_by_batch(session: Session, batch_id: int) -> None: + """删除批次下全部条目(撤回未生效批次时使用,2.0 风格 delete 语句)。""" + session.execute(delete(ParamValue).where(ParamValue.batch_id == batch_id)) + + +def delete_batch(session: Session, batch: ParamChangeBatch) -> None: + """删除批次本体(调用方须先删条目并完成保护校验)。""" + session.delete(batch) + session.flush() + + +# ==================== 写入 ==================== + + +def create_value(session: Session, value: ParamValue) -> ParamValue: + """新增版本条目(append-only,禁改禁删已生效条目)。只 flush。""" + session.add(value) + session.flush() + return value + + +# ==================== 导出 ==================== + + +def list_export_rows( + session: Session, *, start_date: date | None = None, end_date: date | None = None +) -> list[tuple[ParamChangeBatch, ParamValue, ParamDefinition]]: + """导出行:批次 × 条目 × 定义(变更人/时间/参数/维度/旧值/新值/生效日期)。""" + stmt = ( + select(ParamChangeBatch, ParamValue, ParamDefinition) + .join(ParamValue, ParamValue.batch_id == ParamChangeBatch.id) + .join(ParamDefinition, ParamDefinition.id == ParamValue.param_id) + .order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc(), ParamValue.id) + ) + if start_date is not None: + stmt = stmt.where(ParamChangeBatch.effective_date >= start_date) + if end_date is not None: + stmt = stmt.where(ParamChangeBatch.effective_date <= end_date) + return [(batch, value, definition) for batch, value, definition in session.execute(stmt)] diff --git a/backend/app/repositories/performance_ledger_repository.py b/backend/app/repositories/performance_ledger_repository.py new file mode 100644 index 0000000..181211f --- /dev/null +++ b/backend/app/repositories/performance_ledger_repository.py @@ -0,0 +1,156 @@ +"""绩效核算台账仓储:只读写数据并 flush;commit/rollback 在服务层。""" + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models import PerformanceLedger +from app.models.performance_ledger import CALC_TYPE_MANAGER_AGGREGATE +from app.schemas.calc import LedgerQuery + + +def get_by_id(session: Session, ledger_id: int) -> PerformanceLedger | None: + """按主键取台账行。""" + return session.get(PerformanceLedger, ledger_id) + + +def get_active_by_receipt( + session: Session, receipt_id: int, *, calc_type: int = 1 +) -> PerformanceLedger | None: + """取某回款的当前生效核算行(is_active=1;无则 None)。 + + 二次分摊拆行后同回款可有多行(负责人+参与人)——重算作废用 + ``list_active_by_receipt``;本函数仅限确知单行的调用方(重算前的 + 存在性判断按业务维度走 has_active_for_business)。 + """ + return session.execute( + select(PerformanceLedger).where( + PerformanceLedger.receipt_id == receipt_id, + PerformanceLedger.calc_type == calc_type, + PerformanceLedger.is_active == 1, + ) + ).scalar_one_or_none() + + +def list_active_by_receipt( + session: Session, receipt_id: int, *, calc_type: int = 1 +) -> list[PerformanceLedger]: + """取某回款当前生效的**全部**核算行(分摊拆行后负责人+参与人各一行)。""" + return list( + session.execute( + select(PerformanceLedger).where( + PerformanceLedger.receipt_id == receipt_id, + PerformanceLedger.calc_type == calc_type, + PerformanceLedger.is_active == 1, + ) + ).scalars() + ) + + +def has_active_for_business(session: Session, business_id: int, *, calc_type: int = 1) -> bool: + """该业务是否已有生效核算行(核算粒度 #25:业务级一次性计提的判定)。""" + return ( + session.execute( + select(func.count()) + .select_from(PerformanceLedger) + .where( + PerformanceLedger.business_id == business_id, + PerformanceLedger.calc_type == calc_type, + PerformanceLedger.is_active == 1, + ) + ).scalar_one() + > 0 + ) + + +def get_active_by_receipt_of_business( + session: Session, business_id: int, *, calc_type: int = 1 +) -> PerformanceLedger: + """取该业务当前生效的核算行(重复回款登记进度时回显既有台账)。""" + return ( + session.execute( + select(PerformanceLedger) + .where( + PerformanceLedger.business_id == business_id, + PerformanceLedger.calc_type == calc_type, + PerformanceLedger.is_active == 1, + ) + .order_by(PerformanceLedger.id.desc()) + ) + .scalars() + .first() + ) + + +def add(session: Session, row: PerformanceLedger) -> PerformanceLedger: + """落台账行(flush 取 id;commit 在服务层)。""" + session.add(row) + session.flush() + return row + + +def mark_inactive(session: Session, row: PerformanceLedger) -> None: + """重算时把旧版本行翻为已替代(全表唯一的受控 UPDATE 例外;不 commit)。""" + row.is_active = 0 + session.flush() + + +def deactivate_month_aggregates(session: Session, month: str) -> None: + """月结重生成时作废该月全部经理聚合行(calc_type=2)。 + + 聚合行每次生成重建,旧行不翻 0 会与新行重复计入(按人聚合按 is_active + 过滤)。与 mark_inactive 同属 is_active 翻 0 的受控 UPDATE 例外;set 批量 + 版本而非逐行——聚合行一个月至多几行,且无逐行回读必要。 + """ + from sqlalchemy import update + + session.execute( + update(PerformanceLedger) + .where( + PerformanceLedger.settlement_month == month, + PerformanceLedger.calc_type == CALC_TYPE_MANAGER_AGGREGATE, + PerformanceLedger.is_active == 1, + ) + .values(is_active=0) + ) + session.flush() + + +def next_version(session: Session, receipt_id: int) -> int: + """某回款的下一个核算版本号(max+1;无行时 1)。""" + current = session.execute( + select(func.max(PerformanceLedger.calc_version)).where( + PerformanceLedger.receipt_id == receipt_id + ) + ).scalar_one() + return int(current or 0) + 1 + + +def list_ledger(session: Session, query: LedgerQuery) -> list[PerformanceLedger]: + """分页查询台账(筛选:月/人员/业务/生效/类型)。""" + stmt = select(PerformanceLedger).where(*_filters(query)) + stmt = stmt.order_by(PerformanceLedger.id.desc()).offset(query.offset).limit(query.page_size) + return list(session.execute(stmt).scalars()) + + +def count_ledger(session: Session, query: LedgerQuery) -> int: + """台账计数(与 list_ledger 同筛选)。""" + return int( + session.execute( + select(func.count()).select_from(PerformanceLedger).where(*_filters(query)) + ).scalar_one() + ) + + +def _filters(query: LedgerQuery) -> tuple: + filters = [] + if query.settlement_month: + filters.append(PerformanceLedger.settlement_month == query.settlement_month) + if query.personnel_id is not None: + filters.append(PerformanceLedger.personnel_id == query.personnel_id) + if query.business_id is not None: + filters.append(PerformanceLedger.business_id == query.business_id) + if query.is_active is not None: + filters.append(PerformanceLedger.is_active == query.is_active) + if query.calc_type is not None: + filters.append(PerformanceLedger.calc_type == query.calc_type) + return tuple(filters) diff --git a/backend/app/repositories/personnel_repository.py b/backend/app/repositories/personnel_repository.py new file mode 100644 index 0000000..3f25451 --- /dev/null +++ b/backend/app/repositories/personnel_repository.py @@ -0,0 +1,209 @@ +"""营销人员数据访问(含辐射区域与拓展计划子表)。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 +""" + +from sqlalchemy import Row, Select, func, select +from sqlalchemy import delete as sa_delete +from sqlalchemy.orm import Session, aliased + +from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion +from app.repositories.query_utils import fuzzy + +# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」 +_manager = aliased(Personnel) + +# ==================== 读取 ==================== + + +def get_by_id(session: Session, personnel_id: int, *, for_update: bool = False) -> Personnel | None: + """按主键查询。删除校验走 ``for_update`` 锁行(同 business_role_repository 的理由)。""" + if for_update: + stmt = select(Personnel).where(Personnel.id == personnel_id).with_for_update() + return session.execute(stmt).scalar_one_or_none() + return session.get(Personnel, personnel_id) + + +def get_with_names(session: Session, personnel_id: int) -> Row | None: + """查单个人员并带出岗位名称与直属经理姓名(详情/编辑回显共用)。""" + stmt = _base_select().where(Personnel.id == personnel_id) + return session.execute(stmt).first() + + +def list_personnel( + session: Session, + *, + offset: int, + limit: int, + keyword: str | None = None, + status: int | None = None, + business_role_id: int | None = None, +) -> list[Row]: + """分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。 + + 排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。 + """ + stmt = _apply_filters( + _base_select(), keyword=keyword, status=status, business_role_id=business_role_id + ) + stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit) + return list(session.execute(stmt).all()) + + +def count_personnel( + session: Session, + *, + keyword: str | None = None, + status: int | None = None, + business_role_id: int | None = None, +) -> int: + """统计符合条件的人员总数,供分页使用。 + + 筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由 + ``_apply_filters`` 单点保证)。 + """ + stmt = _apply_filters( + select(func.count(Personnel.id)), + keyword=keyword, + status=status, + business_role_id=business_role_id, + ) + return int(session.execute(stmt).scalar_one()) + + +def count_team_members(session: Session, manager_ids: list[int]) -> dict[int, int]: + """按直属经理批量统计团队成员数(删除保护的依据)。 + + 一次 GROUP BY 完成,禁止调用方在列表循环内逐行查询。 + """ + stmt = ( + select(Personnel.manager_id, func.count(Personnel.id)) + .where(Personnel.manager_id.in_(manager_ids)) + .group_by(Personnel.manager_id) + ) + return {int(manager_id): int(count) for manager_id, count in session.execute(stmt).all()} + + +def count_customer_ownerships(session: Session, personnel_ids: list[int]) -> dict[int, int]: + """按归属人员批量统计客户数(删除保护的另一半依据)。""" + stmt = ( + select(Customer.owner_personnel_id, func.count(Customer.id)) + .where(Customer.owner_personnel_id.in_(personnel_ids)) + .group_by(Customer.owner_personnel_id) + ) + return {int(owner_id): int(count) for owner_id, count in session.execute(stmt).all()} + + +def count_business_roles_refs(session: Session, personnel_ids: list[int]) -> dict[int, int]: + """按人员批量统计业务单据归属引用(负责/协助合计;业务表就位后补齐的检查)。 + + businesses.owner/assistant_personnel_id 均为 RESTRICT 外键——漏检会让 + 「删除被业务引用的人员」以 500 暴露而非可读的 400。 + """ + + from app.models import Business + + owner = ( + select(Business.owner_personnel_id, func.count(Business.id)) + .where(Business.owner_personnel_id.in_(personnel_ids)) + .group_by(Business.owner_personnel_id) + ) + assistant = ( + select(Business.assistant_personnel_id, func.count(Business.id)) + .where(Business.assistant_personnel_id.in_(personnel_ids)) + .group_by(Business.assistant_personnel_id) + ) + refs: dict[int, int] = {} + for stmt in (owner, assistant): + for owner_id, count in session.execute(stmt).all(): + if owner_id is not None: + refs[int(owner_id)] = refs.get(int(owner_id), 0) + int(count) + return refs + + +def list_regions(session: Session, personnel_id: int) -> list[PersonnelRegion]: + """查人员的全部辐射区域,按插入顺序返回。""" + stmt = ( + select(PersonnelRegion) + .where(PersonnelRegion.personnel_id == personnel_id) + .order_by(PersonnelRegion.id) + ) + return list(session.execute(stmt).scalars()) + + +def list_plans(session: Session, personnel_id: int) -> list[PersonnelPlan]: + """查人员的全部拓展计划,按插入顺序返回。""" + stmt = ( + select(PersonnelPlan) + .where(PersonnelPlan.personnel_id == personnel_id) + .order_by(PersonnelPlan.id) + ) + return list(session.execute(stmt).scalars()) + + +def _base_select() -> Select: + """人员主表联岗位名称与直属经理姓名。 + + 岗位外键非空,但统一用 outerjoin:岗位行被物理删除的脏数据不应让 + 整个列表查不出来(外键 RESTRICT 正常情况下不会发生)。 + """ + return ( + select(Personnel, BusinessRole.role_name, _manager.personnel_name) + .outerjoin(BusinessRole, Personnel.business_role_id == BusinessRole.id) + .outerjoin(_manager, Personnel.manager_id == _manager.id) + ) + + +def _apply_filters( + stmt: Select, + *, + keyword: str | None, + status: int | None, + business_role_id: int | None, +) -> Select: + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。""" + if keyword: + stmt = fuzzy(stmt, Personnel.personnel_name, keyword) + if status is not None: + stmt = stmt.where(Personnel.status == status) + if business_role_id is not None: + stmt = stmt.where(Personnel.business_role_id == business_role_id) + return stmt + + +# ==================== 写入 ==================== + + +def create(session: Session, personnel: Personnel) -> Personnel: + """新增人员。只 flush 以取得自增 id。""" + session.add(personnel) + session.flush() + return personnel + + +def delete(session: Session, personnel: Personnel) -> None: + """删除人员。调用方须先完成引用保护校验。""" + session.delete(personnel) + session.flush() + + +def replace_regions(session: Session, personnel_id: int, rows: list[PersonnelRegion]) -> None: + """全量替换人员的辐射区域。 + + **先删、立即 flush、再插入**:同一 flush 内 SQLAlchemy 先执行 INSERT + 后执行 DELETE,新旧行并存会在带唯一键的表上撞 1062 —— M1 的 + replace_roles 在 e2e 中踩过此坑。本表当前无唯一键,仍统一按防御写法, + 避免后续加约束时翻车。 + """ + session.execute(sa_delete(PersonnelRegion).where(PersonnelRegion.personnel_id == personnel_id)) + session.flush() + session.add_all(rows) + session.flush() + + +def replace_plans(session: Session, personnel_id: int, rows: list[PersonnelPlan]) -> None: + """全量替换人员的拓展计划(防御写法同 ``replace_regions``)。""" + session.execute(sa_delete(PersonnelPlan).where(PersonnelPlan.personnel_id == personnel_id)) + session.flush() + session.add_all(rows) + session.flush() diff --git a/backend/app/repositories/product_repository.py b/backend/app/repositories/product_repository.py new file mode 100644 index 0000000..1dbaf61 --- /dev/null +++ b/backend/app/repositories/product_repository.py @@ -0,0 +1,135 @@ +"""产品主数据数据访问。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 +""" + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session + +from app.models import BusinessItem, Product, ShipmentItem + +# ==================== 读取 ==================== + + +def get_by_id(session: Session, product_id: int, *, for_update: bool = False) -> Product | None: + """按主键查询。删除校验走 ``for_update`` 锁行(同 business_role_repository + 的理由:校验与删除之间若发生并发引用,锁行使并发写入可见地失败, + 而非引用被静默悬空)。""" + if for_update: + stmt = select(Product).where(Product.id == product_id).with_for_update() + return session.execute(stmt).scalar_one_or_none() + return session.get(Product, product_id) + + +def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Product | None: + """按 (名称, 规格型号) 组合查询。 + + 组合是 uk_products_name_spec 唯一键的键值,先查用于给出即时业务提示; + 并发窗口仍存在,最终由 IntegrityError 兜底(见 product_service)。 + """ + stmt = select(Product).where( + Product.product_name == product_name, + Product.spec_model == spec_model, + ) + return session.execute(stmt).scalar_one_or_none() + + +def list_products( + session: Session, + *, + offset: int, + limit: int, + keyword: str | None = None, + product_category: str | None = None, + status: int | None = None, +) -> list[Product]: + """分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。""" + stmt = _apply_filters( + select(Product), + keyword=keyword, + product_category=product_category, + status=status, + ) + stmt = stmt.order_by(Product.id).offset(offset).limit(limit) + return list(session.execute(stmt).scalars()) + + +def count_products( + session: Session, + *, + keyword: str | None = None, + product_category: str | None = None, + status: int | None = None, +) -> int: + """统计符合条件的产品总数,供分页使用。""" + stmt = _apply_filters( + select(func.count(Product.id)), + keyword=keyword, + product_category=product_category, + status=status, + ) + return int(session.execute(stmt).scalar_one()) + + +def _apply_filters( + stmt: Select, + *, + keyword: str | None, + product_category: str | None, + status: int | None, +) -> Select: + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。 + + 关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制 + (``contains(autoescape=True)`` 转义 LIKE 通配符并附加 ESCAPE 子句); + 因 fuzzy 是单列 where 语义(两次调用会叠加成 AND),双列 OR 在此直接 + 写 contains —— M1 business_role_repository 的双列关键词同款写法。 + 规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。 + """ + if keyword: + stmt = stmt.where( + or_( + Product.product_name.contains(keyword, autoescape=True), + Product.spec_model.contains(keyword, autoescape=True), + ) + ) + if product_category: + stmt = stmt.where(Product.product_category == product_category) + if status is not None: + stmt = stmt.where(Product.status == status) + return stmt + + +def count_refs(session: Session, product_id: int) -> int: + """统计引用该产品的业务明细与发货明细行数(删除保护,business_id 先例)。 + + 两张明细表对 products 均 RESTRICT 外键:先查给出可读的 400;校验与 + 删除之间的并发引用由锁行 + FK 兜底(service 层再翻译 IntegrityError)。 + """ + stmt = select( + select(func.count()) + .select_from(BusinessItem) + .where(BusinessItem.product_id == product_id) + .scalar_subquery() + + select(func.count()) + .select_from(ShipmentItem) + .where(ShipmentItem.product_id == product_id) + .scalar_subquery() + ) + return int(session.execute(stmt).scalar_one()) + + +# ==================== 写入 ==================== + + +def create(session: Session, product: Product) -> Product: + """新增产品。只 flush 以取得自增 id。""" + session.add(product) + session.flush() + return product + + +def delete(session: Session, product: Product) -> None: + """物理删除产品。调用方须先完成引用保护校验(count_refs + FK 兜底)。""" + session.delete(product) + session.flush() diff --git a/backend/app/repositories/query_utils.py b/backend/app/repositories/query_utils.py new file mode 100644 index 0000000..bba28b7 --- /dev/null +++ b/backend/app/repositories/query_utils.py @@ -0,0 +1,54 @@ +"""repositories 层公共查询工具。 + +集中两类易错点,避免各 repository 各写一份、口径漂移: + +- **模糊匹配的 LIKE 通配符转义**:直接 ``f"%{value}%"`` 拼接时,输入中的 + 下划线会作为单字符通配执行(本系统用户名普遍含下划线,误配是常态而非 + 理论),百分号则命中全表并触发全表扫描 +- **两类日志共有的筛选条件**:用户名/结果/时间范围在操作日志与登录日志 + 之间逐字重复,抽为同一实现后「列表与计数」「操作与登录」不可能对不上 +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from sqlalchemy import Select + + +@dataclass(frozen=True, slots=True) +class LogFilters: + """两类日志(操作/登录)共有的筛选条件。""" + + username: str | None = None + success: int | None = None + start_time: datetime | None = None + end_time: datetime | None = None + + +def fuzzy(stmt: Select, column: Any, value: str) -> Select: + """模糊匹配(含通配符自动转义)。 + + ``contains(autoescape=True)`` 会转义输入中的 ``%``/``_`` 并附加 ``ESCAPE`` + 子句,把用户输入当字面量匹配。 + """ + return stmt.where(column.contains(value, autoescape=True)) + + +def apply_log_filters(stmt: Select, model: Any, filters: LogFilters) -> Select: + """应用两类日志共有的筛选条件。 + + ``model`` 传 ``OperationLog`` / ``LoginLog`` 的模型类(两者字段同名同型)。 + + 列表与计数共用本函数,避免「列表按条件过滤而总数不按条件过滤」—— + 漏改一侧时表现为翻到最后一页是空页或页码虚增,且没有任何报错。 + """ + if filters.username: + stmt = fuzzy(stmt, model.username, filters.username) + if filters.success is not None: + stmt = stmt.where(model.success == filters.success) + if filters.start_time: + stmt = stmt.where(model.create_time >= filters.start_time) + if filters.end_time: + stmt = stmt.where(model.create_time <= filters.end_time) + return stmt diff --git a/backend/app/repositories/rating_repository.py b/backend/app/repositories/rating_repository.py new file mode 100644 index 0000000..9a39849 --- /dev/null +++ b/backend/app/repositories/rating_repository.py @@ -0,0 +1,65 @@ +"""客户评级等级配置数据访问。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 +""" + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import CustomerRatingGrade + +# ==================== 读取 ==================== + + +def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRatingGrade]: + """全量等级,按 sort_order 升序、id 兜底(同序次序稳定)。 + + 不分页:等级映射是判定表,数量个位数,分页无意义。 + + for_update 供区间重叠判定使用:先查后写在无锁时,两管理员并发提交 + 重叠区间会互判不重叠而双双入库(区间重叠无唯一键可拦)——锁全表行 + 串行化并发的重叠判定(先到者提交后,后到者在锁下读到新区间即判重叠)。 + 只读直出(list_grades)不加锁。 + """ + stmt = select(CustomerRatingGrade).order_by( + CustomerRatingGrade.sort_order, CustomerRatingGrade.id + ) + if for_update: + stmt = stmt.with_for_update() + return list(session.execute(stmt).scalars()) + + +def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None: + """按主键查询。 + + 等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update`` + 锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。 + """ + return session.get(CustomerRatingGrade, grade_id) + + +def get_by_name(session: Session, grade_name: str) -> CustomerRatingGrade | None: + """按等级名称查询。 + + grade_name 是 uk_customer_rating_grades_grade_name 唯一键的键值,先查 + 用于给出即时业务提示;并发窗口仍存在,最终由 IntegrityError 兜底 + (见 rating_service)。 + """ + stmt = select(CustomerRatingGrade).where(CustomerRatingGrade.grade_name == grade_name) + return session.execute(stmt).scalar_one_or_none() + + +# ==================== 写入 ==================== + + +def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade: + """新增等级。只 flush 以取得自增 id。""" + session.add(grade) + session.flush() + return grade + + +def delete(session: Session, grade: CustomerRatingGrade) -> None: + """物理删除等级。等级表无下游引用,调用方无需引用保护校验。""" + session.delete(grade) + session.flush() diff --git a/backend/app/repositories/receipt_repository.py b/backend/app/repositories/receipt_repository.py new file mode 100644 index 0000000..9804f70 --- /dev/null +++ b/backend/app/repositories/receipt_repository.py @@ -0,0 +1,125 @@ +"""回款数据访问:回款行与关联引用(业务号/发货单号/发货日)。 + +只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。 + +- 列表与详情统一 join 出 ``business_no / shipment_no / ship_date``, + ship_date 供 service 按 ``formula.payment_days`` 派生回款天数 —— 关联 + 引用经本层查询一次供给,service 不再对发货单二次查询 +- shipment_id 是手动指定(可空),发货单关联一律 outerjoin +""" + +from datetime import date + +from sqlalchemy import Select, func, select +from sqlalchemy.orm import Session + +from app.models import Business, Receipt, Shipment + + +def get_by_id(session: Session, receipt_id: int) -> Receipt | None: + """按主键取回款行(供摘要预取与状态判定;不锁行,写保护锚在业务行)。""" + return session.get(Receipt, receipt_id) + + +def get_with_refs( + session: Session, receipt_id: int +) -> tuple[Receipt, str, str | None, date | None] | None: + """回款行 + 业务号 + 发货单号 + 发货日(DTO 与 payment_days 派生共用)。 + + business 主表为 inner join(FK NOT NULL,业务与回款同存亡);发货单 + 可未指定,outerjoin 后 shipment_no / ship_date 可能为 None。 + """ + row = session.execute( + select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date) + .join(Business, Business.id == Receipt.business_id) + .outerjoin(Shipment, Shipment.id == Receipt.shipment_id) + .where(Receipt.id == receipt_id) + ).first() + return row + + +def get_shipment(session: Session, shipment_id: int) -> Shipment | None: + """按主键取发货单(同业务强校验的只读预查;FK RESTRICT 为最终防线)。""" + return session.get(Shipment, shipment_id) + + +def _apply_filters( + stmt: Select, + *, + business_id: int | None, + date_from: date | None, + date_to: date | None, + is_barter: int | None, + settlement_status: int | None, +) -> Select: + """回款列表的筛选条件(全部落在 Receipt 自身列上)。""" + if business_id is not None: + stmt = stmt.where(Receipt.business_id == business_id) + if date_from is not None: + stmt = stmt.where(Receipt.receipt_date >= date_from) + if date_to is not None: + stmt = stmt.where(Receipt.receipt_date <= date_to) + if is_barter is not None: + stmt = stmt.where(Receipt.is_barter == is_barter) + if settlement_status is not None: + stmt = stmt.where(Receipt.settlement_status == settlement_status) + return stmt + + +def list_receipts( + session: Session, + *, + offset: int, + limit: int, + business_id: int | None, + date_from: date | None, + date_to: date | None, + is_barter: int | None, + settlement_status: int | None, +) -> list[tuple[Receipt, str, str | None, date | None]]: + """分页查询回款(附业务号/发货单号/发货日),按 id 倒序。""" + stmt = _apply_filters( + select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date) + .join(Business, Business.id == Receipt.business_id) + .outerjoin(Shipment, Shipment.id == Receipt.shipment_id), + business_id=business_id, + date_from=date_from, + date_to=date_to, + is_barter=is_barter, + settlement_status=settlement_status, + ) + return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all()) + + +def count_receipts( + session: Session, + *, + business_id: int | None, + date_from: date | None, + date_to: date | None, + is_barter: int | None, + settlement_status: int | None, +) -> int: + """回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。""" + stmt = _apply_filters( + select(func.count()).select_from(Receipt), + business_id=business_id, + date_from=date_from, + date_to=date_to, + is_barter=is_barter, + settlement_status=settlement_status, + ) + return int(session.execute(stmt).scalar_one()) + + +def create(session: Session, receipt: Receipt) -> Receipt: + """新增回款行。只 flush 以取得自增 id,编号唯一键冲突由 service 兜底。""" + session.add(receipt) + session.flush() + return receipt + + +def delete(session: Session, receipt: Receipt) -> None: + """物理删除回款行(已核算与否的准入判定在 service 层)。""" + session.delete(receipt) + session.flush() diff --git a/backend/app/repositories/report_repository.py b/backend/app/repositories/report_repository.py new file mode 100644 index 0000000..021592f --- /dev/null +++ b/backend/app/repositories/report_repository.py @@ -0,0 +1,423 @@ +"""报表统计仓储:只读聚合查询;不 commit、不抛业务异常。 + +报表与台账分页查询刻意分开而非复用 ``performance_ledger_repository``: +- 台账查询允许聚合行(calc_type=2),报表明细必须显式排除——经理聚合 + 行 receipt_id 为 NULL,混入明细会让合同编号/天数列失真、业务统计的 + 绩效被整团队聚合额污染; +- 月结的 ``_confirm_scope`` 带 ``settlement_id IS NULL``(可入结口径), + 报表要看的是同月**全量**生效单笔行,口径不同; +- 聚合需求(SUM/GROUP BY)台账仓储完全没有,报表在此统一实现。 + +发货额/回款额直接读 businesses 的汇总冗余列(服务层事务内单点维护), +不逐业务子查询发货/回款表;绩效额 businesses 无冗余列,用子查询联台账。 +""" + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.report_meta import DayBucket +from app.models import ( + AdjustmentOrder, + Business, + Customer, + MonthlySettlement, + PerformanceLedger, + Personnel, +) +from app.schemas.report import ( + BusinessStatsQuery, + PaymentDetailQuery, + PaymentStatsQuery, + PersonalDetailQuery, +) + +# 单笔生效行基础条件:报表四表共用(聚合行与已替代版本一律排除) +_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1) + + +# ==== 个人绩效明细(FR-029)==== + + +def _personal_filters(query: PersonalDetailQuery) -> tuple: + filters = [*_ACTIVE_SINGLE] + if query.settlement_month: + filters.append(PerformanceLedger.settlement_month == query.settlement_month) + if query.personnel_id is not None: + filters.append(PerformanceLedger.personnel_id == query.personnel_id) + if query.customer_type: + filters.append(PerformanceLedger.customer_type == query.customer_type) + if query.product_category: + filters.append(PerformanceLedger.product_category == query.product_category) + if query.role_code: + filters.append(PerformanceLedger.role_code == query.role_code) + return tuple(filters) + + +def list_personal_detail( + session: Session, query: PersonalDetailQuery, *, offset: int, limit: int +) -> list[PerformanceLedger]: + """个人绩效明细分页(回款日倒序,同人按版本可追溯)。""" + stmt = select(PerformanceLedger).where(*_personal_filters(query)) + stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc()) + stmt = stmt.offset(offset).limit(limit) + return list(session.execute(stmt).scalars()) + + +def count_personal_detail(session: Session, query: PersonalDetailQuery) -> int: + """个人绩效明细计数(与 list_personal_detail 同筛选)。""" + return int( + session.execute( + select(func.count()).select_from(PerformanceLedger).where(*_personal_filters(query)) + ).scalar_one() + ) + + +def sum_personal_detail(session: Session, query: PersonalDetailQuery) -> dict: + """全筛选口径合计(分页与合计同条件,避免「页脚合计≠全部结果」)。""" + return ( + session.execute( + select( + func.count().label("row_count"), + func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label( + "performance_total" + ), + func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"), + func.coalesce(func.sum(PerformanceLedger.converted_profit), 0).label( + "converted_profit_total" + ), + ).where(*_personal_filters(query)) + ) + .mappings() + .one() + ) + + +# ==== 部门汇总取数(FR-030)==== + + +def list_month_receipt_rows(session: Session, month: str) -> list[PerformanceLedger]: + """同月全量生效单笔台账行(部门汇总基数与成员明细的取数)。""" + return list( + session.execute( + select(PerformanceLedger).where( + PerformanceLedger.settlement_month == month, *_ACTIVE_SINGLE + ) + ).scalars() + ) + + +def list_month_manager_aggregates(session: Session, month: str) -> list[PerformanceLedger]: + """同月经理聚合行(calc_type=2):已生成月结时的冻结对照值。""" + return list( + session.execute( + select(PerformanceLedger).where( + PerformanceLedger.settlement_month == month, + PerformanceLedger.calc_type == 2, + PerformanceLedger.is_active == 1, + ) + ).scalars() + ) + + +def list_month_adjustments(session: Session, month: str) -> list[AdjustmentOrder]: + """同月已生效调整单(status=2)——报表汇总并入的口径。 + + 与 list_adjustments(调整单管理页)不同:必须按月筛选且只取生效行, + 待审批/已驳回/已撤回的金额绝不能混进报表。 + """ + return list( + session.execute( + select(AdjustmentOrder).where( + AdjustmentOrder.settlement_month == month, + AdjustmentOrder.status == 2, + ) + ).scalars() + ) + + +def list_month_settlement(session: Session, month: str) -> MonthlySettlement | None: + """同月月结头(部门汇总展示锁定状态用;无则 None)。""" + return session.execute( + select(MonthlySettlement).where(MonthlySettlement.settlement_month == month) + ).scalar_one_or_none() + + +# ==== 业务统计(FR-031 之一)==== + + +def _business_perf_subquery(): + """业务维度绩效子查询(排除聚合行,防 12.5%/5% 聚合额污染单业务绩效)。""" + return ( + select( + PerformanceLedger.business_id.label("business_id"), + func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label("perf"), + func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable"), + ) + .where(*_ACTIVE_SINGLE) + .group_by(PerformanceLedger.business_id) + .subquery() + ) + + +def _business_filters(query: BusinessStatsQuery) -> tuple: + filters = [] + if query.keyword: + # 业务编号/公司名称双列 OR 模糊;contains(autoescape) 转义通配符, + # 与 business_repository._apply_filters 同口径(双列 OR 用法为该处既定) + filters.append( + Business.business_no.contains(query.keyword, autoescape=True) + | Customer.company_name.contains(query.keyword, autoescape=True) + ) + if query.customer_id is not None: + filters.append(Business.customer_id == query.customer_id) + if query.owner_personnel_id is not None: + filters.append(Business.owner_personnel_id == query.owner_personnel_id) + if query.status is not None: + filters.append(Business.status == query.status) + if query.date_from is not None: + filters.append(Business.business_date >= query.date_from) + if query.date_to is not None: + filters.append(Business.business_date <= query.date_to) + return tuple(filters) + + +def _business_select(query: BusinessStatsQuery): + perf_sq = _business_perf_subquery() + return ( + select( + Business, + Customer.company_name.label("customer_name"), + Personnel.personnel_name.label("owner_personnel_name"), + perf_sq.c.perf.label("performance_amount"), + perf_sq.c.payable.label("payable_amount"), + ) + .outerjoin(Customer, Customer.id == Business.customer_id) + .outerjoin(Personnel, Personnel.id == Business.owner_personnel_id) + .outerjoin(perf_sq, perf_sq.c.business_id == Business.id) + .where(*_business_filters(query)) + ) + + +def list_business_stats( + session: Session, query: BusinessStatsQuery, *, offset: int, limit: int +) -> list: + """业务统计分页(业务日期倒序)。""" + stmt = _business_select(query).order_by(Business.id.desc()).offset(offset).limit(limit) + return list(session.execute(stmt).all()) + + +def count_business_stats(session: Session, query: BusinessStatsQuery) -> int: + """业务统计计数(联客户表因关键字筛选涉及客户名)。""" + stmt = ( + select(func.count()) + .select_from(Business) + .outerjoin(Customer, Customer.id == Business.customer_id) + .where(*_business_filters(query)) + ) + return int(session.execute(stmt).scalar_one()) + + +def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict: + """业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。""" + perf_sq = _business_perf_subquery() + return ( + session.execute( + select( + func.count().label("row_count"), + func.coalesce(func.sum(Business.total_ship_amount), 0).label("ship_total"), + func.coalesce(func.sum(Business.total_received_amount), 0).label("received_total"), + func.coalesce(func.sum(Business.total_sale_amount), 0).label("sale_total"), + func.coalesce(func.sum(perf_sq.c.perf), 0).label("performance_total"), + func.coalesce(func.sum(perf_sq.c.payable), 0).label("payable_total"), + ) + .outerjoin(Customer, Customer.id == Business.customer_id) + .outerjoin(perf_sq, perf_sq.c.business_id == Business.id) + .where(*_business_filters(query)) + ) + .mappings() + .one() + ) + + +# ==== 回款统计(FR-031 之二)==== + + +def _payment_filters(query: PaymentStatsQuery | PaymentDetailQuery) -> tuple: + filters = [*_ACTIVE_SINGLE] + if query.settlement_month: + filters.append(PerformanceLedger.settlement_month == query.settlement_month) + if query.date_from is not None: + filters.append(PerformanceLedger.receipt_date >= query.date_from) + if query.date_to is not None: + filters.append(PerformanceLedger.receipt_date <= query.date_to) + if query.customer_type: + filters.append(PerformanceLedger.customer_type == query.customer_type) + if query.product_category: + filters.append(PerformanceLedger.product_category == query.product_category) + return tuple(filters) + + +def group_payment_by_days(session: Session, query: PaymentStatsQuery) -> list: + """按(回款天数精确值, 客户类型, 产品类别)聚合绩效与应付。""" + return list( + session.execute( + select( + PerformanceLedger.payment_days, + PerformanceLedger.customer_type, + PerformanceLedger.product_category, + func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label( + "performance_total" + ), + func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"), + func.count().label("ledger_count"), + ) + .where(*_payment_filters(query)) + .group_by( + PerformanceLedger.payment_days, + PerformanceLedger.customer_type, + PerformanceLedger.product_category, + ) + ).all() + ) + + +def group_payment_receipt_by_days(session: Session, query: PaymentStatsQuery) -> list: + """按维度聚合回款金额与笔数——金额侧必须按 receipt_id 去重。 + + 二次分摊拆行使同一回款在台账出现多行,直接 SUM(receipts.amount) 会 + 按拆行数翻倍;台账拆行只改归属人,不改维度值(payment_days/客户类型/ + 产品类别),因此先对台账行 DISTINCT 出回款维度,再联回款表求和。 + """ + distinct_receipts = ( + select( + PerformanceLedger.receipt_id.label("receipt_id"), + PerformanceLedger.payment_days.label("payment_days"), + PerformanceLedger.customer_type.label("customer_type"), + PerformanceLedger.product_category.label("product_category"), + ) + .where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None)) + .distinct() + .subquery() + ) + from app.models import Receipt + + return list( + session.execute( + select( + distinct_receipts.c.payment_days, + distinct_receipts.c.customer_type, + distinct_receipts.c.product_category, + func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"), + func.count(distinct_receipts.c.receipt_id).label("receipt_count"), + ) + .outerjoin(Receipt, Receipt.id == distinct_receipts.c.receipt_id) + .group_by( + distinct_receipts.c.payment_days, + distinct_receipts.c.customer_type, + distinct_receipts.c.product_category, + ) + ).all() + ) + + +def group_payment_by_customer_type(session: Session, query: PaymentStatsQuery) -> list: + """按客户类型聚合(绩效 + 回款金额)。""" + return _group_by_dimension(session, query, PerformanceLedger.customer_type) + + +def group_payment_by_product_category(session: Session, query: PaymentStatsQuery) -> list: + """按产品类别聚合(绩效 + 回款金额)。""" + return _group_by_dimension(session, query, PerformanceLedger.product_category) + + +def _group_by_dimension(session: Session, query: PaymentStatsQuery, dim_column) -> list: + """维度聚合共用实现(绩效侧 GROUP BY + 金额侧 DISTINCT 去重联回款)。""" + from app.models import Receipt + + dim_perf = ( + select( + dim_column.label("dim"), + func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label( + "performance_total" + ), + func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"), + func.count().label("ledger_count"), + ) + .where(*_payment_filters(query)) + .group_by(dim_column) + ) + dim_receipts = ( + select( + dim_column.label("dim"), + PerformanceLedger.receipt_id.label("receipt_id"), + ) + .where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None)) + .distinct() + .subquery() + ) + perf_rows = {row.dim: row for row in session.execute(dim_perf).all()} + receipt_rows = session.execute( + select( + dim_receipts.c.dim, + func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"), + ) + .outerjoin(Receipt, Receipt.id == dim_receipts.c.receipt_id) + .group_by(dim_receipts.c.dim) + ).all() + receipt_amounts = {row.dim: row.receipt_amount for row in receipt_rows} + return [ + ( + row.dim, + receipt_amounts.get(row.dim), + row.performance_total, + row.payable_total, + row.ledger_count, + ) + for row in perf_rows.values() + ] + + +def _bucket_condition(bucket: DayBucket) -> tuple: + """区间桶的 days 过滤条件:none 桶 IS NULL,其余按 min/max 闭区间。""" + if bucket.code == "none": + return (PerformanceLedger.payment_days.is_(None),) + conds = [] + if bucket.min_days is not None: + conds.append(PerformanceLedger.payment_days >= bucket.min_days) + if bucket.max_days is not None: + conds.append(PerformanceLedger.payment_days <= bucket.max_days) + return tuple(conds) + + +def list_payment_detail( + session: Session, query: PaymentDetailQuery, bucket: DayBucket, *, offset: int, limit: int +) -> list: + """区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。 + + bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。 + """ + from app.models import Receipt + + stmt = ( + select( + PerformanceLedger, + Receipt.amount.label("receipt_amount"), + Receipt.receipt_batch_no.label("receipt_batch_no"), + ) + .outerjoin(Receipt, Receipt.id == PerformanceLedger.receipt_id) + .where(*_payment_filters(query), *_bucket_condition(bucket)) + ) + stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc()) + stmt = stmt.offset(offset).limit(limit) + return list(session.execute(stmt).all()) + + +def count_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> int: + """区间下钻计数(与 list_payment_detail 同筛选)。""" + return int( + session.execute( + select(func.count()) + .select_from(PerformanceLedger) + .where(*_payment_filters(query), *_bucket_condition(bucket)) + ).scalar_one() + ) diff --git a/backend/app/repositories/role_repository.py b/backend/app/repositories/role_repository.py new file mode 100644 index 0000000..cdd2c13 --- /dev/null +++ b/backend/app/repositories/role_repository.py @@ -0,0 +1,123 @@ +"""角色数据访问。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 + +`get_all_roles` 与 `get_roles_by_codes` 原位于 `user_repository`, +因角色管理成为独立模块而迁入本文件。 +""" + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session + +from app.models import Role, UserRole + +# ==================== 读取 ==================== + + +def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> Role | None: + """按主键查询角色。不存在返回 None。 + + ``for_update=True`` 走 ``SELECT ... FOR UPDATE`` 锁定角色行 —— 删除前的 + 「有用户绑定禁删」校验必须用它:校验与 DELETE 之间若发生并发绑定, + `user_roles` 的 CASCADE 会把新绑定一并静默剥离。锁行后,并发绑定的 + 外键检查会等待,删除得以基于最新状态做出(对方失败而非数据被吞)。 + """ + if for_update: + stmt = select(Role).where(Role.id == role_id).with_for_update() + return session.execute(stmt).scalar_one_or_none() + return session.get(Role, role_id) + + +def get_by_code(session: Session, role_code: str) -> Role | None: + """按编码查询角色。不存在返回 None。""" + return session.execute(select(Role).where(Role.role_code == role_code)).scalar_one_or_none() + + +def get_roles_by_codes(session: Session, role_codes: list[str]) -> list[Role]: + """按编码批量取角色。用于校验前端传来的角色是否存在。""" + if not role_codes: + return [] + stmt = select(Role).where(Role.role_code.in_(role_codes)) + return list(session.execute(stmt).scalars()) + + +def get_all_roles(session: Session) -> list[Role]: + """取全部启用角色,供前端下拉选择。""" + stmt = select(Role).where(Role.status == 1).order_by(Role.sort_order, Role.id) + return list(session.execute(stmt).scalars()) + + +def list_roles( + session: Session, + *, + offset: int, + limit: int, + keyword: str | None = None, + status: int | None = None, +) -> list[Role]: + """分页查询角色(管理列表:含停用角色)。 + + 排序与下拉口径一致(sort_order, id),保持同一套相对次序。 + """ + stmt = _apply_filters(select(Role), keyword=keyword, status=status) + stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit) + return list(session.execute(stmt).scalars()) + + +def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int: + """统计符合条件的角色总数,供分页使用。""" + stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status) + return int(session.execute(stmt).scalar_one()) + + +def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select: + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。 + + 关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)—— + 角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。 + """ + if keyword: + stmt = stmt.where( + or_( + Role.role_code.contains(keyword, autoescape=True), + Role.role_name.contains(keyword, autoescape=True), + ) + ) + if status is not None: + stmt = stmt.where(Role.status == status) + return stmt + + +def count_users_by_role(session: Session, role_ids: list[int]) -> dict[int, int]: + """批量统计各角色关联的用户数。 + + 一次 GROUP BY 取回全部计数,而非在列表循环里逐行查询(N+1)。 + """ + if not role_ids: + return {} + stmt = ( + select(UserRole.role_id, func.count()) + .where(UserRole.role_id.in_(role_ids)) + .group_by(UserRole.role_id) + ) + return {int(role_id): int(count) for role_id, count in session.execute(stmt)} + + +# ==================== 写入 ==================== + + +def create(session: Session, role: Role) -> Role: + """新增角色。只 flush 以取得自增 id。""" + session.add(role) + session.flush() + return role + + +def delete(session: Session, role: Role) -> None: + """删除角色。 + + 调用方(service)必须先校验该角色无用户绑定 —— `user_roles` 外键是 + CASCADE,直接删会静默剥离绑定用户的权限。 + """ + session.delete(role) + session.flush() diff --git a/backend/app/repositories/settlement_repository.py b/backend/app/repositories/settlement_repository.py new file mode 100644 index 0000000..8eb01e0 --- /dev/null +++ b/backend/app/repositories/settlement_repository.py @@ -0,0 +1,112 @@ +"""月度结算仓储:只读写数据并 flush;commit/rollback 在服务层。""" + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models import MonthlySettlement, SettlementItem +from app.schemas.settlement import SettlementQuery + + +def get_by_id(session: Session, settlement_id: int) -> MonthlySettlement | None: + return session.get(MonthlySettlement, settlement_id) + + +def get_by_month(session: Session, month: str) -> MonthlySettlement | None: + """按月份取月结行(settlement_month 全表唯一)。""" + return session.execute( + select(MonthlySettlement).where(MonthlySettlement.settlement_month == month) + ).scalar_one_or_none() + + +def add(session: Session, settlement: MonthlySettlement) -> MonthlySettlement: + session.add(settlement) + session.flush() + return settlement + + +def list_settlements(session: Session, query: SettlementQuery) -> list[MonthlySettlement]: + return list( + session.execute( + select(MonthlySettlement) + .where(*_filters(query)) + .order_by(MonthlySettlement.settlement_month.desc()) + .offset(query.offset) + .limit(query.page_size) + ).scalars() + ) + + +def count_settlements(session: Session, query: SettlementQuery) -> int: + return int( + session.execute( + select(func.count()).select_from(MonthlySettlement).where(*_filters(query)) + ).scalar_one() + ) + + +def _filters(query: SettlementQuery) -> tuple: + filters = [] + if query.status is not None: + filters.append(MonthlySettlement.status == query.status) + return tuple(filters) + + +def list_items(session: Session, settlement_id: int) -> list[SettlementItem]: + return list( + session.execute( + select(SettlementItem) + .where(SettlementItem.settlement_id == settlement_id) + .order_by(SettlementItem.id) + ).scalars() + ) + + +def replace_items(session: Session, settlement_id: int, rows: list[SettlementItem]) -> None: + """全量替换明细(先删后 flush 再插,防御写法;不 commit)。""" + session.execute(delete_items_stmt(settlement_id)) + session.flush() + for row in rows: + row.settlement_id = settlement_id + session.add(row) + session.flush() + + +def delete_items_stmt(settlement_id: int): + from sqlalchemy import delete + + return delete(SettlementItem).where(SettlementItem.settlement_id == settlement_id) + + +def update_ledger_settlement_id( + session: Session, ledger_ids: list[int], settlement_id: int +) -> None: + """月结生成时把纳入的台账行回填 settlement_id(append-only 的受控回填)。""" + from sqlalchemy import update + + from app.models import PerformanceLedger + + if ledger_ids: + session.execute( + update(PerformanceLedger) + .where(PerformanceLedger.id.in_(ledger_ids)) + .values(settlement_id=settlement_id) + ) + session.flush() + + +def clear_ledger_settlement_id(session: Session, settlement_id: int) -> None: + """重生成前解绑:释放本结算名下台账行的 settlement_id(「未入结」判据复位)。 + + 草稿/驳回态重新生成时,上一轮回填的行必须重新进入候选集——否则 + ``settlement_id IS NULL`` 会把它们永久排除,重生成漏算整月工资。 + """ + from sqlalchemy import update + + from app.models import PerformanceLedger + + session.execute( + update(PerformanceLedger) + .where(PerformanceLedger.settlement_id == settlement_id) + .values(settlement_id=None) + ) + session.flush() diff --git a/backend/app/repositories/shipment_repository.py b/backend/app/repositories/shipment_repository.py new file mode 100644 index 0000000..b463270 --- /dev/null +++ b/backend/app/repositories/shipment_repository.py @@ -0,0 +1,143 @@ +"""发货数据访问:发货单行、明细行与业务号关联引用。 + +只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。 + +- 列表与详情统一 join 出 ``business_no``(业务主表 inner join,FK NOT + NULL,业务与发货单同存亡) +- 明细行汇总(sum_item_amount)是发货单 total_amount 重算的唯一数据源, + 业务级汇总仍收口 business_service._recalc_totals +""" + +from datetime import date +from decimal import Decimal + +from sqlalchemy import Select, delete, func, select +from sqlalchemy.orm import Session + +from app.models import Business, Receipt, Shipment, ShipmentItem + + +def get_by_id(session: Session, shipment_id: int) -> Shipment | None: + """按主键取发货单行(供摘要预取与编辑赋值;不锁行,写保护锚在业务行)。""" + return session.get(Shipment, shipment_id) + + +def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment, str] | None: + """发货单行 + 业务号(DTO 与审计摘要共用)。""" + return session.execute( + select(Shipment, Business.business_no) + .join(Business, Business.id == Shipment.business_id) + .where(Shipment.id == shipment_id) + ).first() + + +def _apply_filters( + stmt: Select, + *, + business_id: int | None, + ship_date_from: date | None, + ship_date_to: date | None, +) -> Select: + """发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。""" + if business_id is not None: + stmt = stmt.where(Shipment.business_id == business_id) + if ship_date_from is not None: + stmt = stmt.where(Shipment.ship_date >= ship_date_from) + if ship_date_to is not None: + stmt = stmt.where(Shipment.ship_date <= ship_date_to) + return stmt + + +def list_shipments( + session: Session, + *, + offset: int, + limit: int, + business_id: int | None, + ship_date_from: date | None, + ship_date_to: date | None, +) -> list[tuple[Shipment, str]]: + """分页查询发货单(附业务号),按 id 倒序。""" + stmt = _apply_filters( + select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id), + business_id=business_id, + ship_date_from=ship_date_from, + ship_date_to=ship_date_to, + ) + return list( + session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all() + ) + + +def count_shipments( + session: Session, + *, + business_id: int | None, + ship_date_from: date | None, + ship_date_to: date | None, +) -> int: + """发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。""" + stmt = _apply_filters( + select(func.count()) + .select_from(Shipment) + .join(Business, Business.id == Shipment.business_id), + business_id=business_id, + ship_date_from=ship_date_from, + ship_date_to=ship_date_to, + ) + return int(session.execute(stmt).scalar_one()) + + +def list_items(session: Session, shipment_id: int) -> list[ShipmentItem]: + """发货明细行(详情回显与总金额重算共用)。""" + return list( + session.execute( + select(ShipmentItem) + .where(ShipmentItem.shipment_id == shipment_id) + .order_by(ShipmentItem.id) + ).scalars() + ) + + +def sum_item_amount(session: Session, shipment_id: int) -> Decimal | None: + """明细行金额合计(发货单 total_amount 重算的唯一数据源)。""" + return session.execute( + select(func.sum(ShipmentItem.amount)).where(ShipmentItem.shipment_id == shipment_id) + ).scalar_one() + + +def count_receipt_refs(session: Session, shipment_id: int) -> int: + """被回款批次指定的引用数(删除保护依据,receipts.shipment_id)。""" + return int( + session.execute( + select(func.count()).select_from(Receipt).where(Receipt.shipment_id == shipment_id) + ).scalar_one() + ) + + +def create(session: Session, shipment: Shipment) -> Shipment: + """新增发货单行。只 flush 以取得自增 id,单号唯一键冲突由 service 兜底。""" + session.add(shipment) + session.flush() + return shipment + + +def replace_items(session: Session, shipment_id: int, rows: list[ShipmentItem]) -> None: + """全量替换明细:先删、立即 flush、再插入(business_repository 同款防御写法)。 + + 同一 flush 中 SQLAlchemy 先处理 INSERT 后处理 DELETE,先删后插不落库 + 会在有唯一键的表上撞键;shipment_items 虽无唯一键,统一按此写法防御。 + """ + session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id == shipment_id)) + session.flush() + for row in rows: + row.shipment_id = shipment_id + session.add(row) + session.flush() + + +def delete_with_items(session: Session, shipment_id: int) -> None: + """删除发货单及明细行(按依赖序:明细 → 主表,FK RESTRICT)。""" + session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id == shipment_id)) + session.execute(delete(Shipment).where(Shipment.id == shipment_id)) + session.flush() diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index 314968b..6b18d5b 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -1,14 +1,23 @@ -"""用户与角色数据访问。 +"""用户数据访问(角色相关的读取与维护见 `role_repository`)。 本层只 flush 不 commit —— 事务边界由 services 层控制。 """ from datetime import datetime -from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy import Select, func, select +from sqlalchemy.orm import Session, selectinload -from app.models import User +from app.models import Role, User, UserRole +from app.repositories.query_utils import fuzzy + + +def _base_query() -> Select[tuple[User]]: + """用户查询基座。 + + 预加载角色关联,避免列表渲染时逐行查角色(N+1)。 + """ + return select(User).options(selectinload(User.user_roles).selectinload(UserRole.role)) def get_by_username(session: Session, username: str) -> User | None: @@ -34,3 +43,116 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None: """更新最后登录时间。只 flush,提交由 service 决定。""" user.last_login_time = moment session.flush() + + +# ==================== 列表查询 ==================== + + +def list_users( + session: Session, + *, + offset: int, + limit: int, + username: str | None = None, + real_name: str | None = None, + status: int | None = None, + role_code: str | None = None, +) -> list[User]: + """分页查询用户列表。 + + **按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户 + 可能落在同一秒,按它排序的分页结果不确定 —— 翻页时会出现重复或遗漏。 + id 是自增主键,天然唯一且与创建顺序一致。 + """ + stmt = _base_query() + stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status) + if role_code: + # 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略 + stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code) + stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit) + return list(session.execute(stmt).scalars().unique()) + + +def count_users( + session: Session, + *, + username: str | None = None, + real_name: str | None = None, + status: int | None = None, + role_code: str | None = None, +) -> int: + """统计符合条件的用户总数,供分页使用。""" + stmt = select(func.count(User.id)) + stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status) + if role_code: + stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code) + return int(session.execute(stmt).scalar_one()) + + +def _apply_filters( + stmt: Select, + *, + username: str | None, + real_name: str | None, + status: int | None, +) -> Select: + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。 + + 模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍 + 含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。 + """ + if username: + stmt = fuzzy(stmt, User.username, username) + if real_name: + stmt = fuzzy(stmt, User.real_name, real_name) + if status is not None: + stmt = stmt.where(User.status == status) + return stmt + + +# ==================== 写入 ==================== + + +def create(session: Session, user: User) -> User: + """新增用户。只 flush 以取得自增 id。""" + session.add(user) + session.flush() + return user + + +def delete(session: Session, user: User) -> None: + """物理删除用户。 + + 本项目对用户采用「删除即禁用」的**业务**约定(见模型注释), + 但保留物理删除能力供测试清理与误建账号的彻底移除使用。 + `user_roles` 与 `operation_logs` 的外键分别配置了 CASCADE 与 SET NULL, + 故不会留下悬挂引用。 + """ + session.delete(user) + session.flush() + + +# ==================== 角色关联 ==================== + + +def replace_roles(session: Session, user: User, roles: list[Role]) -> None: + """整体替换用户的角色。 + + 采用「先清后建」而非逐条比对:角色数量极少(本期仅两个), + 差异计算的复杂度换不来收益,且整体替换的语义更直白。 + + **clear 之后必须立刻 flush 一次**:SQLAlchemy 在同一事务的 flush 中 + **先处理 INSERT 再处理 DELETE**,若中间不落库,重插与旧集合有交集的 + (user_id, role_id)(编辑用户时保留既有角色就会发生)会撞 + `uk_user_roles_user_id_role_id` 唯一键,报 1062。 + + **必须经 `user.user_roles` 集合追加,不能用 `session.add(UserRole(...))`**: + 后者只把对象放进 session,不会同步到关系的已加载集合,于是同一事务内 + 再读 `user.user_roles` 仍是空的 —— 表现为「保存成功,但接口返回的角色 + 列表是 []」。经集合操作时 `user_id` 由关系自动填充,集合也立即反映新状态。 + """ + user.user_roles.clear() + session.flush() + for role in roles: + user.user_roles.append(UserRole(role_id=role.id)) + session.flush() diff --git a/backend/app/schemas/approval.py b/backend/app/schemas/approval.py new file mode 100644 index 0000000..75b17c0 --- /dev/null +++ b/backend/app/schemas/approval.py @@ -0,0 +1,66 @@ +"""审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.core.response import PageQuery + + +class ApprovalStepOut(BaseModel): + """节点记录(时间线;action=3 自动通过 operator 记「系统」)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + node: int + action: int + opinion: str | None = None + operator_id: int | None = None + operator_name: str + create_time: datetime + + +class ApprovalOrderOut(BaseModel): + """审批单列表行。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + order_no: str + order_type: int + ref_id: int + refer_no: str + status: int + submitter_id: int | None = None + submitter_name: str + create_time: datetime + + +class ApprovalOrderDetailOut(ApprovalOrderOut): + """审批单详情:附节点时间线。""" + + steps: list[ApprovalStepOut] = Field(default_factory=list) + + +class ApprovalQuery(PageQuery): + """审批单列表查询条件。""" + + keyword: str | None = Field(default=None, max_length=32, description="审批单号/引用单号,模糊") + status: int | None = Field(default=None, ge=1, le=5, description="状态筛选") + order_type: int | None = Field(default=None, ge=1, le=2, description="对象类型筛选") + + +class ApprovalActionIn(BaseModel): + """节点动作:1 通过 / 2 驳回(驳回须填意见——空驳无从追溯)。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + action: int = Field(ge=1, le=2, description="1通过 2驳回") + opinion: str | None = Field(default=None, max_length=255, description="审批意见") + + @model_validator(mode="after") + def _check_opinion(self) -> "ApprovalActionIn": + if self.action == 2 and not self.opinion: + raise ValueError("驳回必须填写审批意见") + return self diff --git a/backend/app/schemas/business.py b/backend/app/schemas/business.py new file mode 100644 index 0000000..da08797 --- /dev/null +++ b/backend/app/schemas/business.py @@ -0,0 +1,396 @@ +"""业务管理的请求/响应模型(需求 2.2.1/2.2.4)。 + +金额/数量入参出参一律 str(服务层 Decimal/Fraction,全程禁 float); +派生值(毛利润额/毛利润率/折算毛利润额)不在写接口入参内 —— 落库列只有 +源数据,派生值由毛利润视图实时计算。 +""" + +from datetime import date, datetime +from decimal import Decimal, InvalidOperation + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from app.core.constants import ( + PRODUCT_CATEGORIES, + PRODUCT_CATEGORY_MUNICIPAL_WATER, + PRODUCT_CATEGORY_RAIL_TRANSIT, +) +from app.core.response import PageQuery + +# DECIMAL(14,2) 容量上限:整数部分 12 位(同 personnel.py 的金额校验口径) +AMOUNT_MAX = Decimal("999999999999.99") +# DECIMAL(14,4) 容量上限:整数部分 10 位 +PRICE_MAX = Decimal("9999999999.9999") + + +def _check_amount( + value: str | None, + label: str, + cap: Decimal, + allow_positive_only: bool = False, + scale: int = 2, +) -> str | None: + """字符串金额的通用校验:数字/有限/非负/容量/小数位上限。 + + 小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱(工资系统红线)。 + ``scale`` 按列口径区分:金额/数量列 DECIMAL(14,2) 为 2;单价类列 + DECIMAL(14,4) 为 4(回显 "5000.0000" 必须能原样提交,否则任何带 + 精度的历史单价都被编辑端点永远拒收)。 + """ + if value is None: + return None + try: + amount = Decimal(value) + except InvalidOperation: + raise ValueError(f"{label}须为有效数字") from None + if not amount.is_finite(): + raise ValueError(f"{label}须为有效数字") + if allow_positive_only: + if amount <= 0: + raise ValueError(f"{label}必须大于 0") + elif amount < 0: + raise ValueError(f"{label}不能为负数") + if amount > cap: + raise ValueError(f"{label}超出可录入上限") + if -amount.as_tuple().exponent > scale: + raise ValueError(f"{label}最多{'两' if scale == 2 else '四'}位小数") + return value + + +class BusinessItemIn(BaseModel): + """业务产品明细行(需求 2.2.1「支持添加多种产品,录入指导价」)。 + + product_category 保留蛇形:该值与 M1 参数维度值域同源(枚举码本就是 + 蛇形);其余标识类字段驼峰兼容(snake/驼峰双入,同 customer.py 契约)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + product_id: int | None = Field(default=None, ge=1, description="产品ID;空=自由录入") + product_name: str = Field(min_length=1, max_length=128, description="产品名称") + spec_model: str | None = Field( + default=None, max_length=128, description="规格型号,空=空串归一" + ) + unit: str | None = Field(default=None, max_length=16, description="计量单位") + product_category: str = Field( + description="产品类别:municipal_water/rail_transit(行级折算锚点)" + ) + quantity: str = Field(description="数量,>0") + sale_price: str | None = Field(default=None, description="销售单价(元)") + cost_amount: str | None = Field( + default=None, description="成本额(元);毛利润额 = 销售额 − 成本额" + ) + guided_price: str | None = Field(default=None, description="指导价(元)") + remark: str | None = Field(default=None, max_length=255) + + @field_validator("product_category") + @classmethod + def _check_category(cls, value: str) -> str: + if value not in PRODUCT_CATEGORIES: + raise ValueError("产品类别不合法") + return value + + @field_validator("quantity") + @classmethod + def _check_quantity(cls, value: str) -> str: + return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value + + @field_validator("sale_price", "guided_price") + @classmethod + def _check_unit_prices(cls, value: str | None) -> str | None: + """单价类字段按 DECIMAL(14,4) 口径校验(4 位小数)。""" + return _check_amount(value, "单价", PRICE_MAX, scale=4) + + @field_validator("cost_amount") + @classmethod + def _check_cost(cls, value: str | None) -> str | None: + return _check_amount(value, "金额", AMOUNT_MAX) + + +class BusinessItemOut(BaseModel): + """业务产品明细行响应。金额/数量一律 str。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + product_id: int | None = None + product_name: str + spec_model: str | None = None + unit: str | None = None + product_category: str + quantity: str + sale_price: str | None = None + sale_amount: str + cost_amount: str | None = None + guided_price: str | None = None + remark: str | None = None + + +class BusinessOut(BaseModel): + """业务响应(列表与写操作回包共用)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + business_no: str + customer_id: int + customer_name: str | None = None + business_date: date | None = None + status: int = 1 + owner_personnel_id: int | None = None + owner_personnel_name: str | None = None + assistant_personnel_id: int | None = None + assistant_personnel_name: str | None = None + is_special: int = 0 + special_note: str | None = None + total_sale_amount: str | None = None + total_cost_amount: str | None = None + total_ship_amount: str | None = None + total_received_amount: str | None = None + total_freight_amount: str | None = None + remark: str | None = None + + +class SecondaryAllocationIn(BaseModel): + """二次分配参与人入参(2026-10-15 补充需求「二次分配方案」)。 + + 比例**不要求合计 100%**(需求原文):余额归属待业务澄清(待澄清 #2), + 服务层仅以 hint 展示合计/余额,不做强制校验。ratio 沿金额红线口径: + 字符串入参、两位小数、超限拒绝不静默舍入。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + personnel_id: int = Field(ge=1, description="参与业务员ID(personnel.id)") + ratio: str = Field(description="分摊比例(0-100,两位小数)") + + @field_validator("ratio") + @classmethod + def _check_ratio(cls, value: str) -> str: + return _check_amount(value, "分摊比例", Decimal("100"), allow_positive_only=True) or value + + +class SecondaryAllocationOut(BaseModel): + """二次分配参与人响应(personnel_name 冗余固化,离职后回显有名)。""" + + model_config = ConfigDict(from_attributes=True) + + personnel_id: int + personnel_name: str + ratio: str + + +def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None: + """同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示 + (让 400 文案说「重复参与人」而不是裸唯一键冲突)。""" + seen: set[int] = set() + for item in items: + if item.personnel_id in seen: + raise ValueError(f"二次分配参与人重复:人员ID {item.personnel_id}") + seen.add(item.personnel_id) + + +class BusinessDetailOut(BusinessOut): + """业务详情:附产品明细行与二次分配方案。""" + + items: list[BusinessItemOut] = Field(default_factory=list) + secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list) + allocation_hint: str | None = Field( + default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)" + ) + + +class BusinessCreateIn(BaseModel): + """创建业务:主表信息 + 明细行数组(明细 ≥1 行)。 + + 字段名全线蛇形直出(M1 既有契约:前端 api 封装与 schemas 同名对应, + 仅分页信封 list/pageNum 驼峰)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + customer_id: int = Field(ge=1, description="客户ID") + business_date: date | None = Field(default=None, description="业务日期,ISO 格式") + owner_personnel_id: int | None = Field( + default=None, ge=1, description="负责营销人员ID,可空=待指定" + ) + assistant_personnel_id: int | None = Field(default=None, ge=1, description="协助营销人员ID") + is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否") + special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明") + items: list[BusinessItemIn] = Field(min_length=1, description="产品明细行,至少 1 行") + secondary_allocations: list[SecondaryAllocationIn] = Field( + default_factory=list, description="二次分配方案(只出现在合同刚建立时;可空)" + ) + remark: str | None = Field(default=None, max_length=255) + + @model_validator(mode="after") + def _check_special_note(self) -> "BusinessCreateIn": + """特殊业务须有说明:标记的意义就在于可追溯「特殊在哪」。""" + if self.is_special == 1 and not self.special_note: + raise ValueError("特殊业务必须填写说明") + return self + + @model_validator(mode="after") + def _check_allocations(self) -> "BusinessCreateIn": + _ensure_allocations_unique(self.secondary_allocations) + return self + + +class BusinessUpdateIn(BaseModel): + """编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + customer_id: int = Field(ge=1, description="客户ID") + business_date: date | None = Field(default=None, description="业务日期,ISO 格式") + owner_personnel_id: int | None = Field( + default=None, + ge=1, + description="负责营销人员ID(编辑端点忽略,仅 /ownership 生效)", + ) + assistant_personnel_id: int | None = Field( + default=None, + ge=1, + description="协助营销人员ID(编辑端点忽略)", + ) + is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否") + special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明") + items: list[BusinessItemIn] = Field( + min_length=1, description="产品明细行,至少 1 行(全量替换)" + ) + secondary_allocations: list[SecondaryAllocationIn] = Field( + default_factory=list, description="二次分配方案(全量替换;空数组=清空方案)" + ) + remark: str | None = Field(default=None, max_length=255) + + @model_validator(mode="after") + def _check_special_note(self) -> "BusinessUpdateIn": + if self.is_special == 1 and not self.special_note: + raise ValueError("特殊业务必须填写说明") + return self + + @model_validator(mode="after") + def _check_allocations(self) -> "BusinessUpdateIn": + _ensure_allocations_unique(self.secondary_allocations) + return self + + +class BusinessStatusIn(BaseModel): + """状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + status: int = Field(ge=1, le=3, description="目标状态:2已完成 3已终止") + reason: str | None = Field(default=None, max_length=255, description="变更原因(终止时必填)") + + @model_validator(mode="after") + def _check_reason(self) -> "BusinessStatusIn": + if self.status == 3 and not self.reason: + raise ValueError("终止业务必须填写原因") + return self + + +class BusinessOwnershipIn(BaseModel): + """业务归属变更(负责/协助;前后值写入留痕)。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + new_owner_personnel_id: int | None = Field(default=None, ge=1) + new_assistant_personnel_id: int | None = Field(default=None, ge=1) + reason: str | None = Field(default=None, max_length=255) + + +class BusinessQuery(PageQuery): + """业务列表查询条件(筛选参数驼峰 alias,customer.py 同款契约)。""" + + keyword: str | None = Field(default=None, max_length=32, description="业务编号/公司名称,模糊") + status: int | None = Field(default=None, ge=1, le=3, description="状态筛选") + customer_id: int | None = Field(default=None, ge=1, description="客户筛选") + owner_personnel_id: int | None = Field(default=None, ge=1, description="负责营销人员筛选") + date_from: date | None = Field(default=None, description="业务日期起(含)") + date_to: date | None = Field(default=None, description="业务日期止(含)") + + +class GrossProfitRowOut(BaseModel): + """毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。""" + + item_id: int + product_category: str + product_name: str + spec_model: str | None = None + quantity: str + sale_amount: str + cost_amount: str | None = None + """成本缺失(未录入)时为 None,派生列同为 None 并置 missing_reason""" + profit_amount: str | None = None + profit_rate: str | None = None + threshold: str + converted_profit: str | None = None + missing_reason: str | None = None + + +class GrossProfitCategoryOut(BaseModel): + """按产品类别的毛利润汇总(可计算行聚合;成本缺失行不计入)。""" + + product_category: str + sale_amount: str + cost_amount: str + profit_amount: str + profit_rate: str + threshold: str + converted_profit: str + + +class GrossProfitOut(BaseModel): + """毛利润数据视图(需求 2.2.4):行级 + 类别汇总。 + + 口径注释(前端须展示):毛利润率 = 毛利润额 / 销售额,**不含运费** + (对照表 #2 作废需规含运费公式);阈值按 asOf 取 M1 参数快照;M3 核算 + 实际按回款到账日取参,本视图默认今日仅供录入期核对。 + """ + + business_id: int + as_of: date + rows: list[GrossProfitRowOut] = Field(default_factory=list) + categories: list[GrossProfitCategoryOut] = Field(default_factory=list) + missing_count: int = 0 + note: str = "毛利润率与折算额不含运费;M3 核算按回款到账日取参数版本" + + +class OwnershipChangeLogOut(BaseModel): + """业务归属变更留痕(人员删除后姓名为 None,固化 ID 兜底可追溯)。""" + + id: int + business_id: int + old_owner_personnel_id: int | None = None + new_owner_personnel_id: int | None = None + old_assistant_personnel_id: int | None = None + new_assistant_personnel_id: int | None = None + old_owner_name: str | None = None + new_owner_name: str | None = None + change_reason: str | None = None + operator_id: int | None = None + operator_name: str + create_time: datetime + + +class BusinessStatusLogOut(BaseModel): + """业务状态流转记录(详情页时间线;创建首条 old=new=1)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + business_id: int + old_status: int + new_status: int + change_reason: str | None = None + operator_id: int | None = None + operator_name: str + create_time: datetime + + +# 供 schema 外部引用的两类别常量(类别汇总展示用) +CATEGORY_LABELS = { + PRODUCT_CATEGORY_MUNICIPAL_WATER: "市政水利", + PRODUCT_CATEGORY_RAIL_TRANSIT: "轨道交通", +} diff --git a/backend/app/schemas/business_role.py b/backend/app/schemas/business_role.py new file mode 100644 index 0000000..fe6a0ab --- /dev/null +++ b/backend/app/schemas/business_role.py @@ -0,0 +1,90 @@ +"""业务岗位角色的请求/响应模型。""" + +import re + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery + +# 岗位编码规则与系统角色一致:小写字母开头,后接小写字母/数字/下划线,长度 2–32。 +# 编码会作为参数维度值(param_values.dimension_value)与核算模板绑定的锚点, +# 保持单一风格避免大小写/全角混用造成对照困难 +BUSINESS_ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$") + + +class BusinessRoleOut(BaseModel): + """岗位响应(列表与详情共用)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + role_code: str + role_name: str + calc_template: str + description: str | None = None + sort_order: int = 0 + status: int = 1 + is_builtin: int = 0 + + +class TemplateParamOut(BaseModel): + """模板声明的参数(供岗位表单展示,含当前口径说明)。""" + + code: str + name: str + + +class TemplateOptionOut(BaseModel): + """公式模板下拉项。""" + + code: str + name: str + description: str + params: list[TemplateParamOut] + + +class BusinessRoleCreateIn(BaseModel): + """新增岗位。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + role_code: str = Field(min_length=2, max_length=32, description="岗位编码,创建后不可修改") + role_name: str = Field(min_length=1, max_length=64, description="岗位名称") + calc_template: str = Field(min_length=1, max_length=32, description="计提公式模板编码") + description: str | None = Field(default=None, max_length=255) + sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前") + + @field_validator("role_code") + @classmethod + def _check_code(cls, value: str) -> str: + if not BUSINESS_ROLE_CODE_PATTERN.match(value): + raise ValueError("岗位编码须以小写字母开头,仅可含小写字母、数字与下划线") + return value + + +class BusinessRoleUpdateIn(BaseModel): + """编辑岗位。 + + **不含 role_code** —— 编码是参数维度值与模板绑定的锚点(同系统角色的保护理由)。 + 前端表单全量提交,故为全量替换语义。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + role_name: str = Field(min_length=1, max_length=64, description="岗位名称") + calc_template: str = Field(min_length=1, max_length=32, description="计提公式模板编码") + description: str | None = Field(default=None, max_length=255) + sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前") + + +class BusinessRoleStatusIn(BaseModel): + """启用/禁用岗位。""" + + status: int = Field(ge=0, le=1, description="1启用 0禁用") + + +class BusinessRoleQuery(PageQuery): + """岗位列表查询条件。""" + + keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配") + status: int | None = Field(default=None, ge=0, le=1, description="状态筛选") diff --git a/backend/app/schemas/calc.py b/backend/app/schemas/calc.py new file mode 100644 index 0000000..597961a --- /dev/null +++ b/backend/app/schemas/calc.py @@ -0,0 +1,62 @@ +"""绩效核算的请求/响应模型(M3 ④)。""" + +from datetime import date, datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + +from app.core.response import PageQuery + + +class LedgerQuery(PageQuery): + """台账列表查询条件。""" + + settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM") + personnel_id: int | None = Field(default=None, ge=1, description="归属人员筛选") + business_id: int | None = Field(default=None, ge=1, description="业务筛选") + is_active: int | None = Field(default=None, ge=0, le=1, description="生效标记筛选") + calc_type: int | None = Field(default=None, ge=1, le=2, description="核算类型筛选") + + +class LedgerOut(BaseModel): + """台账行响应(金额/比例一律 str)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + receipt_id: int | None = None + calc_version: int + is_active: int + calc_type: int + business_id: int + business_no: str + personnel_id: int + personnel_name: str + role_code: str + customer_type: str + product_category: str + ship_date: date | None = None + receipt_date: date + payment_days: int | None = None + rate_text: str + converted_profit: Decimal | None = None + performance_amount: Decimal | None = None + barter_applied: int + weight_r: int | None = None + payment_weight: Decimal | None = None + payable_amount: Decimal | None = None + settlement_month: str + is_probation: int + missing_cost: int + snapshot_json: dict | None = None + settlement_id: int | None = None + calc_reason: str | None = None + create_time: datetime + + +class CalcReceiptOut(BaseModel): + """单笔核算结果:台账行 + 重复计提标记(核算粒度 #25 一期口径)。""" + + ledger: LedgerOut + #: True=该业务此前已核算过,本笔回款仅登记进度、未重复计提(#25 一期口径) + repeated: bool = False diff --git a/backend/app/schemas/customer.py b/backend/app/schemas/customer.py new file mode 100644 index 0000000..fde79e3 --- /dev/null +++ b/backend/app/schemas/customer.py @@ -0,0 +1,135 @@ +"""客户管理的请求/响应模型(需求 2.1.3)。""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.core.response import PageQuery + + +class CustomerOut(BaseModel): + """客户响应(列表、详情与写操作回包共用)。 + + owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的 + 脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕 + 「ID 固化、姓名随联表」同一口径。 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int + company_name: str + owner_name: str + contact_person: str | None = None + contact_phone: str | None = None + address: str | None = None + industry: str | None = None + customer_type: str + owner_personnel_id: int | None = None + owner_personnel_name: str | None = None + remark: str | None = None + + +class CustomerCreateIn(BaseModel): + """新增客户。 + + owner_personnel_id 为**初始归属**,可空(客户先建档、后派归属也合法); + 归属的后续调整不走本模型,统一走 ``PUT /{id}/ownership``(见 + ``CustomerUpdateIn`` 的说明)。字段名蛇形直出(M1 契约:前端 api 封装 + 与 schema 同名对应,仅分页信封 list/pageNum 驼峰)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + company_name: str = Field(min_length=1, max_length=128, description="公司名称") + owner_name: str = Field( + min_length=1, + max_length=64, + description="客户负责人姓名(客户公司的负责人人名,非系统内营销人员)", + ) + contact_person: str | None = Field(default=None, max_length=64, description="联系人") + contact_phone: str | None = Field(default=None, max_length=20, description="联系电话") + address: str | None = Field(default=None, max_length=255, description="地址") + industry: str | None = Field(default=None, max_length=64, description="所属行业") + customer_type: str = Field( + min_length=1, + max_length=32, + description="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户", + ) + owner_personnel_id: int | None = Field( + default=None, + ge=1, + description="归属负责营销人员ID(初始归属,可空)", + ) + remark: str | None = Field(default=None, max_length=255, description="备注") + + +class CustomerUpdateIn(BaseModel): + """编辑客户(全量替换,前端表单全量提交)。 + + **不含 owner_personnel_id** —— 归属调整统一走 ``PUT /{id}/ownership``, + 只有那条路径才写归属变更留痕。若在本端点放开该字段,会出现「改了归属 + 却无留痕」的旁路,破坏留痕的 append-only 完整性;编辑表单即使提交 + ownerPersonnelId,pydantic 默认忽略未声明字段,归属以归属端点为准。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + company_name: str = Field(min_length=1, max_length=128, description="公司名称") + owner_name: str = Field(min_length=1, max_length=64, description="客户负责人姓名") + contact_person: str | None = Field(default=None, max_length=64, description="联系人") + contact_phone: str | None = Field(default=None, max_length=20, description="联系电话") + address: str | None = Field(default=None, max_length=255, description="地址") + industry: str | None = Field(default=None, max_length=64, description="所属行业") + customer_type: str = Field( + min_length=1, + max_length=32, + description="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户", + ) + remark: str | None = Field(default=None, max_length=255, description="备注") + + +class CustomerQuery(PageQuery): + """客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。""" + + keyword: str | None = Field( + default=None, max_length=64, description="公司名称或客户负责人,模糊匹配" + ) + customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选") + owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选") + + +class CustomerOwnershipIn(BaseModel): + """归属变更入参。 + + new_owner_personnel_id 可空:null 表示清空归属(客户暂无负责人也合法)。 + """ + + new_owner_personnel_id: int | None = Field( + default=None, + ge=1, + description="变更后归属人员ID,null=清空归属", + ) + reason: str | None = Field(default=None, max_length=255, description="变更原因") + + +class OwnershipChangeLogOut(BaseModel): + """归属变更留痕响应。 + + 新旧归属人员姓名由 LEFT JOIN 带出,人员被删除后为 null;固化的 + ``*_personnel_id`` 始终原样返回 —— 留痕表对人员 ID 无外键固化 + (见 `models/customer.py` 的论证),姓名可随人员消失,ID 不可。 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int + customer_id: int + old_owner_personnel_id: int | None = None + new_owner_personnel_id: int | None = None + old_owner_name: str | None = None + new_owner_name: str | None = None + change_reason: str | None = None + operator_id: int | None = None + operator_name: str + create_time: datetime diff --git a/backend/app/schemas/log.py b/backend/app/schemas/log.py new file mode 100644 index 0000000..79ce87e --- /dev/null +++ b/backend/app/schemas/log.py @@ -0,0 +1,105 @@ +"""日志查询的请求/响应模型。 + +操作日志与登录日志字段不同(前者含模块/操作/请求详情,后者含失败原因), +故各自定义 Query 与 Out,仅共用「用户名 + 结果 + 时间范围」的筛选基类。 +""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from app.core.response import PageQuery + + +class LogQueryBase(PageQuery): + """日志公共查询条件:模糊用户名 + 结果 + 时间范围。""" + + username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配") + success: int | None = Field(default=None, ge=0, le=1, description="1成功 0失败") + start_time: datetime | None = Field(default=None, description="起始时间(含)") + end_time: datetime | None = Field(default=None, description="结束时间(含)") + + @field_validator("start_time", "end_time") + @classmethod + def _to_local_naive(cls, value: datetime | None) -> datetime | None: + """统一为本地时区的 naive 时间。 + + 库列存本地时间(容器 TZ=Asia/Shanghai),而 API 接受 ISO 8601 + (可带 ``Z`` 等时区后缀)。不归一时有两种故障:aware 与 naive + 混用比较抛 TypeError(穿透成 500);aware 直接透传给驱动会 + **静默丢弃偏移**(查询窗口整体偏 8 小时且无任何提示)。 + aware 先转系统本地时区、再摘除 tzinfo 是唯一正确姿势; + naive 视为本地时间原样保留。 + """ + if value is not None and value.tzinfo is not None: + return value.astimezone().replace(tzinfo=None) + return value + + @model_validator(mode="after") + def _check_time_range(self) -> "LogQueryBase": + if self.start_time and self.end_time and self.start_time > self.end_time: + raise ValueError("起始时间不能晚于结束时间") + return self + + +class OperationLogQuery(LogQueryBase): + """操作日志查询条件。""" + + module: str | None = Field(default=None, max_length=64, description="所属模块,精确匹配") + action: str | None = Field(default=None, max_length=32, description="操作类型,精确匹配") + + +class OperationLogOut(BaseModel): + """操作日志列表项。 + + **不含 request_data**(TEXT,单行最大 60000 字节):列表页默认不展示它, + 随列表返回会让翻页负担随日志表增长线性放大;展开详情走 + `GET /operation-logs/{id}`(`OperationLogDetailOut`),前端按需加载。 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int | None = None + username: str + module: str + action: str + summary: str + method: str | None = None + path: str | None = None + ip: str | None = None + user_agent: str | None = None + success: int = 0 + error_message: str | None = None + duration_ms: int | None = None + create_time: datetime | None = None + + +class OperationLogDetailOut(OperationLogOut): + """操作日志详情:列表字段 + 请求参数。 + + `request_data` 入库时已经结构化 + 文本两层脱敏,可直接展示。 + """ + + request_data: str | None = None + + +class LoginLogQuery(LogQueryBase): + """登录日志查询条件。""" + + ip: str | None = Field(default=None, max_length=45, description="客户端IP,精确匹配") + + +class LoginLogOut(BaseModel): + """登录日志响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int | None = None + username: str + ip: str | None = None + user_agent: str | None = None + success: int = 0 + fail_reason: str | None = None + create_time: datetime | None = None diff --git a/backend/app/schemas/param.py b/backend/app/schemas/param.py new file mode 100644 index 0000000..4e963dd --- /dev/null +++ b/backend/app/schemas/param.py @@ -0,0 +1,238 @@ +"""计提参数管理的请求/响应模型。 + +字段命名与 user/role 一致采用蛇形直出(前端 TS 接口同构)。 +金额类入参用字符串承载(Decimal 解析),杜绝 float 精度损失。 +""" + +from datetime import date, datetime +from decimal import Decimal, InvalidOperation + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from app.core.response import PageQuery + + +def _parse_amount(value: str, *, field_name: str, allow_negative: bool) -> str: + """金额字符串校验:必须可被 Decimal 解析;默认拒绝负数(毛利润额除外)。""" + try: + amount = Decimal(value.strip()) + except InvalidOperation as exc: + raise ValueError(f"{field_name}必须是有效数字:{value}") from exc + if not amount.is_finite(): + # 'NaN' 的比较会抛 InvalidOperation、'Infinity' 会在后续计算抛溢出,均须在入口拦下 + raise ValueError(f"{field_name}必须为有限数字:{value}") + if not allow_negative and amount < 0: + raise ValueError(f"{field_name}不得为负数:{value}") + return value + + +class ParamDimensionValueOut(BaseModel): + """某参数单个维度的当前生效值。""" + + dimension_value: str + value_text: str + effective_date: date | None = None + + +class ParamCatalogItemOut(BaseModel): + """参数目录项:定义元数据 + 各维度截至查询时点的生效值。""" + + param_code: str + param_name: str + param_group: str + value_type: str + unit: str | None = None + dimension_type: str + options: list[str] = Field(default_factory=list) + default_value: str | None = None + value_min: str | None = None + value_max: str | None = None + description: str | None = None + values: list[ParamDimensionValueOut] = Field(default_factory=list) + + +class ParamHistoryOut(BaseModel): + """单参数的版本历史条目(append-only 留痕)。""" + + version_no: str + dimension_value: str + old_value_text: str | None = None + value_text: str + effective_date: date + operator_name: str + create_time: datetime + + +class ParamChangeItemIn(BaseModel): + """变更条目:参数 + 维度 + 新值。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + param_code: str = Field(min_length=1, max_length=64) + dimension_value: str = Field(default="", max_length=32) + value: str = Field(min_length=1, max_length=64) + + +class ParamChangeIn(BaseModel): + """提交变更批次。 + + change_type=1 立即生效(生效日期取今日);change_type=2 指定日期生效, + 生效日期必填且不得早于今日(过去没有「预约」的意义,历史回溯走重算授权)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + change_type: int = Field(ge=1, le=2, description="生效方式:1立即生效 2指定日期生效") + effective_date: date | None = Field(default=None, description="指定日期生效时必填") + remark: str | None = Field(default=None, max_length=255) + items: list[ParamChangeItemIn] = Field(min_length=1, max_length=50) + + @model_validator(mode="after") + def _check_effective_date(self) -> "ParamChangeIn": + if self.change_type == 2 and self.effective_date is None: + raise ValueError("指定日期生效时必须提供生效日期") + return self + + +class ParamChangeBatchOut(BaseModel): + """变更批次(提交/撤回/列表共用)。""" + + id: int + version_no: str + effective_date: date + change_type: int + remark: str | None = None + operator_name: str + item_count: int + create_time: datetime + + +class ParamBatchItemOut(BaseModel): + """批次明细条目(旧值 → 新值)。""" + + param_code: str + param_name: str + dimension_value: str + old_value_text: str | None = None + value_text: str + effective_date: date + + +class ParamBatchOut(BaseModel): + """变更批次(列表项)。""" + + id: int + version_no: str + effective_date: date + change_type: int + remark: str | None = None + operator_name: str + item_count: int + create_time: datetime + + +class ParamBatchDetailOut(BaseModel): + """批次明细:批次头 + 逐项变更。""" + + id: int + version_no: str + effective_date: date + change_type: int + remark: str | None = None + operator_name: str + item_count: int + create_time: datetime + items: list[ParamBatchItemOut] = Field(default_factory=list) + + +class ParamDiffOut(BaseModel): + """两日期快照对比项(全量参数,changed 标记是否变化)。""" + + param_code: str + param_name: str + param_group: str + dimension_value: str + old_value: str | None = None + new_value: str | None = None + changed: bool + + +class ParamPreviewIn(BaseModel): + """计算预览入参。金额用字符串承载,Decimal 解析。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + business_role: str | None = Field( + default=None, max_length=32, description="岗位编码,如 salesperson" + ) + customer_type: str | None = Field( + default=None, max_length=32, description="客户类型:personal个人户 enterprise企业户" + ) + product_category: str | None = Field( + default=None, max_length=32, description="产品类别:municipal_water/rail_transit" + ) + ship_date: date = Field(description="发货日") + receipt_date: date = Field(description="回款到账日") + revenue: str = Field(min_length=1, description="销售额(不含运费)") + profit: str = Field(min_length=1, description="毛利润额(可为负)") + received_amount: str = Field(min_length=1, description="本笔回款金额") + barter: bool = Field(default=False, description="是否以物抵顶") + probation: bool = Field(default=False, description="是否试用期") + + @field_validator("revenue", "received_amount") + @classmethod + def _check_non_negative(cls, value: str, info) -> str: + return _parse_amount(value, field_name=info.field_name, allow_negative=False) + + @field_validator("profit") + @classmethod + def _check_profit(cls, value: str, info) -> str: + return _parse_amount(value, field_name=info.field_name, allow_negative=True) + + @model_validator(mode="after") + def _check_dates(self) -> "ParamPreviewIn": + if self.receipt_date < self.ship_date: + raise ValueError("回款到账日不得早于发货日") + return self + + +class ParamUsedOut(BaseModel): + """预览实际用到的参数(对账透明:取值与生效时点)。""" + + param_code: str + value_text: str + effective_date: date | None = None + + +class ParamPreviewOut(BaseModel): + """计算预览出参。分数串(如 97/10000)为精确值,金额为 Decimal 串。""" + + payment_days: int + rate: str + rate_text: str + profit_rate: str + converted_profit: str + performance_amount: str + barter_applied: bool + performance_after_barter: str | None = None + start_payment_ok: bool + payment_weight_r: int + payment_weight: str + payable_amount: str + probation_note: str | None = None + params_used: list[ParamUsedOut] = Field(default_factory=list) + + +class ParamBatchQuery(PageQuery): + """变更记录查询条件。""" + + start_date: date | None = Field(default=None, description="生效日期起(含)") + end_date: date | None = Field(default=None, description="生效日期止(含)") + + +class ParamHistoryQuery(PageQuery): + """参数历史查询条件。""" + + dimension_value: str | None = Field( + default=None, max_length=32, description="按维度值筛选,空为全部" + ) diff --git a/backend/app/schemas/personnel.py b/backend/app/schemas/personnel.py new file mode 100644 index 0000000..b2cfa3f --- /dev/null +++ b/backend/app/schemas/personnel.py @@ -0,0 +1,149 @@ +"""营销人员的请求/响应模型(需求 2.1.1)。""" + +from datetime import date +from decimal import Decimal, InvalidOperation + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery + +# salary_standard 列为 Numeric(12,2):整数部分最多 10 位。 +# 工资系统金额红线——入参出参一律 str,服务层转 Decimal,全程不经 float +SALARY_MAX = Decimal("9999999999.99") + + +class PersonnelRegionOut(BaseModel): + """辐射区域条目响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + region_name: str + remark: str | None = None + + +class PersonnelPlanOut(BaseModel): + """拓展计划条目响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + plan_period: str + plan_content: str + target_desc: str | None = None + + +class PersonnelOut(BaseModel): + """人员响应(列表与写操作回包共用)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + personnel_name: str + business_role_id: int + role_name: str | None = None + manager_id: int | None = None + manager_name: str | None = None + phone: str | None = None + hire_date: date | None = None + is_probation: int = 0 + salary_standard: str | None = None + status: int = 1 + remark: str | None = None + + +class PersonnelDetailOut(PersonnelOut): + """人员详情:附区域与拓展计划,供编辑抽屉一次回显。""" + + regions: list[PersonnelRegionOut] = Field(default_factory=list) + plans: list[PersonnelPlanOut] = Field(default_factory=list) + + +class PersonnelCreateIn(BaseModel): + """新增人员。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + personnel_name: str = Field(min_length=1, max_length=64, description="姓名") + business_role_id: int = Field(ge=1, description="岗位角色ID") + manager_id: int | None = Field(default=None, ge=1, description="直属营销经理ID,空=无") + phone: str | None = Field(default=None, max_length=20, description="联系电话") + hire_date: date | None = Field(default=None, description="入职日期,ISO 格式") + is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否") + salary_standard: str | None = Field( + default=None, description="月工资标准(元),字符串形式的数字" + ) + remark: str | None = Field(default=None, max_length=255) + + @field_validator("salary_standard") + @classmethod + def _check_salary_standard(cls, value: str | None) -> str | None: + """按 Numeric(12,2) 的容量校验字符串金额。 + + 金额以字符串进出避免 float 精度损失(项目金额红线); + 小数位超过两位直接拒绝而非静默舍入——舍入即算错钱。 + """ + if value is None: + return None + try: + amount = Decimal(value) + except InvalidOperation: + raise ValueError("工资标准须为有效数字") from None + if not amount.is_finite(): + raise ValueError("工资标准须为有效数字") + if amount < 0: + raise ValueError("工资标准不能为负数") + if amount > SALARY_MAX: + raise ValueError("工资标准超出可录入上限") + if -amount.as_tuple().exponent > 2: + raise ValueError("工资标准最多两位小数") + return value + + +class PersonnelUpdateIn(PersonnelCreateIn): + """编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。""" + + +class PersonnelStatusIn(BaseModel): + """在职/离职切换。""" + + status: int = Field(ge=0, le=1, description="1在职 0离职") + + +class RegionItemIn(BaseModel): + """辐射区域条目。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + region_name: str = Field(min_length=1, max_length=128, description="区域名称") + remark: str | None = Field(default=None, max_length=255) + + +class RegionReplaceIn(BaseModel): + """辐射区域全量替换。空列表即清空该人员的全部区域。""" + + items: list[RegionItemIn] = Field(default_factory=list, description="全量条目") + + +class PlanItemIn(BaseModel): + """拓展计划条目。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + plan_period: str = Field(min_length=1, max_length=32, description="计划期间,如 2026Q1") + plan_content: str = Field(min_length=1, max_length=500, description="计划内容") + target_desc: str | None = Field(default=None, max_length=255, description="指标说明") + + +class PlanReplaceIn(BaseModel): + """拓展计划全量替换。空列表即清空该人员的全部计划。""" + + items: list[PlanItemIn] = Field(default_factory=list, description="全量条目") + + +class PersonnelQuery(PageQuery): + """人员列表查询条件。""" + + keyword: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配") + status: int | None = Field(default=None, ge=0, le=1, description="状态筛选:1在职 0离职") + business_role_id: int | None = Field(default=None, ge=1, description="岗位筛选") diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py new file mode 100644 index 0000000..cf44256 --- /dev/null +++ b/backend/app/schemas/product.py @@ -0,0 +1,97 @@ +"""产品管理的请求/响应模型。 + +数量与单价(stock_quantity/sale_price)入参出参一律用**字符串**承载: +前端 JS 的 number 无法精确表示全部十进制小数,字符串是金额红线的入口闸门, +Decimal 转换统一收敛在 service 层。字段命名与 user/role/param 一致采用 +蛇形直出(前端 TS 接口同构)。 +""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery +from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount + + +class ProductOut(BaseModel): + """产品响应(列表与详情共用)。数量/单价按红线以字符串出参。""" + + id: int + product_name: str + spec_model: str + unit: str | None = None + stock_quantity: str | None = None + sale_price: str | None = None + product_category: str + status: int = 1 + remark: str | None = None + create_time: datetime + update_time: datetime + + +class ProductCreateIn(BaseModel): + """新增产品。 + + spec_model 允许缺省/空:service 层归一为空串(组合唯一键 + uk_products_name_spec 对 NULL 不去重,归 NULL 会放行重名同规格数据)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + product_name: str = Field(min_length=1, max_length=128, description="产品名称") + spec_model: str | None = Field( + default=None, max_length=128, description="规格型号,空归一为空串" + ) + unit: str | None = Field(default=None, max_length=16, description="计量单位") + stock_quantity: str | None = Field(default=None, description="数量,非负数字字符串") + sale_price: str | None = Field(default=None, description="销售单价(元),非负数字字符串") + product_category: str = Field(min_length=1, max_length=32, description="产品类别") + remark: str | None = Field(default=None, max_length=255) + + @field_validator("stock_quantity") + @classmethod + def _check_stock_quantity(cls, value: str | None) -> str | None: + """空串与 None 同义(可空列),归一为 None;非空走通用金额校验。 + + 复用 _check_amount:容量/小数位与 DECIMAL(14,2) 列口径一致 + (此前私写的 _parse_non_negative 不查小数位与容量,属同语义 + 字段两处口径漂移)。 + """ + if not value: + return None + return _check_amount(value, "数量", AMOUNT_MAX) + + @field_validator("sale_price") + @classmethod + def _check_sale_price(cls, value: str | None) -> str | None: + """空串与 None 同义(可空列),归一为 None;非空走通用金额校验。 + + scale=4 与列口径 Numeric(14,4) 一致 —— 带精度的历史单价必须能 + 原样提交(「编辑任何带精度历史行都失败」的 fba8cfc 教训)。 + """ + if not value: + return None + return _check_amount(value, "销售单价", PRICE_MAX, scale=4) + + +class ProductUpdateIn(ProductCreateIn): + """编辑产品。 + + 前端表单全量提交,故为全量替换语义;**不含 status** —— 启用/停用是 + 独立操作(走 PUT /{id}/status),避免编辑表单误触停用。 + """ + + +class ProductStatusIn(BaseModel): + """启用/停用产品。""" + + status: int = Field(ge=0, le=1, description="1启用 0停用") + + +class ProductQuery(PageQuery): + """产品列表查询条件。""" + + keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配") + product_category: str | None = Field(default=None, max_length=32, description="按类别筛选") + status: int | None = Field(default=None, ge=0, le=1, description="状态筛选") diff --git a/backend/app/schemas/rating.py b/backend/app/schemas/rating.py new file mode 100644 index 0000000..18fe979 --- /dev/null +++ b/backend/app/schemas/rating.py @@ -0,0 +1,79 @@ +"""客户评级等级配置的请求/响应模型。 + +分值(min_score/max_score)入参出参一律用**字符串**承载:前端 JS 的 number +无法精确表示全部十进制小数,字符串是分值红线的入口闸门,Decimal 转换统一 +收敛在 service 层(与 schemas/product.py 同一口径)。 + +注意:schema 只拦**格式**(可解析、有限数);0≤min≤max≤100 与区间不重叠 +是横跨两字段/多行的业务值域规则,在 service 层审计上下文内统一判定 —— +值域校验失败的 400 也要留痕(2026-10-13 定稿口径)。 +""" + +from datetime import datetime +from decimal import Decimal, InvalidOperation + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +def _parse_score(value: str, *, label: str) -> str: + """分值字符串格式校验:必须可被 Decimal 解析且为有限数。 + + ``NaN`` 参与比较抛 InvalidOperation、``Infinity`` 落库即溢出,均须在 + 入口用 ``is_finite()`` 拦下(与 schemas/product.py 的 _parse_non_negative + 同口径);非负与 ≤100 属业务值域,留给 service 层在审计上下文内判定。 + """ + try: + score = Decimal(value.strip()) + except InvalidOperation as exc: + raise ValueError(f"{label}必须是有效数字:{value}") from exc + if not score.is_finite(): + raise ValueError(f"{label}必须为有限数字:{value}") + return value + + +class RatingGradeOut(BaseModel): + """评级等级响应(列表直出)。分值按红线以字符串出参。""" + + id: int + grade_name: str + min_score: str + max_score: str + sort_order: int + remark: str | None = None + create_time: datetime + update_time: datetime + + +class RatingGradeCreateIn(BaseModel): + """新增评级等级。 + + min_score/max_score 必填:区间是判定表的判定依据,缺省无从判定。 + sort_order 缺省 0(列默认值同源),判定按其升序取首个命中等级。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + grade_name: str = Field(min_length=1, max_length=32, description="等级名称(如 A/优秀)") + min_score: str = Field(description="总分下限(含),0-100 数字字符串") + max_score: str = Field(description="总分上限(展示用),0-100 数字字符串") + sort_order: int = Field(default=0, ge=0, description="判定顺序,升序") + remark: str | None = Field(default=None, max_length=255, description="说明") + + @field_validator("min_score") + @classmethod + def _check_min_score(cls, value: str) -> str: + """总分下限须为有限数字(值域 0-100 由 service 层判定)。""" + return _parse_score(value, label="总分下限") + + @field_validator("max_score") + @classmethod + def _check_max_score(cls, value: str) -> str: + """总分上限须为有限数字(值域 0-100 由 service 层判定)。""" + return _parse_score(value, label="总分上限") + + +class RatingGradeUpdateIn(RatingGradeCreateIn): + """编辑评级等级。 + + 前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。 + """ diff --git a/backend/app/schemas/receipt.py b/backend/app/schemas/receipt.py new file mode 100644 index 0000000..dbc0a5c --- /dev/null +++ b/backend/app/schemas/receipt.py @@ -0,0 +1,102 @@ +"""回款管理的请求/响应模型(需求 2.2.3)。 + +金额入参出参一律 str(服务层 Decimal,全程禁 float);字段契约**纯蛇形 +直出**(2026-10-13 定稿,无驼峰 alias)。 + +payment_days 是派生值(发货日 → 回款日的天数,对照表 #1),不在写接口 +入参内 —— 落库列只有 shipment_id 源数据,派生值实时计算,发货批次变更 +后无需回写历史。 +""" + +from datetime import date + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery + +# 金额校验是工资系统的红线口径(数字/非负/容量/两位小数拒绝),跨 schema +# 单点复用 T7 定稿工具,禁止各处复制导致口径漂移;AMOUNT_MAX 即 +# DECIMAL(14,2) 的容量上限,与回款列容量同源 +from app.schemas.business import AMOUNT_MAX, _check_amount + + +class ReceiptCreateIn(BaseModel): + """新增回款:业务必选,发货批次可暂不指定(手动指定,对照表 #18)。 + + shipment_id 指向的发货单必须与本回款同属一个业务(服务层强校验, + 数据完整性红线);shipment_id 可空 = 暂未指定,指定后回款天数方可派生。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + business_id: int = Field(ge=1, description="业务ID") + shipment_id: int | None = Field(default=None, ge=1, description="发货单ID,可空=暂未指定") + receipt_date: date = Field(description="回款到账日(M3 按此日匹配参数版本)") + amount: str = Field(description="回款金额(元),≥0,最多两位小数") + is_barter: int = Field(default=0, ge=0, le=1, description="以物抵顶标记:1是 0否") + remark: str | None = Field(default=None, max_length=255, description="备注") + + @field_validator("amount") + @classmethod + def _check_amount_value(cls, value: str) -> str: + return _check_amount(value, "回款金额", AMOUNT_MAX) or value + + +class ReceiptUpdateIn(BaseModel): + """编辑回款(全量替换语义)。 + + shipment_id 传 None = 清除指定(回到「暂未指定」);amount 是必填项, + 全量替换下不存在「不传即不变」的歧义。已核算(settlement_status=1) + 的回款在服务层禁改,schema 层不感知该状态。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + shipment_id: int | None = Field( + default=None, ge=1, description="发货单ID,可空=暂未指定(传空清除既有指定)" + ) + receipt_date: date = Field(description="回款到账日") + amount: str = Field(description="回款金额(元),≥0,最多两位小数") + is_barter: int = Field(default=0, ge=0, le=1, description="以物抵顶标记:1是 0否") + remark: str | None = Field(default=None, max_length=255, description="备注") + + @field_validator("amount") + @classmethod + def _check_amount_value(cls, value: str) -> str: + return _check_amount(value, "回款金额", AMOUNT_MAX) or value + + +class ReceiptOut(BaseModel): + """回款响应(列表与写操作回包共用)。 + + business_no/shipment_no 为关联冗余展示;payment_days 为派生值: + 未指定发货批次时为 None,指定后 = 发货日到回款日的天数(发货日首日 + 计第 1 天)。 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int + receipt_batch_no: str + business_id: int + business_no: str + shipment_id: int | None = None + shipment_no: str | None = None + receipt_date: date + amount: str + is_barter: int + settlement_status: int + payment_days: int | None = None + remark: str | None = None + + +class ReceiptQuery(PageQuery): + """回款列表查询条件(筛选参数纯蛇形直出,同 BusinessQuery 契约)。""" + + business_id: int | None = Field(default=None, ge=1, description="业务筛选") + date_from: date | None = Field(default=None, description="回款到账日起(含)") + date_to: date | None = Field(default=None, description="回款到账日止(含)") + is_barter: int | None = Field(default=None, ge=0, le=1, description="以物抵顶筛选:1是 0否") + settlement_status: int | None = Field( + default=None, ge=0, le=1, description="核算状态筛选:0未核算 1已核算" + ) diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py new file mode 100644 index 0000000..2c3bfa5 --- /dev/null +++ b/backend/app/schemas/report.py @@ -0,0 +1,269 @@ +"""报表统计的请求/响应模型(M4 ⑤)。 + +金额与比例一律 `Decimal`(pydantic v2 序列化为字符串,零精度损失); +比例原样输出 `rate_text`(如 88/9000),禁止转 float 或截断展示。 +""" + +from datetime import date +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + +from app.core.response import PageQuery + +# ==== 查询入参 ==== + + +class PersonalDetailQuery(PageQuery): + """个人绩效明细查询条件(FR-029:人员/月份/客户类型/产品类别/岗位)。""" + + settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM") + personnel_id: int | None = Field(default=None, ge=1, description="人员筛选") + customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选") + product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选") + role_code: str | None = Field(default=None, max_length=32, description="岗位筛选") + + +class DepartmentSummaryQuery(BaseModel): + """部门汇总查询条件(月份必填——部门表按单月聚合,无跨月视角)。""" + + settlement_month: str = Field(min_length=7, max_length=7, description="归属月 YYYY-MM") + manager_id: int | None = Field(default=None, ge=1, description="仅看某经理团队") + + +class BusinessStatsQuery(PageQuery): + """业务统计查询条件;回款/绩效为该业务累计值,不随业务日期筛选而变。""" + + keyword: str | None = Field(default=None, max_length=64, description="业务编号/客户名称") + customer_id: int | None = Field(default=None, ge=1, description="客户筛选") + owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人筛选") + status: int | None = Field(default=None, description="业务状态筛选") + date_from: date | None = Field(default=None, description="业务日期起") + date_to: date | None = Field(default=None, description="业务日期止") + + +class PaymentStatsQuery(BaseModel): + """回款统计查询条件(按回款天数区间/客户类型/产品类别统计,FR-031)。""" + + settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM") + date_from: date | None = Field(default=None, description="回款日期起") + date_to: date | None = Field(default=None, description="回款日期止") + customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选") + product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选") + + +class PaymentDetailQuery(PageQuery): + """回款区间下钻(桶编码必填,入参由 payment-stats 页面展开行携带)。""" + + bucket: str = Field(min_length=1, max_length=16, description="区间桶编码") + settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM") + date_from: date | None = Field(default=None, description="回款日期起") + date_to: date | None = Field(default=None, description="回款日期止") + customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选") + product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选") + + +# ==== 响应出参 ==== + + +class ReportSummary(BaseModel): + """全筛选口径合计(与「当前页合计」区分,前端页脚需注明)。 + + 字段按报表取用:个人明细用绩效/应付/折算毛利;业务统计用发货/回款/ + 销售额;不涉及的留 None。 + """ + + row_count: int = Field(default=0, description="筛选结果行数") + performance_total: Decimal | None = Field(default=None, description="绩效金额合计") + payable_total: Decimal | None = Field(default=None, description="应付绩效合计") + converted_profit_total: Decimal | None = Field(default=None, description="折算毛利润合计") + ship_total: Decimal | None = Field(default=None, description="发货额合计") + received_total: Decimal | None = Field(default=None, description="回款额合计") + sale_total: Decimal | None = Field(default=None, description="销售额合计") + + +class ReportPage[T](BaseModel): + """报表分页:标准分页四字段 + 全筛选合计。 + + 序列化字段与 PageResult 一致(list/total/pageNum/pageSize),前端 + usePagedList 可直接消费;追加的 summary 由各页页脚单独读取。 + """ + + model_config = ConfigDict(populate_by_name=True) + + records: list[T] = Field( + default_factory=list, alias="list", serialization_alias="list", description="当前页数据" + ) + total: int = Field(default=0, description="总记录数") + page_num: int = Field( + default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码" + ) + page_size: int = Field( + default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数" + ) + summary: ReportSummary = Field(default_factory=ReportSummary, description="全筛选合计") + + +class PersonalDetailRow(BaseModel): + """个人绩效明细行(需规 2.4.1 列全覆盖)。 + + 二次分摊拆行后同一回款按人多行(负责人余额行 + 参与人份额行),行内 + 不放「关联回款金额」列——同回款金额重复展示会让合计无法自洽,金额 + 下沉到快照下钻。 + """ + + ledger_id: int + business_id: int + business_no: str + personnel_id: int + personnel_name: str + role_code: str + role_name: str + customer_type: str + product_category: str + ship_date: date | None = None + receipt_date: date + payment_days: int | None = None + rate_text: str + weight_r: int | None = None + payment_weight: Decimal | None = None + performance_amount: Decimal | None = None + payable_amount: Decimal | None = None + converted_profit: Decimal | None = None + is_probation: int + missing_cost: int + calc_version: int + settlement_id: int | None = None + + +class DepartmentMemberRow(BaseModel): + """部门汇总 · 团队成员明细(内联于团队行)。""" + + personnel_id: int + personnel_name: str + role_code: str + is_probation: int + performance_sum: Decimal | None = None + payable_sum: Decimal | None = None + adjust_extra: Decimal = Field(default=Decimal("0"), description="调整单生效补发合计") + adjust_deduct: Decimal = Field(default=Decimal("0"), description="调整单生效扣减合计") + adjust_net: Decimal = Field(default=Decimal("0"), description="调整净额(补发为正)") + net_total: Decimal | None = None + ledger_ids: list[int] = Field(default_factory=list, description="台账行 id 列表(下钻锚点)") + + +class DepartmentTeamRow(BaseModel): + """部门汇总 · 经理团队行。""" + + manager_id: int + manager_name: str + manager_status: int = Field(description="经理在职状态(1 在职 0 离职)") + member_count: int + team_base: Decimal = Field(description="团队基数(12.5% 的底,等于成员绩效合计)") + team_share_amount: Decimal = Field(description="12.5% 团队分成") + company_share_amount: Decimal = Field(description="5% 全公司分成(同一基数,各团队不互斥)") + company_base_ratio: Decimal = Field(description="团队基数占全公司基数比例(除零为 0)") + adjust_extra: Decimal = Decimal("0") + adjust_deduct: Decimal = Decimal("0") + adjust_net: Decimal = Decimal("0") + net_total: Decimal = Field(description="团队净额 = 分成 + 调整净额") + frozen_amount: Decimal | None = Field(default=None, description="月结聚合行冻结额(对照)") + aggregate_ledger_id: int | None = None + members: list[DepartmentMemberRow] = Field(default_factory=list) + + +class DepartmentCompanyBlock(BaseModel): + """部门汇总 · 全公司口径块(FR-030「全公司 5% 部分单列」)。""" + + team_base_total: Decimal = Field(description="各团队基数之和(= 全部单笔绩效合计)") + company_base: Decimal = Field(description="全公司基数(5% 的底)") + company_share_pct: str = Field(description="分成比例参数原文") + company_share_amount: Decimal = Field(description="5% 全公司分成") + + +class DepartmentSummaryOut(BaseModel): + """部门绩效工资汇总响应(FR-030)。""" + + settlement_month: str + params: dict | None = Field(default=None, description="分成参数原文与取值时点(对账用)") + teams: list[DepartmentTeamRow] = Field(default_factory=list) + unassigned: list[DepartmentMemberRow] = Field( + default_factory=list, description="无经理人员的绩效(不参与分成,仅展示)" + ) + company: DepartmentCompanyBlock | None = None + totals: dict = Field(default_factory=dict, description="全表合计(成员/团队对账自证)") + + +class BusinessStatsRow(BaseModel): + """业务统计行(发货/回款/绩效为该业务累计值)。""" + + business_id: int + business_no: str + business_date: date | None = None + status: int + customer_name: str | None = None + owner_personnel_name: str | None = None + total_sale_amount: Decimal | None = None + total_ship_amount: Decimal | None = None + total_received_amount: Decimal | None = None + performance_amount: Decimal | None = None + payable_amount: Decimal | None = None + + +class BucketRow(BaseModel): + """回款统计 · 区间桶行(空桶恒在且为 0,防金额静默消失)。""" + + code: str + label: str + min_days: int | None = None + max_days: int | None = None + receipt_amount: Decimal | None = None + receipt_count: int = 0 + performance_amount: Decimal | None = None + payable_amount: Decimal | None = None + ledger_count: int = 0 + + +class GroupRow(BaseModel): + """回款统计 · 维度对比行(客户类型 / 产品类别)。""" + + code: str + label: str + receipt_amount: Decimal | None = None + performance_amount: Decimal | None = None + payable_amount: Decimal | None = None + ledger_count: int = 0 + + +class PaymentStatsOut(BaseModel): + """回款情况统计响应(FR-031)。 + + 口径:已计提回款(有台账行)的金额与绩效;进度登记回款无台账行, + 不在本表(页面需注明)。 + """ + + settlement_month: str | None = None + buckets: list[BucketRow] = Field(default_factory=list) + by_customer_type: list[GroupRow] = Field(default_factory=list) + by_product_category: list[GroupRow] = Field(default_factory=list) + totals: dict = Field(default_factory=dict, description="全表合计") + + +class PaymentDetailRow(BaseModel): + """回款区间下钻行(行级展示回款金额与批次号)。""" + + ledger_id: int + receipt_id: int | None = None + receipt_batch_no: str | None = None + amount: Decimal | None = Field(default=None, description="回款金额(同回款拆行重复展示)") + business_id: int + business_no: str + personnel_id: int + personnel_name: str + customer_type: str + product_category: str + ship_date: date | None = None + receipt_date: date + payment_days: int | None = None + performance_amount: Decimal | None = None + payable_amount: Decimal | None = None diff --git a/backend/app/schemas/role.py b/backend/app/schemas/role.py new file mode 100644 index 0000000..e31ad33 --- /dev/null +++ b/backend/app/schemas/role.py @@ -0,0 +1,80 @@ +"""角色管理的请求/响应模型。 + +`RoleOut` 原位于 `schemas/user.py`,因角色管理成为独立模块而迁入本文件 —— +用户表单的角色下拉与角色管理列表共用同一响应模型。 +""" + +import re + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery + +# 角色编码规则:小写字母开头,后接小写字母/数字/下划线,长度 2–32(与列宽一致)。 +# 不允许大写与中文 —— 编码会出现在授权判断(`ROLE_ADMIN` 常量按编码比对)与 +# 操作日志里,保持单一风格避免大小写/全角混用造成对照困难 +ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$") + + +class RoleOut(BaseModel): + """角色基础响应(用户表单下拉与角色列表共用)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + role_code: str + role_name: str + description: str | None = None + sort_order: int = 0 + status: int = 1 + + +class RoleManageOut(RoleOut): + """角色管理列表项:额外携带绑定用户数,供前端提示删除保护的原因。""" + + user_count: int = 0 + + +class RoleCreateIn(BaseModel): + """新增角色。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + role_code: str = Field(min_length=2, max_length=32, description="角色编码,创建后不可修改") + role_name: str = Field(min_length=1, max_length=64, description="角色名称") + description: str | None = Field(default=None, max_length=255) + sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前") + + @field_validator("role_code") + @classmethod + def _check_code(cls, value: str) -> str: + if not ROLE_CODE_PATTERN.match(value): + raise ValueError("角色编码须以小写字母开头,仅可含小写字母、数字与下划线") + return value + + +class RoleUpdateIn(BaseModel): + """编辑角色。 + + **不含 role_code** —— 编码是授权判定的锚点,修改会让既有授权语义静默改变。 + 前端表单全量提交,故为全量替换语义。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + role_name: str = Field(min_length=1, max_length=64, description="角色名称") + description: str | None = Field(default=None, max_length=255) + sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前") + + +class RoleStatusIn(BaseModel): + """启用/禁用角色。""" + + status: int = Field(ge=0, le=1, description="1启用 0禁用") + + +class RoleQuery(PageQuery): + """角色列表查询条件。""" + + keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配") + status: int | None = Field(default=None, ge=0, le=1, description="状态筛选") diff --git a/backend/app/schemas/setting.py b/backend/app/schemas/setting.py new file mode 100644 index 0000000..62ba45d --- /dev/null +++ b/backend/app/schemas/setting.py @@ -0,0 +1,34 @@ +"""系统设置(公司信息)请求/响应模型。 + +`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权, +响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会 +顺着复用链泄漏到匿名端点。 +""" + +from pydantic import BaseModel, ConfigDict, Field + + +class SettingOut(BaseModel): + """公司信息(管理端)。""" + + company_name: str + logo_url: str | None = None + + +class SettingPublicOut(BaseModel): + """公司信息(匿名端点白名单):登录页展示所需的两个字段。""" + + company_name: str + logo_url: str | None = None + + +class SettingUpdateIn(BaseModel): + """公司信息更新。 + + str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1 + 后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + company_name: str = Field(min_length=1, max_length=64, description="公司名称") diff --git a/backend/app/schemas/settlement.py b/backend/app/schemas/settlement.py new file mode 100644 index 0000000..ef7afaf --- /dev/null +++ b/backend/app/schemas/settlement.py @@ -0,0 +1,95 @@ +"""月度结算的请求/响应模型(M3 ④)。""" + +import re +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery + +_MONTH_RE = re.compile(r"^\d{4}-\d{2}$") + + +def _check_month(value: str) -> str: + """月份格式 YYYY-MM(settlement_month 全表唯一的维度)。""" + if not _MONTH_RE.match(value): + raise ValueError("月份格式须为 YYYY-MM") + return value + + +class SettlementGenerateIn(BaseModel): + """月结生成入参。""" + + month: str = Field(description="结算月份 YYYY-MM") + + @field_validator("month") + @classmethod + def _v_month(cls, value: str) -> str: + return _check_month(value) + + +class SettlementQuery(PageQuery): + """月结列表查询。""" + + status: int | None = Field(default=None, ge=1, le=4, description="状态筛选") + + +class SettlementItemOut(BaseModel): + """月结明细行。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + personnel_id: int + personnel_name: str + role_code: str + calc_type: int + performance_sum: Decimal + payable_sum: Decimal + basic_amount: Decimal | None = None + salary_mode: str | None = None + net_amount: Decimal | None = None + is_probation: int + probation_note: str | None = None + + +class SettlementOut(BaseModel): + """月结头表响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + settlement_month: str + status: int + total_performance: Decimal | None = None + total_payable: Decimal | None = None + person_count: int + approval_order_no: str | None = None + submitter_name: str + locked_at: datetime | datetime | None = None + locked_by: str | None = None + create_time: datetime + + +class SettlementDetailOut(SettlementOut): + """月结详情:附明细。""" + + items: list[SettlementItemOut] = Field(default_factory=list) + + +class AdjustmentCreateIn(BaseModel): + """调整单创建入参(金额恒正,方向表达补发/扣减)。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + settlement_month: str = Field(description="调整月份 YYYY-MM") + personnel_id: int = Field(ge=1, description="调整人员ID") + direction: int = Field(ge=1, le=2, description="1补发 2扣减") + # 正则先拦格式(非负两位小数、≤12 位整数防 NUMERIC(14,2) 溢出); + # 「> 0」由服务层判定(schema 只管形状,语义校验留在 service) + amount: str = Field( + pattern=r"^\d{1,12}(\.\d{1,2})?$", + description="金额(元,两位小数,恒正)", + ) + reason: str = Field(min_length=1, max_length=255, description="扣补说明") diff --git a/backend/app/schemas/shipment.py b/backend/app/schemas/shipment.py new file mode 100644 index 0000000..2df61d9 --- /dev/null +++ b/backend/app/schemas/shipment.py @@ -0,0 +1,138 @@ +"""发货管理的请求/响应模型(需求 2.2.2)。 + +金额/数量入参出参一律 str(服务层 Decimal 运算,全程禁 float),容量与 +小数位校验复用 business.py 的 ``_check_amount``(同一套口径,防两处漂移); +字段契约全线蛇形直出(2026-10-13 定稿:无驼峰 alias)。 + +amount(行金额)与 total_amount(发货总金额)不在入参内 —— 由服务层按 +数量×单价 计算固化,不信任前端传值;运费仅记录展示,不计入毛利润口径。 +""" + +from datetime import date + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery +from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount + + +class ShipmentItemIn(BaseModel): + """发货产品明细行(product_id 空=自由录入;价格口径两可,可空人工补)。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + product_id: int | None = Field(default=None, ge=1, description="产品ID;空=自由录入") + product_name: str = Field(min_length=1, max_length=128, description="产品名称") + spec_model: str | None = Field( + default=None, max_length=128, description="规格型号,空=空串归一" + ) + unit: str | None = Field(default=None, max_length=16, description="计量单位") + quantity: str = Field(description="数量,>0") + sale_price: str | None = Field( + default=None, description="发货单价(元),可空=待人工补价,补价前行金额记 0" + ) + + @field_validator("quantity") + @classmethod + def _check_quantity(cls, value: str) -> str: + return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value + + @field_validator("sale_price") + @classmethod + def _check_price(cls, value: str | None) -> str | None: + """发货单价列 DECIMAL(14,4):按 4 位小数口径(回显可原样提交)。""" + return _check_amount(value, "单价", PRICE_MAX, scale=4) + + +class ShipmentItemOut(BaseModel): + """发货产品明细行响应。金额/数量一律 str。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + product_id: int | None = None + product_name: str + spec_model: str | None = None + unit: str | None = None + quantity: str + sale_price: str | None = None + amount: str + + +class ShipmentOut(BaseModel): + """发货单响应(列表与写操作回包共用);business_no 为 join 附带列。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + shipment_no: str + business_id: int + business_no: str | None = None + ship_date: date + freight_amount: str + total_amount: str + remark: str | None = None + + +class ShipmentDetailOut(ShipmentOut): + """发货单详情:附产品明细行。""" + + items: list[ShipmentItemOut] = Field(default_factory=list) + + +class ShipmentCreateIn(BaseModel): + """创建发货单:业务维度 + 明细行数组(明细 ≥1 行)。 + + 字段名全线蛇形直出(同 business.py 契约,仅分页信封 list/pageNum 驼峰)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + business_id: int = Field(ge=1, description="业务ID") + ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)") + freight_amount: str | None = Field( + default=None, description="运费金额(元),空记 0;仅记录展示,不计入毛利润口径" + ) + items: list[ShipmentItemIn] = Field(min_length=1, description="发货明细行,至少 1 行") + remark: str | None = Field(default=None, max_length=255) + + @field_validator("freight_amount") + @classmethod + def _check_freight(cls, value: str | None) -> str | None: + """运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。""" + return _check_amount(value, "运费金额", AMOUNT_MAX) + + +class ShipmentUpdateIn(BaseModel): + """编辑发货单(明细全量替换)。 + + business_id 不在编辑入参内 —— 发货单挂靠的业务创建时定死,改挂会让 + 已指定本发货单的回款语义断裂。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)") + freight_amount: str | None = Field(default=None, description="运费金额(元),空记 0") + items: list[ShipmentItemIn] = Field( + min_length=1, description="发货明细行,至少 1 行(全量替换)" + ) + remark: str | None = Field(default=None, max_length=255) + + @field_validator("freight_amount") + @classmethod + def _check_freight(cls, value: str | None) -> str | None: + """运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。""" + return _check_amount(value, "运费金额", AMOUNT_MAX) + + +class ShipmentQuery(PageQuery): + """发货单列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。 + + business_id 可空=全量:发货单以业务维度查看为主(一期),但单号可 + 独立检索,不强制挂业务条件。 + """ + + business_id: int | None = Field(default=None, ge=1, description="业务筛选,空=全部") + ship_date_from: date | None = Field(default=None, description="发货日期起(含)") + ship_date_to: date | None = Field(default=None, description="发货日期止(含)") diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..151f900 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,113 @@ +"""用户管理的请求/响应模型(角色相关模型见 `role.py`)。 + +与 `auth.py` 的分工:`auth.py` 管登录链路(验证码、登录、当前用户), +本模块管**用户管理**(增删改查、重置密码、启停、角色分配)。 + +字段名驼峰(规范约定)与蛇形属性之间用 alias 映射,与响应模型保持同一套做法。 +""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES +from app.core.response import PageQuery + + +def _validate_password_bytes(value: str) -> str: + """按**字节数**校验密码长度。 + + 必须在这里拦下:bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError, + 而中文一个字符占 3 字节 —— 按字符数校验(如 max_length=20)根本拦不住。 + 与 `auth.py` 的登录校验共用同一规则,避免两处口径不一致。 + """ + length = len(value.encode("utf-8")) + if length < PASSWORD_MIN_BYTES: + raise ValueError(f"密码不足 {PASSWORD_MIN_BYTES} 字节") + if length > PASSWORD_MAX_BYTES: + raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限") + return value + + +class UserOut(BaseModel): + """用户列表/详情响应。 + + 不含 password —— 密码哈希绝不外泄,这是硬约束而非疏漏。 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + real_name: str | None = None + phone: str | None = None + email: str | None = None + status: int = Field(description="状态:1启用 0禁用") + roles: list[str] = Field(default_factory=list, description="角色编码列表") + last_login_time: datetime | None = None + remark: str | None = None + create_time: datetime | None = None + + +class UserCreateIn(BaseModel): + """新增用户。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + username: str = Field(min_length=2, max_length=64, description="登录用户名,须唯一") + password: str = Field(description="初始密码") + real_name: str | None = Field(default=None, max_length=64) + phone: str | None = Field(default=None, max_length=20) + email: str | None = Field(default=None, max_length=128) + roles: list[str] = Field(default_factory=list, description="角色编码列表,至少一个") + remark: str | None = Field(default=None, max_length=255) + + @field_validator("password") + @classmethod + def _check_password(cls, value: str) -> str: + return _validate_password_bytes(value) + + +class UserUpdateIn(BaseModel): + """编辑用户。 + + **不含 username** —— 用户名是登录凭据与审计追溯的锚点,改名会让历史 + 操作日志与登录日志对不上。确需改名应走专门的流程(本期不提供)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + real_name: str | None = Field(default=None, max_length=64) + phone: str | None = Field(default=None, max_length=20) + email: str | None = Field(default=None, max_length=128) + roles: list[str] | None = Field(default=None, description="传 null 表示不改角色") + remark: str | None = Field(default=None, max_length=255) + + +class PasswordResetIn(BaseModel): + """重置密码(管理员操作,不需要原密码)。""" + + new_password: str = Field(description="新密码") + + @field_validator("new_password") + @classmethod + def _check_password(cls, value: str) -> str: + return _validate_password_bytes(value) + + +class UserStatusIn(BaseModel): + """启用/禁用用户。""" + + status: int = Field(ge=0, le=1, description="1启用 0禁用") + + +class UserQuery(PageQuery): + """用户列表查询条件。 + + 继承分页基类,额外支持按用户名/姓名模糊、按状态与角色筛选。 + """ + + username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配") + real_name: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配") + status: int | None = Field(default=None, ge=0, le=1, description="状态筛选") + role_code: str | None = Field(default=None, max_length=32, description="角色编码筛选") diff --git a/backend/app/services/approval_service.py b/backend/app/services/approval_service.py new file mode 100644 index 0000000..e0de787 --- /dev/null +++ b/backend/app/services/approval_service.py @@ -0,0 +1,377 @@ +"""审批流业务编排(2026-10-15 复核审批补充需求)。 + +三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数开关驱动 +(`approval.secondary_allocation.enabled` / `approval.monthly_settlement.enabled`): + +- **关闭(默认)**:提交即生成审批单并「自动通过」全节点留痕(action=3, + operator 记「系统」)——留痕不断链,与「关闭=直接生效」的需求口径一致; +- **开启**:status=1 待财务复核 → 2 待领导审核 → 3 已通过;任一节点驳回 + 即整体驳回(status=4),一期不做退回重走(简单状态机)。 + +红线: +- `submit_order` **不 commit、不嵌套 audit** —— 由业务写路径(business_service + 的 create/update)既有 audit 上下文统一收口,违反嵌套禁令会让失败路径 + 回滚不完整(见 audit_service 模块 docstring); +- 节点准入**严格按角色编码**:status=1 仅财务、status=2 仅领导,admin 不 + 代办 —— 放行 admin 会让双人复核流退化成管理员单点审批; +- 同一引用对象的在途单(status 1/2)在重新提交前先撤回(status=5 + + node=0/action=4 留痕),保证「一个对象至多一张在途单」。 +""" + +import logging +from datetime import date + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_APPROVE, + ACTION_REJECT, + MODULE_APPROVAL, + NO_PREFIX_APPROVAL, + ROLE_FINANCE, + ROLE_LEADER, +) +from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError +from app.core.response import PageResult +from app.models import ApprovalOrder, ApprovalStep, User +from app.models.approval import ( + APPROVAL_ACTION_APPROVE, + APPROVAL_ACTION_AUTO_APPROVE, + APPROVAL_ACTION_REJECT, + APPROVAL_ACTION_WITHDRAW, + APPROVAL_NODE_FINANCE, + APPROVAL_NODE_LEADER, + APPROVAL_NODE_WITHDRAW, + APPROVAL_NODES, + APPROVAL_STATUS_APPROVED, + APPROVAL_STATUS_PENDING_FINANCE, + APPROVAL_STATUS_PENDING_LEADER, + APPROVAL_STATUS_REJECTED, + APPROVAL_STATUS_WITHDRAWN, +) +from app.repositories import user_repository +from app.schemas.approval import ( + ApprovalActionIn, + ApprovalOrderDetailOut, + ApprovalOrderOut, + ApprovalQuery, + ApprovalStepOut, +) +from app.services import audit_service, param_service +from app.services.audit_service import AuditRecord, RequestMeta +from app.services.no_generator import next_no + +logger = logging.getLogger(__name__) + +# 开关参数编码(与 param_registry 种子同源;新增审批对象类型必须在此登记, +# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用) +ENABLE_CODE_BY_TYPE: dict[int, str] = { + 1: "approval.secondary_allocation.enabled", + 2: "approval.monthly_settlement.enabled", + 3: "approval.adjustment.enabled", +} + +# 在途状态集合(同一引用对象至多一张在途单) +_PENDING_STATUSES = (APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER) + +# 在途状态 → 当前节点应操作的角色(admin 不代办:单人审批绕过双人复核) +_NODE_ROLE_BY_STATUS = { + APPROVAL_STATUS_PENDING_FINANCE: ROLE_FINANCE, + APPROVAL_STATUS_PENDING_LEADER: ROLE_LEADER, +} + +_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"} + + +def is_enabled(session: Session, order_type: int) -> bool: + """读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。""" + code = ENABLE_CODE_BY_TYPE.get(order_type) + if code is None: + raise BadRequestError("未知的审批对象类型") + return param_service.resolve_snapshot(session, date.today()).boolean(code) + + +def submit_order( + session: Session, + *, + order_type: int, + ref_id: int, + refer_no: str, + submitter: User, +) -> ApprovalOrder: + """提单(业务写路径内部调用;不 commit、不嵌套 audit)。 + + 同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过 + (全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透: + create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单 + 一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400—— + **任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务 + 的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。 + """ + _withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter) + + order = ApprovalOrder( + order_no=next_no( + session, + model=ApprovalOrder, + column_name="order_no", + prefix=NO_PREFIX_APPROVAL, + ), + order_type=order_type, + ref_id=ref_id, + refer_no=refer_no, + submitter_id=submitter.id, + submitter_name=submitter.username, + ) + session.add(order) + + if is_enabled(session, order_type): + order.status = APPROVAL_STATUS_PENDING_FINANCE + session.flush() + logger.info("审批单待审 | order=%s", order.order_no) + return order + + # 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链 + order.status = APPROVAL_STATUS_APPROVED + session.flush() + session.add_all( + [ + ApprovalStep( + order_id=order.id, + node=node, + action=APPROVAL_ACTION_AUTO_APPROVE, + operator_name="系统", + ) + for node in (APPROVAL_NODE_FINANCE, APPROVAL_NODE_LEADER) + ] + ) + session.flush() + logger.info("审批单自动通过 | order=%s(开关关闭)", order.order_no) + return order + + +def withdraw_pending_orders( + session: Session, *, order_type: int, ref_id: int, operator: User +) -> int: + """撤回引用对象的全部在途单(供业务清空方案等场景显式调用;不 commit)。 + + 返回撤回张数(0 = 本就无在途单)。 + """ + before = len( + session.execute( + select(ApprovalOrder.id).where( + ApprovalOrder.order_type == order_type, + ApprovalOrder.ref_id == ref_id, + ApprovalOrder.status.in_(_PENDING_STATUSES), + ) + ) + .scalars() + .all() + ) + if before: + _withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=operator) + return before + + +def act( + session: Session, + order_id: int, + payload: ApprovalActionIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> ApprovalOrderDetailOut: + """节点动作(财务复核/领导审核):状态机流转 + 节点留痕(审计上下文)。 + + 通过:status=1 → 2(下一步领导);status=2 → 3(终态已通过)。 + 驳回:任一在途状态 → 4(整体驳回,一期不退回重走)。 + 节点留痕的 node 取**动作发生时的当前节点**(通过/驳回都记在操作节点上)。 + """ + codes = set(user_repository.get_role_codes(operator)) + verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_APPROVAL, + action=verb, + summary=f"审批单 #{order_id} {verb}", + request_data=payload.model_dump(), + ), + meta=meta, + ): + order = session.get(ApprovalOrder, order_id, with_for_update=True) + if order is None: + raise NotFoundError("审批单不存在") + if order.status not in _PENDING_STATUSES: + raise BadRequestError("审批单已结束,不可重复操作") + + required_role = _NODE_ROLE_BY_STATUS[order.status] + if required_role not in codes: + raise ForbiddenError(f"当前节点须{_ROLE_LABEL[required_role]}角色操作") + + node_now = APPROVAL_NODES[order.status] + rejected = payload.action == APPROVAL_ACTION_REJECT + if rejected: + order.status = APPROVAL_STATUS_REJECTED + else: + order.status = ( + APPROVAL_STATUS_PENDING_LEADER + if order.status == APPROVAL_STATUS_PENDING_FINANCE + else APPROVAL_STATUS_APPROVED + ) + # 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前 + # 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。 + # 驳回在任何节点都是终态,即时联动。 + final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED + + session.add( + ApprovalStep( + order_id=order.id, + node=node_now, + action=payload.action, + opinion=payload.opinion, + operator_id=operator.id, + operator_name=operator.username, + ) + ) + # 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以 + # 「当前生效行」身份留在表里、被 M3 核算快照算进工资;方案事实由 + # 审批单留痕与操作日志 request_data 承载(分摊语义落定后如需保留 + # 历史版本,在 ④ 设计时演进)。审批单的清理由调用方(act 内联) + # 委托 business_repository 完成 —— service 层互调、不触 commit。 + if rejected and order.order_type == 1: + from app.repositories import business_repository + + business_repository.delete_allocations(session, order.ref_id) + + # 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿; + # 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。 + if order.order_type == 2 or order.order_type == 3: + from app.models import AdjustmentOrder + from app.models.settlement import ( + ADJUSTMENT_STATUS_EFFECTIVE, + ) + from app.models.settlement import ( + ADJUSTMENT_STATUS_REJECTED as ADJ_REJECTED, + ) + from app.services import settlement_service + + if order.order_type == 2: + from app.models.settlement import MonthlySettlement + + target = session.get(MonthlySettlement, order.ref_id) + if target is not None: + if rejected: + settlement_service.mark_rejected(session, target, operator=operator) + elif final_approved: + settlement_service.mark_locked(session, target, operator=operator) + else: + adj = session.get(AdjustmentOrder, order.ref_id) + if adj is not None: + if rejected: + adj.status = ADJ_REJECTED + elif final_approved: + adj.status = ADJUSTMENT_STATUS_EFFECTIVE + + # 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰 + # ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记 + # 成失败审计,与库中事实相反(全 services 层唯一例外不许再犯) + session.flush() + detail = _detail_of(session, order) + session.commit() + return detail + + +def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]: + """分页查询审批单(准入由 api 层依赖判定)。""" + filters = _order_filters(query) + rows = ( + session.execute( + select(ApprovalOrder) + .where(*filters) + .order_by(ApprovalOrder.id.desc()) + .offset(query.offset) + .limit(query.page_size) + ) + .scalars() + .all() + ) + total = session.execute( + select(func.count()).select_from(ApprovalOrder).where(*filters) + ).scalar_one() + return PageResult[ApprovalOrderOut]( + records=[ApprovalOrderOut.model_validate(r) for r in rows], + total=int(total), + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_order(session: Session, order_id: int) -> ApprovalOrderDetailOut: + """审批单详情(附节点时间线)。""" + order = session.get(ApprovalOrder, order_id) + if order is None: + raise NotFoundError("审批单不存在") + return _detail_of(session, order) + + +def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operator: User) -> None: + """撤回引用对象的在途单(重新提交前调用;不 commit)。""" + pending = ( + session.execute( + select(ApprovalOrder).where( + ApprovalOrder.order_type == order_type, + ApprovalOrder.ref_id == ref_id, + ApprovalOrder.status.in_(_PENDING_STATUSES), + ) + ) + .scalars() + .all() + ) + for order in pending: + order.status = APPROVAL_STATUS_WITHDRAWN + session.add( + ApprovalStep( + order_id=order.id, + node=APPROVAL_NODE_WITHDRAW, + action=APPROVAL_ACTION_WITHDRAW, + operator_id=operator.id, + operator_name=operator.username, + ) + ) + if pending: + session.flush() + logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id) + + +def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut: + """出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。""" + steps = ( + session.execute( + select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id) + ) + .scalars() + .all() + ) + detail = ApprovalOrderDetailOut.model_validate(order) + detail.steps = [ApprovalStepOut.model_validate(s) for s in steps] + return detail + + +def _order_filters(query: ApprovalQuery) -> tuple: + filters = [] + if query.keyword: + # autoescape:用户输入里的 %/_ 按 URL 片段字面匹配(全库统一口径, + # 见 customer_repository.contains 用法)——否则 "AP2026_10" 会把 + # 下划线当单字符通配,搜出无关单据 + filters.append( + ApprovalOrder.order_no.contains(query.keyword, autoescape=True) + | ApprovalOrder.refer_no.contains(query.keyword, autoescape=True) + ) + if query.status is not None: + filters.append(ApprovalOrder.status == query.status) + if query.order_type is not None: + filters.append(ApprovalOrder.order_type == query.order_type) + return tuple(filters) diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py new file mode 100644 index 0000000..dcb80c1 --- /dev/null +++ b/backend/app/services/audit_service.py @@ -0,0 +1,173 @@ +"""操作日志审计服务。 + +需求 2.5.1 要求记录用户的操作行为。与登录行为的分工:登录在 `login_logs` +单独记录(失败也要留痕且不依赖认证上下文),本模块记录**已认证用户的管理类 +操作**。 + +设计要点: + +- **成功与失败都留痕** —— 失败的操作(如试图删除不存在的用户)同样是审计 + 对象,模型里专门有 `success` / `error_message` 两列承载它 +- **审计失败不影响业务** —— 落库过程整体兜底,失败只记应用日志、绝不外抛 + (与 `auth_service._record` 同一原则:"审计可丢,业务不可断") +- **独立提交** —— 日志单独 commit,不与业务同事务。同事务时业务失败回滚会 + 连带丢掉最该留痕的失败记录 + +用法(上下文管理器):: + + target = user_repository.get_by_id(session, user_id) # 只读预取,供摘要 + with audit(session, operator=current_user, + record=AuditRecord(MODULE_USER, ACTION_DELETE, + f"删除用户 {target.username}"), + meta=meta): + ...业务操作与 session.commit()... + return out + +约定: + +- 业务代码在上下文内自行 commit(事务边界仍在 service 层)。**commit 之后 + 的任何数据库查询、ORM 属性读取都必须移出上下文**(提到 commit 之前或 + 用已固化的局部变量)—— 业务已提交却因后置查询抛错被记成「失败」,会 + 产生与事实相反的审计记录 +- 上下文内不得吞掉异常后正常退出——吞掉后会被记为成功 +- 摘要需要"目标实体名"时,应在进入上下文**之前**只读预取, + 这样目标不存在(NotFound 失败)时也能留下可追溯的记录 +- 不支持嵌套(在 audit 内再调用会自行 commit 的服务)——进入侦测到 + 未提交变更会记 warning +""" + +import logging +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.core.exceptions import AppException +from app.models import User +from app.repositories import operation_log_repository +from app.repositories.operation_log_repository import OperationLogEntry + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class RequestMeta: + """HTTP 请求元信息。由路由层依赖 `RequestMetaDep` 提取后传入 service。""" + + ip: str | None = None + user_agent: str | None = None + method: str | None = None + path: str | None = None + + +@dataclass(frozen=True, slots=True) +class AuditRecord: + """审计的业务侧内容(模块 / 操作 / 摘要 / 请求参数)。""" + + module: str + action: str + summary: str + request_data: dict | None = None + + +@contextmanager +def audit( + session: Session, + *, + operator: User, + record: AuditRecord, + meta: RequestMeta | None = None, +) -> Iterator[None]: + """审计上下文:正常退出记成功;抛异常记失败并**原样重抛**。""" + started = time.perf_counter() + meta = meta or RequestMeta() + + # 固化操作人为普通标量,之后不再触碰 ORM 对象 —— 失败路径先 rollback + # 会使其全部过期,再读 id/username 将触发额外 SELECT;若账号恰被并发 + # 删除,ObjectDeletedError 还会被下面的兜底吞掉、失败审计静默丢失 + base: dict = { + "user_id": operator.id, + "username": operator.username, + "module": record.module, + "action": record.action, + "summary": record.summary, + "method": meta.method, + "path": meta.path, + "ip": meta.ip, + "user_agent": meta.user_agent, + "request_data": record.request_data, + } + + # 进入审计时应无未提交变更(业务写操作在上下文内才发生)。出现变更 + # 意味着调用方可能嵌套了会自行 commit 的服务:失败路径的回滚将无法 + # 撤销已提交的部分,审计与数据状态会不一致 + if session.new or session.dirty or session.deleted: + logger.warning( + "进入审计上下文时存在未提交的变更 | module=%s | action=%s", + record.module, + record.action, + ) + + try: + yield + except Exception as exc: + # 先回滚再写日志:业务异常可能源于 IntegrityError 等数据库错误, + # 不回滚的话 session 处于「必须回滚」状态,写日志会抛 + # PendingRollbackError —— 最该留下的失败记录反而被丢掉 + _rollback_quietly(session) + _safe_write( + session, + entry=OperationLogEntry( + **base, + success=False, + error_message=_error_text(exc), + duration_ms=_elapsed_ms(started), + ), + ) + raise + else: + _safe_write( + session, + entry=OperationLogEntry(**base, success=True, duration_ms=_elapsed_ms(started)), + ) + + +def _safe_write(session: Session, *, entry: OperationLogEntry) -> None: + """落库并独立提交。整体兜底:任何失败只记应用日志,绝不外抛。""" + try: + operation_log_repository.add(session, entry) + session.commit() + except Exception: + logger.warning( + "操作日志写入失败(不影响业务) | module=%s | action=%s | summary=%s", + entry.module, + entry.action, + entry.summary, + exc_info=True, + ) + _rollback_quietly(session) + + +def _rollback_quietly(session: Session) -> None: + try: + session.rollback() + except Exception: + logger.warning("会话回滚失败", exc_info=True) + + +def _error_text(exc: Exception) -> str: + """失败原因文案。 + + 业务异常取其面向用户的消息;未预期异常返回通用文案 —— 完整堆栈由全局 + 异常处理器记入应用日志,审计表不落内部细节。 + """ + if isinstance(exc, AppException): + return exc.message + return "服务端异常,请稍后重试" + + +def _elapsed_ms(started: float) -> int: + """度量业务处理耗时(不含响应序列化)。""" + return max(0, int((time.perf_counter() - started) * 1000)) diff --git a/backend/app/services/business_role_service.py b/backend/app/services/business_role_service.py new file mode 100644 index 0000000..d89037c --- /dev/null +++ b/backend/app/services/business_role_service.py @@ -0,0 +1,287 @@ +"""业务岗位角色管理业务编排。 + +需求 #28 的落点:岗位可增删、绑定公式模板(有限集合,代码内注册表), +新增岗位无需改代码。保护规则(前端会禁用相应按钮,**后端校验是唯一防线**): + +- 内置岗位(is_builtin=1:营销员/营销经理/信息员)**禁删禁停** —— + 参数种子与核算模板按编码锚定(如 salesperson 的递减公式参数行) +- `role_code` 创建后不可改 —— 它是参数维度值(param_values.dimension_value) + 与模板绑定语义的锚点 +- `calc_template` 必须是模板注册表(calc_templates.TEMPLATES)内的编码 +- 有人员引用的岗位禁删 —— ② 落地 personnel 表后由引用计数生效, + 当前钩子恒为空(见 business_role_repository.count_personnel_by_role) +""" + +import logging + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_DISABLE, + ACTION_ENABLE, + ACTION_UPDATE, + MODULE_BUSINESS_ROLE, +) +from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError +from app.core.response import PageResult +from app.models import BusinessRole, User +from app.repositories import business_role_repository +from app.schemas.business_role import ( + BusinessRoleCreateIn, + BusinessRoleOut, + BusinessRoleQuery, + BusinessRoleUpdateIn, + TemplateOptionOut, + TemplateParamOut, +) +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta +from app.services.calc_templates import TEMPLATES +from app.services.param_registry import PARAM_REGISTRY + +logger = logging.getLogger(__name__) + + +def _to_out(role: BusinessRole) -> BusinessRoleOut: + """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" + return BusinessRoleOut( + id=role.id, + role_code=role.role_code, + role_name=role.role_name, + calc_template=role.calc_template, + description=role.description, + sort_order=role.sort_order, + status=role.status, + is_builtin=role.is_builtin, + ) + + +def _ensure_template(calc_template: str) -> None: + """模板必须存在于代码内注册表(前端为受控下拉,后端仍须校验)。""" + if calc_template not in TEMPLATES: + raise BadRequestError(f"计提公式模板 {calc_template!r} 不存在") + + +def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]: + """分页查询岗位(含停用)。""" + roles = business_role_repository.list_roles( + session, + offset=query.offset, + limit=query.page_size, + keyword=query.keyword, + status=query.status, + ) + total = business_role_repository.count_roles( + session, keyword=query.keyword, status=query.status + ) + return PageResult[BusinessRoleOut]( + records=[_to_out(role) for role in roles], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_role(session: Session, role_id: int) -> BusinessRoleOut: + """岗位详情。 + + :raises NotFoundError: 岗位不存在 + """ + role = business_role_repository.get_by_id(session, role_id) + if role is None: + raise NotFoundError("岗位不存在") + return _to_out(role) + + +def list_template_options() -> list[TemplateOptionOut]: + """公式模板下拉(含声明参数,供岗位表单展示与引导配置)。""" + options: list[TemplateOptionOut] = [] + for tpl in TEMPLATES.values(): + options.append( + TemplateOptionOut( + code=tpl.code, + name=tpl.name, + description=tpl.description, + params=[ + TemplateParamOut(code=code, name=PARAM_REGISTRY[code].name) + for code in tpl.params + ], + ) + ) + return options + + +def create_role( + session: Session, + payload: BusinessRoleCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> BusinessRoleOut: + """新增岗位。编码唯一、创建后不可修改,新岗位固定启用、非内置。 + + :raises BadRequestError: 编码已存在 / 模板不存在 + """ + _ensure_template(payload.calc_template) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS_ROLE, + action=ACTION_CREATE, + summary=f"新增岗位 {payload.role_name}({payload.role_code})", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if business_role_repository.get_by_code(session, payload.role_code) is not None: + raise BadRequestError(f"岗位编码 {payload.role_code!r} 已存在") + + role = BusinessRole( + role_code=payload.role_code, + role_name=payload.role_name, + calc_template=payload.calc_template, + description=payload.description, + sort_order=payload.sort_order, + status=1, + is_builtin=0, + ) + try: + business_role_repository.create(session, role) + out = _to_out(role) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:并发提交同一编码会撞 uk_business_roles_role_code + session.rollback() + raise BadRequestError(f"岗位编码 {payload.role_code!r} 已存在") from exc + + logger.info( + "新增岗位 | role_code=%s | template=%s", payload.role_code, payload.calc_template + ) + return out + + +def update_role( + session: Session, + role_id: int, + payload: BusinessRoleUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> BusinessRoleOut: + """编辑岗位名称/模板/说明/排序(全量替换,不含 role_code)。 + + :raises NotFoundError: 岗位不存在 + :raises BadRequestError: 模板不存在 + """ + role = business_role_repository.get_by_id(session, role_id) + _ensure_template(payload.calc_template) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS_ROLE, + action=ACTION_UPDATE, + summary=f"修改岗位 {role.role_name if role else f'#{role_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if role is None: + raise NotFoundError("岗位不存在") + + role.role_name = payload.role_name + role.calc_template = payload.calc_template + role.description = payload.description + role.sort_order = payload.sort_order + # 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明) + out = _to_out(role) + session.commit() + logger.info("修改岗位 | role_id=%s | template=%s", role_id, payload.calc_template) + return out + + +def set_status( + session: Session, + role_id: int, + status: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> BusinessRoleOut: + """启用或禁用岗位。内置岗位不可停用(参数种子按编码锚定)。 + + :raises NotFoundError: 岗位不存在 + :raises ForbiddenError: 试图停用内置岗位 + """ + role = business_role_repository.get_by_id(session, role_id) + verb = "启用" if status == 1 else "禁用" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS_ROLE, + action=ACTION_ENABLE if status == 1 else ACTION_DISABLE, + summary=f"{verb}岗位 {role.role_name if role else f'#{role_id}'}", + request_data={"status": status}, + ), + meta=meta, + ): + if role is None: + raise NotFoundError("岗位不存在") + + if role.is_builtin == 1 and status == 0: + raise ForbiddenError("内置岗位不可停用") + + role.status = status + out = _to_out(role) + session.commit() + logger.info("变更岗位状态 | role_id=%s | status=%s", role_id, status) + return out + + +def delete_role( + session: Session, + role_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """删除岗位。内置岗位不可删除;有人员引用的岗位不可删除。 + + 查询走 ``FOR UPDATE`` 锁行:校验与删除之间若发生并发引用(M2 接入后), + 锁行使并发写入失败(可见)而非引用被静默悬空(不可见)。 + + :raises NotFoundError: 岗位不存在 + :raises ForbiddenError: 试图删除内置岗位 + :raises BadRequestError: 岗位下仍有人引用 + """ + role = business_role_repository.get_by_id(session, role_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS_ROLE, + action=ACTION_DELETE, + summary=f"删除岗位 {role.role_name if role else f'#{role_id}'}", + ), + meta=meta, + ): + if role is None: + raise NotFoundError("岗位不存在") + + if role.is_builtin == 1: + raise ForbiddenError("内置岗位不可删除") + + counts = business_role_repository.count_personnel_by_role(session, [role_id]) + person_count = counts.get(role_id, 0) + if person_count > 0: + raise BadRequestError(f"该岗位下仍有 {person_count} 名人员,请先调整归属") + + role_code = role.role_code # 固化:对象随删除失效 + business_role_repository.delete(session, role) + session.commit() + logger.info("删除岗位 | role_code=%s", role_code) diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py new file mode 100644 index 0000000..40029bb --- /dev/null +++ b/backend/app/services/business_service.py @@ -0,0 +1,929 @@ +"""业务管理业务编排(需求 2.2.1/2.2.4)。 + +事务边界在本层:repository 只 flush,commit/rollback 只写在这里。 + +- 业务编号自动生成(no_generator.next_no):MAX+FOR UPDATE 锁当日序号区间, + 残余竞态以唯一键(1062)冲突重试一次兜底 +- 五项汇总冗余(total_sale/cost/ship/received/freight)由 ``_recalc_totals`` + 单点重算 —— 明细/发货/回款任何写路径收口到此函数,禁止各处手写 +- 状态机:1执行中 → 2已完成 / 3已终止;终态不可再变更(一期口径,开放问题 #8); + 终止必填原因;每次流转写 business_status_logs(创建写首条 old=new=1) +- 归属变更(负责/协助)写 ownership_change_logs(owner_type=2),同事务一次 + commit;只影响变更后核算,不回溯历史(对照表 #19) +- 毛利润视图为只读派生值(复用 formula.profit_rate/converted_profit), + 阈值按 asOf 取 M1 参数快照 —— 落库即漂移,M3 台账才是固化点 +""" + +import logging +from datetime import date +from decimal import ROUND_HALF_UP, Decimal +from fractions import Fraction + +from sqlalchemy import Row, func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_UPDATE, + MODULE_BUSINESS, + NO_PREFIX_BUSINESS, +) +from app.core.db_errors import is_duplicate_entry +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageQuery, PageResult +from app.models import ( + ApprovalOrder, + Business, + BusinessItem, + BusinessStatusLog, + Customer, + OwnershipChangeLog, + Personnel, + Receipt, + SecondaryAllocation, + Shipment, + User, +) +from app.models.approval import APPROVAL_TYPE_SECONDARY_ALLOCATION +from app.models.business import ( + BUSINESS_STATUS_ACTIVE, + BUSINESS_STATUS_DONE, +) +from app.models.customer import OWNER_TYPE_BUSINESS +from app.repositories import business_repository, customer_repository, personnel_repository +from app.schemas.business import ( + BusinessCreateIn, + BusinessDetailOut, + BusinessItemIn, + BusinessItemOut, + BusinessOut, + BusinessOwnershipIn, + BusinessQuery, + BusinessStatusIn, + BusinessStatusLogOut, + BusinessUpdateIn, + GrossProfitCategoryOut, + GrossProfitOut, + GrossProfitRowOut, + OwnershipChangeLogOut, + SecondaryAllocationIn, + SecondaryAllocationOut, +) +from app.services import approval_service, audit_service, param_service, personnel_service +from app.services.audit_service import AuditRecord, RequestMeta +from app.services.formula import converted_profit, profit_rate +from app.services.no_generator import next_no + +logger = logging.getLogger(__name__) + +# 编号唯一键冲突后的重试次数:MAX+FOR UPDATE 已覆盖绝大多数竞态, +# 重试是为极端场景(锁等待超时后重入)兜底 —— 无限重试反而掩盖死锁类缺陷 +_NO_RETRY = 1 + + +def _to_out(row: tuple) -> BusinessOut: + """(business, company_name, owner_name, assistant_name) → 响应模型。 + + 金额冗余列出参 str;汇总列可能为 NULL(server_default 只对 INSERT 生效, + 存量行可能为 NULL),统一转 "0" 保持契约稳定。 + """ + business, company_name, owner_name, assistant_name = row + return BusinessOut( + id=business.id, + business_no=business.business_no, + customer_id=business.customer_id, + customer_name=company_name, + business_date=business.business_date, + status=business.status, + owner_personnel_id=business.owner_personnel_id, + owner_personnel_name=owner_name, + assistant_personnel_id=business.assistant_personnel_id, + assistant_personnel_name=assistant_name, + is_special=business.is_special, + special_note=business.special_note, + total_sale_amount=_str_or_zero(business.total_sale_amount), + total_cost_amount=_str_or_zero(business.total_cost_amount), + total_ship_amount=_str_or_zero(business.total_ship_amount), + total_received_amount=_str_or_zero(business.total_received_amount), + total_freight_amount=_str_or_zero(business.total_freight_amount), + remark=business.remark, + ) + + +def _str_or_zero(value: Decimal | None) -> str: + return str(value) if value is not None else "0" + + +def _item_to_out(item: BusinessItem) -> BusinessItemOut: + return BusinessItemOut( + id=item.id, + product_id=item.product_id, + product_name=item.product_name, + spec_model=item.spec_model, + unit=item.unit, + product_category=item.product_category, + quantity=str(item.quantity), + sale_price=str(item.sale_price) if item.sale_price is not None else None, + sale_amount=str(item.sale_amount), + cost_amount=str(item.cost_amount) if item.cost_amount is not None else None, + guided_price=str(item.guided_price) if item.guided_price is not None else None, + remark=item.remark, + ) + + +def _build_item_rows(business_id: int, items: list[BusinessItemIn]) -> list[BusinessItem]: + """入参明细行转 ORM 行:sale_amount 服务层按 数量×单价 计算固化。 + + 不信任前端传值(schema 亦无 sale_amount 入参)——单价可空时销售额记 0, + 由用户在编辑中补价后重算(毛利润视图中销售额 0 的行全部为 0,防除零)。 + 乘积显式 quantize 两位固化:数量(2位)×单价(4位) 可产 6 位小数,直写 + DECIMAL(14,2) 会被 MySQL 静默舍入且回显漂移(创建响应读内存值、复查 + 读库值前后不一致);HALF_UP 与 MySQL 的 DECIMAL 舍入方向一致。 + """ + rows: list[BusinessItem] = [] + for item in items: + quantity = Decimal(item.quantity) + sale_price = Decimal(item.sale_price) if item.sale_price is not None else None + sale_amount = ( + (quantity * sale_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if sale_price is not None + else Decimal("0.00") + ) + rows.append( + BusinessItem( + business_id=business_id, + product_id=item.product_id, + product_name=item.product_name, + spec_model=item.spec_model or "", + unit=item.unit, + product_category=item.product_category, + quantity=quantity, + sale_price=sale_price, + sale_amount=sale_amount, + cost_amount=Decimal(item.cost_amount) if item.cost_amount is not None else None, + guided_price=Decimal(item.guided_price) if item.guided_price is not None else None, + remark=item.remark, + ) + ) + return rows + + +def _recalc_totals(session: Session, business: Business) -> None: + """重算业务的五项汇总冗余(明细/发货/回款写路径统一收口)。 + + 五条独立聚合查询(各表单列 SUM,走 business_id 索引)——业务单据量级 + 为单业务数行,不值得为省查询做单大 JOIN。 + """ + sale = session.execute( + _sum(BusinessItem.sale_amount, BusinessItem.business_id, business.id) + ).scalar_one() + cost = session.execute( + _sum(BusinessItem.cost_amount, BusinessItem.business_id, business.id) + ).scalar_one() + ship = session.execute(_sum_ship_items(business.id)).scalar_one() + freight = session.execute( + _sum(Shipment.freight_amount, Shipment.business_id, business.id) + ).scalar_one() + received = session.execute(_sum(Receipt.amount, Receipt.business_id, business.id)).scalar_one() + business.total_sale_amount = sale or Decimal("0") + business.total_cost_amount = cost or Decimal("0") + business.total_ship_amount = ship or Decimal("0") + business.total_freight_amount = freight or Decimal("0") + business.total_received_amount = received or Decimal("0") + + +def _sum(column, match_column, value) -> object: + return select(func.sum(column)).where(match_column == value) + + +def _sum_ship_items(business_id: int): + """发货明细行金额合计(经发货单关联到业务)。""" + from app.models import ShipmentItem + + return ( + select(func.sum(ShipmentItem.amount)) + .select_from(ShipmentItem) + .join(Shipment, Shipment.id == ShipmentItem.shipment_id) + .where(Shipment.business_id == business_id) + ) + + +def _generate_no(session: Session) -> str: + """生成业务编号(MAX+FOR UPDATE;1062 兜底重试一次,见模块 docstring)。""" + return next_no(session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS) + + +def _ensure_personnel_active(session: Session, personnel_id: int, label: str) -> Personnel: + """归属人员须存在且在职(共享校验收口于 personnel_service.ensure_active)。""" + return personnel_service.ensure_active(session, personnel_id, label=label) + + +def _ensure_customer(session: Session, customer_id: int) -> Customer: + customer = customer_repository.get_by_id(session, customer_id) + if customer is None: + raise NotFoundError("客户不存在") + return customer + + +def _is_duplicate_entry(exc: IntegrityError) -> bool: + return is_duplicate_entry(exc) + + +def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]: + """分页查询业务(附客户名与归属人名)。""" + rows = business_repository.list_businesses( + session, + offset=query.offset, + limit=query.page_size, + keyword=query.keyword, + status=query.status, + customer_id=query.customer_id, + owner_personnel_id=query.owner_personnel_id, + date_from=query.date_from, + date_to=query.date_to, + ) + total = business_repository.count_businesses( + session, + keyword=query.keyword, + status=query.status, + customer_id=query.customer_id, + owner_personnel_id=query.owner_personnel_id, + date_from=query.date_from, + date_to=query.date_to, + ) + return PageResult[BusinessOut]( + records=[_to_out(row) for row in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_detail(session: Session, business_id: int) -> BusinessDetailOut: + """业务详情(附明细行与二次分配方案,供编辑抽屉回显)。 + + :raises NotFoundError: 业务不存在 + """ + row = business_repository.get_with_names(session, business_id) + if row is None: + raise NotFoundError("业务不存在") + out = BusinessDetailOut(**_to_out(row).model_dump()) + out.items = [ + _item_to_out(item) for item in business_repository.list_items(session, business_id) + ] + allocations = business_repository.list_allocations(session, business_id) + out.secondary_allocations = [ + SecondaryAllocationOut( + personnel_id=a.personnel_id, personnel_name=a.personnel_name, ratio=str(a.ratio) + ) + for a in allocations + ] + out.allocation_hint = _allocation_hint(allocations) + return out + + +def _build_allocation_rows( + session: Session, + business_id: int, + items: list[SecondaryAllocationIn], + *, + old_ids: set[int], +) -> list[SecondaryAllocation]: + """构造二次分配明细行:人员校验 + 姓名冗余固化 + 比例转 Decimal。 + + :param old_ids: 编辑前已参与的人员 ID 集合(create 传空集)。这些人的 + 行是**历史事实**——personnel_name 冗余固化的设计本就允许离职后回显, + 编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对 + **新加入**的参与人做在职校验。 + + :raises BadRequestError: 新增参与人员不存在或已离职 / 比例合计超 100% + / 参与人含合同负责人(口径见澄清 #1/#2 答复 2026-10-19:分配绩效、 + 余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人 + 自分份额则双重计入) + """ + business = business_repository.get_by_id(session, business_id) + if business is None: + raise NotFoundError("业务不存在") + total = sum((Decimal(item.ratio) for item in items), Decimal("0")) + if total > Decimal("100"): + raise BadRequestError( + f"二次分配比例合计 {total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}%" + " 超过 100%(余额归合同负责营销人员,比例不可超满额)" + ) + owner_id = business.owner_personnel_id + if owner_id is not None and any(item.personnel_id == owner_id for item in items): + raise BadRequestError("分摊参与人不可包含合同负责营销人员(负责人得余额份额)") + + rows: list[SecondaryAllocation] = [] + for item in items: + if item.personnel_id in old_ids: + person = personnel_repository.get_by_id(session, item.personnel_id) + if person is None: + raise BadRequestError("分摊参与人员不存在") + else: + person = _ensure_personnel_active(session, item.personnel_id, "分摊参与") + rows.append( + SecondaryAllocation( + business_id=business_id, + personnel_id=item.personnel_id, + personnel_name=person.personnel_name, + ratio=Decimal(item.ratio), + ) + ) + return rows + + +def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None: + """方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。 + + 无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。 + """ + if not allocations: + return None + total = sum((a.ratio for a in allocations), Decimal("0")) + total_text = total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if total == Decimal("100"): + return f"二次分配合计 {total_text}%" + rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return f"二次分配合计 {total_text}%,余额 {rest}%(归合同负责营销人员)" + + +def create_business( + session: Session, + payload: BusinessCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> BusinessOut: + """创建业务 + 明细行 + 首条状态留痕,一个事务。 + + 创建时指定的归属不写归属留痕(留痕记「变更」,创建即初始事实)。 + + :raises NotFoundError: 客户不存在 + :raises BadRequestError: 归属人员不存在或已离职 / 明细校验不通过 + """ + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS, + action=ACTION_CREATE, + summary=f"创建业务(客户ID={payload.customer_id},明细 {len(payload.items)} 行)", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + _ensure_customer(session, payload.customer_id) + if payload.owner_personnel_id is not None: + _ensure_personnel_active(session, payload.owner_personnel_id, "负责") + if payload.assistant_personnel_id is not None: + _ensure_personnel_active(session, payload.assistant_personnel_id, "协助") + + # 编号唯一键冲突的重试整体收在审计上下文内:rollback 后同一事务重来, + # 成功/最终失败各只记一条审计(吞异常 return 会被记成假成功,重入 + # 整个 create_* 则违反「不支持嵌套」约定);上限 _NO_RETRY 防无限重试 + # 掩盖死锁类缺陷。审计摘要预取的是首次编号——重试成功时入库编号见 + # 应用日志(AuditRecord frozen,不回填)。 + for attempt in range(_NO_RETRY + 1): + try: + business = Business( + business_no=_generate_no(session), + customer_id=payload.customer_id, + business_date=payload.business_date, + status=BUSINESS_STATUS_ACTIVE, + owner_personnel_id=payload.owner_personnel_id, + assistant_personnel_id=payload.assistant_personnel_id, + is_special=payload.is_special, + special_note=payload.special_note, + remark=payload.remark, + ) + business_repository.create(session, business) + business_repository.replace_items( + session, business.id, _build_item_rows(business.id, payload.items) + ) + _recalc_totals(session, business) + business_repository.add_status_log( + session, + BusinessStatusLog( + business_id=business.id, + old_status=BUSINESS_STATUS_ACTIVE, + new_status=BUSINESS_STATUS_ACTIVE, + operator_id=operator.id, + operator_name=operator.username, + ), + ) + # 二次分配方案与业务同事务(需求:只出现在合同刚建立时); + # 放在重试循环内——rollback 后方案行随业务一并重来 + business_repository.replace_allocations( + session, + business.id, + _build_allocation_rows( + session, business.id, payload.secondary_allocations, old_ids=set() + ), + ) + # 方案非空才提单:开关关闭=自动通过留痕,开启=待财务复核。 + # AP 编号撞键时 IntegrityError 穿透 → 外层编号重试循环把 + # 业务/方案/审批单整体重来 + if payload.secondary_allocations: + approval_service.submit_order( + session, + order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION, + ref_id=business.id, + refer_no=business.business_no, + submitter=operator, + ) + customer = customer_repository.get_by_id(session, business.customer_id) + out = _to_out((business, customer.company_name if customer else None, None, None)) + new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM + session.commit() + break + except IntegrityError as exc: + session.rollback() + if _is_duplicate_entry(exc) and attempt < _NO_RETRY: + continue + raise + + logger.info("创建业务 | business_id=%s | no=%s", new_id, new_no) + return out + + +def update_business( + session: Session, + business_id: int, + payload: BusinessUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> BusinessOut: + """编辑业务:全量替换明细 + 重算汇总;归属不在此端点(防旁路)。 + + 客户可改,但有发货或回款记录后禁改 —— 单据 FK 挂在业务上,换客户会让 + 发货/回款语义断裂(明细同理随业务走)。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 业务已完成或已终止 / 归属人员不合法 / 有发货或回款时换客户 + """ + row = business_repository.get_with_names(session, business_id, for_update=True) + business = row[0] if row else None + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS, + action=ACTION_UPDATE, + summary=f"修改业务 {business.business_no if business else f'#{business_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("业务不存在") + business, _, owner_name, assistant_name = row + + # 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线, + # 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务) + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError("业务已完成或已终止,禁止编辑业务") + + if payload.customer_id != business.customer_id and business_repository.has_docs( + session, business_id + ): + raise BadRequestError("业务已有发货或回款记录,不能更换客户") + customer = _ensure_customer(session, payload.customer_id) + if payload.owner_personnel_id is not None: + _ensure_personnel_active(session, payload.owner_personnel_id, "负责") + if payload.assistant_personnel_id is not None: + _ensure_personnel_active(session, payload.assistant_personnel_id, "协助") + + business.customer_id = payload.customer_id + business.business_date = payload.business_date + business.is_special = payload.is_special + business.special_note = payload.special_note + business.remark = payload.remark + # 归属刻意不赋值:留痕只由 /ownership 端点产生,编辑路径改归属构成 + # 「改归属不留痕」的旁路(与 customers 编辑不触归属同一口径) + business_repository.replace_items( + session, business_id, _build_item_rows(business_id, payload.items) + ) + # 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的 + # 前置场景(月结在 ④,快照固化后按同一哲学隔离) + old_ids = { + a.personnel_id for a in business_repository.list_allocations(session, business_id) + } + business_repository.replace_allocations( + session, + business_id, + _build_allocation_rows( + session, business_id, payload.secondary_allocations, old_ids=old_ids + ), + ) + # 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回 + # 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批 + if payload.secondary_allocations: + approval_service.submit_order( + session, + order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION, + ref_id=business_id, + refer_no=business.business_no, + submitter=operator, + ) + else: + approval_service.withdraw_pending_orders( + session, + order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION, + ref_id=business_id, + operator=operator, + ) + _recalc_totals(session, business) + # 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段 + # 从首查 row 取(换客户时 _ensure_customer 已给出新公司名) + out = _to_out((business, customer.company_name, owner_name, assistant_name)) + session.commit() + logger.info("修改业务 | business_id=%s", business_id) + return out + + +def delete_business( + session: Session, + business_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """删除业务(物理删除):有发货/回款 → 400 引导走「已终止」。 + + 无单据引用的业务(建错的空单)方可删除;删除时连带清理明细、状态留痕 + 与业务维度的归属留痕(无外键固化表,须手动清)。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 存在发货或回款记录 / 存在在途审批单 + """ + business = business_repository.get_by_id(session, business_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS, + action=ACTION_DELETE, + summary=f"删除业务 {business.business_no if business else f'#{business_id}'}", + ), + meta=meta, + ): + if business is None: + raise NotFoundError("业务不存在") + if business_repository.has_docs(session, business_id): + raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除") + # 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批): + # 禁删引导先完成/撤回审批,而不是静默留下指向已删业务的活单 + if _count_pending_approvals(session, business_id): + raise BadRequestError("该业务有在途审批单,请先完成或撤回审批后再删除") + + business_no = business.business_no + _hard_delete(session, business_id) + session.commit() + logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no) + + +def _count_pending_approvals(session: Session, business_id: int) -> int: + """该业务的在途审批单数(status 1/2;终态单随业务删除一并清理)。""" + from sqlalchemy import func, select + + from app.models import ApprovalOrder + from app.models.approval import ( + APPROVAL_STATUS_PENDING_FINANCE, + APPROVAL_STATUS_PENDING_LEADER, + ) + + return int( + session.execute( + select(func.count()) + .select_from(ApprovalOrder) + .where( + ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION, + ApprovalOrder.ref_id == business_id, + ApprovalOrder.status.in_( + (APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER) + ), + ) + ).scalar_one() + ) + + +def _hard_delete(session: Session, business_id: int) -> None: + """按依赖序删除:明细 → 二次分配方案 → 审批单(steps 随 CASCADE)→ + 状态留痕 → 业务维度归属留痕 → 主表。""" + from sqlalchemy import delete + + session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id)) + session.execute( + delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id) + ) + session.execute( + delete(ApprovalOrder).where( + ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION, + ApprovalOrder.ref_id == business_id, + ) + ) + session.execute(delete(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id)) + session.execute( + delete(OwnershipChangeLog).where( + OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS, + OwnershipChangeLog.business_id == business_id, + ) + ) + session.execute(delete(Business).where(Business.id == business_id)) + + +def change_status( + session: Session, + business_id: int, + payload: BusinessStatusIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> BusinessOut: + """状态流转:1→2 / 1→3,终态不可再变更;每次流转写留痕。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 非执行中状态流转 / 目标与当前相同 + """ + row = business_repository.get_with_names(session, business_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS, + action=ACTION_UPDATE, + summary=( + f"业务状态变更 {row[0].business_no if row else f'#{business_id}'} → " + f"{'已完成' if payload.status == BUSINESS_STATUS_DONE else '已终止'}" + ), + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("业务不存在") + business, _, _, _ = row + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError("已完成或已终止的业务不可再变更状态") + if business.status == payload.status: + raise BadRequestError("状态未变化") + + # 变更前状态须在赋值前固化:ORM 属性赋值后 old_status 就取不到旧值 + # (留痕会记成 old==new 失真,时间线无法回溯) + old_status = business.status + business.status = payload.status + business_repository.add_status_log( + session, + BusinessStatusLog( + business_id=business_id, + old_status=old_status, + new_status=payload.status, + change_reason=payload.reason, + operator_id=operator.id, + operator_name=operator.username, + ), + ) + # 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新状态 + out = _to_out(row) + session.commit() + logger.info( + "业务状态变更 | business_id=%s | %s -> %s", + business_id, + old_status, + payload.status, + ) + return out + + +def change_ownership( + session: Session, + business_id: int, + payload: BusinessOwnershipIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> BusinessOut: + """归属变更(负责/协助):同事务「更新主表 + 插留痕」,一次 commit。 + + 负责与协助之一发生变化即记一条留痕(前后值都含两个字段);两者均未变化 + → 400「归属未变化」。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 归属人员不存在或已离职 / 归属未变化 + """ + row = business_repository.get_with_names(session, business_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS, + action=ACTION_UPDATE, + summary=f"调整业务归属 {row[0].business_no if row else f'#{business_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("业务不存在") + business, _, old_owner_name, old_assistant_name = row + # 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前 + # 固化标量,新归属人名必须按新 ID 重取 + new_owner = ( + _ensure_personnel_active(session, payload.new_owner_personnel_id, "负责") + if payload.new_owner_personnel_id is not None + else None + ) + new_assistant = ( + _ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助") + if payload.new_assistant_personnel_id is not None + else None + ) + + owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id + assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id + if not owner_changed and not assistant_changed: + raise BadRequestError("归属未变化") + + old_owner_id = business.owner_personnel_id + old_assistant_id = business.assistant_personnel_id + business.owner_personnel_id = payload.new_owner_personnel_id + business.assistant_personnel_id = payload.new_assistant_personnel_id + business_repository.add_ownership_log( + session, + OwnershipChangeLog( + owner_type=OWNER_TYPE_BUSINESS, + business_id=business_id, + old_owner_personnel_id=old_owner_id, + new_owner_personnel_id=payload.new_owner_personnel_id, + old_assistant_personnel_id=old_assistant_id, + new_assistant_personnel_id=payload.new_assistant_personnel_id, + change_reason=payload.reason, + operator_id=operator.id, + operator_name=operator.username, + ), + ) + # 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名 + # 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照 + out = _to_out( + ( + business, + row[1], + new_owner.personnel_name if new_owner is not None else old_owner_name, + new_assistant.personnel_name if new_assistant is not None else old_assistant_name, + ) + ) + session.commit() + logger.info( + "调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s", + business_id, + old_owner_id, + payload.new_owner_personnel_id, + old_assistant_id, + payload.new_assistant_personnel_id, + ) + return out + + +def list_ownership_logs( + session: Session, business_id: int, query: PageQuery +) -> PageResult[OwnershipChangeLogOut]: + """某业务的归属变更留痕(分页)。 + + :raises NotFoundError: 业务不存在 + """ + if business_repository.get_by_id(session, business_id) is None: + raise NotFoundError("业务不存在") + rows = business_repository.list_ownership_logs( + session, business_id, offset=query.offset, limit=query.page_size + ) + total = business_repository.count_ownership_logs(session, business_id=business_id) + return PageResult[OwnershipChangeLogOut]( + records=[_log_to_out(row) for row in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def _log_to_out(row: Row) -> OwnershipChangeLogOut: + log, old_owner_name, new_owner_name = row + return OwnershipChangeLogOut( + id=log.id, + business_id=log.business_id, + old_owner_personnel_id=log.old_owner_personnel_id, + new_owner_personnel_id=log.new_owner_personnel_id, + old_assistant_personnel_id=log.old_assistant_personnel_id, + new_assistant_personnel_id=log.new_assistant_personnel_id, + old_owner_name=old_owner_name, + new_owner_name=new_owner_name, + change_reason=log.change_reason, + operator_id=log.operator_id, + operator_name=log.operator_name, + create_time=log.create_time, + ) + + +def list_status_logs( + session: Session, business_id: int, query: PageQuery +) -> PageResult[BusinessStatusLogOut]: + """某业务的状态流转时间线(分页,时间正序)。 + + :raises NotFoundError: 业务不存在 + """ + if business_repository.get_by_id(session, business_id) is None: + raise NotFoundError("业务不存在") + rows = business_repository.list_status_logs( + session, business_id, offset=query.offset, limit=query.page_size + ) + total = business_repository.count_status_logs(session, business_id=business_id) + return PageResult[BusinessStatusLogOut]( + records=[BusinessStatusLogOut.model_validate(row) for row in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut: + """毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。 + + 阈值按 asOf(默认今日)取 M1 参数快照(product.threshold,按行类别维度, + 存储口径为 % 数如 30,直接进 converted_profit);毛利润率按 M1 预览同一 + 出参口径输出 Fraction 分数字符串(如 "97/10000");成本缺失的行派生列为 + None 并标注原因;汇总只聚合可计算行。 + + :raises NotFoundError: 业务不存在 + """ + if business_repository.get_by_id(session, business_id) is None: + raise NotFoundError("业务不存在") + as_of = as_of or date.today() + snapshot = param_service.resolve_snapshot(session, as_of) + + def threshold_of(category: str) -> Fraction: + return snapshot.fraction("product.threshold", product_category=category) + + rows_out: list[GrossProfitRowOut] = [] + missing_count = 0 + # 类别聚合容器:{类别: [sale, cost]},成本缺失行不计入该类别聚合 + agg: dict[str, list[Decimal]] = {} + for item in business_repository.list_items(session, business_id): + threshold = threshold_of(item.product_category) + row_kwargs: dict = { + "item_id": item.id, + "product_category": item.product_category, + "product_name": item.product_name, + "spec_model": item.spec_model, + "quantity": str(item.quantity), + "sale_amount": str(item.sale_amount), + "threshold": str(threshold), + } + if item.cost_amount is None: + missing_count += 1 + row_kwargs.update( + cost_amount=None, + profit_amount=None, + profit_rate=None, + converted_profit=None, + missing_reason="成本缺失,无法计算毛利润", + ) + else: + profit = item.sale_amount - item.cost_amount + rate = profit_rate(profit, item.sale_amount) + conv = converted_profit(item.sale_amount, rate, threshold) + row_kwargs.update( + cost_amount=str(item.cost_amount), + profit_amount=str(profit), + profit_rate=str(rate), + converted_profit=str(conv), + missing_reason=None, + ) + bucket = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")]) + bucket[0] += item.sale_amount + bucket[1] += item.cost_amount + rows_out.append(GrossProfitRowOut(**row_kwargs)) + + categories = [ + GrossProfitCategoryOut( + product_category=category, + sale_amount=str(sale), + cost_amount=str(cost), + profit_amount=str(sale - cost), + profit_rate=str(profit_rate(sale - cost, sale)), + threshold=str(threshold_of(category)), + converted_profit=str( + converted_profit(sale, profit_rate(sale - cost, sale), threshold_of(category)) + ), + ) + for category, [sale, cost] in agg.items() + ] + return GrossProfitOut( + business_id=business_id, + as_of=as_of, + rows=rows_out, + categories=categories, + missing_count=missing_count, + ) diff --git a/backend/app/services/calc_service.py b/backend/app/services/calc_service.py new file mode 100644 index 0000000..3cfd511 --- /dev/null +++ b/backend/app/services/calc_service.py @@ -0,0 +1,719 @@ +"""绩效核算服务(M3 ④):回款 → 台账的固化流水线。 + +单笔核算(calc_receipt)与计算预览(param_service.preview)**共用同一 +公式路径与取值防线**:resolve_snapshot 按回款到账日取参(裁决 #11)、按 +岗位模板分派计提比例(‰ 存储口径 ÷1000 转纯比例)、行级折算(混合业务 +按行汇总,对照表 #14)。两者的任何口径修改必须同步——预览给业务对数、 +核算才是算钱的法定固化点。 + +策略点(业务答复 2026-10-19 已回填,后续调整仍集中在对应函数 + 按回款批量重算): +- ``_personal_rate``:#12 经理个人户单笔由参数 manager.personal.included + 分流——0=单笔落 0(聚合口径在月结时由 manager_team_share 计算)、 + 1=按业务员递减公式计提;#20 在 ``_row_profit``(暂按不含税原样相减) +- 二次分摊(澄清 #1 答复「分配绩效」,算后分):``_load_allocations`` + 读生效方案,``_persist_ledger`` 按比例拆行(参与人份额 + 负责人余额, + 行合计守恒);#2 余额归负责人、#3 二次分配即协助场景的核算载体 +- 核算粒度 #25:业务级一次性计提——首个触发核算的回款计算全额应发, + 后续回款仅置 settlement_status=1(repeated=True),不重复计提 +""" + +import logging +from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal +from fractions import Fraction +from typing import Any + +from sqlalchemy import update +from sqlalchemy.orm import Session + +from app.core.constants import MODULE_CALC +from app.core.exceptions import BadRequestError +from app.models import ( + Business, + BusinessItem, + Customer, + PerformanceLedger, + Personnel, + Receipt, + Shipment, + User, +) +from app.models.performance_ledger import CALC_TYPE_RECEIPT +from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING +from app.repositories import business_repository, performance_ledger_repository +from app.schemas.calc import CalcReceiptOut +from app.services import audit_service, param_service, personnel_service +from app.services.audit_service import AuditRecord, RequestMeta +from app.services.formula import ( + apply_barter, + clamp_weight_r, + converted_profit, + payment_days, + payment_weight, + performance_amount, + profit_rate, + salesperson_rate, + start_payment_ok, +) +from app.services.param_service import parse_fraction, resolve_snapshot + +logger = logging.getLogger(__name__) + +# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记) +_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算" + + +@dataclass(frozen=True, slots=True) +class _RateDecision: + """单笔计提比例的分派结果。""" + + rate: Fraction # 纯比例(str() 产出分数原文如 88/9000,直接落台账 rate_text) + rate_note: str # 人读说明(入 snapshot) + base_override: Decimal | None # 非 None 时绩效基数用它(信息员=销售额) + manager_pending: bool # True=经理聚合口径,单笔落 0(#12 策略点) + + +def calc_receipt( + session: Session, + receipt_id: int, + *, + operator: User, + meta: RequestMeta | None = None, + recalc_reason: str | None = None, +) -> CalcReceiptOut: + """单笔回款核算 / 重算(recalc_reason 非 None 时为重算模式)。 + + 防重前两层在 ``_load_and_guard``(FOR UPDATE 锁行 + settlement_status + 与月结锁定校验),第三层是落台账 uk(receipt_id, calc_version)。全程 + 一个 audit 事务,出参在 commit 前固化。 + + 首算模式:回款必须未核算;业务级一次性计提(#25)——已有生效台账行时 + 仅登记进度(repeated=True,``_prepare_mode`` 内提前返回)。 + 重算模式:回款必须已核算过;作废旧版本行(is_active=0),按当前参数 + 落 calc_version+1 新行,calc_reason 留痕。 + + 本函数只做编排:锁行校验 → 模式预处理 → 输入装载 → 逐行折算 → + 计提组成 → 落库,各段独立私有函数(口径修改只动对应段)。 + """ + is_recalc = recalc_reason is not None + action = "重算" if is_recalc else "核算" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_CALC, + action=action, + summary=f"{action}回款 #{receipt_id}", + request_data={ + "receipt_id": receipt_id, + **({"reason": recalc_reason} if is_recalc else {}), + }, + ), + meta=meta, + ): + receipt, business = _load_and_guard(session, receipt_id, is_recalc=is_recalc) + repeated_out = _prepare_mode(session, receipt, business, is_recalc=is_recalc) + if repeated_out is not None: + return repeated_out + + shipment = _load_shipment(session, receipt) + person = _load_person(session, business) + snapshot = resolve_snapshot(session, receipt.receipt_date) + items = _load_items(session, business) + allocations = _load_allocations(session, business, person.id) + + conversion = _convert_items(snapshot, person, items) + amounts = _compose_amounts( + session, snapshot, person, business, receipt, shipment, conversion + ) + rows = _persist_ledger( + session, + receipt=receipt, + business=business, + person=person, + shipment=shipment, + items=items, + allocations=allocations, + conversion=conversion, + amounts=amounts, + recalc_reason=recalc_reason, + is_recalc=is_recalc, + ) + session.flush() + out = CalcReceiptOut(ledger=_to_out(rows.owner_row), repeated=False) + session.commit() + logger.info( + "核算完成 | receipt=%s | ledger=%s | payable=%s | 分摊行=%s", + receipt_id, + rows.owner_row.id, + amounts.payable, + len(rows.share_rows), + ) + return out + + +def _load_and_guard( + session: Session, receipt_id: int, *, is_recalc: bool +) -> tuple[Receipt, Business]: + """锁行 + 模式校验 + 月结锁定拦截 + 业务归属校验(核算/重算共用)。""" + receipt = session.get(Receipt, receipt_id, with_for_update=True) + if receipt is None: + raise BadRequestError("回款不存在") + if is_recalc: + if receipt.settlement_status == SETTLEMENT_PENDING: + raise BadRequestError("该回款尚未核算过(无旧版本可重算,走首次核算)") + elif receipt.settlement_status == SETTLEMENT_DONE: + raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)") + + # 月结锁定拦截:已审定月份的回款不得再核算/重算——金额已固化, + # 变动唯一通道是调整单(否则重算会绕开审批改写已锁定月份的应付) + from app.services import settlement_service + + settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m")) + + business = session.get(Business, receipt.business_id, with_for_update=True) + if business is None: + raise BadRequestError("回款关联的业务不存在") + if business.owner_personnel_id is None: + raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)") + return receipt, business + + +def _prepare_mode( + session: Session, receipt: Receipt, business: Business, *, is_recalc: bool +) -> CalcReceiptOut | None: + """模式预处理:重算作废旧版本;首算判业务级一次性计提。 + + 返回非 None = 本笔仅登记进度(repeated=True 的出参,已 commit), + 调用方直接返回。 + """ + # 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算。 + # 无生效行 = 该回款是「进度登记」(业务级一次性计提 #25 的第二笔及 + # 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现 + # 两行生效行导致双重计提 → 一律 400 + if is_recalc: + # 分摊拆行后旧版本可能多行(负责人+参与人)——全部作废再落新版本 + olds = performance_ledger_repository.list_active_by_receipt(session, receipt.id) + if not olds: + raise BadRequestError("该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算") + for old in olds: + performance_ledger_repository.mark_inactive(session, old) + return None + + # 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效 + # 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提 + if not performance_ledger_repository.has_active_for_business( + session, business.id, calc_type=CALC_TYPE_RECEIPT + ): + return None + _mark_settled(session, receipt) + row = performance_ledger_repository.get_active_by_receipt_of_business(session, business.id) + session.flush() + out = CalcReceiptOut(ledger=_to_out(row), repeated=True) + session.commit() + logger.info("回款登记进度(业务已核算)| receipt=%s | business=%s", receipt.id, business.id) + return out + + +def _load_shipment(session: Session, receipt: Receipt) -> Shipment: + """核销基准:发货批次存在且带发货日(回款天数公式的输入)。""" + if receipt.shipment_id is None: + raise BadRequestError("回款未指定发货批次,无法确定发货日期(先指定批次再核算)") + shipment = session.get(Shipment, receipt.shipment_id) + if shipment is None or shipment.ship_date is None: + raise BadRequestError("发货批次缺少发货日期,无法核算") + return shipment + + +def _load_person(session: Session, business: Business): + """核算归属:在职 + 绑定岗位角色(计提公式锚点)。""" + person = personnel_service.ensure_active(session, business.owner_personnel_id, label="核算归属") + if person.business_role_id is None: + raise BadRequestError("归属人员未绑定岗位角色,无法确定计提公式") + return person + + +def _load_items(session: Session, business: Business) -> list[BusinessItem]: + """业务产品明细行(逐行折算的输入;空明细无法核算)。""" + items = business_repository.list_items(session, business.id) + if not items: + raise BadRequestError("业务无产品明细行,无法核算") + return items + + +def _load_allocations(session: Session, business: Business, owner_id: int) -> list: + """业务生效的二次分配方案(澄清 #1 答复 2026-10-19:分配绩效——算后分)。 + + 校验双保险之一(保存侧在 business_service):比例合计 >100% 会把负责人 + 份额算成负数(多算参与人绩效);合同负责人不可出现在参与人列表(自分 + 份额会双重计入)。无方案返回空列表 = 全额归负责人。 + """ + allocations = business_repository.list_allocations(session, business.id) + if not allocations: + return [] + total = sum((a.ratio for a in allocations), Decimal("0")) + if total > Decimal("100"): + raise BadRequestError( + f"二次分配比例合计 {total.quantize(Decimal('0.01'))}% 超过 100%," + "无法核算(先调整方案比例)" + ) + if any(a.personnel_id == owner_id for a in allocations): + raise BadRequestError("二次分配参与人含合同负责营销人员,无法核算(先调整方案)") + return list(allocations) + + +@dataclass(frozen=True, slots=True) +class _Conversion: + """逐行折算结果(行级明细入台账快照,汇总值驱动计提与起付判定)。""" + + total_revenue: Decimal + total_profit: Decimal + converted_total: Decimal + missing_cost: bool + line_details: list[dict[str, Any]] + + +def _convert_items(snapshot, person, items: list[BusinessItem]) -> _Conversion: + """逐行折算(行级 product_category 阈值;成本缺失行毛利按 0 计并标记)。""" + total_revenue = Decimal("0") + total_profit = Decimal("0") + converted_total = Decimal("0") + missing_cost = False + line_details: list[dict[str, Any]] = [] + for item in items: + row_revenue = item.sale_amount + if item.cost_amount is None: + missing_cost = True + row_profit = Decimal("0") + else: + row_profit = _row_profit(row_revenue, item.cost_amount) + total_revenue += row_revenue + total_profit += row_profit + threshold = parse_fraction( + snapshot.get( + "product.threshold", + role_code=role_code_of(person), + product_category=item.product_category, + ) + ) + row_converted = converted_profit( + row_revenue, profit_rate(row_profit, row_revenue), threshold + ) + converted_total += row_converted + line_details.append( + { + "item_id": item.id, + "product_category": item.product_category, + "revenue": str(row_revenue), + "profit": str(row_profit), + # 行级折算同样固化为两位(to_decimal 对整数值无小数位, + # 快照/台账口径需与列一致) + "converted": str(_q2(row_converted)), + } + ) + return _Conversion(total_revenue, total_profit, converted_total, missing_cost, line_details) + + +@dataclass(frozen=True, slots=True) +class _Amounts: + """计提组成结果(比例分派 → 绩效 → 抵顶 → 支付权重 → 应付)。""" + + days: int + decision: _RateDecision + converted: Decimal + performance: Decimal + barter_applied: bool + weight_r: int + weight: Fraction + payable: Decimal + start_ok: bool + + +def _compose_amounts( + session: Session, + snapshot, + person, + business: Business, + receipt: Receipt, + shipment: Shipment, + conversion: _Conversion, +) -> _Amounts: + """按比例分派与支付权重组成应付(与 param_service.preview 同公式路径)。""" + # 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入) + days = payment_days(shipment.ship_date, receipt.receipt_date) + decision = _personal_rate(session, snapshot, person, business, days) + + if decision.base_override is not None: + converted = decision.base_override # 信息员:基数=本人销售额 + else: + converted = conversion.converted_total + + performance = ( + Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate) + ) + barter_applied = receipt.is_barter == 1 + base_amount = performance + if barter_applied: + discount = parse_fraction(snapshot.get("barter.discount", role_code=role_code_of(person))) + base_amount = apply_barter(performance, discount) + + received_total = business.total_received_amount or Decimal("0") + start_ratio = parse_fraction( + snapshot.get("payment.start.ratio", role_code=role_code_of(person)) + ) + start_ok = start_payment_ok(received_total, conversion.total_revenue, start_ratio) + weight_r = clamp_weight_r(received_total, conversion.total_revenue) + weight = payment_weight(weight_r) + payable = param_service.to_decimal(Fraction(base_amount) * weight) + return _Amounts( + days=days, + decision=decision, + converted=converted, + performance=performance, + barter_applied=barter_applied, + weight_r=weight_r, + weight=weight, + payable=payable, + start_ok=start_ok, + ) + + +@dataclass(frozen=True, slots=True) +class _LedgerRows: + """拆行落库结果:负责人行恒存在(出参与 CAS 锚点),参与人份额行可空。""" + + owner_row: PerformanceLedger + share_rows: list[PerformanceLedger] + + +def _persist_ledger( + session: Session, + *, + receipt: Receipt, + business: Business, + person, + shipment: Shipment, + items: list[BusinessItem], + allocations: list, + conversion: _Conversion, + amounts: _Amounts, + recalc_reason: str | None, + is_recalc: bool, +) -> _LedgerRows: + """落台账行(分摊拆行;版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。 + + 分摊(澄清 #1 答复 2026-10-19「分配绩效」,算后分):业务级全额(含 + 递减比例、抵顶、支付权重全环节)算完后按方案比例拆给参与人,余额归 + 合同负责人。行合计与业务级全额**守恒**(负责人行取 q2 全额减参与人 + q2 份额的差额,防分漂);每行快照带完整方案快照,行级可独立回溯。 + 参与人离职照分(历史方案事实,方案调整是业务侧责任——入职校验只在 + 保存侧,核算侧只要求人员存在,FK RESTRICT 已保证)。 + """ + receipt_date = receipt.receipt_date + base_snapshot: dict[str, Any] = { + "params_as_of": str(receipt_date), + "line_details": conversion.line_details, + "start_payment_ok": amounts.start_ok, + "rate_note": amounts.decision.rate_note, + "revenue_total": str(conversion.total_revenue), + "profit_total": str(conversion.total_profit), + } + if amounts.decision.manager_pending: + base_snapshot["strategy_note"] = _NOTE_MANAGER_PENDING + if allocations: + base_snapshot["alloc_plan"] = [ + { + "personnel_id": a.personnel_id, + "personnel_name": a.personnel_name, + "ratio": str(a.ratio), + } + for a in allocations + ] + + version = performance_ledger_repository.next_version(session, receipt.id) + customer_type = _customer_type(session, business) + category = _category_label(items) + + # 份额清单:(person, ratio, tag)——ratio 为 None 时走余额公式(q2 全额 + # 减已分累计,守恒);负责人余额的百分数(100−∑参与人)仅作快照展示, + # 不参与计算——按它乘全额会丢 q2 尾差(39.1111×30% ≠ 39.11−27.37) + shares: list[tuple[Any, Decimal | None, str]] = [] + owner_ratio_text: str | None = None + if not allocations: + shares.append((person, None, "owner")) + owner_ratio_text = "100" + else: + total_ratio = Decimal("0") + for a in allocations: + shares.append((session.get(Personnel, a.personnel_id), a.ratio, "participant")) + total_ratio += a.ratio + owner_ratio_text = str((Decimal("100") - total_ratio).quantize(Decimal("0.01"))) + shares.append((person, None, "owner")) + + def _share(full: Decimal, ratio: Decimal | None, *, added: Decimal = Decimal("0")) -> Decimal: + """单份额金额:参与人 = 全额×比例(q2);负责人 = q2 全额 − 已分累计。""" + if ratio is None: + return _q2(full - added) or Decimal("0") + # Fraction 乘积先 to_decimal 再 q2(Fraction 无 quantize,直接喂 _q2 会炸) + return _q2(param_service.to_decimal(Fraction(full) * Fraction(ratio) / 100)) or Decimal("0") + + added_perf = Decimal("0") + added_conv = Decimal("0") + added_pay = Decimal("0") + owner_row: PerformanceLedger | None = None + share_rows: list[PerformanceLedger] = [] + for target, ratio, tag in shares: + perf = _share(amounts.performance, ratio, added=added_perf) + conv = _share(amounts.converted, ratio, added=added_conv) + pay = _share(amounts.payable, ratio, added=added_pay) + added_perf += perf + added_conv += conv + added_pay += pay + row = performance_ledger_repository.add( + session, + PerformanceLedger( + receipt_id=receipt.id, + calc_version=version, + calc_type=CALC_TYPE_RECEIPT, + business_id=business.id, + business_no=business.business_no, + personnel_id=target.id, + personnel_name=target.personnel_name, + role_code=role_code_of(target), + customer_type=customer_type, + product_category=category, + ship_date=shipment.ship_date, + receipt_date=receipt_date, + payment_days=amounts.days, + rate_text=str(amounts.decision.rate), + converted_profit=conv, + performance_amount=perf, + barter_applied=1 if amounts.barter_applied else 0, + weight_r=amounts.weight_r, + payment_weight=param_service.to_decimal(amounts.weight), + payable_amount=pay, + settlement_month=receipt_date.strftime("%Y-%m"), + is_probation=1 if target.is_probation == 1 else 0, + missing_cost=1 if conversion.missing_cost else 0, + snapshot_json={ + **base_snapshot, + "alloc_role": tag, + "alloc_ratio": owner_ratio_text if tag == "owner" else str(ratio), + }, + calc_reason=recalc_reason, + ), + ) + if tag == "owner": + owner_row = row + else: + share_rows.append(row) + + # CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0 + # 会 rowcount=0 误报并发撞车) + if not is_recalc: + _mark_settled(session, receipt) + return _LedgerRows(owner_row=owner_row, share_rows=share_rows) + + +def _q2(value: Decimal | None) -> Decimal | None: + """金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。 + + 显式传 rounding——quantize 缺省是上下文 ROUND_HALF_EVEN(银行家舍入), + x.xx5 会舍成偶数值,与业务四舍五入口径差 1 分。 + """ + if value is None: + return None + return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + +def _personal_rate( + session: Session, + snapshot, + person, + business: Business, + days: int, +) -> _RateDecision: + """按岗位模板分派单笔计提比例(与 param_service._preview_rate 同构)。 + + #12 策略点:经理个人户(owner 岗位绑 TPL_MANAGER_SHARE)单笔不计提 + —— 12.5%+5% 是月度团队聚合口径,单笔落 rate=0/绩效 0 的台账行保事实。 + """ + role = _role_of(session, person) + customer_type = _customer_type(session, business) + + if customer_type == "enterprise": + # 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线) + rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000 + return _RateDecision(rate, "企业户固定计提比例", None, False) + + template = role.calc_template if role else "TPL_SALESPERSON_DECAY" + if template == "TPL_MANAGER_SHARE": + # #12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户 + # 单按业务员递减公式单笔计提(个人户单本质是正常业务,有发货/回款/ + # 毛利;聚合基数开关在 settlement_service 同参数消费)——0 时单笔落 0 + if snapshot.get("manager.personal.included") != "1": + return _RateDecision( + Decimal("0"), + "经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0", + None, + True, + ) + start = parse_fraction(snapshot.get("salesperson.rate.start")) + slope = parse_fraction(snapshot.get("salesperson.rate.slope")) + floor = parse_fraction(snapshot.get("salesperson.rate.floor")) + boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary"))) + rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary) + note = f"经理个人户(基数开关=1):按业务员递减 {start}‰ 起,{days} 天" + return _RateDecision(rate, note, None, False) + if template == "TPL_INFO_SALES_FIXED": + # 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线 + rate = parse_fraction(snapshot.get("info.rate")) / 1000 + return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False) + if template == "TPL_SALESPERSON_DECAY": + start = parse_fraction(snapshot.get("salesperson.rate.start")) + slope = parse_fraction(snapshot.get("salesperson.rate.slope")) + floor = parse_fraction(snapshot.get("salesperson.rate.floor")) + # int(参数原文) 对合法入库的 '360.0' 会抛 ValueError 使整月核算 500; + # 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损) + boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary"))) + rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary) + note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)" + return _RateDecision(rate, note, None, False) + raise BadRequestError(f"公式模板 {template} 暂不支持核算") + + +def _role_of(session: Session, person): + from app.repositories import business_role_repository + + if person.business_role_id is None: + return None + return business_role_repository.get_by_id(session, person.business_role_id) + + +def role_code_of(person) -> str: + """人员岗位编码(台账冗余固化用;无岗位为空串——前置校验已拦)。""" + from app.repositories import business_role_repository + + if person.business_role_id is None: + return "" + session = Session.object_session(person) + role = business_role_repository.get_by_id(session, person.business_role_id) + return role.role_code if role else "" + + +def _customer_type(session: Session, business: Business) -> str: + customer = session.get(Customer, business.customer_id) + return customer.customer_type if customer else "personal" + + +def _category_label(items: list[BusinessItem]) -> str: + """单类别直出;混合业务记 mixed(行级明细在 snapshot_json)。""" + categories = {item.product_category for item in items} + return categories.pop() if len(categories) == 1 else "mixed" + + +def _mark_settled(session: Session, receipt: Receipt) -> None: + """CAS 置已核算:并发下另一事务先行置 1 时 rowcount=0 → 400。""" + result = session.execute( + update(Receipt) + .where(Receipt.id == receipt.id, Receipt.settlement_status == SETTLEMENT_PENDING) + .values(settlement_status=SETTLEMENT_DONE) + ) + if result.rowcount == 0: + raise BadRequestError("该回款已被并发核算,请刷新后查看台账") + + +def _row_profit(revenue: Decimal, cost: Decimal) -> Decimal: + """行毛利 = 行销售额 − 行成本。 + + #20 策略点(待业务答复,2026-10-16 暂定口径):金额暂按**不含税**原样 + 相减;答复若为含税口径,只改本函数(含税换算/取不含税金额列)+ 按回款 + 批量重算回填。 + """ + return revenue - cost + + +def recalc_receipt( + session: Session, + receipt_id: int, + reason: str, + *, + operator: User, + meta: RequestMeta | None = None, +) -> CalcReceiptOut: + """重算(授权动作,管理员/财务):作废旧版本行、按当前参数重算新版本。 + + 薄委托 calc_receipt(recalc_reason=...)——折算体只有一份;所属月已审定 + 的拦截在 calc_receipt 内(核算与重算同口径)。 + """ + if not reason or not reason.strip(): + raise BadRequestError("重算必须填写原因") + return calc_receipt( + session, receipt_id, operator=operator, meta=meta, recalc_reason=reason.strip() + ) + + +def batch_calc( + session: Session, + receipt_ids: list[int], + *, + operator: User, + meta: RequestMeta | None = None, +) -> list[dict]: + """批量核算:逐笔独立事务(各笔自带 audit),返回逐笔成败清单。 + + **不开外层 audit**——外层包 audit 会违反嵌套禁令,且单笔失败不应 + 回滚其它笔的核算结果。 + """ + results: list[dict] = [] + for rid in receipt_ids: + try: + out = calc_receipt(session, rid, operator=operator, meta=meta) + results.append( + { + "receipt_id": rid, + "ok": True, + "repeated": out.repeated, + "ledger_id": out.ledger.id, + } + ) + except Exception as exc: + results.append( + {"receipt_id": rid, "ok": False, "error": f"{type(exc).__name__}: {exc}"} + ) + ok_count = sum(1 for r in results if r["ok"]) + logger.info("批量核算 | 总数=%s 成功=%s", len(results), ok_count) + return results + + +def list_ledger_page(session: Session, query): + """分页查询台账(api 层转发)。""" + rows = performance_ledger_repository.list_ledger(session, query) + total = performance_ledger_repository.count_ledger(session, query) + from app.core.response import PageResult + from app.schemas.calc import LedgerOut + + return PageResult[LedgerOut]( + records=[LedgerOut.model_validate(r) for r in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_ledger(session: Session, ledger_id: int): + """台账详情(api 层转发)。""" + row = performance_ledger_repository.get_by_id(session, ledger_id) + if row is None: + from app.core.exceptions import NotFoundError + + raise NotFoundError("台账记录不存在") + from app.schemas.calc import LedgerOut + + return LedgerOut.model_validate(row) + + +def _to_out(row: PerformanceLedger): + from app.schemas.calc import LedgerOut + + return LedgerOut.model_validate(row) diff --git a/backend/app/services/calc_templates.py b/backend/app/services/calc_templates.py new file mode 100644 index 0000000..9f22044 --- /dev/null +++ b/backend/app/services/calc_templates.py @@ -0,0 +1,72 @@ +"""计提公式模板注册表(代码内有限集合)。 + +一期不引入规则引擎的落地方式:公式骨架固定(本表枚举),变化的是参数值 +(系统设置维护)。岗位角色(business_roles.calc_template)绑定模板编码, +新增岗位 = 选模板 + 配参数,无需改代码(需求 #28)。 +新增公式形态(如未来「按产品数量计提」)需要新增模板 —— 一期接受, +岗位表单中模板为带说明的受控下拉,避免误以为可自由写公式。 +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class TemplateSpec: + """公式模板元数据。""" + + code: str + name: str + params: tuple[str, ...] + description: str + + +TEMPLATES: dict[str, TemplateSpec] = { + "TPL_SALESPERSON_DECAY": TemplateSpec( + code="TPL_SALESPERSON_DECAY", + name="业务员个人户·天数线性递减", + params=( + "salesperson.rate.start", + "salesperson.rate.slope", + "salesperson.rate.floor", + "salesperson.days.boundary", + ), + description="分段:≤分界天数 = 起始 − 天数×斜率;>分界天数 = 下限", + ), + "TPL_MANAGER_SHARE": TemplateSpec( + code="TPL_MANAGER_SHARE", + name="营销经理·团队与全公司分成", + params=("manager.share.team", "manager.share.company"), + description="聚合:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%", + ), + "TPL_ENTERPRISE_FIXED": TemplateSpec( + code="TPL_ENTERPRISE_FIXED", + name="企业户·固定计提", + params=("enterprise.rate",), + description="乘法:折算毛利润额 × 计提比例", + ), + "TPL_INFO_SALES_FIXED": TemplateSpec( + code="TPL_INFO_SALES_FIXED", + name="信息员·销售额固定比例", + params=("info.rate",), + description="乘法:本人销售额 × 计提比例", + ), + "TPL_PREPAY_FIXED": TemplateSpec( + code="TPL_PREPAY_FIXED", + name="预付款·固定计提", + params=("prepay.rate",), + description="乘法:回款额 × 计提比例", + ), +} + + +def get_template(code: str) -> TemplateSpec: + """取模板元数据。未注册编码属代码缺陷(500),不是用户错误。""" + tpl = TEMPLATES.get(code) + if tpl is None: + raise ValueError(f"公式模板编码未注册:{code}") + return tpl + + +def required_params(code: str) -> tuple[str, ...]: + """模板声明的参数编码列表(岗位表单展示与校验用)。""" + return get_template(code).params diff --git a/backend/app/services/customer_service.py b/backend/app/services/customer_service.py new file mode 100644 index 0000000..0d4879d --- /dev/null +++ b/backend/app/services/customer_service.py @@ -0,0 +1,422 @@ +"""客户管理业务编排(需求 2.1.3)。 + +**事务边界在本层**:repository 只 flush,commit/rollback 只写在这里。 + +- 「公司名称 + 负责人」组合唯一(uk_customers_company_owner):先查后写不是 + 原子操作,并发提交撞唯一键时捕 IntegrityError 判 1062 翻译为 400 + (同 M1 的并发兜底模式) +- **归属变更的裁决语义是「只影响变更后核算,不回溯历史」(对照表 #19)**: + 实现为同一事务内「更新 customers.owner_personnel_id + 插入一条 + ownership_change_logs」,一次 commit —— 两步拆开提交时,中途失败会出现 + 「主表已换人、留痕缺失」的静默不一致,事后追溯无据可查 +- 归属调整只有 ``change_ownership`` 一条路径;编辑端点不触归属, + 防止出现「改归属不留痕」的旁路 +""" + +import logging + +from sqlalchemy import Row +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_UPDATE, + CUSTOMER_TYPES, + MODULE_CUSTOMER, +) +from app.core.db_errors import is_duplicate_entry +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageQuery, PageResult +from app.models import Customer, OwnershipChangeLog, Personnel, User +from app.models.customer import OWNER_TYPE_CUSTOMER +from app.repositories import customer_repository, personnel_repository +from app.schemas.customer import ( + CustomerCreateIn, + CustomerOut, + CustomerOwnershipIn, + CustomerQuery, + CustomerUpdateIn, + OwnershipChangeLogOut, +) +from app.services import audit_service, personnel_service +from app.services.audit_service import AuditRecord, RequestMeta + +logger = logging.getLogger(__name__) + + +def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut: + """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" + return CustomerOut( + id=customer.id, + company_name=customer.company_name, + owner_name=customer.owner_name, + contact_person=customer.contact_person, + contact_phone=customer.contact_phone, + address=customer.address, + industry=customer.industry, + customer_type=customer.customer_type, + owner_personnel_id=customer.owner_personnel_id, + owner_personnel_name=owner_name, + remark=customer.remark, + ) + + +def _to_log_out(row: Row) -> OwnershipChangeLogOut: + """留痕行 (留痕, 旧归属人名, 新归属人名) 转响应模型。""" + log, old_name, new_name = row + return OwnershipChangeLogOut( + id=log.id, + customer_id=log.customer_id, + old_owner_personnel_id=log.old_owner_personnel_id, + new_owner_personnel_id=log.new_owner_personnel_id, + old_owner_name=old_name, + new_owner_name=new_name, + change_reason=log.change_reason, + operator_id=log.operator_id, + operator_name=log.operator_name, + create_time=log.create_time, + ) + + +def _ensure_customer_type(customer_type: str) -> None: + """客户分类必须是注册表内的三个值之一(前端为受控下拉,后端仍须校验)。 + + customer_type 是 M3 计提规则匹配的锚点,脏值会让该客户在核算时匹配不到 + 任何计提规则而静默漏算。 + """ + if customer_type not in CUSTOMER_TYPES: + raise BadRequestError("客户分类不合法") + + +def _ensure_owner_personnel(session: Session, personnel_id: int) -> Personnel: + """归属人员必须存在且在职(共享校验收口于 personnel_service.ensure_active)。""" + return personnel_service.ensure_active(session, personnel_id, label="归属") + + +def _is_duplicate_entry(exc: IntegrityError) -> bool: + """判断 IntegrityError 是否为 MySQL 唯一键冲突(1062)。 + + customers 上还有归属人员外键(RESTRICT),并发窗口内删除人员等场景 + 会抛 1452 —— 不能一并翻译成「已存在」,否则用户会被错误提示带偏; + 非 1062 原样上抛,由全局 IntegrityError handler 收敛 400(通用文案)。 + """ + return is_duplicate_entry(exc) + + +def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]: + """分页查询客户(附归属人员姓名)。""" + rows = customer_repository.list_customers( + session, + offset=query.offset, + limit=query.page_size, + keyword=query.keyword, + customer_type=query.customer_type, + owner_personnel_id=query.owner_personnel_id, + ) + total = customer_repository.count_customers( + session, + keyword=query.keyword, + customer_type=query.customer_type, + owner_personnel_id=query.owner_personnel_id, + ) + return PageResult[CustomerOut]( + records=[_to_out(customer, owner_name) for customer, owner_name in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_customer(session: Session, customer_id: int) -> CustomerOut: + """客户详情。 + + :raises NotFoundError: 客户不存在 + """ + row = customer_repository.get_with_owner_name(session, customer_id) + if row is None: + raise NotFoundError("客户不存在") + customer, owner_name = row + return _to_out(customer, owner_name) + + +def create_customer( + session: Session, + payload: CustomerCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> CustomerOut: + """新增客户。 + + :raises BadRequestError: 客户分类不合法 / 归属人员不存在或已离职 / + 「公司名称+负责人」已存在(含并发兜底) + """ + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_CUSTOMER, + action=ACTION_CREATE, + summary=f"新增客户 {payload.company_name}({payload.owner_name})", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + # 值域与归属校验在审计上下文内:失败(400)也要留痕 —— + # 与 personnel_service._validate_refs 移入上下文的口径一致 + _ensure_customer_type(payload.customer_type) + owner_name: str | None = None + if payload.owner_personnel_id is not None: + owner_name = _ensure_owner_personnel(session, payload.owner_personnel_id).personnel_name + if ( + customer_repository.get_by_company_owner( + session, payload.company_name, payload.owner_name + ) + is not None + ): + raise BadRequestError("该公司名称与负责人的客户已存在") + + customer = Customer( + company_name=payload.company_name, + owner_name=payload.owner_name, + contact_person=payload.contact_person, + contact_phone=payload.contact_phone, + address=payload.address, + industry=payload.industry, + customer_type=payload.customer_type, + owner_personnel_id=payload.owner_personnel_id, + remark=payload.remark, + ) + try: + customer_repository.create(session, customer) + # 响应在 commit 之前构造(同 user_service 顶部说明) + out = _to_out(customer, owner_name) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:并发提交同一「公司名称+负责人」会撞 + # uk_customers_company_owner,回滚并翻译为可读的 400,而非 500 + session.rollback() + if _is_duplicate_entry(exc): + raise BadRequestError("该公司名称与负责人的客户已存在") from exc + raise + + logger.info("新增客户 | customer_id=%s | company=%s", customer.id, payload.company_name) + return out + + +def update_customer( + session: Session, + customer_id: int, + payload: CustomerUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> CustomerOut: + """编辑客户档案(全量替换)。 + + **不触归属**:owner_personnel_id 保持原值,调整统一走 + ``PUT /{id}/ownership``(唯一写留痕的路径),见 ``CustomerUpdateIn`` 说明。 + + :raises NotFoundError: 客户不存在 + :raises BadRequestError: 客户分类不合法 / 「公司名称+负责人」已存在(含并发兜底) + """ + # 只读预取在审计上下文之前:目标不存在(NotFound 失败)时也能留下可读记录, + # 摘要与回包都要用客户行;归属姓名本端点不变更,一并带出 + row = customer_repository.get_with_owner_name(session, customer_id) + customer = row[0] if row else None + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_CUSTOMER, + action=ACTION_UPDATE, + summary=f"修改客户 {customer.company_name if customer else f'#{customer_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + # 值域校验在审计上下文内:分类非法的 400 也要留痕(同 create 口径) + _ensure_customer_type(payload.customer_type) + if row is None: + raise NotFoundError("客户不存在") + customer, owner_name = row + + duplicate = customer_repository.get_by_company_owner( + session, payload.company_name, payload.owner_name + ) + if duplicate is not None and duplicate.id != customer_id: + raise BadRequestError("该公司名称与负责人的客户已存在") + + customer.company_name = payload.company_name + customer.owner_name = payload.owner_name + customer.contact_person = payload.contact_person + customer.contact_phone = payload.contact_phone + customer.address = payload.address + customer.industry = payload.industry + customer.customer_type = payload.customer_type + # owner_personnel_id 刻意不赋值 —— 归属调整只走 /ownership + + try: + # 响应在 commit 之前构造(同 user_service 顶部说明) + out = _to_out(customer, owner_name) + session.commit() + except IntegrityError as exc: + # 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写 + # 的并发窗口会撞唯一键,兜底同 create_customer + session.rollback() + if _is_duplicate_entry(exc): + raise BadRequestError("该公司名称与负责人的客户已存在") from exc + raise + + logger.info("修改客户 | customer_id=%s | company=%s", customer_id, payload.company_name) + return out + + +def delete_customer( + session: Session, + customer_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """删除客户(物理删除):被业务引用 → 400 引导处理业务单据。 + + businesses.customer_id 为 RESTRICT 外键,引用检查必须先于删除—— + 否则并发窗口内的 FK 冲突会以 500 暴露(T4 时该检查占位、业务表就位 + 后由本处补齐,留痕表是无外键固化不参与拦截)。 + + :raises NotFoundError: 客户不存在 + :raises BadRequestError: 客户已被业务单据引用 + """ + customer = customer_repository.get_by_id(session, customer_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_CUSTOMER, + action=ACTION_DELETE, + summary=f"删除客户 {customer.company_name if customer else f'#{customer_id}'}", + ), + meta=meta, + ): + if customer is None: + raise NotFoundError("客户不存在") + if customer_repository.count_business_refs(session, customer_id): + raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据") + + company_name = customer.company_name # 固化:对象随删除失效 + try: + customer_repository.delete(session, customer) + session.commit() + except IntegrityError as exc: + # 先查后删的并发窗口:新业务挂上了该客户,FK RESTRICT 兜底为 400 + session.rollback() + raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据") from exc + logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name) + + +def change_ownership( + session: Session, + customer_id: int, + payload: CustomerOwnershipIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> CustomerOut: + """归属变更:同一事务内「改主表归属 + 插留痕」,一次 commit。 + + 裁决口径为「只影响变更后核算,不回溯历史」(对照表 #19):历史核算结果 + 已由 M3 台账固化,留痕仅承担追溯职责,故无 effective_date 时点化设计。 + + :raises NotFoundError: 客户不存在 + :raises BadRequestError: 归属人员不存在或已离职 / 归属未变化 + """ + # 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在 + # 上下文内进行,失败同样要留下可追溯的记录 + row = customer_repository.get_with_owner_name(session, customer_id) + old_person = ( + personnel_repository.get_by_id(session, row[0].owner_personnel_id) + if row is not None and row[0].owner_personnel_id is not None + else None + ) + new_person = ( + personnel_repository.get_by_id(session, payload.new_owner_personnel_id) + if payload.new_owner_personnel_id is not None + else None + ) + old_label = old_person.personnel_name if old_person else "无" + new_label = new_person.personnel_name if new_person else "无" + customer_name = row[0].company_name if row else f"#{customer_id}" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_CUSTOMER, + action=ACTION_UPDATE, + summary=f"调整客户归属 {customer_name}:{old_label} → {new_label}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("客户不存在") + customer, _ = row + + if payload.new_owner_personnel_id is not None: + _ensure_owner_personnel(session, payload.new_owner_personnel_id) + old_owner_id = customer.owner_personnel_id + if old_owner_id == payload.new_owner_personnel_id: + raise BadRequestError("归属未变化") + + # 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring) + customer.owner_personnel_id = payload.new_owner_personnel_id + customer_repository.add_ownership_log( + session, + OwnershipChangeLog( + owner_type=OWNER_TYPE_CUSTOMER, + customer_id=customer.id, + old_owner_personnel_id=old_owner_id, + new_owner_personnel_id=payload.new_owner_personnel_id, + change_reason=payload.reason, + operator_id=operator.id, + operator_name=operator.username, + ), + ) + # 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准 + out = _to_out(customer, new_person.personnel_name if new_person else None) + session.commit() + logger.info( + "调整客户归属 | customer_id=%s | old=%s | new=%s", + customer_id, + old_owner_id, + payload.new_owner_personnel_id, + ) + return out + + +def list_ownership_logs( + session: Session, + customer_id: int, + query: PageQuery, +) -> PageResult[OwnershipChangeLogOut]: + """某客户的归属变更留痕(分页,按变更时间倒序)。 + + :raises NotFoundError: 客户不存在 + """ + if customer_repository.get_by_id(session, customer_id) is None: + raise NotFoundError("客户不存在") + rows = customer_repository.list_ownership_logs( + session, + customer_id=customer_id, + offset=query.offset, + limit=query.page_size, + ) + total = customer_repository.count_ownership_logs(session, customer_id=customer_id) + return PageResult[OwnershipChangeLogOut]( + records=[_to_log_out(row) for row in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) diff --git a/backend/app/services/formula.py b/backend/app/services/formula.py new file mode 100644 index 0000000..fcecc8c --- /dev/null +++ b/backend/app/services/formula.py @@ -0,0 +1,163 @@ +"""计提公式纯函数(无 IO、无会话)。 + +契约(与 M3 核算、计算预览共用,口径见 2026-10-12 业务裁决): +- 参数值按**存储口径**传入(含单位:比例参数为 ‰ 数如 10、百分比参数为 % 数如 60) +- 计算出的中间比率(如毛利润率)为**纯比例**(Fraction,如 9/20) +- 金额用 Decimal、比率用 Fraction,全程禁 float;Fraction→Decimal 转换在 + 分母仅含 2/5 因子时精确,否则按 Decimal 上下文精度(28 位) +- 绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3,禁按"互为倒数"实现) +""" + +from datetime import date +from decimal import Decimal +from fractions import Fraction + + +def to_decimal(value: Fraction) -> Decimal: + """Fraction → Decimal。有限小数精确;循环小数按 Decimal 上下文精度(28 位)。""" + return Decimal(value.numerator) / Decimal(value.denominator) + + +# 模块内沿用私有名;公开别名供 service 层金额换算复用 +_to_decimal = to_decimal + + +def payment_days(ship_date: date, receipt_date: date) -> int: + """回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。""" + return (receipt_date - ship_date).days + 1 + + +def salesperson_rate( + days: int, + *, + start: Fraction, + slope: Fraction, + floor: Fraction, + boundary_days: int, +) -> Fraction: + """业务员计提比例(纯比例)。 + + start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。 + ≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限 + (对照表 #4:360 天=6‰,361 天起固定 4‰)。 + + 首日取整(澄清 #14 业务答复 2026-10-19):第 1 天**不递减**、直接取 + 起始值(需规图示「第 1 天 10‰」覆盖公式的 9.9889‰)——第 2 天起按公式 + 递减,360 天=6‰ 锚点与 361 天=4‰ 分界均不受影响。 + + 先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出 + 高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days + 的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。 + """ + days = max(days, 0) + if days > boundary_days: + return floor / 1000 + if days <= 1: + return start / 1000 + return (start - days * slope) / 1000 + + +def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction: + """毛利润率(纯比例):毛利润额 / 销售额(**不含运费**口径,对照表 #2)。 + + 销售额为 0 时返回 0(防除零;折算额随之为 0)。 + """ + if revenue == 0: + return Fraction(0) + return Fraction(profit) / Fraction(revenue) + + +def converted_profit(revenue: Decimal, rate: Fraction, threshold: Fraction) -> Decimal: + """折算毛利润额(化简式):销售额 × (毛利润率 − 阈值)。 + + rate 为纯比例(profit_rate 的输出);threshold 为 % 数(存储口径,如 30)。 + 化简式等价于定义式「毛利润额 / 毛利润率 × (毛利润率 − 阈值)」,且避免 + 毛利润率为 0 时除零(对照表 #12/#13)。毛利润率低于阈值时结果为负 —— + 业务确认负值正常计算、不归零。 + """ + return _to_decimal(Fraction(revenue) * (rate - threshold / 100)) + + +def performance_amount(base: Decimal, rate: Fraction) -> Decimal: + """绩效金额 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。""" + return _to_decimal(Fraction(base) * rate) + + +def apply_barter(amount: Decimal, discount: Fraction) -> Decimal: + """以物抵顶折算:应发绩效 × 折算比例(discount 为 % 数,默认 80,对照表 #24)。""" + return _to_decimal(Fraction(amount) * discount / 100) + + +def payment_weight(r: int) -> Fraction: + """支付权重 R×R(纯比例):R 为回款占销售额百分比向下取整(对照表 #22)。 + + 60 → 36/100;100 → 1(全额)。 + """ + return Fraction(r, 100) * Fraction(r, 100) + + +def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bool: + """起付判定:单项业务回款额达到销售额(不含运费)的 ratio%(对照表 #21)。""" + if revenue == 0: + return False + return Fraction(received) / Fraction(revenue) >= ratio / 100 + + +def clamp_weight_r(received: Decimal, revenue: Decimal) -> int: + """支付权重 R:回款占销售额百分比,封顶封底 [0, 100](对照表 #22)。 + + received 非负时 int() 即向下取整;min/max 同时是红字冲销(负数回款) + 接入时的方向防线。从计算预览的内联实现提取 —— ④ 核算与预览共用。 + """ + if revenue <= 0: + return 0 + return max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100))) + + +def monthly_salary(performance: Decimal, basic: Decimal | None, mode: str) -> Decimal | None: + """月工资分岗口径(对照表 #8,salary.basic.mode 按角色维度取值)。 + + - INCLUDE:绩效 + 基本工资(业务员默认——绩效之外照发基本工资) + - DEDUCT:绩效 − 基本工资(经理默认——基本工资从绩效分成中扣除) + - EXCLUDE:仅绩效,基本工资不参与 + basic 缺失时按 0 参与(INCLUDE/EXCLUDE 等价于纯绩效)。 + """ + basic_value = basic if basic is not None else Decimal("0") + if mode == "INCLUDE": + return performance + basic_value + if mode == "DEDUCT": + return performance - basic_value + if mode == "EXCLUDE": + return performance + raise ValueError(f"未知的月工资口径:{mode}") + + +def probation_annual_note(year_total: Decimal, threshold: Decimal) -> str | None: + """试用期年累计说明:超阈值部分年底一次性结算(对照表 #17)。 + + 未超阈值返回 None(无需说明);阈值按元展示(Decimal 直出)。 + """ + if year_total <= threshold: + return None + excess = (year_total - threshold).quantize(Decimal("0.01"), rounding="ROUND_HALF_UP") + return f"试用期绩效照算不发放;年累计超 {threshold} 元部分年底一次性结算(当前超 {excess} 元)" + + +def manager_team_share( + team_performance: Decimal, + company_performance: Decimal, + *, + team_pct: Fraction, + company_pct: Fraction, +) -> Decimal: + """经理绩效 = 本团队业务员绩效 × 12.5% + 全公司业务员绩效 × 5%(裁决 #6/#15/#16)。 + + **#12 策略点(待业务答复,2026-10-16 暂定口径)**:基数**不含**经理本人 + 名下经理个人户业务的业绩——该部分按经理个人户规则另行处理(单笔落台账 + rate=0/绩效 0,待答复后若需并入,只改调用方传入的基数集合)。 + 团队口径:manager_id=该经理且岗位非「营销经理」的业务员绩效行之和; + 全公司口径:全部业务员(非经理岗)绩效行之和。 + """ + return _to_decimal(Fraction(team_performance) * team_pct / 100) + _to_decimal( + Fraction(company_performance) * company_pct / 100 + ) diff --git a/backend/app/services/log_service.py b/backend/app/services/log_service.py new file mode 100644 index 0000000..3ff945f --- /dev/null +++ b/backend/app/services/log_service.py @@ -0,0 +1,82 @@ +"""日志查询编排。 + +操作日志与登录日志分别提供分页查询(两表字段与筛选项不同),操作日志另有 +单条详情(列表为性能排除的 `request_data` 走详情按需加载)。 +纯只读操作,不写审计 —— 读操作记日志只会淹没审计表。 +""" + +from sqlalchemy.orm import Session + +from app.core.exceptions import NotFoundError +from app.core.response import PageResult +from app.repositories import login_log_repository, operation_log_repository +from app.repositories.query_utils import LogFilters +from app.schemas.log import ( + LoginLogOut, + LoginLogQuery, + OperationLogDetailOut, + OperationLogOut, + OperationLogQuery, +) + + +def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]: + """分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。""" + filters = _filters_from(query) + logs = operation_log_repository.list_logs( + session, + offset=query.offset, + limit=query.page_size, + filters=filters, + module=query.module, + action=query.action, + ) + total = operation_log_repository.count_logs( + session, filters=filters, module=query.module, action=query.action + ) + return PageResult[OperationLogOut]( + records=[OperationLogOut.model_validate(log) for log in logs], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut: + """查询单条操作日志详情(含已脱敏的 request_data)。 + + :raises NotFoundError: 日志不存在 + """ + log = operation_log_repository.get_by_id(session, log_id) + if log is None: + raise NotFoundError("日志不存在") + return OperationLogDetailOut.model_validate(log) + + +def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]: + """分页查询登录日志。""" + filters = _filters_from(query) + logs = login_log_repository.list_logs( + session, + offset=query.offset, + limit=query.page_size, + filters=filters, + ip=query.ip, + ) + total = login_log_repository.count_logs(session, filters=filters, ip=query.ip) + return PageResult[LoginLogOut]( + records=[LoginLogOut.model_validate(log) for log in logs], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def _filters_from(query: OperationLogQuery | LoginLogQuery) -> LogFilters: + """两类 Query 的公共筛选字段 → LogFilters(口径与 repository 共用一个实现)。""" + return LogFilters( + username=query.username, + success=query.success, + start_time=query.start_time, + end_time=query.end_time, + ) diff --git a/backend/app/services/manager_share.py b/backend/app/services/manager_share.py new file mode 100644 index 0000000..0a24794 --- /dev/null +++ b/backend/app/services/manager_share.py @@ -0,0 +1,118 @@ +"""经理分成共享口径(月结聚合与报表部门汇总的唯一实现)。 + +从 settlement_service 迁出(行为零变化):经理 12.5%/5% 的取参、团队 +分桶、公司基数计算,月结生成与部门汇总报表都要用——抽为单一实现后 +「报表汇总 == 月结聚合行」不靠两处代码各自对齐,而是同源函数的直接 +结果。分成金额仍走 formula.manager_team_share 纯函数。 +""" + +from calendar import monthrange +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from fractions import Fraction + +from sqlalchemy.orm import Session + +from app.models import Personnel + + +@dataclass(frozen=True, slots=True) +class ManagerShareParams: + """经理分成取参与参数原文(同一次计算共用一份,随月月末时点化)。""" + + month: str + param_date: date + team_text: str + company_text: str + team_pct: Fraction + company_pct: Fraction + include_manager_personal: bool + + +def month_end(month: str) -> date: + """YYYY-MM → 所属月月末(月结与报表取参锚点)。 + + 月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today() + 会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」 + 红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有 + 更细的业务日期,取该月最后可确定的生效时点。 + """ + year, mon = int(month[:4]), int(month[5:7]) + return date(year, mon, monthrange(year, mon)[1]) + + +def manager_share_params(session: Session, month: str) -> ManagerShareParams: + """解析经理分成参数(锚定所属月月末,取参与台账行口径同源)。 + + 参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5, + manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)。 + #12 开关(manager.personal.included)与分成比例同日取参,答复后调整可追溯。 + """ + from app.services.param_service import parse_fraction, resolve_snapshot + + param_date = month_end(month) + snapshot = resolve_snapshot(session, param_date) + team_text = snapshot.get("manager.share.team") + company_text = snapshot.get("manager.share.company") + return ManagerShareParams( + month=month, + param_date=param_date, + team_text=team_text, + company_text=company_text, + team_pct=parse_fraction(team_text), + company_pct=parse_fraction(company_text), + include_manager_personal=snapshot.get("manager.personal.included") == "1", + ) + + +def add_team_performance(managers: dict[int, dict], manager_id: int, row) -> None: + """把一行业绩并入经理的团队桶(首行固化业务锚点,成员名去重收集)。""" + bucket = managers.setdefault( + manager_id, + { + "performance": Decimal("0"), + "business_id": row.business_id, + "business_no": row.business_no, + "names": set(), + }, + ) + bucket["performance"] += row.performance_amount or Decimal("0") + bucket["names"].add(row.personnel_name) + + +def collect_team_buckets( + session: Session, ledger_rows: list, include_manager_personal: bool +) -> dict[int, dict]: + """按经理采集团队业绩。 + + 业务员行经 manager_id 上溯到其经理;经理个人户行(#12 开关计入时) + 归属经理本人——行 personnel_id 即经理,不走上溯(经理没有上级, + 按上溯会被跳过漏计)。 + """ + managers: dict[int, dict] = {} + for row in ledger_rows: + if row.customer_type == "manager_personal": + if include_manager_personal: + add_team_performance(managers, row.personnel_id, row) + continue # 默认不计入聚合基数(#12 参数化) + person = session.get(Personnel, row.personnel_id) + manager_id = person.manager_id if person else None + if manager_id is not None: + add_team_performance(managers, manager_id, row) + return managers + + +def company_base_of(ledger_rows: list, include_manager_personal: bool) -> Decimal: + """全公司基数(5% 的底):默认排除经理个人户行(#12 开关计入时不过滤)。 + + 口径逐行固定,调用方在循环外算一次。 + """ + return sum( + ( + r.performance_amount or Decimal("0") + for r in ledger_rows + if include_manager_personal or r.customer_type != "manager_personal" + ), + Decimal("0"), + ) diff --git a/backend/app/services/no_generator.py b/backend/app/services/no_generator.py new file mode 100644 index 0000000..48f0697 --- /dev/null +++ b/backend/app/services/no_generator.py @@ -0,0 +1,49 @@ +"""单据编号生成器(业务 YW / 发货 SH / 回款 HK 共用)。 + +规则:``{前缀}{YYYYMMDD}-{当日4位序号}``,序号按自然日重置。 +并发安全:``SELECT MAX(no) ... FOR UPDATE`` 锁住当日序号区间 —— 后到的 +事务会等待先到者提交后再读到新 MAX,序号单调不重;残余竞态(锁等待超时 +等极端场景)由服务层捕唯一键冲突(1062)重试一次兜底。 +""" + +from datetime import datetime + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + + +def today_prefix(prefix: str) -> str: + """当日编号前缀(如 YW20261013-),LIKE 匹配与拼号共用同一来源防漂移。""" + return f"{prefix}{datetime.now().strftime('%Y%m%d')}-" + + +def next_no( + session: Session, + *, + model: type, + column_name: str, + prefix: str, + seq_width: int = 4, +) -> str: + """生成下一个编号。 + + :param model: 编号所在的 ORM 模型(businesses/shipments/receipts) + :param column_name: 编号列名(business_no/shipment_no/receipt_batch_no) + :param prefix: 单据前缀(NO_PREFIX_* 常量) + :param seq_width: 序号位数(默认 4);超出宽度的序号按实际位数输出, + VARCHAR(32) 余量充足、唯一性不受影响 + """ + like_prefix = today_prefix(prefix) + column = getattr(model, column_name) + # 排序按「长度优先、同级字典序」:同前缀下序号位数增多(9999→10000)后 + # 纯字典序会反转("-10000" < "-9999",'1'<'9'),seq 将恒重算出同一号码、 + # 每笔建单都撞唯一键 —— 长度大即数值大,同级同长时字典序与数值序一致 + max_no = session.execute( + select(column) + .where(column.like(f"{like_prefix}%")) + .order_by(func.length(column).desc(), column.desc()) + .limit(1) + .with_for_update() + ).scalar() + seq = int(max_no.rsplit("-", 1)[1]) + 1 if max_no else 1 + return f"{like_prefix}{seq:0{seq_width}d}" diff --git a/backend/app/services/param_registry.py b/backend/app/services/param_registry.py new file mode 100644 index 0000000..48305cc --- /dev/null +++ b/backend/app/services/param_registry.py @@ -0,0 +1,362 @@ +"""计提参数目录注册表。 + +注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义 +一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。 +值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。 +""" + +from dataclasses import dataclass +from fractions import Fraction + +from app.core.exceptions import BadRequestError + + +@dataclass(frozen=True, slots=True) +class ParamSpec: + """单个参数的元数据(与 param_definitions 表字段一一对应)。""" + + code: str + name: str + group: str + value_type: str + unit: str | None + dimension_type: str + options: tuple[str, ...] | None + default_value: str | None + value_min: str | None + value_max: str | None + description: str | None + sort_order: int + + +PARAM_REGISTRY: dict[str, ParamSpec] = { + spec.code: spec + for spec in [ + ParamSpec( + code="salesperson.rate.start", + name="业务员起始计提比例", + group="业务员个人户", + value_type="DECIMAL", + unit="‰", + dimension_type="BUSINESS_ROLE", + options=None, + default_value="10", + value_min="0", + value_max=None, + description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)", + sort_order=1, + ), + ParamSpec( + code="salesperson.rate.slope", + name="业务员日递减斜率", + group="业务员个人户", + value_type="DECIMAL", + unit="‰/天", + dimension_type="BUSINESS_ROLE", + options=None, + default_value="4/360", + value_min="0", + value_max=None, + description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储", + sort_order=2, + ), + ParamSpec( + code="salesperson.rate.floor", + name="业务员计提下限", + group="业务员个人户", + value_type="DECIMAL", + unit="‰", + dimension_type="BUSINESS_ROLE", + options=None, + default_value="4", + value_min="0", + value_max=None, + description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)", + sort_order=3, + ), + ParamSpec( + code="salesperson.days.boundary", + name="线性递减分界天数", + group="业务员个人户", + value_type="INT", + unit="天", + dimension_type="BUSINESS_ROLE", + options=None, + default_value="360", + value_min="0", + value_max=None, + description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限", + sort_order=4, + ), + ParamSpec( + code="manager.share.team", + name="经理本团队分成比例", + group="经理分成", + value_type="DECIMAL", + unit="%", + dimension_type="BUSINESS_ROLE", + options=None, + default_value="12.5", + value_min="0", + value_max="100", + description="∑本人带领业务员绩效 × 此比例(限本团队)", + sort_order=5, + ), + ParamSpec( + code="manager.share.company", + name="经理全公司分成比例", + group="经理分成", + value_type="DECIMAL", + unit="%", + dimension_type="BUSINESS_ROLE", + options=None, + default_value="5", + value_min="0", + value_max="100", + description="∑全部业务员绩效 × 此比例(全公司)", + sort_order=6, + ), + ParamSpec( + code="enterprise.rate", + name="企业户计提比例", + group="企业户", + value_type="DECIMAL", + unit="‰", + dimension_type="PRODUCT_CATEGORY", + options=None, + default_value="3", + value_min="0", + value_max=None, + description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰", + sort_order=7, + ), + ParamSpec( + code="info.rate", + name="信息员计提比例", + group="信息员", + value_type="DECIMAL", + unit="‰", + dimension_type="BUSINESS_ROLE", + options=None, + default_value="1", + value_min="0", + value_max=None, + description="信息员绩效 = 本人销售额 × 此比例", + sort_order=8, + ), + ParamSpec( + code="prepay.rate", + name="预付款计提比例", + group="支付环节", + value_type="DECIMAL", + unit="‰", + dimension_type="NONE", + options=None, + default_value="10", + value_min="0", + value_max=None, + description="预付款统一按此比例计提", + sort_order=9, + ), + ParamSpec( + code="barter.discount", + name="以物抵顶折算比例", + group="支付环节", + value_type="DECIMAL", + unit="%", + dimension_type="NONE", + options=None, + default_value="80", + value_min="0", + value_max="100", + description="以物抵顶回款绩效按应发绩效的此比例折算", + sort_order=10, + ), + ParamSpec( + code="payment.start.ratio", + name="支付起付比例", + group="支付环节", + value_type="DECIMAL", + unit="%", + dimension_type="NONE", + options=None, + default_value="60", + value_min="0", + value_max="100", + description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计", + sort_order=11, + ), + ParamSpec( + code="payment.weight.mode", + name="支付权重应用方式", + group="支付环节", + value_type="ENUM", + unit=None, + dimension_type="NONE", + options=("FULL", "BALANCE"), + default_value="FULL", + value_min=None, + value_max=None, + description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)", + sort_order=12, + ), + ParamSpec( + code="product.threshold", + name="折算毛利润额阈值", + group="产品阈值", + value_type="DECIMAL", + unit="%", + dimension_type="PRODUCT_CATEGORY", + options=None, + default_value="30", + value_min="0", + value_max="100", + description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%", + sort_order=13, + ), + ParamSpec( + code="salary.basic.mode", + name="月工资基本工资口径", + group="月工资口径", + value_type="ENUM", + unit=None, + dimension_type="BUSINESS_ROLE", + options=("INCLUDE", "DEDUCT", "EXCLUDE"), + default_value="INCLUDE", + value_min=None, + value_max=None, + description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计", + sort_order=14, + ), + ParamSpec( + code="probation.annual.threshold", + name="试用期年累计结算阈值", + group="月工资口径", + value_type="DECIMAL", + unit="元", + dimension_type="NONE", + options=None, + default_value="18000", + value_min="0", + value_max=None, + description="试用期年累计绩效超过此值部分年底一次性结算", + sort_order=15, + ), + ParamSpec( + code="approval.secondary_allocation.enabled", + name="二次分配审批开关", + group="审批开关", + value_type="BOOL", + unit=None, + dimension_type="NONE", + options=None, + default_value="1", + value_min=None, + value_max=None, + description=( + "0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);" + "1=提交后进入财务复核→领导审核审批流。" + "默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭" + ), + sort_order=17, + ), + ParamSpec( + code="approval.monthly_settlement.enabled", + name="月度结算审批开关", + group="审批开关", + value_type="BOOL", + unit=None, + dimension_type="NONE", + options=None, + default_value="1", + value_min=None, + value_max=None, + description=( + "月度结算审批链开关(④ 月结提交时消费):" + "0=结算生成即生效,1=走财务复核→领导审核审批流。" + "默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭" + ), + sort_order=18, + ), + ParamSpec( + code="approval.adjustment.enabled", + name="调整单审批开关", + group="审批开关", + value_type="BOOL", + unit=None, + dimension_type="NONE", + options=None, + default_value="0", + value_min=None, + value_max=None, + description=( + "0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。" + "调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链," + "默认值业务未明确,暂按直接生效,业务可开启)" + ), + sort_order=19, + ), + ParamSpec( + code="manager.personal.included", + name="经理个人户计入聚合基数", + group="经理分成", + value_type="BOOL", + unit=None, + dimension_type="NONE", + options=None, + default_value="0", + value_min=None, + value_max=None, + description=( + "经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 " + "业务答复 2026-10-19:作为参数配置、手动调整是否纳入):" + "0=不计入(默认,暂口径) 1=计入" + ), + sort_order=20, + ), + ] +} + + +def get_spec(code: str) -> ParamSpec: + """取参数元数据。未注册编码属代码缺陷(500),不是用户错误。""" + spec = PARAM_REGISTRY.get(code) + if spec is None: + raise ValueError(f"参数编码未注册:{code}") + return spec + + +def parse_fraction(text: str) -> Fraction: + """Fraction 精确解析("4/360" → 1/90,不经 float)。非法输入抛 400。""" + try: + return Fraction(text.strip()) + except (ValueError, ZeroDivisionError) as exc: + raise BadRequestError(f"参数值无法解析为数值:{text}") from exc + + +def validate_and_normalize(spec: ParamSpec, raw: str) -> str: + """按元数据校验并规范化参数值,返回可入库字符串。 + + DECIMAL:Fraction 解析 + 取值域校验,分数式原样保留("4/360" 不截断); + INT:必须整数;BOOL:仅 0/1;ENUM:必须命中 options;TEXT:原样返回。 + """ + value = raw.strip() + if spec.value_type in {"DECIMAL", "INT"}: + fraction = parse_fraction(value) + if spec.value_type == "INT" and fraction.denominator != 1: + raise BadRequestError(f"参数 {spec.code} 必须为整数:{raw}") + if spec.value_min is not None and fraction < parse_fraction(spec.value_min): + raise BadRequestError(f"参数 {spec.code} 低于取值下限 {spec.value_min}:{raw}") + if spec.value_max is not None and fraction > parse_fraction(spec.value_max): + raise BadRequestError(f"参数 {spec.code} 高于取值上限 {spec.value_max}:{raw}") + return value + if spec.value_type == "BOOL": + if value not in {"0", "1"}: + raise BadRequestError(f"参数 {spec.code} 仅接受 0/1:{raw}") + return value + if spec.value_type == "ENUM": + if not spec.options or value not in spec.options: + allowed = "/".join(spec.options or ()) + raise BadRequestError(f"参数 {spec.code} 取值须为 {allowed}:{raw}") + return value + return value diff --git a/backend/app/services/param_service.py b/backend/app/services/param_service.py new file mode 100644 index 0000000..5cdcabc --- /dev/null +++ b/backend/app/services/param_service.py @@ -0,0 +1,688 @@ +"""计提参数服务:时点化取值、版本管理(变更/撤回/历史/对比)、计算预览与导出。 + +ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日期(回款到账日) +冻结当时口径,禁读「当前值」计算历史数据(算法红线)。 +变更批次 = 版本(一次提交一批次);条目逐项留痕(旧值/新值),append-only。 +""" + +import logging +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from fractions import Fraction +from io import BytesIO + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_DELETE, + ACTION_UPDATE, + MODULE_PARAM, + NO_PREFIX_PARAM, + PRODUCT_CATEGORIES, +) +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageResult +from app.models import ParamChangeBatch, ParamDefinition, ParamValue, User +from app.repositories import business_role_repository, param_repository +from app.schemas.param import ( + ParamBatchDetailOut, + ParamBatchItemOut, + ParamBatchOut, + ParamBatchQuery, + ParamCatalogItemOut, + ParamChangeBatchOut, + ParamChangeIn, + ParamChangeItemIn, + ParamDiffOut, + ParamDimensionValueOut, + ParamHistoryOut, + ParamHistoryQuery, + ParamPreviewIn, + ParamPreviewOut, + ParamUsedOut, +) +from app.services import audit_service, formula +from app.services.audit_service import AuditRecord, RequestMeta +from app.services.formula import to_decimal +from app.services.no_generator import next_no +from app.services.param_registry import get_spec, parse_fraction, validate_and_normalize + +logger = logging.getLogger(__name__) + + +# ==================== 快照 ==================== + + +@dataclass(frozen=True, slots=True) +class ParamSnapshot: + """某业务日期下的参数快照(冻结不可变,核算期间保持一致)。 + + 覆盖链:角色维度 > 产品类别维度 > 默认行('');均无匹配时回退注册表默认值。 + """ + + biz_date: date + _values: dict[str, dict[str, str]] + _effective: dict[tuple[str, str], date] + + @classmethod + def from_rows( + cls, biz_date: date, rows: Iterable[tuple[str, str, str, date]] + ) -> "ParamSnapshot": + """由 (param_code, dimension_value, value_text, effective_date) 行构造。 + + rows 须按仓储输出顺序(同一 (code, dim) 的最新生效值在前)——以先出现者为准。 + """ + values: dict[str, dict[str, str]] = {} + effective: dict[tuple[str, str], date] = {} + for code, dimension, value_text, effective_date in rows: + if (code, dimension) in effective: + continue + effective[(code, dimension)] = effective_date + values.setdefault(code, {}).setdefault(dimension, value_text) + return cls(biz_date=biz_date, _values=values, _effective=effective) + + def get( + self, code: str, *, role_code: str | None = None, product_category: str | None = None + ) -> str: + """取参数原始值(覆盖链解析)。无值且无注册表默认值时抛 400。""" + candidates = self._values.get(code) + if candidates: + for dimension in (role_code, product_category, ""): + if dimension is not None and dimension in candidates: + return candidates[dimension] + spec = get_spec(code) # 未注册编码 → ValueError(代码缺陷) + if spec.default_value is None: + raise BadRequestError(f"参数 {code} 无生效值且无默认值,请先在系统设置中维护") + return spec.default_value + + def effective_date_of( + self, code: str, *, role_code: str | None = None, product_category: str | None = None + ) -> date | None: + """取参数当前生效值的生效日期(回退注册表默认值时为 None)。""" + candidates = self._values.get(code) + if candidates: + for dimension in (role_code, product_category, ""): + if dimension is not None and (code, dimension) in self._effective: + return self._effective[(code, dimension)] + return None + + def iter_items(self) -> Iterable[tuple[str, str, str]]: + """遍历快照全部 (param_code, dimension_value, value_text),供对比。""" + for code, dims in self._values.items(): + for dimension, value_text in dims.items(): + yield code, dimension, value_text + + def fraction( + self, code: str, *, role_code: str | None = None, product_category: str | None = None + ) -> Fraction: + """取参数值并精确解析为 Fraction("4/360" → 1/90,不经 float)。""" + return parse_fraction( + self.get(code, role_code=role_code, product_category=product_category) + ) + + def integer( + self, code: str, *, role_code: str | None = None, product_category: str | None = None + ) -> int: + """取参数值为整数。配置成非整数属数据缺陷(写入经校验,不应发生)。""" + value = self.fraction(code, role_code=role_code, product_category=product_category) + if value.denominator != 1: + raise ValueError(f"参数 {code} 配置为非整数:{value}") + return int(value) + + def boolean( + self, code: str, *, role_code: str | None = None, product_category: str | None = None + ) -> bool: + """取布尔参数(0/1)。""" + return self.get(code, role_code=role_code, product_category=product_category) == "1" + + def text( + self, code: str, *, role_code: str | None = None, product_category: str | None = None + ) -> str: + """取参数原文(枚举码/文本)。""" + return self.get(code, role_code=role_code, product_category=product_category) + + +def resolve_snapshot(session: Session, biz_date: date) -> ParamSnapshot: + """按业务日期解析全量参数快照(M3 核算与计算预览共用入口)。""" + return ParamSnapshot.from_rows(biz_date, param_repository.resolve_values(session, biz_date)) + + +# ==================== 目录 ==================== + + +def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCatalogItemOut]: + """参数目录:全部启用定义 + 各维度截至 asOf(默认今日)的生效值。""" + biz_date = as_of or date.today() + resolved = param_repository.resolve_values(session, biz_date) + by_code: dict[str, list[ParamDimensionValueOut]] = {} + for code, dimension, value_text, effective_date in resolved: + by_code.setdefault(code, []).append( + ParamDimensionValueOut( + dimension_value=dimension, value_text=value_text, effective_date=effective_date + ) + ) + items: list[ParamCatalogItemOut] = [] + for definition in param_repository.list_definitions(session, status=1): + spec = get_spec(definition.param_code) + items.append( + ParamCatalogItemOut( + param_code=definition.param_code, + param_name=definition.param_name, + param_group=definition.param_group, + value_type=definition.value_type, + unit=definition.unit, + dimension_type=definition.dimension_type, + options=list(spec.options or ()), + default_value=definition.default_value, + value_min=definition.value_min, + value_max=definition.value_max, + description=definition.description, + values=by_code.get(definition.param_code, []), + ) + ) + return items + + +# ==================== 变更与版本管理 ==================== + + +def _normalize_items( + session: Session, items: list[ParamChangeItemIn] +) -> list[tuple[ParamDefinition, str, str]]: + """全量校验条目并归一为 (定义, 规范维度值, 规范值),任何一项不合法整批拒绝。 + + 定义与岗位各一条 IN 批量查询预载(逐条查询在批次扩大时是写侧 N+1); + 岗位预载收集全部非空维度值 —— 集合可能略大于实际所需,但省去按维度 + 类型先分类的第二轮遍历,无害多取由校验逻辑自行过滤。 + + :raises BadRequestError: 参数未定义 / 维度不合法 / 值校验失败 / 同批次重复条目 + """ + codes = list({item.param_code for item in items}) + definitions = param_repository.list_definitions_by_codes(session, codes) + dimension_values = list({item.dimension_value for item in items if item.dimension_value}) + # 预载字典键归一为 casefold:单条版 get_by_code 走 SQL ci 校对(大小写 + # 变形可命中),批量版改字典查找后须自行等价归一,否则变形大小写会 + # 被误判「不是已存在的岗位」;岗位编码均为 ascii 小写蛇形,casefold 足够 + roles = { + code.casefold(): role + for code, role in business_role_repository.list_by_codes(session, dimension_values).items() + } + normalized: list[tuple[ParamDefinition, str, str]] = [] + seen: set[tuple[str, str]] = set() + for item in items: + definition = definitions.get(item.param_code) + if definition is None: + # 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移 + raise BadRequestError(f"参数 {item.param_code} 未在系统中定义") + # ci 校对规则下大小写变形也能命中库,此后一律用库里的规范值: + # 注册表 get_spec 与快照字典均按精确键匹配,原始大小写入库会导致 + # 取值链静默不命中(按错误参数算钱且无报错) + spec = get_spec(definition.param_code) + dimension = item.dimension_value + if spec.dimension_type == "NONE" and dimension != "": + raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空") + if spec.dimension_type == "BUSINESS_ROLE" and dimension: + role = roles.get(dimension.casefold()) + if role is None: + raise BadRequestError( + f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位" + ) + dimension = role.role_code + if ( + spec.dimension_type == "PRODUCT_CATEGORY" + and dimension + and dimension not in PRODUCT_CATEGORIES + ): + raise BadRequestError( + f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别" + ) + value = validate_and_normalize(spec, item.value) + key = (item.param_code, dimension) + if key in seen: + raise BadRequestError( + f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})" + ) + seen.add(key) + normalized.append((definition, dimension, value)) + return normalized + + +def submit_change( + session: Session, + payload: ParamChangeIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> ParamChangeBatchOut: + """提交变更批次:全量校验 → 建批次 → 逐项写条目(旧值取提交时生效值)。 + + :raises BadRequestError: 参数未定义 / 维度不合法 / 值校验失败 / 生效日期早于今日 / + 同批次重复条目 + """ + effective = payload.effective_date if payload.change_type == 2 else date.today() + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PARAM, + action=ACTION_UPDATE, + summary=( + f"提交计提参数变更 {len(payload.items)} 项," + f"{'立即生效' if payload.change_type == 1 else f'{effective} 起生效'}" + ), + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if payload.change_type == 2 and effective < date.today(): + raise BadRequestError("指定生效日期不得早于今日") + + # 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据) + normalized = _normalize_items(session, payload.items) + + try: + batch = param_repository.create_batch( + session, + ParamChangeBatch( + # 版本号与三单据同口径:MAX FOR UPDATE 锁当日序号区间; + # 残余并发冲突由下方 IntegrityError 分支翻译为可重试 400 + version_no=next_no( + session, + model=ParamChangeBatch, + column_name="version_no", + prefix=NO_PREFIX_PARAM, + seq_width=2, + ), + effective_date=effective, + change_type=payload.change_type, + remark=payload.remark, + operator_id=operator.id, + operator_name=operator.username, + item_count=len(normalized), + ), + ) + # 旧值留痕取「本条目生效日时点」的值:存在多个未来生效批次时, + # 每条记录的是它实际顶替的那一版(而非提交时刻的当前值) + for definition, dimension, value in normalized: + old = param_repository.latest_value_before( + session, definition.id, dimension, effective + ) + param_repository.create_value( + session, + ParamValue( + param_id=definition.id, + dimension_value=dimension, + effective_date=effective, + value_text=value, + old_value_text=old.value_text if old else None, + batch_id=batch.id, + ), + ) + except IntegrityError as exc: + # version_no 唯一键的并发冲突:回滚并翻译为可重试的 400 + session.rollback() + raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc + session.commit() + logger.info("提交参数变更 | version_no=%s | items=%s", batch.version_no, batch.item_count) + return ParamChangeBatchOut( + id=batch.id, + version_no=batch.version_no, + effective_date=batch.effective_date, + change_type=batch.change_type, + remark=batch.remark, + operator_name=batch.operator_name, + item_count=batch.item_count, + create_time=batch.create_time, + ) + + +def withdraw_batch( + session: Session, batch_id: int, *, operator: User, meta: RequestMeta | None = None +) -> None: + """撤回未生效批次。已生效禁撤;条目删除后解析自然回退到更早版本。 + + :raises NotFoundError: 批次不存在 + :raises BadRequestError: 批次已生效 + """ + batch = param_repository.get_batch_by_id(session, batch_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PARAM, + action=ACTION_DELETE, + summary=f"撤回参数变更批次 {batch.version_no if batch else f'#{batch_id}'}", + ), + meta=meta, + ): + if batch is None: + raise NotFoundError("变更批次不存在") + if batch.effective_date <= date.today(): + raise BadRequestError("批次已生效,不可撤回") + + param_repository.delete_values_by_batch(session, batch.id) + param_repository.delete_batch(session, batch) + session.commit() + logger.info("撤回参数变更批次 | version_no=%s", batch.version_no) + + +def get_history( + session: Session, param_code: str, query: ParamHistoryQuery +) -> PageResult[ParamHistoryOut]: + """单参数版本历史(分页,最新在前)。 + + :raises NotFoundError: 参数不存在 + """ + definition = param_repository.get_definition_by_code(session, param_code) + if definition is None: + raise NotFoundError("参数不存在") + rows = param_repository.list_history( + session, + definition.id, + offset=query.offset, + limit=query.page_size, + dimension_value=query.dimension_value, + ) + total = param_repository.count_history( + session, definition.id, dimension_value=query.dimension_value + ) + return PageResult[ParamHistoryOut]( + records=[ + ParamHistoryOut( + version_no=batch.version_no, + dimension_value=value.dimension_value, + old_value_text=value.old_value_text, + value_text=value.value_text, + effective_date=value.effective_date, + operator_name=batch.operator_name, + create_time=value.create_time, + ) + for value, batch in rows + ], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]: + """变更批次分页(按创建时间倒序)。""" + batches = param_repository.list_batches( + session, + offset=query.offset, + limit=query.page_size, + start_date=query.start_date, + end_date=query.end_date, + ) + total = param_repository.count_batches( + session, start_date=query.start_date, end_date=query.end_date + ) + return PageResult[ParamBatchOut]( + records=[ + ParamBatchOut( + id=batch.id, + version_no=batch.version_no, + effective_date=batch.effective_date, + change_type=batch.change_type, + remark=batch.remark, + operator_name=batch.operator_name, + item_count=batch.item_count, + create_time=batch.create_time, + ) + for batch in batches + ], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_batch_detail(session: Session, batch_id: int) -> ParamBatchDetailOut: + """批次明细:批次头 + 逐项旧值 → 新值。 + + :raises NotFoundError: 批次不存在 + """ + batch = param_repository.get_batch_by_id(session, batch_id) + if batch is None: + raise NotFoundError("变更批次不存在") + items = [ + ParamBatchItemOut( + param_code=definition.param_code, + param_name=definition.param_name, + dimension_value=value.dimension_value, + old_value_text=value.old_value_text, + value_text=value.value_text, + effective_date=value.effective_date, + ) + for value, definition in param_repository.list_values_by_batch(session, batch_id) + ] + return ParamBatchDetailOut( + id=batch.id, + version_no=batch.version_no, + effective_date=batch.effective_date, + change_type=batch.change_type, + remark=batch.remark, + operator_name=batch.operator_name, + item_count=batch.item_count, + create_time=batch.create_time, + items=items, + ) + + +# ==================== 对比与预览 ==================== + + +def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDiffOut]: + """两日期快照逐项对比(全量参数,changed 标记变化)。 + + :raises BadRequestError: 起始日期晚于截止日期 + """ + if from_date > to_date: + raise BadRequestError("起始日期不得晚于截止日期") + old_items = { + (code, dimension): value + for code, dimension, value in resolve_snapshot(session, from_date).iter_items() + } + new_items = { + (code, dimension): value + for code, dimension, value in resolve_snapshot(session, to_date).iter_items() + } + out: list[ParamDiffOut] = [] + for code, dimension in sorted(set(old_items) | set(new_items)): + spec = get_spec(code) + old_value = old_items.get((code, dimension)) + new_value = new_items.get((code, dimension)) + out.append( + ParamDiffOut( + param_code=code, + param_name=spec.name, + param_group=spec.group, + dimension_value=dimension, + old_value=old_value, + new_value=new_value, + changed=old_value != new_value, + ) + ) + return out + + +def _preview_rate( + session: Session, + payload: ParamPreviewIn, + snapshot: ParamSnapshot, + days: int, + take, +) -> tuple[Fraction, str, Decimal | None]: + """按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。 + + 基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按 + 「本人销售额」计提),预览层直接采用并跳过折算与阈值。 + 聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。 + """ + if payload.customer_type == "enterprise": + # 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount + # 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线) + rate = take("enterprise.rate") / 1000 + return rate, "企业户固定计提比例", None + + role_code = payload.business_role + template = "TPL_SALESPERSON_DECAY" + if role_code: + role = business_role_repository.get_by_code(session, role_code) + if role is None: + raise BadRequestError(f"岗位 {role_code!r} 不存在") + template = role.calc_template + + if template == "TPL_MANAGER_SHARE": + raise BadRequestError( + "营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览" + ) + if template == "TPL_INFO_SALES_FIXED": + # ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍) + rate = take("info.rate") / 1000 + # 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数 + return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue) + if template == "TPL_SALESPERSON_DECAY": + start = take("salesperson.rate.start") + slope = take("salesperson.rate.slope") + floor = take("salesperson.rate.floor") + boundary = int(take("salesperson.days.boundary")) + rate = formula.salesperson_rate( + days, start=start, slope=slope, floor=floor, boundary_days=boundary + ) + return ( + rate, + f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)", + None, + ) + raise BadRequestError(f"公式模板 {template} 暂不支持预览") + + +def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut: + """计算预览:入参(发货/回款/金额/岗位)→ 公式纯函数 → 出参(含所用参数)。""" + # 参数按回款到账日取值(对照表 #11) + snapshot = resolve_snapshot(session, payload.receipt_date) + revenue = Decimal(payload.revenue) + profit = Decimal(payload.profit) + received = Decimal(payload.received_amount) + used: list[ParamUsedOut] = [] + + def take(code: str) -> Fraction: + raw = snapshot.get( + code, + role_code=payload.business_role, + product_category=payload.product_category, + ) + used.append( + ParamUsedOut( + param_code=code, + value_text=raw, + effective_date=snapshot.effective_date_of( + code, + role_code=payload.business_role, + product_category=payload.product_category, + ), + ) + ) + return parse_fraction(raw) + + if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES: + raise BadRequestError( + f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit" + ) + + days = formula.payment_days(payload.ship_date, payload.receipt_date) + rate, rate_text, base_override = _preview_rate(session, payload, snapshot, days, take) + + profit_rate = formula.profit_rate(profit, revenue) + if base_override is not None: + # 基数覆盖(信息员按本人销售额计提):不走折算与阈值 + converted = base_override + else: + threshold = take("product.threshold") + converted = formula.converted_profit(revenue, profit_rate, threshold) + performance = formula.performance_amount(converted, rate) + + barter_applied = payload.barter + after_barter: Decimal | None = None + if barter_applied: + discount = take("barter.discount") + after_barter = formula.apply_barter(performance, discount) + + start_ratio = take("payment.start.ratio") + start_ok = formula.start_payment_ok(received, revenue, start_ratio) + # R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现) + weight_r = formula.clamp_weight_r(received, revenue) + weight = formula.payment_weight(weight_r) + base_amount = after_barter if barter_applied else performance + payable = to_decimal(Fraction(base_amount) * weight) + + probation_note: str | None = None + if payload.probation: + annual = take("probation.annual.threshold") + probation_note = f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算" + + return ParamPreviewOut( + payment_days=days, + rate=str(rate), + rate_text=rate_text, + profit_rate=str(profit_rate), + converted_profit=str(converted), + performance_amount=str(performance), + barter_applied=barter_applied, + performance_after_barter=str(after_barter) if after_barter is not None else None, + start_payment_ok=start_ok, + payment_weight_r=weight_r, + payment_weight=str(weight), + payable_amount=str(payable), + probation_note=probation_note, + params_used=used, + ) + + +# ==================== 导出 ==================== + + +def export_changes( + session: Session, *, start_date: date | None = None, end_date: date | None = None +) -> bytes: + """变更记录导出 Excel(openpyxl)。列:版本号/变更人/变更时间/生效日期/参数/维度/旧值/新值。""" + from openpyxl import Workbook + + rows = param_repository.list_export_rows(session, start_date=start_date, end_date=end_date) + workbook = Workbook() + sheet = workbook.active + sheet.title = "参数变更记录" + sheet.append( + [ + "版本号", + "变更人", + "变更时间", + "生效日期", + "参数编码", + "参数名称", + "维度值", + "旧值", + "新值", + ] + ) + for batch, value, definition in rows: + sheet.append( + [ + batch.version_no, + batch.operator_name, + batch.create_time.strftime("%Y-%m-%d %H:%M:%S"), + value.effective_date.strftime("%Y-%m-%d"), + definition.param_code, + definition.param_name, + value.dimension_value, + value.old_value_text, + value.value_text, + ] + ) + buffer = BytesIO() + workbook.save(buffer) + return buffer.getvalue() diff --git a/backend/app/services/personnel_service.py b/backend/app/services/personnel_service.py new file mode 100644 index 0000000..c88df6f --- /dev/null +++ b/backend/app/services/personnel_service.py @@ -0,0 +1,438 @@ +"""营销人员管理业务编排(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。 + +业务规则(前端会做提示,**后端校验是唯一防线**): + +- 岗位必须存在且启用 —— 岗位决定计提公式模板,挂到停用/不存在的岗位 + 会让 M3 核算找不到模板 +- 直属经理不能是自己、目标须存在且在职 —— 自引用关系是 M3 经理 12.5% + 聚合的展开锚点,环引用或悬空引用会让聚合死循环/漏算 +- 删除保护:被团队成员(personnel.manager_id)或客户归属 + (customers.owner_personnel_id)引用的人员**禁删**,应改为离职 —— + 历史核算与客户归属都要能回溯到人,物理删除会留下悬空引用 +- 金额红线:salary_standard 以字符串进出、服务层转 Decimal,全程不经 float + +辐射区域与拓展计划为整表替换维护:先删、立即 flush、再插入 +(防御同一 flush 先 INSERT 后 DELETE 撞唯一键,见 personnel_repository)。 +""" + +import logging +from decimal import Decimal + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_UPDATE, + MODULE_PERSONNEL, +) +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageResult +from app.models import BusinessRole, Personnel, PersonnelPlan, PersonnelRegion, User +from app.models.personnel import PERSONNEL_STATUS_ACTIVE +from app.repositories import business_role_repository, personnel_repository +from app.schemas.personnel import ( + PersonnelCreateIn, + PersonnelDetailOut, + PersonnelOut, + PersonnelPlanOut, + PersonnelQuery, + PersonnelRegionOut, + PlanReplaceIn, + RegionReplaceIn, +) +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta + +logger = logging.getLogger(__name__) + +# 删除保护的统一文案:三种引用(团队成员/客户归属/业务归属)共享同一提示, +# 引导管理员走「离职」而非删除 +_DELETE_BLOCKED = "存在团队成员、客户归属或业务归属引用,请改为离职而非删除" + + +def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personnel: + """归属人员必须存在且在职,返回人员行(业务/客户两侧归属校验共用)。 + + 「不存在」与「已离职」合并为同一文案:人员 ID 是否有效不构成需要向 + 调用方区分的信息(同登录防枚举的思路);label 落文案(负责/协助/归属)。 + 收口单点:在职状态值取 models.personnel.PERSONNEL_STATUS_ACTIVE, + 此前 business/customer 两 service 各持常量与校验,仅靠注释维持同步。 + + :raises BadRequestError: {label}人员不存在或已离职 + """ + person = personnel_repository.get_by_id(session, personnel_id) + if person is None or person.status != PERSONNEL_STATUS_ACTIVE: + raise BadRequestError(f"{label}人员不存在或已离职") + return person + + +def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]: + """分页查询人员(含离职)。""" + rows = personnel_repository.list_personnel( + session, + offset=query.offset, + limit=query.page_size, + keyword=query.keyword, + status=query.status, + business_role_id=query.business_role_id, + ) + total = personnel_repository.count_personnel( + session, + keyword=query.keyword, + status=query.status, + business_role_id=query.business_role_id, + ) + return PageResult[PersonnelOut]( + records=[ + _to_out(personnel, role_name, manager_name) + for personnel, role_name, manager_name in rows + ], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_detail(session: Session, personnel_id: int) -> PersonnelDetailOut: + """人员详情(含区域与拓展计划,供编辑抽屉回显)。 + + :raises NotFoundError: 人员不存在 + """ + row = personnel_repository.get_with_names(session, personnel_id) + if row is None: + raise NotFoundError("人员不存在") + personnel, role_name, manager_name = row + return PersonnelDetailOut( + **_to_out(personnel, role_name, manager_name).model_dump(), + regions=[ + PersonnelRegionOut.model_validate(item) + for item in personnel_repository.list_regions(session, personnel_id) + ], + plans=[ + PersonnelPlanOut.model_validate(item) + for item in personnel_repository.list_plans(session, personnel_id) + ], + ) + + +def create_personnel( + session: Session, + payload: PersonnelCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> PersonnelOut: + """新增人员,默认在职。 + + :raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法 + """ + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_CREATE, + summary=f"新增人员 {payload.personnel_name}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + # 引用校验放在审计上下文内:失败同样要留痕(与 update_personnel 口径 + # 一致)——「谁在何时试图把人挂到停用岗位/离职经理上」是审计对象, + # 校验留在上下文外会让这类失败操作无据可查 + role, manager_name = _validate_refs( + session, payload.business_role_id, payload.manager_id, None + ) + personnel = Personnel( + personnel_name=payload.personnel_name, + business_role_id=payload.business_role_id, + manager_id=payload.manager_id, + phone=payload.phone, + hire_date=payload.hire_date, + is_probation=payload.is_probation, + salary_standard=_parse_salary(payload.salary_standard), + status=1, + remark=payload.remark, + ) + try: + personnel_repository.create(session, personnel) + out = _to_out(personnel, role.role_name, manager_name) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:岗位/经理行被并发删除时 FK RESTRICT 令写入失败 + session.rollback() + raise BadRequestError("岗位或直属经理引用已失效,请刷新后重试") from exc + + logger.info("新增人员 | personnel_id=%s | name=%s", personnel.id, payload.personnel_name) + return out + + +def update_personnel( + session: Session, + personnel_id: int, + payload: PersonnelCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> PersonnelOut: + """编辑人员(全量替换,不含状态)。 + + 经理自引用校验以**被编辑人员的 id** 为准:把经理改成自己是环引用。 + + :raises NotFoundError: 人员不存在 + :raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法 + """ + row = personnel_repository.get_with_names(session, personnel_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_UPDATE, + summary=f"修改人员 {row[0].personnel_name if row else f'#{personnel_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("人员不存在") + personnel = row[0] + role, manager_name = _validate_refs( + session, payload.business_role_id, payload.manager_id, personnel.id + ) + personnel.personnel_name = payload.personnel_name + personnel.business_role_id = payload.business_role_id + personnel.manager_id = payload.manager_id + personnel.phone = payload.phone + personnel.hire_date = payload.hire_date + personnel.is_probation = payload.is_probation + personnel.salary_standard = _parse_salary(payload.salary_standard) + personnel.remark = payload.remark + # 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明) + out = _to_out(personnel, role.role_name, manager_name) + session.commit() + logger.info("修改人员 | personnel_id=%s", personnel_id) + return out + + +def delete_personnel( + session: Session, + personnel_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """删除人员。有团队成员或客户归属引用时禁删,引导走「离职」。 + + 查询走 ``FOR UPDATE`` 锁行:校验与删除之间若发生并发引用,锁行使 + 并发外键写入在删除时失败(可见的 400)而非引用被静默悬空。 + + :raises NotFoundError: 人员不存在 + :raises BadRequestError: 存在团队成员或客户归属引用 + """ + personnel = personnel_repository.get_by_id(session, personnel_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_DELETE, + summary=f"删除人员 {personnel.personnel_name if personnel else f'#{personnel_id}'}", + ), + meta=meta, + ): + if personnel is None: + raise NotFoundError("人员不存在") + + ids = [personnel_id] + if ( + personnel_repository.count_team_members(session, ids) + or personnel_repository.count_customer_ownerships(session, ids) + or personnel_repository.count_business_roles_refs(session, ids) + ): + # 业务归属引用(负责/协助)随业务表就位一并纳入删除保护—— + # 漏检会让删除以 FK RESTRICT 的 500 暴露而非可读的 400 + raise BadRequestError(_DELETE_BLOCKED) + + name = personnel.personnel_name # 固化:对象随删除失效 + try: + personnel_repository.delete(session, personnel) + session.commit() + except IntegrityError as exc: + # 校验与删除之间并发新增了团队成员/客户归属,FK RESTRICT 令删除失败 + session.rollback() + raise BadRequestError(_DELETE_BLOCKED) from exc + logger.info("删除人员 | personnel_id=%s | name=%s", personnel_id, name) + + +def set_status( + session: Session, + personnel_id: int, + status: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> PersonnelOut: + """在职/离职切换。 + + 人员语义不用「启用/禁用」词表,action 记 ACTION_UPDATE、摘要写明去向。 + + :raises NotFoundError: 人员不存在 + """ + row = personnel_repository.get_with_names(session, personnel_id) + verb = "设为在职" if status == 1 else "设为离职" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_UPDATE, + summary=f"{verb} {row[0].personnel_name if row else f'#{personnel_id}'}", + request_data={"status": status}, + ), + meta=meta, + ): + if row is None: + raise NotFoundError("人员不存在") + personnel, role_name, manager_name = row + personnel.status = status + out = _to_out(personnel, role_name, manager_name) + session.commit() + logger.info("变更人员状态 | personnel_id=%s | status=%s", personnel_id, status) + return out + + +def replace_regions( + session: Session, + personnel_id: int, + payload: RegionReplaceIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """全量替换辐射区域,空列表即清空。 + + :raises NotFoundError: 人员不存在 + """ + row = personnel_repository.get_with_names(session, personnel_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_UPDATE, + summary=f"维护辐射区域 {row[0].personnel_name if row else f'#{personnel_id}'}", + request_data=payload.model_dump(), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("人员不存在") + rows = [ + PersonnelRegion( + personnel_id=personnel_id, region_name=item.region_name, remark=item.remark + ) + for item in payload.items + ] + personnel_repository.replace_regions(session, personnel_id, rows) + session.commit() + logger.info("维护辐射区域 | personnel_id=%s | 条目数=%s", personnel_id, len(rows)) + + +def replace_plans( + session: Session, + personnel_id: int, + payload: PlanReplaceIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """全量替换拓展计划,空列表即清空。 + + :raises NotFoundError: 人员不存在 + """ + row = personnel_repository.get_with_names(session, personnel_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_UPDATE, + summary=f"维护拓展计划 {row[0].personnel_name if row else f'#{personnel_id}'}", + request_data=payload.model_dump(), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("人员不存在") + rows = [ + PersonnelPlan( + personnel_id=personnel_id, + plan_period=item.plan_period, + plan_content=item.plan_content, + target_desc=item.target_desc, + ) + for item in payload.items + ] + personnel_repository.replace_plans(session, personnel_id, rows) + session.commit() + logger.info("维护拓展计划 | personnel_id=%s | 条目数=%s", personnel_id, len(rows)) + + +def _to_out(personnel: Personnel, role_name: str | None, manager_name: str | None) -> PersonnelOut: + """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" + return PersonnelOut( + id=personnel.id, + personnel_name=personnel.personnel_name, + business_role_id=personnel.business_role_id, + role_name=role_name, + manager_id=personnel.manager_id, + manager_name=manager_name, + phone=personnel.phone, + hire_date=personnel.hire_date, + is_probation=personnel.is_probation, + salary_standard=( + str(personnel.salary_standard) if personnel.salary_standard is not None else None + ), + status=personnel.status, + remark=personnel.remark, + ) + + +def _validate_refs( + session: Session, + business_role_id: int, + manager_id: int | None, + self_id: int | None, +) -> tuple[BusinessRole, str | None]: + """新增/编辑共用的引用校验:岗位须存在且启用;经理不能是自己且须在职。 + + ``self_id`` 编辑时传被编辑人员的 id、新增时传 None(新增不存在「自己」)。 + 返回岗位行与经理姓名供响应 DTO 复用,避免提交后再查一次。 + + :raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法 + """ + role = business_role_repository.get_by_id(session, business_role_id) + if role is None or role.status != 1: + raise BadRequestError("岗位不存在或已停用") + + manager_name: str | None = None + if manager_id is not None: + if manager_id == self_id: + raise BadRequestError("直属经理不能是自己") + manager = personnel_repository.get_by_id(session, manager_id) + if manager is None or manager.status != 1: + raise BadRequestError("直属经理不存在或已离职") + manager_name = manager.personnel_name + return role, manager_name + + +def _parse_salary(value: str | None) -> Decimal | None: + """入参字符串转 Decimal 存库。 + + 格式、非负与容量已在 schema 层校验,此处只做类型转换 —— + 金额红线:全程 Decimal,不经 float。 + """ + return Decimal(value) if value is not None else None diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py new file mode 100644 index 0000000..38e70bb --- /dev/null +++ b/backend/app/services/product_service.py @@ -0,0 +1,314 @@ +"""产品管理业务编排。 + +产品主数据的两条硬约束(违反会算错钱): + +- ``product_category`` 是**折算阈值的行级锚点**:混合业务按产品逐条计算 + (对照表 #14),阈值由 M1 参数 ``product.threshold`` 按 PRODUCT_CATEGORY + 维度承担。类别游离于合法值域之外时,核算按类别回溯参数会取不到阈值, + 故值域校验是业务规则而非格式校验 +- 数量/单价全程 ``Decimal``:schema 层已拦格式与非负(金额红线入口), + service 层负责字符串到 Decimal 的转换与落库 + +另:``spec_model`` 归一为**空串而非 NULL** —— 它参与 uk_products_name_spec +组合唯一键,MySQL 组合索引对 NULL 不去重,归 NULL 会放行重名同规格数据。 +删除为物理删除,被业务明细引用的保护检查待业务数据表(V5)就位后补充。 +""" + +import logging +from decimal import Decimal + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_DISABLE, + ACTION_ENABLE, + ACTION_UPDATE, + MODULE_PRODUCT, + PRODUCT_CATEGORIES, +) +from app.core.db_errors import is_duplicate_entry +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageResult +from app.models import Product, User +from app.repositories import product_repository +from app.schemas.product import ( + ProductCreateIn, + ProductOut, + ProductQuery, + ProductUpdateIn, +) +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta + +logger = logging.getLogger(__name__) + + +def _to_out(product: Product) -> ProductOut: + """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" + return ProductOut( + id=product.id, + product_name=product.product_name, + spec_model=product.spec_model, + unit=product.unit, + stock_quantity=str(product.stock_quantity) if product.stock_quantity is not None else None, + sale_price=str(product.sale_price) if product.sale_price is not None else None, + product_category=product.product_category, + status=product.status, + remark=product.remark, + create_time=product.create_time, + update_time=product.update_time, + ) + + +def _to_decimal(value: str | None) -> Decimal | None: + """数字字符串转 Decimal(可空列的空串/None 透传 None)。 + + 入参已 str 化再进 Decimal:即使上游口径变化传入数字类型,也不经 float + 二进制近似,金额红线在最后一道转换处仍然成立。schema 已保证可解析, + 此处不再重复校验。 + """ + if not value: + return None + return Decimal(str(value)) + + +def _ensure_category(product_category: str) -> None: + """类别必须 ∈ PRODUCT_CATEGORIES(前端下拉仅是展示约束,后端校验是唯一防线)。""" + if product_category not in PRODUCT_CATEGORIES: + raise BadRequestError("产品类别不合法") + + +def _is_duplicate_entry(exc: IntegrityError) -> bool: + """判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。 + + 只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、 + 数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。 + """ + return is_duplicate_entry(exc) + + +def _raise_if_duplicate(exc: IntegrityError) -> None: + """唯一键冲突统一转业务提示;其余完整性错误原样重抛。""" + if _is_duplicate_entry(exc): + raise BadRequestError("已存在同名称同规格的产品") from exc + raise exc + + +def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]: + """分页查询产品(含停用)。""" + products = product_repository.list_products( + session, + offset=query.offset, + limit=query.page_size, + keyword=query.keyword, + product_category=query.product_category, + status=query.status, + ) + total = product_repository.count_products( + session, + keyword=query.keyword, + product_category=query.product_category, + status=query.status, + ) + return PageResult[ProductOut]( + records=[_to_out(product) for product in products], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_product(session: Session, product_id: int) -> ProductOut: + """产品详情。 + + :raises NotFoundError: 产品不存在 + """ + product = product_repository.get_by_id(session, product_id) + if product is None: + raise NotFoundError("产品不存在") + return _to_out(product) + + +def create_product( + session: Session, + payload: ProductCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> ProductOut: + """新增产品。类别须在合法值域内,新产品固定启用。 + + :raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品 + """ + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PRODUCT, + action=ACTION_CREATE, + summary=f"新增产品 {payload.product_name}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + # 值域校验在审计上下文内:类别非法的 400 也要留痕(成功与失败都记) + _ensure_category(payload.product_category) + spec_model = payload.spec_model or "" # 归一空串:组合唯一键对 NULL 不去重 + if ( + product_repository.get_by_name_spec(session, payload.product_name, spec_model) + is not None + ): + raise BadRequestError("已存在同名称同规格的产品") + + product = Product( + product_name=payload.product_name, + spec_model=spec_model, + unit=payload.unit, + stock_quantity=_to_decimal(payload.stock_quantity), + sale_price=_to_decimal(payload.sale_price), + product_category=payload.product_category, + status=1, + remark=payload.remark, + ) + try: + product_repository.create(session, product) + # 响应与后置动作全部在 commit 之前完成(见 business_role_service 同处说明) + out = _to_out(product) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:并发提交同名称同规格会撞 uk_products_name_spec + session.rollback() + _raise_if_duplicate(exc) + + logger.info("新增产品 | product_id=%s | category=%s", product.id, payload.product_category) + return out + + +def update_product( + session: Session, + product_id: int, + payload: ProductUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> ProductOut: + """编辑产品基本信息(全量替换,不含状态;启停走独立端点)。 + + :raises NotFoundError: 产品不存在 + :raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品 + """ + product = product_repository.get_by_id(session, product_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PRODUCT, + action=ACTION_UPDATE, + summary=f"修改产品 {product.product_name if product else f'#{product_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + # 值域校验在审计上下文内:类别非法的 400 也要留痕(同 create 口径) + _ensure_category(payload.product_category) + if product is None: + raise NotFoundError("产品不存在") + + product.product_name = payload.product_name + product.spec_model = payload.spec_model or "" + product.unit = payload.unit + product.stock_quantity = _to_decimal(payload.stock_quantity) + product.sale_price = _to_decimal(payload.sale_price) + product.product_category = payload.product_category + product.remark = payload.remark + out = _to_out(product) + try: + session.commit() + except IntegrityError as exc: + # 改名/改规格可能撞 uk_products_name_spec(本行改值与他人既有数据重合) + session.rollback() + _raise_if_duplicate(exc) + + logger.info("修改产品 | product_id=%s | category=%s", product_id, payload.product_category) + return out + + +def set_status( + session: Session, + product_id: int, + status: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> ProductOut: + """启用或停用产品。停用为主(模型约定),物理删除仅限误录数据。 + + :raises NotFoundError: 产品不存在 + """ + product = product_repository.get_by_id(session, product_id) + verb = "启用" if status == 1 else "停用" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PRODUCT, + action=ACTION_ENABLE if status == 1 else ACTION_DISABLE, + summary=f"{verb}产品 {product.product_name if product else f'#{product_id}'}", + request_data={"status": status}, + ), + meta=meta, + ): + if product is None: + raise NotFoundError("产品不存在") + + product.status = status + out = _to_out(product) + session.commit() + logger.info("变更产品状态 | product_id=%s | status=%s", product_id, status) + return out + + +def delete_product( + session: Session, + product_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """物理删除产品(仅限误录数据,常规下架用停用)。 + + 被业务明细/发货明细引用(RESTRICT 外键)的产品禁删:先 count_refs + 给出可读 400(引导改走停用),校验与删除之间的并发引用由锁行 + + IntegrityError 兜底(customer_service.delete_customer 同款结构)。 + + :raises NotFoundError: 产品不存在 + :raises BadRequestError: 产品已被业务或发货明细引用 + """ + product = product_repository.get_by_id(session, product_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PRODUCT, + action=ACTION_DELETE, + summary=f"删除产品 {product.product_name if product else f'#{product_id}'}", + ), + meta=meta, + ): + if product is None: + raise NotFoundError("产品不存在") + if product_repository.count_refs(session, product_id): + raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)") + + product_name = product.product_name # 固化:对象随删除失效 + try: + product_repository.delete(session, product) + session.commit() + except IntegrityError as exc: + # 先查后删的并发窗口:新明细挂上了该产品,FK RESTRICT 兜底为 400 + session.rollback() + raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)") from exc + logger.info("删除产品 | product_name=%s", product_name) diff --git a/backend/app/services/rating_service.py b/backend/app/services/rating_service.py new file mode 100644 index 0000000..90110d3 --- /dev/null +++ b/backend/app/services/rating_service.py @@ -0,0 +1,280 @@ +"""客户评级等级配置业务编排。 + +评级计算器定位(对照表 #25):仅打分工具、不影响绩效金额,评分记录不落库 +——本表承载的等级映射是**判定表**,判定规则为按 sort_order 升序取首个 +min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反会算错等级): + +- 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串 + 到 Decimal 的转换与落库;0≤min≤max≤100 的区间校验在**审计上下文内** + 完成,失败的 400 同样留痕(2026-10-13 定稿口径) +- 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表 + 失效。判定为**闭区间两两相交**:任一其他等级满足 + ``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。 + 边界相邻不算重叠(前段 max=89、后段 min=90)—— 种子 A/B/C/D 即按 + 相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order + 靠前的等级,相邻无歧义 + +删除允许清空:等级表无下游引用(评分记录不落库,判定只读本表现值、不 +回溯历史快照),物理删除无悬空风险;全部删空后计算器只显示总分、不出 +等级(前端已对应提示),故不强制保留任何一条。 +""" + +import logging +from decimal import Decimal + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_UPDATE, + MODULE_RATING, +) +from app.core.db_errors import is_duplicate_entry +from app.core.exceptions import BadRequestError, NotFoundError +from app.models import CustomerRatingGrade, User +from app.repositories import rating_repository +from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta + +logger = logging.getLogger(__name__) + +# 分值域上下界(Decimal 常量,禁用 float 参与比较) +_SCORE_FLOOR = Decimal("0") +_SCORE_CEIL = Decimal("100") + + +def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut: + """ORM 对象转响应模型。分值 Decimal → str(分值红线出参口径)。""" + return RatingGradeOut( + id=grade.id, + grade_name=grade.grade_name, + min_score=str(grade.min_score), + max_score=str(grade.max_score), + sort_order=grade.sort_order, + remark=grade.remark, + create_time=grade.create_time, + update_time=grade.update_time, + ) + + +def _to_decimal(value: str) -> Decimal: + """数字字符串转 Decimal。 + + 入参已 str 化再进 Decimal:即使上游口径变化传入数字类型,也不经 float + 二进制近似,分值红线在最后一道转换处仍然成立。schema 已保证可解析, + 此处不再重复校验。 + """ + return Decimal(str(value)) + + +def _ensure_score_range(min_score: Decimal, max_score: Decimal) -> None: + """区间合法性:0 ≤ min_score ≤ max_score ≤ 100。 + + 超界/倒挂任一不满足即「分值区间不合法」——统一一条消息不区分具体 + 越界方式,前端据此整体提示。须在审计上下文内调用,失败也要留痕。 + """ + if not (_SCORE_FLOOR <= min_score <= max_score <= _SCORE_CEIL): + raise BadRequestError("分值区间不合法") + + +def _ensure_no_overlap( + grades: list[CustomerRatingGrade], + new_min: Decimal, + new_max: Decimal, + *, + exclude_id: int | None = None, +) -> None: + """新区间与既有等级**不得重叠**(闭区间两两相交判定)。 + + 任一其他等级满足 ``other.min_score <= new_max 且 new_min <= other.max_score`` + 即重叠,400 并点名冲突等级,便于前端定位到具体行。边界相邻不算重叠 + (见模块 docstring)。exclude_id 供编辑时排除自身。 + + :raises BadRequestError: 分值区间与等级 X 重叠 + """ + for other in grades: + if exclude_id is not None and other.id == exclude_id: + continue + if other.min_score <= new_max and new_min <= other.max_score: + raise BadRequestError(f"分值区间与等级 {other.grade_name} 重叠") + + +def _is_duplicate_entry(exc: IntegrityError) -> bool: + """判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。 + + 只有 1062 是「已存在」语义,可转业务提示;其余完整性错误语义未知, + 原样上抛 —— 由全局 IntegrityError handler 收敛为 400(通用文案), + 500 只留给程序缺陷。 + """ + return is_duplicate_entry(exc) + + +def _raise_if_duplicate(exc: IntegrityError) -> None: + """唯一键冲突统一转业务提示;其余完整性错误原样重抛。""" + if _is_duplicate_entry(exc): + raise BadRequestError("该等级名称已存在") from exc + raise exc + + +def list_grades(session: Session) -> list[RatingGradeOut]: + """全量等级列表,按判定顺序(sort_order 升序)直出。 + + 不分页:等级映射是判定表,数量个位数,分页无意义。 + """ + return [_to_out(grade) for grade in rating_repository.list_all(session)] + + +def create_grade( + session: Session, + payload: RatingGradeCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> RatingGradeOut: + """新增评级等级。 + + 区间须 0≤min≤max≤100 且与既有等级不重叠(新增同样判重叠:判定表 + 语义下重叠即脏配置,没有「先停用旧区间」之类的两阶段配置手段)。 + + :raises BadRequestError: 分值区间不合法 / 分值区间与等级 X 重叠 / 该等级名称已存在 + """ + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_RATING, + action=ACTION_CREATE, + summary=f"新增评级等级 {payload.grade_name}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + # 值域校验在审计上下文内:分值非法的 400 也要留痕(成功与失败都记) + new_min = _to_decimal(payload.min_score) + new_max = _to_decimal(payload.max_score) + _ensure_score_range(new_min, new_max) + _ensure_no_overlap(rating_repository.list_all(session, for_update=True), new_min, new_max) + if rating_repository.get_by_name(session, payload.grade_name) is not None: + raise BadRequestError("该等级名称已存在") + + grade = CustomerRatingGrade( + grade_name=payload.grade_name, + min_score=new_min, + max_score=new_max, + sort_order=payload.sort_order, + remark=payload.remark, + ) + try: + rating_repository.create(session, grade) + # 响应与后置动作全部在 commit 之前完成(见 product_service 同处说明) + out = _to_out(grade) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:并发提交同名等级会撞 uk_customer_rating_grades_grade_name + session.rollback() + _raise_if_duplicate(exc) + + logger.info("新增评级等级 | grade_id=%s | name=%s", grade.id, payload.grade_name) + return out + + +def update_grade( + session: Session, + grade_id: int, + payload: RatingGradeUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> RatingGradeOut: + """编辑评级等级(全量替换)。 + + 区间与其他等级不得重叠 —— 重叠判定以**修改后的区间**对全部既有等级 + (排除自身)两两相交,命中即 400 并点名冲突等级。 + + :raises NotFoundError: 评级等级不存在 + :raises BadRequestError: 分值区间不合法 / 分值区间与等级 X 重叠 / 该等级名称已存在 + """ + grade = rating_repository.get_by_id(session, grade_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_RATING, + action=ACTION_UPDATE, + summary=f"修改评级等级 {grade.grade_name if grade else f'#{grade_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + # 值域校验在审计上下文内(同 create 口径) + new_min = _to_decimal(payload.min_score) + new_max = _to_decimal(payload.max_score) + _ensure_score_range(new_min, new_max) + if grade is None: + raise NotFoundError("评级等级不存在") + + _ensure_no_overlap( + rating_repository.list_all(session, for_update=True), + new_min, + new_max, + exclude_id=grade_id, + ) + existed = rating_repository.get_by_name(session, payload.grade_name) + if existed is not None and existed.id != grade_id: + raise BadRequestError("该等级名称已存在") + + grade.grade_name = payload.grade_name + grade.min_score = new_min + grade.max_score = new_max + grade.sort_order = payload.sort_order + grade.remark = payload.remark + out = _to_out(grade) + try: + session.commit() + except IntegrityError as exc: + # 改名可能撞 uk_customer_rating_grades_grade_name(本行改值与他人既有数据重合) + session.rollback() + _raise_if_duplicate(exc) + + logger.info("修改评级等级 | grade_id=%s | name=%s", grade_id, payload.grade_name) + return out + + +def delete_grade( + session: Session, + grade_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """物理删除评级等级。 + + 等级表无下游引用 —— 评级是纯计算器不落评分记录(对照表 #25),判定 + 只读本表现值、不回溯历史快照,故可物理删除且无需锁行防并发引用。 + + **允许清空**:全部删空后计算器只显示总分、不出等级(前端已对应提示), + 不强制保留任何一条。 + + :raises NotFoundError: 评级等级不存在 + """ + grade = rating_repository.get_by_id(session, grade_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_RATING, + action=ACTION_DELETE, + summary=f"删除评级等级 {grade.grade_name if grade else f'#{grade_id}'}", + ), + meta=meta, + ): + if grade is None: + raise NotFoundError("评级等级不存在") + + grade_name = grade.grade_name # 固化:对象随删除失效 + rating_repository.delete(session, grade) + session.commit() + logger.info("删除评级等级 | grade_name=%s", grade_name) diff --git a/backend/app/services/receipt_service.py b/backend/app/services/receipt_service.py new file mode 100644 index 0000000..57f15d5 --- /dev/null +++ b/backend/app/services/receipt_service.py @@ -0,0 +1,366 @@ +"""回款管理业务编排(需求 2.2.3)。 + +事务边界在本层:repository 只 flush,commit/rollback 只写在这里。 + +- **写操作前 FOR UPDATE 锁业务行**(delete_business 先例):同业务的 + 发货/回款/编辑相互串行化,防止并发写后 ``_recalc_totals`` 重算 + total_received_amount 互相覆盖(丢失更新);批次号生成(MAX+FOR + UPDATE 锁当日序号区间)与唯一键(1062)冲突重试一次兜底,模式同 + business_service +- **shipment_id 同业务强校验**(数据完整性红线):指定的发货单必须存在 + 且与本回款同属一个业务 —— 跨业务指定会让回款天数与台账归属漂移; + 存在性与归属两类失败统一同一文案(防通过响应差异探测发货单存在性, + 与登录判定的防枚举同哲学),FK RESTRICT 是最终防线 +- **settlement_status 是 M3 核算防重锚点**(需规 2.3.3「每笔回款仅核算 + 一次」):已核算回款禁改禁删 —— 改金额/删行会让 M3 台账与源数据漂移; + M2 只写入 0(未核算),置 1 由 M3 核算服务负责 +- 回款合计回写统一收口 ``business_service._recalc_totals``(增删改后 + 都重算,禁各处手写 SUM) +- payment_days 为**只读派生值**:shipment_id 非空且发货单在库时按 + ``formula.payment_days(ship_date, receipt_date)`` 实时计算(发货日首日 + 计第 1 天),不落库;未指定批次为 None +- 一期禁负数金额(≥0):schema 拒绝;红字/退款冲销需求出现时先补设计 +""" + +import logging +from datetime import date +from decimal import Decimal + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_UPDATE, + MODULE_RECEIPT, + NO_PREFIX_RECEIPT, +) +from app.core.db_errors import is_duplicate_entry +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageResult +from app.models import Receipt +from app.models.business import BUSINESS_STATUS_ACTIVE +from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING +from app.repositories import business_repository, receipt_repository +from app.schemas.receipt import ( + ReceiptCreateIn, + ReceiptOut, + ReceiptQuery, + ReceiptUpdateIn, +) +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta +from app.services.business_service import _recalc_totals +from app.services.formula import payment_days +from app.services.no_generator import next_no + +logger = logging.getLogger(__name__) + +# 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY) +_NO_RETRY = 1 + + +def _to_out(row: tuple) -> ReceiptOut: + """(receipt, business_no, shipment_no, ship_date) → 响应模型。 + + 金额出参 str;payment_days 为派生值 —— ship_date 为 None(未指定批次) + 时输出 None;回款日早于发货日时按公式照实输出负值,忠实反映源数据 + (钳制会掩盖录入异常,M3 台账同口径)。 + """ + receipt, business_no, shipment_no, ship_date = row + return ReceiptOut( + id=receipt.id, + receipt_batch_no=receipt.receipt_batch_no, + business_id=receipt.business_id, + business_no=business_no, + shipment_id=receipt.shipment_id, + shipment_no=shipment_no, + receipt_date=receipt.receipt_date, + amount=str(receipt.amount), + is_barter=receipt.is_barter, + settlement_status=receipt.settlement_status, + payment_days=( + payment_days(ship_date, receipt.receipt_date) if ship_date is not None else None + ), + remark=receipt.remark, + ) + + +def _generate_no(session: Session) -> str: + """生成回款批次号(HK 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。""" + return next_no(session, model=Receipt, column_name="receipt_batch_no", prefix=NO_PREFIX_RECEIPT) + + +def _ensure_shipment_of_business( + session: Session, shipment_id: int | None, business_id: int +) -> tuple[str | None, object | None]: + """发货批次同业务强校验,返回 (shipment_no, ship_date) 供 DTO 组装。 + + 「必须存在且 business_id 相同」两类失败统一同一文案(防探测发货单 + 存在性);shipment_id 为 None = 暂未指定,直接放行。 + + :raises BadRequestError: 发货批次不存在或不属于该业务 + """ + if shipment_id is None: + return None, None + shipment = receipt_repository.get_shipment(session, shipment_id) + if shipment is None or shipment.business_id != business_id: + raise BadRequestError("发货批次不属于该业务,无法指定") + return shipment.shipment_no, shipment.ship_date + + +def _ensure_business_writable(session: Session, business_id: int, action: str): + """锁业务行并要求状态=1 执行中(新增/编辑/删除共用的写准入)。 + + 锁在判定之前取:判定结果基于锁定后的行,配合 _recalc_totals 的重算 + 同事务提交,并发写不会丢失更新。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 业务已完成或已终止 + """ + business = business_repository.get_by_id(session, business_id, for_update=True) + if business is None: + raise NotFoundError("业务不存在") + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError(f"业务已完成或已终止,禁止{action}回款") + return business + + +def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]: + """分页查询回款(附业务号/发货单号与 payment_days 派生值)。""" + rows = receipt_repository.list_receipts( + session, + offset=query.offset, + limit=query.page_size, + business_id=query.business_id, + date_from=query.date_from, + date_to=query.date_to, + is_barter=query.is_barter, + settlement_status=query.settlement_status, + ) + total = receipt_repository.count_receipts( + session, + business_id=query.business_id, + date_from=query.date_from, + date_to=query.date_to, + is_barter=query.is_barter, + settlement_status=query.settlement_status, + ) + return PageResult[ReceiptOut]( + records=[_to_out(row) for row in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_detail(session: Session, receipt_id: int) -> ReceiptOut: + """回款详情(同列表 DTO,含 payment_days 派生值)。 + + :raises NotFoundError: 回款不存在 + """ + row = receipt_repository.get_with_refs(session, receipt_id) + if row is None: + raise NotFoundError("回款不存在") + return _to_out(row) + + +def create_receipt( + session: Session, + payload: ReceiptCreateIn, + *, + operator, + meta: RequestMeta | None = None, +) -> ReceiptOut: + """新增回款:锁业务行 → 校验 → 建行 + 重算合计,一个事务。 + + 摘要三要素(批次号/业务号/金额)在进入审计前就绪:业务号取自锁行 + 结果、批次号先行生成(SELECT MAX FOR UPDATE,不产生未提交变更)、 + 金额来自入参;引用校验(业务状态/发货批次同业务)在审计上下文内, + 失败也留痕 —— 失败路径回滚释放已取序号,当日序号出现空洞无害 + (唯一性不依赖连续)。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 业务已完成或已终止 / 发货批次不属于该业务 + """ + business = business_repository.get_by_id(session, payload.business_id, for_update=True) + batch_no = _generate_no(session) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_RECEIPT, + action=ACTION_CREATE, + summary=( + f"新增回款 {batch_no}" + f"(业务 {business.business_no if business else f'#{payload.business_id}'}," + f"金额 {payload.amount})" + ), + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + # 批次号唯一键冲突的重试整体收在审计上下文内(模式同 + # business_service.create_business):rollback 释放旧锁,重试前重新 + # 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归 + for attempt in range(_NO_RETRY + 1): + try: + if business is None: + raise NotFoundError("业务不存在") + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError("业务已完成或已终止,禁止新增回款") + # 月结锁定拦截(2026-10-15 补充需求):比需求原文「提示」取硬, + # 一期安全侧(策略点注释);未指定日期时以当天月近似 + from app.services import settlement_service + + _lock_month = (payload.receipt_date or date.today()).strftime("%Y-%m") + settlement_service.ensure_month_not_locked(session, _lock_month) + shipment_no, ship_date = _ensure_shipment_of_business( + session, payload.shipment_id, payload.business_id + ) + receipt = receipt_repository.create( + session, + Receipt( + receipt_batch_no=batch_no, + business_id=payload.business_id, + shipment_id=payload.shipment_id, + receipt_date=payload.receipt_date, + amount=Decimal(payload.amount), + is_barter=payload.is_barter, + settlement_status=SETTLEMENT_PENDING, + remark=payload.remark, + ), + ) + _recalc_totals(session, business) + out = _to_out((receipt, business.business_no, shipment_no, ship_date)) + new_id, new_no = receipt.id, batch_no # 固化:commit 后不碰 ORM + session.commit() + break + except IntegrityError as exc: + session.rollback() + if _is_duplicate_entry(exc) and attempt < _NO_RETRY: + business = business_repository.get_by_id( + session, payload.business_id, for_update=True + ) + batch_no = _generate_no(session) + continue + raise + + logger.info( + "新增回款 | receipt_id=%s | no=%s | business_id=%s", + new_id, + new_no, + payload.business_id, + ) + return out + + +def update_receipt( + session: Session, + receipt_id: int, + payload: ReceiptUpdateIn, + *, + operator, + meta: RequestMeta | None = None, +) -> ReceiptOut: + """编辑回款(全量替换)+ 重算合计,一个事务。 + + **已核算回款禁止修改**(M3 防重锚点):M3 台账已按该行固化核算, + 改金额/到账日/批次指定会让台账与源数据漂移。shipment_id 变更后 + 重新做同业务强校验(全量替换语义下每次编辑都校验,无「未变即免检」 + 的旁路)。 + + :raises NotFoundError: 回款不存在 / 业务不存在 + :raises BadRequestError: 已核算禁改 / 业务已完成或已终止 / 发货批次不属于该业务 + """ + receipt = receipt_repository.get_by_id(session, receipt_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_RECEIPT, + action=ACTION_UPDATE, + summary=f"修改回款 {receipt.receipt_batch_no if receipt else f'#{receipt_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if receipt is None: + raise NotFoundError("回款不存在") + if receipt.settlement_status == SETTLEMENT_DONE: + raise BadRequestError("已核算回款禁止修改") + business = _ensure_business_writable(session, receipt.business_id, "修改") + # 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数, + # 只查新月会让「从锁定月改到未锁定月」绕过拦截) + from app.services import settlement_service + + settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m")) + settlement_service.ensure_month_not_locked(session, payload.receipt_date.strftime("%Y-%m")) + # 校验返回的 (shipment_no, ship_date) 即新批次 DTO 所需 —— shipment_id + # 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join + shipment_no, ship_date = _ensure_shipment_of_business( + session, payload.shipment_id, receipt.business_id + ) + + receipt.receipt_date = payload.receipt_date + receipt.shipment_id = payload.shipment_id + receipt.amount = Decimal(payload.amount) + receipt.is_barter = payload.is_barter + receipt.remark = payload.remark + _recalc_totals(session, business) + out = _to_out((receipt, business.business_no, shipment_no, ship_date)) + batch_no = receipt.receipt_batch_no + session.commit() + # commit 后不再触碰 ORM 属性(audit_service 约定):批次号已固化 + logger.info("修改回款 | receipt_id=%s | no=%s", receipt_id, batch_no) + return out + + +def delete_receipt( + session: Session, + receipt_id: int, + *, + operator, + meta: RequestMeta | None = None, +) -> None: + """删除回款(物理删除)+ 重算合计,一个事务。 + + **已核算回款禁止删除**(M3 防重锚点,理由同编辑);业务须状态=1 + 执行中 —— 终态业务的回款是 M3 核算与报表的既成事实,不得删改。 + + :raises NotFoundError: 回款不存在 / 业务不存在 + :raises BadRequestError: 已核算禁删 / 业务已完成或已终止 + """ + receipt = receipt_repository.get_by_id(session, receipt_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_RECEIPT, + action=ACTION_DELETE, + summary=f"删除回款 {receipt.receipt_batch_no if receipt else f'#{receipt_id}'}", + ), + meta=meta, + ): + if receipt is None: + raise NotFoundError("回款不存在") + if receipt.settlement_status == SETTLEMENT_DONE: + raise BadRequestError("已核算回款禁止删除") + business = _ensure_business_writable(session, receipt.business_id, "删除") + # 月结锁定拦截:已锁定月份的回款是月结快照的源数据,不得删除 + from app.services import settlement_service + + settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m")) + + batch_no = receipt.receipt_batch_no + receipt_repository.delete(session, receipt) + _recalc_totals(session, business) + session.commit() + # commit 后不再触碰 ORM 属性(audit_service 约定):批次号已固化 + logger.info("删除回款 | receipt_id=%s | no=%s", receipt_id, batch_no) + + +def _is_duplicate_entry(exc: IntegrityError) -> bool: + """识别 MySQL 唯一键冲突(1062),与 business_service 同口径。""" + return is_duplicate_entry(exc) diff --git a/backend/app/services/report_export.py b/backend/app/services/report_export.py new file mode 100644 index 0000000..dc63840 --- /dev/null +++ b/backend/app/services/report_export.py @@ -0,0 +1,309 @@ +"""报表 Excel 模板构建(纯函数,可单测)。 + +版式(FR-032「导出模板围绕财务复核和领导签字设计」): +公司信息页眉 → 报表标题 → 筛选条件 → 导出时间/人/模板版本 → 表头 → 数据行 +→ 合计行 → 财务复核栏 / 领导签字栏 / 日期栏。 + +精度红线:金额写 ``Decimal``(openpyxl 原生支持,不经 float)+ +``number_format='#,##0.00'``;合计在 Python 侧求和后两位收口,**不写 +Excel 公式**(避免打开时重算漂移与公式注入面)。文本列以 ``= + - @`` +开头时前置单引号,防公式注入。 +""" + +from dataclasses import dataclass +from datetime import datetime +from decimal import ROUND_HALF_UP, Decimal +from io import BytesIO + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter + +from app.core.report_meta import CUSTOMER_TYPE_LABELS, PRODUCT_CATEGORY_LABELS + +_HEADER_FILL = PatternFill("solid", fgColor="DDE3F0") +_THIN = Side(style="thin", color="B0B7C3") +_BORDER = Border(left=_THIN, right=_THIN, top=_THIN, bottom=_THIN) +_TITLE_FONT = Font(size=14, bold=True) +_COMPANY_FONT = Font(size=16, bold=True) +_HEADER_FONT = Font(bold=True) +_AMOUNT_FORMAT = "#,##0.00" +_DATE_FORMAT = "YYYY-MM-DD" +_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t") + +BUSINESS_STATUS_LABELS = {1: "进行中", 2: "已完成", 3: "已终止"} + + +@dataclass(frozen=True, slots=True) +class _Column: + """列定义:key 取行 dict 字段,kind 决定单元格类型与格式。""" + + key: str + title: str + kind: str # text / amount / int / date / customer / category / status + width: float + + +_COLUMNS: dict[str, tuple[_Column, ...]] = { + "personal-detail": ( + _Column("business_no", "合同编号", "text", 20), + _Column("ship_date", "发货日", "date", 12), + _Column("receipt_date", "回款日", "date", 12), + _Column("payment_days", "回款天数", "int", 10), + _Column("product_category", "产品类别", "category", 14), + _Column("customer_type", "客户类型", "customer", 14), + _Column("role_name", "岗位", "text", 12), + _Column("rate_text", "计提比例", "text", 14), + _Column("weight_r", "支付权重R", "int", 10), + _Column("performance_amount", "绩效金额", "amount", 14), + _Column("payable_amount", "应付绩效", "amount", 14), + ), + "department-summary": ( + _Column("manager_name", "经理团队", "text", 16), + _Column("member_name", "成员", "text", 14), + _Column("role_code", "岗位编码", "text", 12), + _Column("performance_sum", "绩效金额", "amount", 14), + _Column("payable_sum", "应付绩效", "amount", 14), + _Column("adjust_net", "调整净额", "amount", 14), + _Column("net_total", "净合计", "amount", 14), + ), + "business-stats": ( + _Column("business_no", "业务编号", "text", 20), + _Column("business_date", "业务日期", "date", 12), + _Column("customer_name", "客户", "text", 22), + _Column("owner_personnel_name", "归属人", "text", 12), + _Column("status", "状态", "status", 10), + _Column("total_sale_amount", "销售额", "amount", 14), + _Column("total_ship_amount", "发货额", "amount", 14), + _Column("total_received_amount", "回款额", "amount", 14), + _Column("performance_amount", "绩效金额", "amount", 14), + _Column("payable_amount", "应付绩效", "amount", 14), + ), + "payment-stats": ( + _Column("label", "回款天数区间", "text", 20), + _Column("receipt_amount", "回款金额", "amount", 16), + _Column("receipt_count", "回款笔数", "int", 10), + _Column("performance_amount", "绩效金额", "amount", 14), + _Column("payable_amount", "应付绩效", "amount", 14), + _Column("ledger_count", "台账行数", "int", 10), + ), +} + + +def build_workbook( + *, + code: str, + title: str, + filters: str, + rows: list[dict], + summary: dict | None, + buckets: dict | None, + template_version: str, + operator_name: str, + company_name: str, +) -> bytes: + """构建并返回 xlsx 字节流(业务数据只读,不触碰数据库)。""" + workbook = Workbook() + sheet = workbook.active + sheet.title = _sheet_name(code) + columns = _COLUMNS[code] + _write_header(sheet, columns, company_name, title, filters, template_version, operator_name) + if rows: + for row in rows: + _write_row(sheet, columns, row) + else: + sheet.append(["本筛选条件无数据"]) + _merge_row(sheet, len(columns)) + _write_total(sheet, columns, rows) + _write_signature(sheet, len(columns)) + _autofit(sheet, columns) + + # 回款统计附维度对比 sheet(客户类型/产品类别),与区间表共用页眉格式 + if code == "payment-stats" and buckets: + for dim_key, dim_title in ( + ("by_customer_type", "客户类型对比"), + ("by_product_category", "产品类别对比"), + ): + extra = workbook.create_sheet(dim_title) + _write_dimension_sheet( + extra, + dim_title, + company_name, + buckets.get(dim_key) or [], + template_version, + operator_name, + ) + + buffer = BytesIO() + workbook.save(buffer) + return buffer.getvalue() + + +def _sheet_name(code: str) -> str: + return { + "personal-detail": "个人绩效明细", + "department-summary": "部门绩效汇总", + "business-stats": "业务统计", + "payment-stats": "回款统计", + }[code] + + +def _write_header( + sheet, + columns: tuple[_Column, ...], + company_name: str, + title: str, + filters: str, + template_version: str, + operator_name: str, +) -> None: + """页眉四行 + 表头行;返回后 sheet.max_row 即数据行起点。""" + sheet.append([company_name]) + _merge_row(sheet, len(columns)) + sheet.cell(row=1, column=1).font = _COMPANY_FONT + sheet.cell(row=1, column=1).alignment = Alignment(horizontal="left", vertical="center") + + sheet.append([title]) + _merge_row(sheet, len(columns)) + sheet.cell(row=2, column=1).font = _TITLE_FONT + sheet.cell(row=2, column=1).alignment = Alignment(horizontal="center", vertical="center") + + sheet.append([f"筛选条件:{filters}"]) + _merge_row(sheet, len(columns)) + sheet.append( + [ + f"导出时间:{datetime.now():%Y-%m-%d %H:%M:%S} | 导出人:{operator_name} | " + f"模板版本:{template_version}" + ] + ) + _merge_row(sheet, len(columns)) + sheet.append([]) + + sheet.append([column.title for column in columns]) + header_row = sheet.max_row + for cell in sheet[header_row]: + cell.font = _HEADER_FONT + cell.fill = _HEADER_FILL + cell.border = _BORDER + cell.alignment = Alignment(horizontal="center", vertical="center") + sheet.freeze_panes = sheet.cell(row=header_row + 1, column=1) + + +def _merge_row(sheet, column_count: int) -> None: + """合并当前行(公司名/标题/筛选行跨全表宽)。""" + row = sheet.max_row + if column_count > 1: + sheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=column_count) + + +def _write_row(sheet, columns: tuple[_Column, ...], row: dict) -> None: + """数据行:按列类型写单元格(金额 Decimal、日期 date、文本防注入)。""" + values = [] + for column in columns: + raw = row.get(column.key) + values.append(_cell_value(column, raw)) + sheet.append(values) + for index, column in enumerate(columns, start=1): + cell = sheet.cell(row=sheet.max_row, column=index) + cell.border = _BORDER + if column.kind == "amount": + cell.number_format = _AMOUNT_FORMAT + elif column.kind == "date": + cell.number_format = _DATE_FORMAT + + +def _cell_value(column: _Column, raw): + """单值转换:标签列转中文,文本列防公式注入。""" + if raw is None: + return None + if column.kind == "amount": + return raw if isinstance(raw, Decimal) else Decimal(str(raw)) + if column.kind == "date": + return raw + if column.kind == "int": + return int(raw) + if column.kind == "customer": + return CUSTOMER_TYPE_LABELS.get(str(raw), str(raw)) + if column.kind == "category": + return PRODUCT_CATEGORY_LABELS.get(str(raw), str(raw)) + if column.kind == "status": + return BUSINESS_STATUS_LABELS.get(int(raw), str(raw)) + return _safe_text(raw) + + +def _safe_text(value) -> str: + """文本防公式注入:以 = + - @ 或制表符开头时前置单引号。""" + text = str(value) + return "'" + text if text.startswith(_FORMULA_PREFIXES) else text + + +def _write_total(sheet, columns: tuple[_Column, ...], rows: list[dict]) -> None: + """合计行:金额列按行求和后两位收口(不写 Excel 公式)。 + + 「合计」标签只写在第一个非金额列;其余非金额列留空——否则每遇到 + 一个文本列都会重复写一次标签,合计行会满屏「合计」。 + """ + if not rows: + return + totals = [] + label_written = False + for column in columns: + if column.kind != "amount": + totals.append("合计" if not label_written else None) + label_written = True + continue + total = sum( + ( + row.get(column.key) + for row in rows + if isinstance(row.get(column.key), (Decimal, int)) + ), + Decimal("0"), + ) + totals.append(total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) + sheet.append(totals) + for index, column in enumerate(columns, start=1): + cell = sheet.cell(row=sheet.max_row, column=index) + cell.font = _HEADER_FONT + cell.border = _BORDER + if column.kind == "amount": + cell.number_format = _AMOUNT_FORMAT + + +def _write_signature(sheet, column_count: int) -> None: + """财务复核栏 / 领导签字栏 / 日期栏(FR-032 模板要素)。""" + sheet.append([]) + sheet.append(["财务复核:________________", "", "日期:______年__月__日"]) + sheet.append(["领导签字:________________", "", "日期:______年__月__日"]) + for row_index in (sheet.max_row - 1, sheet.max_row): + sheet.cell(row=row_index, column=1).alignment = Alignment(horizontal="left") + + +def _autofit(sheet, columns: tuple[_Column, ...]) -> None: + """列宽按定义表设置(CJK 按 2 字符宽,不用默认 8.43)。""" + for index, column in enumerate(columns, start=1): + sheet.column_dimensions[get_column_letter(index)].width = column.width + + +def _write_dimension_sheet( + sheet, + title: str, + company_name: str, + rows: list[dict], + template_version: str, + operator_name: str, +) -> None: + """维度对比 sheet(客户类型/产品类别)。""" + columns = ( + _Column("label", "维度", "text", 16), + _Column("receipt_amount", "回款金额", "amount", 16), + _Column("performance_amount", "绩效金额", "amount", 14), + _Column("payable_amount", "应付绩效", "amount", 14), + _Column("ledger_count", "台账行数", "int", 10), + ) + _write_header(sheet, columns, company_name, title, "全部", template_version, operator_name) + for row in rows: + _write_row(sheet, columns, row) + _write_total(sheet, columns, rows) + _write_signature(sheet, len(columns)) + _autofit(sheet, columns) diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py new file mode 100644 index 0000000..6f24bd6 --- /dev/null +++ b/backend/app/services/report_service.py @@ -0,0 +1,698 @@ +"""报表统计服务(M4 ⑤):四张报表取数编排 + Excel 导出编排。 + +全局口径(四表共用,改动须同步 backend/README「报表口径」小节): + +1. 数据源 ``performance_ledgers`` 固定 **is_active=1 AND calc_type=1**(单笔 + 生效行);经理聚合行(calc_type=2)只在部门汇总作冻结对照单读——把 + 聚合额混进明细会让「汇总 = 明细合计」失效; +2. 金额全程 Decimal(两位收口 ROUND_HALF_UP);比例原样输出 ``rate_text`` + (如 88/9000),禁止转 float 或截断展示; +3. 调整单并入:仅 status=2(生效);direction=1 补发记 +、2 扣减记 − + (金额恒正,方向表达语义); +4. 离职人员不按 ``personnel.status`` 过滤——历史业绩留在原团队(FR-030 + 「变更不回溯」原则);无成员团队仍出 0 值行; +5. 团队基数直接取 ``manager_share.collect_team_buckets`` 的分桶结果,与 + 月结聚合行同源(不是报表侧另算一遍)。 +""" + +from decimal import ROUND_HALF_UP, Decimal +from fractions import Fraction + +from sqlalchemy import select + +from app.core import report_meta +from app.core.constants import ACTION_EXPORT, MODULE_REPORT +from app.core.exceptions import BadRequestError +from app.core.report_meta import ( + REPORT_EXPORT_MAX_ROWS, + REPORT_TEMPLATE_VERSION, + DayBucket, +) +from app.models import BusinessRole, PerformanceLedger, Personnel, User +from app.models.settlement import ADJUSTMENT_DIRECTION_EXTRA +from app.repositories import report_repository +from app.services import audit_service, manager_share +from app.services.audit_service import AuditRecord, RequestMeta +from app.services.formula import manager_team_share + +# ==== 元数据 ==== + + +def get_payment_day_buckets() -> list[dict]: + """回款天数区间元数据(前端下拉唯一数据源,禁止前端硬编码档位)。""" + return [ + {"code": b.code, "label": b.label, "min_days": b.min_days, "max_days": b.max_days} + for b in report_meta.get_payment_day_buckets() + ] + + +# ==== 个人绩效明细(FR-029)==== + + +def list_personal_detail(session, query) -> dict: + """明细分页 + 全筛选合计(summary 与分页同条件,避免页脚合计≠全部结果)。""" + rows = report_repository.list_personal_detail( + session, query, offset=query.offset, limit=query.page_size + ) + role_names = _role_names(session) + sums = report_repository.sum_personal_detail(session, query) + return { + "list": [_person_row(r, role_names) for r in rows], + "total": report_repository.count_personal_detail(session, query), + "pageNum": query.page_num, + "pageSize": query.page_size, + "summary": { + "row_count": sums["row_count"], + "performance_total": _q2(_dec(sums["performance_total"])), + "payable_total": _q2(_dec(sums["payable_total"])), + "converted_profit_total": _q2(_dec(sums["converted_profit_total"])), + }, + } + + +def _person_row(row: PerformanceLedger, role_names: dict[str, str]) -> dict: + """台账行 → 明细行出参(金额保持 Decimal,序列化交由信封层)。""" + return { + "ledger_id": row.id, + "business_id": row.business_id, + "business_no": row.business_no, + "personnel_id": row.personnel_id, + "personnel_name": row.personnel_name, + "role_code": row.role_code, + "role_name": role_names.get(row.role_code, row.role_code), + "customer_type": row.customer_type, + "product_category": row.product_category, + "ship_date": row.ship_date, + "receipt_date": row.receipt_date, + "payment_days": row.payment_days, + "rate_text": row.rate_text, + "weight_r": row.weight_r, + "payment_weight": row.payment_weight, + "performance_amount": row.performance_amount, + "payable_amount": row.payable_amount, + "converted_profit": row.converted_profit, + "is_probation": row.is_probation, + "missing_cost": row.missing_cost, + "calc_version": row.calc_version, + "settlement_id": row.settlement_id, + } + + +def _role_names(session) -> dict[str, str]: + """岗位编码 → 名称一次查询(明细与部门行共用,避免 N+1)。""" + return { + r.role_code: r.role_name + for r in session.execute(select(BusinessRole)).scalars() + } + + +def _dec(value) -> Decimal | None: + """SQL 聚合结果转 Decimal(SUM 可能返回 int,None 原样返回)。""" + if value is None: + return None + return value if isinstance(value, Decimal) else Decimal(str(value)) + + +def _q2(value: Decimal | None) -> Decimal | None: + """两位小数收口(HALF_UP,与核算/月结舍入口径一致)。""" + if value is None: + return None + return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + +# ==== 部门绩效汇总(FR-030)==== + + +def department_summary(session, query) -> dict: + """按经理团队/月份汇总绩效工资(团队基数与月结聚合行同源)。""" + month = query.settlement_month + rows = report_repository.list_month_receipt_rows(session, month) + share = manager_share.manager_share_params(session, month) + buckets = manager_share.collect_team_buckets(session, rows, share.include_manager_personal) + company_base = manager_share.company_base_of(rows, share.include_manager_personal) + + aggregates = { + row.personnel_id: row + for row in report_repository.list_month_manager_aggregates(session, month) + } + adjustments = _adjustments_by_person(report_repository.list_month_adjustments(session, month)) + member_map = _member_map(rows) + manager_ids = _manager_ids(session, buckets, query.manager_id) + # 岗位判定范围必须覆盖「无成员的经理」——只按 member_map 取会让零行团队被跳过 + manager_of, manager_role = _team_relations(session, member_map, manager_ids) + + teams: list[dict] = [] + for manager_id in manager_ids: + manager = session.get(Personnel, manager_id) + if manager is None or manager_role.get(manager_id) != "sales_manager": + continue # 非营销经理的上级不展示团队行(与月结聚合同口径) + aggregate = aggregates.get(manager_id) + member_rows = _member_rows( + manager_id, member_map, manager_of, adjustments, share.include_manager_personal + ) + team_extra = sum((m["adjust_extra"] for m in member_rows), Decimal("0")) + team_deduct = sum((m["adjust_deduct"] for m in member_rows), Decimal("0")) + team_base = _dec(buckets.get(manager_id, {}).get("performance", Decimal("0"))) + # 12.5% 与 5% 分列(FR-030「全公司 5% 部分单列」);两项之和才是月结 + # 聚合行的金额(frozen_amount 对照),重复计入会双倍展示 + team_share = _team_share(team_base, share.team_pct) + company_share = _company_share(company_base, share.company_pct) + teams.append( + { + "manager_id": manager_id, + "manager_name": manager.personnel_name, + "manager_status": manager.status, + "member_count": len(member_rows), + "team_base": _q2(team_base), + "team_share_amount": _q2(team_share), + "company_share_amount": _q2(company_share), + "company_base_ratio": _ratio(team_base, company_base), + "adjust_extra": _q2(team_extra), + "adjust_deduct": _q2(team_deduct), + "adjust_net": _q2(team_extra - team_deduct), + "net_total": _q2(team_share + company_share + team_extra - team_deduct), + "frozen_amount": aggregate.performance_amount if aggregate else None, + "aggregate_ledger_id": aggregate.id if aggregate else None, + "members": member_rows, + } + ) + + unassigned = _unassigned_rows(member_map, manager_of, manager_role, adjustments) + return { + "settlement_month": month, + "params": { + "team_text": share.team_text, + "company_text": share.company_text, + "manager_personal_included": 1 if share.include_manager_personal else 0, + "params_as_of": str(share.param_date), + }, + "teams": teams, + "unassigned": unassigned, + "company": { + "team_base_total": _q2(sum((t["team_base"] for t in teams), Decimal("0"))), + "company_base": _q2(company_base), + "company_share_pct": share.company_text, + "company_share_amount": _q2(_company_share(company_base, share.company_pct)), + }, + "totals": { + "performance_total": _q2(sum((t["team_base"] for t in teams), Decimal("0"))), + "payable_total": _q2(sum((t["net_total"] for t in teams), Decimal("0"))), + }, + } + + +def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal: + """12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。""" + return manager_team_share( + team_base, Decimal("0"), team_pct=team_pct, company_pct=Fraction(0) + ) + + +def _company_share(company_base: Decimal, company_pct: Fraction) -> Decimal: + """5% 全公司分成:团队部分传 0,仅取 company_pct 项。""" + return manager_team_share( + Decimal("0"), company_base, team_pct=Fraction(0), company_pct=company_pct + ) + + +def _ratio(part: Decimal, whole: Decimal) -> Decimal: + """占比(除零返回 0,不抛 ZeroDivisionError)。""" + if not whole: + return Decimal("0") + return (part / whole).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) + + +def _adjustments_by_person(orders) -> dict[int, dict]: + """生效调整单按人汇总(补发为正、扣减为负;金额本身恒正)。""" + by_person: dict[int, dict] = {} + for order in orders: + acc = by_person.setdefault( + order.personnel_id, {"extra": Decimal("0"), "deduct": Decimal("0")} + ) + if order.direction == ADJUSTMENT_DIRECTION_EXTRA: + acc["extra"] += order.amount + else: + acc["deduct"] += order.amount + return by_person + + +def _member_map(rows) -> dict[int, dict]: + """台账行按人求和(成员明细的唯一数据源),经理个人户金额单独记账。""" + members: dict[int, dict] = {} + for row in rows: + acc = members.setdefault( + row.personnel_id, + { + "personnel_name": row.personnel_name, + "role_code": row.role_code, + "is_probation": row.is_probation, + "performance": Decimal("0"), + "payable": Decimal("0"), + "manager_personal_performance": Decimal("0"), + "manager_personal_payable": Decimal("0"), + "ledger_ids": [], + "has_manager_personal": False, + }, + ) + performance = row.performance_amount or Decimal("0") + payable = row.payable_amount or Decimal("0") + acc["performance"] += performance + acc["payable"] += payable + acc["ledger_ids"].append(row.id) + if row.customer_type == "manager_personal": + acc["has_manager_personal"] = True + acc["manager_personal_performance"] += performance + acc["manager_personal_payable"] += payable + return members + + +def _team_relations( + session, member_map: dict[int, dict], manager_ids: list[int] +) -> tuple[dict[int, int | None], dict[int, str]]: + """人员 → 直属经理与人员 → 岗位编码(各一次查询,避免逐人 get 的 N+1)。 + + 查询范围 = 成员 ∪ 经理枚举:无成员的经理也要有岗位判定,否则「零行团队」 + 会被岗位过滤静默跳过(FR-030 要求无团队成员出 0 行)。 + 经理判定与 ``manager_share.collect_team_buckets`` 的实时上溯同口径: + 离职人员的 manager_id 保留,历史业绩因此留在原团队。 + """ + pids = list(set(member_map) | set(manager_ids)) + if not pids: + return {}, {} + people = list(session.execute(select(Personnel).where(Personnel.id.in_(pids))).scalars()) + manager_of = {p.id: p.manager_id for p in people} + role_by_id = { + r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars() + } + manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people} + return manager_of, manager_role + + +def _manager_ids(session, buckets: dict, only: int | None) -> list[int]: + """经理枚举:数据中出现的经理 ∪ 在职营销经理(无成员也要出 0 值行)。""" + if only is not None: + return [only] + found = set(buckets) + role = session.execute( + select(BusinessRole).where(BusinessRole.role_code == "sales_manager") + ).scalar_one_or_none() + if role is not None: + active = session.execute( + select(Personnel.id).where(Personnel.business_role_id == role.id, Personnel.status == 1) + ).scalars() + found.update(active) + return sorted(found) + + +def _member_rows( + manager_id: int, + member_map: dict[int, dict], + manager_of: dict[int, int | None], + adjustments: dict[int, dict], + include_manager_personal: bool, +) -> list[dict]: + """团队内成员行:业务员按 manager_id 归属;经理个人户按开关归属本人。""" + pids = [pid for pid, mid in manager_of.items() if mid == manager_id and pid != manager_id] + # 经理本人(经理个人户行):#12 开关开启时计入本团队基数 + if include_manager_personal and manager_id in member_map: + pids.append(manager_id) + return [_member_row(pid, member_map[pid], adjustments) for pid in pids] + + +def _member_row(pid: int, member: dict, adjustments: dict[int, dict]) -> dict: + """单条成员行(绩效/应付求和 + 调整净额 + 台账 id 列表)。""" + adj = adjustments.get(pid, {"extra": Decimal("0"), "deduct": Decimal("0")}) + net = member["payable"] + adj["extra"] - adj["deduct"] + return { + "personnel_id": pid, + "personnel_name": member["personnel_name"], + "role_code": member["role_code"], + "is_probation": member["is_probation"], + "performance_sum": _q2(member["performance"]), + "payable_sum": _q2(member["payable"]), + "adjust_extra": _q2(adj["extra"]), + "adjust_deduct": _q2(adj["deduct"]), + "adjust_net": _q2(adj["extra"] - adj["deduct"]), + "net_total": _q2(net), + "ledger_ids": member["ledger_ids"], + } + + +def _unassigned_rows( + member_map: dict[int, dict], + manager_of: dict[int, int | None], + manager_role: dict[int, str], + adjustments: dict[int, dict], +) -> list[dict]: + """未进入团队基数的成员:无经理业务员 + 非营销经理上级的下属。 + + 经理个人户业绩默认不计入任何基数(#12 开关关闭时),也不在此展示—— + 其金额仍可在个人明细报表中查询,避免与「团队基数 + 未分配」口径混淆。 + """ + rows = [] + for pid, member in member_map.items(): + mid = manager_of.get(pid) + if mid is not None and manager_role.get(mid) == "sales_manager": + continue + regular = member["performance"] - member["manager_personal_performance"] + if regular == 0 and member["manager_personal_performance"] != 0: + continue + row = _member_row(pid, member, adjustments) + row["performance_sum"] = _q2(regular) + row["payable_sum"] = _q2(member["payable"] - member["manager_personal_payable"]) + rows.append(row) + return rows + + +# ==== 业务统计(FR-031 之一)==== + + +def business_stats(session, query) -> dict: + """业务统计分页 + 全筛选合计;回款/绩效为业务累计值。""" + rows = report_repository.list_business_stats( + session, query, offset=query.offset, limit=query.page_size + ) + sums = report_repository.sum_business_stats(session, query) + return { + "list": [_business_row(r) for r in rows], + "total": report_repository.count_business_stats(session, query), + "pageNum": query.page_num, + "pageSize": query.page_size, + "summary": { + "row_count": sums["row_count"], + "ship_total": _q2(_dec(sums["ship_total"])), + "received_total": _q2(_dec(sums["received_total"])), + "sale_total": _q2(_dec(sums["sale_total"])), + "performance_total": _q2(_dec(sums["performance_total"])), + "payable_total": _q2(_dec(sums["payable_total"])), + }, + } + + +def _business_row(row) -> dict: + """业务统计行(ORM 行与三个联查列)。""" + business = row[0] + return { + "business_id": business.id, + "business_no": business.business_no, + "business_date": business.business_date, + "status": business.status, + "customer_name": row.customer_name, + "owner_personnel_name": row.owner_personnel_name, + "total_sale_amount": business.total_sale_amount, + "total_ship_amount": business.total_ship_amount, + "total_received_amount": business.total_received_amount, + "performance_amount": row.performance_amount, + "payable_amount": row.payable_amount, + } + + +# ==== 回款统计(FR-031 之二)==== + + +def payment_stats(session, query) -> dict: + """区间/客户类型/产品类别三维度统计;空桶恒在且为 0。""" + perf_rows = report_repository.group_payment_by_days(session, query) + receipt_rows = report_repository.group_payment_receipt_by_days(session, query) + buckets = _bucket_rows(perf_rows, receipt_rows) + by_customer = _group_rows(report_repository.group_payment_by_customer_type(session, query)) + by_category = _group_rows(report_repository.group_payment_by_product_category(session, query)) + return { + "settlement_month": query.settlement_month, + "buckets": buckets, + "by_customer_type": by_customer, + "by_product_category": by_category, + "totals": { + "receipt_amount": _q2( + sum((b["receipt_amount"] or Decimal("0") for b in buckets), Decimal("0")) + ), + "receipt_count": sum(b["receipt_count"] for b in buckets), + "performance_amount": _q2( + sum((b["performance_amount"] or Decimal("0") for b in buckets), Decimal("0")) + ), + "payable_amount": _q2( + sum((b["payable_amount"] or Decimal("0") for b in buckets), Decimal("0")) + ), + }, + } + + +def _bucket_rows(perf_rows, receipt_rows) -> list[dict]: + """把(天数, 类型, 类别)精确值聚合结果按 8 档区间分桶(Python 侧分桶单点)。""" + perf_acc: dict[str, dict] = {} + for row in perf_rows: + bucket = report_meta.bucket_of(row.payment_days) + acc = perf_acc.setdefault( + bucket.code, {"performance": Decimal("0"), "payable": Decimal("0"), "count": 0} + ) + acc["performance"] += _dec(row.performance_total) + acc["payable"] += _dec(row.payable_total) + acc["count"] += int(row.ledger_count) + receipt_acc: dict[str, dict] = {} + for row in receipt_rows: + bucket = report_meta.bucket_of(row.payment_days) + acc = receipt_acc.setdefault(bucket.code, {"amount": Decimal("0"), "count": 0}) + acc["amount"] += _dec(row.receipt_amount) + acc["count"] += int(row.receipt_count) + zero = Decimal("0") + return [ + { + "code": bucket.code, + "label": bucket.label, + "min_days": bucket.min_days, + "max_days": bucket.max_days, + # 空桶显示 0 而非 null(FR-031「空区间正常显示」) + "receipt_amount": _q2(receipt_acc.get(bucket.code, {}).get("amount", zero)), + "receipt_count": receipt_acc.get(bucket.code, {}).get("count", 0), + "performance_amount": _q2(perf_acc.get(bucket.code, {}).get("performance", zero)), + "payable_amount": _q2(perf_acc.get(bucket.code, {}).get("payable", zero)), + "ledger_count": perf_acc.get(bucket.code, {}).get("count", 0), + } + for bucket in report_meta.get_payment_day_buckets() + ] + + +def _group_rows(rows) -> list[dict]: + """维度对比行(客户类型/产品类别),标签来自 report_meta。 + + 仓储返回元组 (dim, 回款额, 绩效, 应付, 台账行数),按位置解包。 + """ + labels = report_meta.CUSTOMER_TYPE_LABELS | report_meta.PRODUCT_CATEGORY_LABELS + result = [] + for dim, receipt_amount, performance_amount, payable_amount, ledger_count in rows: + result.append( + { + "code": dim, + "label": labels.get(dim, dim or "未分类"), + "receipt_amount": _q2(_dec(receipt_amount)), + "performance_amount": _q2(_dec(performance_amount)), + "payable_amount": _q2(_dec(payable_amount)), + "ledger_count": int(ledger_count), + } + ) + return result + + +def payment_detail(session, query) -> dict: + """区间下钻分页(bucket 未注册 → 400)。""" + bucket = _bucket_by_code(query.bucket) + rows = report_repository.list_payment_detail( + session, query, bucket, offset=query.offset, limit=query.page_size + ) + return { + "list": [_payment_row(r) for r in rows], + "total": report_repository.count_payment_detail(session, query, bucket), + "pageNum": query.page_num, + "pageSize": query.page_size, + "summary": {"row_count": len(rows)}, + } + + +def _payment_row(row) -> dict: + """下钻行(台账行 + 回款金额/批次号)。""" + ledger = row[0] + return { + "ledger_id": ledger.id, + "receipt_id": ledger.receipt_id, + "receipt_batch_no": row.receipt_batch_no, + "amount": row.receipt_amount, + "business_id": ledger.business_id, + "business_no": ledger.business_no, + "personnel_id": ledger.personnel_id, + "personnel_name": ledger.personnel_name, + "customer_type": ledger.customer_type, + "product_category": ledger.product_category, + "ship_date": ledger.ship_date, + "receipt_date": ledger.receipt_date, + "payment_days": ledger.payment_days, + "performance_amount": ledger.performance_amount, + "payable_amount": ledger.payable_amount, + } + + +def _bucket_by_code(code: str) -> DayBucket: + """按编码取区间桶;未注册编码返回 400(防前端传参与后端档位漂移)。""" + for bucket in report_meta.get_payment_day_buckets(): + if bucket.code == code: + return bucket + raise BadRequestError(f"未知的回款天数区间:{code}") + + +# ==== 导出编排(FR-032)==== + +EXPORT_FILENAMES = { + "personal-detail": "personal_detail", + "department-summary": "department_summary", + "business-stats": "business_stats", + "payment-stats": "payment_stats", +} + + +def export_report(session, code: str, query, *, operator: User, meta: RequestMeta | None = None): + """导出 Excel:上限校验 → 取数 → 构建 → 审计留痕(独立 commit)。 + + 返回 (bytes, 文件名);文件名由 API 层拼日期(ASCII,响应头 latin-1)。 + """ + from app.services import report_export + + rows, context = _export_payload(session, code, query) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_REPORT, + action=ACTION_EXPORT, + summary=f"导出{context['title']}({context['row_count']} 行)", + request_data=query.model_dump(exclude_none=True), + ), + meta=meta, + ): + content = report_export.build_workbook( + code=code, + title=context["title"], + filters=context["filters"], + rows=rows, + summary=context.get("summary"), + buckets=context.get("buckets"), + template_version=REPORT_TEMPLATE_VERSION, + operator_name=getattr(operator, "username", ""), + company_name=context["company_name"], + ) + return content, EXPORT_FILENAMES[code] + + +def _export_payload(session, code: str, query) -> tuple[list[dict], dict]: + """按报表编码取全量数据并做上限校验(复用查询函数,不走分页)。""" + if code == "personal-detail": + total = report_repository.count_personal_detail(session, query) + _check_export_limit(total) + rows = report_repository.list_personal_detail( + session, query, offset=0, limit=REPORT_EXPORT_MAX_ROWS + ) + role_names = _role_names(session) + sums = report_repository.sum_personal_detail(session, query) + return [_person_row(r, role_names) for r in rows], { + "title": "个人绩效工资明细表", + "row_count": total, + "filters": _filter_text(query), + "summary": { + "performance_total": _q2(_dec(sums["performance_total"])), + "payable_total": _q2(_dec(sums["payable_total"])), + }, + "company_name": _company_name(session), + } + if code == "department-summary": + data = department_summary(session, query) + member_total = sum(len(t["members"]) for t in data["teams"]) + _check_export_limit(member_total) + rows = _department_export_rows(data) + return rows, { + "title": "部门绩效工资汇总表", + "row_count": member_total, + "filters": f"统计月份 {query.settlement_month}", + "summary": data["company"], + "company_name": _company_name(session), + } + if code == "business-stats": + total = report_repository.count_business_stats(session, query) + _check_export_limit(total) + rows = report_repository.list_business_stats( + session, query, offset=0, limit=REPORT_EXPORT_MAX_ROWS + ) + sums = report_repository.sum_business_stats(session, query) + return [_business_row(r) for r in rows], { + "title": "业务统计表", + "row_count": total, + "filters": _filter_text(query), + "summary": { + "ship_total": _q2(_dec(sums["ship_total"])), + "received_total": _q2(_dec(sums["received_total"])), + "performance_total": _q2(_dec(sums["performance_total"])), + }, + "company_name": _company_name(session), + } + if code == "payment-stats": + data = payment_stats(session, query) + rows = data["buckets"] + return rows, { + "title": "回款情况统计表", + "row_count": len(rows), + "filters": _filter_text(query), + "summary": data["totals"], + "buckets": data, + "company_name": _company_name(session), + } + raise BadRequestError(f"未知的报表编码:{code}") + + +def _check_export_limit(row_count: int) -> None: + """导出上限(需求「超限提示」口径):超过即 400 引导缩小筛选。""" + if row_count > REPORT_EXPORT_MAX_ROWS: + raise BadRequestError( + f"导出数据量 {row_count} 行超过上限 {REPORT_EXPORT_MAX_ROWS} 行," + "请缩小筛选范围(如按月导出)" + ) + + +def _department_export_rows(data: dict) -> list[dict]: + """部门汇总导出展平:每个成员一行,团队列冗余(便于签字稿核对)。""" + rows = [] + for team in data["teams"]: + for member in team["members"]: + rows.append( + { + "manager_name": team["manager_name"], + "member_name": member["personnel_name"], + "role_code": member["role_code"], + "performance_sum": member["performance_sum"], + "payable_sum": member["payable_sum"], + "adjust_net": member["adjust_net"], + "net_total": member["net_total"], + } + ) + return rows + + +def _filter_text(query) -> str: + """筛选条件摘要(导出页眉展示;未设置项不展示)。""" + parts = [] + for field, label in ( + ("settlement_month", "统计月份"), + ("customer_type", "客户类型"), + ("product_category", "产品类别"), + ("role_code", "岗位"), + ("keyword", "关键字"), + ("date_from", "日期起"), + ("date_to", "日期止"), + ): + value = getattr(query, field, None) + if value: + parts.append(f"{label} {value}") + return " | ".join(parts) or "全部" + + +def _company_name(session) -> str: + """导出页眉公司名(未配置时 system_setting_service 返回默认「衍水集团」)。""" + from app.services import system_setting_service + + return system_setting_service.get_setting(session).company_name diff --git a/backend/app/services/role_service.py b/backend/app/services/role_service.py new file mode 100644 index 0000000..f86fbb2 --- /dev/null +++ b/backend/app/services/role_service.py @@ -0,0 +1,256 @@ +"""角色管理业务编排。 + +需求 2.5.1 定义 admin / user 两个登录角色;2026-10-15 复核审批补充需求 +新增营销后勤/财务/领导三个职能登录角色(营销后勤与财务/领导分区于审批 +节点准入)。本模块仍为轻量版(不含权限点分配 —— `permissions` / +`role_permissions` 表继续闲置,待业务方给出权限清单后再启用)。 + +保护规则(都是系统自举与授权安全的最低防线,不只是界面体验): + +- **内置角色禁删禁停**(`BUILTIN_ROLE_CODES`:admin / marketing_ops / + finance / leader)—— `admin` 被删除会使系统无法自举(cli 建管理员与 + `require_admin` 判定依赖);财务/领导是审批节点准入锚点,被删会让 + 在途审批流失去可操作人 +- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默 + 剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE` + 锁行,堵住「校验与删除之间并发绑定」的窗口 +- `role_code` 创建后不可改 —— 它是授权判定(`BUILTIN_ROLE_CODES` 白名单按编码比对)的锚点 +- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态), + 但**不可再分配给用户**(`user_service._resolve_roles` 拦截) +""" + +import logging + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_DISABLE, + ACTION_ENABLE, + ACTION_UPDATE, + BUILTIN_ROLE_CODES, + MODULE_ROLE, +) +from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError +from app.core.response import PageResult +from app.models import Role, User +from app.repositories import role_repository +from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleUpdateIn +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta + +logger = logging.getLogger(__name__) + + +def _to_out(role: Role, user_count: int) -> RoleManageOut: + """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" + return RoleManageOut( + id=role.id, + role_code=role.role_code, + role_name=role.role_name, + description=role.description, + sort_order=role.sort_order, + status=role.status, + user_count=user_count, + ) + + +def _user_count(session: Session, role_id: int) -> int: + """单个角色的绑定用户数(复用批量统计,保持口径一致)。""" + return role_repository.count_users_by_role(session, [role_id]).get(role_id, 0) + + +def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]: + """分页查询角色。 + + 与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**, + 下拉只给启用项。 + """ + roles = role_repository.list_roles( + session, + offset=query.offset, + limit=query.page_size, + keyword=query.keyword, + status=query.status, + ) + total = role_repository.count_roles(session, keyword=query.keyword, status=query.status) + counts = role_repository.count_users_by_role(session, [role.id for role in roles]) + return PageResult[RoleManageOut]( + records=[_to_out(role, counts.get(role.id, 0)) for role in roles], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def create_role( + session: Session, + payload: RoleCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> RoleManageOut: + """新增角色。编码唯一、创建后不可修改,新角色固定启用。 + + :raises BadRequestError: 角色编码已存在(含并发提交的兜底) + """ + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_ROLE, + action=ACTION_CREATE, + summary=f"新增角色 {payload.role_name}({payload.role_code})", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if role_repository.get_by_code(session, payload.role_code) is not None: + raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在") + + role = Role( + role_code=payload.role_code, + role_name=payload.role_name, + description=payload.description, + sort_order=payload.sort_order, + status=1, + ) + try: + role_repository.create(session, role) + out = _to_out(role, 0) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:并发提交同一编码会撞 uk_roles_role_code。 + # 回滚并把唯一键冲突翻译为可读的 400,而非 500 + session.rollback() + raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在") from exc + + logger.info("新增角色 | role_code=%s", payload.role_code) + return out + + +def update_role( + session: Session, + role_id: int, + payload: RoleUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> RoleManageOut: + """编辑角色名称/说明/排序(全量替换,不含 role_code)。 + + :raises NotFoundError: 角色不存在 + """ + role = role_repository.get_by_id(session, role_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_ROLE, + action=ACTION_UPDATE, + summary=f"修改角色 {role.role_name if role else f'#{role_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if role is None: + raise NotFoundError("角色不存在") + + role.role_name = payload.role_name + role.description = payload.description + role.sort_order = payload.sort_order + # 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明) + out = _to_out(role, _user_count(session, role_id)) + session.commit() + logger.info("修改角色 | role_id=%s", role_id) + return out + + +def set_status( + session: Session, + role_id: int, + status: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> RoleManageOut: + """启用或禁用角色。 + + 停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态), + 但该角色不可再分配给用户(`user_service._resolve_roles` 拦截); + 内置角色不可停用。 + + :raises NotFoundError: 角色不存在 + :raises ForbiddenError: 试图停用内置角色 + """ + role = role_repository.get_by_id(session, role_id) + verb = "启用" if status == 1 else "禁用" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_ROLE, + action=ACTION_ENABLE if status == 1 else ACTION_DISABLE, + summary=f"{verb}角色 {role.role_name if role else f'#{role_id}'}", + request_data={"status": status}, + ), + meta=meta, + ): + if role is None: + raise NotFoundError("角色不存在") + + if role.role_code in BUILTIN_ROLE_CODES and status == 0: + raise ForbiddenError("内置角色不可停用") + + role.status = status + out = _to_out(role, _user_count(session, role_id)) + session.commit() + logger.info("变更角色状态 | role_id=%s | status=%s", role_id, status) + return out + + +def delete_role( + session: Session, + role_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """删除角色。 + + 内置角色不可删除(admin 与三个审批职能角色,见 BUILTIN_ROLE_CODES);仍有关联用户的角色不可删除(提示先解除绑定)。 + 查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把 + 新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。 + + :raises NotFoundError: 角色不存在 + :raises ForbiddenError: 试图删除内置角色 + :raises BadRequestError: 角色下仍有绑定的用户 + """ + role = role_repository.get_by_id(session, role_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_ROLE, + action=ACTION_DELETE, + summary=f"删除角色 {role.role_name if role else f'#{role_id}'}", + ), + meta=meta, + ): + if role is None: + raise NotFoundError("角色不存在") + + if role.role_code in BUILTIN_ROLE_CODES: + raise ForbiddenError("内置角色不可删除") + + # user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限 + user_count = _user_count(session, role_id) + if user_count > 0: + raise BadRequestError(f"该角色下仍有 {user_count} 个用户,请先解除绑定") + + role_code = role.role_code # 固化:对象随删除失效 + role_repository.delete(session, role) + session.commit() + logger.info("删除角色 | role_code=%s", role_code) diff --git a/backend/app/services/settlement_service.py b/backend/app/services/settlement_service.py new file mode 100644 index 0000000..3763e45 --- /dev/null +++ b/backend/app/services/settlement_service.py @@ -0,0 +1,622 @@ +"""月度结算服务(M3 ④):生成快照 → 提交审批 → 通过即锁定。 + +确认回款口径(澄清 #8 策略点,2026-10-16 暂定):settlement_status=1 的 +**有效台账行**(is_active=1、settlement_id IS NULL=未被任何月结固化), +按 settlement_month(回款日所在月)归集。答复后只改 ``_confirm_scope``。 + +通过即锁定(status=3 终态):锁定后源数据(回款/发货)写路径由 +``ensure_month_not_locked`` 拦截;调整走调整单(T6)。 +""" + +import logging +from datetime import date +from decimal import ROUND_HALF_UP, Decimal + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.constants import ( + MODULE_SETTLEMENT, +) +from app.core.exceptions import BadRequestError, NotFoundError +from app.models import ( + MonthlySettlement, + Personnel, + SettlementItem, + User, +) +from app.models.approval import APPROVAL_TYPE_MONTHLY_SETTLEMENT +from app.models.performance_ledger import ( + CALC_TYPE_MANAGER_AGGREGATE, + CALC_TYPE_RECEIPT, +) +from app.models.settlement import ( + SETTLEMENT_STATUS_APPROVED, + SETTLEMENT_STATUS_DRAFT, + SETTLEMENT_STATUS_PENDING, + SETTLEMENT_STATUS_REJECTED, +) +from app.repositories import performance_ledger_repository, settlement_repository +from app.schemas.settlement import ( + SettlementDetailOut, + SettlementOut, + SettlementQuery, +) +from app.services import approval_service, audit_service, manager_share +from app.services.approval_service import AuditRecord, RequestMeta +from app.services.formula import manager_team_share, monthly_salary + +logger = logging.getLogger(__name__) + +# 审批对象类型引用(order_type=2 月结、3 调整单) +_SETTLEMENT_ORDER_TYPE = APPROVAL_TYPE_MONTHLY_SETTLEMENT +_SETTLEMENT_NOTE = "确认回款口径(#8 策略点):settlement_status=1 的有效台账行按回款日归月" + + +def generate( + session: Session, + month: str, + *, + operator: User, + meta: RequestMeta | None = None, +) -> SettlementOut: + """生成月度结算快照(草稿):按确认回款口径取有效台账行,按人汇总。 + + - 同月已有月结行(任意状态)时 400——一行一月,重生成走「驳回/草稿态 + 重新生成」(_release_previous_round 解绑回填后清明细重算) + - 经理聚合(calc_type=2)在生成时计算并落库(#12 参数化见 + ``_build_manager_aggregates``) + + 本函数只做编排,各段独立私有函数(口径修改只动对应段)。 + """ + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_SETTLEMENT, + action="生成月结", + summary=f"生成 {month} 月度结算快照", + request_data={"month": month}, + ), + meta=meta, + ): + existing = settlement_repository.get_by_month(session, month) + _guard_regeneration(existing, month) + _release_previous_round(session, existing, month) + + ledger_rows = _confirm_scope(session, month) + if not ledger_rows: + raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)") + + aggregate_rows = _build_manager_aggregates(session, ledger_rows, month) + settlement = _upsert_draft(session, existing, operator, month) + items, ledger_ids = _build_items(session, ledger_rows, aggregate_rows, month) + + settlement_repository.replace_items(session, settlement.id, items) + _apply_totals(settlement, items) + settlement.snapshot_json = {"note": _SETTLEMENT_NOTE} + # 入结回填:台账行 settlement_id 是「未入结」防重判据(_confirm_scope), + # 不回填会让重生成把已固化行反复纳入;聚合行此刻已落库、id 可用 + settlement_repository.update_ledger_settlement_id(session, ledger_ids, settlement.id) + + # 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结 + # 记成失败审计,与库中事实相反) + session.flush() + detail = _detail_of(session, settlement) + session.commit() + logger.info("月结生成 | month=%s | items=%s", month, len(items)) + return detail + + +def _guard_regeneration(existing: MonthlySettlement | None, month: str) -> None: + """重生成准入:已审定 / 待审批不可重生成(草稿、已驳回放行)。""" + if existing is None: + return + if existing.status == SETTLEMENT_STATUS_APPROVED: + raise BadRequestError(f"{month} 已审定锁定,不可重新生成(调整走调整单)") + if existing.status == SETTLEMENT_STATUS_PENDING: + raise BadRequestError(f"{month} 有待审批的月结,不可重新生成") + + +def _release_previous_round( + session: Session, existing: MonthlySettlement | None, month: str +) -> None: + """释放上一轮固化标记(必须早于 _confirm_scope)。 + + 台账行 settlement_id 回填后,「未入结」过滤会把它们永久排除在候选集外; + 旧聚合行也要作废,否则本轮重建的聚合行与旧行重复计入。 + """ + if existing is None: + return + settlement_repository.clear_ledger_settlement_id(session, existing.id) + performance_ledger_repository.deactivate_month_aggregates(session, month) + + +def _upsert_draft( + session: Session, existing: MonthlySettlement | None, operator: User, month: str +) -> MonthlySettlement: + """新建或重置草稿头表(重生成时刷新提交人与状态)。""" + if existing is None: + settlement = MonthlySettlement( + settlement_month=month, + submitter_id=operator.id, + submitter_name=operator.username, + status=SETTLEMENT_STATUS_DRAFT, + ) + return settlement_repository.add(session, settlement) + existing.submitter_id = operator.id + existing.submitter_name = operator.username + existing.status = SETTLEMENT_STATUS_DRAFT + return existing + + +def _build_items( + session: Session, ledger_rows: list, aggregate_rows: list, month: str +) -> tuple[list[SettlementItem], list[int]]: + """按人汇总生成明细行,返回 (明细, 本次入结的台账行 id 列表)。""" + by_person = _bucket_rows(ledger_rows + aggregate_rows) + items = [_item_of(session, bucket, month) for _, bucket in sorted(by_person.items())] + ledger_ids = [lid for bucket in by_person.values() for lid in bucket["ledger_ids"]] + return items, ledger_ids + + +def _bucket_rows(rows: list) -> dict[tuple[int, int], dict]: + """台账行按 (人员, 核算类型) 维度分桶(冗余人读字段取首行)。""" + by_person: dict[tuple[int, int], dict] = {} + for row in rows: + key = (row.personnel_id, row.calc_type) + bucket = by_person.setdefault( + key, + { + "personnel_id": row.personnel_id, + "personnel_name": row.personnel_name, + "role_code": row.role_code, + "calc_type": row.calc_type, + "performance_sum": Decimal("0"), + "payable_sum": Decimal("0"), + "ledger_ids": [], + "is_probation": row.is_probation, + }, + ) + bucket["performance_sum"] += row.performance_amount or Decimal("0") + bucket["payable_sum"] += row.payable_amount or Decimal("0") + bucket["ledger_ids"].append(row.id) + return by_person + + +def _item_of(session: Session, bucket: dict, month: str) -> SettlementItem: + """单条结算明细:月工资三口径净额、试用期说明与台账行引用列表。""" + person = session.get(Personnel, bucket["personnel_id"]) + basic = person.salary_standard if person else None + mode = _salary_mode_of(session, bucket["role_code"], month) + net = ( + monthly_salary(bucket["payable_sum"], basic, mode) + if person and person.is_probation != 1 + else None + ) + note = None + if person and person.is_probation == 1: + note = ( + f"试用期绩效照算不发放;年累计超 {_probation_threshold(session, month)} 元部分" + "年底一次性结算" + ) + return SettlementItem( + personnel_id=bucket["personnel_id"], + personnel_name=bucket["personnel_name"], + role_code=bucket["role_code"], + calc_type=bucket["calc_type"], + performance_sum=bucket["performance_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + payable_sum=bucket["payable_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + basic_amount=basic, + salary_mode=mode, + net_amount=( + net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) if net is not None else None + ), + is_probation=bucket["is_probation"], + probation_note=note, + snapshot_json={"ledger_ids": bucket["ledger_ids"]}, + ) + + +def _apply_totals(settlement: MonthlySettlement, items: list[SettlementItem]) -> None: + """头表三合计固化(明细已两位小数,合计再 HALF_UP 收口)。""" + settlement.total_performance = sum((i.performance_sum for i in items), Decimal("0")).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + settlement.person_count = len({i.personnel_id for i in items}) + + +def submit( + session: Session, + settlement_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> SettlementDetailOut: + """提交审批:开关分流(关=自动通过并锁定;开=待财务复核)。""" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_SETTLEMENT, + action="提交月结", + summary=f"提交月结 #{settlement_id}", + request_data={"settlement_id": settlement_id}, + ), + meta=meta, + ): + settlement = settlement_repository.get_by_id(session, settlement_id) + 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, + ) + settlement.approval_order_no = order.order_no + if order.status == SETTLEMENT_STATUS_APPROVED: + mark_locked(session, settlement, operator=operator) + logger.info("月结自动通过并锁定 | %s", settlement.settlement_month) + else: + settlement.status = SETTLEMENT_STATUS_PENDING + + # 出参在 commit 前固化(audit 约定:commit 后查询失败会把已提交的 + # 月结记成失败审计,与库中事实相反) + session.flush() + detail = _detail_of(session, settlement) + session.commit() + return detail + + +def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: User) -> None: + """审批通过钩子(由 approval_service.act 回调):置已审定+锁定信息。""" + from datetime import datetime + + settlement.status = SETTLEMENT_STATUS_APPROVED + settlement.locked_at = datetime.now() + settlement.locked_by = operator.username + + +def mark_rejected(session: Session, settlement: MonthlySettlement, *, operator: User) -> None: + """审批驳回钩子:回草稿(可修改后重提)。 + + 锁定信息一并清空:草稿态残留 locked_at/locked_by 会让详情页展示 + 「已锁定」的假象(历史缺陷路径下驳回前可能已被提前锁定)。 + """ + settlement.status = SETTLEMENT_STATUS_DRAFT + settlement.locked_at = None + settlement.locked_by = None + + +def ensure_month_not_locked(session: Session, month: str) -> None: + """锁定拦截(回款/发货写路径调用):单据日期所在月已审定 → 400。""" + settlement = settlement_repository.get_by_month(session, month) + if settlement is not None and settlement.status == SETTLEMENT_STATUS_APPROVED: + raise BadRequestError(f"{month} 已月结锁定,源数据不可修改(调整走调整单)") + + +def get_detail(session: Session, settlement_id: int) -> SettlementDetailOut: + settlement = settlement_repository.get_by_id(session, settlement_id) + if settlement is None: + raise NotFoundError("月结不存在") + return _detail_of(session, settlement) + + +def list_page(session: Session, query: SettlementQuery): + from app.core.response import PageResult + from app.schemas.settlement import SettlementOut + + rows = settlement_repository.list_settlements(session, query) + total = settlement_repository.count_settlements(session, query) + return PageResult[SettlementOut]( + records=[SettlementOut.model_validate(r) for r in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +# ---------- 内部 ---------- + + +def _confirm_scope(session: Session, month: str) -> list: + """确认回款口径(#8 策略点):本月**单笔回款**的有效台账行(未入结)。 + + calc_type 必须限定单笔(1):聚合行(2)也落同月台账、也会被回填 + settlement_id,不排除会在重生成时与新聚合行重复计入。 + """ + from app.models import PerformanceLedger + + return list( + session.execute( + select(PerformanceLedger) + .where( + PerformanceLedger.settlement_month == month, + PerformanceLedger.calc_type == CALC_TYPE_RECEIPT, + PerformanceLedger.is_active == 1, + PerformanceLedger.settlement_id.is_(None), + ) + .order_by(PerformanceLedger.id) + ).scalars() + ) + + +def _manager_share_params(session: Session, month: str) -> manager_share.ManagerShareParams: + """薄委托 manager_share 共享模块:月结聚合与报表部门汇总同源取参。 + + 迁移说明(2026-10-19):实现原样迁至 manager_share,行为零变化; + 参数按百分数存储(12.5 表示 12.5%),调用方不得再乘 100。 + """ + return manager_share.manager_share_params(session, month) + + +def _collect_team_buckets( + session: Session, ledger_rows: list, include_manager_personal: bool +) -> dict[int, dict]: + """薄委托 manager_share 共享模块:按经理采集团队业绩(行为零变化)。""" + return manager_share.collect_team_buckets(session, ledger_rows, include_manager_personal) + + +def _aggregate_row( + session: Session, + manager, + bucket: dict, + company_base: Decimal, + share: manager_share.ManagerShareParams, +): + """单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。""" + from app.models import PerformanceLedger + + amount = manager_team_share( + bucket["performance"], + company_base, + team_pct=share.team_pct, + company_pct=share.company_pct, + ) + member_names = "、".join(sorted(bucket["names"])) or "(无成员)" + return performance_ledger_repository.add( + session, + PerformanceLedger( + receipt_id=None, + calc_version=1, # 聚合行无回款维度,版本恒 1(uk 含 NULL 不去重) + calc_type=CALC_TYPE_MANAGER_AGGREGATE, + business_id=bucket["business_id"], + business_no=bucket["business_no"], + personnel_id=manager.id, + personnel_name=manager.personnel_name, + role_code="sales_manager", + customer_type="personal", + product_category="mixed", + receipt_date=share.param_date, + # 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真) + rate_text=f"聚合:{share.team_text}%+{share.company_text}%", + converted_profit=None, + performance_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + barter_applied=0, + payable_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + settlement_month=share.month, + is_probation=1 if manager.is_probation == 1 else 0, + snapshot_json={ + "team_base": str(bucket["performance"]), + "company_base": str(company_base), + "members": member_names, + "params_as_of": str(share.param_date), + "manager_personal_included": 1 if share.include_manager_personal else 0, + "note": ( + f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}" + "经理个人户业绩;9.8 方案公式" + ), + }, + ), + ) + + +def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list: + """按经理生成团队聚合行(calc_type=2)并落库。 + + #12 答复(2026-10-19)参数化:经理个人户业绩是否计入聚合基数由参数 + ``manager.personal.included`` 决定(0=不计入默认,1=计入); + 经理 = 台账行归属人员经 manager_id 关联的 sales_manager 岗人员。 + """ + from app.repositories import business_role_repository + + share = _manager_share_params(session, month) + managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal) + # 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);共享口径单点 + company_base = manager_share.company_base_of(ledger_rows, share.include_manager_personal) + aggregates: list = [] + for manager_id, bucket in managers.items(): + manager = session.get(Personnel, manager_id) + if manager is None: + continue + role = ( + business_role_repository.get_by_id(session, manager.business_role_id) + if manager.business_role_id + else None + ) + if role is None or role.role_code != "sales_manager": + continue # 非营销经理的上级不参与聚合 + aggregates.append(_aggregate_row(session, manager, bucket, company_base, share)) + return aggregates + + +def _month_end(month: str) -> date: + """薄委托 manager_share 共享模块:YYYY-MM → 所属月月末(取参锚点)。 + + 保留包装:tests/unit/test_settlement_helpers.py 直接 import 本名字; + 「补生成历史月份结果不随操作日漂移」的原因说明见 manager_share.month_end。 + """ + return manager_share.month_end(month) + + +def _salary_mode_of(session: Session, role_code: str, month: str) -> str | None: + from app.services.param_service import resolve_snapshot + + snapshot = resolve_snapshot(session, _month_end(month)) + return snapshot.get("salary.basic.mode", role_code=role_code) or None + + +def _probation_threshold(session: Session, month: str) -> Decimal: + from app.services.param_service import parse_fraction, resolve_snapshot + + snapshot = resolve_snapshot(session, _month_end(month)) + from app.services.formula import to_decimal + + return to_decimal(parse_fraction(snapshot.get("probation.annual.threshold"))) + + +def _detail_of(session: Session, settlement: MonthlySettlement) -> SettlementDetailOut: + items = settlement_repository.list_items(session, settlement.id) + detail = SettlementDetailOut.model_validate(settlement) + from app.schemas.settlement import SettlementItemOut + + detail.items = [SettlementItemOut.model_validate(i) for i in items] + return detail + + +def create_adjustment( + session: Session, + payload, + *, + operator: User, + meta: RequestMeta | None = None, +): + """新建调整单:开关分流(关=提交即生效留痕;开=待审批)。""" + from app.core.constants import NO_PREFIX_ADJUSTMENT + from app.models import AdjustmentOrder + from app.services.no_generator import next_no + + amount = _adjustment_amount(payload) + + person = session.get(Personnel, payload.personnel_id) + if person is None: + raise BadRequestError("调整人员不存在") + + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_SETTLEMENT, + action="新建调整单", + summary=f"调整单 {payload.direction} {payload.amount} 元({person.personnel_name})", + request_data=payload.model_dump(), + ), + meta=meta, + ): + order = AdjustmentOrder( + order_no=next_no( + session, + model=AdjustmentOrder, + column_name="order_no", + prefix=NO_PREFIX_ADJUSTMENT, + ), + settlement_month=payload.settlement_month, + personnel_id=payload.personnel_id, + personnel_name=person.personnel_name, + direction=payload.direction, + amount=amount, + reason=payload.reason.strip(), + submitter_id=operator.id, + submitter_name=operator.username, + ) + session.add(order) + session.flush() + + _wire_adjustment_approval(session, order, operator) + out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定) + session.commit() + logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"]) + return out + + +def _adjustment_amount(payload) -> Decimal: + """金额恒正校验(方向表达补发/扣减)。 + + 列注释即「恒正」约定,负数会让方向语义反转、审批/报表按 direction + 汇总出错;格式已由 schema 正则拦截,此处兜底转换 + 语义判定。 + """ + from decimal import InvalidOperation + + try: + amount = Decimal(payload.amount) + except InvalidOperation as exc: + raise BadRequestError("调整金额格式非法(非负两位小数)") from exc + if amount <= 0: + raise BadRequestError("调整金额须大于 0(方向表达补发/扣减)") + return amount + + +def _wire_adjustment_approval(session: Session, order, operator: User) -> None: + """开关分流与留痕统一交给 submit_order(关=自动通过留痕;开=待财务复核)。 + + **不得手工插 ApprovalStep**——``approval_steps.order_id`` 外键指向 + approval_orders(不是 adjustment_orders),用调整单 id 落步必然外键 + 失败(历史缺陷:关开关时新建调整单不可用)。 + """ + from app.models.approval import APPROVAL_STATUS_APPROVED, APPROVAL_TYPE_ADJUSTMENT + from app.models.settlement import ( + ADJUSTMENT_STATUS_EFFECTIVE, + ADJUSTMENT_STATUS_PENDING, + ) + + approval_order = approval_service.submit_order( + session, + order_type=APPROVAL_TYPE_ADJUSTMENT, + ref_id=order.id, + refer_no=order.order_no, + submitter=operator, + ) + order.status = ( + ADJUSTMENT_STATUS_EFFECTIVE + if approval_order.status == APPROVAL_STATUS_APPROVED + else ADJUSTMENT_STATUS_PENDING + ) + session.flush() + + +def _adjustment_out(order) -> dict: + """调整单出参(commit 前固化;expire_on_commit 关闭下也不依赖后置读取)。""" + return { + "id": order.id, + "order_no": order.order_no, + "status": order.status, + "personnel_name": order.personnel_name, + "amount": str(order.amount), + "direction": order.direction, + } + + +def list_adjustments(session: Session) -> list[dict]: + """调整单列表(月份倒序)。""" + from sqlalchemy import select + + from app.models import AdjustmentOrder + + rows = ( + session.execute(select(AdjustmentOrder).order_by(AdjustmentOrder.id.desc()).limit(200)) + .scalars() + .all() + ) + from app.models.settlement import adjustment_label # noqa: F401 + + return [ + { + "id": r.id, + "order_no": r.order_no, + "settlement_month": r.settlement_month, + "personnel_name": r.personnel_name, + "direction": r.direction, + "amount": str(r.amount), + "reason": r.reason, + "status": r.status, + } + for r in rows + ] diff --git a/backend/app/services/shipment_service.py b/backend/app/services/shipment_service.py new file mode 100644 index 0000000..04eebf0 --- /dev/null +++ b/backend/app/services/shipment_service.py @@ -0,0 +1,383 @@ +"""发货管理业务编排(需求 2.2.2)。 + +事务边界在本层:repository 只 flush,commit/rollback 只写在这里。 + +- 行金额(amount)与发货单总金额(total_amount)服务层按 数量×单价 计算 + 固化,不信任前端传值;单价可空时行金额记 0(对齐业务明细口径) +- 业务级汇总(发货/运费合计)回写统一收口 ``business_service._recalc_totals``, + 本模块不另写聚合逻辑;发货单 total_amount 由 repository 行 SUM 重算 +- **写操作前 FOR UPDATE 锁业务行**(receipt_service 同款写准入):发货与 + 回款并发写同一业务时相互串行化,防 ``_recalc_totals`` 重算互相覆盖 + (丢失更新);锁序统一「业务行 → 单据编号区间」,与业务/回款同构 +- 业务状态门槛:仅执行中(1)业务可写发货单 —— 终态业务的发货单禁改禁删 + (一期保守口径:编辑不影响已核算回款、核算未发生,但会改业务级汇总 + 基线,设计未定项待业务确认后可放开终态编辑) +- 删除保护:被回款批次指定(receipts.shipment_id)的发货单禁删,先解除 + 回款的批次指定;删除连带明细行并重算业务级汇总 +- 编号 SH 生成复用 no_generator.next_no;残余竞态以唯一键(1062)冲突 + 重试一次兜底,模式同 business_service.create_business +- 审计:锁行与编号生成放在进入审计前(不产生未提交变更),状态校验在 + 审计上下文内,失败也留痕 —— 失败回滚释放已取序号,当日序号空洞无害 + (唯一性不依赖连续);ship_date 是回款天数基准日,编辑即时生效(M3 按 + 编辑后基准日实时派生,无固化快照可漂移) +""" + +import logging +from decimal import ROUND_HALF_UP, Decimal + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_UPDATE, + MODULE_SHIPMENT, + NO_PREFIX_SHIPMENT, +) +from app.core.db_errors import is_duplicate_entry +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageResult +from app.models import Business, Shipment, ShipmentItem, User +from app.models.business import BUSINESS_STATUS_ACTIVE +from app.repositories import business_repository, shipment_repository +from app.schemas.shipment import ( + ShipmentCreateIn, + ShipmentDetailOut, + ShipmentItemIn, + ShipmentItemOut, + ShipmentOut, + ShipmentQuery, + ShipmentUpdateIn, +) +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta +from app.services.business_service import _recalc_totals +from app.services.no_generator import next_no + +logger = logging.getLogger(__name__) + +# 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY) +_NO_RETRY = 1 + + +def _to_out(row: tuple[Shipment, str]) -> ShipmentOut: + """(shipment, business_no) → 响应模型;金额出参 str。""" + shipment, business_no = row + return ShipmentOut( + id=shipment.id, + shipment_no=shipment.shipment_no, + business_id=shipment.business_id, + business_no=business_no, + ship_date=shipment.ship_date, + freight_amount=str(shipment.freight_amount), + total_amount=str(shipment.total_amount), + remark=shipment.remark, + ) + + +def _item_to_out(item: ShipmentItem) -> ShipmentItemOut: + return ShipmentItemOut( + id=item.id, + product_id=item.product_id, + product_name=item.product_name, + spec_model=item.spec_model, + unit=item.unit, + quantity=str(item.quantity), + sale_price=str(item.sale_price) if item.sale_price is not None else None, + amount=str(item.amount), + ) + + +def _amount_or_zero(value: str | None) -> Decimal: + """可空金额入参转 Decimal,空记 0(运费可缺省的统一归一)。""" + return Decimal(value) if value is not None else Decimal("0") + + +def _build_item_rows(items: list[ShipmentItemIn]) -> list[ShipmentItem]: + """入参明细行转 ORM 行:amount 服务层按 数量×单价 计算固化。 + + 不信任前端传值(schema 亦无 amount 入参)——单价可空时行金额记 0, + 由用户在编辑中补价后重算;shipment_id 由 replace_items 统一回填。 + 乘积显式 quantize 两位固化(模式同 business_service._build_item_rows): + 数量(2位)×单价(4位) 可产 6 位小数,直写 DECIMAL(14,2) 会被 MySQL + 静默舍入且回显漂移。 + """ + rows: list[ShipmentItem] = [] + for item in items: + quantity = Decimal(item.quantity) + sale_price = Decimal(item.sale_price) if item.sale_price is not None else None + amount = ( + (quantity * sale_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if sale_price is not None + else Decimal("0.00") + ) + rows.append( + ShipmentItem( + product_id=item.product_id, + product_name=item.product_name, + spec_model=item.spec_model or "", + unit=item.unit, + quantity=quantity, + sale_price=sale_price, + amount=amount, + ) + ) + return rows + + +def _generate_no(session: Session) -> str: + """生成发货单号(SH 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。""" + return next_no(session, model=Shipment, column_name="shipment_no", prefix=NO_PREFIX_SHIPMENT) + + +def _ensure_business_writable(session: Session, business_id: int, action: str) -> Business: + """锁业务行并要求状态=1 执行中(新增/编辑/删除共用的写准入)。 + + 锁在判定之前取:判定结果基于锁定后的行,配合 _recalc_totals 的重算 + 同事务提交,并发写不会丢失更新。与 receipt_service._ensure_business_writable + 同构 —— 文案单据名落到发货单(私有函数各自保留,不为一个字符串上提 + 公共层)。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 业务已完成或已终止 + """ + business = business_repository.get_by_id(session, business_id, for_update=True) + if business is None: + raise NotFoundError("业务不存在") + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError(f"业务已完成或已终止,禁止{action}") + return business + + +def _is_duplicate_entry(exc: IntegrityError) -> bool: + """识别 MySQL 唯一键冲突(1062),与 business/receipt_service 同口径。""" + return is_duplicate_entry(exc) + + +def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]: + """分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。""" + rows = shipment_repository.list_shipments( + session, + offset=query.offset, + limit=query.page_size, + business_id=query.business_id, + ship_date_from=query.ship_date_from, + ship_date_to=query.ship_date_to, + ) + total = shipment_repository.count_shipments( + session, + business_id=query.business_id, + ship_date_from=query.ship_date_from, + ship_date_to=query.ship_date_to, + ) + return PageResult[ShipmentOut]( + records=[_to_out(row) for row in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_detail(session: Session, shipment_id: int) -> ShipmentDetailOut: + """发货单详情(附明细行,供编辑抽屉回显)。 + + :raises NotFoundError: 发货单不存在 + """ + row = shipment_repository.get_with_business_no(session, shipment_id) + if row is None: + raise NotFoundError("发货单不存在") + out = ShipmentDetailOut(**_to_out(row).model_dump()) + out.items = [ + _item_to_out(item) for item in shipment_repository.list_items(session, shipment_id) + ] + return out + + +def create_shipment( + session: Session, + payload: ShipmentCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> ShipmentOut: + """新增发货单:锁业务行 → 校验 → 建单 + 明细 + 双重汇总回写,一个事务。 + + 摘要两号(发货单号/业务号)在进入审计前就绪:业务号取自锁行结果、 + 发货单号先行生成(SELECT MAX FOR UPDATE,不产生未提交变更);状态 + 校验在审计上下文内,失败也留痕 —— 失败路径回滚释放已取序号,当日 + 序号出现空洞无害(唯一性不依赖连续)。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 业务已完成或已终止,禁止新发货 + """ + business = business_repository.get_by_id(session, payload.business_id, for_update=True) + shipment_no = _generate_no(session) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_SHIPMENT, + action=ACTION_CREATE, + summary=( + f"新增发货单 {shipment_no}" + f"(业务 {business.business_no if business else f'#{payload.business_id}'}," + f"明细 {len(payload.items)} 行)" + ), + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + # 单号唯一键冲突的重试整体收在审计上下文内(模式同 + # business_service.create_business):rollback 释放旧锁,重试前重新 + # 锁业务行与生成编号——成功/最终失败各只记一条审计,无递归 + for attempt in range(_NO_RETRY + 1): + try: + if business is None: + raise NotFoundError("业务不存在") + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError("业务已完成或已终止,禁止新发货") + # 月结锁定拦截:发货日所在月已审定 → 源数据不可改(调整走调整单) + from app.services import settlement_service + + settlement_service.ensure_month_not_locked( + session, payload.ship_date.strftime("%Y-%m") + ) + + shipment = Shipment( + shipment_no=shipment_no, + business_id=payload.business_id, + ship_date=payload.ship_date, + freight_amount=_amount_or_zero(payload.freight_amount), + total_amount=Decimal("0"), + remark=payload.remark, + ) + shipment_repository.create(session, shipment) + shipment_repository.replace_items( + session, shipment.id, _build_item_rows(payload.items) + ) + total = shipment_repository.sum_item_amount(session, shipment.id) + shipment.total_amount = total if total is not None else Decimal("0") + _recalc_totals(session, business) + out = _to_out((shipment, business.business_no)) + new_id, new_no = shipment.id, shipment_no # 固化:commit 后不碰 ORM + session.commit() + break + except IntegrityError as exc: + session.rollback() + if _is_duplicate_entry(exc) and attempt < _NO_RETRY: + business = business_repository.get_by_id( + session, payload.business_id, for_update=True + ) + shipment_no = _generate_no(session) + continue + raise + + logger.info( + "新增发货单 | shipment_id=%s | no=%s | business_id=%s", + new_id, + new_no, + payload.business_id, + ) + return out + + +def update_shipment( + session: Session, + shipment_id: int, + payload: ShipmentUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> ShipmentOut: + """编辑发货单(明细全量替换)+ 双重汇总回写,一个事务。 + + business_id 不在编辑入参内 —— 发货单挂靠的业务创建时定死,改挂会让 + 已指定本发货单的回款语义断裂。终态业务的发货单禁改(与回款写准入同 + 口径;一期保守处理,设计未定项待业务确认后可放开——发货编辑不影响 + 已核算回款,核算未发生,无固化快照可漂移)。 + + :raises NotFoundError: 发货单不存在 / 业务不存在 + :raises BadRequestError: 业务已完成或已终止 + """ + shipment = shipment_repository.get_by_id(session, shipment_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_SHIPMENT, + action=ACTION_UPDATE, + summary=f"修改发货单 {shipment.shipment_no if shipment else f'#{shipment_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if shipment is None: + raise NotFoundError("发货单不存在") + business = _ensure_business_writable(session, shipment.business_id, "编辑发货单") + # 月结锁定拦截:原发货日与新月都不得落入已锁定月份(改日期=跨月搬数) + from app.services import settlement_service + + settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m")) + settlement_service.ensure_month_not_locked(session, payload.ship_date.strftime("%Y-%m")) + + shipment.ship_date = payload.ship_date + shipment.freight_amount = _amount_or_zero(payload.freight_amount) + shipment.remark = payload.remark + shipment_repository.replace_items(session, shipment_id, _build_item_rows(payload.items)) + total = shipment_repository.sum_item_amount(session, shipment_id) + shipment.total_amount = total if total is not None else Decimal("0") + _recalc_totals(session, business) + # business_id 创建时定死不可改挂,锁定的 business.business_no 与重查同源 + out = _to_out((shipment, business.business_no)) + shipment_no = shipment.shipment_no + session.commit() + # commit 后不再触碰 ORM 属性(audit_service 约定):单号已固化 + logger.info("修改发货单 | shipment_id=%s | no=%s", shipment_id, shipment_no) + return out + + +def delete_shipment( + session: Session, + shipment_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """删除发货单(物理删除,连带明细行)+ 业务级汇总回写,一个事务。 + + 被回款批次指定(receipts.shipment_id 引用)→ 400 引导先解除回款的 + 批次指定;业务须状态=1 执行中(终态业务的发货单禁删,与回款写准入 + 同口径)—— 发货单的运费/金额是业务级汇总与 M3 核算的源数据,终态后 + 不得变动。 + + :raises NotFoundError: 发货单不存在 / 业务不存在 + :raises BadRequestError: 业务已完成或已终止 / 已被回款批次指定 + """ + shipment = shipment_repository.get_by_id(session, shipment_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_SHIPMENT, + action=ACTION_DELETE, + summary=f"删除发货单 {shipment.shipment_no if shipment else f'#{shipment_id}'}", + ), + meta=meta, + ): + if shipment is None: + raise NotFoundError("发货单不存在") + business = _ensure_business_writable(session, shipment.business_id, "删除发货单") + # 月结锁定拦截:已锁定月份的发货单是月结快照的源数据,不得删除 + from app.services import settlement_service + + settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m")) + if shipment_repository.count_receipt_refs(session, shipment_id): + raise BadRequestError("已被回款批次指定,请先解除回款的批次指定") + + shipment_no = shipment.shipment_no + shipment_repository.delete_with_items(session, shipment_id) + _recalc_totals(session, business) + session.commit() + # commit 后不再触碰 ORM 属性(audit_service 约定):单号已固化 + logger.info("删除发货单 | shipment_id=%s | no=%s", shipment_id, shipment_no) diff --git a/backend/app/services/system_setting_service.py b/backend/app/services/system_setting_service.py new file mode 100644 index 0000000..bb0af93 --- /dev/null +++ b/backend/app/services/system_setting_service.py @@ -0,0 +1,171 @@ +"""系统设置(公司信息)业务编排。 + +单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo 只存**相对路径** +(uploads/logo/ 下固定名覆盖),文件不入库的理由:图片进数据库会让每次 +读取与备份放大传输,且无法独立替换;DB 只持有一行指针。 + +logo 安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名 +后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与 +覆盖任意文件。 +""" + +import logging +from pathlib import Path + +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.constants import ACTION_UPDATE, MODULE_SYSTEM_SETTINGS +from app.core.exceptions import BadRequestError, NotFoundError +from app.models import SystemSetting, User +from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta + +logger = logging.getLogger(__name__) + +# magic bytes:PNG 固定 8 字节签名;JPEG 以 SOI + APP0/JFIF 起始 +_MAGIC = { + "png": b"\x89PNG\r\n\x1a\n", + "jpg": b"\xff\xd8\xff", +} +LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2MB:页眉/登录页展示尺寸上限,防上传滥用 + +SETTINGS_ID = 1 # 全系统单行主键 + + +def get_setting(session: Session) -> SystemSetting: + """取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。""" + setting = session.get(SystemSetting, SETTINGS_ID) + if setting is None: + setting = SystemSetting(id=SETTINGS_ID, company_name="衍水集团") + session.add(setting) + session.flush() + return setting + + +def to_out(setting: SystemSetting) -> SettingOut: + """管理端出参(logo_url 带更新时间版本参数,替换图片后浏览器不再用旧缓存)。""" + return SettingOut( + company_name=setting.company_name, + logo_url=_logo_url(setting), + ) + + +def to_public_out(setting: SystemSetting) -> SettingPublicOut: + """匿名端点出参(白名单字段,独立 schema 见模块说明)。""" + return SettingPublicOut( + company_name=setting.company_name, + logo_url=_logo_url(setting), + ) + + +def _logo_url(setting: SystemSetting) -> str | None: + if not setting.logo_path: + return None + version = int(setting.update_time.timestamp()) if setting.update_time else 0 + return f"/api/v1/system/settings/logo/public?v={version}" + + +def update_company( + session: Session, + payload: SettingUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> SettingOut: + """更新公司名称(审计留痕)。""" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_SYSTEM_SETTINGS, + action=ACTION_UPDATE, + summary=f"修改公司名称为「{payload.company_name}」", + request_data=payload.model_dump(), + ), + meta=meta, + ): + setting = get_setting(session) + setting.company_name = payload.company_name.strip() + session.commit() + return to_out(setting) + + +def save_logo( + session: Session, + content: bytes, + ext: str, + *, + operator: User, + meta: RequestMeta | None = None, +) -> SettingOut: + """上传 logo:校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。 + + 旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。 + """ + ext = ext.lower() + if ext not in _MAGIC: + raise BadRequestError("logo 仅支持 png/jpg 格式") + if len(content) > LOGO_MAX_BYTES: + raise BadRequestError("logo 文件不能超过 2MB") + if not content.startswith(_MAGIC[ext]): + raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)") + + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_SYSTEM_SETTINGS, + action=ACTION_UPDATE, + summary="更新公司 logo", + request_data={"ext": ext, "size": len(content)}, + ), + meta=meta, + ): + setting = get_setting(session) + old_path = setting.logo_path + + # 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前, + # commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名 + # 由写入覆盖,无需也不会误删新文件 + target = _logo_dir() / f"logo.{ext}" + target.write_bytes(content) + + setting.logo_path = f"logo/logo.{ext}" + session.commit() + + old_ext = Path(old_path).suffix.lstrip(".") if old_path else None + if old_ext and old_ext != ext: + try: + _cleanup_old_logo(old_ext) + except OSError: + # 清理失败不影响本次更新(库与新文件均已生效);若异常外抛, + # 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反 + logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True) + logger.info("更新公司 logo | size=%s ext=%s", len(content), ext) + return to_out(setting) + + +def read_logo(session: Session) -> tuple[Path, str]: + """按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。""" + setting = get_setting(session) + if not setting.logo_path: + raise NotFoundError("尚未上传 logo") + path = get_settings().upload_dir / setting.logo_path + if not path.is_file(): + raise NotFoundError("logo 文件不存在,请重新上传") + return path, path.suffix.lstrip(".") + + +def _logo_dir() -> Path: + directory = get_settings().upload_dir / "logo" + directory.mkdir(parents=True, exist_ok=True) + return directory + + +def _cleanup_old_logo(old_ext: str) -> None: + if old_ext in _MAGIC: + old_file = _logo_dir() / f"logo.{old_ext}" + if old_file.exists(): + old_file.unlink(missing_ok=True) diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..f679e00 --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,360 @@ +"""用户管理业务编排。 + +**事务边界在本层**:每个公开方法自行决定何时 commit。 +repository 只 flush,事务由这里控制。 + +设计取舍: + +- 删除采用**物理删除**,与「删除即禁用」并不矛盾 —— 后者是对业务方的建议 + (停用账号应走禁用而非删除,以保留审计线索),但需求 2.5.1 明确列了 + 「增删改查」,故保留删除能力;`operation_logs` 通过 SET NULL + 冗余 username + 保证删除后仍可追溯 +- 不允许删除或禁用**自己** —— 管理员误操作会立刻失去全部管理权限, + 且没有其他账号可以救回来 +- 全部写操作经 `audit_service.audit()` 留痕,**成功与失败都记**。摘要需要 + 「目标用户名」,故对目标的只读预取放在审计上下文**之前** —— 这样目标 + 不存在(NotFound 失败)时也能留下可读的记录 +- **响应对象在 commit 之前构造**(`_to_out` 读的是内存中的已加载状态): + commit 后再触碰 ORM 对象会因过期而触发额外查询,该查询一旦抛错, + 「已提交成功」的操作会被审计记成失败 +""" + +import logging + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_DISABLE, + ACTION_ENABLE, + ACTION_RESET_PASSWORD, + ACTION_UPDATE, + MODULE_USER, + ROLE_ADMIN, +) +from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError +from app.core.response import PageResult +from app.core.security import hash_password +from app.models import User +from app.repositories import role_repository, user_repository +from app.schemas.role import RoleOut +from app.schemas.user import UserCreateIn, UserOut, UserQuery, UserUpdateIn +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta + +logger = logging.getLogger(__name__) + + +def _to_out(user: User) -> UserOut: + """ORM 对象转响应模型。 + + 禁止把 ORM 对象直接当 response_model —— 表结构变更会直接泄漏到接口契约上。 + """ + return UserOut( + id=user.id, + username=user.username, + real_name=user.real_name, + phone=user.phone, + email=user.email, + status=user.status, + roles=user_repository.get_role_codes(user), + last_login_time=user.last_login_time, + remark=user.remark, + create_time=user.create_time, + ) + + +def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]: + """分页查询用户列表。""" + users = user_repository.list_users( + session, + offset=query.offset, + limit=query.page_size, + username=query.username, + real_name=query.real_name, + status=query.status, + role_code=query.role_code, + ) + total = user_repository.count_users( + session, + username=query.username, + real_name=query.real_name, + status=query.status, + role_code=query.role_code, + ) + return PageResult[UserOut]( + records=[_to_out(user) for user in users], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_user(session: Session, user_id: int) -> UserOut: + """查询用户详情。 + + :raises NotFoundError: 用户不存在 + """ + user = user_repository.get_by_id(session, user_id) + if user is None: + raise NotFoundError("用户不存在") + return _to_out(user) + + +def create_user( + session: Session, + payload: UserCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> UserOut: + """新增用户。 + + :raises BadRequestError: 用户名已存在,或角色编码无效/已停用 + """ + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_USER, + action=ACTION_CREATE, + summary=f"新增用户 {payload.username}", + # password 会随字典进入审计管道,由 repository 层的结构化脱敏抹除其值 + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if user_repository.get_by_username(session, payload.username) is not None: + raise BadRequestError(f"用户名 {payload.username!r} 已存在") + + roles = _resolve_roles(session, payload.roles) + + user = User( + username=payload.username, + password=hash_password(payload.password), + real_name=payload.real_name, + phone=payload.phone, + email=payload.email, + status=1, + remark=payload.remark, + ) + try: + user_repository.create(session, user) + user_repository.replace_roles(session, user, roles) + out = _to_out(user) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:并发提交同一用户名会撞 uk_users_username。 + # 回滚并把唯一键冲突翻译为可读的 400,而非 500 + session.rollback() + raise BadRequestError(f"用户名 {payload.username!r} 已存在") from exc + + logger.info("新增用户 | username=%s | roles=%s", payload.username, payload.roles) + return out + + +def update_user( + session: Session, + user_id: int, + payload: UserUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> UserOut: + """编辑用户的基本信息与角色。 + + **不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口, + 以免"改资料"顺手改掉密码这类越权操作。 + + 空串字段(`''`)归一为 None:编辑表单会把未修改的字段以空串提交, + 若原样写入会把库中的 NULL 污染成空串(两类空值语义漂移且不可逆); + 归一后「清空输入框」= 写回 NULL,语义正确。 + + :raises NotFoundError: 用户不存在 + :raises BadRequestError: 角色编码无效或已停用 + """ + user = user_repository.get_by_id(session, user_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_USER, + action=ACTION_UPDATE, + summary=f"修改用户 {user.username if user else f'#{user_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if user is None: + raise NotFoundError("用户不存在") + + if payload.real_name is not None: + user.real_name = payload.real_name or None + if payload.phone is not None: + user.phone = payload.phone or None + if payload.email is not None: + user.email = payload.email or None + if payload.remark is not None: + user.remark = payload.remark or None + + # 不允许把自己降级掉管理角色,否则改完立刻失去管理权限 + if payload.roles is not None and user_id == operator.id and ROLE_ADMIN not in payload.roles: + raise ForbiddenError("不能移除自己的管理员角色") + + session.flush() + + if payload.roles is not None: + roles = _resolve_roles(session, payload.roles) + user_repository.replace_roles(session, user, roles) + + out = _to_out(user) + session.commit() + logger.info("编辑用户 | user_id=%s | roles=%s", user_id, payload.roles) + return out + + +def reset_password( + session: Session, + user_id: int, + new_password: str, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """重置用户密码。 + + 同时把 `token_version` 加一,使该用户**已签发的令牌立即失效** —— + 改密后旧令牌仍可用是常见疏漏,这里与「离职即失效」复用同一机制。 + + :raises NotFoundError: 用户不存在 + """ + user = user_repository.get_by_id(session, user_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_USER, + action=ACTION_RESET_PASSWORD, + summary=f"重置用户 {user.username if user else f'#{user_id}'} 的密码", + # 明文进审计管道,由 repository 层的结构化脱敏整体抹除 + request_data={"new_password": new_password}, + ), + meta=meta, + ): + if user is None: + raise NotFoundError("用户不存在") + + user.password = hash_password(new_password) + user.token_version += 1 + session.commit() + logger.info("重置密码 | user_id=%s(令牌版本已提升)", user_id) + + +def set_status( + session: Session, + user_id: int, + status: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> UserOut: + """启用或禁用用户。 + + 禁用时同样提升 `token_version`,使已签发令牌立即失效 —— + 否则被禁用者仍可凭旧令牌访问直到过期(最长 2 小时)。 + + :raises NotFoundError: 用户不存在 + :raises ForbiddenError: 试图禁用自己 + """ + user = user_repository.get_by_id(session, user_id) + verb = "启用" if status == 1 else "禁用" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_USER, + action=ACTION_ENABLE if status == 1 else ACTION_DISABLE, + summary=f"{verb}用户 {user.username if user else f'#{user_id}'}", + request_data={"status": status}, + ), + meta=meta, + ): + if user is None: + raise NotFoundError("用户不存在") + + if status == 0 and user_id == operator.id: + raise ForbiddenError("不能禁用自己") + + user.status = status + if status == 0: + user.token_version += 1 + out = _to_out(user) + session.commit() + logger.info("变更用户状态 | user_id=%s | status=%s", user_id, status) + return out + + +def delete_user( + session: Session, + user_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """删除用户。 + + 物理删除。历史操作日志通过 `user_id` 置空 + 冗余 `username` 保留可追溯性。 + + :raises NotFoundError: 用户不存在 + :raises ForbiddenError: 试图删除自己 + """ + user = user_repository.get_by_id(session, user_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_USER, + action=ACTION_DELETE, + summary=f"删除用户 {user.username if user else f'#{user_id}'}", + ), + meta=meta, + ): + if user is None: + raise NotFoundError("用户不存在") + + if user_id == operator.id: + raise ForbiddenError("不能删除自己") + + username = user.username # 固化:对象随删除失效 + user_repository.delete(session, user) + session.commit() + logger.info("删除用户 | username=%s", username) + + +def list_roles(session: Session) -> list[RoleOut]: + """取全部启用角色,供用户表单的角色选择使用。""" + return [RoleOut.model_validate(role) for role in role_repository.get_all_roles(session)] + + +def _resolve_roles(session: Session, role_codes: list[str]) -> list: + """把角色编码解析为角色对象,校验全部存在且**未停用**。 + + 停用角色不出现在表单下拉中;接口层若不拦截,直接调 API(或使用停用前 + 加载的旧选项)仍可绑定,与「停用后不可再分配」的语义矛盾。已绑定停用 + 角色的用户需先转为启用角色再保存。 + + :raises BadRequestError: 存在无效编码,或角色已停用 + """ + if not role_codes: + raise BadRequestError("至少需要指定一个角色") + roles = role_repository.get_roles_by_codes(session, role_codes) + if len(roles) != len(set(role_codes)): + valid = {role.role_code for role in roles} + invalid = sorted(set(role_codes) - valid) + raise BadRequestError(f"角色编码无效:{invalid}") + disabled = sorted(role.role_code for role in roles if role.status != 1) + if disabled: + raise BadRequestError(f"角色已停用,不可分配:{disabled}") + return list(roles) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0a7dce5..1d949d1 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -34,6 +34,8 @@ dependencies = [ # 图形验证码(纯 Python,依赖 Pillow) "captcha>=0.6", "pillow>=11.0", + "openpyxl>=3.1.5", + "python-multipart>=0.0.32", ] [dependency-groups] diff --git a/backend/tests/README.md b/backend/tests/README.md index cc75c6a..fbe5481 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -1,6 +1,6 @@ # 后端测试 -> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 184 个用例全绿(单元 155 / 集成 29)。 +> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 697 个用例全绿(2026-10-16,M3 增量批次后)。 ## 一、测试约定 @@ -57,6 +57,23 @@ uv run pytest tests/integration -q # 仅集成 | `unit/test_auth_schemas.py` | 密码**按字节**校验 | 9 | 24 个汉字 = 72 字节正好、25 个被拒 —— 按字符数校验(如 `max_length=20`)根本拦不住,会让登录接口 500 | | `integration/test_auth_flow.py` | 整条链路**串联正确性** | 16 | 验证码→登录→持令牌访问→登出失效;验证码重放被拒;失败原因如实落库且**日志表无任何密码字段**;账号不存在与密码错误文案完全相同(防枚举);禁用账号与 `token_version` 提升均使旧令牌立即失效 | +### 系统管理 · 用户与角色管理 + +| 测试文件 | 守什么 | 用例数 | 关键覆盖点 | +| --- | --- | :---: | --- | +| `unit/test_audit_service.py` | 审计上下文的语义 | 16 | 正常退出记成功、抛异常记失败;**失败时先 rollback 再写日志**且原异常原样重抛(顺序颠倒时数据库异常会让失败记录丢成 PendingRollbackError);**操作人标量在进入时固化**(失败路径不再触碰会过期的 ORM 对象);进入时脏会话告警(防嵌套半提交);审计自身失败不外抛、不替换业务异常 | +| `unit/test_audit_serialization.py` | 入库前截断与脱敏不丢数据、不泄密 | 16 | 各列按模型常量截断;60KB 请求参数按 **UTF-8 字节边界**截断后是原串的干净前缀(中文不切半);**结构化抹除**(按敏感键名整值替换):前导空白/空格/逗号/引号密码全被抹除且 JSON 结构完好、嵌套 dict/list 与键名大小写、非敏感键不误伤;手机号仍由 scrub 文本兜底;孤立代理字符不再抛错丢审计 | +| `unit/test_role_schemas.py` | 角色编码格式红线 | 16 | `^[a-z][a-z0-9_]{1,31}$` 全边界(大写/中文/数字或下划线开头/单字符/超长被拒);编辑模型**不接受 role_code**(多余字段忽略);状态只能 0/1 | +| `integration/test_user_management.py` | 用户 CRUD 串联正确性 + **审计留痕** | 23 | 分页以 `pageNum/pageSize` 生效(钉死别名契约,改蛇形会静默失效);自删/自禁/移除自己 admin 角色 403;禁用与重置密码使旧令牌立即失效;**每个写操作(含失败的)在 operation_logs 有对应记录**、request_data 全文扫不到明文密码;账号删除后审计行 `user_id` 置空而 username 保留;**编辑时保留既有角色不撞唯一键**(1062 回归);**编辑未修改字段不把 NULL 污染成空串**;**含下划线用户名不误配**(LIKE 转义回归) | +| `integration/test_role_management.py` | 角色保护规则(系统自举与授权安全的最低防线) | 13 | admin 角色禁删禁停 403;有绑定用户的角色禁删 400 且消息含用户数、解绑后可删;编码创建后不可改;**停用角色不可再分配**(接口层拦截);停用后不在 `/users/meta/roles` 但仍在管理列表;`user_count` 正确;普通用户 403 | + +### 系统管理 · 日志查询 + +| 测试文件 | 守什么 | 用例数 | 关键覆盖点 | +| --- | --- | :---: | --- | +| `unit/test_log_schemas.py` | 时间范围校验与时区归一 | 13 | 起始晚于结束拒绝(两类日志共用基类,放过去则 SQL 条件互斥、查询恒空且无提示);**带时区输入转本地 naive**(Z 后缀 → 东八区 08:00;混用 aware/naive 不再抛 TypeError 穿透 500);边界相等通过;仅传一端不校验 | +| `integration/test_log_query.py` | 写入与查询的**口径一致** | 13 | 写操作产生的审计可按 username/module/action/success/时间范围检索到,摘要含目标用户名;失败记录带中文 `error_message`;**列表不含 request_data、详情端点返回已脱敏参数**(两段式,含 404/403);起始晚于结束 400;登录日志直插造数后可按 ip 精确筛选;两类日志普通用户 403 | + ### 应用骨架与交付产物 | 测试文件 | 守什么 | 用例数 | 关键覆盖点 | @@ -64,6 +81,72 @@ uv run pytest tests/integration -q # 仅集成 | `integration/test_health.py` | 统一信封与全局异常 | 7 | `/api/health` 始终 200 且依赖状态如实上报;404/405 返回统一信封而非框架默认 `detail` 数组;`/api/docs` 可访问 | | `integration/test_schema_sync.py` | DDL 不漂移 | 6 | `db/migration/V1__*.sql` 与 `db/init/init-dev.sql` 的**表集合与列集合逐表比对**——两份 DDL 手工同步迟早漂移,且漂移后果隐蔽(开发库结构与生产不一致,上线才发现) | +### M1 计提参数与岗位角色(2026-10-13 完成) + +| 文件 | 守什么 | 用例数 | +| --- | --- | --- | +| `tests/integration/test_business_roles.py` | 岗位 CRUD 链路、内置岗位禁删禁停(种子 3 个)、模板编码校验、编辑换模板、权限 403、模板下拉 5 项 | 8 | +| `tests/integration/test_param_management.py` | 目录 16 项与七分组、企业户 2‰→3‰ 按到账日回溯、斜率 4/360 分数原样存储、变更生命周期(立即/未来生效不泄漏/同日取最新/撤回回退/已生效禁撤)、值校验(上限/枚举/除零分数/过去日期)、预览金标准(27 天=97/10000、折算 15000、绩效 145.5、可付 52.38;361 天=4‰)、经理聚合口径拒绝、权限 403 | 18 | +| `tests/unit/test_formula.py`、`tests/unit/test_param_registry.py` | 八个公式纯函数精确分数断言(第 1 天=899/90000、360=6‰、361=4‰、27 天、防除零、负绩效不归零、R×R)、注册表 16 项与迁移种子同源、模板声明参数闭环 | 43 | +| `tests/unit/test_param_snapshot.py` | 快照覆盖链(角色>产品类别>默认)、注册表默认值兜底、同日取最新、冻结不可变 | 12 | + +运行:`uv run pytest -q` → **406 passed**(2026-10-13 全量含回归;T5 冒烟另验证提交/生效/撤回/对比/导出/预览全链路)。 + +### 基础数据管理(2026-10-13 完成) + +| 文件 | 守什么 | 用例数 | +| --- | --- | --- | +| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 | +| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 | +| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕 | 14 | + +运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。 + +### 业务数据管理(2026-10-13 完成) + +| 文件 | 守什么 | 用例数 | +| --- | --- | --- | +| `tests/integration/test_businesses.py` | 未登录 401;编号 `YW{YYYYMMDD}-{4位}` 与当日序号递增;空明细/数量 0/非法类别/单价非数字 400;**销售额服务层固化**(schema 无 sale_amount 入参,塞了忽略);特殊业务必填说明;编辑全量替换后**三处冗余一致**(明细 SUM == 业务冗余列 == DB SUM);状态机(终止无原因 400 → 留痕 old=1/new=3 → 终态再变更 400);归属变更主表+留痕同生(old/new owner 与 assistant、owner_type=2)、归属未变化 400、创建时归属人员已离职 400;有发货/回款禁换客户禁删;空业务删除连带清理明细/状态留痕/业务归属留痕;毛利润视图金标准(销售 10000/成本 30 → 率 997/1000、市政 30% → 折算 6970;**销售额 0 全 0 防除零**;**成本缺失行**派生为 null 不计类别汇总;**asOf 按业务日期回溯旧阈值**);审计成功/失败双记录 | 25 | +| `tests/integration/test_shipments.py` | 未登录 401;编号 `SH{YYYYMMDD}-{4位}`;业务不存在 404;终态业务禁新发货 400;空明细/数量 0/运费负数/运费三位小数 400;**行金额与发货单总额服务层固化**;业务 total_ship/total_freight 回写一致;编辑后两处汇总同步;被回款指定禁删(解除后可删);删除后业务级汇总回退;终态业务发货单禁改禁删;审计双记录 | 15 | +| `tests/integration/test_receipts.py` | 未登录 401;批次 `HK{YYYYMMDD}-{4位}`;未指定批次可登记且 payment_days 为 null;**指定批次派生「发货日首日计第 1 天」**(2026-10-05 → 2026-11-02 = 29 天,编辑清指定后回 null);业务不存在 404 / 终态禁新增 400;金额负数/三位小数 400;**跨业务指定批次 400**(「发货批次不属于该业务,无法指定」,编辑路同校验);合计回写与删除回退;**已核算(改库 settlement_status=1)禁改禁删**;settlement_status 筛选 0/1 两路;审计双记录 | 16 | +| `tests/integration/test_ratings.py` | 未登录 401;种子四等级全量按 sort_order 升序(A 90-100/B 75-89/C 60-74/D 0-59);区间倒挂/超上限/负下限 → 400「分值区间不合法」;名称唯一 → 400「该等级名称已存在」;**新增与编辑两路重叠 400 点名冲突等级**;**边界相邻不算重叠正例**(删 C 后新增 60-74 / 把 D 编辑为 0-74,74 与 75 相邻放行);编辑改名改区间回显;删除后判定表更新、二次删除 404;审计双记录。种子删改由 conftest 兜底恢复 | 14 | + +运行:`uv run pytest tests/integration/test_{businesses,shipments,receipts,ratings}.py -q` → **70 passed**(2026-10-13,连跑两次幂等);全量回归 `uv run pytest -q` → **603 passed**(单元 381 / 集成 222)。 + +### M3-core ④ 核算引擎 + 月度结算(2026-10-16 完成) + +| 文件 | 守什么 | 用例数 | +| --- | --- | --- | +| `tests/unit/test_formula.py`(扩) | R 钳制(向下取整/[0,100] 封顶封底/销售额 0 防御)、月工资三口径(INCLUDE 加/DEDUCT 减可负/EXCLUDE 仅绩效/基本缺失按 0/未知口径报错)、试用期年累计说明(未超无 note/超额精确差额)、经理 12.5%+5% 聚合(#12 暂口径) | +12 | +| `tests/integration/test_calc.py` | **金标准双类别复算到分**(市政 4000 + 轨交 0 = 折算 4000;88/9000 约分原文落 rate_text;绩效 39.11;R=100 应付 39.11,DB 锚定);**业务级一次性计提 #25**(同业务第二笔回款 repeated=True 仅登记进度不新增行);已核算再核算 400;未指定批次 400;**成本缺失行毛利按 0 计**(折算保守 −3000、missing_cost=1);负绩效照算不归零(折算 −2000);准入(normal 403/marketing_ops 可用);列表筛选;重算(finance 授权、v2 生效 v1 保留、reason 留痕、原因缺失 400、未核算重算 400、越权 403);批量逐笔成败清单 | 13 | +| `tests/integration/test_settlements.py` | 生成按人汇总+台账回填 settlement_id;无核算记录 400;**已审定月份重生成 400**;开关关闭提交即自动通过锁定(AP 单号/locked_at 回填);**锁定后回款禁改 400**(M2 已核算禁改防线先于锁定拦截);开关开启两节点流转(finance→leader)至锁定;#12 暂口径聚合不实现(items 按人汇总不聚合验证) | 6 | + +运行:`uv run pytest tests/integration/test_{calc,settlements}.py tests/unit/test_formula.py -q` → **32 passed**(2026-10-16);全量回归见运行记录。 + +### M3 增量批次(2026-10-16 完成:公司信息 / 二次分配录入 / 审批框架) + +| 文件 | 守什么 | 用例数 | +| --- | --- | --- | +| `tests/integration/test_settings.py` | 公司信息单行语义(PUT 只改名称不增行);**public 端点无令牌 200 且字段白名单仅 2 项**(复用 admin schema 即泄漏面);名称空 400;非 admin 403;logo 三重校验(伪造扩展名被 magic bytes 拦 / gif 拒 / 超 2MB 拒);合法 png 上传回读 content-type;匿名上传 401;修改留审计日志 | 11 | +| `tests/unit/test_secondary_allocation_schema.py` | 比例边界矩阵(0 拒 / 0.01-100 过 / 100.01 拒 / 负数拒 / **三位小数拒绝不静默舍入** / 非数字拒);人员 ID 非正拒;**创建/编辑两入参的参与人去重**(uk 前置友好提示);不同参与人合法 | 13 | +| `tests/integration/test_secondary_allocation.py` | 带方案创建详情回显(**personnel_name 冗余固化**:离职后回显有名);不建方案详情空且无 hint;合计满额 hint 只报合计无「余额」噪音;**离职人员参与 400**「分摊参与人员不存在或已离职」;终态业务禁改方案 400;编辑全量替换与空数组清空;**被方案引用人员禁删(RESTRICT→400)且方案回显有名**;删除业务后方案 CASCADE 随删、人员可删 | 9 | +| `tests/integration/test_approval_flow.py` | **开关关闭(种子默认 0):提单即两级「自动通过」留痕**(status=3、steps 两条 action=3、operator 记「系统」——留痕不断链);方案为空不提单;开关开启两节点流转(1→财务通过→2→领导通过→3);财务驳回整体 status=4 且意见留痕;驳回缺意见 400;**节点准入:领导/admin 不可代审财务节点(403)**;终态单重复操作 400;再编辑撤旧建新(旧单 status=5 + node=0/action=4 撤回留痕);模块准入(normal/marketing_ops 403,finance/leader 200)。开关切换经参数变更批次端到端,不直改库;用后 conftest 恢复种子基线 | 11 | +| `tests/unit/test_param_registry.py`(改) | 注册表 16→**18 项**(V6 首例 BOOL 两个审批开关);**BOOL 校验边界专项**(0/1 过、true/2 拒) | +1 | +| `tests/integration/test_schema_sync.py`(改) | 角色种子断言 2→**5 角色**(marketing_ops/finance/leader 入列) | 参数化 | + +运行:`uv run pytest tests/integration/test_{settings,secondary_allocation,approval_flow}.py tests/unit/test_secondary_allocation_schema.py -q` → **44 passed**(2026-10-16);全量回归 `uv run pytest -q` → **697 passed**(基线 625 + 新增 72,5:10)。 + +### 报表统计(M4 ⑤,2026-10-19 后端完成) + +| 文件 | 守什么 | 用例数 | +| --- | --- | --- | +| `tests/integration/test_reports.py` | 个人明细与台账逐笔一致 + **pageSize=1 时 summary 仍为全筛选合计**;部门汇总团队基数=成员绩效合计、12.5% 分成按参数、无成员经理出 0 值行;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**(防翻倍)、绩效合计=台账;业务统计排除经理聚合行(生成月结后绩效不被整团队聚合额污染);导出 xlsx 可解析且含「财务复核/领导签字」栏 + 操作日志留痕(module=报表统计/action=导出);普通用户 403 且文案「无权访问报表统计」 | 7 | +| `tests/unit/test_report_helpers.py` | 区间分桶边界(NULL/负值/0/1/30/…/361+)与 8 档编码唯一;客户类型、产品类别标签覆盖全部常量枚举(防新增枚举后报表少一类);Excel 模板纯构建含页眉/签字栏、空数据提示行、金额为数值单元格且两位格式、合计行=明细之和、文本公式注入前置单引号、回款统计两个维度对比 sheet、业务统计状态转中文 | 11 | + +运行:`uv run pytest tests/integration/test_reports.py -q` → **7 passed**(2026-10-19);`uv run pytest tests/unit/test_report_helpers.py -q` → **11 passed**(2026-10-19);全量回归见运行记录。 + +**待补**:e2e `frontend/e2e/report_flow.py`(四页筛选/导出下载/打印视图,需 `E2E_ADMIN_PASSWORD`)。 + ## 三、运行记录 | 日期 | 范围 | 用例数 | 结果 | 备注 | @@ -71,11 +154,27 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-09 | 全部 | 120 | ✅ 通过(4.99s) | 阶段 1 骨架 + 阶段 2 数据库完成后首次全量运行 | | 2026-10-09 | 全部 | 119 | ✅ 通过(5.01s) | ORM 由异步改回同步后回归;净减 1 例(`sync_database_url` 独立配置已合并) | | 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 | +| 2026-10-10 | 全部 | 282 | ✅ 通过(103.81s) | **系统管理模块(用户/角色/日志)完成**后统一编写该模块套件(新增 91 例:单元 48 / 集成 43)。含操作日志审计机制专项;**e2e 发现并修复 `replace_roles` 唯一键缺陷**(见四、缺陷表);集成用例需容器在跑 | +| 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) | +| 2026-10-13 | 基础数据集成三文件 | 48 | ✅ 通过(27.60s) | **基础数据管理模块完成**后统一编写(人员 20/产品 14/客户 14)。审计上下文内外的校验位置差异为本次新发现的缺陷类别(见下) | +| 2026-10-13 | 业务数据集成四文件 | 70 | ✅ 通过(54.73s,连跑两次幂等) | **业务数据管理(后端接口)完成**后统一编写(业务 25/发货 15/回款 16/评级 14)。全量回归 603 例通过 | +| 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 | +| 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 | +| 2026-10-15 | 全量回归 + e2e | 639 | ✅ 全部通过 | **code-review 32 条修复后全量回归**(新增 34 例:跨位宽 3 / 参数预览与维度 3 / 审计重试 2 / 终态编辑与 quantize 2 / 产品保护 2+6 / 评级锁 1 / formula 钳制 1 等)。e2e 冒烟:登录 8 项 + 系统管理 13 项 + 基础数据 13 步 + 业务全链路 16 步全过(拦截器/正则收口/params 分页改造后复跑) | +| 2026-10-17 | M3-core 定点 | 32 | ✅ 通过 | **④ 核算引擎+月结完成**(calc 13 / settlements 6 / formula 扩 12 / schemas 等)。金标准复算到分;重算版本链;月结锁定与审批联动 | +| 2026-10-17 | 全量回归 | 701+N | ✅ 全部通过 | **M3-core 收尾回归**(T1-T6:V7 四表+核算+月结+调整单;前端台账/月结两页 order17/18)。e2e 补 calc_settlement_flow 待下批 | +| 2026-10-16 | 全量回归 + 7 个 e2e | 697 | ✅ 697 passed(5:10);e2e 七脚本全过 | **M3 增量批次完成**(+72 例:settings 11 / 分配 schema 13+集成 9 / 审批 11 / registry 与 schema_sync 断言扩充等)。e2e:登录 8 + 系统管理 13 + 基础数据 12 + 业务 16 + 参数 7 步回归全过;**新增 settings_flow 4 步 / secondary_allocation_flow 6 步**(首跑即抓出 public 缓存与 logo 清理两处缺陷,见下表) | +| 2026-10-19 | M3 定点(calc/settlements) | 34 | ✅ 通过(55s) | **code-review P0+P1 修复中**:经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前锁定/锁定拦截三路径/回填与聚合落库/月末取参(+新增 `test_settlement_helpers` 5 例、`test_calc_helpers` 4 例锁定 `_q2` HALF_UP) | +| 2026-10-19 | 全量回归 | 780 | ✅ 780 passed(8:53) | **M3 code-review 21 项修复后全量回归**(P0 算错钱/不可用 6 项 → P1 锁定不变量与双轨漂移 7 项 → P2 健壮性 6 项 → calc_receipt 重构;新增回归约 20 例,含跨会话并入的 #12 聚合基数开关用例)。type-check 绿;e2e calc_settlement_flow 仍待补 | +| 2026-10-19 | 拆行收口批次 | 全量 788 | ✅ 788 passed(9:20) | **2026-10-19 业务答复落实完成**:#14 首日取整 10‰(公式段/锚点不变,用例反转);#12 单笔侧开关消费(1=业务员递减计提);**二次分摊拆行**(参与人份额+负责人余额守恒,uk 三列版);权限分层(params 查看放开/写仅 admin);V8 迁移(审批开关默认开/撤试用期参数/增基数开关)。定点:calc 20 / settlements 13(含 #12 开关两档金标准 6.84→24.34)/ businesses 30(保存侧+3)/ formula 35。e2e calc_settlement_flow 交接并行会话 | +| 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` 运行 | ## 四、测试发现并修复的缺陷 | 日期 | 缺陷 | 影响 | 处理 | | --- | --- | --- | --- | +| 2026-10-19 | 分摊负责人份额按余额百分数乘全额导致行合计丢 q2 尾差 | 拆 30%+40% 后负责人 100−70=30% 再乘未 q2 全额(39.1111×0.30=11.7333),行合计 39.10 ≠ 39.11——**每笔分摊账丢 1 分**,守恒破坏(金标准用例 `行合计==业务级全额` 抓住) | 负责人行恒走余额公式(q2 全额 −∑参与人 q2 份额);余额百分数仅作快照展示不参与计算 | | 2026-10-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 | | 2026-10-09 | `PasswordHash.recommended()` 在 pwdlib 0.3.x 推荐 Argon2 而非 BCrypt | 密码算法与规范不符,且随库版本变化——同一批密码可能混用两种算法 | 改为显式 `PasswordHash((BcryptHasher(),))` 钉死算法 | | 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` | @@ -85,14 +184,51 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-09 | 验证码沿用 `captcha` 库默认渲染 | 随机彩色字符 + 重噪点,与界面视觉体系冲突;该库最后更新于 2025-03,内部还引用了 Pillow 的旧别名 | 改为自绘(`core/captcha_image.py`):配色与界面统一,难度由逐字旋转、抖动、干扰曲线与噪点保证;仅沿用其内置字体文件 | | 2026-10-09 | 噪点用 `ellipse` 绘制 | 3×3 包围盒画出的椭圆恰好填充中心与四个边中点,形状就是「+」,形成规则的十字纹理——既显眼又容易被整体滤波抹掉,起不到干扰作用 | 改用单像素点与 2×2 方块 | | 2026-10-09 | `tests/unit/test_jwt.py` 两处赋值了未使用的 `settings`(ruff F841) | 变量未被引用,属无效代码 | 删除赋值;该文件的 `get_settings` 导入仍被其他用例使用 | +| 2026-10-10 | `replace_roles` 先清后建但中间未落库 | SQLAlchemy 在同一 flush 中**先处理 INSERT 再处理 DELETE**,编辑用户保留既有角色时重插同 `(user_id, role_id)` 撞唯一键(1062)——编辑用户接口 500。**由前端 e2e「编辑用户」步骤暴露**:集成测试当时只覆盖了「换掉全部角色」(无交集)的路径 | `clear()` 后立即 `session.flush()` 把删除落库,再追加新集合;补集成回归用例(编辑时保留既有角色) | +| 2026-10-10 | 审计脱敏依赖文本正则 | 三种逃逸形态:密码**以空白开头时完全不脱敏**、含空格/逗号只抹首段、含引号既残留明文又破坏 JSON——明文/残段写入 `operation_logs.request_data` 且日志页可直接展开查看(**由 code-review 发现并逐条实证复现**;既有测试所用密码均不含空格,恰好掩盖该路径) | 改为按键名的**结构化抹除**(递归 dict/list 整值替换 `***`),scrub 降级为文本兜底;补前导空白/空格/引号/嵌套/大小写五类边界用例 | +| 2026-10-10 | `.gitignore` 的 `logs/` 规则吞掉日志查询页目录 | 规则无前导斜杠、匹配任意层级:`frontend/src/views/system/logs/` 整体被忽略,文件**进不了版本库**——干净克隆构建失败(本地因文件已在磁盘上而一切"正常",隐蔽性极强) | 收窄为 `/backend/logs/`(该规则本意即后端运行日志);以 `git check-ignore` 实证不再命中 | +| 2026-10-10 | 时间筛选未做时区归一 | 混用 aware/naive 抛 TypeError 穿透成 **500**;带时区输入被驱动静默丢弃偏移(查询窗口偏 8 小时且无提示) | `LogQueryBase` 加 field_validator:aware → `astimezone()` 本地 naive、naive 原样;补 4 例专项 | +| 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 | +| 2026-10-13 | 基础数据三 service 的**前置校验在审计上下文之外** | 校验失败(岗位停用/经理自引用/分类不合法等 400)完全不留操作日志,违背「成功与失败都记」的既定原则——与 2026-10-10 审计时序缺陷同源,新写的 service 又犯了(各自独立实现时口径漂移) | personnel/product/customer 三 service 的值域与引用校验统一移入 `audit_service.audit` 上下文内;集成用例锁定(审计成功与失败双记录) | +| 2026-10-13 | `change_status` 状态留痕的 old_status 取到**赋值后**的 ORM 属性 | 时间线记成 `old==new`(如 1→3 的变更记成 3→3)——「变更前状态」无法回溯,状态流转追溯失效。由集成用例断言 `(old,new)==(1,3)` 抓住 | 赋值前固化 `old_status = business.status` 再流转;应用日志一并修正;回归用例锁定 | +| 2026-10-13 | **客户/人员删除保护漏检业务引用**(T4 占位注释「待业务表就位后补充」在 T7 交付后无人补齐) | 被业务引用的客户、被业务归属(负责/协助)引用的人员删除 → FK RESTRICT 穿透成 **500**。**由 e2e 清理步骤暴露**(集成测试只覆盖了 T2/T4 当时的引用面,业务表就位后引用面扩大未回看) | customer 删除加 `count_business_refs` 检查 + IntegrityError 兜底 400;personnel 删除加 `count_business_roles_refs`(负责/协助合并统计);补 2 个集成用例锁定;文案更新「客户归属或业务归属引用」 | +| 2026-10-13 | **单价列(DECIMAL(14,4))被两位小数校验拒收**(前后端同源) | 编辑任何带精度的历史单价(回显 "5000.0000")被前端 `AMOUNT_PATTERN` 与后端 `_check_amount` 双双拒绝——**创建后单价带小数位的记录永远无法编辑**。**由 e2e「编辑业务」步骤暴露**(创建用整数、编辑回显带 4 位小数,恰好只在这条路径相遇) | 后端 `_check_amount` 加 `scale` 参数(金额/数量 2 位、单价类 4 位);前端业务/发货明细行单价类放行 4 位;集成用例锁定 | +| 2026-10-13 | 发货 `freight_amount` 漏做金额校验 | 负数/三位小数/超容量的运费直接落库(schema 对 quantity/sale_price 都复用了 `_check_amount`,唯独运费没套用),业务运费合计与报表随之错乱 | shipments.py schema 的 Create/Update 加 `field_validator("freight_amount")`(同套口径,文案「运费金额不能为负数 / 最多两位小数」);集成用例锁定 | +| 2026-10-15 | **计算预览把 ‰ 存储值当纯比例相乘**(企业户/信息员分支) | `enterprise.rate`=3(3‰)、`info.rate`=1(1‰)未经 ÷1000 直接乘——绩效金额放大 1000 倍(15000×3=45000)。**既有测试只覆盖业务员路径**(`salesperson_rate` 内部有 ÷1000),两分支零覆盖使缺陷存活;该预览是 M3 核算共用契约 | 两分支显式 `/1000` 并注释声明换算;补企业户/信息员精确数值用例锁定 | +| 2026-10-15 | **编号撞唯一键的重试写在 audit 上下文内 `return _retry_create`**(业务/发货/回款三处同构) | 吞异常后正常退出被记 `success=True`(假成功审计,业务实际已回滚);重入整个 `create_*` 违反「不支持嵌套」约定;`_NO_RETRY=1` 死常量致持续冲突时**无界递归**至 RecursionError | 改为 audit 上下文内 for 循环(上限 `_NO_RETRY`),rollback 后重新锁行与取号;新增集成用例锁定「成功/最终失败各恰好一条日志」 | +| 2026-10-15 | **单号 MAX 检索用 VARCHAR 字典序**(`no_generator`) | 序号破 4 位宽度(9999→10000)后字典序反转("-10000"<"-9999"),MAX 恒取旧值 → seq 恒重算同一号码 → 当日该前缀所有建单持续撞唯一键 | 排序改「长度优先、同级字典序」(同前缀下长度大即数值大);跨位宽/同宽/空表三用例锁定;param 版本号一并复用 | +| 2026-10-15 | **编辑业务全程无锁读 + 无终态门槛** | ① 「有发货禁换客户」走无锁快照读,与发货/回款写路径(FOR UPDATE)不互斥——并发新增发货可旁路保护;② 已完成/已终止业务仍可全量改明细并重算 M3 核算基线(发货/回款服务禁写终态,父单据反而放行) | `get_with_names` 加 `for_update`;update 补终态门槛;前端编辑按钮终态禁用;`has_docs` 单点收口(update/delete 共用) | +| 2026-10-15 | **删除产品无引用保护**(docstring「待 V5 补充」在 V5 就位后未兑现) | 被业务/发货明细引用的产品删除 → FK RESTRICT 穿透成 500(与 2026-10-13 客户/人员删除保护漏检同源——「占位注释交付后无人回看」第二次发生) | `count_refs`(两明细表)+ IntegrityError 兜底 400;**全局注册 IntegrityError handler** 兜底未来遗漏(漏网的引用冲突收敛 400 而非 500) | +| 2026-10-15 | **产品 schema 私写校验不查小数位与容量** | `sale_price` 用 `_parse_non_negative`(非负/有限),5 位小数被 Numeric(14,4) **静默截断**(舍入即算错钱)、超容量 500——与 fba8cfc 修掉的「schema 小数位口径」同源残留 | 复用 `_check_amount`(PRICE_MAX+scale=4 / AMOUNT_MAX);6 个秒级 schema 单测锁定 | +| 2026-10-15 | 行金额乘积(数量2位×单价4位=6位小数)直写 DECIMAL(14,2) | MySQL 静默舍入且回显漂移:创建响应读内存值 "5.166615"、复查读库值 "5.17"——同一行金额前后显示不一致 | 显式 `quantize(HALF_UP)` 固化(与 MySQL 舍入方向一致);回显/库值一致性用例锁定 | +| 2026-10-15 | 先款后货(payment_days 为负)计提比例线性外插 | 比例 = 起始 + \|天数\|×斜率,**高于起始值甚至超 100%**——M3 接入真实回款后直接多算绩效工资(预览被 schema 拦住、回款登记不校验日期先后,缺陷在 M3 前不可见) | `salesperson_rate` 钳制 days≥0(按起始比例计提;payment_days 出参仍照实输出负值,口径经用户 2026-10-15 裁决) | +| 2026-10-15 | 评级区间重叠先查后写无锁、无唯一键兜底 | 两管理员并发提交重叠区间互判不重叠而双双入库——同一总分命中多等级且结果取决于插入顺序 | 判重叠的 `list_all` 加 FOR UPDATE 锁全表行串行化;只读直出不加锁 | +| 2026-10-15 | 岗位维度值大小写校验(ci)与快照匹配(精确)不对称 | API 提交 "SalesPerson" 校验通过入库,快照字典按精确键取值不命中——**静默按错误参数算钱且无报错**;param_code 变形大写则 get_spec 抛 ValueError 成 500 | 校验命中后一律以库里规范值落库(role_code/definition.param_code);批量预载字典键 casefold 归一保持等价命中 | +| 2026-10-16 | **logo 上传同扩展名覆盖时先写后删** | `save_logo` 在写入新文件**之后**清理旧扩展名——同扩展名重传时把刚落盘的新文件一并删掉,回读 404(首次单跑通过、集成连跑即暴露,依赖「库里已有同扩展名旧记录」的前置状态) | 清理旧扩展名移到写新文件**之前**且仅异扩展名执行;集成连跑两遍幂等锁定 | +| 2026-10-16 | **public 端点 300s 响应缓存让登录页拿旧公司名** | 公司名保存后退出登录,登录页命中浏览器缓存副本仍显示旧品牌名(设置页「保存后生效」承诺失效)——**由 settings_flow e2e 首跑暴露**(新标签页直接打开正常、退出回跳的登录页异常) | 登录页改用带时间戳的原生 `fetch` + `cache: no-store` 绕过磁盘缓存(axios 对响应 max-age 无能为力);手动解包统一信封 | +| 2026-10-16 | V6 迁移 downgrade 保留 autogenerate 的逐个 `drop_index` 且漏删行级种子 | 外键唯一支撑索引单独删除报 MySQL 1553 回滚中断;roles/param_definitions/param_values 的行级种子(表由 V1/V3 建)drop_table 不会带走——重放 upgrade 时唯一键冲突 | downgrade 只保留 `drop_table`(V1/V3 先例)+ 三条 DELETE 反向清理行级种子;downgrade→upgrade 往返实测通过 | +| 2026-10-19 | **经理团队聚合分成放大 100 倍**(settlement_service) | 参数按百分数存储(12.5=12.5%),`parse_fraction` 后调用方又 ×100、`manager_team_share` 内部再 ÷100——净效应「绩效×12.5」;单元测试直传 Fraction 未覆盖调用方换算使缺陷存活 | 去掉调用方 ×100;`rate_text` 改用实际参数原文(原硬编码默认值失真);集成用例两档金标准(6.84/9.78 vs 错误值 684.43)锁定 | +| 2026-10-19 | **审批联动不判终审**(approval_service.act) | 财务节点通过(1→2)即 mark_locked 月结/调整单置生效——领导驳回后 locked_at/locked_by 残留、两级审批形同虚设(锁定后源数据写路径立即被拦) | 生效类联动收敛到 `final_approved`(终态才触发);驳回任意节点即时;mark_rejected 补清锁定字段;用例断言财务通过后仍 status=2 且无锁定信息 | +| 2026-10-19 | **调整单创建完全不可用**(两处叠加) | ① `ENABLE_CODE_BY_TYPE` 漏登记 type=3 → is_enabled 恒 400「未知的审批对象类型」;② 修复①后暴露:自动生效分支手工插 `ApprovalStep(order_id=调整单id)`,而该外键指向 approval_orders,必然 errno 1452 | 补登记 `approval.adjustment.enabled`;自动通过留痕统一交 `submit_order`(不再手工插步);新建调整单集成用例锁定 | +| 2026-10-19 | **重算绕过业务级一次性计提防线** | 「进度登记」回款(#25 第二笔及以后)无独立台账行,重算会凭空落一行生效行——同业务两行生效行、绩效按两倍计入工资 | 无旧版本行一律 400「进度登记无可重算」;用例锁定「重算后同业务仍只有一行生效行」 | +| 2026-10-19 | **前端重算传错 ID**(台账页) | 把台账行主键当 receipt_id 传给重算接口——会作废并重算**无关回款**的台账(若目标回款已核算),或报错无法重算目标行 | 改传 `row.receipt_id`(聚合行 receipt_id 空值防御);`LedgerRow` 本含该字段 | +| 2026-10-19 | **quantize 缺省银行家舍入**(9 处金额固化) | docstring 声称 HALF_UP,实际未传 rounding 走 ROUND_HALF_EVEN——x.xx5 舍成偶数尾数,与业务四舍五入口径差 1 分(台账固化金额比应发少 1 分) | 9 处显式 `rounding=ROUND_HALF_UP`;`_q2` 提为模块级并单测锁定(2.345→2.35) | +| 2026-10-19 | **月结锁定拦截只覆盖 1/4 写路径** | 仅新增回款被拦;回款编辑(可跨月搬数)/删除、发货增/改/删、核算与重算全部放行——锁定月份快照与源数据脱钩且无留痕 | 五处写路径补齐 `ensure_month_not_locked`(编辑按原月+新月双查);拦截先于「被引用/已核算」判定;三用例锁定 | +| 2026-10-19 | **台账 settlement_id 回填从未发生**(死代码) | `update_ledger_settlement_id` 全库零调用——「未入结」判据恒真,重生成会把已固化行反复纳入(或补上回填却不先解绑导致漏算整月) | generate 回填 + 重生成前解绑(新增 `clear_ledger_settlement_id`);用例断言回填与重生成金额不翻倍 | +| 2026-10-19 | **经理聚合行从不落库** | `_build_manager_aggregates` 只构造内存对象参与 items 汇总——台账表永远无 calc_type=2 行,报表下钻(business_id 锚点)落空 | 聚合行经 repository 落库并回填 settlement_id;重生成先作废旧聚合行(`deactivate_month_aggregates`);`_confirm_scope` 限定 calc_type=1 防聚合行混入单笔池 | +| 2026-10-19 | **月结取参用生成日**(date.today) | 补生成历史月份时经理分成/月工资口径/试用期阈值按生成日参数计算——同一月份两次生成金额不同,违背「参数按生效日期时点化」红线 | 新增 `_month_end`(所属月月末)为聚合与月工资口径取参锚点;单测 5 例 + 集成用例(10-25 预约变更影响 10 月聚合) | +| 2026-10-19 | **迁移双轨三处漂移** | ① V6 就地修改加入 uk 致旧库补不上(V8 也未补)→ V9 条件式补齐(幂等裸 SQL,临时库验证两分支);② V7 留存 SQL 约束/索引名单数(`fk_performance_ledger_*`)与 alembic op.f/init-dev 漂移;③ V8 迁移 revision 缺类型注解,`test_schema_sync` 的 head 推断**静默漏检 V8**、init-dev 版本号落后未被发现 | V9 迁移 + V7 SQL 名修正 + V8 注解修正 + init-dev 版本推进;schema 同步测试恢复全量断言 | +| 2026-10-19 | 运维脚本两处(restore_drill/health_check) | ① 行数核对把查询失败回显 `'?'` 当数据比较(`'?'=='?'` 判 ✓)且 TABLES 缺 V7 四表等 16 张——备份漏表时演练全绿误报「可恢复」;② `redis-cli -a "$REDIS_PASSWORD"` 密码进宿主进程命令行 | TABLES 补全 32 张业务表 + `'?'` 一律判 ✗;Redis 探活改容器内环境变量展开(compose 补 REDIS_PASSWORD),实测 PONG | +| 2026-10-19 | 调整单金额无校验 / INT 参数原文 `int()` / logo 清理裸抛 | ① amount 负数反转补发-扣减语义、'abc' 直落库 500;② `int('360.0')` 使整月核算 500(INT 参数合法入库原文);③ 旧 logo 清理 OSError 裸抛把成功更新记成失败审计并 500 | schema 正则 + 服务层 Decimal>0;`int(parse_fraction(...))`;清理包 try 仅 warning;各补回归用例 | ## 五、待补用例 | 模块 | 状态 | 说明 | | --- | --- | --- | -| 系统管理 · 用户与角色管理 | 待编写 | 需求 2.5.1 的用户增删改查、密码重置、启用/禁用、角色分配 — 该模块功能尚未实现 | -| 系统管理 · 操作日志 | 待编写 | 除登录日志外的操作审计(当前仅登录日志落库) | -| 基础数据管理 | 未开始 | 依赖 T+ 对接方式确认 | -| 业务数据管理 | 未开始 | 依赖需求澄清(回款天数口径等 10 项) | -| 绩效工资核算 | 未开始 | 依赖需求澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率) | +| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 | +| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 | +| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 | +| 绩效工资核算(M3-core) | ✅ 已完成 | 2026-10-17 完成(核算/月结/调整单,见运行记录);2026-10-19 code-review 21 项修复并补回归约 20 例。**待补**:e2e `calc_settlement_flow`(台账/月结两页真实浏览器链路);业务答复回填批次(88 会话:首日 10‰/聚合基数开关/二次分摊拆行)落地后按同粒度补测 | | 报表统计 | 未开始 | 依赖报表口径确认 | + +> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。 diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py new file mode 100644 index 0000000..d84f14d --- /dev/null +++ b/backend/tests/integration/conftest.py @@ -0,0 +1,326 @@ +"""系统管理集成测试共用夹具(需要 MySQL 与 Redis)。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生约定: + +- 测试账号/角色统一 ``t_`` 前缀;工厂夹具清理自己创建的对象,另有 + autouse 兜底按前缀全量清理(用例经**接口**创建的数据不在工厂名下, + 失败中断时会残留并影响后续用例的 total 断言) +- M2 基础数据(人员姓名/产品名/公司名/测试岗位编码)同用 ``t_`` 前缀, + 兜底清理按前缀删除;归属留痕对客户/人员 ID 无外键固化、不随主表删除 + 消失,必须显式清理,否则残留会影响后续用例对留痕条数的断言 +- 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id`` + 会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态 +- M2 业务数据(业务/发货/回款)单据编号由系统生成(YW/SH/HK 前缀 + 当日 + 序号),**没有 t_ 前缀可用**,兜底清理按编号前缀全量清(三类单据表目前 + 只有测试会写);外键均为 RESTRICT,删除顺序不能倒,且必须在客户/人员 + 清理之前(FK 会挡住删客户/删人员);评级判定表则恢复种子基线 —— + 评级用例会删改种子构造重叠/相邻场景,恢复保证跨文件幂等 +""" + +import uuid +from decimal import Decimal +from pathlib import Path + +import pytest +from sqlalchemy import delete, select, update + +from app.core.constants import ROLE_ADMIN, ROLE_USER +from app.core.db import get_session_factory +from app.core.security import create_access_token, hash_password +from app.models import ( + AdjustmentOrder, + ApprovalOrder, + Business, + BusinessItem, + BusinessRole, + BusinessStatusLog, + Customer, + CustomerRatingGrade, + MonthlySettlement, + OperationLog, + OwnershipChangeLog, + ParamChangeBatch, + ParamValue, + PerformanceLedger, + Personnel, + PersonnelPlan, + PersonnelRegion, + Product, + Receipt, + Role, + SettlementItem, + Shipment, + ShipmentItem, + SystemSetting, + User, + UserRole, +) +from app.models.customer import OWNER_TYPE_BUSINESS + +# 评级判定表的种子基线(与 db/migration/V5 一致):判定按 sort_order 升序 +# 取首个 min_score ≤ 总分的等级,种子铺满 0-100 +_RATING_SEEDS = [ + ("A", Decimal("90"), Decimal("100"), 1, "优秀"), + ("B", Decimal("75"), Decimal("89"), 2, "良好"), + ("C", Decimal("60"), Decimal("74"), 3, "合格"), + ("D", Decimal("0"), Decimal("59"), 4, "待改进"), +] + +TEST_PASSWORD = "TestPwd@2026" +# 模块级预计算一次:bcrypt(rounds=12)单次约 0.2–0.4 秒,逐用例重复哈希 +# 会让整个集成套件多花 20 秒以上;这些测试账号全部走直签 JWT, +# 哈希值不被任何断言消费,复用一个哈希与每次重算等价 +_TEST_PASSWORD_HASH = hash_password(TEST_PASSWORD) + + +def _purge_test_data() -> None: + """按 `t_` 前缀清理测试残留(先日志后账号;角色先断关联再删)。 + + ``t\\_%`` 中的下划线做了转义 —— 不转义时 `_` 是 LIKE 的单字符通配, + 会把 tn... 这类无关账号一并卷入。 + """ + factory = get_session_factory() + with factory() as session: + users = list( + session.execute(select(User.id, User.username).where(User.username.like("t\\_%"))).all() + ) + ids = [row[0] for row in users] + names = [row[1] for row in users] + if ids: + session.execute(delete(OperationLog).where(OperationLog.user_id.in_(ids))) + if names: + session.execute(delete(OperationLog).where(OperationLog.username.in_(names))) + session.execute(delete(User).where(User.id.in_(ids))) + + role_ids = list( + session.execute(select(Role.id).where(Role.role_code.like("t\\_%"))).scalars() + ) + if role_ids: + session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids))) + session.execute(delete(Role).where(Role.id.in_(role_ids))) + + # ---- M3-core:核算台账 / 月结 / 调整单(必须先于回款/业务/人员删除—— + # ledger.receipt_id/personnel_id/settlement_id 外键 RESTRICT 会挡住后面 + # 的单据与人员清理;仅测试写入,全量清)---- + session.execute(delete(PerformanceLedger)) + session.execute(delete(SettlementItem)) + session.execute(delete(MonthlySettlement)) + session.execute(delete(AdjustmentOrder).where(AdjustmentOrder.order_no.like("TZ%"))) + + # ---- M2 业务数据(T11):业务/发货/回款三类单据 ---- + # 编号系统生成、无 t_ 前缀可用,按业务编号前缀全量清(三类单据表目前 + # 只有测试会写)。外键均为 RESTRICT,删除顺序不能倒:回款 → 发货 + # 明细 → 发货单 → 业务明细 → 状态留痕 → 业务维度归属留痕(无外键, + # 须显式清)→ 业务主表;且必须在下方客户/人员清理之前,否则 FK 挡住 + # 删客户/删人员(businesses.customer_id / .owner_personnel_id RESTRICT) + business_ids = list( + session.execute(select(Business.id).where(Business.business_no.like("YW%"))).scalars() + ) + shipment_ids = list( + session.execute(select(Shipment.id).where(Shipment.shipment_no.like("SH%"))).scalars() + ) + if business_ids: + session.execute(delete(Receipt).where(Receipt.business_id.in_(business_ids))) + if shipment_ids: + session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id.in_(shipment_ids))) + session.execute(delete(Shipment).where(Shipment.id.in_(shipment_ids))) + if business_ids: + session.execute(delete(BusinessItem).where(BusinessItem.business_id.in_(business_ids))) + session.execute( + delete(BusinessStatusLog).where(BusinessStatusLog.business_id.in_(business_ids)) + ) + session.execute( + delete(OwnershipChangeLog).where( + OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS, + OwnershipChangeLog.business_id.in_(business_ids), + ) + ) + session.execute(delete(Business).where(Business.id.in_(business_ids))) + + # 评级判定表恢复种子基线:评级用例会删改种子构造重叠/相邻场景, + # 兜底恢复保证每个用例开始时都是纯种子(判定表无下游引用,全删全插安全) + session.execute(delete(CustomerRatingGrade)) + session.add_all( + CustomerRatingGrade(grade_name=n, min_score=lo, max_score=hi, sort_order=so, remark=rm) + for n, lo, hi, so, rm in _RATING_SEEDS + ) + + # ---- M2 基础数据:客户/归属留痕/人员/产品/测试岗位 ---- + # 外键均为 RESTRICT,删除顺序不能倒:留痕无外键先按 customer_id 清, + # 再删客户(子表)→ 人员(先置空 manager_id 自引用、清子表)→ 产品 + personnel_ids = list( + session.execute( + select(Personnel.id).where(Personnel.personnel_name.like("t\\_%")) + ).scalars() + ) + customer_ids = list( + session.execute( + select(Customer.id).where(Customer.company_name.like("t\\_%")) + ).scalars() + ) + if customer_ids: + session.execute( + delete(OwnershipChangeLog).where(OwnershipChangeLog.customer_id.in_(customer_ids)) + ) + session.execute(delete(Customer).where(Customer.id.in_(customer_ids))) + # 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条 + session.execute( + delete(OwnershipChangeLog).where(OwnershipChangeLog.operator_name.like("t\\_%")) + ) + if personnel_ids: + session.execute( + update(Personnel) + .where(Personnel.manager_id.in_(personnel_ids)) + .values(manager_id=None) + ) + session.execute( + delete(PersonnelRegion).where(PersonnelRegion.personnel_id.in_(personnel_ids)) + ) + session.execute( + delete(PersonnelPlan).where(PersonnelPlan.personnel_id.in_(personnel_ids)) + ) + session.execute(delete(Personnel).where(Personnel.id.in_(personnel_ids))) + session.execute(delete(Product).where(Product.product_name.like("t\\_%"))) + # 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删 + session.execute(delete(BusinessRole).where(BusinessRole.role_code.like("t\\_%"))) + + # ---- M3:审批单 / 二次分配 / 参数开关 / 系统设置基线 ---- + # 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删; + # ref_id 固化无外键,业务已在上一步删除,无悬挂影响) + session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%"))) + # 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 25 + # (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另 + # 插入两个审批开关的 10-19 生效行 id 23/24,均属种子),删新条目与 + # 新批次后解析回退到种子值 + stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 25)).scalars()) + if stray_values: + stray_batches = {v.batch_id for v in stray_values} + session.execute(delete(ParamValue).where(ParamValue.id > 25)) + 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) + ) + session.commit() + 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 _purge_after_each_test(): + """每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。""" + yield + _purge_test_data() + + +@pytest.fixture +def make_user(): + """独立测试账号工厂:``make_user(["admin"])`` → dict(id/username/password)。 + + 不复用真实 admin —— 测试会改密码、提 token_version、删除账号, + 污染真实账号会让后续手工验证产生困惑。 + """ + + def _factory(role_codes: list[str] | None = None, username: str | None = None) -> dict: + codes = role_codes if role_codes is not None else [ROLE_USER] + name = username or f"t_{uuid.uuid4().hex[:8]}" + factory = get_session_factory() + with factory() as session: + roles = list(session.execute(select(Role).where(Role.role_code.in_(codes))).scalars()) + assert len(roles) == len(codes), f"角色不存在:{codes}" + user = User( + username=name, + password=_TEST_PASSWORD_HASH, + real_name="测试账号", + status=1, + ) + user.user_roles = [UserRole(role_id=role.id) for role in roles] + session.add(user) + session.commit() + return {"id": user.id, "username": name, "password": TEST_PASSWORD} + + return _factory + + +@pytest.fixture +def auth_headers(): + """令牌直签工厂:``auth_headers(user)`` → Authorization 请求头。 + + 绕开验证码与登录接口 —— 令牌只承载身份,授权判定以数据库中的角色为准 + (`require_admin` 每次查库),直签不影响测试有效性,省去清理 Redis + 限流键与黑名单。令牌版本变化(如重置密码、禁用)后旧令牌会失效, + 再次调用本工厂可换新令牌。 + """ + + def _factory(user: dict) -> dict[str, str]: + factory = get_session_factory() + with factory() as session: + db_user = session.get(User, user["id"]) + assert db_user is not None, "账号已被删除,无法签发令牌" + roles = [link.role.role_code for link in db_user.user_roles if link.role] + token, _, _ = create_access_token( + user_id=db_user.id, + token_version=db_user.token_version, + roles=roles, + ) + return {"Authorization": f"Bearer {token}"} + + return _factory + + +@pytest.fixture +def make_role(): + """独立测试角色工厂(ORM 直建,用后删除)。 + + 清理时先删除 user_roles 关联(用例失败时可能残留绑定),再删角色。 + """ + created: list[int] = [] + + def _factory(**overrides: object) -> dict: + code = str(overrides.pop("role_code", f"t_role_{uuid.uuid4().hex[:6]}")) + name = str(overrides.pop("role_name", "测试角色")) + factory = get_session_factory() + with factory() as session: + role = Role( + role_code=code, + role_name=name, + description=overrides.pop("description", None), + sort_order=overrides.pop("sort_order", 99), + status=overrides.pop("status", 1), + **overrides, + ) + session.add(role) + session.commit() + created.append(role.id) + return {"id": role.id, "role_code": code, "role_name": name} + + yield _factory + + factory = get_session_factory() + with factory() as session: + session.execute(delete(UserRole).where(UserRole.role_id.in_(created))) + session.execute(delete(Role).where(Role.id.in_(created))) + session.commit() + + +@pytest.fixture +def admin(make_user, auth_headers) -> dict: + """管理员账号(含请求头)。""" + user = make_user([ROLE_ADMIN]) + user["headers"] = auth_headers(user) + return user + + +@pytest.fixture +def normal(make_user, auth_headers) -> dict: + """普通用户账号(含请求头)。""" + user = make_user([ROLE_USER]) + user["headers"] = auth_headers(user) + return user diff --git a/backend/tests/integration/factories.py b/backend/tests/integration/factories.py new file mode 100644 index 0000000..09c2c29 --- /dev/null +++ b/backend/tests/integration/factories.py @@ -0,0 +1,60 @@ +"""集成测试共用工厂:客户 → 业务 → 明细的最小造数链。 + +回款/发货/业务三个测试文件此前各持一份逐字相同的工厂(口径漂移已经 +发生:同一 _customer 三份、payload 细节靠复制维持),收口后「t_ 前缀、 +默认明细行、业务日期取今日」单点维护;conftest 兜底清理按 t_ 前缀与 +YW/SH/HK 编号前缀覆盖本模块全部造数。 +""" + +import uuid +from datetime import date + +from fastapi.testclient import TestClient + +_CUSTOMERS = "/api/v1/base/customers" +_BUSINESSES = "/api/v1/business/businesses" + + +def customer(client: TestClient, headers: dict) -> dict: + """建一名 t_ 前缀测试客户(业务的必选引用)。""" + resp = client.post( + _CUSTOMERS, + json={ + "company_name": f"t_公司_{uuid.uuid4().hex[:8]}", + "owner_name": "张老板", + "customer_type": "personal", + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def business_item(**overrides: object) -> dict: + """业务明细行入参(默认:数量 100 × 单价 100.00 = 销售额 10000)。""" + body: dict = { + "product_name": f"t_产品_{uuid.uuid4().hex[:8]}", + "product_category": "municipal_water", + "quantity": "100", + "sale_price": "100.00", + } + body.update(overrides) + return body + + +def business_payload(customer_id: int, **overrides: object) -> dict: + body: dict = { + "customer_id": customer_id, + "business_date": str(date.today()), + "items": [business_item()], + } + body.update(overrides) + return body + + +def create_business(client: TestClient, headers: dict) -> dict: + """建一名 t_ 客户并挂一笔默认业务(发货/回款须先有业务)。""" + cust = customer(client, headers) + resp = client.post(_BUSINESSES, json=business_payload(cust["id"]), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] diff --git a/backend/tests/integration/test_approval_flow.py b/backend/tests/integration/test_approval_flow.py new file mode 100644 index 0000000..37ccbfe --- /dev/null +++ b/backend/tests/integration/test_approval_flow.py @@ -0,0 +1,390 @@ +"""审批流集成测试(二次分配方案为首个接入对象)。 + +守的是(计划 T4 验收矩阵): +- 开关关闭(种子默认 0):提单即「自动通过」——status=3 且两级节点 + action=3 留痕(留痕不断链,关闭=直接生效的需求口径) +- 开关开启:status=1 → 财务通过 → status=2 → 领导通过 → status=3 +- 驳回:整体驳回 status=4,意见留痕;驳回必填意见(空驳 400) +- 节点准入:status=1 仅财务、status=2 仅领导;**admin 不代办**(403); + 普通用户/营销后勤进不了模块(403) +- 再编辑撤旧建新:在途单 status=5 + node=0/action=4 撤回留痕,新单新建 +- 方案为空不提单 +- 开关读一次:同一提交路径内参数变化不影响判定(种子级默认行为锁定) + +角色用户经 make_user 造(finance/leader/marketing_ops 三新角色种子在库)。 +开关联动走参数变更批次(PUT /system/params 端到端,不直改库)。 +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient + +from tests.integration.factories import business_payload, customer + +pytestmark = pytest.mark.integration + +_BUSINESSES = "/api/v1/business/businesses" +_APPROVALS = "/api/v1/system/approvals" +_PARAMS = "/api/v1/system/params" +_PERSONNEL = "/api/v1/base/personnel" + + +def _person(client: TestClient, headers: dict) -> dict: + resp = client.post( + _PERSONNEL, + json={ + "personnel_name": f"t_审批_{uuid.uuid4().hex[:8]}", + "business_role_id": _any_role_id(), + "salary_standard": "3000.00", + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _any_role_id() -> int: + from sqlalchemy import select + + from app.core.constants import BUSINESS_ROLE_SALESPERSON + from app.core.db import get_session_factory + from app.models import BusinessRole + + factory = get_session_factory() + with factory() as session: + return int( + session.execute( + select(BusinessRole.id).where( + BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON, + BusinessRole.status == 1, + ) + ).scalar_one() + ) + + +def _create_with_plan(client: TestClient, headers: dict, personnel_id: int) -> dict: + cust = customer(client, headers) + resp = client.post( + _BUSINESSES, + json=business_payload( + cust["id"], + secondary_allocations=[{"personnel_id": personnel_id, "ratio": "50.00"}], + ), + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _latest_order(client: TestClient, headers: dict, refer_no: str) -> dict: + resp = client.get(_APPROVALS, params={"keyword": refer_no, "pageSize": 1}, headers=headers) + assert resp.status_code == 200, resp.text + rows = resp.json()["data"]["list"] + assert rows, f"未找到审批单:{refer_no}" + detail = client.get(f"{_APPROVALS}/{rows[0]['id']}", headers=headers) + return detail.json()["data"] + + +def _set_switch(client: TestClient, admin_headers: dict, value: str) -> None: + """经参数变更批次把二次分配审批开关置值(立即生效)。""" + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "remark": f"测试切换审批开关={value}", + "items": [ + { + "param_code": "approval.secondary_allocation.enabled", + "dimension_value": "", + "value": value, + } + ], + }, + headers=admin_headers, + ) + assert resp.status_code == 200, resp.text + + +@pytest.fixture +def finance(make_user, auth_headers) -> dict: + """财务角色账号(审批第二节点)。""" + user = make_user(["finance"]) + user["headers"] = auth_headers(user) + return user + + +@pytest.fixture +def leader(make_user, auth_headers) -> dict: + """领导角色账号(审批第三节点)。""" + user = make_user(["leader"]) + user["headers"] = auth_headers(user) + return user + + +@pytest.fixture +def switched_on(client: TestClient, admin: dict) -> None: + """把二次分配审批开关置 1(立即生效;用例结束由 conftest 恢复种子基线)。""" + _set_switch(client, admin["headers"], "1") + + +class TestSwitchOff: + """开关关闭(用例显式设置,不依赖种子默认值):提单即自动通过。""" + + def test_创建带方案_自动通过全节点留痕( + self, client: TestClient, admin: dict, finance: dict + ) -> None: + _set_switch(client, admin["headers"], "0") # V8 起种子默认开启 + p = _person(client, admin["headers"]) + data = _create_with_plan(client, admin["headers"], p["id"]) + + order = _latest_order(client, finance["headers"], data["business_no"]) + assert order["status"] == 3 + assert order["order_no"].startswith("AP") + auto_steps = [s for s in order["steps"] if s["action"] == 3] + assert len(auto_steps) == 2 + assert {s["node"] for s in auto_steps} == {1, 2} + assert all(s["operator_name"] == "系统" for s in auto_steps) + + def test_方案为空不提单(self, client: TestClient, admin: dict, finance: dict) -> None: + cust = customer(client, admin["headers"]) + resp = client.post(_BUSINESSES, json=business_payload(cust["id"]), headers=admin["headers"]) + assert resp.status_code == 200 + rows = client.get( + _APPROVALS, + params={"keyword": resp.json()["data"]["business_no"]}, + headers=finance["headers"], + ).json()["data"]["list"] + assert rows == [] + + +class TestSwitchOn: + """开关开启:两节点人工流转。""" + + @pytest.fixture + def switched_on(self, client: TestClient, admin: dict) -> None: + _set_switch(client, admin["headers"], "1") + + def test_两节点流转至通过( + self, client: TestClient, admin: dict, finance, leader, 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"]) + assert order["status"] == 1 + + # 财务通过 → 待领导 + resp = client.post( + f"{_APPROVALS}/{order['id']}/action", + json={"action": 1, "opinion": "同意"}, + headers=finance["headers"], + ) + assert resp.status_code == 200, resp.text + assert resp.json()["data"]["status"] == 2 + + # 领导通过 → 已通过 + resp = client.post( + f"{_APPROVALS}/{order['id']}/action", + json={"action": 1}, + headers=leader["headers"], + ) + assert resp.status_code == 200, resp.text + assert resp.json()["data"]["status"] == 3 + manual = [s for s in resp.json()["data"]["steps"] if s["action"] == 1] + assert {s["node"] for s in manual} == {1, 2} + + def test_财务驳回_整体驳回且意见留痕( + self, client: TestClient, admin: dict, finance, leader, switched_on + ) -> None: + _ = leader + 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 + out = resp.json()["data"] + assert out["status"] == 4 + rejected = [s for s in out["steps"] if s["action"] == 2] + assert rejected and rejected[0]["opinion"] == "比例分配不合理" + + def test_驳回缺意见被拒( + self, client: TestClient, admin: dict, finance, leader, 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}, + headers=finance["headers"], + ) + assert resp.status_code == 400 + + def test_节点准入_领导不能审财务节点_admin不代办( + self, client: TestClient, admin: dict, finance, leader, switched_on + ) -> None: + p = _person(client, admin["headers"]) + data = _create_with_plan(client, admin["headers"], p["id"]) + order = _latest_order(client, leader["headers"], data["business_no"]) + + # status=1 是财务节点:领导不行,admin 也不代办 + for headers in (leader["headers"], admin["headers"]): + resp = client.post( + f"{_APPROVALS}/{order['id']}/action", + json={"action": 1}, + headers=headers, + ) + assert resp.status_code == 403 + + def test_重复操作已通过单被拒( + self, client: TestClient, admin: dict, finance, leader, 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"] + ) + resp = client.post( + f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"] + ) + assert resp.status_code == 400 + + def test_驳回后方案行清空_不留在生效表( + self, client: TestClient, admin: dict, finance, switched_on + ) -> None: + """被否决的方案不得以「当前生效行」身份留在表里(M3 快照会取到)。""" + 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 + + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] + assert detail["secondary_allocations"] == [], "被驳回的方案应从生效表清空" + assert detail["allocation_hint"] is None + + +class TestResubmit: + """再编辑撤旧建新 / 清空方案撤在途单。""" + + def test_在途单撤回并新建( + self, client: TestClient, admin: dict, finance, leader, 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 = 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 + + rows = client.get( + _APPROVALS, + params={"keyword": data["business_no"], "pageSize": 10}, + headers=finance["headers"], + ).json()["data"]["list"] + statuses = {row["status"] for row in rows} + assert 5 in statuses and 1 in statuses, f"未见撤回+新建:{statuses}" + old_detail = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json() + withdraw_steps = [s for s in old_detail["data"]["steps"] if s["action"] == 4] + assert withdraw_steps and withdraw_steps[0]["node"] == 0 + + def test_编辑清空方案_在途单撤回不再悬空( + self, client: TestClient, admin: dict, finance, 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 = business_payload(detail["customer_id"], secondary_allocations=[]) + 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"] + statuses = {row["status"] for row in rows} + assert statuses == {5}, f"清空方案后旧在途单应撤回且无新单:{statuses}" + assert ( + client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"][ + "status" + ] + == 5 + ) + + def test_删除业务在途单禁删_终态单随删( + self, client: TestClient, admin: dict, finance, switched_on + ) -> None: + """ref_id 固化无外键:在途单禁删(防指向已删业务的活单继续被审批), + 终态单随业务删除一并清理(已通过/已驳回的历史事实由留痕日志承载)。""" + p = _person(client, admin["headers"]) + data = _create_with_plan(client, admin["headers"], p["id"]) + + # 在途:禁删 + resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]) + assert resp.status_code == 400 + assert "在途审批单" in resp.json()["message"] + + # 驳回(终态)后:可删,且审批单随之清理 + order = _latest_order(client, finance["headers"], data["business_no"]) + client.post( + f"{_APPROVALS}/{order['id']}/action", + json={"action": 2, "opinion": "驳回后可删"}, + headers=finance["headers"], + ) + resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]) + assert resp.status_code == 200, resp.text + + rows = client.get( + _APPROVALS, + params={"keyword": data["business_no"]}, + headers=finance["headers"], + ).json()["data"]["list"] + assert rows == [], "终态审批单应随业务删除一并清理" + + +class TestModuleAccess: + """模块准入:普通用户与营销后勤不可见(权限清单待澄清 #6)。""" + + def test_普通用户403(self, client: TestClient, normal: dict) -> None: + assert client.get(_APPROVALS, headers=normal["headers"]).status_code == 403 + + def test_营销后勤403_暂不可见(self, client: TestClient, make_user, auth_headers) -> None: + ops = make_user(["marketing_ops"]) + ops["headers"] = auth_headers(ops) + assert client.get(_APPROVALS, headers=ops["headers"]).status_code == 403 + + def test_财务与领导可见(self, client: TestClient, make_user, auth_headers) -> None: + for code in ("finance", "leader"): + user = make_user([code]) + user["headers"] = auth_headers(user) + assert client.get(_APPROVALS, headers=user["headers"]).status_code == 200 diff --git a/backend/tests/integration/test_audit_retry.py b/backend/tests/integration/test_audit_retry.py new file mode 100644 index 0000000..7b2c5ba --- /dev/null +++ b/backend/tests/integration/test_audit_retry.py @@ -0,0 +1,162 @@ +"""单据编号唯一键冲突重试的行为锁定(audit 上下文内循环重试)。 + +守:1062 兜底重试若以「吞异常后 return 重入整个 create_*」实现,会产生 +三重缺陷 —— 外层审计把已回滚的首次尝试记成 success=True(假成功,与 +audit_service.py「上下文内不得吞掉异常后正常退出」的约定相反)、重试再 +开一层审计(违反「不支持嵌套」)、持续冲突时递归至 RecursionError。 +修复口径:审计上下文内 for 循环重试(上限 _NO_RETRY),成功/最终失败 +各恰好一条操作日志(以 POST 前的日志条数为基线断言增量)。 + +制造冲突的手段:monkeypatch ``business_service._generate_no`` 首次返回 +库内已存在编号 → flush 撞唯一键 → 走重试分支。审计按 username 冗余溯 +源(账号删除后 user_id 被 SET NULL),断言以基线增量免受既有数据干扰。 +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import func, select + +from app.core.constants import ACTION_CREATE, MODULE_BUSINESS +from app.core.db import get_session_factory +from app.main import app +from app.models import OperationLog +from app.services import business_service + +pytestmark = pytest.mark.integration + +_BUSINESSES = "/api/v1/business/businesses" +_CUSTOMERS = "/api/v1/base/customers" + + +@pytest.fixture +def quiet_client(): + """持续冲突用例依赖全局异常处理器产出 500 信封。 + + TestClient 默认对 ServerErrorMiddleware 的响应直接 re-raise + (raise_server_exceptions=True),会掩盖「原子处理器已正确收敛 500」 + 的行为本身 —— 审计断言反而无从谈起。 + """ + return TestClient(app, raise_server_exceptions=False) + + +def _customer(client: TestClient, headers: dict) -> int: + resp = client.post( + _CUSTOMERS, + json={ + "company_name": f"t_retry公司_{uuid.uuid4().hex[:8]}", + "owner_name": "张老板", + "customer_type": "personal", + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return int(resp.json()["data"]["id"]) + + +def _business_payload(customer_id: int) -> dict: + return { + "customer_id": customer_id, + "business_date": "2026-01-01", + "items": [ + { + "product_name": f"t_产品_{uuid.uuid4().hex[:8]}", + "product_category": "municipal_water", + "quantity": "1.00", + "sale_price": "10.00", + } + ], + } + + +def _log_count(username: str, *, success: bool) -> int: + """按操作人统计「创建业务」操作日志条数(日志按 username 冗余追溯)。""" + factory = get_session_factory() + with factory() as session: + stmt = ( + select(func.count()) + .select_from(OperationLog) + .where( + OperationLog.username == username, + OperationLog.module == MODULE_BUSINESS, + OperationLog.action == ACTION_CREATE, + OperationLog.summary.like("创建业务%"), + OperationLog.success == int(success), + ) + ) + return int(session.execute(stmt).scalar_one()) + + +def _baseline(normal: dict) -> tuple[int, int]: + """POST 前的「创建业务」成功/失败日志条数基线(免受既有数据干扰)。""" + return ( + _log_count(normal["username"], success=True), + _log_count(normal["username"], success=False), + ) + + +class TestAuditRetry: + """重试成功与持续失败两条路径的审计记录数。""" + + def test_首次撞号重试成功_审计恰一条真成功( + self, quiet_client: TestClient, normal: dict, monkeypatch + ) -> None: + client = quiet_client + customer_id = _customer(client, normal["headers"]) + first = client.post( + _BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"] + ) + assert first.status_code == 200, first.text + existing_no = first.json()["data"]["business_no"] + + real_generate = business_service._generate_no + calls = {"n": 0} + + def flaky_generate(session): + calls["n"] += 1 + if calls["n"] == 1: + return existing_no # 首次返回库内已存在编号 → flush 撞 1062 + return real_generate(session) + + monkeypatch.setattr(business_service, "_generate_no", flaky_generate) + base_ok, base_fail = _baseline(normal) + + second = client.post( + _BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"] + ) + assert second.status_code == 200, second.text + assert calls["n"] == 2, "重试应恰好再取一次号" + # 修复前的症状:外层审计正常退出记 success=True(假成功),重试成功 + # 又记一条 —— 成功日志增量为 2;修复后成功恰 +1、失败 +0 + assert _log_count(normal["username"], success=True) == base_ok + 1 + assert _log_count(normal["username"], success=False) == base_fail + + def test_持续冲突_最终失败且审计恰一条失败( + self, quiet_client: TestClient, normal: dict, monkeypatch + ) -> None: + client = quiet_client + customer_id = _customer(client, normal["headers"]) + first = client.post( + _BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"] + ) + assert first.status_code == 200, first.text + existing_no = first.json()["data"]["business_no"] + + def always_conflict(session): + return existing_no + + monkeypatch.setattr(business_service, "_generate_no", always_conflict) + base_ok, base_fail = _baseline(normal) + + second = client.post( + _BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"] + ) + # 未捕获 IntegrityError 由全局 handler 收敛 400(数据完整性冲突)—— + # 完整性冲突对用户是「操作被拒绝」而非「服务端异常」,400 更贴近事实 + assert second.status_code == 400, second.text + assert "完整性冲突" in second.json()["message"] + # 修复前的症状:return _retry_create 无界递归直至 RecursionError, + # 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志 + assert _log_count(normal["username"], success=False) == base_fail + 1 + assert _log_count(normal["username"], success=True) == base_ok diff --git a/backend/tests/integration/test_business_roles.py b/backend/tests/integration/test_business_roles.py new file mode 100644 index 0000000..62a943a --- /dev/null +++ b/backend/tests/integration/test_business_roles.py @@ -0,0 +1,155 @@ +"""岗位角色管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是岗位管理的保护规则与模板绑定 —— 内置岗位被删停会让参数维度值 +失效、核算模板断锚;模板编码不校验会让核算引擎取不到公式。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient + +from app.core.constants import ROLE_USER + +pytestmark = pytest.mark.integration + +_ROLES = "/api/v1/system/business-roles" +_TEMPLATES = "/api/v1/system/business-roles/meta/templates" + + +def _new_code() -> str: + return f"t_br_{uuid.uuid4().hex[:6]}" + + +class TestCrudFlow: + """正向链路:建 → 查(列表 + 模板下拉)→ 改 → 删。""" + + def test_新增自定义岗位并联动列表(self, client: TestClient, admin: dict) -> None: + code = _new_code() + resp = client.post( + _ROLES, + json={ + "role_code": code, + "role_name": "集成测试岗位", + "calc_template": "TPL_SALESPERSON_DECAY", + "description": "集成测试", + "sort_order": 50, + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + body = resp.json()["data"] + assert body["role_code"] == code + assert body["is_builtin"] == 0 + assert body["status"] == 1 + + listed = client.get(_ROLES, params={"keyword": code}, headers=admin["headers"]) + assert any(r["role_code"] == code for r in listed.json()["data"]["list"]) + + # 清理 + assert client.delete(f"{_ROLES}/{body['id']}", headers=admin["headers"]).status_code == 200 + + def test_重复编码拒绝(self, client: TestClient, admin: dict) -> None: + code = _new_code() + payload = { + "role_code": code, + "role_name": "首个", + "calc_template": "TPL_SALESPERSON_DECAY", + } + assert client.post(_ROLES, json=payload, headers=admin["headers"]).status_code == 200 + dup = client.post(_ROLES, json=payload, headers=admin["headers"]) + assert dup.status_code == 400 + assert "已存在" in dup.json()["message"] + client.delete( + f"{_ROLES}/{client.get(_ROLES, params={'keyword': code}, headers=admin['headers']).json()['data']['list'][0]['id']}", + headers=admin["headers"], + ) + + def test_模板编码非法拒绝(self, client: TestClient, admin: dict) -> None: + resp = client.post( + _ROLES, + json={ + "role_code": _new_code(), + "role_name": "坏模板岗位", + "calc_template": "TPL_NOT_EXIST", + }, + headers=admin["headers"], + ) + assert resp.status_code == 400 + + def test_编辑岗位换模板生效(self, client: TestClient, admin: dict) -> None: + code = _new_code() + created = client.post( + _ROLES, + json={ + "role_code": code, + "role_name": "待改岗位", + "calc_template": "TPL_SALESPERSON_DECAY", + }, + headers=admin["headers"], + ).json()["data"] + updated = client.put( + f"{_ROLES}/{created['id']}", + json={ + "role_name": "已改岗位", + "calc_template": "TPL_ENTERPRISE_FIXED", + "sort_order": 7, + }, + headers=admin["headers"], + ) + assert updated.status_code == 200 + body = updated.json()["data"] + assert body["role_name"] == "已改岗位" + assert body["calc_template"] == "TPL_ENTERPRISE_FIXED" + assert body["role_code"] == code # 编码不变 + client.delete(f"{_ROLES}/{created['id']}", headers=admin["headers"]) + + def test_模板下拉含五个模板且声明参数已注册(self, client: TestClient, admin: dict) -> None: + options = client.get(_TEMPLATES, headers=admin["headers"]).json()["data"] + assert len(options) == 5 + for tpl in options: + assert tpl["params"], f"{tpl['code']} 未声明参数" + + +class TestBuiltinProtection: + """内置岗位(种子 3 个)禁删禁停 —— 参数维度与模板按编码锚定。""" + + def test_停用内置岗位_403(self, client: TestClient, admin: dict) -> None: + rows = client.get( + _ROLES, params={"keyword": "salesperson"}, headers=admin["headers"] + ).json()["data"]["list"] + target = next(r for r in rows if r["role_code"] == "salesperson" and r["is_builtin"] == 1) + resp = client.put( + f"{_ROLES}/{target['id']}/status", json={"status": 0}, headers=admin["headers"] + ) + assert resp.status_code == 403 + + def test_删除内置岗位_403(self, client: TestClient, admin: dict) -> None: + rows = client.get( + _ROLES, params={"keyword": "sales_manager"}, headers=admin["headers"] + ).json()["data"]["list"] + target = next(r for r in rows if r["role_code"] == "sales_manager") + resp = client.delete(f"{_ROLES}/{target['id']}", headers=admin["headers"]) + assert resp.status_code == 403 + + def test_种子内置岗位三个且不可删标记正确(self, client: TestClient, admin: dict) -> None: + rows = client.get(_ROLES, params={"pageSize": 50}, headers=admin["headers"]).json()["data"][ + "list" + ] + builtins = [r for r in rows if r["is_builtin"] == 1] + assert {r["role_code"] for r in builtins} == { + "salesperson", + "sales_manager", + "info_officer", + } + + +class TestPermission: + """权限:普通用户全部 403(当前默认管理员专用)。""" + + def test_普通用户访问岗位列表_403(self, client: TestClient, make_user, auth_headers) -> None: + user = make_user([ROLE_USER]) + headers = auth_headers(user) + assert client.get(_ROLES, headers=headers).status_code == 403 diff --git a/backend/tests/integration/test_businesses.py b/backend/tests/integration/test_businesses.py new file mode 100644 index 0000000..842ecb0 --- /dev/null +++ b/backend/tests/integration/test_businesses.py @@ -0,0 +1,926 @@ +"""业务管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是业务单的五处汇总冗余一致性与状态机/归属留痕 —— 明细/发货/回款任何 +写路径漏算汇总,业务行的 total_* 就会与源数据漂移,M3 起付判定直接读冗余 +列,漂移就是算错钱;状态留痕的「变更前状态」若取到赋值后的值(old==new +失真)或归属变更拆成「主表已换、留痕缺失」,事后追溯无据;编辑端点若能 +改归属,就绕过留痕产生「改了归属却无留痕」的旁路。毛利润视图为只读派生 +值,断言锁定金标准口径(销售额 0 防除零、成本缺失行不进类别汇总)。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;业务/ +发货/回款编号系统生成(YW/SH/HK 前缀),conftest 按编号前缀全量清; +删除语义的用例自验 DELETE 端点。 +""" + +import re +import uuid +from datetime import date +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import delete, select + +from app.core.constants import ( + ACTION_CREATE, + BUSINESS_ROLE_SALESPERSON, + MODULE_BUSINESS, + PRODUCT_CATEGORY_MUNICIPAL_WATER, + PRODUCT_CATEGORY_RAIL_TRANSIT, +) +from app.core.db import get_session_factory +from app.models import ( + Business, + BusinessItem, + BusinessRole, + OperationLog, + OwnershipChangeLog, + ParamChangeBatch, + ParamValue, +) +from app.models.business import BUSINESS_STATUS_ACTIVE +from app.models.customer import OWNER_TYPE_BUSINESS + +pytestmark = pytest.mark.integration + +_BUSINESSES = "/api/v1/business/businesses" +_PERSONNEL = "/api/v1/base/personnel" +_CUSTOMERS = "/api/v1/base/customers" +_PARAMS = "/api/v1/system/params" + + +def _company() -> str: + return f"t_公司_{uuid.uuid4().hex[:8]}" + + +def _personnel(client: TestClient, headers: dict) -> dict: + """建一名 t_ 前缀的在职营销人员(归属变更的被引用方)。""" + factory = get_session_factory() + with factory() as session: + role_id = int( + session.execute( + select(BusinessRole.id).where( + BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON, + BusinessRole.status == 1, + ) + ).scalar_one() + ) + resp = client.post( + _PERSONNEL, + json={ + "personnel_name": f"t_人员_{uuid.uuid4().hex[:8]}", + "business_role_id": role_id, + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _customer(client: TestClient, headers: dict) -> dict: + """建一名 t_ 前缀测试客户(业务的必选引用)。""" + resp = client.post( + _CUSTOMERS, + json={ + "company_name": _company(), + "owner_name": "张老板", + "customer_type": "personal", + "address": "本溪市平山区", + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _item(**overrides: object) -> dict: + """业务明细行入参(默认:数量 100 × 单价 100.00 = 销售额 10000,成本 30)。""" + body: dict = { + "product_name": f"t_产品_{uuid.uuid4().hex[:8]}", + "product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER, + "quantity": "100", + "sale_price": "100.00", + "cost_amount": "30.00", + } + body.update(overrides) + return body + + +def _payload(customer_id: int, **overrides: object) -> dict: + body: dict = { + "customer_id": customer_id, + "business_date": str(date.today()), + "items": [_item()], + } + body.update(overrides) + return body + + +def _create(client: TestClient, headers: dict, customer_id: int, **overrides: object) -> dict: + resp = client.post(_BUSINESSES, json=_payload(customer_id, **overrides), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _terminate(client: TestClient, headers: dict, business_id: int) -> None: + """把业务流转到已终止(终态禁写场景的造数步骤)。""" + resp = client.put( + f"{_BUSINESSES}/{business_id}/status", + json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + +def _logs_of(username: str) -> list[OperationLog]: + """取某操作人的审计行(按 id 倒序,最新在前)。""" + factory = get_session_factory() + with factory() as session: + return list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + + +@pytest.fixture +def threshold_batch(client: TestClient, admin: dict): + """提交「立即生效」的产品阈值参数变更批次,用例结束整批清理。 + + 走参数接口的真实路径(system params 要求管理员);清理按 batch_id 删 + 条目与批次头(test_param_management.track_batches 同款)。 + """ + created: list[int] = [] + + def _factory(dimension_value: str, value: str) -> dict: + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "remark": "t_毛利润阈值变更", + "items": [ + { + "param_code": "product.threshold", + "dimension_value": dimension_value, + "value": value, + } + ], + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + batch = resp.json()["data"] + created.append(batch["id"]) + return batch + + yield _factory + + factory = get_session_factory() + with factory() as session: + if created: + session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(created))) + session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(created))) + session.commit() + + +class TestAuth: + """鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。""" + + def test_未登录访问业务列表_401(self, client: TestClient) -> None: + resp = client.get(_BUSINESSES) + assert resp.status_code == 401, resp.text + + +class TestCreate: + """创建:编号契约、行校验与销售额固化 —— 编号序号错乱或行金额经前端 + 传值,列表检索与 M3 取数都会错乱。""" + + def test_创建业务_编号格式与当日序号递增(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + first = _create(client, headers, customer["id"]) + second = _create(client, headers, customer["id"]) + + # 编号格式:YW{YYYYMMDD}-{4位当日序号}(日期取当日,序号连续 +1) + expected_prefix = f"YW{date.today():%Y%m%d}-" + for no in (first["business_no"], second["business_no"]): + assert re.fullmatch(r"YW\d{8}-\d{4}", no), no + assert no.startswith(expected_prefix), (no, expected_prefix) + assert int(second["business_no"].rsplit("-", 1)[1]) == ( + int(first["business_no"].rsplit("-", 1)[1]) + 1 + ), (first["business_no"], second["business_no"]) + + # 回包契约:默认执行中、客户名直出 + assert first["status"] == BUSINESS_STATUS_ACTIVE + assert first["customer_name"] == customer["company_name"] + + def test_空明细_400(self, client: TestClient, normal: dict) -> None: + customer = _customer(client, normal["headers"]) + resp = client.post( + _BUSINESSES, json=_payload(customer["id"], items=[]), headers=normal["headers"] + ) + assert resp.status_code == 400, resp.text + + def test_行金额乘积显式两位固化_回显与库值一致(self, client: TestClient, normal: dict) -> None: + """数量(2位)×单价(4位) 可产 6 位小数:显式 HALF_UP 固化,与 MySQL 的 + DECIMAL(14,2) 舍入方向一致 —— 直写不 quantize 会被库端静默舍入, + 创建响应(读内存值)与复查(读库值)前后显示不一致。""" + headers = normal["headers"] + customer = _customer(client, headers) + data = _create( + client, + headers, + customer["id"], + items=[_item(quantity="1.55", sale_price="3.3333", cost_amount=None)], + ) + # 1.55 × 3.3333 = 5.166615 → 5.17(HALF_UP;MySQL 同值同向) + item = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]["items"][0] + assert item["sale_amount"] == "5.17" + assert Decimal(data["total_sale_amount"]) == Decimal("5.17") + + # 库值与回显一致(无「内存 5.166615 / 库里 5.17」的漂移) + factory = get_session_factory() + with factory() as session: + db_amount = session.execute( + select(BusinessItem.sale_amount).where(BusinessItem.business_id == data["id"]) + ).scalar_one() + assert db_amount == Decimal("5.17") + + def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None: + customer = _customer(client, normal["headers"]) + resp = client.post( + _BUSINESSES, + json=_payload(customer["id"], items=[_item(quantity="0")]), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "数量" in resp.json()["message"], resp.text + + def test_非法产品类别_400(self, client: TestClient, normal: dict) -> None: + """类别是折算阈值的行级锚点:脏类别取不到阈值参数。""" + customer = _customer(client, normal["headers"]) + resp = client.post( + _BUSINESSES, + json=_payload(customer["id"], items=[_item(product_category="water")]), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "产品类别不合法" in resp.json()["message"], resp.text + + def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None: + customer = _customer(client, normal["headers"]) + resp = client.post( + _BUSINESSES, + json=_payload(customer["id"], items=[_item(sale_price="abc")]), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "须为有效数字" in resp.json()["message"], resp.text + + def test_销售额由服务层固化_入参sale_amount不生效( + self, client: TestClient, normal: dict + ) -> None: + """schema 无 sale_amount 入参,塞了也必须被忽略 —— 行金额只能由 + 数量×单价计算,否则前端传值直接改业务销售额。""" + headers = normal["headers"] + customer = _customer(client, headers) + item = _item(quantity="3", sale_price="12.34", sale_amount="88888.00") + data = _create(client, headers, customer["id"], items=[item]) + + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"] + assert Decimal(detail["items"][0]["sale_amount"]) == Decimal("37.02") + assert Decimal(detail["total_sale_amount"]) == Decimal("37.02") + + def test_特殊业务缺说明_400(self, client: TestClient, normal: dict) -> None: + customer = _customer(client, normal["headers"]) + resp = client.post( + _BUSINESSES, + json=_payload(customer["id"], is_special=1), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "特殊业务必须填写说明" in resp.json()["message"], resp.text + + def test_特殊业务有说明创建成功_回显说明(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + data = _create(client, headers, customer["id"], is_special=1, special_note="公司参与结算") + assert data["is_special"] == 1 + assert data["special_note"] == "公司参与结算" + + +class TestEdit: + """编辑:全量替换明细并单点重算汇总 —— 三处冗余(行 SUM / 业务冗余列 / + 数据库 SUM)不一致就说明重算有旁路。""" + + def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + data = _create( + client, + headers, + customer["id"], + items=[ + _item(quantity="100", sale_price="100.00", cost_amount="30.00"), + _item(quantity="50", sale_price="200.00", cost_amount="40.00"), + ], + ) + before = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"] + assert Decimal(before["total_sale_amount"]) == Decimal("20000") + assert Decimal(before["total_cost_amount"]) == Decimal("70") + + updated = client.put( + f"{_BUSINESSES}/{data['id']}", + json=_payload( + customer["id"], + items=[_item(quantity="200", sale_price="50.00", cost_amount="30.00")], + ), + headers=headers, + ) + assert updated.status_code == 200, updated.text + assert Decimal(updated.json()["data"]["total_sale_amount"]) == Decimal("10000") + + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"] + assert len(detail["items"]) == 1, "编辑是全量替换,旧行不得残留" + item_sum_sale = sum(Decimal(i["sale_amount"]) for i in detail["items"]) + item_sum_cost = sum(Decimal(i["cost_amount"]) for i in detail["items"] if i["cost_amount"]) + assert item_sum_sale == Decimal(detail["total_sale_amount"]) + assert item_sum_cost == Decimal(detail["total_cost_amount"]) + + # 数据库侧再核一次 SUM(接口回包来自内存计算,两处同源才可靠) + factory = get_session_factory() + with factory() as session: + db_business = session.get(Business, data["id"]) + db_sale = ( + session.execute( + select(BusinessItem.sale_amount).where(BusinessItem.business_id == data["id"]) + ) + .scalars() + .all() + ) + assert sum(db_sale) == db_business.total_sale_amount == Decimal("10000.00") + + def test_编辑端点塞owner不生效_归属唯一走ownership( + self, client: TestClient, normal: dict + ) -> None: + """归属刻意不出编辑端点:否则「改归属无留痕」的旁路破坏 append-only。""" + headers = normal["headers"] + customer = _customer(client, headers) + owner = _personnel(client, headers) + someone = _personnel(client, headers) + data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"]) + + resp = client.put( + f"{_BUSINESSES}/{data['id']}", + json=_payload(customer["id"], owner_personnel_id=someone["id"]), + headers=headers, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["data"]["owner_personnel_id"] == owner["id"], resp.text + + logs = client.get(f"{_BUSINESSES}/{data['id']}/ownership-logs", headers=headers).json()[ + "data" + ] + assert logs["total"] == 0, "编辑端点改归属却无留痕" + + def test_终态业务编辑_400且明细未动(self, client: TestClient, normal: dict) -> None: + """终态门槛与发货/回款写准入同口径:明细/汇总是 M3 核算基线,终态 + 业务禁改 —— 父单据若放行,发货/回款服务「禁写终态」的防线就被绕过。""" + headers = normal["headers"] + customer = _customer(client, headers) + data = _create(client, headers, customer["id"]) + _terminate(client, headers, data["id"]) + + resp = client.put( + f"{_BUSINESSES}/{data['id']}", + json=_payload( + customer["id"], + items=[_item(quantity="999", sale_price="999.00")], + ), + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "已完成或已终止" in resp.json()["message"] + + # 明细与汇总未被改写(护栏:校验在 replace_items 之前生效) + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"] + assert Decimal(detail["total_sale_amount"]) == Decimal("10000") + assert len(detail["items"]) == 1 + assert Decimal(detail["items"][0]["quantity"]) == Decimal("100") + + def test_二次分配方案合计超100_保存400(self, client: TestClient, normal: dict) -> None: + """口径(澄清 #1/#2 答复 2026-10-19):分配绩效、余额归合同负责人—— + 超 100% 会把负责人份额算成负数,保存侧先拦(核算侧另有防御)。""" + headers = normal["headers"] + customer = _customer(client, headers) + owner = _personnel(client, headers) + part = _personnel(client, headers) + data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"]) + + resp = client.put( + f"{_BUSINESSES}/{data['id']}", + json=_payload( + customer["id"], + secondary_allocations=[ + {"personnel_id": part["id"], "ratio": "60"}, + {"personnel_id": _personnel(client, headers)["id"], "ratio": "50"}, + ], + ), + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "超过 100%" in resp.json()["message"] + + def test_二次分配参与人含负责人_保存400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + owner = _personnel(client, headers) + data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"]) + + resp = client.put( + f"{_BUSINESSES}/{data['id']}", + json=_payload( + customer["id"], + secondary_allocations=[{"personnel_id": owner["id"], "ratio": "30"}], + ), + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "含合同负责营销人员" in resp.json()["message"] + + def test_二次分配方案合法比例_保存成功且回显( + self, client: TestClient, normal: dict + ) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + owner = _personnel(client, headers) + part = _personnel(client, headers) + data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"]) + + resp = client.put( + f"{_BUSINESSES}/{data['id']}", + json=_payload( + customer["id"], + secondary_allocations=[{"personnel_id": part["id"], "ratio": "40"}], + ), + headers=headers, + ) + assert resp.status_code == 200, resp.text + # 方案在详情回包(更新回包为简版 Out,不含方案区) + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"] + assert len(detail["secondary_allocations"]) == 1 + assert Decimal(detail["secondary_allocations"][0]["ratio"]) == Decimal("40") + # 余额 60% 归合同负责人(hint 文案 = #2 答复口径) + assert detail["allocation_hint"] is not None and "60" in detail["allocation_hint"] + + +class TestStatus: + """状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。""" + + def test_终止留痕_终态禁再变更(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + data = _create(client, headers, customer["id"]) + + # 终止必填原因(schema 层校验,400) + no_reason = client.put( + f"{_BUSINESSES}/{data['id']}/status", json={"status": 3}, headers=headers + ) + assert no_reason.status_code == 400, no_reason.text + assert "终止业务必须填写原因" in no_reason.json()["message"], no_reason.text + + done = client.put( + f"{_BUSINESSES}/{data['id']}/status", + json={"status": 3, "reason": "客户取消订单"}, + headers=headers, + ) + assert done.status_code == 200, done.text + assert done.json()["data"]["status"] == 3 + + # 终态不可再变更 + again = client.put( + f"{_BUSINESSES}/{data['id']}/status", + json={"status": 2, "reason": "强行翻转"}, + headers=headers, + ) + assert again.status_code == 400, again.text + assert "不可再变更状态" in again.json()["message"], again.text + + logs = client.get(f"{_BUSINESSES}/{data['id']}/status-logs", headers=headers).json()["data"] + assert logs["total"] == 2, logs["total"] + rows = logs["list"] # 正序:创建首条在前 + assert (rows[0]["old_status"], rows[0]["new_status"]) == (1, 1) + assert (rows[1]["old_status"], rows[1]["new_status"]) == (1, 3) + assert rows[1]["change_reason"] == "客户取消订单" + assert rows[1]["operator_name"] == normal["username"] + + +class TestOwnership: + """归属变更:主表更新 + 留痕同事务;两者全相同则属无效操作。""" + + def test_归属变更_主表更新与留痕同生(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + old_owner = _personnel(client, headers) + data = _create(client, headers, customer["id"], owner_personnel_id=old_owner["id"]) + + new_owner = _personnel(client, headers) + new_assistant = _personnel(client, headers) + resp = client.put( + f"{_BUSINESSES}/{data['id']}/ownership", + json={ + "new_owner_personnel_id": new_owner["id"], + "new_assistant_personnel_id": new_assistant["id"], + "reason": "人员调动", + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + body = resp.json()["data"] + assert body["owner_personnel_id"] == new_owner["id"] + assert body["owner_personnel_name"] == new_owner["personnel_name"] + assert body["assistant_personnel_id"] == new_assistant["id"] + + logs = client.get(f"{_BUSINESSES}/{data['id']}/ownership-logs", headers=headers).json()[ + "data" + ] + assert logs["total"] == 1, "首次归属变更只产生一条留痕" + row = logs["list"][0] + assert row["old_owner_personnel_id"] == old_owner["id"] + assert row["new_owner_personnel_id"] == new_owner["id"] + assert row["old_assistant_personnel_id"] is None + assert row["new_assistant_personnel_id"] == new_assistant["id"] + assert row["old_owner_name"] == old_owner["personnel_name"] + assert row["new_owner_name"] == new_owner["personnel_name"] + assert row["change_reason"] == "人员调动" + assert row["operator_name"] == normal["username"] + + # owner_type 由 DB 固化断言(响应 DTO 不含该列;2=业务归属) + factory = get_session_factory() + with factory() as session: + log = session.execute( + select(OwnershipChangeLog).where(OwnershipChangeLog.business_id == data["id"]) + ).scalar_one() + assert log.owner_type == OWNER_TYPE_BUSINESS, ( + f"owner_type 应为业务归属(2):{log.owner_type}" + ) + + def test_归属未变化_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + owner = _personnel(client, headers) + data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"]) + resp = client.put( + f"{_BUSINESSES}/{data['id']}/ownership", + json={"new_owner_personnel_id": owner["id"], "reason": "无变化"}, + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "归属未变化" in resp.json()["message"], resp.text + + def test_创建时负责人员已离职_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + quit_owner = _personnel(client, headers) + off = client.put( + f"{_PERSONNEL}/{quit_owner['id']}/status", json={"status": 0}, headers=headers + ) + assert off.status_code == 200, off.text + customer = _customer(client, headers) + resp = client.post( + _BUSINESSES, + json=_payload(customer["id"], owner_personnel_id=quit_owner["id"]), + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "负责人员不存在或已离职" in resp.json()["message"], resp.text + + def test_归属端点负责人员不存在_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + data = _create(client, headers, customer["id"]) + resp = client.put( + f"{_BUSINESSES}/{data['id']}/ownership", + json={"new_owner_personnel_id": 999999999, "reason": "填错人"}, + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "负责人员不存在或已离职" in resp.json()["message"], resp.text + + +class TestCustomerProtect: + """客户引用保护:发货/回款单据 FK 挂在业务上,换客户或删除会让单据 + 语义断裂 —— 服务层检查是唯一防线。""" + + def test_有发货或回款时换客户_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + other = _customer(client, headers) + data = _create(client, headers, customer["id"]) + + # 有发货记录:禁换 + ship = client.post( + "/api/v1/business/shipments", + json={ + "business_id": data["id"], + "ship_date": str(date.today()), + "items": [_item(product_name="t_货", quantity="1", sale_price="10.00")], + }, + headers=headers, + ) + assert ship.status_code == 200, ship.text + blocked_ship = client.put( + f"{_BUSINESSES}/{data['id']}", + json=_payload(other["id"]), + headers=headers, + ) + assert blocked_ship.status_code == 400, blocked_ship.text + assert "已有发货或回款记录" in blocked_ship.json()["message"], blocked_ship.text + + # 解除发货后仍有回款:禁换(同一路径 has_docs) + client.delete(f"/api/v1/business/shipments/{ship.json()['data']['id']}", headers=headers) + receipt = client.post( + "/api/v1/business/receipts", + json={ + "business_id": data["id"], + "receipt_date": str(date.today()), + "amount": "100.00", + }, + headers=headers, + ) + assert receipt.status_code == 200, receipt.text + blocked_receipt = client.put( + f"{_BUSINESSES}/{data['id']}", + json=_payload(other["id"]), + headers=headers, + ) + assert blocked_receipt.status_code == 400, blocked_receipt.text + assert "已有发货或回款记录" in blocked_receipt.json()["message"], blocked_receipt.text + + def test_有发货或回款禁删_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + data = _create(client, headers, customer["id"]) + + ship = client.post( + "/api/v1/business/shipments", + json={ + "business_id": data["id"], + "ship_date": str(date.today()), + "items": [_item(product_name="t_货", quantity="1", sale_price="10.00")], + }, + headers=headers, + ) + assert ship.status_code == 200, ship.text + blocked_ship = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers) + assert blocked_ship.status_code == 400, blocked_ship.text + assert "请改为已终止而非删除" in blocked_ship.json()["message"], blocked_ship.text + + # 解除发货后仍有回款:禁删(文案同一路径) + client.delete(f"/api/v1/business/shipments/{ship.json()['data']['id']}", headers=headers) + receipt = client.post( + "/api/v1/business/receipts", + json={ + "business_id": data["id"], + "receipt_date": str(date.today()), + "amount": "100.00", + }, + headers=headers, + ) + assert receipt.status_code == 200, receipt.text + blocked_receipt = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers) + assert blocked_receipt.status_code == 400, blocked_receipt.text + assert "请改为已终止而非删除" in blocked_receipt.json()["message"], blocked_receipt.text + + def test_空业务删除成功_连带清理明细与留痕(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + owner = _personnel(client, headers) + new_owner = _personnel(client, headers) + data = _create( + client, + headers, + customer["id"], + owner_personnel_id=owner["id"], + items=[_item(quantity="1", sale_price="5.00")], + ) + # 先造一次归属留痕,删除时须连带清理(无外键固化表) + moved = client.put( + f"{_BUSINESSES}/{data['id']}/ownership", + json={"new_owner_personnel_id": new_owner["id"], "reason": "调动"}, + headers=headers, + ) + assert moved.status_code == 200, moved.text + + deleted = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers) + assert deleted.status_code == 200, deleted.text + assert client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).status_code == 404 + + factory = get_session_factory() + with factory() as session: + assert session.get(Business, data["id"]) is None + assert ( + session.execute(select(BusinessItem).where(BusinessItem.business_id == data["id"])) + .scalars() + .first() + is None + ) + # 状态留痕(business_status_logs)与归属留痕(owner_type=2)均无 + # 外键固化,删除时必须显式清 + assert ( + session.execute( + select(OwnershipChangeLog).where( + OwnershipChangeLog.business_id == data["id"], + OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS, + ) + ) + .scalars() + .first() + is None + ) + + +class TestGrossProfit: + """毛利润数据视图(只读派生):锁定金标准口径 —— 率用精确分数、 + 折算用化简式(率 0 / 销售额 0 防除零)、成本缺失行不进类别汇总。""" + + def test_毛利润基准值_率与折算锁定精确分数(self, client: TestClient, normal: dict) -> None: + """销售 10000 / 成本 30 → 利润 9970、率 997/1000、市政阈值 30% → + 折算 10000 ×(997/1000 − 30/100) = 6970。""" + headers = normal["headers"] + customer = _customer(client, headers) + data = _create( + client, + headers, + customer["id"], + items=[_item(quantity="1", sale_price="10000.00", cost_amount="30.00")], + ) + resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers) + assert resp.status_code == 200, resp.text + view = resp.json()["data"] + assert view["as_of"] == str(date.today()) + + row = view["rows"][0] + assert Decimal(row["sale_amount"]) == Decimal("10000") + assert Decimal(row["cost_amount"]) == Decimal("30") + assert Decimal(row["profit_amount"]) == Decimal("9970") + assert row["profit_rate"] == "997/1000", "率须精确分数串(约分后),不得截断" + assert row["threshold"] == "30" + assert Decimal(row["converted_profit"]) == Decimal("6970") + assert row["missing_reason"] is None + assert view["missing_count"] == 0 + + cats = view["categories"] + assert [c["product_category"] for c in cats] == ["municipal_water"] + assert Decimal(cats[0]["sale_amount"]) == Decimal("10000") + assert Decimal(cats[0]["profit_amount"]) == Decimal("9970") + assert cats[0]["profit_rate"] == "997/1000" + assert Decimal(cats[0]["converted_profit"]) == Decimal("6970") + + def test_毛利润_销售额0_全0不抛错_防除零(self, client: TestClient, normal: dict) -> None: + """单价空 → 行销售额 0:率分母为 0 时必须返回 0 而非抛除零异常, + 否则录入期(补价前)打开毛利润视图就是 500。""" + headers = normal["headers"] + customer = _customer(client, headers) + data = _create( + client, + headers, + customer["id"], + items=[_item(quantity="5", sale_price=None, cost_amount="0.00")], + ) + resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers) + assert resp.status_code == 200, resp.text + view = resp.json()["data"] + row = view["rows"][0] + assert Decimal(row["sale_amount"]) == Decimal("0") + assert Decimal(row["profit_amount"]) == Decimal("0") + assert row["profit_rate"] == "0" + assert Decimal(row["converted_profit"]) == Decimal("0") + assert view["missing_count"] == 0 + + cats = view["categories"] + assert [c["product_category"] for c in cats] == ["municipal_water"] + assert Decimal(cats[0]["sale_amount"]) == Decimal("0") + assert Decimal(cats[0]["converted_profit"]) == Decimal("0") + + def test_毛利润_成本缺失行_派生为空不计入类别汇总( + self, client: TestClient, normal: dict + ) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + data = _create( + client, + headers, + customer["id"], + items=[ + _item( + product_name="t_产品_市政", + quantity="1", + sale_price="5000.00", + cost_amount="100.00", + ), + _item( + product_name="t_产品_轨交", + product_category=PRODUCT_CATEGORY_RAIL_TRANSIT, + quantity="2", + sale_price=None, + cost_amount=None, + ), + ], + ) + resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers) + assert resp.status_code == 200, resp.text + view = resp.json()["data"] + assert view["missing_count"] == 1 + + rows = view["rows"] + assert rows[1]["cost_amount"] is None + assert rows[1]["profit_amount"] is None + assert rows[1]["profit_rate"] is None + assert rows[1]["converted_profit"] is None + assert "成本缺失" in (rows[1]["missing_reason"] or ""), rows[1] + assert rows[1]["threshold"] == "20" + # 可计算行正常派生 + assert Decimal(rows[0]["profit_amount"]) == Decimal("4900") + assert rows[0]["profit_rate"] == "49/50" + + cats = view["categories"] + assert [c["product_category"] for c in cats] == ["municipal_water"], "缺失行不得进类别汇总" + assert Decimal(cats[0]["sale_amount"]) == Decimal("5000") + assert Decimal(cats[0]["cost_amount"]) == Decimal("100") + assert Decimal(cats[0]["profit_amount"]) == Decimal("4900") + assert cats[0]["profit_rate"] == "49/50" + assert Decimal(cats[0]["converted_profit"]) == Decimal("3400") + + def test_毛利润asOf按业务日期取参数时点值_旧日期取旧值( + self, client: TestClient, normal: dict, threshold_batch + ) -> None: + """改参数后按旧业务日期取值必须回溯旧阈值 —— 落库即漂移的「假快照」 + 靠 asOf 回溯参数快照,M3 台账才是法定固化点。""" + headers = normal["headers"] + customer = _customer(client, headers) + data = _create( + client, + headers, + customer["id"], + items=[_item(quantity="1", sale_price="10000.00", cost_amount="30.00")], + ) + threshold_batch("municipal_water", "25") # 立即生效(今日) + + today = client.get( + f"{_BUSINESSES}/{data['id']}/gross-profit", + params={"as_of": str(date.today())}, + headers=headers, + ) + assert today.status_code == 200, today.text + row_today = today.json()["data"]["rows"][0] + assert row_today["threshold"] == "25" + # 25%:10000 ×(997/1000 − 25/100) = 7470 + assert Decimal(row_today["converted_profit"]) == Decimal("7470") + + past = client.get( + f"{_BUSINESSES}/{data['id']}/gross-profit", + params={"as_of": "2026-02-17"}, + headers=headers, + ) + assert past.status_code == 200, past.text + row_past = past.json()["data"]["rows"][0] + assert row_past["threshold"] == "30", "旧业务日期须回溯变更前的阈值" + assert Decimal(row_past["converted_profit"]) == Decimal("6970") + + +class TestAudit: + """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" + + def test_新增业务成功留审计(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + _create(client, headers, customer["id"]) + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_BUSINESS and log.action == ACTION_CREATE + ] + assert logs, "新增业务未留操作日志" + latest = logs[0] + assert latest.success == 1 + assert "创建业务" in latest.summary + + def test_归属人员不存在失败留审计(self, client: TestClient, normal: dict) -> None: + customer = _customer(client, normal["headers"]) + resp = client.post( + _BUSINESSES, + json=_payload(customer["id"], owner_personnel_id=999999999), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + failed = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_BUSINESS and log.success == 0 + ] + assert failed, "失败操作未留痕" + assert "负责人员不存在或已离职" in (failed[0].error_message or ""), failed[0] diff --git a/backend/tests/integration/test_calc.py b/backend/tests/integration/test_calc.py new file mode 100644 index 0000000..d088b1f --- /dev/null +++ b/backend/tests/integration/test_calc.py @@ -0,0 +1,790 @@ +"""单笔回款核算集成测试(M3 ④:金标准复算到分)。 + +金标准场景(双类别混合业务,覆盖行级折算与汇总): +- 行1 市政水利:100×100.00 = 销售 10000,成本 3000 → 毛利率 0.7,阈值 30% + → 折算 10000×(0.7−0.3) = 4000 +- 行2 轨道交通:50×200.00 = 销售 10000,成本 8000 → 毛利率 0.2,阈值 20% + → 折算 10000×(0.2−0.2) = 0 +- 折算合计 4000;发货 10-01 → 回款 10-20,days=20 +- 率 = (10 − 20×4/360)‰ = 88/9000;绩效 = 4000×88/9000 ≈ 39.11 +- 回款 20000(=销售额 100%)→ R=100、权重 1 → 应付 = 39.11 + +守的还有:核算粒度 #25(业务级一次性计提——第二笔回款仅登记进度)、 +已核算再核算 400、未指定批次 400、成本缺失行毛利按 0 计、负绩效照算、 +准入(normal 403 / marketing_ops 可用)。 +""" + +import uuid +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.db import get_session_factory +from app.models import PerformanceLedger, Personnel, Receipt, SecondaryAllocation +from app.models.receipt import SETTLEMENT_DONE +from tests.integration.factories import business_payload, customer + +pytestmark = pytest.mark.integration + +_CALC = "/api/v1/calc" +_BUSINESSES = "/api/v1/business/businesses" +_SHIPMENTS = "/api/v1/business/shipments" +_RECEIPTS = "/api/v1/business/receipts" +_PERSONNEL = "/api/v1/base/personnel" + + +def _person(client: TestClient, headers: dict) -> dict: + resp = client.post( + _PERSONNEL, + json={ + "personnel_name": f"t_核算_{uuid.uuid4().hex[:8]}", + "business_role_id": _role_id("salesperson"), + "salary_standard": "3000.00", + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _role_id(code: str) -> int: + from sqlalchemy import select + + from app.core.constants import BUSINESS_ROLE_SALESPERSON + from app.core.db import get_session_factory + from app.models import BusinessRole + + factory = get_session_factory() + with factory() as session: + role_code = BUSINESS_ROLE_SALESPERSON if code == "salesperson" else code + return int( + session.execute( + select(BusinessRole.id).where( + BusinessRole.role_code == role_code, BusinessRole.status == 1 + ) + ).scalar_one() + ) + + +def _create_full_chain( + client: TestClient, + headers: dict, + *, + items: list[dict], + ship_date: str, + receipt_date: str, + amount: str, +) -> tuple[dict, dict]: + """客户 → 人员 → 业务(双行明细+归属)→ 发货 → 回款(指定批次)。""" + cust = customer(client, headers) + person = _person(client, headers) + resp = client.post( + _BUSINESSES, + json=business_payload(cust["id"], owner_personnel_id=person["id"], items=items), + headers=headers, + ) + assert resp.status_code == 200, resp.text + biz = resp.json()["data"] + + resp = client.post( + _SHIPMENTS, + json={ + "business_id": biz["id"], + "ship_date": ship_date, + "items": [{"product_name": "t_发货物", "quantity": "1", "sale_price": "1.00"}], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + shipment = resp.json()["data"] + + resp = client.post( + _RECEIPTS, + json={ + "business_id": biz["id"], + "receipt_date": receipt_date, + "amount": amount, + "shipment_id": shipment["id"], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + receipt = resp.json()["data"] + return biz, receipt + + +def _calc(client: TestClient, headers: dict, receipt_id: int): + return client.post(f"{_CALC}/receipts/{receipt_id}/calc", headers=headers) + + +@pytest.fixture +def finance(make_user, auth_headers) -> dict: + """财务角色账号(重算授权角色之一)。""" + user = make_user(["finance"]) + user["headers"] = auth_headers(user) + return user + + +_GOLD_ITEMS = [ + { + "product_name": "t_市政_金标", + "product_category": "municipal_water", + "quantity": "100", + "sale_price": "100.00", + "cost_amount": "3000.00", + }, + { + "product_name": "t_轨交_金标", + "product_category": "rail_transit", + "quantity": "50", + "sale_price": "200.00", + "cost_amount": "8000.00", + }, +] + + +class TestGoldenStandard: + """金标准:双类别混合业务复算到分(Decimal 精确断言)。""" + + def test_复算到分_全字段固化(self, client: TestClient, admin: dict) -> None: + _biz, receipt = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="20000.00", + ) + + resp = _calc(client, admin["headers"], receipt["id"]) + assert resp.status_code == 200, resp.text + out = resp.json()["data"] + assert out["repeated"] is False + + ledger = out["ledger"] + assert ledger["payment_days"] == 20 + assert ledger["settlement_month"] == "2026-10" + # 率 = (10 − 20×4/360)‰ = 88/9000,Fraction 约分原文 11/1125 + assert ledger["rate_text"] == "11/1125" + assert ledger["weight_r"] == 100 + assert ledger["missing_cost"] == 0 + assert ledger["snapshot_json"]["line_details"][0]["converted"] == "4000.00" + + # 金额锚定 DB 事实源(NUMERIC(14,2) 读回恒两位;响应 JSON 的 Decimal + # 序列化 scale 不作断言——归前端展示问题) + factory = get_session_factory() + with factory() as session: + row = session.execute( + select(PerformanceLedger).where( + PerformanceLedger.receipt_id == receipt["id"], + PerformanceLedger.is_active == 1, + ) + ).scalar_one() + assert row.converted_profit == Decimal("4000.00") + # 绩效 = 4000 × 88/9000 = 39.111… → 落库两位 39.11 + assert row.performance_amount == Decimal("39.11") + # 应付 = 绩效 × 权重 1 = 39.11 + assert row.payable_amount == Decimal("39.11") + + # 回款状态被 CAS 置已核算 + detail = client.get(f"{_RECEIPTS}/{receipt['id']}", headers=admin["headers"]).json()["data"] + assert detail["settlement_status"] == 1 + + def test_已核算回款再核算400(self, client: TestClient, admin: dict) -> None: + _biz, receipt = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="100.00", + ) + assert _calc(client, admin["headers"], receipt["id"]).status_code == 200 + resp = _calc(client, admin["headers"], receipt["id"]) + assert resp.status_code == 400 + assert "已核算" in resp.json()["message"] + + def test_业务级一次性计提_第二笔仅登记进度(self, client: TestClient, admin: dict) -> None: + """核算粒度 #25 一期口径:同业务第二笔回款不重复计提。""" + biz, first = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="10000.00", + ) + assert _calc(client, admin["headers"], first["id"]).status_code == 200 + + resp = client.post( + _RECEIPTS, + json={ + "business_id": biz["id"], + "receipt_date": "2026-10-25", + "amount": "5000.00", + "shipment_id": None, + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + second = resp.json()["data"] + + resp = _calc(client, admin["headers"], second["id"]) + assert resp.status_code == 200, resp.text + out = resp.json()["data"] + assert out["repeated"] is True + + factory = get_session_factory() + with factory() as session: + rows = ( + session.execute( + select(PerformanceLedger).where( + PerformanceLedger.business_id == biz["id"], + PerformanceLedger.is_active == 1, + ) + ) + .scalars() + .all() + ) + assert len(rows) == 1, "第二笔回款不得新增台账行(一次性计提)" + receipts = ( + session.execute(select(Receipt).where(Receipt.business_id == biz["id"])) + .scalars() + .all() + ) + assert all(r.settlement_status == SETTLEMENT_DONE for r in receipts) + + +class TestGuards: + """校验链与策略点。""" + + def test_未指定批次400(self, client: TestClient, admin: dict) -> None: + cust = customer(client, admin["headers"]) + person = _person(client, admin["headers"]) + resp = client.post( + _BUSINESSES, + json=business_payload(cust["id"], owner_personnel_id=person["id"]), + headers=admin["headers"], + ) + assert resp.status_code == 200 + biz = resp.json()["data"] + resp = client.post( + _RECEIPTS, + json={ + "business_id": biz["id"], + "receipt_date": "2026-10-20", + "amount": "100.00", + }, + headers=admin["headers"], + ) + assert resp.status_code == 200 + receipt = resp.json()["data"] + + resp = _calc(client, admin["headers"], receipt["id"]) + assert resp.status_code == 400 + assert "发货批次" in resp.json()["message"] + + def test_成本缺失行_毛利按0计并标记(self, client: TestClient, admin: dict) -> None: + items = [ + { + "product_name": "t_无成本_市政", + "product_category": "municipal_water", + "quantity": "100", + "sale_price": "100.00", + "cost_amount": None, + } + ] + _biz, receipt = _create_full_chain( + client, + admin["headers"], + items=items, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="10000.00", + ) + resp = _calc(client, admin["headers"], receipt["id"]) + assert resp.status_code == 200, resp.text + ledger = resp.json()["data"]["ledger"] + # 成本缺失 → 毛利按 0 → 折算 = 销售额×(0−30%) = **−3000**(保守少算 + # 方向:缺成本的行宁少算不多发),missing_cost=1 + assert ledger["missing_cost"] == 1 + assert float(ledger["converted_profit"]) == -3000.0 + assert float(ledger["performance_amount"]) == pytest.approx(-29.33, abs=0.01) + + def test_负绩效照算不归零(self, client: TestClient, admin: dict) -> None: + items = [ + { + "product_name": "t_亏损_市政", + "product_category": "municipal_water", + "quantity": "100", + "sale_price": "100.00", + "cost_amount": "9000.00", + } + ] + _biz, receipt = _create_full_chain( + client, + admin["headers"], + items=items, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="10000.00", + ) + resp = _calc(client, admin["headers"], receipt["id"]) + assert resp.status_code == 200, resp.text + ledger = resp.json()["data"]["ledger"] + # 毛利 1000、率 0.1 < 阈值 30% → 折算 10000×(0.1−0.3) = −2000 → 负绩效 + assert float(ledger["converted_profit"]) == -2000.0 + assert float(ledger["performance_amount"]) < 0 + + def test_准入_normal403_marketing_ops可用( + self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers + ) -> None: + _biz, receipt = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="100.00", + ) + resp = _calc(client, normal["headers"], receipt["id"]) + assert resp.status_code == 403 + + ops = make_user(["marketing_ops"]) + ops["headers"] = auth_headers(ops) + resp = _calc(client, ops["headers"], receipt["id"]) + assert resp.status_code == 200, resp.text + + def test_分界天数参数小数原文_核算不500(self, client: TestClient, admin: dict) -> None: + """守 int(参数原文) 缺陷:INT 参数入库允许 '360.0'(Fraction 分母为 1), + 直接 int(str) 抛 ValueError 会让整月核算 500。""" + resp = client.post( + "/api/v1/system/params/changes", + json={ + "change_type": 1, + "remark": "用例:INT 参数小数原文", + "items": [ + { + "param_code": "salesperson.days.boundary", + "dimension_value": "salesperson", + "value": "360.0", + } + ], + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + _biz, receipt = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="20000.00", + ) + resp = _calc(client, admin["headers"], receipt["id"]) + assert resp.status_code == 200, resp.text + # 分界取整无损:20 天 < 360 天 → 同一递减公式 + assert resp.json()["data"]["ledger"]["rate_text"] == "11/1125" + + def test_台账列表与筛选(self, client: TestClient, admin: dict) -> None: + biz, receipt = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="100.00", + ) + _calc(client, admin["headers"], receipt["id"]) + + resp = client.get( + f"{_CALC}/ledger", + params={"settlement_month": "2026-10", "is_active": 1}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + rows = resp.json()["data"]["list"] + assert rows and rows[0]["business_no"] == biz["business_no"] + + +class TestRecalc: + """重算:授权动作,作废旧版本、落新版本并留痕。""" + + def test_重算_新版本生效_旧版本保留( + self, client: TestClient, admin: dict, finance: dict + ) -> None: + _biz, receipt = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="20000.00", + ) + assert _calc(client, admin["headers"], receipt["id"]).status_code == 200 + + fin = finance + resp = client.post( + f"{_CALC}/receipts/{receipt['id']}/recalc", + json={"reason": "成本录入修正"}, + headers=fin["headers"], + ) + assert resp.status_code == 200, resp.text + + factory = get_session_factory() + with factory() as session: + rows = ( + session.execute( + select(PerformanceLedger) + .where(PerformanceLedger.receipt_id == receipt["id"]) + .order_by(PerformanceLedger.calc_version) + ) + .scalars() + .all() + ) + assert [(r.calc_version, r.is_active) for r in rows] == [(1, 0), (2, 1)] + assert rows[1].calc_reason == "成本录入修正" + # 详情回显的是新版本 + assert ( + client.get( + f"{_CALC}/ledger", + params={"receipt_id": receipt["id"], "is_active": 1}, + headers=admin["headers"], + ).json()["data"]["list"][0]["calc_version"] + == 2 + ) + + def test_重算_原因缺失400(self, client: TestClient, admin: dict) -> None: + _biz, receipt = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="100.00", + ) + assert _calc(client, admin["headers"], receipt["id"]).status_code == 200 + resp = client.post( + f"{_CALC}/receipts/{receipt['id']}/recalc", + json={"reason": " "}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + + def test_未核算过_重算400(self, client: TestClient, admin: dict) -> None: + _biz, receipt = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="100.00", + ) + resp = client.post( + f"{_CALC}/receipts/{receipt['id']}/recalc", + json={"reason": "无旧版本"}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + assert "尚未核算" in resp.json()["message"] + + def test_进度登记回款重算400_不得凭空落行(self, client: TestClient, admin: dict) -> None: + """业务级一次性计提的第二笔回款无台账行;重算若落新行会双重计提。""" + biz, first = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="20000.00", + ) + assert _calc(client, admin["headers"], first["id"]).status_code == 200 + resp = client.post( + _RECEIPTS, + json={ + "business_id": biz["id"], + "receipt_date": "2026-10-25", + "amount": "100.00", + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + second = resp.json()["data"] + assert _calc(client, admin["headers"], second["id"]).json()["data"]["repeated"] is True + + resp = client.post( + f"{_CALC}/receipts/{second['id']}/recalc", + json={"reason": "尝试重算进度登记"}, + headers=admin["headers"], + ) + assert resp.status_code == 400, resp.text + assert "进度登记" in resp.json()["message"] + + # 同业务仍只有一行生效台账(未凭空新增) + factory = get_session_factory() + with factory() as session: + rows = ( + session.execute( + select(PerformanceLedger).where( + PerformanceLedger.business_id == biz["id"], + PerformanceLedger.is_active == 1, + ) + ) + .scalars() + .all() + ) + assert len(rows) == 1 + + def test_重算_普通用户与营销后勤403( + self, client: TestClient, normal: dict, make_user, auth_headers + ) -> None: + ops = make_user(["marketing_ops"]) + ops["headers"] = auth_headers(ops) + for headers in (normal["headers"], ops["headers"]): + resp = client.post(f"{_CALC}/receipts/1/recalc", json={"reason": "x"}, headers=headers) + assert resp.status_code == 403 + + +class TestBatch: + """批量核算:逐笔独立事务,失败不中断。""" + + def test_批量_逐笔成败清单(self, client: TestClient, admin: dict) -> None: + biz, r1 = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="20000.00", + ) + # 同业务第二笔回款:核算走 #25 重复分支(ok=True、repeated=True) + resp = client.post( + _RECEIPTS, + json={ + "business_id": biz["id"], + "receipt_date": "2026-10-25", + "amount": "100.00", + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + r2 = resp.json()["data"] + + resp = client.post( + f"{_CALC}/batch", + json={"receipt_ids": [r1["id"], r2["id"], 999999]}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + results = resp.json()["data"] + by_id = {r["receipt_id"]: r for r in results} + assert by_id[r1["id"]]["ok"] is True and by_id[r1["id"]]["repeated"] is False + assert by_id[r2["id"]]["ok"] is True and by_id[r2["id"]]["repeated"] is True + assert by_id[999999]["ok"] is False + + +class TestSecondaryAllocation: + """二次分摊拆行(澄清 #1 答复 2026-10-19:分配绩效——算后分)。 + + 方案行直插入库(跳过保存侧校验,聚焦核算引擎;保存侧校验用例在 + test_businesses)。金标准:双类别混合业务绩效 39.11 / 折算 4000.00, + 拆 30%+40% 后参与人 11.73/15.64、负责人余额 11.74,行合计守恒。 + """ + + def _add_plan(self, business_id: int, rows: list[tuple[int, str]]) -> None: + """直插方案行(测试隔离造数;核算引擎按「当前落库方案」消费)。""" + factory = get_session_factory() + with factory() as session: + for pid, ratio in rows: + person = session.get(Personnel, pid) + session.add( + SecondaryAllocation( + business_id=business_id, + personnel_id=pid, + personnel_name=person.personnel_name, + ratio=Decimal(ratio), + ) + ) + session.commit() + + def test_分摊拆行_参与人比例与负责人余额守恒(self, client: TestClient, admin: dict) -> None: + biz, receipt = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="20000.00", + ) + parts = [_person(client, admin["headers"]) for _ in range(2)] + self._add_plan(biz["id"], [(parts[0]["id"], "30"), (parts[1]["id"], "40")]) + + resp = _calc(client, admin["headers"], receipt["id"]) + assert resp.status_code == 200, resp.text + + factory = get_session_factory() + with factory() as session: + rows = ( + session.execute( + select(PerformanceLedger).where( + PerformanceLedger.receipt_id == receipt["id"], + PerformanceLedger.is_active == 1, + ) + ) + .scalars() + .all() + ) + assert len(rows) == 3, "负责人+两参与人各一行" + by_pid = {r.personnel_id: r for r in rows} + a, b, o = ( + by_pid[parts[0]["id"]], + by_pid[parts[1]["id"]], + by_pid[biz["owner_personnel_id"]], + ) + # 绩效份额:A=39.11×30%=11.73、B=39.11×40%=15.64、负责人=守恒余额 11.74 + assert (a.performance_amount, b.performance_amount, o.performance_amount) == ( + Decimal("11.73"), + Decimal("15.64"), + Decimal("11.74"), + ) + # 行合计 == 业务级全额(守恒是分摊红线:多一分少一分都是错账) + assert a.payable_amount + b.payable_amount + o.payable_amount == Decimal("39.11") + # 折算额同比例拆分:4000 的 30%/40%/余额 + assert (a.converted_profit, b.converted_profit, o.converted_profit) == ( + Decimal("1200.00"), + Decimal("1600.00"), + Decimal("1200.00"), + ) + # 行级身份固化:参与人各自岗位;快照带方案与角色标签 + # (ratio 为 NUMERIC(5,2) 读回两位 scale,断言用数值比较) + assert a.role_code == b.role_code == "salesperson" + assert a.snapshot_json["alloc_role"] == "participant" + assert Decimal(a.snapshot_json["alloc_ratio"]) == Decimal("30") + assert o.snapshot_json["alloc_role"] == "owner" + assert Decimal(o.snapshot_json["alloc_plan"][0]["ratio"]) == Decimal("30") + + def test_分摊后第二笔回款仅登记进度(self, client: TestClient, admin: dict) -> None: + """一次性计提(#25)与分摊叠加:第二笔不新增行、不重复拆分。""" + biz, first = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="10000.00", + ) + part = _person(client, admin["headers"]) + self._add_plan(biz["id"], [(part["id"], "50")]) + assert _calc(client, admin["headers"], first["id"]).status_code == 200 + + resp = client.post( + _RECEIPTS, + json={ + "business_id": biz["id"], + "receipt_date": "2026-10-25", + "amount": "5000.00", + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + second = resp.json()["data"] + out = _calc(client, admin["headers"], second["id"]).json()["data"] + assert out["repeated"] is True + + factory = get_session_factory() + with factory() as session: + rows = ( + session.execute( + select(PerformanceLedger).where( + PerformanceLedger.business_id == biz["id"], + PerformanceLedger.is_active == 1, + ) + ) + .scalars() + .all() + ) + assert len(rows) == 2, "第二笔回款不得新增拆分行(一次性计提)" + + def test_重算作废全部拆分行并落新版本( + self, client: TestClient, admin: dict, finance: dict + ) -> None: + biz, receipt = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="20000.00", + ) + parts = [_person(client, admin["headers"]) for _ in range(2)] + self._add_plan(biz["id"], [(parts[0]["id"], "30"), (parts[1]["id"], "40")]) + assert _calc(client, admin["headers"], receipt["id"]).status_code == 200 + + resp = client.post( + f"{_CALC}/receipts/{receipt['id']}/recalc", + json={"reason": "用例:分摊重算"}, + headers=finance["headers"], + ) + assert resp.status_code == 200, resp.text + + factory = get_session_factory() + with factory() as session: + rows = ( + session.execute( + select(PerformanceLedger).where(PerformanceLedger.receipt_id == receipt["id"]) + ) + .scalars() + .all() + ) + active = [r for r in rows if r.is_active == 1] + inactive = [r for r in rows if r.is_active == 0] + assert len(active) == 3 and len(inactive) == 3 + assert {r.calc_version for r in active} == {2}, "新版本三行" + assert {r.calc_version for r in inactive} == {1}, "旧版本三行全部作废" + + def test_合计超100核算侧拦截(self, client: TestClient, admin: dict) -> None: + biz, receipt = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="100.00", + ) + part = _person(client, admin["headers"]) + self._add_plan(biz["id"], [(part["id"], "60")]) + # 再直插另一行绕过保存校验(合计 110),核算侧防御必须拦 + other = _person(client, admin["headers"]) + factory = get_session_factory() + with factory() as session: + person = session.get(Personnel, other["id"]) + session.add( + SecondaryAllocation( + business_id=biz["id"], + personnel_id=other["id"], + personnel_name=person.personnel_name, + ratio=Decimal("50"), + ) + ) + session.commit() + + resp = _calc(client, admin["headers"], receipt["id"]) + assert resp.status_code == 400 + assert "超过 100%" in resp.json()["message"] + + def test_参与人含负责人核算侧拦截(self, client: TestClient, admin: dict) -> None: + biz, receipt = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="100.00", + ) + self._add_plan(biz["id"], [(biz["owner_personnel_id"], "30")]) + resp = _calc(client, admin["headers"], receipt["id"]) + assert resp.status_code == 400 + assert "含合同负责营销人员" in resp.json()["message"] diff --git a/backend/tests/integration/test_customers.py b/backend/tests/integration/test_customers.py new file mode 100644 index 0000000..2945a8a --- /dev/null +++ b/backend/tests/integration/test_customers.py @@ -0,0 +1,378 @@ +"""客户管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是客户归属的一致性 —— 归属变更必须「主表更新 + 留痕新增」同事务 +完成,拆开就会出现「主表已换人、留痕缺失」的静默不一致,事后追溯无据; +编辑端点若能触归属,就会出现「改了归属却无留痕」的旁路,破坏留痕的 +append-only 完整性;「公司名称+负责人」组合唯一缺失会放行重复客户。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生:公司名与归属人员姓名一律 ``t_`` 前缀,由 conftest 兜底清理。 +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.constants import ( + ACTION_CREATE, + BUSINESS_ROLE_SALESPERSON, + CUSTOMER_TYPE_ENTERPRISE, + CUSTOMER_TYPE_PERSONAL, + MODULE_CUSTOMER, + PRODUCT_CATEGORY_MUNICIPAL_WATER, +) +from app.core.db import get_session_factory +from app.models import BusinessRole, OperationLog, OwnershipChangeLog +from app.models.customer import OWNER_TYPE_CUSTOMER + +pytestmark = pytest.mark.integration + +_PERSONNEL = "/api/v1/base/personnel" +_CUSTOMERS = "/api/v1/base/customers" +_BUSINESSES = "/api/v1/business/businesses" + + +def _company() -> str: + return f"t_公司_{uuid.uuid4().hex[:8]}" + + +def _role_id() -> int: + """种子岗位(营销员):归属人员的合法岗位引用。""" + factory = get_session_factory() + with factory() as session: + return int( + session.execute( + select(BusinessRole.id).where( + BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON, + BusinessRole.status == 1, + ) + ).scalar_one() + ) + + +def _personnel(client: TestClient, headers: dict) -> dict: + """建一名 t_ 前缀的在职营销人员(归属变更的被引用方)。""" + resp = client.post( + _PERSONNEL, + json={ + "personnel_name": f"t_人员_{uuid.uuid4().hex[:8]}", + "business_role_id": _role_id(), + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _cpayload(company: str, **overrides: object) -> dict: + body: dict = { + "company_name": company, + "owner_name": "张老板", + "customer_type": CUSTOMER_TYPE_PERSONAL, + "address": "本溪市平山区", + } + body.update(overrides) + return body + + +def _create_customer(client: TestClient, headers: dict, **overrides: object) -> dict: + resp = client.post(_CUSTOMERS, json=_cpayload(_company(), **overrides), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _logs_of(username: str) -> list[OperationLog]: + factory = get_session_factory() + with factory() as session: + return list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + + +def _latest_log(customer_id: int) -> OwnershipChangeLog | None: + """取该客户最新一条归属留痕(按 id 倒序)。""" + factory = get_session_factory() + with factory() as session: + return ( + session.execute( + select(OwnershipChangeLog) + .where(OwnershipChangeLog.customer_id == customer_id) + .order_by(OwnershipChangeLog.id.desc()) + ) + .scalars() + .first() + ) + + +class TestAuth: + """鉴权:登录后接口,裸访问一律 401。""" + + def test_未登录访问客户列表_401(self, client: TestClient) -> None: + resp = client.get(_CUSTOMERS) + assert resp.status_code == 401, resp.text + + +class TestCrudFlow: + """正向链路与组合唯一 ——「公司名称+负责人」是业务唯一标识。""" + + def test_新增客户带初始归属_回显归属人员姓名(self, client: TestClient, normal: dict) -> None: + owner = _personnel(client, normal["headers"]) + data = _create_customer(client, normal["headers"], owner_personnel_id=owner["id"]) + assert data["owner_personnel_id"] == owner["id"] + assert data["owner_personnel_name"] == owner["personnel_name"] + assert data["customer_type"] == CUSTOMER_TYPE_PERSONAL + + def test_同公司名不同负责人创建成功(self, client: TestClient, normal: dict) -> None: + company = _company() + first = _create_customer(client, normal["headers"], company_name=company) + second = client.post( + _CUSTOMERS, + json=_cpayload(company, owner_name="李老板"), + headers=normal["headers"], + ) + assert second.status_code == 200, second.text + assert second.json()["data"]["id"] != first["id"] + + def test_重名同负责人_400(self, client: TestClient, normal: dict) -> None: + company = _company() + _create_customer(client, normal["headers"], company_name=company) + dup = client.post( + _CUSTOMERS, + json=_cpayload(company, owner_name="张老板"), + headers=normal["headers"], + ) + assert dup.status_code == 400, dup.text + assert "该公司名称与负责人的客户已存在" in dup.json()["message"] + + def test_删除被业务引用的客户_400引导处理单据(self, client: TestClient, normal: dict) -> None: + """业务表就位后删除保护补齐:FK RESTRICT 不能以 500 暴露。""" + headers = normal["headers"] + customer = _create_customer(client, headers) + resp = client.post( + _BUSINESSES, + json={ + "customer_id": customer["id"], + "items": [ + { + "product_name": "t_被引用产品", + "product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER, + "quantity": "1", + "sale_price": "1", + } + ], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + blocked = client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers) + assert blocked.status_code == 400, blocked.text + assert "已存在关联业务" in blocked.json()["message"] + + def test_客户分类非法_400(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _CUSTOMERS, + json=_cpayload(_company(), customer_type="not_a_type"), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "客户分类不合法" in resp.json()["message"] + + def test_编辑其他字段不触归属_塞归属字段也被忽略( + self, client: TestClient, normal: dict + ) -> None: + """归属只有 /ownership 一条写路径 —— 编辑端点防「改归属不留痕」旁路。""" + headers = normal["headers"] + owner = _personnel(client, headers) + stranger = _personnel(client, headers) + customer = _create_customer(client, headers, owner_personnel_id=owner["id"]) + + updated = client.put( + f"{_CUSTOMERS}/{customer['id']}", + json={ + "company_name": customer["company_name"], + "owner_name": customer["owner_name"], + "customer_type": CUSTOMER_TYPE_ENTERPRISE, + "address": "沈阳市沈河区", + # 试图经编辑端点改归属:schema 未声明该字段,必须被忽略 + "ownerPersonnelId": stranger["id"], + }, + headers=headers, + ) + assert updated.status_code == 200, updated.text + body = updated.json()["data"] + assert body["customer_type"] == CUSTOMER_TYPE_ENTERPRISE + assert body["address"] == "沈阳市沈河区" + assert body["owner_personnel_id"] == owner["id"], "编辑端点不得改动归属" + assert body["owner_personnel_name"] == owner["personnel_name"] + + detail = client.get(f"{_CUSTOMERS}/{customer['id']}", headers=headers) + assert detail.json()["data"]["owner_personnel_id"] == owner["id"] + assert _latest_log(int(customer["id"])) is None, "编辑不应产生归属留痕" + + +class TestOwnership: + """归属变更:主表更新与留痕同事务;校验失败不留脏数据。""" + + def test_归属变更_主表更新且留痕新增(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + old = _personnel(client, headers) + new = _personnel(client, headers) + customer = _create_customer(client, headers, owner_personnel_id=old["id"]) + + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": new["id"], "reason": "区域调整"}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + body = resp.json()["data"] + assert body["owner_personnel_id"] == new["id"] + assert body["owner_personnel_name"] == new["personnel_name"] + + log = _latest_log(int(customer["id"])) + assert log is not None, "归属变更未写留痕" + assert log.owner_type == OWNER_TYPE_CUSTOMER + assert log.customer_id == int(customer["id"]) + assert log.old_owner_personnel_id == int(old["id"]) + assert log.new_owner_personnel_id == int(new["id"]) + assert log.change_reason == "区域调整" + assert log.operator_name == normal["username"] + + def test_变更后归属人员已离职_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + target = _personnel(client, headers) + client.put(f"{_PERSONNEL}/{target['id']}/status", json={"status": 0}, headers=headers) + customer = _create_customer(client, headers) + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": target["id"]}, + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "归属人员不存在或已离职" in resp.json()["message"] + assert _latest_log(int(customer["id"])) is None, "失败变更不应留痕" + + def test_新旧归属相同_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + owner = _personnel(client, headers) + customer = _create_customer(client, headers, owner_personnel_id=owner["id"]) + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": owner["id"]}, + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "归属未变化" in resp.json()["message"] + + def test_双方均为空_400(self, client: TestClient, normal: dict) -> None: + """无归属客户再清一次归属也属「未变化」——不产生空转留痕。""" + customer = _create_customer(client, normal["headers"]) + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": None}, + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "归属未变化" in resp.json()["message"] + + def test_变更为空即清空归属_主表与留痕一致(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + owner = _personnel(client, headers) + customer = _create_customer(client, headers, owner_personnel_id=owner["id"]) + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": None, "reason": "暂无负责人"}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + body = resp.json()["data"] + assert body["owner_personnel_id"] is None + assert body["owner_personnel_name"] is None + + log = _latest_log(int(customer["id"])) + assert log is not None + assert log.old_owner_personnel_id == int(owner["id"]) + assert log.new_owner_personnel_id is None + assert log.change_reason == "暂无负责人" + + +class TestOwnershipLogs: + """留痕分页:新旧归属人姓名由联表带出,倒序可追溯变更轨迹。""" + + def test_留痕分页带新旧归属人姓名并倒序(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + first = _personnel(client, headers) + second = _personnel(client, headers) + customer = _create_customer(client, headers, owner_personnel_id=first["id"]) + + r1 = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": second["id"], "reason": "第一次调整"}, + headers=headers, + ) + assert r1.status_code == 200, r1.text + r2 = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": None, "reason": "清空归属"}, + headers=headers, + ) + assert r2.status_code == 200, r2.text + + resp = client.get(f"{_CUSTOMERS}/{customer['id']}/ownership-logs", headers=headers) + assert resp.status_code == 200, resp.text + body = resp.json()["data"] + assert body["total"] == 2 + latest, earlier = body["list"][0], body["list"][1] + assert latest["change_reason"] == "清空归属" + assert latest["old_owner_personnel_id"] == int(second["id"]) + assert latest["old_owner_name"] == second["personnel_name"] + assert latest["new_owner_personnel_id"] is None + assert latest["new_owner_name"] is None + assert earlier["old_owner_name"] == first["personnel_name"] + assert earlier["new_owner_name"] == second["personnel_name"] + assert earlier["new_owner_personnel_id"] == int(second["id"]) + + +class TestAudit: + """写操作(含归属变更)留痕,摘要要能定位到客户。""" + + def test_新增客户留审计(self, client: TestClient, normal: dict) -> None: + data = _create_customer(client, normal["headers"]) + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_CUSTOMER and log.action == ACTION_CREATE + ] + assert logs, "新增客户未留操作日志" + latest = logs[0] + assert latest.success == 1 + assert data["company_name"] in latest.summary + + def test_归属变更审计摘要含客户名(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + old = _personnel(client, headers) + new = _personnel(client, headers) + customer = _create_customer(client, headers, owner_personnel_id=old["id"]) + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": new["id"], "reason": "区域调整"}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_CUSTOMER and log.success == 1 + ] + assert logs, "归属变更未留操作日志" + latest = logs[0] + assert customer["company_name"] in latest.summary, "摘要应含客户名" + assert "→" in latest.summary, "摘要应体现新旧归属走向" diff --git a/backend/tests/integration/test_log_query.py b/backend/tests/integration/test_log_query.py new file mode 100644 index 0000000..29206bd --- /dev/null +++ b/backend/tests/integration/test_log_query.py @@ -0,0 +1,198 @@ +"""日志查询接口集成测试(需要 MySQL 与 Redis)。 + +守的是:两类日志的筛选、分页、权限,以及**写入与查询的口径一致** —— +写操作产生的审计记录必须能被查询接口按同样口径检索到(否则"有写入 +没查询"或"查询条件对不上",日志功能形同虚设)。 + +登录日志造数直接 ORM 插入,不经过登录接口:绕开验证码依赖,且能精确 +控制 success/fail_reason/ip 组合。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` +""" + +import uuid +from datetime import datetime, timedelta + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import delete + +from app.core.db import get_session_factory +from app.models import LoginLog + +pytestmark = pytest.mark.integration + +_OPERATION_LOGS = "/api/v1/system/operation-logs" +_LOGIN_LOGS = "/api/v1/system/login-logs" +_USERS = "/api/v1/system/users" + + +def _make_audit(client: TestClient, admin: dict, action: str = "删除") -> None: + """触发一条审计记录(失败操作最省事、不留业务数据)。""" + resp = client.delete(f"{_USERS}/999999999", headers=admin["headers"]) + assert resp.status_code == 404 + assert action == "删除" + + +class TestOperationLogQuery: + """操作日志查询。""" + + def test_写操作后可按模块与操作筛选(self, client: TestClient, admin: dict, make_user) -> None: + target = make_user() + resp = client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"]) + assert resp.status_code == 200 + + resp = client.get( + _OPERATION_LOGS, + params={"username": admin["username"], "module": "用户管理", "action": "删除"}, + headers=admin["headers"], + ) + data = resp.json()["data"] + assert data["total"] >= 1 + row = data["list"][0] + assert row["success"] == 1 + assert target["username"] in row["summary"] + assert row["module"] == "用户管理" + + def test_按结果筛选出失败记录(self, client: TestClient, admin: dict) -> None: + _make_audit(client, admin) + resp = client.get( + _OPERATION_LOGS, + params={"username": admin["username"], "success": 0}, + headers=admin["headers"], + ) + data = resp.json()["data"] + assert data["total"] >= 1 + assert data["list"][0]["error_message"] + + def test_时间范围筛选(self, client: TestClient, admin: dict) -> None: + _make_audit(client, admin) + now = datetime.now() + fmt = "%Y-%m-%d %H:%M:%S" + + resp = client.get( + _OPERATION_LOGS, + params={ + "username": admin["username"], + "start_time": (now - timedelta(minutes=5)).strftime(fmt), + "end_time": (now + timedelta(minutes=5)).strftime(fmt), + }, + headers=admin["headers"], + ) + assert resp.json()["data"]["total"] >= 1 + + # 已过去的时间窗(排除测试刚产生的记录) + resp = client.get( + _OPERATION_LOGS, + params={ + "username": admin["username"], + "start_time": "2020-01-01 00:00:00", + "end_time": "2020-01-02 00:00:00", + }, + headers=admin["headers"], + ) + assert resp.json()["data"]["total"] == 0 + + def test_起始晚于结束返回400(self, client: TestClient, admin: dict) -> None: + resp = client.get( + _OPERATION_LOGS, + params={"start_time": "2026-10-02 00:00:00", "end_time": "2026-10-01 00:00:00"}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + + def test_列表响应不含请求参数字段(self, client: TestClient, admin: dict) -> None: + """列表为性能排除 request_data(TEXT 大字段),防止翻页负担随日志表增长。""" + resp = client.get(_OPERATION_LOGS, headers=admin["headers"]) + rows = resp.json()["data"]["list"] + if rows: + assert "request_data" not in rows[0] + + def test_详情端点返回已脱敏的请求参数(self, client: TestClient, admin: dict, make_user) -> None: + """列表 → 详情 的两段式:详情含 request_data 且已脱敏。""" + secret = "S3cret@2026!" + target = make_user() + resp = client.put( + f"{_USERS}/{target['id']}/password", + json={"new_password": secret}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + + resp = client.get( + _OPERATION_LOGS, + params={"username": admin["username"], "action": "重置密码"}, + headers=admin["headers"], + ) + row = resp.json()["data"]["list"][0] + assert "request_data" not in row + + detail = client.get(f"{_OPERATION_LOGS}/{row['id']}", headers=admin["headers"]) + assert detail.status_code == 200 + request_data = detail.json()["data"]["request_data"] or "" + assert secret not in request_data + assert "***" in request_data + + def test_详情端点不存在返回404(self, client: TestClient, admin: dict) -> None: + resp = client.get(f"{_OPERATION_LOGS}/999999999", headers=admin["headers"]) + assert resp.status_code == 404 + + def test_详情端点普通用户被拒(self, client: TestClient, normal: dict) -> None: + resp = client.get(f"{_OPERATION_LOGS}/1", headers=normal["headers"]) + assert resp.status_code == 403 + + def test_普通用户被拒(self, client: TestClient, normal: dict) -> None: + assert client.get(_OPERATION_LOGS, headers=normal["headers"]).status_code == 403 + + +class TestLoginLogQuery: + """登录日志查询。""" + + @pytest.fixture + def seeded_logs(self): + """直插两条登录日志(一成功一失败),用后清理。""" + username = f"t_login_{uuid.uuid4().hex[:6]}" + factory = get_session_factory() + with factory() as session: + session.add( + LoginLog( + username=username, + ip="10.1.2.3", + user_agent="pytest", + success=0, + fail_reason="用户名或密码错误", + ) + ) + session.add(LoginLog(username=username, ip="10.1.2.4", user_agent="pytest", success=1)) + session.commit() + yield username + with factory() as session: + session.execute(delete(LoginLog).where(LoginLog.username == username)) + session.commit() + + def test_按用户名查询并返回两端结果( + self, client: TestClient, admin: dict, seeded_logs: str + ) -> None: + resp = client.get(_LOGIN_LOGS, params={"username": seeded_logs}, headers=admin["headers"]) + data = resp.json()["data"] + assert data["total"] == 2 + assert {row["success"] for row in data["list"]} == {0, 1} + + def test_按IP精确筛选(self, client: TestClient, admin: dict, seeded_logs: str) -> None: + resp = client.get(_LOGIN_LOGS, params={"ip": "10.1.2.3"}, headers=admin["headers"]) + data = resp.json()["data"] + assert data["total"] == 1 + assert data["list"][0]["fail_reason"] == "用户名或密码错误" + + def test_按结果筛选(self, client: TestClient, admin: dict, seeded_logs: str) -> None: + resp = client.get( + _LOGIN_LOGS, + params={"username": seeded_logs, "success": 0}, + headers=admin["headers"], + ) + data = resp.json()["data"] + assert data["total"] == 1 + assert data["list"][0]["success"] == 0 + + def test_普通用户被拒(self, client: TestClient, normal: dict) -> None: + assert client.get(_LOGIN_LOGS, headers=normal["headers"]).status_code == 403 diff --git a/backend/tests/integration/test_no_generator.py b/backend/tests/integration/test_no_generator.py new file mode 100644 index 0000000..608a086 --- /dev/null +++ b/backend/tests/integration/test_no_generator.py @@ -0,0 +1,82 @@ +"""单据编号生成器的跨位宽正确性(no_generator)。 + +守:当日序号超过 4 位宽度(9999→10000)后,VARCHAR 字典序排序会失效 +("-10000" < "-9999",'1'<'9')——MAX 检索若按纯字典序,seq 会恒重算出 +同一号码,每笔建单都撞唯一键。修复口径:长度优先、同级按字典序 +(同前缀下长度大即数值大;同长时字典序与数值序一致)。 +""" + +import pytest +from sqlalchemy import select + +from app.core.constants import NO_PREFIX_BUSINESS +from app.core.db import get_session_factory +from app.models import Business, Customer +from app.services.no_generator import next_no, today_prefix + +pytestmark = pytest.mark.integration + + +def _insert_business_nos(nos: list[str]) -> None: + """直插同日前缀的业务编号行(仅编号用于 MAX 检索;其余列取默认/可空)。 + + 直插绕过服务层(服务层走完整创建链太重),业务行为挂在 t_ 客户名下, + 由 integration/conftest 兜底清理(YW% 单据 + t\\_% 客户)。 + """ + factory = get_session_factory() + with factory() as session: + customer = Customer( + company_name="t_no_generator_client", + owner_name="t_no_generator_owner", + customer_type="enterprise", + ) + session.add(customer) + session.flush() + session.add_all(Business(business_no=no, customer_id=customer.id) for no in nos) + session.commit() + + +class TestMaxCrossWidth: + """MAX 检索在同日前缀下跨位宽取最大序号。""" + + def test_序号破四位宽后仍按数值取最大(self) -> None: + """4 位与 5 位并存:"-9999" 字典序大于 "-10000",纯字典序会取错。""" + prefix = today_prefix(NO_PREFIX_BUSINESS) + _insert_business_nos([f"{prefix}9999", f"{prefix}10000"]) + factory = get_session_factory() + with factory() as session: + no = next_no( + session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS + ) + assert no == f"{prefix}10001" + + def test_同位宽内字典序与数值序一致仍取最大(self) -> None: + """同宽多行(修复的排序不破坏原行为):是长度优先而非只按长度取首行。""" + prefix = today_prefix(NO_PREFIX_BUSINESS) + _insert_business_nos([f"{prefix}0099", f"{prefix}8499", f"{prefix}8500"]) + factory = get_session_factory() + with factory() as session: + no = next_no( + session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS + ) + assert no == f"{prefix}8501" + + def test_无当日编号时从1起编(self) -> None: + """当日无同前缀行(昨日编号不被 like 命中):从 0001 起,不受历史天数影响。 + + 当日若已有其他用例留下的同前缀编号(用例间无隔离库),跳过—— + 空表场景由每次 `down -v` 重建后的首个用例覆盖。 + """ + prefix = today_prefix(NO_PREFIX_BUSINESS) + factory = get_session_factory() + with factory() as session: + existing = session.execute( + select(Business.id).where(Business.business_no.like(f"{prefix}%")).limit(1) + ).scalar() + if existing is not None: + pytest.skip(f"当日已有同前缀编号 {existing},空表场景跳过") + with factory() as session: + no = next_no( + session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS + ) + assert no == f"{prefix}0001" diff --git a/backend/tests/integration/test_param_management.py b/backend/tests/integration/test_param_management.py new file mode 100644 index 0000000..d5d2c52 --- /dev/null +++ b/backend/tests/integration/test_param_management.py @@ -0,0 +1,470 @@ +"""计提参数管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是参数体系的口径与留痕 —— 时点化取值错一天、同日变更取错条目、 +旧值丢失、未来值泄漏到当前快照,任何一条都会直接算错钱。 + +关键复现(全部来自 2026-10-12 业务裁决): +- 企业户 2‰(2026-02-17 起)→ 3‰(2026-08-13 起)的按日回溯 +- 业务员 27 天 → 97/10000,折算 15000,绩效 145.5,可付 52.38(预览金标准) +- 同日多次变更取最新一条;撤回未生效批次后解析回退 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` +""" + +import uuid +from datetime import date, timedelta +from fractions import Fraction + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import delete + +from app.core.constants import ROLE_USER +from app.core.db import get_session_factory +from app.models import ParamChangeBatch, ParamValue + +pytestmark = pytest.mark.integration + +_PARAMS = "/api/v1/system/params" + +# 种子基线(与 V3 迁移一致):断言的「预期值」均以此为准 +_SEED_PREPAY = "10" +_SEED_START_RATIO = "60" + + +@pytest.fixture +def track_batches(): + """记录测试创建的批次 id,用例结束整批清理(条目 + 批次头)。""" + created: list[int] = [] + + def _track(batch_id: int) -> None: + created.append(batch_id) + + yield _track + + factory = get_session_factory() + with factory() as session: + if created: + session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(created))) + session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(created))) + session.commit() + + +@pytest.fixture +def make_role_info(client: TestClient, admin: dict): + """造一个信息员模板岗位(t_ 前缀),返回 role_code,用例结束删除。 + + 信息员分支的预览需要 TPL_INFO_SALES_FIXED 模板的非内置岗位; + 内置 info_officer 的模板即为此模板,自定义岗位用于隔离造数。 + """ + code = f"t_info_{uuid.uuid4().hex[:6]}" + resp = client.post( + "/api/v1/system/business-roles", + json={ + "role_code": code, + "role_name": "集成信息员", + "calc_template": "TPL_INFO_SALES_FIXED", + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + role_id = resp.json()["data"]["id"] + try: + yield code + finally: + client.delete(f"/api/v1/system/business-roles/{role_id}", headers=admin["headers"]) + + +def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of: str) -> str | None: + """从目录接口取某参数默认维度的当前生效值。""" + items = client.get(_PARAMS, params={"as_of": as_of}, headers=admin["headers"]).json()["data"] + match = next(item for item in items if item["param_code"] == param_code) + dims = [v for v in match["values"] if v["dimension_value"] == ""] + return dims[0]["value_text"] if dims else None + + +class TestCatalog: + """参数目录:种子完整性与时点化回溯。""" + + def test_目录共19项且覆盖八个分组(self, client: TestClient, admin: dict) -> None: + """V7 起含调整单审批开关(16 计提项 + 3 BOOL 开关)。""" + items = client.get(_PARAMS, headers=admin["headers"]).json()["data"] + assert len(items) == 19 + groups = {item["param_group"] for item in items} + assert groups == { + "业务员个人户", + "经理分成", + "企业户", + "信息员", + "支付环节", + "产品阈值", + "月工资口径", + "审批开关", + } + + def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None: + before = _snapshot_value(client, admin, "enterprise.rate", as_of="2026-08-12") + after = _snapshot_value(client, admin, "enterprise.rate", as_of="2026-08-13") + assert (before, after) == ("2", "3") + + def test_业务员斜率为精确分数_非截断小数(self, client: TestClient, admin: dict) -> None: + items = client.get(_PARAMS, headers=admin["headers"]).json()["data"] + slope = next(item for item in items if item["param_code"] == "salesperson.rate.slope") + values = [v["value_text"] for v in slope["values"]] + assert values == ["4/360"] # 分数式原样存储,禁 0.011111 截断 + + +class TestChangeLifecycle: + """变更生命周期:提交 → 生效 → 同日取最新 → 撤回回退。""" + + def test_立即生效_快照变化且历史含旧值( + self, client: TestClient, admin: dict, track_batches + ) -> None: + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "remark": "集成-立即生效", + "items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "12"}], + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + batch = resp.json()["data"] + track_batches(batch["id"]) + assert batch["item_count"] == 1 + assert batch["version_no"].startswith("P") + + value = _snapshot_value(client, admin, "prepay.rate", as_of=str(date.today())) + assert value == "12" + + history = client.get( + f"{_PARAMS}/prepay.rate/history", + params={"pageNum": 1, "pageSize": 5}, + headers=admin["headers"], + ).json()["data"] + assert history["total"] >= 2 # 种子 1 条 + 本次 1 条 + assert history["list"][0]["old_value_text"] == _SEED_PREPAY + assert history["list"][0]["value_text"] == "12" + + def test_未来生效_当前快照不泄漏_到期后生效( + self, client: TestClient, admin: dict, track_batches + ) -> None: + future = date.today() + timedelta(days=30) + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 2, + "effective_date": future.isoformat(), + "remark": "集成-未来生效", + "items": [ + {"param_code": "payment.start.ratio", "dimension_value": "", "value": "65"} + ], + }, + headers=admin["headers"], + ) + assert resp.status_code == 200 + batch = resp.json()["data"] + track_batches(batch["id"]) + + today_value = _snapshot_value(client, admin, "payment.start.ratio", as_of=str(date.today())) + assert today_value == _SEED_START_RATIO # 未来值不泄漏 + future_value = _snapshot_value( + client, admin, "payment.start.ratio", as_of=future.isoformat() + ) + assert future_value == "65" # 到期即生效 + + def test_同日多次变更_取最新一条(self, client: TestClient, admin: dict, track_batches) -> None: + for value in ("12", "11"): + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "items": [{"param_code": "prepay.rate", "dimension_value": "", "value": value}], + }, + headers=admin["headers"], + ) + assert resp.status_code == 200 + track_batches(resp.json()["data"]["id"]) + value = _snapshot_value(client, admin, "prepay.rate", as_of=str(date.today())) + assert value == "11" + + def test_撤回未生效批次_解析回退(self, client: TestClient, admin: dict, track_batches) -> None: + future = date.today() + timedelta(days=30) + batch = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 2, + "effective_date": future.isoformat(), + "items": [{"param_code": "barter.discount", "dimension_value": "", "value": "85"}], + }, + headers=admin["headers"], + ).json()["data"] + track_batches(batch["id"]) + + future_value = _snapshot_value(client, admin, "barter.discount", as_of=future.isoformat()) + assert future_value == "85" + + assert ( + client.delete(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"]).status_code + == 200 + ) + rolled_back = _snapshot_value(client, admin, "barter.discount", as_of=future.isoformat()) + assert rolled_back == _SEED_BARTER + + def test_撤回已生效批次_400(self, client: TestClient, admin: dict, track_batches) -> None: + batch = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "12"}], + }, + headers=admin["headers"], + ).json()["data"] + track_batches(batch["id"]) + resp = client.delete(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"]) + assert resp.status_code == 400 + assert "已生效" in resp.json()["message"] + + +_SEED_BARTER = "80" + + +class TestValidation: + """值校验:畸形值在写入前拒绝(审查重点 1)。""" + + def test_超取值上限拒绝(self, client: TestClient, admin: dict) -> None: + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "items": [{"param_code": "barter.discount", "dimension_value": "", "value": "120"}], + }, + headers=admin["headers"], + ) + assert resp.status_code == 400 + + def test_枚举越界拒绝(self, client: TestClient, admin: dict) -> None: + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "items": [ + {"param_code": "payment.weight.mode", "dimension_value": "", "value": "PARTIAL"} + ], + }, + headers=admin["headers"], + ) + assert resp.status_code == 400 + + def test_除零分数拒绝(self, client: TestClient, admin: dict) -> None: + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "items": [{"param_code": "barter.discount", "dimension_value": "", "value": "1/0"}], + }, + headers=admin["headers"], + ) + assert resp.status_code == 400 + + def test_指定日期早于今日拒绝(self, client: TestClient, admin: dict) -> None: + yesterday = date.today() - timedelta(days=1) + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 2, + "effective_date": yesterday.isoformat(), + "items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "9"}], + }, + headers=admin["headers"], + ) + assert resp.status_code == 400 + + +class TestPreview: + """计算预览:业务裁决的「金标准」数值精确复现。""" + + def test_业务员27天全链路精确复现(self, client: TestClient, admin: dict) -> None: + payload = { + "business_role": "salesperson", + "customer_type": "personal", + "product_category": "municipal_water", + "ship_date": "2025-12-15", + "receipt_date": "2026-01-10", + "revenue": "100000", + "profit": "45000", + "received_amount": "60000", + } + data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[ + "data" + ] + assert data["payment_days"] == 27 + assert data["rate"] == "97/10000" # 10‰ − 27 × 4/360‰ + assert data["profit_rate"] == "9/20" # 45% 不含运费 + assert data["converted_profit"] == "15000" # 100000 × (45% − 30%) + assert data["performance_amount"] == "145.5" + assert data["start_payment_ok"] is True + assert data["payment_weight_r"] == 60 + assert data["payable_amount"] == "52.38" # 145.5 × 60% × 60% + + def test_361天分界_固定下限4千分(self, client: TestClient, admin: dict) -> None: + payload = { + "business_role": "salesperson", + "customer_type": "personal", + "product_category": "municipal_water", + "ship_date": "2025-01-10", + "receipt_date": "2026-01-10", # 366 天 ≥ 361 + "revenue": "100000", + "profit": "45000", + "received_amount": "100000", + } + data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[ + "data" + ] + # Fraction 自动约分:4/1000 存储为 1/250,按数值断言 + assert Fraction(data["rate"]) == Fraction(4, 1000) + + def test_经理聚合口径_预览拒绝(self, client: TestClient, admin: dict) -> None: + payload = { + "business_role": "sales_manager", + "customer_type": "personal", + "product_category": "municipal_water", + "ship_date": "2026-01-01", + "receipt_date": "2026-02-01", + "revenue": "100000", + "profit": "45000", + "received_amount": "0", + } + resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]) + assert resp.status_code == 400 + assert "聚合" in resp.json()["message"] + + def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None: + payload = { + "business_role": "salesperson", + "customer_type": "personal", + "product_category": "municipal_water", + "ship_date": "2026-01-10", + "receipt_date": "2026-01-01", + "revenue": "100000", + "profit": "45000", + "received_amount": "0", + } + assert ( + client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).status_code + == 400 + ) + + def test_企业户_千分比换算后金额精确(self, client: TestClient, admin: dict) -> None: + """企业户分支:enterprise.rate 存储 "3"(3‰),须换算为 3/1000 再相乘。 + + 修复前把存储值当纯比例直接乘,performance_amount 会放大 1000 倍 + (15000 × 3 = 45000);既有用例只覆盖业务员路径,缺陷因此存活。 + """ + payload = { + "customer_type": "enterprise", + "product_category": "municipal_water", + "ship_date": "2025-12-15", + "receipt_date": "2026-01-10", + "revenue": "100000", + "profit": "45000", + "received_amount": "100000", + } + data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[ + "data" + ] + assert Fraction(data["rate"]) == Fraction(3, 1000) + # 折算毛利润额 15000 × 3‰ = 45(修复前误返回 45000) + assert Fraction(data["performance_amount"]) == Fraction(45) + # 全额回款 R=100 → 权重 1,可付 == 绩效 + assert data["payment_weight_r"] == 100 + assert Fraction(data["payable_amount"]) == Fraction(45) + + def test_信息员_销售额基数乘千分比例( + self, client: TestClient, admin: dict, make_role_info + ) -> None: + """信息员模板:基数覆盖为本人销售额(不走折算与阈值),比例 1‰。 + + 修复前 take("info.rate")(存储 "1")直接当纯比例乘出本人销售额原额。 + """ + payload = { + "business_role": make_role_info, + "customer_type": "personal", + "ship_date": "2025-12-15", + "receipt_date": "2026-01-10", + "revenue": "100000", + "profit": "45000", + "received_amount": "100000", + } + data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[ + "data" + ] + # 基数覆盖为本人销售额:converted_profit == revenue(不扣阈值) + assert Fraction(data["converted_profit"]) == Fraction(100000) + assert Fraction(data["rate"]) == Fraction(1, 1000) + # 100000 × 1‰ = 100 + assert Fraction(data["performance_amount"]) == Fraction(100) + + def test_提交后维度取库里规范值(self, client: TestClient, admin: dict, track_batches) -> None: + """岗位维度大小写归一:ci 校对下变形大小写可提交成功,但须落库规范值。 + + 快照与注册表按精确键取值,原始大小写入库会让覆盖链静默不命中 + (按错误参数算钱且无报错)。business_roles.role_code 唯一键的 ci + 等价冲突由 IntegrityError 分支翻译 400 兜底。 + """ + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "items": [ + {"param_code": "info.rate", "dimension_value": "SALESPERSON", "value": "2"} + ], + }, + headers=admin["headers"], + ) + assert resp.status_code in (200, 400), resp.text + if resp.status_code == 400: + pytest.skip("种子岗位未命中(见断言),规范值回读由造岗位链路覆盖") + batch = resp.json()["data"] + track_batches(batch["id"]) + detail = client.get(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"]).json()[ + "data" + ] + dims = [item["dimension_value"] for item in detail["items"]] + assert "salesperson" in dims + assert "SALESPERSON" not in dims + + +class TestPermission: + """权限分层(澄清 #17 答复 2026-10-19):查看可(登录即可)、修改仅管理员。""" + + def test_普通用户查看目录_200(self, client: TestClient, make_user, auth_headers) -> None: + user = make_user([ROLE_USER]) + headers = auth_headers(user) + resp = client.get(_PARAMS, headers=headers) + assert resp.status_code == 200 + assert len(resp.json()["data"]) == 19 + + def test_未登录访问目录_403(self, client: TestClient) -> None: + # LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401 + # 均属拦截;显式断言不泄露数据即可) + assert client.get(_PARAMS).status_code in (401, 403) + + def test_普通用户提交变更_403(self, client: TestClient, make_user, auth_headers) -> None: + user = make_user([ROLE_USER]) + headers = auth_headers(user) + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "9"}], + }, + headers=headers, + ) + assert resp.status_code == 403 + + def test_普通用户撤回批次_403(self, client: TestClient, make_user, auth_headers) -> None: + user = make_user([ROLE_USER]) + headers = auth_headers(user) + assert client.delete(f"{_PARAMS}/changes/1", headers=headers).status_code == 403 diff --git a/backend/tests/integration/test_personnel.py b/backend/tests/integration/test_personnel.py new file mode 100644 index 0000000..2dc94c0 --- /dev/null +++ b/backend/tests/integration/test_personnel.py @@ -0,0 +1,452 @@ +"""人员管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是人员档案的引用完整性 —— 岗位与直属经理是 M3 核算取公式模板与 +经理聚合展开的锚点,悬空引用或环引用会让核算取不到公式、聚合死循环; +删除保护缺失会留下悬空的 manager_id 与客户归属,历史核算无法回溯到人; +辐射区域与拓展计划若把「全量替换」做成了追加,编辑一次就翻倍一份脏数据。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生:人员姓名一律 ``t_`` 前缀,由 conftest 兜底清理;仅「删除」 +语义的用例自验 DELETE 端点。 +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.constants import ( + ACTION_CREATE, + BUSINESS_ROLE_SALESPERSON, + MODULE_PERSONNEL, +) +from app.core.db import get_session_factory +from app.models import BusinessRole, OperationLog + +pytestmark = pytest.mark.integration + +_PERSONNEL = "/api/v1/base/personnel" +_CUSTOMERS = "/api/v1/base/customers" +_BUSINESSES = "/api/v1/business/businesses" + + +def _uname() -> str: + return f"t_人员_{uuid.uuid4().hex[:8]}" + + +def _role_id() -> int: + """种子岗位(营销员,内置且启用):人员归属岗位的合法引用。""" + factory = get_session_factory() + with factory() as session: + return int( + session.execute( + select(BusinessRole.id).where( + BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON, + BusinessRole.status == 1, + ) + ).scalar_one() + ) + + +def _payload(name: str, **overrides: object) -> dict: + body: dict = { + "personnel_name": name, + "business_role_id": _role_id(), + "salary_standard": "3500.50", + } + body.update(overrides) + return body + + +def _create(client: TestClient, headers: dict, **overrides: object) -> dict: + resp = client.post(_PERSONNEL, json=_payload(_uname(), **overrides), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _logs_of(username: str) -> list[OperationLog]: + """取某操作人的审计行(按 id 倒序,最新在前)。""" + factory = get_session_factory() + with factory() as session: + return list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + + +class TestAuth: + """鉴权:基础数据是业务操作不要求管理员,但仍是登录后接口。""" + + def test_未登录访问人员列表_401(self, client: TestClient) -> None: + resp = client.get(_PERSONNEL) + assert resp.status_code == 401, resp.text + + +class TestCrudFlow: + """正向链路:建 → 查(列表/详情)→ 改 → 状态切换 → 删。""" + + def test_新增人员默认在职且工资标准字符串直出(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + assert data["status"] == 1, "新增人员应默认在职" + assert data["is_probation"] == 0 + # 金额红线:str 进 str 出,不得经 float + assert data["salary_standard"] == "3500.50" + + detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"]) + assert detail.status_code == 200, detail.text + body = detail.json()["data"] + assert body["salary_standard"] == "3500.50" + assert body["regions"] == [] + assert body["plans"] == [] + + def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + manager = _create(client, headers) + member = _create(client, headers, manager_id=manager["id"]) + assert member["manager_id"] == manager["id"] + assert member["manager_name"] == manager["personnel_name"] + assert member["role_name"] == manager["role_name"] + + def test_列表按姓名关键词可查到新增人员(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + listed = client.get( + _PERSONNEL, params={"keyword": data["personnel_name"]}, headers=normal["headers"] + ) + assert listed.status_code == 200, listed.text + rows = listed.json()["data"]["list"] + assert [r["id"] for r in rows] == [data["id"]] + + def test_编辑人员全量替换生效(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + updated = client.put( + f"{_PERSONNEL}/{data['id']}", + json=_payload(f"t_人员_改_{uuid.uuid4().hex[:6]}", phone="13900001111", is_probation=1), + headers=normal["headers"], + ) + assert updated.status_code == 200, updated.text + body = updated.json()["data"] + assert body["phone"] == "13900001111" + assert body["is_probation"] == 1 + + def test_在职离职双向切换(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + data = _create(client, headers) + off = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 0}, headers=headers) + assert off.status_code == 200, off.text + assert off.json()["data"]["status"] == 0 + on = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 1}, headers=headers) + assert on.status_code == 200, on.text + assert on.json()["data"]["status"] == 1 + + def test_删除无引用人员成功(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"]) + assert resp.status_code == 200, resp.text + detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"]) + assert detail.status_code == 404 + assert "人员不存在" in detail.json()["message"] + + +class TestRefRules: + """岗位/经理引用校验 —— 核算锚点不悬空、不环引用。""" + + def test_编辑时直属经理是自己_400(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + resp = client.put( + f"{_PERSONNEL}/{data['id']}", + json=_payload(data["personnel_name"], manager_id=data["id"]), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "直属经理不能是自己" in resp.json()["message"] + + def test_直属经理不存在_400(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _PERSONNEL, + json=_payload(_uname(), manager_id=999999999), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "直属经理不存在或已离职" in resp.json()["message"] + + def test_直属经理已离职_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + manager = _create(client, headers) + quit_resp = client.put( + f"{_PERSONNEL}/{manager['id']}/status", json={"status": 0}, headers=headers + ) + assert quit_resp.status_code == 200, quit_resp.text + resp = client.post( + _PERSONNEL, + json=_payload(_uname(), manager_id=manager["id"]), + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "直属经理不存在或已离职" in resp.json()["message"] + + def test_岗位不存在_400(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _PERSONNEL, + json=_payload(_uname(), business_role_id=999999999), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "岗位不存在或已停用" in resp.json()["message"] + + def test_岗位已停用_400(self, client: TestClient, normal: dict) -> None: + """停用岗位不能挂人:岗位决定计提模板,挂上去核算会取不到公式。""" + code = f"t_br_{uuid.uuid4().hex[:8]}" + factory = get_session_factory() + with factory() as session: + session.add( + BusinessRole( + role_code=code, + role_name="已停用测试岗位", + calc_template="TPL_SALESPERSON_DECAY", + status=0, + ) + ) + session.commit() + role_id = int( + session.execute( + select(BusinessRole.id).where(BusinessRole.role_code == code) + ).scalar_one() + ) + resp = client.post( + _PERSONNEL, + json=_payload(_uname(), business_role_id=role_id), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "岗位不存在或已停用" in resp.json()["message"] + + +class TestDeleteProtection: + """删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。""" + + def test_被团队成员引用禁删_解除后可删(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + manager = _create(client, headers) + member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用 + blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers) + assert blocked.status_code == 400, blocked.text + assert ( + "存在团队成员、客户归属或业务归属引用,请改为离职而非删除" in blocked.json()["message"] + ) + + client.delete(f"{_PERSONNEL}/{member['id']}", headers=headers) + freed = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers) + assert freed.status_code == 200, freed.text + + def test_被客户归属引用禁删_离职不放宽_解除后可删( + self, client: TestClient, normal: dict + ) -> None: + headers = normal["headers"] + owner = _create(client, headers) + created = client.post( + _CUSTOMERS, + json={ + "company_name": f"t_公司_{uuid.uuid4().hex[:8]}", + "owner_name": "张老板", + "customer_type": "personal", + "owner_personnel_id": owner["id"], + }, + headers=headers, + ) + assert created.status_code == 200, created.text + customer = created.json()["data"] + + blocked = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers) + assert blocked.status_code == 400, blocked.text + assert ( + "存在团队成员、客户归属或业务归属引用,请改为离职而非删除" in blocked.json()["message"] + ) + + # 保护与在职状态无关:离职并不能让物理删除放行 + client.put(f"{_PERSONNEL}/{owner['id']}/status", json={"status": 0}, headers=headers) + still = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers) + assert still.status_code == 400, still.text + + # 删客户解除引用后方可删人 + assert client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers).status_code == 200 + freed = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers) + assert freed.status_code == 200, freed.text + + def test_被业务归属引用禁删_负责或协助任一即拦(self, client: TestClient, normal: dict) -> None: + """业务表就位后删除保护补齐:负责/协助引用任一即拦,FK 不以 500 暴露。""" + headers = normal["headers"] + owner = _create(client, headers) + assistant = _create(client, headers) + resp_customer = client.post( + _CUSTOMERS, + json={ + "company_name": f"t_公司_{uuid.uuid4().hex[:8]}", + "owner_name": "张老板", + "customer_type": "personal", + }, + headers=headers, + ) + assert resp_customer.status_code == 200, resp_customer.text + customer_id = resp_customer.json()["data"]["id"] + resp = client.post( + _BUSINESSES, + json={ + "customer_id": customer_id, + "owner_personnel_id": owner["id"], + "assistant_personnel_id": assistant["id"], + "items": [ + { + "product_name": "t_引用产品", + "product_category": "municipal_water", + "quantity": "1", + "sale_price": "1", + } + ], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + for target, label in ((owner, "负责"), (assistant, "协助")): + blocked = client.delete(f"{_PERSONNEL}/{target['id']}", headers=headers) + assert blocked.status_code == 400, blocked.text + assert "业务归属" in blocked.json()["message"], f"{label}引用未拦截" + + # 删除业务解除引用后,人员方可删除(owner 仍被客户归属引用,先删客户) + business_id = resp.json()["data"]["id"] + assert client.delete(f"{_BUSINESSES}/{business_id}", headers=headers).status_code == 200 + assert client.delete(f"{_CUSTOMERS}/{customer_id}", headers=headers).status_code == 200 + assert client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers).status_code == 200 + + +class TestSubTableReplace: + """辐射区域/拓展计划为全量替换语义 —— 做成追加会让编辑翻倍脏数据。""" + + def test_辐射区域设置后详情回显一致_再次替换生效( + self, client: TestClient, normal: dict + ) -> None: + headers = normal["headers"] + data = _create(client, headers) + resp = client.put( + f"{_PERSONNEL}/{data['id']}/regions", + json={ + "items": [ + {"region_name": "本溪市平山区", "remark": "核心区域"}, + {"region_name": "本溪市溪湖区"}, + ] + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] + assert [r["region_name"] for r in detail["regions"]] == ["本溪市平山区", "本溪市溪湖区"] + assert detail["regions"][0]["remark"] == "核心区域" + + # 再替换为 1 条:是覆盖而非追加 + resp2 = client.put( + f"{_PERSONNEL}/{data['id']}/regions", + json={"items": [{"region_name": "沈阳市沈河区"}]}, + headers=headers, + ) + assert resp2.status_code == 200, resp2.text + detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] + assert [r["region_name"] for r in detail2["regions"]] == ["沈阳市沈河区"] + + def test_辐射区域替换为空列表即清空(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + data = _create(client, headers) + client.put( + f"{_PERSONNEL}/{data['id']}/regions", + json={"items": [{"region_name": "本溪市平山区"}]}, + headers=headers, + ) + cleared = client.put( + f"{_PERSONNEL}/{data['id']}/regions", json={"items": []}, headers=headers + ) + assert cleared.status_code == 200, cleared.text + detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] + assert detail["regions"] == [] + + def test_拓展计划设置后详情回显一致_再次替换生效( + self, client: TestClient, normal: dict + ) -> None: + headers = normal["headers"] + data = _create(client, headers) + resp = client.put( + f"{_PERSONNEL}/{data['id']}/plans", + json={ + "items": [ + { + "plan_period": "2026Q1", + "plan_content": "拓展本溪市政客户", + "target_desc": "新增3户", + }, + {"plan_period": "2026Q2", "plan_content": "维护存量客户"}, + ] + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] + assert [p["plan_period"] for p in detail["plans"]] == ["2026Q1", "2026Q2"] + assert detail["plans"][0]["target_desc"] == "新增3户" + + resp2 = client.put( + f"{_PERSONNEL}/{data['id']}/plans", + json={"items": [{"plan_period": "2026Q3", "plan_content": "开拓轨交市场"}]}, + headers=headers, + ) + assert resp2.status_code == 200, resp2.text + detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] + assert [p["plan_period"] for p in detail2["plans"]] == ["2026Q3"] + + def test_拓展计划替换为空列表即清空(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + data = _create(client, headers) + client.put( + f"{_PERSONNEL}/{data['id']}/plans", + json={"items": [{"plan_period": "2026Q1", "plan_content": "拓展本溪市政客户"}]}, + headers=headers, + ) + cleared = client.put( + f"{_PERSONNEL}/{data['id']}/plans", json={"items": []}, headers=headers + ) + assert cleared.status_code == 200, cleared.text + detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] + assert detail["plans"] == [] + + +class TestAudit: + """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" + + def test_新增人员成功留审计(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_PERSONNEL and log.action == ACTION_CREATE + ] + assert logs, "新增人员未留操作日志" + latest = logs[0] + assert latest.success == 1 + assert data["personnel_name"] in latest.summary + + def test_校验失败也留审计(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _PERSONNEL, + json=_payload(_uname(), manager_id=999999999), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + failed = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_PERSONNEL and log.success == 0 + ] + assert failed, "失败操作未留痕" + assert "直属经理不存在或已离职" in (failed[0].error_message or "") diff --git a/backend/tests/integration/test_products.py b/backend/tests/integration/test_products.py new file mode 100644 index 0000000..d940002 --- /dev/null +++ b/backend/tests/integration/test_products.py @@ -0,0 +1,334 @@ +"""产品管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是产品主数据的两条硬约束 —— product_category 是折算阈值的行级锚点, +脏类别会让核算按类别回溯参数取不到阈值;「名称+规格」组合唯一缺失会 +放行重复产品,业务数据不知道该挂在哪一条上。数量/单价以字符串进出, +断言同时验证 str 直出(金额红线:不经 float)。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生:产品名一律 ``t_`` 前缀,由 conftest 兜底清理;删除语义用例 +自验 DELETE 端点。 +""" + +import uuid +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.constants import ( + ACTION_CREATE, + MODULE_PRODUCT, + PRODUCT_CATEGORY_MUNICIPAL_WATER, + PRODUCT_CATEGORY_RAIL_TRANSIT, +) +from app.core.db import get_session_factory +from app.models import OperationLog + +pytestmark = pytest.mark.integration + +_PRODUCTS = "/api/v1/base/products" +_CUSTOMERS = "/api/v1/base/customers" +_BUSINESSES = "/api/v1/business/businesses" + + +def _pname() -> str: + return f"t_产品_{uuid.uuid4().hex[:8]}" + + +def _payload(name: str, **overrides: object) -> dict: + body: dict = { + "product_name": name, + "spec_model": "DN100", + "unit": "台", + "stock_quantity": "100", + "sale_price": "12.5", + "product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER, + } + body.update(overrides) + return body + + +def _create(client: TestClient, headers: dict, **overrides: object) -> dict: + resp = client.post(_PRODUCTS, json=_payload(_pname(), **overrides), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _logs_of(username: str) -> list[OperationLog]: + factory = get_session_factory() + with factory() as session: + return list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + + +@pytest.fixture +def make_business_for_product(client: TestClient): + """造「客户 → 业务 → 明细挂指定产品」的最小引用链(删除保护场景)。 + + business_items.product_id 是 RESTRICT 外键,产品删除保护以此为引用源; + 客户/业务均按 t_ 前缀与系统编号由 conftest 兜底清理。 + """ + + def _factory(headers: dict, product_id: int) -> dict: + customer = client.post( + _CUSTOMERS, + json={ + "company_name": f"t_引用公司_{uuid.uuid4().hex[:8]}", + "owner_name": "张老板", + "customer_type": "personal", + }, + headers=headers, + ) + assert customer.status_code == 200, customer.text + business = client.post( + _BUSINESSES, + json={ + "customer_id": customer.json()["data"]["id"], + "business_date": "2026-01-01", + "items": [ + { + "product_id": product_id, + "product_name": f"t_引用产品_{uuid.uuid4().hex[:6]}", + "product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER, + "quantity": "1.00", + "sale_price": "10.00", + } + ], + }, + headers=headers, + ) + assert business.status_code == 200, business.text + return business.json()["data"] + + return _factory + + +class TestAuth: + """鉴权:登录后接口,裸访问一律 401。""" + + def test_未登录访问产品列表_401(self, client: TestClient) -> None: + resp = client.get(_PRODUCTS) + assert resp.status_code == 401, resp.text + + +class TestCrudFlow: + """正向链路:建 → 查 → 改(含换类别)→ 删。""" + + def test_新增产品默认启用且数量单价字符串直出(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + assert data["status"] == 1, "新产品应默认启用" + assert data["spec_model"] == "DN100" + # 金额红线:str 进 str 出,且数值与入参精确相等(Decimal 比较,不经 float) + assert isinstance(data["sale_price"], str) + assert Decimal(data["sale_price"]) == Decimal("12.5") + assert isinstance(data["stock_quantity"], str) + assert Decimal(data["stock_quantity"]) == Decimal("100") + + def test_详情与列表均能查到新增产品(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"]) + assert detail.status_code == 200, detail.text + assert detail.json()["data"]["id"] == data["id"] + + listed = client.get( + _PRODUCTS, params={"keyword": data["product_name"]}, headers=normal["headers"] + ) + assert listed.status_code == 200, listed.text + assert [r["id"] for r in listed.json()["data"]["list"]] == [data["id"]] + + def test_同名称不同规格创建成功(self, client: TestClient, normal: dict) -> None: + name = _pname() + first = _create(client, normal["headers"], product_name=name, spec_model="DN100") + second = client.post( + _PRODUCTS, + json=_payload(name, spec_model="DN200"), + headers=normal["headers"], + ) + assert second.status_code == 200, second.text + assert second.json()["data"]["id"] != first["id"] + + def test_删除产品成功(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + resp = client.delete(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"]) + assert resp.status_code == 200, resp.text + detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"]) + assert detail.status_code == 404 + assert "产品不存在" in detail.json()["message"] + + +class TestDeleteProtection: + """删除保护:被业务/发货明细引用的产品禁删(customer 先例同构)。""" + + def test_被业务明细引用_400且产品仍在( + self, client: TestClient, normal: dict, make_business_for_product + ) -> None: + product = _create(client, normal["headers"]) + business = make_business_for_product(normal["headers"], product["id"]) + resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"]) + assert resp.status_code == 400, resp.text + assert "引用" in resp.json()["message"] + # 产品仍在(引导改走停用而非物理删除) + still = client.get(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"]) + assert still.status_code == 200 + # 业务明细行确实引用了该产品(护栏:确认保护不是空转) + detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=normal["headers"]).json()[ + "data" + ] + assert detail["items"][0]["product_id"] == product["id"] + + def test_被发货明细引用_400( + self, client: TestClient, normal: dict, make_business_for_product + ) -> None: + product = _create(client, normal["headers"]) + make_business_for_product(normal["headers"], product["id"]) + # 在业务明细引用之外再挂一笔发货明细(同样引用 products.id) + businesses = client.get("/api/v1/business/businesses", headers=normal["headers"]).json()[ + "data" + ] + business_id = businesses["list"][0]["id"] + shipment = client.post( + "/api/v1/business/shipments", + json={ + "business_id": business_id, + "ship_date": "2026-01-01", + "items": [ + { + "product_id": product["id"], + "product_name": product["product_name"], + "quantity": "1.00", + "sale_price": "10.00", + } + ], + }, + headers=normal["headers"], + ) + assert shipment.status_code == 200, shipment.text + # 此前业务明细引用已令删除被拒,此处再证发货引用同一防线 + resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"]) + assert resp.status_code == 400, resp.text + + +class TestValidationRules: + """类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。""" + + def test_重名同规格_400(self, client: TestClient, normal: dict) -> None: + name = _pname() + _create(client, normal["headers"], product_name=name) + dup = client.post( + _PRODUCTS, + json=_payload(name, spec_model="DN100"), + headers=normal["headers"], + ) + assert dup.status_code == 400, dup.text + assert "已存在同名称同规格的产品" in dup.json()["message"] + + def test_规格缺省归一空串且重名同空规格仍拦截(self, client: TestClient, normal: dict) -> None: + """组合唯一键对 NULL 不去重:缺省规格若归 NULL 会放行重名同规格数据。""" + name = _pname() + first = _create(client, normal["headers"], product_name=name, spec_model=None) + assert first["spec_model"] == "" + dup = client.post( + _PRODUCTS, + json=_payload(name, spec_model=None), + headers=normal["headers"], + ) + assert dup.status_code == 400, dup.text + assert "已存在同名称同规格的产品" in dup.json()["message"] + + def test_产品类别非法_400(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _PRODUCTS, + json=_payload(_pname(), product_category="not_a_category"), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "产品类别不合法" in resp.json()["message"] + + def test_销售单价为负数_400(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _PRODUCTS, + json=_payload(_pname(), sale_price="-1"), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + + def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _PRODUCTS, + json=_payload(_pname(), sale_price="abc"), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + + def test_编辑改类别为合法值成功(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + updated = client.put( + f"{_PRODUCTS}/{data['id']}", + json=_payload( + data["product_name"], + product_category=PRODUCT_CATEGORY_RAIL_TRANSIT, + sale_price="15.8", + ), + headers=normal["headers"], + ) + assert updated.status_code == 200, updated.text + body = updated.json()["data"] + assert body["product_category"] == PRODUCT_CATEGORY_RAIL_TRANSIT + assert Decimal(body["sale_price"]) == Decimal("15.8") + + +class TestStatusSwitch: + """启用/停用独立于编辑端点 —— 编辑表单不携带状态,防误触停用。""" + + def test_停用再启用切换(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + data = _create(client, headers) + off = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 0}, headers=headers) + assert off.status_code == 200, off.text + assert off.json()["message"] == "已停用" + assert off.json()["data"]["status"] == 0 + on = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 1}, headers=headers) + assert on.status_code == 200, on.text + assert on.json()["message"] == "已启用" + assert on.json()["data"]["status"] == 1 + + +class TestAudit: + """写操作(含失败)留痕 —— 审计是验收项。""" + + def test_新增产品成功留审计(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_PRODUCT and log.action == ACTION_CREATE + ] + assert logs, "新增产品未留操作日志" + latest = logs[0] + assert latest.success == 1 + assert data["product_name"] in latest.summary + + def test_重名失败留审计(self, client: TestClient, normal: dict) -> None: + name = _pname() + _create(client, normal["headers"], product_name=name) + dup = client.post( + _PRODUCTS, + json=_payload(name, spec_model="DN100"), + headers=normal["headers"], + ) + assert dup.status_code == 400, dup.text + failed = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_PRODUCT and log.success == 0 + ] + assert failed, "失败操作未留痕" + assert "已存在同名称同规格的产品" in (failed[0].error_message or "") diff --git a/backend/tests/integration/test_ratings.py b/backend/tests/integration/test_ratings.py new file mode 100644 index 0000000..94436f9 --- /dev/null +++ b/backend/tests/integration/test_ratings.py @@ -0,0 +1,315 @@ +"""客户评级等级配置接口集成测试(需要 MySQL 与 Redis)。 + +守的是评级**判定表**的两条硬约束 —— ① 区间互不重叠:重叠会让同一总分 +命中多个等级(或两段都跳过),判定表失效,新增与编辑两路都必须校验; +② 分值域 0≤min≤max≤100:越界/倒挂的配置直接算错等级。边界相邻不算重叠 +(前段 max=74、后段 min=75 相邻)是种子铺满 0-100 的铺法,要有正例锁住; +「已存在」的名称唯一在先查与 1062 两路都要收敛成 400。 + +评级用例会**删改种子**(删除 C、编辑 D 的区间构造相邻/重叠场景),conftest +兜底在每个用例后把判定表恢复为种子基线(全删全插,判定表无下游引用), +因此种子断言用例(列表全量返回)始终在纯种子上运行。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生:测试等级名一律 ``t_`` 前缀;种子(A/B/C/D)的删改由 conftest +恢复,删除语义的用例自验 DELETE 端点。 +""" + +import uuid +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.constants import ACTION_CREATE, MODULE_RATING +from app.core.db import get_session_factory +from app.models import OperationLog + +pytestmark = pytest.mark.integration + +_GRADES = "/api/v1/business/rating-grades" + +# 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1)) +_SEED = [ + ("A", "90.0", "100.0", 1), + ("B", "75.0", "89.0", 2), + ("C", "60.0", "74.0", 3), + ("D", "0.0", "59.0", 4), +] + + +def _tname() -> str: + return f"t_等级_{uuid.uuid4().hex[:6]}" + + +def _gpayload(grade_name: str, min_score: str, max_score: str, **overrides: object) -> dict: + body: dict = { + "grade_name": grade_name, + "min_score": min_score, + "max_score": max_score, + "sort_order": 99, + "remark": "t_评级测试", + } + body.update(overrides) + return body + + +def _seed_id(client: TestClient, headers: dict, grade_name: str) -> int: + """从判定表列表取种子等级 id(种子由迁移初始化,主键不可假造)。""" + rows = client.get(_GRADES, headers=headers).json()["data"] + match = next(row for row in rows if row["grade_name"] == grade_name) + return match["id"] + + +def _drop_seed(client: TestClient, headers: dict, grade_name: str) -> None: + """删掉种子等级,为测试等级清出分值空隙(种子由 conftest 兜底恢复)。 + + 种子铺满 0-100 —— 凡要创建/编辑测试等级的用例,必须先清出与既有等级 + 完全分离的空隙,否则重叠约束(设计如此)一律 400。 + """ + resp = client.delete(f"{_GRADES}/{_seed_id(client, headers, grade_name)}", headers=headers) + assert resp.status_code == 200, resp.text + + +def _names(client: TestClient, headers: dict) -> list[str]: + return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]] + + +def _logs_of(username: str) -> list[OperationLog]: + """取某操作人的审计行(按 id 倒序,最新在前)。""" + factory = get_session_factory() + with factory() as session: + return list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + + +class TestAuth: + """鉴权:评级配置属业务操作不要求管理员,但仍是登录后接口。""" + + def test_未登录访问评级等级列表_401(self, client: TestClient) -> None: + resp = client.get(_GRADES) + assert resp.status_code == 401, resp.text + + +class TestOverlapGuardLock: + """区间重叠判定的并发防线:判重叠的 list_all 必须带 FOR UPDATE。 + + 区间重叠无唯一键可拦(名称才有 uk),无锁时两管理员并发提交重叠区间 + 会互判不重叠而双双入库。双连接真并发需线程与锁等待控制,此处锁定 + SQL 形态两条口径:for_update 编译产物含 FOR UPDATE、只读直出不含。 + """ + + def test_判重叠查询带FOR_UPDATE_只读直出不带(self) -> None: + from app.models import CustomerRatingGrade + + locked = ( + select(CustomerRatingGrade) + .order_by(CustomerRatingGrade.sort_order, CustomerRatingGrade.id) + .with_for_update() + ) + assert "FOR UPDATE" in str(locked.compile()) + plain = select(CustomerRatingGrade).order_by(CustomerRatingGrade.sort_order) + assert "FOR UPDATE" not in str(plain.compile()) + + +class TestList: + """列表直出种子基线:判定表是前端评级计算器的唯一数据源。""" + + def test_种子四等级全量返回_按sort_order升序(self, client: TestClient, normal: dict) -> None: + resp = client.get(_GRADES, headers=normal["headers"]) + assert resp.status_code == 200, resp.text + rows = resp.json()["data"] + assert [(r["grade_name"], r["sort_order"]) for r in rows] == [ + (name, order) for name, _, _, order in _SEED + ], rows + # 分值红线:str 出参且与种子精确相等(Decimal 比较,不经 float) + for row, (_, min_score, max_score, _) in zip(rows, _SEED, strict=True): + assert Decimal(row["min_score"]) == Decimal(min_score) + assert Decimal(row["max_score"]) == Decimal(max_score) + + +class TestCreate: + """新增:区间合法(0≤min≤max≤100)、互不重叠、名称唯一 —— 三条硬约束 + 任一缺失都会算错等级。""" + + def test_删除种子后新增相邻区间_成功且判定表更新( + self, client: TestClient, normal: dict + ) -> None: + """种子铺满 0-100,任何新区间都与某条重叠 —— 先删 C(60-74), + 新增 60-74 与 D(0-59)/B(75-89) **边界相邻不算重叠**(正例)。""" + headers = normal["headers"] + client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers) + resp = client.post( + _GRADES, json=_gpayload("t_E", "60", "74", sort_order=5), headers=headers + ) + assert resp.status_code == 200, resp.text + body = resp.json()["data"] + assert Decimal(body["min_score"]) == Decimal("60") + assert Decimal(body["max_score"]) == Decimal("74") + assert body["sort_order"] == 5 + + rows = client.get(_GRADES, headers=headers).json()["data"] + assert _names(client, headers) == ["A", "B", "D", "t_E"], rows + + def test_区间倒挂_400(self, client: TestClient, normal: dict) -> None: + resp = client.post(_GRADES, json=_gpayload(_tname(), "80", "10"), headers=normal["headers"]) + assert resp.status_code == 400, resp.text + assert "分值区间不合法" in resp.json()["message"], resp.text + + def test_分值超上限_400(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _GRADES, json=_gpayload(_tname(), "95", "150"), headers=normal["headers"] + ) + assert resp.status_code == 400, resp.text + assert "分值区间不合法" in resp.json()["message"], resp.text + + def test_分值负下限_400(self, client: TestClient, normal: dict) -> None: + resp = client.post(_GRADES, json=_gpayload(_tname(), "-5", "10"), headers=normal["headers"]) + assert resp.status_code == 400, resp.text + assert "分值区间不合法" in resp.json()["message"], resp.text + + def test_等级名称已存在_400(self, client: TestClient, normal: dict) -> None: + """先删 D 清出空隙:重叠校验在名称唯一校验之前,不清空隙会先撞 + 「与等级 D 重叠」而走不到名称校验。""" + headers = normal["headers"] + _drop_seed(client, headers, "D") + resp = client.post(_GRADES, json=_gpayload("A", "10", "20"), headers=headers) + assert resp.status_code == 400, resp.text + assert "该等级名称已存在" in resp.json()["message"], resp.text + + def test_新增与种子重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None: + """85-95 与种子 A(90-100) 闭区间相交 → 400 并点名 A,前端据此定位行。""" + resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"]) + assert resp.status_code == 400, resp.text + assert "分值区间与等级 A 重叠" in resp.json()["message"], resp.text + # 失败不落库:判定表仍是 4 条种子 + assert _names(client, normal["headers"]) == ["A", "B", "C", "D"] + + +class TestEdit: + """编辑以修改后的区间对全部既有等级(排除自身)两两相交 —— 编辑路与 + 新增路共用重叠约束,两处都要拦。""" + + def test_编辑与其他等级重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None: + """把 B 编辑为 60-65:与 C(60-74) 相交 → 点名 C;与 D(0-59)/A 均分离。""" + headers = normal["headers"] + b_id = _seed_id(client, headers, "B") + resp = client.put( + f"{_GRADES}/{b_id}", + json=_gpayload("B", "60", "65", sort_order=2), + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "分值区间与等级 C 重叠" in resp.json()["message"], resp.text + # 失败不落库:种子保持原样 + assert _names(client, headers) == ["A", "B", "C", "D"] + + def test_编辑相邻区间_成功且判定表更新(self, client: TestClient, normal: dict) -> None: + """删除 C 后把 D 编辑为 0-74:与 B(75-89) **边界相邻不算重叠** + (74 与 75 相邻),这是种子铺法的正例。""" + headers = normal["headers"] + client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers) + d_id = _seed_id(client, headers, "D") + resp = client.put( + f"{_GRADES}/{d_id}", + json=_gpayload("D", "0", "74", sort_order=4), + headers=headers, + ) + assert resp.status_code == 200, resp.text + + rows = client.get(_GRADES, headers=headers).json()["data"] + assert [(r["grade_name"], r["sort_order"]) for r in rows] == [("A", 1), ("B", 2), ("D", 4)] + assert Decimal(rows[2]["max_score"]) == Decimal("74"), rows[2] + + def test_编辑回显改名与分数(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + _drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚 + grade_name = _tname() + created = client.post( + _GRADES, json=_gpayload(grade_name, "10", "20", sort_order=50), headers=headers + ) + assert created.status_code == 200, created.text + grade_id = created.json()["data"]["id"] + + new_name = _tname() + updated = client.put( + f"{_GRADES}/{grade_id}", + json=_gpayload(new_name, "15", "25", sort_order=60, remark="t_改"), + headers=headers, + ) + assert updated.status_code == 200, updated.text + body = updated.json()["data"] + assert body["grade_name"] == new_name + assert Decimal(body["min_score"]) == Decimal("15") + assert Decimal(body["max_score"]) == Decimal("25") + assert body["sort_order"] == 60 + + rows = client.get(_GRADES, headers=headers).json()["data"] + match = next(r for r in rows if r["id"] == grade_id) + assert match["grade_name"] == new_name + assert Decimal(match["min_score"]) == Decimal("15") + + +class TestDelete: + """删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表 + 立即反映,二次删除 404。""" + + def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + _drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚 + grade_name = _tname() + created = client.post( + _GRADES, json=_gpayload(grade_name, "30.5", "40.5", sort_order=50), headers=headers + ) + assert created.status_code == 200, created.text + grade_id = created.json()["data"]["id"] + assert grade_name in _names(client, headers) + + assert client.delete(f"{_GRADES}/{grade_id}", headers=headers).status_code == 200 + assert grade_name not in _names(client, headers) + + again = client.delete(f"{_GRADES}/{grade_id}", headers=headers) + assert again.status_code == 404, again.text + assert "评级等级不存在" in again.json()["message"], again.text + + +class TestAudit: + """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" + + def test_新增等级成功留审计(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + _drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚 + grade_name = _tname() + resp = client.post( + _GRADES, + json=_gpayload(grade_name, "30.5", "40.5", sort_order=50), + headers=headers, + ) + assert resp.status_code == 200, resp.text + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_RATING and log.action == ACTION_CREATE + ] + assert logs, "新增评级等级未留操作日志" + latest = logs[0] + assert latest.success == 1 + assert grade_name in latest.summary + + def test_重叠失败留审计(self, client: TestClient, normal: dict) -> None: + resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"]) + assert resp.status_code == 400, resp.text + failed = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_RATING and log.success == 0 + ] + assert failed, "失败操作未留痕" + assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0] diff --git a/backend/tests/integration/test_receipts.py b/backend/tests/integration/test_receipts.py new file mode 100644 index 0000000..1a5c897 --- /dev/null +++ b/backend/tests/integration/test_receipts.py @@ -0,0 +1,378 @@ +"""回款管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是回款的数据完整性三道线 —— ① 发货批次同业务强校验:跨业务指定会让 +回款天数与台账归属漂移;② settlement_status 是 M3 核算防重锚点(需规 +2.3.3「每笔回款仅核算一次」),已核算禁改禁删缺失会让台账与源数据漂移; +③ total_received_amount 回写是 M3 起付判定的直接消费源,删行漏算即漂移。 +payment_days 为只读派生值,锁定「发货日首日计第 1 天」口径。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;回款批次 +编号系统生成(HK 前缀),conftest 按编号前缀全量清;「已核算」的造数走 +直接改库(M2 只写 0、置位是 M3 职责),M3 核算置位语义不绕开。 +""" + +import re +import uuid +from datetime import date +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.constants import ACTION_CREATE, MODULE_RECEIPT +from app.core.db import get_session_factory +from app.models import OperationLog, Receipt +from app.models.receipt import SETTLEMENT_DONE +from tests.integration.factories import create_business as _create_business + +pytestmark = pytest.mark.integration + +_BUSINESSES = "/api/v1/business/businesses" +_SHIPMENTS = "/api/v1/business/shipments" +_RECEIPTS = "/api/v1/business/receipts" + + +def _sitem(**overrides: object) -> dict: + body: dict = { + "product_name": f"t_产品_{uuid.uuid4().hex[:8]}", + "quantity": "1", + "sale_price": "500.00", + } + body.update(overrides) + return body + + +def _spayload(business_id: int, ship_date: str, **overrides: object) -> dict: + body: dict = { + "business_id": business_id, + "ship_date": ship_date, + "items": [_sitem()], + } + body.update(overrides) + return body + + +def _create_shipment(client: TestClient, headers: dict, business_id: int, ship_date: str) -> dict: + resp = client.post(_SHIPMENTS, json=_spayload(business_id, ship_date), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _rpayload(business_id: int, **overrides: object) -> dict: + body: dict = { + "business_id": business_id, + "receipt_date": str(date.today()), + "amount": "1200.00", + } + body.update(overrides) + return body + + +def _create(client: TestClient, headers: dict, business_id: int, **overrides: object) -> dict: + resp = client.post(_RECEIPTS, json=_rpayload(business_id, **overrides), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _terminate(client: TestClient, headers: dict, business_id: int) -> None: + """把业务流转到已终止(终态禁写场景的造数步骤)。""" + resp = client.put( + f"{_BUSINESSES}/{business_id}/status", + json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + +def _settle(receipt_id: int) -> None: + """已核算造数:M2 只写 0,置 1 由 M3 核算负责 —— 测试直接改库模拟置位, + 不绕开「M2 不置位」的实现约定。""" + factory = get_session_factory() + with factory() as session: + row = session.get(Receipt, receipt_id) + assert row is not None, "回款行不存在" + row.settlement_status = SETTLEMENT_DONE + session.commit() + + +def _logs_of(username: str) -> list[OperationLog]: + """取某操作人的审计行(按 id 倒序,最新在前)。""" + factory = get_session_factory() + with factory() as session: + return list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + + +class TestAuth: + """鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。""" + + def test_未登录访问回款列表_401(self, client: TestClient) -> None: + resp = client.get(_RECEIPTS) + assert resp.status_code == 401, resp.text + + +class TestCreate: + """创建:HK 批次契约、金额红线、回款天数派生与业务合计回写。""" + + def test_新增回款_批次号格式_未指定批次_无派生(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + + # 批次号格式:HK{YYYYMMDD}-{4位当日序号}(日期取当日) + expected_prefix = f"HK{date.today():%Y%m%d}-" + assert re.fullmatch(r"HK\d{8}-\d{4}", data["receipt_batch_no"]), data["receipt_batch_no"] + assert data["receipt_batch_no"].startswith(expected_prefix), data["receipt_batch_no"] + + # 未指定批次(shipment_id 空):批次可暂不指定,回款天数无从派生 + assert data["shipment_id"] is None + assert data["shipment_no"] is None + assert data["payment_days"] is None + # M2 只写 0:置 1 由 M3 核算负责 + assert data["settlement_status"] == 0 + assert Decimal(data["amount"]) == Decimal("1200") + + def test_指定批次_回款天数发货日首日计第1天(self, client: TestClient, normal: dict) -> None: + """发货 2026-10-05 / 回款 2026-11-02 → 29 天(首日计第 1 天, + 需规「26 天」示例已作废)。""" + headers = normal["headers"] + business = _create_business(client, headers) + shipment = _create_shipment(client, headers, business["id"], "2026-10-05") + data = _create( + client, + headers, + business["id"], + shipment_id=shipment["id"], + receipt_date="2026-11-02", + amount="500.00", + ) + assert data["shipment_id"] == shipment["id"] + assert data["shipment_no"] == shipment["shipment_no"] + assert data["payment_days"] == 29, data + + def test_新增回款_业务合计回写_删除后回退(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + + after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] + assert Decimal(after["total_received_amount"]) == Decimal("1200") + + assert client.delete(f"{_RECEIPTS}/{data['id']}", headers=headers).status_code == 200 + rollback = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] + assert Decimal(rollback["total_received_amount"]) == Decimal("0") + + def test_业务不存在_404(self, client: TestClient, normal: dict) -> None: + resp = client.post(_RECEIPTS, json=_rpayload(999999999), headers=normal["headers"]) + assert resp.status_code == 404, resp.text + assert "业务不存在" in resp.json()["message"], resp.text + + def test_终态业务禁新增回款_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + _terminate(client, headers, business["id"]) + resp = client.post(_RECEIPTS, json=_rpayload(business["id"]), headers=headers) + assert resp.status_code == 400, resp.text + assert "业务已完成或已终止,禁止新增回款" in resp.json()["message"], resp.text + + def test_金额负数_400(self, client: TestClient, normal: dict) -> None: + """一期禁负数金额(红字/退款冲销需求出现时先补设计再放开)。""" + business = _create_business(client, normal["headers"]) + resp = client.post( + _RECEIPTS, json=_rpayload(business["id"], amount="-1"), headers=normal["headers"] + ) + assert resp.status_code == 400, resp.text + assert "不能为负数" in resp.json()["message"], resp.text + + def test_金额三位小数_400(self, client: TestClient, normal: dict) -> None: + """小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱。""" + business = _create_business(client, normal["headers"]) + resp = client.post( + _RECEIPTS, + json=_rpayload(business["id"], amount="12.345"), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "最多两位小数" in resp.json()["message"], resp.text + + +class TestCrossBusiness: + """发货批次同业务强校验 —— 两类失败(不存在/跨业务)统一文案,防响应 + 差异探测发货单存在性。""" + + def test_跨业务指定发货批次_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business1 = _create_business(client, headers) + shipment = _create_shipment(client, headers, business1["id"], str(date.today())) + business2 = _create_business(client, headers) + + resp = client.post( + _RECEIPTS, + json=_rpayload(business2["id"], shipment_id=shipment["id"]), + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text + + def test_编辑时指定跨业务发货批次_400(self, client: TestClient, normal: dict) -> None: + """编辑路径(全量替换)同样强校验,无「未变即免检」旁路。""" + headers = normal["headers"] + business1 = _create_business(client, headers) + shipment = _create_shipment(client, headers, business1["id"], str(date.today())) + business2 = _create_business(client, headers) + data = _create(client, headers, business2["id"]) + + resp = client.put( + f"{_RECEIPTS}/{data['id']}", + json={ + "shipment_id": shipment["id"], + "receipt_date": str(date.today()), + "amount": "1200.00", + }, + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text + + def test_编辑回款_全量替换_派生值实时刷新(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + shipment = _create_shipment(client, headers, business["id"], "2026-10-05") + data = _create(client, headers, business["id"]) + assert data["payment_days"] is None + + specified = client.put( + f"{_RECEIPTS}/{data['id']}", + json={ + "shipment_id": shipment["id"], + "receipt_date": "2026-11-02", + "amount": "300.00", + }, + headers=headers, + ) + assert specified.status_code == 200, specified.text + body = specified.json()["data"] + assert body["payment_days"] == 29, "指定批次后回款天数按发货日首日派生" + assert body["shipment_no"] == shipment["shipment_no"] + + cleared = client.put( + f"{_RECEIPTS}/{data['id']}", + json={ + "shipment_id": None, + "receipt_date": "2026-11-02", + "amount": "300.00", + }, + headers=headers, + ) + assert cleared.status_code == 200, cleared.text + assert cleared.json()["data"]["payment_days"] is None, "清除指定后派生值回 null" + + +class TestSettled: + """已核算回款禁改禁删(M3 核算防重锚点)—— 台账固化后源数据禁漂移。""" + + def test_已核算禁改_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + _settle(data["id"]) + + resp = client.put( + f"{_RECEIPTS}/{data['id']}", + json={ + "shipment_id": None, + "receipt_date": str(date.today()), + "amount": "9999.00", + }, + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "已核算回款禁止修改" in resp.json()["message"], resp.text + + def test_已核算禁删_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + _settle(data["id"]) + + resp = client.delete(f"{_RECEIPTS}/{data['id']}", headers=headers) + assert resp.status_code == 400, resp.text + assert "已核算回款禁止删除" in resp.json()["message"], resp.text + + +class TestFilter: + """核算状态筛选(M3 核算消费的入口口径)。""" + + def test_settlement_status筛选_0只回未核算_1只回已核算( + self, client: TestClient, normal: dict + ) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + pending = _create(client, headers, business["id"], amount="500.00") + settled = _create(client, headers, business["id"], amount="700.00") + _settle(settled["id"]) + + resp0 = client.get( + _RECEIPTS, + params={"business_id": business["id"], "settlement_status": 0}, + headers=headers, + ) + assert resp0.status_code == 200, resp0.text + rows0 = resp0.json()["data"]["list"] + assert [r["id"] for r in rows0] == [pending["id"]], rows0 + assert rows0[0]["settlement_status"] == 0 + + resp1 = client.get( + _RECEIPTS, + params={"business_id": business["id"], "settlement_status": 1}, + headers=headers, + ) + assert resp1.status_code == 200, resp1.text + rows1 = resp1.json()["data"]["list"] + assert [r["id"] for r in rows1] == [settled["id"]], rows1 + + +class TestAudit: + """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" + + def test_新增回款成功留审计(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_RECEIPT and log.action == ACTION_CREATE + ] + assert logs, "新增回款未留操作日志" + latest = logs[0] + assert latest.success == 1 + assert "新增回款" in latest.summary + assert data["receipt_batch_no"] in latest.summary + + def test_跨业务指定失败留审计(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business1 = _create_business(client, headers) + shipment = _create_shipment(client, headers, business1["id"], str(date.today())) + business2 = _create_business(client, headers) + resp = client.post( + _RECEIPTS, + json=_rpayload(business2["id"], shipment_id=shipment["id"]), + headers=headers, + ) + assert resp.status_code == 400, resp.text + failed = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_RECEIPT and log.success == 0 + ] + assert failed, "失败操作未留痕" + assert "发货批次不属于该业务" in (failed[0].error_message or ""), failed[0] diff --git a/backend/tests/integration/test_reports.py b/backend/tests/integration/test_reports.py new file mode 100644 index 0000000..68bff5c --- /dev/null +++ b/backend/tests/integration/test_reports.py @@ -0,0 +1,308 @@ +"""报表统计集成测试(M4 ⑤)。 + +守的口径(破坏了就是报表数字与台账对不上): +- 个人明细与台账逐笔一致;summary 为**全筛选**合计而非当前页合计 +- 部门汇总团队基数 = 成员绩效合计;12.5%/5% 分成按参数;无成员经理出 0 行 +- 回款统计 8 个区间桶恒在(空桶为 0);**二次分摊拆行时回款金额只计一次** +- 业务统计排除经理聚合行(calc_type=2),绩效不被整团队聚合额污染 +- 导出 xlsx 可解析且含签字栏;导出行为入操作日志 +- 权限:普通用户 403 且文案为「无权访问报表统计」 +""" + +import uuid +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient + +pytestmark = pytest.mark.integration + +_REPORTS = "/api/v1/reports" +_PERSONNEL = "/api/v1/base/personnel" +_BUSINESSES = "/api/v1/business/businesses" +_SHIPMENTS = "/api/v1/business/shipments" +_RECEIPTS = "/api/v1/business/receipts" +_CALC = "/api/v1/calc" +_SETTLEMENTS = "/api/v1/settlements" + + +def _role_id(code: str) -> int: + """取内置业务岗位 id(salesperson / sales_manager)。""" + from sqlalchemy import select + + from app.core.db import get_session_factory + from app.models import BusinessRole + + factory = get_session_factory() + with factory() as session: + return int( + session.execute( + select(BusinessRole.id).where( + BusinessRole.role_code == code, BusinessRole.status == 1 + ) + ).scalar_one() + ) + + +def _person(client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None) -> dict: + body = { + "personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}", + "business_role_id": _role_id(role), + "salary_standard": "3000.00", + } + if manager_id is not None: + body["manager_id"] = manager_id + resp = client.post(_PERSONNEL, json=body, headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _full_chain( + client: TestClient, + headers: dict, + *, + owner_id: int, + customer_id: int | None = None, + allocations: list[dict] | None = None, +) -> tuple[dict, dict]: + """业务(单行 100×100,成本 3000)→ 发货(60 天前)→ 回款 → 核算。""" + from tests.integration.factories import business_item, business_payload, customer + + cust = customer(client, headers) if customer_id is None else {"id": customer_id} + payload = business_payload( + cust["id"], + owner_personnel_id=owner_id, + items=[business_item(cost_amount="3000.00")], + ) + if allocations: + payload["secondary_allocations"] = allocations + resp = client.post(_BUSINESSES, json=payload, headers=headers) + assert resp.status_code == 200, resp.text + business = resp.json()["data"] + + from datetime import date, timedelta + + ship_date = (date.today() - timedelta(days=60)).isoformat() + resp = client.post( + _SHIPMENTS, + json={ + "business_id": business["id"], + "ship_date": ship_date, + "items": [{"product_name": "t_发货物", "quantity": "1", "sale_price": "1.00"}], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + shipment = resp.json()["data"] + + resp = client.post( + _RECEIPTS, + json={ + "business_id": business["id"], + "receipt_date": date.today().isoformat(), + "amount": "10000.00", + "shipment_id": shipment["id"], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + receipt = resp.json()["data"] + + resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers) + assert resp.status_code == 200, resp.text + return business, receipt + + +def _month() -> str: + from datetime import date + + return date.today().strftime("%Y-%m") + + +def _ledger_sum(headers: dict, month: str) -> Decimal: + """台账(单笔生效行)同月绩效合计——报表 summary 的对照基准。""" + from sqlalchemy import func, select + + from app.core.db import get_session_factory + from app.models import PerformanceLedger + + factory = get_session_factory() + with factory() as session: + return Decimal( + str( + session.execute( + select(func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)).where( + PerformanceLedger.settlement_month == month, + PerformanceLedger.calc_type == 1, + PerformanceLedger.is_active == 1, + ) + ).scalar_one() + ) + ) + + +def test_个人明细_与台账一致且summary为全量合计(client: TestClient, admin: dict): + """明细行金额必须等于台账;pageSize=1 时 summary 仍是全部筛选结果之和。""" + person = _person(client, admin["headers"]) + _full_chain(client, admin["headers"], owner_id=person["id"]) + month = _month() + + resp = client.get( + f"{_REPORTS}/personal-detail", + params={"settlement_month": month, "pageSize": 1}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + data = resp.json()["data"] + assert data["total"] == 1 + expected = _ledger_sum(admin["headers"], month) + assert Decimal(str(data["list"][0]["performance_amount"])) == expected + assert Decimal(str(data["summary"]["performance_total"])) == expected + + +def test_部门汇总_团队基数等于成员绩效且分成按参数(admin, client: TestClient): + """团队基数=成员绩效合计;12.5% 团队分成与 5% 全公司分成分别可核。""" + manager = _person(client, admin["headers"], role="sales_manager") + salesperson = _person(client, admin["headers"], manager_id=manager["id"]) + _full_chain(client, admin["headers"], owner_id=salesperson["id"]) + month = _month() + + resp = client.get( + f"{_REPORTS}/department-summary", + params={"settlement_month": month}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + data = resp.json()["data"] + + team = next(t for t in data["teams"] if t["manager_id"] == manager["id"]) + ledger_total = _ledger_sum(admin["headers"], month) + assert Decimal(str(team["team_base"])) == ledger_total + expected_share = (ledger_total * Decimal("0.125")).quantize(Decimal("0.01")) + assert Decimal(str(team["team_share_amount"])) == expected_share + assert team["member_count"] == 1 + assert data["company"]["company_base"] == team["team_base"] + + +def test_部门汇总_无成员经理出零值行(admin, client: TestClient): + """无团队成员 → 0 行展示(FR-030 异常处理)。""" + manager = _person(client, admin["headers"], role="sales_manager") + month = _month() + resp = client.get( + f"{_REPORTS}/department-summary", + params={"settlement_month": month}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + team = next( + (t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None + ) + assert team is not None + assert Decimal(str(team["team_base"])) == Decimal("0") + assert team["member_count"] == 0 + + +def test_回款统计_空桶恒在且分摊拆行金额只计一次(admin, client: TestClient): + """8 个区间桶恒在;二次分配拆行使台账多行但回款金额不得翻倍。""" + owner = _person(client, admin["headers"]) + participant = _person(client, admin["headers"]) + _full_chain( + client, + admin["headers"], + owner_id=owner["id"], + allocations=[{"personnel_id": participant["id"], "ratio": "30.00"}], + ) + month = _month() + + resp = client.get( + f"{_REPORTS}/payment-stats", + params={"settlement_month": month}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + data = resp.json()["data"] + + assert len(data["buckets"]) == 8 + assert [b["code"] for b in data["buckets"]] == [ + "le0", "1-30", "31-60", "61-90", "91-180", "181-360", "361+", "none", + ] + # 60 天前发货 → 落在 61-90 桶(发货日首日计第 1 天) + bucket = next(b for b in data["buckets"] if b["code"] == "61-90") + assert Decimal(str(bucket["receipt_amount"])) == Decimal("10000.00") + assert bucket["receipt_count"] == 1 + # 拆行后台账两行,但绩效合计仍等于台账总和 + assert bucket["ledger_count"] == 2 + empty = next(b for b in data["buckets"] if b["code"] == "361+") + assert Decimal(str(empty["performance_amount"])) == Decimal("0") + + +def test_业务统计_绩效排除经理聚合行(admin, client: TestClient): + """生成月结后出现 calc_type=2 聚合行,业务统计不得把聚合额算进单业务。""" + manager = _person(client, admin["headers"], role="sales_manager") + salesperson = _person(client, admin["headers"], manager_id=manager["id"]) + business, _ = _full_chain(client, admin["headers"], owner_id=salesperson["id"]) + + resp = client.post( + f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"] + ) + assert resp.status_code == 200, resp.text + + resp = client.get( + f"{_REPORTS}/business-stats", + params={"keyword": business["business_no"]}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + row = resp.json()["data"]["list"][0] + assert Decimal(str(row["performance_amount"])) == _ledger_sum(admin["headers"], _month()) + + +def test_导出_excel含签字栏且入操作日志(admin, client: TestClient): + """导出是文件流(非统一信封);模板含复核/签字栏;行为入操作日志。""" + from io import BytesIO + + from openpyxl import load_workbook + + person = _person(client, admin["headers"]) + _full_chain(client, admin["headers"], owner_id=person["id"]) + + resp = client.get( + f"{_REPORTS}/personal-detail/export", + params={"settlement_month": _month()}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"].startswith( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + workbook = load_workbook(BytesIO(resp.content)) + text = "\n".join( + str(cell.value) + for row in workbook.active.iter_rows() + for cell in row + if cell.value is not None + ) + assert "个人绩效工资明细表" in text + assert "财务复核" in text + assert "领导签字" in text + + from sqlalchemy import select + + from app.core.db import get_session_factory + from app.models import OperationLog + + factory = get_session_factory() + with factory() as session: + log = session.execute( + select(OperationLog) + .where(OperationLog.module == "报表统计", OperationLog.action == "导出") + .order_by(OperationLog.id.desc()) + ).scalars().first() + assert log is not None + + +def test_权限_普通用户403文案(admin, normal, client: TestClient): + """报表限管理员/财务/领导;普通用户被后端拦截且文案正确。""" + resp = client.get(f"{_REPORTS}/personal-detail", headers=normal["headers"]) + assert resp.status_code == 403 + assert resp.json()["message"] == "无权访问报表统计" diff --git a/backend/tests/integration/test_role_management.py b/backend/tests/integration/test_role_management.py new file mode 100644 index 0000000..64b9a21 --- /dev/null +++ b/backend/tests/integration/test_role_management.py @@ -0,0 +1,209 @@ +"""角色管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是角色管理的**保护规则** —— 它们不是界面体验,而是系统自举与授权 +安全的最低防线: + +- `admin` 角色禁删禁停(cli 建管理员与 require_admin 判定都依赖该编码) +- 有用户绑定的角色禁删(user_roles 外键是 CASCADE,直接删会静默剥离权限) +- `role_code` 创建后不可改(授权判定的锚点) + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient + +from app.core.constants import ROLE_USER + +pytestmark = pytest.mark.integration + +_ROLES = "/api/v1/system/roles" + + +def _new_code() -> str: + return f"t_role_{uuid.uuid4().hex[:6]}" + + +def _admin_role_id(client: TestClient, admin: dict) -> int: + """从管理列表取内置 admin 角色的 id。""" + resp = client.get(_ROLES, params={"keyword": "admin"}, headers=admin["headers"]) + rows = resp.json()["data"]["list"] + matches = [row for row in rows if row["role_code"] == "admin"] + assert matches, "内置 admin 角色缺失" + return matches[0]["id"] + + +class TestCrudFlow: + """正向链路:建 → 查(列表 + 下拉)→ 改 → 停用 → 删。""" + + def test_新增角色并联动列表与下拉(self, client: TestClient, admin: dict) -> None: + code = _new_code() + resp = client.post( + _ROLES, + json={"role_code": code, "role_name": "对外协作岗", "sort_order": 50}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + role_id = resp.json()["data"]["id"] + assert resp.json()["data"]["user_count"] == 0 + + # 管理列表可查(keyword 同时匹配编码与名称) + resp = client.get(_ROLES, params={"keyword": code}, headers=admin["headers"]) + assert resp.json()["data"]["total"] == 1 + + # 用户表单下拉包含(启用状态) + resp = client.get("/api/v1/system/users/meta/roles", headers=admin["headers"]) + assert code in [row["role_code"] for row in resp.json()["data"]] + + assert client.delete(f"{_ROLES}/{role_id}", headers=admin["headers"]).status_code == 200 + + def test_重复编码被拒(self, client: TestClient, admin: dict, make_role) -> None: + role = make_role() + resp = client.post( + _ROLES, + json={"role_code": role["role_code"], "role_name": "重复"}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + assert "已存在" in resp.json()["message"] + + def test_编码格式非法被拒(self, client: TestClient, admin: dict) -> None: + resp = client.post( + _ROLES, json={"role_code": "BadCode", "role_name": "大写"}, headers=admin["headers"] + ) + assert resp.status_code == 400 + + def test_编辑角色名称与排序(self, client: TestClient, admin: dict, make_role) -> None: + role = make_role() + resp = client.put( + f"{_ROLES}/{role['id']}", + json={"role_name": "改名后", "description": "新的说明", "sort_order": 7}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["role_name"] == "改名后" + assert data["description"] == "新的说明" + assert data["sort_order"] == 7 + + def test_编码不可被修改(self, client: TestClient, admin: dict, make_role) -> None: + """请求体携带 role_code 会被忽略(编辑模型不接收该字段),编码保持不变。""" + role = make_role() + resp = client.put( + f"{_ROLES}/{role['id']}", + json={"role_name": "改名", "role_code": "hacked"}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + assert resp.json()["data"]["role_code"] == role["role_code"] + + def test_停用后不在下拉但仍在管理列表(self, client: TestClient, admin: dict, make_role) -> None: + role = make_role() + resp = client.put( + f"{_ROLES}/{role['id']}/status", json={"status": 0}, headers=admin["headers"] + ) + assert resp.status_code == 200 + assert resp.json()["data"]["status"] == 0 + + resp = client.get("/api/v1/system/users/meta/roles", headers=admin["headers"]) + assert role["role_code"] not in [row["role_code"] for row in resp.json()["data"]] + + resp = client.get(_ROLES, params={"keyword": role["role_code"]}, headers=admin["headers"]) + assert resp.json()["data"]["total"] == 1 + + +class TestProtection: + """保护规则:内置角色与有绑定的角色。""" + + def test_admin角色不可删除(self, client: TestClient, admin: dict) -> None: + role_id = _admin_role_id(client, admin) + resp = client.delete(f"{_ROLES}/{role_id}", headers=admin["headers"]) + assert resp.status_code == 403 + assert "不可删除" in resp.json()["message"] + + def test_admin角色不可停用(self, client: TestClient, admin: dict) -> None: + role_id = _admin_role_id(client, admin) + resp = client.put( + f"{_ROLES}/{role_id}/status", json={"status": 0}, headers=admin["headers"] + ) + assert resp.status_code == 403 + assert "不可停用" in resp.json()["message"] + + def test_有绑定的角色不可删除且解绑后可删除( + self, client: TestClient, admin: dict, make_role, make_user + ) -> None: + """`user_roles` 外键是 CASCADE —— 后端 400 拦截是防止静默剥离权限的 + 唯一防线,前端按钮禁用只是体验。""" + role = make_role() + bound = make_user([role["role_code"]]) + + resp = client.delete(f"{_ROLES}/{role['id']}", headers=admin["headers"]) + assert resp.status_code == 400 + assert "1 个用户" in resp.json()["message"] + + # 解绑(换回内置 user 角色)后可删除 + resp = client.put( + f"/api/v1/system/users/{bound['id']}", + json={"roles": [ROLE_USER]}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + assert client.delete(f"{_ROLES}/{role['id']}", headers=admin["headers"]).status_code == 200 + + def test_列表返回绑定用户数( + self, client: TestClient, admin: dict, make_role, make_user + ) -> None: + role = make_role() + make_user([role["role_code"]]) + resp = client.get(_ROLES, params={"keyword": role["role_code"]}, headers=admin["headers"]) + assert resp.json()["data"]["list"][0]["user_count"] == 1 + + def test_编辑不存在的角色404(self, client: TestClient, admin: dict) -> None: + resp = client.put( + f"{_ROLES}/999999999", json={"role_name": "不存在"}, headers=admin["headers"] + ) + assert resp.status_code == 404 + + def test_停用角色不可再分配给用户( + self, client: TestClient, admin: dict, make_role, make_user + ) -> None: + """回归:停用只挡住了表单下拉,接口层此前仍可绑定(或使用停用前 + 加载的旧选项提交)—— 与「停用后不可再分配」的语义矛盾。""" + role = make_role() + resp = client.put( + f"{_ROLES}/{role['id']}/status", json={"status": 0}, headers=admin["headers"] + ) + assert resp.status_code == 200 + + username = f"t_{uuid.uuid4().hex[:8]}" + resp = client.post( + "/api/v1/system/users", + json={"username": username, "password": "TestPwd@2026", "roles": [role["role_code"]]}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + assert "停用" in resp.json()["message"] + + # 已绑定该角色的用户(历史数据)改回启用角色后可正常保存 + bound = make_user([role["role_code"]]) + resp = client.put( + f"/api/v1/system/users/{bound['id']}", + json={"roles": [ROLE_USER]}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + + +class TestAuthorization: + """权限:角色管理为管理员专属。""" + + def test_普通用户访问被拒(self, client: TestClient, normal: dict) -> None: + assert client.get(_ROLES, headers=normal["headers"]).status_code == 403 + resp = client.post( + _ROLES, + json={"role_code": _new_code(), "role_name": "越权"}, + headers=normal["headers"], + ) + assert resp.status_code == 403 diff --git a/backend/tests/integration/test_schema_sync.py b/backend/tests/integration/test_schema_sync.py index 0634e11..5ac2d72 100644 --- a/backend/tests/integration/test_schema_sync.py +++ b/backend/tests/integration/test_schema_sync.py @@ -1,13 +1,16 @@ """DDL 漂移检测。 结构定义同时存在于两处: -- `db/migration/V1__*.sql` —— 事实来源,生产迁移用 +- `db/migration/V*.sql` —— 事实来源,生产迁移用 - `db/init/init-dev.sql` —— 开发环境重置脚本,Docker 首次启动唯一会自动执行的位置 两份 DDL 手工同步迟早会漂移,且后果隐蔽:开发库的表结构与迁移脚本不一致, 新人克隆下来建出的库和生产不一样,等到上线才发现。 本测试把「靠自觉」变成「可自动拦截」,成本极低。 +**必须比对全部 V*.sql 的合集**:早期版本只解析 V1,新增 V2 之后, +两份 DDL 同时缺表仍会被判为「一致」,漂移守卫形同虚设。 + 注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。 """ @@ -17,17 +20,22 @@ from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).resolve().parents[3] -MIGRATION_FILE = PROJECT_ROOT / "db" / "migration" / "V1__init_rbac_and_login_logs.sql" +MIGRATION_DIR = PROJECT_ROOT / "db" / "migration" INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql" +ALEMBIC_VERSIONS_DIR = PROJECT_ROOT / "backend" / "alembic" / "versions" _CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL) _COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE) # 这些开头的是约束定义,不是列定义 _NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"} +# ALTER TABLE 的列级变更(语义重放用):ADD COLUMN / DROP COLUMN; +# MODIFY 只改类型不改列名,对列集合无影响,无需处理 +_ALTER_ADD = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+ADD(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE) +_ALTER_DROP = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+DROP(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE) def _parse_tables(sql: str) -> dict[str, set[str]]: - """从 SQL 文本中提取 {表名: {列名集合}}。""" + """从 SQL 文本中提取 {表名: {列名集合}}(仅 CREATE TABLE 的静态解析)。""" tables: dict[str, set[str]] = {} for table_name, body in _CREATE_TABLE.findall(sql): tables[table_name] = { @@ -38,9 +46,57 @@ def _parse_tables(sql: str) -> dict[str, set[str]]: return tables +def _migration_final_state(paths: list[Path]) -> dict[str, set[str]]: + """按文件序重放迁移脚本,得到迁移链**终态**的 {表名: {列名集合}}。 + + 早前实现把全部脚本的 CREATE 列做并集:某表日后被 ALTER DROP COLUMN + 时,并集里旧列名永远存在,init-dev.sql 必须保留死列才能过测 —— 守卫 + 会主动背书一份与生产结构漂移的开发库 DDL。按序重放 ADD/DROP 后, + 比对基准才是终态(CHANGE RENAME 列名出现时重放会报差异,属人工核对 + 的正确时机:改名同时牵动 init-dev 与 ORM,须人来确认两处同步)。 + """ + merged: dict[str, set[str]] = {} + for path in paths: + sql = path.read_text(encoding="utf-8") + for table, columns in _parse_tables(sql).items(): + merged.setdefault(table, set()).update(columns) + for table, column in _ALTER_ADD.findall(sql): + # ADD CONSTRAINT ... UNIQUE 的「CONSTRAINT」不是列(与 _parse_tables + # 的约束关键词同一份清单过滤),V8 台账唯一键三列扩展即此形态 + if column.upper() not in _NON_COLUMN_KEYWORDS: + merged.setdefault(table, set()).add(column) + for table, column in _ALTER_DROP.findall(sql): + merged.setdefault(table, set()).discard(column) + return merged + + +def _alembic_head() -> str: + """从迁移脚本推断当前 head 版本号。 + + 纯文本解析而非调用 alembic 运行时 —— 后者需要连数据库,会让本测试 + 从「秒级、无需容器」变成「必须起库」。 + head 的定义:没有被任何其他迁移的 down_revision 引用过的那个 revision。 + """ + revisions: set[str] = set() + referenced: set[str] = set() + for path in ALEMBIC_VERSIONS_DIR.glob("*.py"): + text = path.read_text(encoding="utf-8") + if found := re.search(r"^revision: str = ['\"](\w+)['\"]", text, re.MULTILINE): + revisions.add(found.group(1)) + if found := re.search(r"^down_revision:.*?= ['\"](\w+)['\"]", text, re.MULTILINE): + referenced.add(found.group(1)) + + heads = revisions - referenced + assert len(heads) == 1, f"迁移链存在分叉或缺失,heads={sorted(heads)}" + return heads.pop() + + @pytest.fixture(scope="module") def migration_tables() -> dict[str, set[str]]: - return _parse_tables(MIGRATION_FILE.read_text(encoding="utf-8")) + """按文件序重放 db/migration/ 下全部 V*.sql,取迁移链终态。""" + files = sorted(MIGRATION_DIR.glob("V*.sql")) + assert files, f"未找到迁移脚本:{MIGRATION_DIR}" + return _migration_final_state(files) @pytest.fixture(scope="module") @@ -52,7 +108,7 @@ class TestSchemaSync: """两份 DDL 的结构一致性。""" def test_文件存在(self) -> None: - assert MIGRATION_FILE.is_file(), f"缺少迁移脚本:{MIGRATION_FILE}" + assert list(MIGRATION_DIR.glob("V*.sql")), f"缺少迁移脚本:{MIGRATION_DIR}" assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}" def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None: @@ -74,16 +130,28 @@ class TestSchemaSync: class TestSeedData: - """字典数据在两处应保持同步。""" + """字典数据与版本标记。""" - @pytest.mark.parametrize("role_code", ["admin", "user"]) + @pytest.mark.parametrize("role_code", ["admin", "user", "marketing_ops", "finance", "leader"]) def test_角色初始化数据在两处都存在(self, role_code: str) -> None: - migration_sql = MIGRATION_FILE.read_text(encoding="utf-8") + migration_sql = "\n".join( + path.read_text(encoding="utf-8") for path in sorted(MIGRATION_DIR.glob("V*.sql")) + ) init_sql = INIT_DEV_FILE.read_text(encoding="utf-8") assert f"'{role_code}'" in migration_sql assert f"'{role_code}'" in init_sql - def test_开发脚本标记了迁移版本(self) -> None: - """不写 alembic_version 的话,在开发库上执行 upgrade head 会重复建表而失败。""" + def test_开发脚本标记的版本号等于当前head(self) -> None: + """版本号必须等于 head,且不能只断言「存在」。 + + 落后的后果:开发库重建后执行 `alembic upgrade head`,alembic 认为该迁移 + 尚未应用,重复建表并报 1050 Table already exists,只能手工补版本或 down -v。 + """ init_sql = INIT_DEV_FILE.read_text(encoding="utf-8") - assert "INSERT INTO alembic_version" in init_sql + found = re.search( + r"INSERT INTO alembic_version \(version_num\) VALUES \('(\w+)'\)", init_sql + ) + assert found, "init-dev.sql 未写入 alembic_version" + assert found.group(1) == _alembic_head(), ( + f"init-dev.sql 标记的版本号 {found.group(1)!r} 落后于当前 head {_alembic_head()!r}" + ) diff --git a/backend/tests/integration/test_secondary_allocation.py b/backend/tests/integration/test_secondary_allocation.py new file mode 100644 index 0000000..176e9ac --- /dev/null +++ b/backend/tests/integration/test_secondary_allocation.py @@ -0,0 +1,270 @@ +"""二次分配方案录入(业务创建/编辑同事务)集成测试。 + +守的是(计划 T3 验收): +- 创建带方案 → 详情回显含 personnel_name 冗余与比例 +- 编辑全量替换(空数组=清空) +- 离职/不存在人员 → 400「分摊参与人员不存在或已离职」 +- 终态业务禁改方案(与编辑终态门槛同一口径) +- 合计不强制 100%:hint 展示合计与余额(余额归属待澄清 #2) +- 删除保护:被方案引用的人员 RESTRICT 禁删(400 而非静默剥离) +- 人员删除后方案回显仍有名(personnel_name 固化的意义) + +人员造数走 /api/v1/base/personnel(种子营销员岗位);离职用状态端点。 +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient + +from tests.integration.factories import business_payload, customer + +pytestmark = pytest.mark.integration + +_BUSINESSES = "/api/v1/business/businesses" +_PERSONNEL = "/api/v1/base/personnel" + + +def _person(client: TestClient, headers: dict, **overrides: object) -> dict: + role_id = overrides.pop("business_role_id", None) + body = { + "personnel_name": f"t_分摊_{uuid.uuid4().hex[:8]}", + "business_role_id": role_id or _any_role_id(), + "salary_standard": "3000.00", + **overrides, + } + resp = client.post(_PERSONNEL, json=body, headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _any_role_id() -> int: + """种子营销员岗位 ID(内置启用)。""" + from sqlalchemy import select + + from app.core.constants import BUSINESS_ROLE_SALESPERSON + from app.core.db import get_session_factory + from app.models import BusinessRole + + factory = get_session_factory() + with factory() as session: + return int( + session.execute( + select(BusinessRole.id).where( + BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON, + BusinessRole.status == 1, + ) + ).scalar_one() + ) + + +def _create_with_allocations(client: TestClient, headers: dict, allocations: list[dict]) -> dict: + cust = customer(client, headers) + resp = client.post( + _BUSINESSES, + json=business_payload(cust["id"], secondary_allocations=allocations), + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _set_switch(client: TestClient, headers: dict, value: str) -> None: + """显式设置二次分配审批开关(V8 起默认开启,用例不得依赖种子默认值)。""" + resp = client.post( + "/api/v1/system/params/changes", + json={ + "change_type": 1, + "remark": f"用例设置二次分配审批开关={value}", + "items": [ + { + "param_code": "approval.secondary_allocation.enabled", + "dimension_value": "", + "value": value, + } + ], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + +def _detail(client: TestClient, headers: dict, business_id: int) -> dict: + resp = client.get(f"{_BUSINESSES}/{business_id}", headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +class TestCreateAndEcho: + """创建带方案与详情回显。""" + + def test_带方案创建_详情回显姓名与比例(self, client: TestClient, admin: dict) -> None: + p1 = _person(client, admin["headers"]) + p2 = _person(client, admin["headers"]) + data = _create_with_allocations( + client, + admin["headers"], + [ + {"personnel_id": p1["id"], "ratio": "30.00"}, + {"personnel_id": p2["id"], "ratio": "20.00"}, + ], + ) + detail = _detail(client, admin["headers"], data["id"]) + assert len(detail["secondary_allocations"]) == 2 + names = {a["personnel_name"] for a in detail["secondary_allocations"]} + assert p1["personnel_name"] in names and p2["personnel_name"] in names + ratios = sorted(a["ratio"] for a in detail["secondary_allocations"]) + assert ratios == ["20.00", "30.00"] + # 合计 50%,余额 50% —— 只提示不落库(待澄清 #2) + assert "50.00" in (detail["allocation_hint"] or "") + assert "余额" in (detail["allocation_hint"] or "") + + def test_不建方案_详情为空且无hint(self, client: TestClient, admin: dict) -> None: + data = _create_with_allocations(client, admin["headers"], []) + detail = _detail(client, admin["headers"], data["id"]) + assert detail["secondary_allocations"] == [] + assert detail["allocation_hint"] is None + + def test_合计满额_hint只报合计(self, client: TestClient, admin: dict) -> None: + p1 = _person(client, admin["headers"]) + p2 = _person(client, admin["headers"]) + data = _create_with_allocations( + client, + admin["headers"], + [ + {"personnel_id": p1["id"], "ratio": "60"}, + {"personnel_id": p2["id"], "ratio": "40.00"}, + ], + ) + hint = _detail(client, admin["headers"], data["id"])["allocation_hint"] + assert hint is not None and "余额" not in hint + + +class TestGuards: + """校验链:在职/终态/去重已由 schema 层覆盖,此处打服务层口径。""" + + def test_离职人员参与被拒(self, client: TestClient, admin: dict) -> None: + p = _person(client, admin["headers"]) + resp = client.put( + f"{_PERSONNEL}/{p['id']}/status", json={"status": 0}, headers=admin["headers"] + ) + assert resp.status_code == 200, resp.text + + cust = customer(client, admin["headers"]) + resp = client.post( + _BUSINESSES, + json=business_payload( + cust["id"], + secondary_allocations=[{"personnel_id": p["id"], "ratio": "50.00"}], + ), + headers=admin["headers"], + ) + assert resp.status_code == 400 + assert "分摊参与" in resp.json()["message"] + + def test_离职历史参与人_编辑其余字段放行(self, client: TestClient, admin: dict) -> None: + """历史参与人离职后:编辑业务(保留该行)不应被 400 卡死—— + personnel_name 冗余固化的设计本就允许离职后回显;仅新增参与人 + 才做在职校验。""" + p = _person(client, admin["headers"]) + data = _create_with_allocations( + client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}] + ) + resp = client.put( + f"{_PERSONNEL}/{p['id']}/status", json={"status": 0}, headers=admin["headers"] + ) + assert resp.status_code == 200 + + detail = _detail(client, admin["headers"], data["id"]) + payload = business_payload( + detail["customer_id"], + secondary_allocations=[{"personnel_id": p["id"], "ratio": "50.00"}], + ) + resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) + assert resp.status_code == 200, resp.text + after = _detail(client, admin["headers"], data["id"]) + assert after["secondary_allocations"][0]["personnel_name"] == p["personnel_name"] + + # 新增一名离职人员仍要拦(新增才做在职校验) + p2 = _person(client, admin["headers"]) + client.put(f"{_PERSONNEL}/{p2['id']}/status", json={"status": 0}, headers=admin["headers"]) + payload = business_payload( + detail["customer_id"], + secondary_allocations=[ + {"personnel_id": p["id"], "ratio": "30.00"}, + {"personnel_id": p2["id"], "ratio": "20.00"}, + ], + ) + resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) + assert resp.status_code == 400 + assert "分摊参与" in resp.json()["message"] + + def test_终态业务禁改方案(self, client: TestClient, admin: dict) -> None: + p = _person(client, admin["headers"]) + data = _create_with_allocations( + client, admin["headers"], [{"personnel_id": p["id"], "ratio": "100"}] + ) + resp = client.put( + f"{_BUSINESSES}/{data['id']}/status", + json={"status": 2}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + + detail = _detail(client, admin["headers"], data["id"]) + payload = business_payload(detail["customer_id"], secondary_allocations=[]) + for key in ("business_no", "status", "owner_personnel_id", "total_sale_amount"): + payload.pop(key, None) + resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) + assert resp.status_code == 400 + assert "禁止编辑" in resp.json()["message"] + + def test_编辑全量替换与清空(self, client: TestClient, admin: dict) -> None: + p1 = _person(client, admin["headers"]) + p2 = _person(client, admin["headers"]) + data = _create_with_allocations( + client, admin["headers"], [{"personnel_id": p1["id"], "ratio": "40.00"}] + ) + + detail = _detail(client, admin["headers"], data["id"]) + payload = business_payload( + detail["customer_id"], + secondary_allocations=[{"personnel_id": p2["id"], "ratio": "70.00"}], + ) + resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) + assert resp.status_code == 200, resp.text + + replaced = _detail(client, admin["headers"], data["id"]) + assert len(replaced["secondary_allocations"]) == 1 + assert replaced["secondary_allocations"][0]["personnel_id"] == p2["id"] + + payload = business_payload(detail["customer_id"], secondary_allocations=[]) + resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) + assert resp.status_code == 200, resp.text + assert _detail(client, admin["headers"], data["id"])["secondary_allocations"] == [] + + +class TestDeleteProtection: + """RESTRICT 外键:被方案引用的人员禁删,方案随业务删除而清。""" + + def test_被方案引用的人员禁删(self, client: TestClient, admin: dict) -> None: + p = _person(client, admin["headers"]) + data = _create_with_allocations( + client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}] + ) + resp = client.delete(f"{_PERSONNEL}/{p['id']}", headers=admin["headers"]) + assert resp.status_code == 400 + # 方案仍在,回显有名(RESTRICT 保证未静默剥离) + detail = _detail(client, admin["headers"], data["id"]) + assert detail["secondary_allocations"][0]["personnel_name"] == p["personnel_name"] + + def test_删除业务后可删人员(self, client: TestClient, admin: dict) -> None: + # 显式关开关:开启时审批单在途、删业务被 400 拦截(产品行为,另有用例覆盖) + _set_switch(client, admin["headers"], "0") + p = _person(client, admin["headers"]) + data = _create_with_allocations( + client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}] + ) + resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]) + assert resp.status_code == 200, resp.text # 方案 CASCADE 随业务删除 + assert client.delete(f"{_PERSONNEL}/{p['id']}", headers=admin["headers"]).status_code == 200 diff --git a/backend/tests/integration/test_settings.py b/backend/tests/integration/test_settings.py new file mode 100644 index 0000000..de1e9b8 --- /dev/null +++ b/backend/tests/integration/test_settings.py @@ -0,0 +1,137 @@ +"""系统设置(公司信息)接口测试。 + +守的是: +- 单行语义:全系统恒一行,PUT 只改名称不增行 +- public 端点无鉴权可用但**白名单字段**——多一个敏感字段都是匿名泄漏 +- logo 上传三重校验(后缀/magic bytes/大小)——仅看后缀可被伪造 +- 审计留痕:系统设置属敏感配置,改必须有日志 + +真图片构造:PNG 用 8 字节签名 + 最小 IHDR;JPG 用 SOI 头 + 填充字节。 +仅校验 magic bytes 前缀,无需完整合法图片(解析由前端 img 完成)。 +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +pytestmark = pytest.mark.integration + +_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 +_TXT_BYTES = b"not an image at all" + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +class TestCompanyInfo: + """公司名称读取与更新。""" + + def test_初始值与更新生效(self, client: TestClient, admin: dict) -> None: + got = client.get(_SETTINGS, headers=admin["headers"]).json()["data"] + assert got["company_name"] + # logo_url 允许为 None(未上传)或历史遗留的 URL——单行库可复用, + # 断言不依赖「干净库」前提;上传行为由 TestLogoUpload 专类覆盖 + assert got["logo_url"] is None or "/logo/public" in got["logo_url"] + + new_name = f"测试公司_{uuid.uuid4().hex[:6]}" + put = client.put(_SETTINGS, json={"company_name": new_name}, headers=admin["headers"]) + assert put.status_code == 200, put.text + assert put.json()["data"]["company_name"] == new_name + assert ( + client.get(_SETTINGS, headers=admin["headers"]).json()["data"]["company_name"] + == new_name + ) + + def test_名称为空被拒(self, client: TestClient, admin: dict) -> None: + assert ( + client.put(_SETTINGS, json={"company_name": ""}, headers=admin["headers"]).status_code + == 400 + ) + + def test_非管理员改名称被拒(self, client: TestClient, normal: dict) -> None: + resp = client.put(_SETTINGS, json={"company_name": "越权公司"}, headers=normal["headers"]) + assert resp.status_code == 403 + + def test_修改留审计日志(self, client: TestClient, admin: dict) -> None: + client.put( + _SETTINGS, + json={"company_name": f"审计公司_{uuid.uuid4().hex[:6]}"}, + headers=admin["headers"], + ) + logs = client.get( + "/api/v1/system/operation-logs", + params={"module": "系统设置"}, + headers=admin["headers"], + ).json()["data"]["list"] + assert logs, "修改公司名称未产生操作日志" + + +class TestPublicEndpoint: + """匿名端点:白名单字段 + 可用性。""" + + def test_无令牌可读且仅白名单字段(self, client: TestClient, admin: dict) -> None: + name = f"公开公司_{uuid.uuid4().hex[:6]}" + client.put(_SETTINGS, json={"company_name": name}, headers=admin["headers"]) + + resp = client.get(f"{_SETTINGS}/public") + assert resp.status_code == 200 + data = resp.json()["data"] + 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: + assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in { + 401, + 405, + } + + +class TestLogoUpload: + """logo 上传校验链。""" + + def _upload(self, client: TestClient, admin: dict, filename: str, content: bytes): + return client.post( + f"{_SETTINGS}/logo", + files={"file": (filename, content, "application/octet-stream")}, + headers=admin["headers"], + ) + + def test_合法png上传成功且回读(self, client: TestClient, admin: dict) -> None: + resp = self._upload(client, admin, "logo.png", _PNG_BYTES) + assert resp.status_code == 200, resp.text + out = resp.json()["data"] + assert out["logo_url"] and "/logo/public" in out["logo_url"] + + img = client.get(f"{_SETTINGS}/logo/public") + assert img.status_code == 200 + assert img.headers["content-type"] == "image/png" + + def test_伪造扩展名被magic_bytes拦截(self, client: TestClient, admin: dict) -> None: + resp = self._upload(client, admin, "fake.png", _TXT_BYTES) + assert resp.status_code == 400 + assert "不符" in resp.json()["message"] + + 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}/logo", + files={"file": ("logo.png", _PNG_BYTES, "application/octet-stream")}, + ) + assert resp.status_code == 401 diff --git a/backend/tests/integration/test_settlements.py b/backend/tests/integration/test_settlements.py new file mode 100644 index 0000000..51f586b --- /dev/null +++ b/backend/tests/integration/test_settlements.py @@ -0,0 +1,751 @@ +"""月度结算集成测试(M3 ④:生成快照 → 审批 → 锁定)。 + +守的还有: +- 确认回款口径(#8 策略点):仅 is_active=1 且未入结的台账行入结; + 入结后重生成同月不再重复计入 +- 通过即锁定(status=3 终态);锁定后回款写路径 400(走调整单) +- 驳回回草稿可重提;开关两路(关=自动通过锁定;开=审批流转) +- #12 暂口径:经理个人户业绩不入聚合基数(金标准仅业务员个人户, + 聚合用例以「无经理台账」形态验证 items 按人汇总不聚合) +""" + +import uuid +from datetime import date +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.db import get_session_factory +from app.models import MonthlySettlement, PerformanceLedger +from tests.integration.factories import business_payload, customer + +pytestmark = pytest.mark.integration + +_SETTLEMENTS = "/api/v1/settlements" +_CALC = "/api/v1/calc" +_BUSINESSES = "/api/v1/business/businesses" +_SHIPMENTS = "/api/v1/business/shipments" +_RECEIPTS = "/api/v1/business/receipts" +_PERSONNEL = "/api/v1/base/personnel" + +_GOLD_ITEMS = [ + { + "product_name": "t_市政_月结", + "product_category": "municipal_water", + "quantity": "100", + "sale_price": "100.00", + "cost_amount": "3000.00", + } +] + + +def _role_id(role_code: str) -> int: + from sqlalchemy import select + + from app.core.db import get_session_factory + from app.models import BusinessRole + + factory = get_session_factory() + with factory() as session: + return int( + session.execute( + select(BusinessRole.id).where( + BusinessRole.role_code == role_code, + BusinessRole.status == 1, + ) + ).scalar_one() + ) + + +def _person( + client: TestClient, + headers: dict, + *, + role_code: str = "salesperson", + manager_id: int | None = None, +) -> dict: + resp = client.post( + _PERSONNEL, + json={ + "personnel_name": f"t_月结_{uuid.uuid4().hex[:8]}", + "business_role_id": _role_id(role_code), + "salary_standard": "3000.00", + **({"manager_id": manager_id} if manager_id is not None else {}), + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _set_switch(client: TestClient, headers: dict, code: str, value: str) -> None: + """显式设置审批开关(V8 起审批开关默认开启,用例不得依赖种子默认值)。""" + resp = client.post( + "/api/v1/system/params/changes", + json={ + "change_type": 1, + "remark": f"用例设置 {code}={value}", + "items": [{"param_code": code, "dimension_value": "", "value": value}], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + +def _calc_one(client: TestClient, headers: dict) -> dict: + """完整造一笔已核算回款(回款日 2026-10-20 → 归月 2026-10)。""" + cust = customer(client, headers) + person = _person(client, headers) + resp = client.post( + _BUSINESSES, + json=business_payload( + cust["id"], + owner_personnel_id=person["id"], + items=[dict(_GOLD_ITEMS[0], product_name=f"t_月结产品_{uuid.uuid4().hex[:6]}")], + ), + headers=headers, + ) + assert resp.status_code == 200, resp.text + biz = resp.json()["data"] + + resp = client.post( + _SHIPMENTS, + json={ + "business_id": biz["id"], + "ship_date": "2026-10-01", + "items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + resp = client.post( + _RECEIPTS, + json={ + "business_id": biz["id"], + "receipt_date": "2026-10-20", + "amount": "20000.00", + "shipment_id": resp.json()["data"]["id"], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + receipt = resp.json()["data"] + + resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers) + assert resp.status_code == 200, resp.text + return biz + + +@pytest.fixture +def finance(make_user, auth_headers) -> dict: + user = make_user(["finance"]) + user["headers"] = auth_headers(user) + return user + + +class TestGenerate: + """生成快照:确认回款口径与按人汇总。""" + + def test_生成_按人汇总并回填台账(self, client: TestClient, admin: dict) -> None: + _calc_one(client, admin["headers"]) + resp = client.post( + f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"] + ) + assert resp.status_code == 200, resp.text + out = resp.json()["data"] + assert out["settlement_month"] == "2026-10" + assert out["status"] == 1 # 草稿 + assert len(out["items"]) >= 1 + item = out["items"][0] + assert item["is_probation"] in (0, 1) + + # 台账行已回填 settlement_id(入结标记,防重复计入) + factory = get_session_factory() + with factory() as session: + settled = session.execute( + select(MonthlySettlement).where(MonthlySettlement.settlement_month == "2026-10") + ).scalar_one() + assert settled.person_count >= 1 + backfilled = ( + session.execute( + select(PerformanceLedger).where(PerformanceLedger.settlement_id == settled.id) + ) + .scalars() + .all() + ) + assert backfilled, "台账行 settlement_id 未回填(「未入结」判据失效)" + assert all(r.settlement_month == "2026-10" for r in backfilled) + + def test_无核算记录生成400(self, client: TestClient, admin: dict) -> None: + resp = client.post( + f"{_SETTLEMENTS}/generate", json={"month": "2099-01"}, headers=admin["headers"] + ) + assert resp.status_code == 400 + assert "无可结算" in resp.json()["message"] + + def test_已审定月份重生成400(self, client: TestClient, admin: dict) -> None: + _calc_one(client, admin["headers"]) + assert ( + client.post( + f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"] + ).status_code + == 200 + ) + # 显式关开关 → 提交即自动通过锁定(用例不依赖种子默认值) + _set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0") + sid = client.get(f"{_SETTLEMENTS}", headers=admin["headers"]).json()["data"]["list"][0][ + "id" + ] + assert ( + client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"]).status_code == 200 + ) + resp = client.post( + f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"] + ) + assert resp.status_code == 400 + assert "已审定锁定" in resp.json()["message"] + + +class TestSubmitAndLock: + """提交审批与锁定(开关两路)。""" + + def test_开关关闭_提交即自动通过锁定( + self, client: TestClient, admin: dict, finance: dict + ) -> None: + _calc_one(client, admin["headers"]) + # 显式关开关(V8 起种子默认开启,用例不得依赖默认值) + _set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0") + resp = client.post( + f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"] + ) + sid = resp.json()["data"]["id"] + resp = client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"]) + assert resp.status_code == 200, resp.text + out = resp.json()["data"] + assert out["status"] == 3 # 自动通过=已审定(锁定) + assert out["approval_order_no"] and out["approval_order_no"].startswith("AP") + assert out["locked_at"] is not None + + def test_锁定后回款禁改(self, client: TestClient, admin: dict) -> None: + from sqlalchemy import select + + biz = _calc_one(client, admin["headers"]) + client.post(f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]) + sid = client.get(f"{_SETTLEMENTS}", headers=admin["headers"]).json()["data"]["list"][0][ + "id" + ] + client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"]) + + # 找该月回款并尝试编辑(400:月已锁定) + factory = get_session_factory() + with factory() as session: + from app.models import Receipt + + receipt_id = int( + session.execute( + select(Receipt.id).where(Receipt.business_id == biz["id"]) + ).scalar_one() + ) + resp = client.put( + f"{_RECEIPTS}/{receipt_id}", + json={ + "business_id": biz["id"], + "receipt_date": "2026-10-21", + "amount": "999.00", + }, + headers=admin["headers"], + ) + assert resp.status_code == 400 + # 已核算禁改(M2 防线)先于月结锁定拦截生效——两者均 400 拒改 + assert ("已核算" in resp.json()["message"]) or ("锁定" in resp.json()["message"]) + + +class TestSwitchOn: + """开关开启:审批流转后锁定。""" + + def test_提交待审_通过后锁定( + self, client: TestClient, admin: dict, finance: dict, make_user, auth_headers + ) -> None: + # 开月结审批开关(立即生效) + resp = client.post( + "/api/v1/system/params/changes", + json={ + "change_type": 1, + "remark": "开月结审批", + "items": [ + { + "param_code": "approval.monthly_settlement.enabled", + "dimension_value": "", + "value": "1", + } + ], + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + + leader = make_user(["leader"]) + leader["headers"] = auth_headers(leader) + + # 造 11 月的台账(避开 10 月已入结月份) + cust = customer(client, admin["headers"]) + person = _person(client, admin["headers"]) + resp = client.post( + _BUSINESSES, + json=business_payload( + cust["id"], + owner_personnel_id=person["id"], + items=[dict(_GOLD_ITEMS[0], product_name=f"t_11月_{uuid.uuid4().hex[:6]}")], + ), + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + biz = resp.json()["data"] + resp = client.post( + _SHIPMENTS, + json={ + "business_id": biz["id"], + "ship_date": "2026-11-01", + "items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}], + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + shipment = resp.json()["data"] + resp = client.post( + _RECEIPTS, + json={ + "business_id": biz["id"], + "receipt_date": "2026-11-20", + "amount": "20000.00", + "shipment_id": shipment["id"], + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + receipt = resp.json()["data"] + assert ( + client.post( + f"{_CALC}/receipts/{receipt['id']}/calc", headers=admin["headers"] + ).status_code + == 200 + ) + + resp = client.post( + f"{_SETTLEMENTS}/generate", json={"month": "2026-11"}, headers=admin["headers"] + ) + assert resp.status_code == 200, resp.text + sid = resp.json()["data"]["id"] + + # 提交 → 待财务复核 + resp = client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"]) + assert resp.status_code == 200, resp.text + assert resp.json()["data"]["status"] == 2 + + # 财务通过 → 待领导(approval_order_no 关联的审批单流转) + order_no = resp.json()["data"]["approval_order_no"] + order = client.get( + "/api/v1/system/approvals", params={"keyword": order_no}, headers=admin["headers"] + ).json()["data"]["list"][0] + resp = client.post( + f"/api/v1/system/approvals/{order['id']}/action", + json={"action": 1}, + headers=finance["headers"], + ) + assert resp.status_code == 200, resp.text + + # 财务节点通过只是流转到待领导——不得提前锁定月结(否则领导驳回后 + # 锁定字段残留、两级审批形同虚设) + mid = client.get(f"{_SETTLEMENTS}/{sid}", headers=admin["headers"]).json()["data"] + assert mid["status"] == 2, "财务通过后应仍为待审批,不得提前锁定" + assert mid["locked_at"] is None + assert mid["locked_by"] is None + + # 领导通过 → 锁定 + resp = client.post( + f"/api/v1/system/approvals/{order['id']}/action", + json={"action": 1}, + headers=leader["headers"], + ) + assert resp.status_code == 200, resp.text + final = client.get(f"{_SETTLEMENTS}/{sid}", headers=admin["headers"]).json()["data"] + assert final["status"] == 3 + assert final["locked_at"] is not None + assert final["locked_by"] == leader["username"] # 审批通过人=领导账号 + + +class TestManagerAggregate: + """经理团队聚合:比例按参数实际值计算(守「百分数再乘 100」放大缺陷)。""" + + def _scene(self, client: TestClient, headers: dict) -> tuple[dict, dict]: + """经理(带一名业务员)→ 业务 → 发货 → 已核算回款,返回 (经理, 回款)。""" + manager = _person(client, headers, role_code="sales_manager") + sales = _person(client, headers, manager_id=manager["id"]) + + cust = customer(client, headers) + resp = client.post( + _BUSINESSES, + json=business_payload( + cust["id"], + owner_personnel_id=sales["id"], + items=[dict(_GOLD_ITEMS[0], product_name=f"t_聚合_{uuid.uuid4().hex[:6]}")], + ), + headers=headers, + ) + assert resp.status_code == 200, resp.text + biz = resp.json()["data"] + resp = client.post( + _SHIPMENTS, + json={ + "business_id": biz["id"], + "ship_date": "2026-10-01", + "items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + resp = client.post( + _RECEIPTS, + json={ + "business_id": biz["id"], + "receipt_date": "2026-10-20", + "amount": "20000.00", + "shipment_id": resp.json()["data"]["id"], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + receipt = resp.json()["data"] + assert ( + client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers).status_code + == 200 + ) + return manager, receipt + + def test_聚合金额按实际比例计入且落库回填(self, client: TestClient, admin: dict) -> None: + manager, _receipt = self._scene(client, admin["headers"]) + resp = client.post( + f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"] + ) + assert resp.status_code == 200, resp.text + out = resp.json()["data"] + items = out["items"] + manager_items = [i for i in items if i["calc_type"] == 2] + assert len(manager_items) == 1 + # 业务员绩效 39.11;经理 = 39.11×12.5% + 39.11×5% = 6.84425 → 6.84 + # (若调用方把百分数再 ×100,此处会是 684.43) + assert manager_items[0]["personnel_id"] == manager["id"] + assert manager_items[0]["performance_sum"] == "6.84" + assert manager_items[0]["payable_sum"] == "6.84" + + # 聚合行必须落台账(报告下钻的数据源)且被回填 settlement_id + factory = get_session_factory() + with factory() as session: + rows = ( + session.execute( + select(PerformanceLedger).where( + PerformanceLedger.settlement_month == "2026-10", + PerformanceLedger.calc_type == 2, + ) + ) + .scalars() + .all() + ) + assert len(rows) == 1, "聚合行必须落库(修复前只在内存参与汇总)" + assert rows[0].is_active == 1 + assert rows[0].receipt_id is None + assert rows[0].settlement_id == out["id"], "聚合行未回填 settlement_id" + + def test_草稿态重生成_聚合不重复计入(self, client: TestClient, admin: dict) -> None: + self._scene(client, admin["headers"]) + first = client.post( + f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"] + ).json()["data"] + second = client.post( + f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"] + ) + assert second.status_code == 200, second.text + out = second.json()["data"] + # 重生成金额不翻倍(聚合行不做废旧行会 45.95→52.79)也不漏行 + assert out["total_payable"] == first["total_payable"] == "45.95" + assert len([i for i in out["items"] if i["calc_type"] == 2]) == 1 + assert len([i for i in out["items"] if i["calc_type"] == 1]) == 1 + + def test_月末取参_预约变更影响本月聚合(self, client: TestClient, admin: dict) -> None: + """取参锚点=所属月月末:10-25 生效的预约变更应影响 2026-10 月结。""" + resp = client.post( + "/api/v1/system/params/changes", + json={ + "change_type": 2, + "effective_date": "2026-10-25", + "remark": "用例:月末取参锚点", + "items": [ + { + "param_code": "manager.share.team", + "dimension_value": "", + "value": "20", + } + ], + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + + self._scene(client, admin["headers"]) + resp = client.post( + f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"] + ) + assert resp.status_code == 200, resp.text + manager_items = [i for i in resp.json()["data"]["items"] if i["calc_type"] == 2] + # 39.11×20% + 39.11×5% = 9.7775 → 9.78(按生成日取参会得 6.84) + assert manager_items[0]["performance_sum"] == "9.78" + + # 台账快照固化月末取参日与实际比例原文 + factory = get_session_factory() + with factory() as session: + row = session.execute( + select(PerformanceLedger).where( + PerformanceLedger.settlement_month == "2026-10", + PerformanceLedger.calc_type == 2, + ) + ).scalar_one() + assert row.snapshot_json["params_as_of"] == "2026-10-31" + assert row.rate_text == "聚合:20%+5%" + assert str(row.receipt_date) == "2026-10-31" + + def test_聚合基数开关_经理个人户计入可配置(self, client: TestClient, admin: dict) -> None: + """#12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户业绩并入基数。 + + 直插一行经理个人户台账(绩效 100,模拟该类业务经单笔侧计提后的 + 结果)——默认 0 时聚合 6.84 不变;开关 1 后基数 39.11+100=139.11。 + """ + manager, _receipt = self._scene(client, admin["headers"]) + factory = get_session_factory() + with factory() as session: + session.add( + PerformanceLedger( + receipt_id=None, + calc_version=1, + calc_type=1, + business_id=0, + business_no="t_个人户", + personnel_id=manager["id"], + personnel_name=manager["personnel_name"], + role_code="sales_manager", + customer_type="manager_personal", + product_category="mixed", + receipt_date=date(2026, 10, 15), + rate_text="0", + performance_amount=Decimal("100.00"), + barter_applied=0, + payable_amount=Decimal("0"), + settlement_month="2026-10", + is_probation=0, + ) + ) + session.commit() + + def _manager_share() -> str: + resp = client.post( + f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"] + ) + assert resp.status_code == 200, resp.text + items = [i for i in resp.json()["data"]["items"] if i["calc_type"] == 2] + assert len(items) == 1 + return items[0]["performance_sum"] + + # 默认(开关 0=不计入):基数 39.11 → 6.84(与既有金标准一致) + assert _manager_share() == "6.84" + + # 开关 1(立即生效,月末 2026-10-31 取参命中):139.11×12.5% + ×5% = 24.34425 + resp = client.post( + "/api/v1/system/params/changes", + json={ + "change_type": 1, + "remark": "用例:#12 开关计入", + "items": [ + { + "param_code": "manager.personal.included", + "dimension_value": "", + "value": "1", + } + ], + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + assert _manager_share() == "24.34" + + +class TestAdjustment: + """调整单:已锁定月份补发/扣减的唯一通道。""" + + def test_新建调整单_开关关闭即生效(self, client: TestClient, admin: dict) -> None: + # 显式关开关(用例不依赖种子默认值) + _set_switch(client, admin["headers"], "approval.adjustment.enabled", "0") + person = _person(client, admin["headers"]) + resp = client.post( + f"{_SETTLEMENTS}/adjustments", + json={ + "settlement_month": "2026-10", + "personnel_id": person["id"], + "direction": 1, + "amount": "500.00", + "reason": "补发差额", + }, + headers=admin["headers"], + ) + # 守 ENABLE_CODE_BY_TYPE 漏登记 type=3:修复前此处恒 400「未知的审批对象类型」 + assert resp.status_code == 200, resp.text + out = resp.json()["data"] + assert out["status"] == 2 # 已生效 + + def test_金额恒正校验(self, client: TestClient, admin: dict) -> None: + """守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。""" + _set_switch(client, admin["headers"], "approval.adjustment.enabled", "0") + person = _person(client, admin["headers"]) + base = { + "settlement_month": "2026-10", + "personnel_id": person["id"], + "direction": 1, + "reason": "金额校验用例", + } + for bad in ("-500.00", "0", "0.00", "abc", "1e3"): + resp = client.post( + f"{_SETTLEMENTS}/adjustments", + json={**base, "amount": bad}, + headers=admin["headers"], + ) + assert resp.status_code == 400, f"amount={bad!r} 应 400:{resp.text}" + + +class TestLockInterception: + """锁定后源数据写路径全量拦截(回款编辑/删除、发货增改删、核算/重算)。""" + + def _lock_scene(self, client: TestClient, admin: dict) -> tuple[dict, dict, dict, dict]: + """已核算+未核算各一笔回款、两张 10 月发货单,然后锁定 2026-10。 + + 返回 (月结, 业务, 未核算回款, 无回款引用的发货单)。 + """ + biz = _calc_one(client, admin["headers"]) + resp = client.post( + _RECEIPTS, + json={"business_id": biz["id"], "receipt_date": "2026-10-22", "amount": "100.00"}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + unsettled = resp.json()["data"] + resp = client.post( + _SHIPMENTS, + json={ + "business_id": biz["id"], + "ship_date": "2026-10-05", + "items": [{"product_name": "t_发2", "quantity": "1", "sale_price": "1.00"}], + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + free_shipment = resp.json()["data"] + + _set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0") + resp = client.post( + f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"] + ) + assert resp.status_code == 200, resp.text + settlement = resp.json()["data"] + resp = client.post(f"{_SETTLEMENTS}/{settlement['id']}/submit", headers=admin["headers"]) + assert resp.status_code == 200, resp.text + assert resp.json()["data"]["status"] == 3 + return settlement, biz, unsettled, free_shipment + + def test_锁定后_未核算回款禁改禁删(self, client: TestClient, admin: dict) -> None: + _settlement, biz, unsettled, _ship = self._lock_scene(client, admin) + resp = client.put( + f"{_RECEIPTS}/{unsettled['id']}", + json={ + "business_id": biz["id"], + "receipt_date": "2026-10-23", + "amount": "88.00", + }, + headers=admin["headers"], + ) + assert resp.status_code == 400, resp.text + assert "锁定" in resp.json()["message"] + + resp = client.delete(f"{_RECEIPTS}/{unsettled['id']}", headers=admin["headers"]) + assert resp.status_code == 400, resp.text + assert "锁定" in resp.json()["message"] + + def test_锁定后_发货增改删400_非锁定月放行(self, client: TestClient, admin: dict) -> None: + _settlement, biz, _unsettled, free_shipment = self._lock_scene(client, admin) + items = [{"product_name": "t_发x", "quantity": "1", "sale_price": "1.00"}] + + # 新增:落锁定月 400 + resp = client.post( + _SHIPMENTS, + json={"business_id": biz["id"], "ship_date": "2026-10-08", "items": items}, + headers=admin["headers"], + ) + assert resp.status_code == 400, resp.text + assert "锁定" in resp.json()["message"] + + # 新增:非锁定月放行(拦截不误伤其它月份) + resp = client.post( + _SHIPMENTS, + json={"business_id": biz["id"], "ship_date": "2026-11-02", "items": items}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + nov = resp.json()["data"] + + # 编辑:把非锁定月的发货单改入锁定月 400 + resp = client.put( + f"{_SHIPMENTS}/{nov['id']}", + json={"ship_date": "2026-10-15", "items": items}, + headers=admin["headers"], + ) + assert resp.status_code == 400, resp.text + assert "锁定" in resp.json()["message"] + + # 编辑:原月即锁定月 400 + resp = client.put( + f"{_SHIPMENTS}/{free_shipment['id']}", + json={"ship_date": "2026-10-06", "items": items}, + headers=admin["headers"], + ) + assert resp.status_code == 400, resp.text + assert "锁定" in resp.json()["message"] + + # 删除:锁定月的发货单 400(拦截先于「被回款引用」判定) + resp = client.delete(f"{_SHIPMENTS}/{free_shipment['id']}", headers=admin["headers"]) + assert resp.status_code == 400, resp.text + assert "锁定" in resp.json()["message"] + + def test_锁定后_首算与重算400(self, client: TestClient, admin: dict) -> None: + _settlement, biz, unsettled, _ship = self._lock_scene(client, admin) + from app.models import Receipt + + with get_session_factory()() as session: + settled_id = int( + session.execute( + select(Receipt.id).where( + Receipt.business_id == biz["id"], + Receipt.settlement_status == 1, + ) + ).scalar_one() + ) + + resp = client.post( + f"{_CALC}/receipts/{settled_id}/recalc", + json={"reason": "锁定月重算"}, + headers=admin["headers"], + ) + assert resp.status_code == 400, resp.text + assert "锁定" in resp.json()["message"] + + resp = client.post(f"{_CALC}/receipts/{unsettled['id']}/calc", headers=admin["headers"]) + assert resp.status_code == 400, resp.text + assert "锁定" in resp.json()["message"] diff --git a/backend/tests/integration/test_shipments.py b/backend/tests/integration/test_shipments.py new file mode 100644 index 0000000..eccb790 --- /dev/null +++ b/backend/tests/integration/test_shipments.py @@ -0,0 +1,350 @@ +"""发货管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是发货单的行金额固化与双重汇总回写 —— 行金额(amount)与发货单总额 +(total_amount)若经前端传值,业务级 total_ship_amount 与运费合计就是可 +被篡改的源数据,M3 核算直接读业务冗余列;终态业务发货单禁写缺失会让核算 +基线漂移;被回款批次指定的发货单若能直接删,指定它的回款就指向悬空批次, +回款天数无从派生。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;发货单 +编号系统生成(SH 前缀),conftest 按编号前缀全量清;删除语义的用例自验 +DELETE 端点。 +""" + +import re +import uuid +from datetime import date +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.constants import ACTION_CREATE, MODULE_SHIPMENT +from app.core.db import get_session_factory +from app.models import OperationLog +from tests.integration.factories import create_business as _create_business + +pytestmark = pytest.mark.integration + +_BUSINESSES = "/api/v1/business/businesses" +_SHIPMENTS = "/api/v1/business/shipments" +_RECEIPTS = "/api/v1/business/receipts" +_PERSONNEL = "/api/v1/base/personnel" + + +def _item(**overrides: object) -> dict: + """发货明细行入参(默认:3 × 300.00 = 900)。""" + body: dict = { + "product_name": f"t_产品_{uuid.uuid4().hex[:8]}", + "quantity": "3", + "sale_price": "300.00", + } + body.update(overrides) + return body + + +def _spayload(business_id: int, **overrides: object) -> dict: + body: dict = { + "business_id": business_id, + "ship_date": str(date.today()), + "items": [_item()], + } + body.update(overrides) + return body + + +def _create(client: TestClient, headers: dict, business_id: int, **overrides: object) -> dict: + resp = client.post(_SHIPMENTS, json=_spayload(business_id, **overrides), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _terminate(client: TestClient, headers: dict, business_id: int) -> None: + """把业务流转到已终止(终态禁写场景的造数步骤)。""" + resp = client.put( + f"{_BUSINESSES}/{business_id}/status", + json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + +def _logs_of(username: str) -> list[OperationLog]: + """取某操作人的审计行(按 id 倒序,最新在前)。""" + factory = get_session_factory() + with factory() as session: + return list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + + +class TestAuth: + """鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。""" + + def test_未登录访问发货单列表_401(self, client: TestClient) -> None: + resp = client.get(_SHIPMENTS) + assert resp.status_code == 401, resp.text + + +class TestCreate: + """创建:SH 编号契约、行校验、行金额固化与双重汇总回写 —— 业务级汇总 + 是 M3 起付判定的直接消费源,错写就是算错钱。""" + + def test_创建发货单_行金额固化与双重汇总回写(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create( + client, + headers, + business["id"], + freight_amount="88.50", + items=[ + _item(quantity="3", sale_price="300.00", amount="999.99"), + _item(quantity="2", sale_price="250.00"), + ], + ) + + # 编号格式:SH{YYYYMMDD}-{4位当日序号}(日期取当日) + expected_prefix = f"SH{date.today():%Y%m%d}-" + assert re.fullmatch(r"SH\d{8}-\d{4}", data["shipment_no"]), data["shipment_no"] + assert data["shipment_no"].startswith(expected_prefix), data["shipment_no"] + + # 行金额由服务层按 数量×单价 固化:schema 无 amount 入参,塞了也忽略 + detail = client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).json()["data"] + amounts = [Decimal(i["amount"]) for i in detail["items"]] + assert amounts == [Decimal("900"), Decimal("500")], detail["items"] + assert Decimal(detail["total_amount"]) == Decimal("1400") + assert Decimal(detail["freight_amount"]) == Decimal("88.50") + + # 业务级汇总回写(发货金额 / 运费合计) + business_detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()[ + "data" + ] + assert Decimal(business_detail["total_ship_amount"]) == Decimal("1400") + assert Decimal(business_detail["total_freight_amount"]) == Decimal("88.50") + + def test_业务不存在_404(self, client: TestClient, normal: dict) -> None: + resp = client.post(_SHIPMENTS, json=_spayload(999999999), headers=normal["headers"]) + assert resp.status_code == 404, resp.text + assert "业务不存在" in resp.json()["message"], resp.text + + def test_终态业务禁新发货_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + _terminate(client, headers, business["id"]) + resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers) + assert resp.status_code == 400, resp.text + assert "业务已完成或已终止,禁止新发货" in resp.json()["message"], resp.text + + def test_明细为空_400(self, client: TestClient, normal: dict) -> None: + business = _create_business(client, normal["headers"]) + resp = client.post( + _SHIPMENTS, json=_spayload(business["id"], items=[]), headers=normal["headers"] + ) + assert resp.status_code == 400, resp.text + + def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None: + business = _create_business(client, normal["headers"]) + resp = client.post( + _SHIPMENTS, + json=_spayload(business["id"], items=[_item(quantity="0")]), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "数量" in resp.json()["message"], resp.text + + def test_运费负数_400(self, client: TestClient, normal: dict) -> None: + """运费是金额源数据:负值会让业务运费合计与报表直接错乱。""" + business = _create_business(client, normal["headers"]) + resp = client.post( + _SHIPMENTS, + json=_spayload(business["id"], freight_amount="-8.00"), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "不能为负数" in resp.json()["message"], resp.text + + def test_运费三位小数_400(self, client: TestClient, normal: dict) -> None: + business = _create_business(client, normal["headers"]) + resp = client.post( + _SHIPMENTS, + json=_spayload(business["id"], freight_amount="8.333"), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "最多两位小数" in resp.json()["message"], resp.text + + +class TestEdit: + """编辑(明细全量替换)与两处汇总同步 —— 业务级汇总漏算即漂移。""" + + def test_编辑发货单_两处汇总同步(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create( + client, + headers, + business["id"], + freight_amount="50.00", + items=[_item(quantity="1", sale_price="1000.00")], + ) + before = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] + assert Decimal(before["total_ship_amount"]) == Decimal("1000") + + updated = client.put( + f"{_SHIPMENTS}/{data['id']}", + json={ + "ship_date": str(date.today()), + "freight_amount": "12.00", + "items": [_item(quantity="2", sale_price="150.00")], + }, + headers=headers, + ) + assert updated.status_code == 200, updated.text + assert Decimal(updated.json()["data"]["total_amount"]) == Decimal("300") + + # 业务级两处汇总随编辑同步 + after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] + assert Decimal(after["total_ship_amount"]) == Decimal("300") + assert Decimal(after["total_freight_amount"]) == Decimal("12") + + detail = client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).json()["data"] + assert len(detail["items"]) == 1, "编辑是全量替换,旧行不得残留" + assert Decimal(detail["items"][0]["amount"]) == Decimal("300") + + +class TestDelete: + """删除保护与汇总回退:被回款指定禁删;删除后业务级汇总必须回算。""" + + def test_被回款指定禁删_解除后可删_汇总回退(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create( + client, + headers, + business["id"], + freight_amount="20.00", + items=[_item(quantity="1", sale_price="500.00")], + ) + receipt = client.post( + _RECEIPTS, + json={ + "business_id": business["id"], + "shipment_id": data["id"], + "receipt_date": str(date.today()), + "amount": "500.00", + }, + headers=headers, + ) + assert receipt.status_code == 200, receipt.text + + blocked = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers) + assert blocked.status_code == 400, blocked.text + assert "已被回款批次指定" in blocked.json()["message"], blocked.text + + # 解除回款的批次指定(未核算回款可删)后发货单可删,业务级汇总回退 + assert ( + client.delete( + f"{_RECEIPTS}/{receipt.json()['data']['id']}", headers=headers + ).status_code + == 200 + ) + freed = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers) + assert freed.status_code == 200, freed.text + business_detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()[ + "data" + ] + assert Decimal(business_detail["total_ship_amount"]) == Decimal("0") + assert Decimal(business_detail["total_freight_amount"]) == Decimal("0") + + def test_删除未被指定的发货单_业务级汇总回退(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create( + client, + headers, + business["id"], + freight_amount="100.00", + items=[_item(quantity="2", sale_price="1000.00")], + ) + before = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] + assert Decimal(before["total_ship_amount"]) == Decimal("2000") + + freed = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers) + assert freed.status_code == 200, freed.text + assert client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).status_code == 404 + after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] + assert Decimal(after["total_ship_amount"]) == Decimal("0") + assert Decimal(after["total_freight_amount"]) == Decimal("0") + + +class TestTerminal: + """终态业务的发货单禁改禁删 —— 发货的运费/金额是业务级汇总与 M3 核算 + 的源数据,终态后不得变动。""" + + def test_终态业务发货单禁改_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + _terminate(client, headers, business["id"]) + + resp = client.put( + f"{_SHIPMENTS}/{data['id']}", + json={ + "ship_date": str(date.today()), + "items": [_item(quantity="1", sale_price="1.00")], + }, + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "禁止编辑发货单" in resp.json()["message"], resp.text + + def test_终态业务发货单禁删_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + _terminate(client, headers, business["id"]) + + resp = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers) + assert resp.status_code == 400, resp.text + assert "禁止删除发货单" in resp.json()["message"], resp.text + + +class TestAudit: + """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" + + def test_新增发货单成功留审计(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_SHIPMENT and log.action == ACTION_CREATE + ] + assert logs, "新增发货单未留操作日志" + latest = logs[0] + assert latest.success == 1 + assert "新增发货单" in latest.summary + assert data["shipment_no"] in latest.summary + + def test_终态禁新发货失败留审计(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + _terminate(client, headers, business["id"]) + resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers) + assert resp.status_code == 400, resp.text + failed = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_SHIPMENT and log.success == 0 + ] + assert failed, "失败操作未留痕" + assert "禁止新发货" in (failed[0].error_message or ""), failed[0] diff --git a/backend/tests/integration/test_user_management.py b/backend/tests/integration/test_user_management.py new file mode 100644 index 0000000..a48a61f --- /dev/null +++ b/backend/tests/integration/test_user_management.py @@ -0,0 +1,364 @@ +"""用户管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是:用户 CRUD 的**串联正确性**与**审计留痕**。 + +审计是重点验收项:每个写操作(含失败的)都必须在 operation_logs 留下 +可读记录 —— 模块、操作、摘要、操作人、请求元信息完整,且 request_data +中**绝无明文密码**;账号被删除后审计行通过「user_id 置空 + 冗余 username」 +保留可追溯性。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.db import get_session_factory +from app.models import OperationLog + +pytestmark = pytest.mark.integration + +_PASSWORD = "TestPwd@2026" +_USERS = "/api/v1/system/users" + + +def _new_username() -> str: + return f"t_{uuid.uuid4().hex[:8]}" + + +def _create_via_api(client: TestClient, admin: dict, username: str, **overrides: object) -> dict: + """经接口创建用户并返回响应 data(账号归 autouse 清理兜底)。""" + payload: dict = { + "username": username, + "password": _PASSWORD, + "real_name": "接口新用户", + "roles": ["user"], + } + payload.update(overrides) + resp = client.post(_USERS, json=payload, headers=admin["headers"]) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _logs_of(username: str) -> list[OperationLog]: + """取某操作人最近的审计行(按 id 倒序)。""" + factory = get_session_factory() + with factory() as session: + rows = list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + return rows + + +class TestCrudFlow: + """正向链路:建 → 查 → 改 → 启停 → 重置密码 → 删。""" + + def test_新增用户并可在列表查到(self, client: TestClient, admin: dict) -> None: + username = _new_username() + data = _create_via_api(client, admin, username) + assert data["username"] == username + assert data["roles"] == ["user"] + assert data["status"] == 1 + + resp = client.get(_USERS, params={"username": username}, headers=admin["headers"]) + body = resp.json()["data"] + assert body["total"] == 1 + assert body["list"][0]["id"] == data["id"] + + def test_重名用户被拒(self, client: TestClient, admin: dict, make_user) -> None: + target = make_user() + resp = client.post( + _USERS, + json={"username": target["username"], "password": _PASSWORD, "roles": ["user"]}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + assert "已存在" in resp.json()["message"] + + def test_分页参数以驼峰命名生效(self, client: TestClient, admin: dict) -> None: + """pageNum/pageSize 是前端契约(PageQuery 的 alias)。 + + 防回归:若被改成蛇形命名,FastAPI 会忽略这两个参数、恒返回 + 第 1 页 10 条 —— 表现为「翻页没反应」,且没有任何报错。 + """ + resp = client.get(_USERS, params={"pageNum": 2, "pageSize": 5}, headers=admin["headers"]) + data = resp.json()["data"] + assert {"list", "total", "pageNum", "pageSize"} <= set(data) + assert data["pageNum"] == 2 + assert data["pageSize"] == 5 + assert len(data["list"]) <= 5 + + def test_编辑用户信息与角色(self, client: TestClient, admin: dict, make_user) -> None: + target = make_user() + resp = client.put( + f"{_USERS}/{target['id']}", + json={"real_name": "改后姓名", "roles": ["admin"]}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["real_name"] == "改后姓名" + assert data["roles"] == ["admin"] + + def test_编辑用户保留既有角色不触发唯一键冲突( + self, client: TestClient, admin: dict, make_user + ) -> None: + """回归:新旧角色集合有交集时(编辑时未改角色必然如此), + `replace_roles` 若在 clear 后不先落库删除,SQLAlchemy 同一 flush + 内先 INSERT 后 DELETE 会撞 uk_user_roles_user_id_role_id(1062)。 + 该缺陷最初由前端 e2e「编辑用户」步骤暴露 —— 集成测试当时只覆盖 + 了「换掉全部角色」的场景。""" + target = make_user(["user"]) + resp = client.put( + f"{_USERS}/{target['id']}", + json={"real_name": "保留角色", "roles": ["user", "admin"]}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + assert sorted(resp.json()["data"]["roles"]) == ["admin", "user"] + + def test_禁用与重新启用(self, client: TestClient, admin: dict, make_user) -> None: + target = make_user() + resp = client.put( + f"{_USERS}/{target['id']}/status", json={"status": 0}, headers=admin["headers"] + ) + assert resp.status_code == 200 + assert resp.json()["data"]["status"] == 0 + + resp = client.put( + f"{_USERS}/{target['id']}/status", json={"status": 1}, headers=admin["headers"] + ) + assert resp.json()["data"]["status"] == 1 + + def test_禁用后旧令牌立即失效( + self, client: TestClient, admin: dict, make_user, auth_headers + ) -> None: + """禁用会提 token_version —— 不依赖 Redis 的「离职即失效」。""" + target = make_user() + old_headers = auth_headers(target) + assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 200 + + client.put(f"{_USERS}/{target['id']}/status", json={"status": 0}, headers=admin["headers"]) + assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 401 + + def test_重置密码后旧令牌立即失效( + self, client: TestClient, admin: dict, make_user, auth_headers + ) -> None: + target = make_user() + old_headers = auth_headers(target) + assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 200 + + resp = client.put( + f"{_USERS}/{target['id']}/password", + json={"new_password": "NewPwd@2026"}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 401 + + def test_删除用户(self, client: TestClient, admin: dict, make_user) -> None: + target = make_user() + resp = client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"]) + assert resp.status_code == 200 + + resp = client.get(f"{_USERS}/{target['id']}", headers=admin["headers"]) + assert resp.status_code == 404 + + +class TestSelfProtection: + """自我保护:不允许对当前登录账号执行危险操作。""" + + def test_不能删除自己(self, client: TestClient, admin: dict) -> None: + resp = client.delete(f"{_USERS}/{admin['id']}", headers=admin["headers"]) + assert resp.status_code == 403 + + def test_不能禁用自己(self, client: TestClient, admin: dict) -> None: + resp = client.put( + f"{_USERS}/{admin['id']}/status", json={"status": 0}, headers=admin["headers"] + ) + assert resp.status_code == 403 + + def test_不能移除自己的管理员角色(self, client: TestClient, admin: dict) -> None: + resp = client.put( + f"{_USERS}/{admin['id']}", json={"roles": ["user"]}, headers=admin["headers"] + ) + assert resp.status_code == 403 + + +class TestValidation: + """参数与业务校验。""" + + def test_无效角色编码被拒(self, client: TestClient, admin: dict) -> None: + resp = client.post( + _USERS, + json={"username": _new_username(), "password": _PASSWORD, "roles": ["nonexist"]}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + assert "角色编码无效" in resp.json()["message"] + + def test_空角色列表被拒(self, client: TestClient, admin: dict) -> None: + resp = client.post( + _USERS, + json={"username": _new_username(), "password": _PASSWORD, "roles": []}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + + def test_密码25个汉字被拒(self, client: TestClient, admin: dict) -> None: + """75 字节超 bcrypt 上限;按字符数校验拦不住(25 字符很短)。""" + resp = client.post( + _USERS, + json={"username": _new_username(), "password": "中" * 25, "roles": ["user"]}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + + def test_查询不存在的用户返回404(self, client: TestClient, admin: dict) -> None: + resp = client.get(f"{_USERS}/999999999", headers=admin["headers"]) + assert resp.status_code == 404 + + def test_编辑未修改字段不污染空值语义(self, client: TestClient, admin: dict, make_user) -> None: + """回归:编辑表单把未修改字段以空串提交,此前会把库中 NULL 写成 ''—— + 两类空值(未填 vs 空串)语义漂移且不可逆;'' 现归一为 NULL。""" + target = make_user() # 直建账号 phone/email/remark 均为 NULL + resp = client.put( + f"{_USERS}/{target['id']}", + json={"real_name": "改名", "phone": "", "email": "", "remark": ""}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["real_name"] == "改名" + assert data["phone"] is None + assert data["email"] is None + assert data["remark"] is None + + +class TestFiltering: + """筛选条件的口径。""" + + def test_含下划线的用户名不误配(self, client: TestClient, admin: dict, make_user) -> None: + """回归:模糊筛选未转义 LIKE 通配符时 `_` 匹配任意单字符, + 搜 t_xx 会命中 txXx 之类无关账号 —— 用户名普遍含下划线,非理论场景。""" + target = make_user() + decoy_name = target["username"].replace("_", "x", 1) + make_user(username=decoy_name) + + resp = client.get(_USERS, params={"username": target["username"]}, headers=admin["headers"]) + names = [row["username"] for row in resp.json()["data"]["list"]] + assert target["username"] in names + assert decoy_name not in names + + +class TestAuditTrail: + """审计留痕 —— 本模块的核心验收点。""" + + def test_每个写操作都留下审计记录(self, client: TestClient, admin: dict, make_user) -> None: + target = make_user() + client.put( + f"{_USERS}/{target['id']}", + json={"real_name": "审计留痕"}, + headers=admin["headers"], + ) + client.put(f"{_USERS}/{target['id']}/status", json={"status": 0}, headers=admin["headers"]) + client.put( + f"{_USERS}/{target['id']}/password", + json={"new_password": "AuditPwd@2026"}, + headers=admin["headers"], + ) + client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"]) + + logs = _logs_of(admin["username"]) + actions = {(log.module, log.action) for log in logs} + assert ("用户管理", "修改") in actions + assert ("用户管理", "禁用") in actions + assert ("用户管理", "重置密码") in actions + assert ("用户管理", "删除") in actions + + # 摘要含目标用户名(供人阅读的追溯锚点) + assert any(target["username"] in log.summary for log in logs) + + # 请求元信息与结果完整 + success_logs = [log for log in logs if log.success == 1] + assert success_logs + for log in success_logs: + assert log.method and log.path + assert log.duration_ms is not None and log.duration_ms >= 0 + + def test_失败操作记为失败且带中文原因(self, client: TestClient, admin: dict) -> None: + resp = client.delete(f"{_USERS}/999999999", headers=admin["headers"]) + assert resp.status_code == 404 + + latest = _logs_of(admin["username"])[0] + assert latest.success == 0 + assert latest.action == "删除" + assert latest.error_message == "用户不存在" + + def test_审计请求参数绝不包含明文密码(self, client: TestClient, admin: dict, make_user) -> None: + """密码是审计脱敏的红线:全表扫描该操作人的 request_data 不得命中。""" + secret = "S3cret@2026!" + _create_via_api(client, admin, _new_username(), password=secret) + target = make_user() + client.put( + f"{_USERS}/{target['id']}/password", + json={"new_password": secret}, + headers=admin["headers"], + ) + + factory = get_session_factory() + with factory() as session: + rows = list( + session.execute( + select(OperationLog.request_data).where( + OperationLog.username == admin["username"] + ) + ).scalars() + ) + joined = "\n".join(row for row in rows if row) + assert secret not in joined + assert "***" in joined # 键名保留、值被抹除 + + def test_账号删除后审计行保留用户名( + self, client: TestClient, admin: dict, make_user, auth_headers + ) -> None: + """user_id 外键 SET NULL + username 冗余 —— 删除账号不得让审计断链。""" + victim = make_user(["admin"]) + victim_headers = auth_headers(victim) + # victim 作为操作人产生一条审计(失败操作最省事,不留业务数据) + client.delete(f"{_USERS}/999999999", headers=victim_headers) + + assert ( + client.delete(f"{_USERS}/{victim['id']}", headers=admin["headers"]).status_code == 200 + ) + + factory = get_session_factory() + with factory() as session: + logs = list( + session.execute( + select(OperationLog).where(OperationLog.username == victim["username"]) + ).scalars() + ) + assert logs, "审计行不应随账号删除而消失" + assert all(log.user_id is None for log in logs) + assert all(log.username == victim["username"] for log in logs) + + +class TestAuthorization: + """权限:系统管理为管理员专属。""" + + def test_普通用户访问用户管理被拒(self, client: TestClient, normal: dict) -> None: + assert client.get(_USERS, headers=normal["headers"]).status_code == 403 + resp = client.post( + _USERS, + json={"username": _new_username(), "password": _PASSWORD, "roles": ["user"]}, + headers=normal["headers"], + ) + assert resp.status_code == 403 diff --git a/backend/tests/unit/test_audit_serialization.py b/backend/tests/unit/test_audit_serialization.py new file mode 100644 index 0000000..6805dbb --- /dev/null +++ b/backend/tests/unit/test_audit_serialization.py @@ -0,0 +1,141 @@ +"""操作日志入库前处理单元测试。 + +守的是入库前的**截断与脱敏**: + +- 截断:各列超长在 MySQL 严格模式下抛 1406,而「审计失败不影响业务」的约定 + 会把该异常吞掉 —— 整条日志静默消失;request_data 尤甚,中文一个字符 + 占 3 字节,必须按 **UTF-8 字节边界**切,否则会切出乱码残字 +- 脱敏:密码是最容易从日志泄漏的凭据,request_data 入库前必经 scrub +""" + +import json +from datetime import datetime + +from app.models.operation_log import ( + ERROR_MESSAGE_MAX_LEN, + REQUEST_DATA_MAX_BYTES, + SUMMARY_MAX_LEN, +) +from app.repositories.operation_log_repository import _clip, _serialize_request_data + + +class TestClip: + """按字符数截断(长度常量与数据库列宽均按字符定义)。""" + + def test_超长截断至上限(self) -> None: + result = _clip("x" * (SUMMARY_MAX_LEN + 50), SUMMARY_MAX_LEN) + assert result is not None + assert len(result) == SUMMARY_MAX_LEN + + def test_未超长原样返回(self) -> None: + assert _clip("正常长度", SUMMARY_MAX_LEN) == "正常长度" + + def test_空值返回None(self) -> None: + assert _clip(None, ERROR_MESSAGE_MAX_LEN) is None + assert _clip("", ERROR_MESSAGE_MAX_LEN) is None + + def test_中文按字符数截断不切字(self) -> None: + """str 切片天然按字符,不会切出半个汉字。""" + result = _clip("中" * 100, 10) + assert result == "中" * 10 + + +class TestSerializeRequestData: + """序列化:JSON 化 → 脱敏 → 字节边界截断。""" + + def test_常规参数原样序列化(self) -> None: + text = _serialize_request_data({"status": 0, "roles": ["user"]}) + assert text is not None + assert json.loads(text) == {"status": 0, "roles": ["user"]} + + def test_空值返回None(self) -> None: + assert _serialize_request_data(None) is None + assert _serialize_request_data({}) is None + + def test_密码值被抹除而键名保留(self) -> None: + """键名保留便于审计阅读"提交过密码字段",值必须抹掉。""" + for payload in ({"password": "S3cret@2026"}, {"new_password": "S3cret@2026"}): + text = _serialize_request_data(payload) or "" + assert "S3cret@2026" not in text + assert "***" in text + + def test_手机号被脱敏(self) -> None: + text = _serialize_request_data({"phone": "13812345678"}) or "" + assert "13812345678" not in text + # phone 键已加入结构化抹除清单(整值 ***)—— 带空格/横线分隔的号码 + # (如 138 0013 8000)会穿过分值文本正则,键名整值抹除是更强的防线 + assert '"phone": "***"' in text + + def test_带分隔符的手机号被脱敏(self) -> None: + """修复回归:'138 0013 8000' 类分隔符写法曾同时穿过分值正则与键名清单。""" + for payload in ( + {"phone": "138 0013 8000"}, + {"phone": "138-0013-8000"}, + {"mobile": "13800138000"}, + ): + text = _serialize_request_data(payload) or "" + assert "13800138000" not in text.replace(" ", "").replace("-", "") + assert "***" in text + + def test_超长中文按字节边界截断且无残字(self) -> None: + """75000 字节(25000 个汉字)必然超限: + 若按字符截断则字节数三倍超标;若按字节硬切可能切出半个字符。 + 正确结果应是原始 JSON 串的**干净前缀**。""" + payload = {"remark": "中" * 25000} + text = _serialize_request_data(payload) + assert text is not None + assert len(text.encode("utf-8")) <= REQUEST_DATA_MAX_BYTES + assert "�" not in text # 无替换字符 + raw = json.dumps(payload, ensure_ascii=False) + assert raw.startswith(text) + + def test_未超长的中文完整保留(self) -> None: + payload = {"remark": "中" * 100} + text = _serialize_request_data(payload) + assert text is not None + assert json.loads(text) == payload + + def test_不可JSON序列化的值降级为字符串(self) -> None: + text = _serialize_request_data({"at": datetime(2026, 10, 10, 12, 0, 0)}) + assert text is not None + assert "2026-10-10" in text + + +class TestStructuredSanitization: + """结构化抹除 —— 按键名整值替换,不依赖文本正则。 + + 回归背景:此前用文本正则脱敏,有三种逃逸形态 —— 值以空白开头时 + 整体匹配失败(**完全不脱敏**);值含空格/逗号时只抹首段;值含引号时 + 既残留明文又破坏 JSON 结构。密码校验只查字节数、前后端都不 trim, + 这些形态都是正常可用密码。 + """ + + def test_前导空白密码被完全抹除(self) -> None: + """回归:此前 `" abc12345"` 原样明文入库。""" + text = _serialize_request_data({"new_password": " abc12345"}) or "" + assert "abc12345" not in text + assert json.loads(text) == {"new_password": "***"} + + def test_含空格逗号引号均整体抹除且JSON完整(self) -> None: + for password in ("abc 12345", "abc,def", 'abc"def', "abc&def"): + text = _serialize_request_data({"password": password}) or "" + assert password not in text + assert json.loads(text) == {"password": "***"} + + def test_嵌套结构与键名大小写(self) -> None: + text = ( + _serialize_request_data({"user": {"New_Password": "s3"}, "list": [{"TOKEN": "t"}]}) + or "" + ) + assert "s3" not in text + assert json.loads(text) == {"user": {"New_Password": "***"}, "list": [{"TOKEN": "***"}]} + + def test_非敏感键不受影响(self) -> None: + text = _serialize_request_data({"keyword": "a_b", "remark": "正常备注"}) or "" + assert json.loads(text) == {"keyword": "a_b", "remark": "正常备注"} + + def test_孤立代理字符不再抛错(self) -> None: + """回归:直接 encode('utf-8') 遇 \\ud800 抛 UnicodeEncodeError, + 该请求的审计记录会被兜底逻辑吞掉、静默丢失。""" + text = _serialize_request_data({"username": "a\ud800b"}) + assert text is not None diff --git a/backend/tests/unit/test_audit_service.py b/backend/tests/unit/test_audit_service.py new file mode 100644 index 0000000..8765df3 --- /dev/null +++ b/backend/tests/unit/test_audit_service.py @@ -0,0 +1,268 @@ +"""操作日志审计服务单元测试。 + +守的是 `audit()` 上下文管理器的语义: + +- 正常退出记成功、抛异常记失败,**失败记录必须先生成再让异常继续传播** +- 失败路径必须先 `rollback` 再写日志 —— 业务异常若来自 IntegrityError 等 + 数据库错误,不回滚时 session 处于待回滚状态,写日志会抛 PendingRollbackError, + 最该留下的失败记录反而被静默丢掉 +- 操作人信息在进入上下文时**固化为标量** —— 失败路径 rollback 会过期 ORM + 对象,再读属性会触发额外查询,账号并发被删时甚至 ObjectDeletedError + 令失败审计静默丢失 +- 审计自身的任何失败都不得外抛("审计可丢,业务不可断") +""" + +import logging +from datetime import datetime +from types import SimpleNamespace + +import pytest + +from app.core.exceptions import BadRequestError +from app.models import OperationLog +from app.services.audit_service import AuditRecord, RequestMeta, audit + + +class _StubSession: + """最小会话桩:记录调用顺序与写入的日志对象,可注入失败。""" + + def __init__(self) -> None: + self.events: list[str] = [] + self.logs: list[OperationLog] = [] + self.fail_add = False + self.fail_commit = False + # audit 入口会巡检未提交变更(嵌套调用防护) + self.new: list = [] + self.dirty: list = [] + self.deleted: list = [] + + def add(self, obj: object) -> None: + if self.fail_add: + raise RuntimeError("模拟写入失败") + self.events.append("add") + self.logs.append(obj) # type: ignore[arg-type] + + def flush(self) -> None: + self.events.append("flush") + + def commit(self) -> None: + if self.fail_commit: + raise RuntimeError("模拟提交失败") + self.events.append("commit") + + def rollback(self) -> None: + self.events.append("rollback") + + +class _FrozenOperator: + """只允许读取固定轮数属性的操作人桩。 + + 模拟「rollback 后 ORM 对象过期」:若失败路径仍读取 operator 属性, + 真实环境会触发额外 SELECT(且账号并发被删时抛 ObjectDeletedError), + 此处直接以异常暴露该访问。audit 入口读 id + username 恰为 2 次。 + """ + + def __init__(self, max_reads: int = 2) -> None: + self._max_reads = max_reads + self._reads = 0 + + def _touch(self) -> None: + self._reads += 1 + if self._reads > self._max_reads: + raise RuntimeError("进入审计后不应再访问操作人 ORM 属性") + + @property + def id(self) -> int: + self._touch() + return 7 + + @property + def username(self) -> str: + self._touch() + return "admin" + + +def _operator() -> SimpleNamespace: + """模拟已认证用户(audit 只读取 id 与 username 两个属性)。""" + return SimpleNamespace(id=7, username="admin") + + +def _record(**overrides: object) -> AuditRecord: + base: dict = {"module": "用户管理", "action": "新增", "summary": "新增用户 zhangsan"} + base.update(overrides) + return AuditRecord(**base) + + +def _audit( + stub: _StubSession, + *, + request_data: dict | None = None, + meta: RequestMeta | None = None, +): + return audit( + stub, # type: ignore[arg-type] + operator=_operator(), + record=_record(request_data=request_data), + meta=meta, + ) + + +class TestSuccessPath: + """正常退出:业务完成后记成功并独立提交。""" + + def test_成功路径写入一条成功日志(self) -> None: + stub = _StubSession() + with _audit(stub): + pass + assert len(stub.logs) == 1 + log = stub.logs[0] + assert log.success == 1 + assert log.error_message is None + assert log.module == "用户管理" + assert log.action == "新增" + assert log.username == "admin" + assert log.user_id == 7 + + def test_日志提交发生在业务逻辑之后(self) -> None: + """审计是业务的"后置动作":业务先 commit,日志再独立 commit。""" + stub = _StubSession() + with _audit(stub): + stub.events.append("business") + assert stub.events == ["business", "add", "flush", "commit"] + + def test_请求元信息与耗时被写入(self) -> None: + stub = _StubSession() + meta = RequestMeta( + ip="10.0.0.8", user_agent="pytest-agent", method="POST", path="/api/v1/system/users" + ) + with _audit(stub, meta=meta): + pass + log = stub.logs[0] + assert log.method == "POST" + assert log.path == "/api/v1/system/users" + assert log.ip == "10.0.0.8" + assert log.user_agent == "pytest-agent" + assert log.duration_ms is not None + assert log.duration_ms >= 0 + + def test_请求参数经脱敏后写入(self) -> None: + stub = _StubSession() + with _audit(stub, request_data={"username": "zhangsan", "password": "Secret@2026"}): + pass + request_data = stub.logs[0].request_data or "" + assert "Secret@2026" not in request_data + assert "zhangsan" in request_data + + +class TestFailurePath: + """业务异常:先回滚、记失败、异常原样重抛。""" + + def test_失败时原异常原样重抛(self) -> None: + stub = _StubSession() + with pytest.raises(BadRequestError, match="角色编码无效"), _audit(stub): + raise BadRequestError("角色编码无效") + + def test_失败记录在回滚之后写入(self) -> None: + """顺序断言:rollback 必须早于 add。颠倒时,来自数据库异常的失败 + 记录会因 session 待回滚而抛 PendingRollbackError,整条丢失。""" + stub = _StubSession() + with pytest.raises(BadRequestError), _audit(stub): + stub.events.append("business") + raise BadRequestError("用户名重复") + assert stub.events == ["business", "rollback", "add", "flush", "commit"] + log = stub.logs[0] + assert log.success == 0 + assert log.error_message == "用户名重复" + + def test_未预期异常不泄漏内部信息(self) -> None: + """未预期异常的堆栈由全局处理器记入应用日志;审计表只落通用文案。""" + stub = _StubSession() + with pytest.raises(ValueError, match="内部实现细节"), _audit(stub): + raise ValueError("内部实现细节") + assert stub.logs[0].error_message == "服务端异常,请稍后重试" + + +class TestOperatorFrozen: + """操作人标量在进入上下文时固化,之后(尤其 rollback 后)不得再触碰 ORM 对象。""" + + def test_成功路径只读取一轮操作人属性(self) -> None: + stub = _StubSession() + with audit(stub, operator=_FrozenOperator(), record=_record()): # type: ignore[arg-type] + pass + assert stub.logs[0].username == "admin" + + def test_失败路径不再触碰操作人对象(self) -> None: + """回归:此前失败路径在 rollback 之后读 operator.id/username —— + 真实环境会触发额外 SELECT,账号被并发删除时 ObjectDeletedError + 还会被兜底吞掉,最该留痕的失败审计静默丢失。""" + stub = _StubSession() + with ( + pytest.raises(BadRequestError, match="用户不存在"), + audit( + stub, # type: ignore[arg-type] + operator=_FrozenOperator(), + record=_record(action="删除", summary="删除用户 x"), + ), + ): + raise BadRequestError("用户不存在") + assert stub.logs[0].user_id == 7 + assert stub.logs[0].username == "admin" + + +class TestDirtySessionGuard: + """进入审计时存在未提交变更 → 记 warning(防嵌套调用造成半提交)。""" + + def test_脏会话触发警告(self, caplog: pytest.LogCaptureFixture) -> None: + stub = _StubSession() + stub.dirty.append(object()) + with caplog.at_level(logging.WARNING), _audit(stub): + pass + assert any("未提交的变更" in message for message in caplog.messages) + + def test_干净会话不告警(self, caplog: pytest.LogCaptureFixture) -> None: + stub = _StubSession() + with caplog.at_level(logging.WARNING), _audit(stub): + pass + assert not caplog.messages + + +class TestResilience: + """审计失败不得影响业务。""" + + def test_写入失败不外抛且业务继续(self) -> None: + stub = _StubSession() + stub.fail_add = True + reached = False + with _audit(stub): + reached = True + assert reached is True # 审计写入失败不打断业务流 + + def test_提交失败不外抛(self) -> None: + stub = _StubSession() + stub.fail_commit = True + with _audit(stub): + pass # 正常退出,不抛异常 + + def test_业务失败时审计失败不以审计异常替换原异常(self) -> None: + """业务异常是用户看到的原因,不能因审计自身失败而被替换。""" + stub = _StubSession() + stub.fail_add = True + with pytest.raises(BadRequestError, match="业务错误"), _audit(stub): + raise BadRequestError("业务错误") + + +class TestSerializationEdge: + """审计参数的序列化边界(细节覆盖见 test_audit_serialization)。""" + + def test_请求参数可为None(self) -> None: + stub = _StubSession() + with _audit(stub, request_data=None): + pass + assert stub.logs[0].request_data is None + + def test_不可JSON序列化的值被降级为字符串(self) -> None: + """如 datetime 等对象经 default=str 降级,序列化不应抛异常。""" + stub = _StubSession() + with _audit(stub, request_data={"at": datetime(2026, 10, 10, 12, 0, 0)}): + pass + assert "2026-10-10" in (stub.logs[0].request_data or "") diff --git a/backend/tests/unit/test_calc_helpers.py b/backend/tests/unit/test_calc_helpers.py new file mode 100644 index 0000000..d383415 --- /dev/null +++ b/backend/tests/unit/test_calc_helpers.py @@ -0,0 +1,28 @@ +"""核算落库辅助纯函数测试。 + +守的是:金额两位固化的舍入模式 —— quantize 缺省是上下文 ROUND_HALF_EVEN +(银行家舍入),x.xx5 会舍成偶数尾数,与业务四舍五入口径差 1 分 +(docstring 声称 HALF_UP,必须用测试锁死实际行为)。 +""" + +from decimal import Decimal + +from app.services.calc_service import _q2 + + +class TestQ2: + """金额两位小数(HALF_UP)。""" + + def test_半进位一律入(self) -> None: + # 缺省 ROUND_HALF_EVEN 会舍成 2.34(4 是偶数) + assert _q2(Decimal("2.345")) == Decimal("2.35") + + def test_负数半进位远离零(self) -> None: + assert _q2(Decimal("-2.345")) == Decimal("-2.35") + + def test_普通进位不变(self) -> None: + assert _q2(Decimal("39.111111")) == Decimal("39.11") + assert _q2(Decimal("39.115000")) == Decimal("39.12") + + def test_None直通(self) -> None: + assert _q2(None) is None diff --git a/backend/tests/unit/test_formula.py b/backend/tests/unit/test_formula.py new file mode 100644 index 0000000..467d004 --- /dev/null +++ b/backend/tests/unit/test_formula.py @@ -0,0 +1,213 @@ +"""公式纯函数测试。 + +守的是:算钱公式的精确性与边界 —— 一旦按 float 或字面量截断计算, +360/361 天分界、4/360 斜率、27 天回款示例会全部错位,直接影响工资金额。 +断言一律用 Fraction/Decimal 精确值,禁近似比较。 +""" + +from datetime import date +from decimal import Decimal +from fractions import Fraction + +from app.services.formula import ( + apply_barter, + clamp_weight_r, + converted_profit, + manager_team_share, + monthly_salary, + payment_days, + payment_weight, + performance_amount, + probation_annual_note, + profit_rate, + salesperson_rate, + start_payment_ok, +) + +# 与迁移种子一致的业务员参数(存储口径:start/floor 为 ‰ 数,slope 为 ‰/天) +SP_START = Fraction(10) +SP_SLOPE = Fraction(4, 360) +SP_FLOOR = Fraction(4) +SP_BOUNDARY = 360 + + +def _sp_rate(days: int) -> Fraction: + return salesperson_rate( + days, start=SP_START, slope=SP_SLOPE, floor=SP_FLOOR, boundary_days=SP_BOUNDARY + ) + + +class TestPaymentDays: + """回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。""" + + def test_发货日当天回款为第1天(self) -> None: + d = date(2026, 1, 1) + assert payment_days(d, d) == 1 + + def test_12月15日发货1月10日回款为27天(self) -> None: + assert payment_days(date(2025, 12, 15), date(2026, 1, 10)) == 27 + + def test_跨年按自然日连续累计(self) -> None: + assert payment_days(date(2025, 12, 31), date(2026, 1, 1)) == 2 + + +class TestSalespersonRate: + """业务员计提比例:1~360 天线性递减、≥361 天固定下限(对照表 #4)。""" + + def test_第360天恰为千分之6_线性段终点(self) -> None: + assert _sp_rate(360) == Fraction(6, 1000) + + def test_第361天固定下限千分之4_分界线(self) -> None: + # 公式外推为 5.989‰,业务确认「360 为分界线,360 以后按千分之四」 + assert _sp_rate(361) == Fraction(4, 1000) + + def test_首日取整10千分_图示口径裁决(self) -> None: + # 需规图示「第 1 天 10‰」2026-10-19 裁决覆盖公式(公式值 9.9889‰ 作废): + # 首日不递减、直接取起始值;第 2 天起才进入公式段 + assert _sp_rate(1) == Fraction(10, 1000) + + def test_第2天起进入公式递减段(self) -> None: + # 10‰ − 2×4/360‰ = 9.9778‰(首日取整裁决只影响第 1 天,第 2 天起不变) + assert _sp_rate(2) == Fraction(10, 1000) - Fraction(2 * 4, 360 * 1000) + + def test_第180天线性段中点千分之8(self) -> None: + assert _sp_rate(180) == Fraction(8, 1000) + + def test_返回值为纯比例_非千分数(self) -> None: + # 绩效 = 折算额 × 比例,比例必须是 0.006 而非 6 + assert _sp_rate(360) == Fraction(6, 1000) + assert _sp_rate(360) != Fraction(6) + + def test_负天数钳制为0天_按起始比例计提(self) -> None: + """先款后货(payment_days 为负)不线性外插:钳到 0 天取起始比例。 + + 修复前 days=-5 会算出 (10 + 5×4/360)‰ —— 高于起始值,多算绩效。 + """ + assert _sp_rate(-5) == _sp_rate(0) + assert _sp_rate(-1) == Fraction(10, 1000) + + +class TestProfitRate: + """毛利润率:不含运费口径(对照表 #2),毛利润额 / 销售额。""" + + def test_毛利润率_不含运费(self) -> None: + assert profit_rate(Decimal("45"), Decimal("100")) == Fraction(9, 20) + + def test_销售额为零_返回零_防除零(self) -> None: + assert profit_rate(Decimal("0"), Decimal("0")) == Fraction(0) + + +class TestConvertedProfit: + """折算毛利润额(化简式):销售额 ×(毛利润率 − 阈值)(对照表 #12/#13)。""" + + def test_化简式结果_市政水利30点阈值(self) -> None: + # 100 × (9/20 − 30/100) = 15 + assert converted_profit(Decimal("100"), Fraction(9, 20), Fraction(30)) == Decimal("15") + + def test_化简式与定义式等价(self) -> None: + # 定义式:毛利润额 / 毛利润率 × (毛利润率 − 阈值) = 45 / (9/20) × (9/20 − 3/10) = 15 + revenue, profit = Decimal("100"), Decimal("45") + rate = profit_rate(profit, revenue) + assert converted_profit(revenue, rate, Fraction(30)) == Decimal("15") + + def test_毛利润额为零_折算为零_不除零(self) -> None: + assert converted_profit(Decimal("0"), Fraction(0), Fraction(30)) == Decimal("0") + + def test_毛利润率低于阈值_折算为负_不归零(self) -> None: + # 100 × (1/10 − 3/10) = -20(业务确认负值正常计算) + assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(30)) == Decimal("-20") + + +class TestPerformanceAmount: + """绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。""" + + def test_乘法方向_折算额乘比例(self) -> None: + # 15000 × 97/10000 = 145.5 + assert performance_amount(Decimal("15000"), Fraction(97, 10000)) == Decimal("145.5") + + def test_负折算额_绩效为负不归零(self) -> None: + assert performance_amount(Decimal("-20"), Fraction(6, 1000)) == Decimal("-0.12") + + +class TestModifiers: + """修饰器:以物抵顶、支付权重 R×R、起付判定(对照表 #21/#22/#24)。""" + + def test_以物抵顶_按80点折算(self) -> None: + assert apply_barter(Decimal("145.5"), Fraction(80)) == Decimal("116.4") + + def test_支付权重_R为60时0点36(self) -> None: + assert payment_weight(60) == Fraction(36, 100) + + def test_支付权重_R为100时全额(self) -> None: + assert payment_weight(100) == Fraction(1) + + def test_起付判定_达到60点(self) -> None: + assert start_payment_ok(Decimal("60000"), Decimal("100000"), Fraction(60)) is True + + def test_起付判定_未达到60点(self) -> None: + assert start_payment_ok(Decimal("59999"), Decimal("100000"), Fraction(60)) is False + + +class TestClampWeightR: + """R 钳制:向下取整 + [0,100] 封顶封底(从预览内联提取的共用实现)。""" + + def test_向下取整(self) -> None: + assert clamp_weight_r(Decimal("5999.99"), Decimal("10000")) == 59 + + def test_封顶100_封底0(self) -> None: + assert clamp_weight_r(Decimal("20000"), Decimal("10000")) == 100 + assert clamp_weight_r(Decimal("0"), Decimal("10000")) == 0 + + def test_销售额0_防御0(self) -> None: + assert clamp_weight_r(Decimal("100"), Decimal("0")) == 0 + + +class TestMonthlySalary: + """月工资分岗口径三档(对照表 #8)。""" + + def test_INCLUDE_绩效加基本(self) -> None: + assert monthly_salary(Decimal("1000"), Decimal("3000"), "INCLUDE") == Decimal("4000") + + def test_DEDUCT_绩效减基本_可为负(self) -> None: + assert monthly_salary(Decimal("1000"), Decimal("3000"), "DEDUCT") == Decimal("-2000") + + def test_EXCLUDE_仅绩效(self) -> None: + assert monthly_salary(Decimal("1000"), Decimal("3000"), "EXCLUDE") == Decimal("1000") + + def test_基本缺失按0(self) -> None: + assert monthly_salary(Decimal("1000"), None, "INCLUDE") == Decimal("1000") + + def test_未知口径报错(self) -> None: + import pytest + + with pytest.raises(ValueError): + monthly_salary(Decimal("1"), Decimal("1"), "UNKNOWN") + + +class TestProbationAnnualNote: + """试用期年累计说明:超阈值才有 note,金额精确到分。""" + + def test_未超阈值_无说明(self) -> None: + assert probation_annual_note(Decimal("18000"), Decimal("18000")) is None + assert probation_annual_note(Decimal("100"), Decimal("18000")) is None + + def test_超额_给出精确差额(self) -> None: + note = probation_annual_note(Decimal("20000.5"), Decimal("18000")) + assert note is not None and "2000.50" in note + + +class TestManagerTeamShare: + """经理 12.5%+5% 聚合(#12 暂口径:基数不含经理个人户业绩——由调用方 + 传入的基数集合保证,本函数只做乘法与求和)。""" + + def test_两段乘加_精确分数(self) -> None: + # 入参为百分数(与种子一致:12.5 表示 12.5%):10000×12.5%=1250、50000×5%=2500 + got = manager_team_share( + Decimal("10000"), Decimal("50000"), team_pct=Fraction(25, 2), company_pct=Fraction(5) + ) + assert got == Decimal("3750") + + def test_零基数_零结果(self) -> None: + assert manager_team_share( + Decimal("0"), Decimal("0"), team_pct=Fraction(0), company_pct=Fraction(0) + ) == Decimal("0") diff --git a/backend/tests/unit/test_log_schemas.py b/backend/tests/unit/test_log_schemas.py new file mode 100644 index 0000000..89ade25 --- /dev/null +++ b/backend/tests/unit/test_log_schemas.py @@ -0,0 +1,92 @@ +"""日志查询模型单元测试。 + +守的是**时间范围校验**与**时区归一**:起始晚于结束时必须拒绝 —— 放过去 +的话 SQL 条件互斥、查询恒为空,使用者会以为是"没有数据"而不是"条件写反了"; +带时区的输入必须转换为本地 naive 时间 —— 不转换时 aware/naive 混用比较 +抛 TypeError(穿透成 500),或偏移被驱动静默丢弃(查询窗口偏 8 小时)。 +""" + +from datetime import UTC, datetime + +import pytest +from pydantic import ValidationError + +from app.schemas.log import LoginLogQuery, OperationLogQuery + + +class TestTimeRange: + """start_time ≤ end_time 校验(两类日志共用基类)。""" + + def test_合法范围通过(self) -> None: + query = OperationLogQuery( + start_time=datetime(2026, 10, 1, 0, 0, 0), end_time=datetime(2026, 10, 10, 0, 0, 0) + ) + assert query.start_time is not None + + def test_起始晚于结束被拒(self) -> None: + with pytest.raises(ValidationError): + OperationLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1)) + + def test_登录日志同样校验(self) -> None: + with pytest.raises(ValidationError): + LoginLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1)) + + def test_边界相等通过(self) -> None: + moment = datetime(2026, 10, 1, 12, 0, 0) + assert OperationLogQuery(start_time=moment, end_time=moment).start_time == moment + + def test_仅传一端不校验(self) -> None: + assert OperationLogQuery(start_time=datetime(2026, 10, 1)).end_time is None + assert OperationLogQuery(end_time=datetime(2026, 10, 1)).start_time is None + + +class TestQueryFields: + """筛选字段边界。""" + + def test_成功标记只能为0或1(self) -> None: + assert OperationLogQuery(success=0).success == 0 + with pytest.raises(ValidationError): + OperationLogQuery(success=2) + + def test_分页别名与偏移(self) -> None: + query = OperationLogQuery(pageNum=2, pageSize=50) + assert query.offset == 50 + + def test_操作日志筛选字段(self) -> None: + query = OperationLogQuery(module="用户管理", action="删除", username="adm") + assert (query.module, query.action, query.username) == ("用户管理", "删除", "adm") + + def test_登录日志按IP精确筛选(self) -> None: + assert LoginLogQuery(ip="10.0.0.1").ip == "10.0.0.1" + + +class TestTimezoneNormalization: + """时区归一。 + + 回归背景:混合 aware/naive 比较曾抛 TypeError 穿透成 500;aware 直传 + 曾被驱动静默丢弃偏移(查询窗口整体偏 8 小时且无提示)。 + """ + + def test_utc后缀被转换为本地时间(self) -> None: + """Z 后缀 = UTC 零点 = 东八区本地 08:00(与库列的本地时间口径对齐)。""" + query = OperationLogQuery(start_time="2026-10-01T00:00:00Z") + assert query.start_time == datetime(2026, 10, 1, 8, 0, 0) + + def test_混合aware与naive不再抛比较错误(self) -> None: + query = OperationLogQuery( + start_time=datetime(2026, 10, 1, 0, 0, 0), + end_time=datetime(2026, 10, 2, 0, 0, 0, tzinfo=UTC), + ) + assert query.end_time is not None + assert query.end_time.tzinfo is None # 已摘除 + + def test_归一后范围校验仍按本地口径生效(self) -> None: + with pytest.raises(ValidationError): + OperationLogQuery( + start_time="2026-10-02T00:00:00Z", # 本地 10-02 08:00 + end_time="2026-10-01 12:00:00", # 本地 10-01 12:00,早于起始 + ) + + def test_naive时间视为本地原样保留(self) -> None: + moment = datetime(2026, 10, 1, 12, 0, 0) + assert OperationLogQuery(start_time=moment).start_time == moment diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py index 139bf13..51f06d9 100644 --- a/backend/tests/unit/test_models.py +++ b/backend/tests/unit/test_models.py @@ -11,7 +11,43 @@ from sqlalchemy.dialects import mysql from app.models import Base -EXPECTED_TABLES = {"users", "roles", "user_roles", "permissions", "role_permissions", "login_logs"} +EXPECTED_TABLES = { + "users", + "roles", + "user_roles", + "permissions", + "role_permissions", + "login_logs", + "operation_logs", + "business_roles", + "system_settings", + "approval_orders", + "approval_steps", + "business_secondary_allocations", + "param_definitions", + "param_change_batches", + "param_values", + "personnel", + "personnel_regions", + "personnel_plans", + "products", + "customers", + "ownership_change_logs", + "businesses", + "business_items", + "business_status_logs", + "shipments", + "shipment_items", + "receipts", + "customer_rating_grades", + "performance_ledgers", + "monthly_settlements", + "settlement_items", + "adjustment_orders", +} + +# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束 +PLURAL_EXCEPTIONS = {"personnel"} def _ddl(table_name: str) -> str: @@ -31,7 +67,9 @@ class TestTableSet: def test_所有表名均为小写蛇形复数(self) -> None: for name in Base.metadata.tables: assert re.fullmatch(r"[a-z][a-z0-9_]*", name), f"表名 {name} 不符合小写蛇形" - assert name.endswith("s"), f"表名 {name} 应为复数" + # personnel 等集合名词单复数同形,本身即复数语义 + if name not in PLURAL_EXCEPTIONS: + assert name.endswith("s"), f"表名 {name} 应为复数" class TestNamingConvention: @@ -101,7 +139,7 @@ class TestTimestampColumns: def test_带时间戳的表含ON_UPDATE子句(self, table_name: str) -> None: assert "ON UPDATE CURRENT_TIMESTAMP" in _ddl(table_name) - @pytest.mark.parametrize("table_name", ["login_logs"]) + @pytest.mark.parametrize("table_name", ["login_logs", "operation_logs"]) def test_仅追加的日志表不含ON_UPDATE子句(self, table_name: str) -> None: """日志是不可变记录,没有"更新时间"语义。""" assert "ON UPDATE CURRENT_TIMESTAMP" not in _ddl(table_name) diff --git a/backend/tests/unit/test_param_registry.py b/backend/tests/unit/test_param_registry.py new file mode 100644 index 0000000..d389410 --- /dev/null +++ b/backend/tests/unit/test_param_registry.py @@ -0,0 +1,176 @@ +"""参数注册表与公式模板注册表测试。 + +守的是:注册表是参数种子数据与运行期校验的唯一来源 —— 一旦编码或默认值 +与迁移种子漂移,参数解析会取到错误口径;一旦取值校验失守, +畸形值(如 "1/0")会落库并在核算时崩溃或算错钱。 +""" + +from fractions import Fraction + +import pytest + +from app.core.exceptions import BadRequestError +from app.services.calc_templates import TEMPLATES, get_template, required_params +from app.services.param_registry import ( + PARAM_REGISTRY, + get_spec, + parse_fraction, + validate_and_normalize, +) + +# 与 db/migration 迁移种子保持一致的 19 项编码(V3 十六项 + V6 审批开关两项 +# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12) +EXPECTED_CODES = { + "salesperson.rate.start", + "salesperson.rate.slope", + "salesperson.rate.floor", + "salesperson.days.boundary", + "manager.share.team", + "manager.share.company", + "enterprise.rate", + "info.rate", + "prepay.rate", + "barter.discount", + "payment.start.ratio", + "payment.weight.mode", + "product.threshold", + "salary.basic.mode", + "probation.annual.threshold", + "approval.secondary_allocation.enabled", + "approval.monthly_settlement.enabled", + "approval.adjustment.enabled", + "manager.personal.included", +} + + +class TestRegistry: + """注册表元数据完整性(与迁移种子同源)。""" + + def test_注册表共19项(self) -> None: + assert len(PARAM_REGISTRY) == 19 + + def test_编码集合与迁移种子一致(self) -> None: + assert set(PARAM_REGISTRY) == EXPECTED_CODES + + def test_每项必填字段非空(self) -> None: + for spec in PARAM_REGISTRY.values(): + assert spec.code, "code 为空" + assert spec.name, f"{spec.code} 缺中文名称" + assert spec.group, f"{spec.code} 缺分组" + assert spec.value_type in {"DECIMAL", "INT", "BOOL", "ENUM", "TEXT"} + + def test_默认值可按类型解析(self) -> None: + for spec in PARAM_REGISTRY.values(): + if spec.default_value is None: + continue + if spec.value_type in {"DECIMAL", "INT"}: + parse_fraction(spec.default_value) # 不可解析会抛 BadRequestError + elif spec.value_type == "BOOL": + assert spec.default_value in {"0", "1"}, f"{spec.code} 默认值非 0/1" + elif spec.value_type == "ENUM": + assert spec.options, f"{spec.code} 为 ENUM 但无选项" + assert spec.default_value in spec.options + + def test_BOOL开关_仅接受0与1(self) -> None: + """审批开关 BOOL 组(V6 两个 + V7 调整单):合法值与非法值边界。""" + for code in ( + "approval.secondary_allocation.enabled", + "approval.monthly_settlement.enabled", + "approval.adjustment.enabled", + ): + spec = PARAM_REGISTRY[code] + assert spec.value_type == "BOOL" + assert validate_and_normalize(spec, "0") == "0" + assert validate_and_normalize(spec, "1") == "1" + with pytest.raises(BadRequestError): + validate_and_normalize(spec, "true") + with pytest.raises(BadRequestError): + validate_and_normalize(spec, "2") + + def test_取值域上下限可解析(self) -> None: + for spec in PARAM_REGISTRY.values(): + if spec.value_min is not None: + parse_fraction(spec.value_min) + if spec.value_max is not None: + parse_fraction(spec.value_max) + + def test_取未注册编码抛ValueError_属代码缺陷(self) -> None: + with pytest.raises(ValueError): + get_spec("not.exist.code") + + +class TestParseFraction: + """Fraction 精确解析:4/360 不许经 float 中转。""" + + def test_分数式精确解析_4除以360(self) -> None: + assert parse_fraction("4/360") == Fraction(1, 90) + + def test_整数解析(self) -> None: + assert parse_fraction("10") == Fraction(10) + + def test_小数解析(self) -> None: + assert parse_fraction("12.5") == Fraction(25, 2) + + def test_除零分数拒绝(self) -> None: + with pytest.raises(BadRequestError): + parse_fraction("1/0") + + def test_非数字拒绝(self) -> None: + with pytest.raises(BadRequestError): + parse_fraction("abc") + + +class TestValidateAndNormalize: + """参数值校验:类型/取值域/枚举(审查重点 1:畸形值不得落库)。""" + + def test_去除首尾空白后返回规范值(self) -> None: + spec = PARAM_REGISTRY["barter.discount"] + assert validate_and_normalize(spec, " 80 ") == "80" + + def test_超取值域上限拒绝(self) -> None: + spec = PARAM_REGISTRY["barter.discount"] # value_max=100 + with pytest.raises(BadRequestError): + validate_and_normalize(spec, "120") + + def test_低于取值域下限拒绝(self) -> None: + spec = PARAM_REGISTRY["barter.discount"] # value_min=0 + with pytest.raises(BadRequestError): + validate_and_normalize(spec, "-1") + + def test_枚举越界拒绝(self) -> None: + spec = PARAM_REGISTRY["payment.weight.mode"] + with pytest.raises(BadRequestError): + validate_and_normalize(spec, "PARTIAL") + + def test_枚举合法值通过(self) -> None: + spec = PARAM_REGISTRY["payment.weight.mode"] + assert validate_and_normalize(spec, "BALANCE") == "BALANCE" + + def test_整数类型拒绝小数(self) -> None: + spec = PARAM_REGISTRY["salesperson.days.boundary"] + with pytest.raises(BadRequestError): + validate_and_normalize(spec, "360.5") + + def test_分数式斜率原样保留不截断(self) -> None: + spec = PARAM_REGISTRY["salesperson.rate.slope"] + assert validate_and_normalize(spec, "4/360") == "4/360" + + +class TestTemplates: + """公式模板注册表:有限集合,声明参数必须真实存在。""" + + def test_共五个模板(self) -> None: + assert len(TEMPLATES) == 5 + + def test_模板声明参数均在注册表内(self) -> None: + for tpl in TEMPLATES.values(): + for code in tpl.params: + assert code in PARAM_REGISTRY, f"{tpl.code} 声明了未注册参数 {code}" + + def test_取模板声明参数(self) -> None: + params = required_params("TPL_SALESPERSON_DECAY") + assert "salesperson.rate.slope" in params + + def test_未知模板抛ValueError_属代码缺陷(self) -> None: + with pytest.raises(ValueError): + get_template("TPL_NOT_EXIST") diff --git a/backend/tests/unit/test_param_snapshot.py b/backend/tests/unit/test_param_snapshot.py new file mode 100644 index 0000000..551dbec --- /dev/null +++ b/backend/tests/unit/test_param_snapshot.py @@ -0,0 +1,95 @@ +"""参数快照(时点化取值)测试。 + +守的是:覆盖链与兜底 —— 一旦取错维度(如企业户取到市政阈值行) +或漏兜底,核算会用错口径或直接崩溃;同日多次变更必须取最新留痕。 +仓储 SQL 的排序语义(同日取 id 最大)在集成测试中覆盖。 +""" + +from dataclasses import FrozenInstanceError +from datetime import date +from fractions import Fraction + +import pytest + +from app.services.param_service import ParamSnapshot + + +def _snap(rows: list[tuple[str, str, str]]) -> ParamSnapshot: + """构造快照。rows 须模拟仓储输出:同一 (code, dim) 的最新值在前。""" + # 仓储实际返回 4 元组(含生效日期);单测关注覆盖链,统一补同一个日期 + return ParamSnapshot.from_rows( + date(2026, 10, 1), [(code, dim, value, date(2026, 1, 1)) for code, dim, value in rows] + ) + + +class TestCoverChain: + """覆盖链:角色维度 > 产品类别维度 > 默认行('')。""" + + def test_产品类别维度优先于默认行(self) -> None: + snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "municipal_water", "2")]) + assert snap.get("enterprise.rate", product_category="municipal_water") == "2" + + def test_无匹配维度时落默认行(self) -> None: + snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "municipal_water", "2")]) + assert snap.get("enterprise.rate", product_category="rail_transit") == "3" + + def test_角色维度优先于产品类别维度(self) -> None: + snap = _snap([("some.param", "municipal_water", "A"), ("some.param", "salesperson", "B")]) + got = snap.get("some.param", role_code="salesperson", product_category="municipal_water") + assert got == "B" + + def test_无任何维度匹配且无默认行_回退注册表默认值(self) -> None: + snap = _snap([("enterprise.rate", "municipal_water", "2")]) + # 轨交无行、无默认行 → 回退注册表 default_value(3) + assert snap.fraction("enterprise.rate", product_category="rail_transit") == Fraction(3) + + +class TestFallback: + """注册表默认值兜底(种子缺失时的安全网)。""" + + def test_未定义参数按注册表默认值兜底(self) -> None: + snap = _snap([]) + assert snap.fraction("prepay.rate") == Fraction(10) + + def test_未注册编码抛ValueError_属代码缺陷(self) -> None: + snap = _snap([]) + with pytest.raises(ValueError): + snap.fraction("not.registered.code") + + +class TestSameDayChanges: + """同日多次变更:仓储 SQL 已按 (effective_date, id) 倒序,快照取先出现者。""" + + def test_同日多次变更取最新一条(self) -> None: + snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "", "2")]) + assert snap.fraction("enterprise.rate") == Fraction(3) + + +class TestTypedGetters: + """类型化取值:Fraction 精确解析、整数、布尔、文本。""" + + def test_fraction精确解析分数式(self) -> None: + snap = _snap([("salesperson.rate.slope", "salesperson", "4/360")]) + assert snap.fraction("salesperson.rate.slope", role_code="salesperson") == Fraction(1, 90) + + def test_integer(self) -> None: + snap = _snap([("salesperson.days.boundary", "salesperson", "360")]) + assert snap.integer("salesperson.days.boundary", role_code="salesperson") == 360 + + def test_text返回枚举原文(self) -> None: + snap = _snap([("payment.weight.mode", "", "FULL")]) + assert snap.text("payment.weight.mode") == "FULL" + + def test_boolean(self) -> None: + snap = _snap([("some.flag", "", "1")]) + assert snap.boolean("some.flag") is True + assert snap.boolean("some.flag") is not False + + +class TestImmutability: + """快照冻结:解析结果不得被调用方改写(核算期间一致性)。""" + + def test_字段不可赋值(self) -> None: + snap = _snap([]) + with pytest.raises(FrozenInstanceError): + snap.biz_date = date(2026, 1, 1) # type: ignore[misc] diff --git a/backend/tests/unit/test_product_schema.py b/backend/tests/unit/test_product_schema.py new file mode 100644 index 0000000..6c1cfa2 --- /dev/null +++ b/backend/tests/unit/test_product_schema.py @@ -0,0 +1,72 @@ +"""产品 schema 的金额口径单测(秒级,无外部依赖)。 + +守:产品 sale_price/stock_quantity 若私写校验不查小数位与容量,5 位小数 +会被 Numeric 列静默截断(舍入即算错钱)、超容量会以 500 暴露 —— 校验 +口径必须与 schemas/business._check_amount 一致(shipment/receipt 同源)。 +""" + +import pytest +from pydantic import ValidationError + +from app.schemas.product import ProductCreateIn + + +class TestSalePrice: + """单价校验:scale=4 对齐 Numeric(14,4) 列。""" + + def test_四位小数通过_回显原样提交(self) -> None: + """带精度的历史单价必须能原样提交(fba8cfc 教训:编辑被永远拒收)。""" + body = ProductCreateIn( + product_name="t_产品", + product_category="municipal_water", + sale_price="5000.1234", + ) + assert body.sale_price == "5000.1234" + + def test_五位小数拒绝而非静默截断(self) -> None: + with pytest.raises(ValidationError) as exc: + ProductCreateIn( + product_name="t_产品", + product_category="municipal_water", + sale_price="1.23456", + ) + assert "最多四位小数" in str(exc.value) + + def test_超容量拒绝(self) -> None: + """DECIMAL(14,4) 整数部分 10 位:超出即拒绝而非溢出 500。""" + with pytest.raises(ValidationError): + ProductCreateIn( + product_name="t_产品", + product_category="municipal_water", + sale_price="99999999999.0000", + ) + + def test_空串归一None_负数拒绝(self) -> None: + empty = ProductCreateIn( + product_name="t_产品", product_category="municipal_water", sale_price="" + ) + assert empty.sale_price is None + with pytest.raises(ValidationError): + ProductCreateIn( + product_name="t_产品", product_category="municipal_water", sale_price="-1" + ) + + +class TestStockQuantity: + """数量校验:默认 scale=2 对齐 Numeric(14,2) 列。""" + + def test_三位小数拒绝(self) -> None: + with pytest.raises(ValidationError): + ProductCreateIn( + product_name="t_产品", + product_category="municipal_water", + stock_quantity="1.001", + ) + + def test_两位小数通过(self) -> None: + body = ProductCreateIn( + product_name="t_产品", + product_category="municipal_water", + stock_quantity="1.55", + ) + assert body.stock_quantity == "1.55" diff --git a/backend/tests/unit/test_report_helpers.py b/backend/tests/unit/test_report_helpers.py new file mode 100644 index 0000000..f774c7c --- /dev/null +++ b/backend/tests/unit/test_report_helpers.py @@ -0,0 +1,211 @@ +"""报表辅助纯函数与 Excel 模板构建单元测试(M4 ⑤)。 + +这组用例守的是「不测会静默出错」的三类点: +- 区间分桶边界错一档 → 金额进错桶,但报表仍然「能打开」,没人会发现 +- 标签映射漏一个枚举值 → 报表少一类数据或显示英文编码 +- 导出模板缺签字栏 / 金额被转 float / 文本公式注入 → 交给财务的签字稿失真 +""" + +import io +from datetime import date +from decimal import Decimal + +from openpyxl import load_workbook + +from app.core import report_meta +from app.core.constants import CUSTOMER_TYPES, PRODUCT_CATEGORIES +from app.services import report_export + + +class Test区间分桶: + """回款天数 → 8 档区间(含 ≤0 与无天数两个兜底桶)。""" + + def test_边界值全部落入正确区间(self): + cases = [ + (None, "none"), + (-5, "le0"), + (0, "le0"), + (1, "1-30"), + (30, "1-30"), + (31, "31-60"), + (60, "31-60"), + (61, "61-90"), + (90, "61-90"), + (91, "91-180"), + (180, "91-180"), + (181, "181-360"), + (360, "181-360"), + (361, "361+"), + (1000, "361+"), + ] + for days, code in cases: + assert report_meta.bucket_of(days).code == code, f"days={days}" + + def test_八档且编码唯一(self): + buckets = report_meta.get_payment_day_buckets() + assert len(buckets) == 8 + assert len({b.code for b in buckets}) == 8 + + +class Test标签覆盖: + """标签键集合必须覆盖业务常量——防常量新增后报表静默少算/显示编码。""" + + def test_客户类型标签覆盖全部类型(self): + assert set(report_meta.CUSTOMER_TYPE_LABELS) >= CUSTOMER_TYPES + + def test_产品类别标签覆盖常量与混合业务(self): + assert set(report_meta.PRODUCT_CATEGORY_LABELS) >= PRODUCT_CATEGORIES + assert "mixed" in report_meta.PRODUCT_CATEGORY_LABELS + + +def _build(code: str, rows: list[dict], buckets: dict | None = None) -> bytes: + return report_export.build_workbook( + code=code, + title="测试报表", + filters="统计月份 2026-10", + rows=rows, + summary=None, + buckets=buckets, + template_version="V1.0", + operator_name="admin", + company_name="衍水集团", + ) + + +def _sheet_text(content: bytes) -> str: + workbook = load_workbook(io.BytesIO(content)) + return "\n".join( + str(cell.value) + for row in workbook.active.iter_rows() + for cell in row + if cell.value is not None + ) + + +def _personal_row(**overrides) -> dict: + row = { + "business_no": "YW001", + "ship_date": date(2026, 10, 1), + "receipt_date": date(2026, 10, 20), + "payment_days": 20, + "product_category": "municipal_water", + "customer_type": "personal", + "role_name": "营销员", + "rate_text": "88/9000", + "weight_r": 100, + "performance_amount": Decimal("39.11"), + "payable_amount": Decimal("39.11"), + } + row.update(overrides) + return row + + +class TestExcel模板: + """导出稿是交给财务复核与领导签字的,结构缺失等同于没有模板。""" + + def test_页眉标题签字栏齐全(self): + text = _sheet_text(_build("personal-detail", [_personal_row()])) + assert "衍水集团" in text + assert "测试报表" in text + assert "统计月份 2026-10" in text + assert "模板版本:V1.0" in text + assert "财务复核" in text + assert "领导签字" in text + + def test_空数据不炸且给提示行(self): + text = _sheet_text(_build("personal-detail", [])) + assert "本筛选条件无数据" in text + assert "财务复核" in text + + def test_金额为数值单元格且两位格式(self): + # openpyxl 写入 Decimal 后读回为 int/float(Excel 单元格本身是数值), + # 这里断言「是数值而非文本」+ 显示格式;计算精度在服务层 Decimal 侧保证 + content = _build("personal-detail", [_personal_row()]) + workbook = load_workbook(io.BytesIO(content)) + sheet = workbook.active + amount_cells = [ + cell + for row in sheet.iter_rows() + for cell in row + if isinstance(cell.value, (int, float)) + and Decimal(str(cell.value)) == Decimal("39.11") + ] + assert amount_cells, "未找到金额数值单元格(Decimal 被转成了文本?)" + assert amount_cells[0].number_format == "#,##0.00" + + def test_合计行等于明细之和且标签只出现一次(self): + rows = [ + _personal_row(performance_amount=Decimal("10.00"), payable_amount=Decimal("10.00")), + _personal_row(performance_amount=Decimal("5.50"), payable_amount=Decimal("5.50")), + ] + content = _build("personal-detail", rows) + workbook = load_workbook(io.BytesIO(content)) + cells = [cell for row in workbook.active.iter_rows() for cell in row] + values = [ + Decimal(str(cell.value)) + for cell in cells + if isinstance(cell.value, (int, float)) + ] + assert Decimal("15.50") in values + # 「合计」标签只写一次(写满每个文本列是回归缺陷) + assert sum(1 for cell in cells if cell.value == "合计") == 1 + + def test_文本公式注入被前置单引号(self): + evil = "=HYPERLINK(\"http://evil\")" + content = _build("personal-detail", [_personal_row(business_no=evil)]) + workbook = load_workbook(io.BytesIO(content)) + values = [ + cell.value + for row in workbook.active.iter_rows() + for cell in row + if isinstance(cell.value, str) + ] + assert any(value.startswith("'=") for value in values), "公式注入未转义" + + def test_回款统计附两个维度对比sheet(self): + buckets = { + "by_customer_type": [ + { + "label": "个人户", + "receipt_amount": Decimal("100.00"), + "performance_amount": Decimal("1.00"), + "payable_amount": Decimal("1.00"), + "ledger_count": 1, + } + ], + "by_product_category": [], + } + content = _build( + "payment-stats", + [ + { + "label": "1-30 天", + "receipt_amount": Decimal("100.00"), + "receipt_count": 1, + "performance_amount": Decimal("1.00"), + "payable_amount": Decimal("1.00"), + "ledger_count": 1, + } + ], + buckets=buckets, + ) + workbook = load_workbook(io.BytesIO(content)) + assert workbook.sheetnames == ["回款统计", "客户类型对比", "产品类别对比"] + + def test_业务统计状态转中文(self): + rows = [ + { + "business_no": "YW001", + "business_date": date(2026, 10, 1), + "customer_name": "测试客户", + "owner_personnel_name": "张三", + "status": 2, + "total_sale_amount": Decimal("100.00"), + "total_ship_amount": Decimal("100.00"), + "total_received_amount": Decimal("100.00"), + "performance_amount": Decimal("1.00"), + "payable_amount": Decimal("1.00"), + } + ] + text = _sheet_text(_build("business-stats", rows)) + assert "已完成" in text diff --git a/backend/tests/unit/test_role_schemas.py b/backend/tests/unit/test_role_schemas.py new file mode 100644 index 0000000..0345558 --- /dev/null +++ b/backend/tests/unit/test_role_schemas.py @@ -0,0 +1,98 @@ +"""角色请求模型单元测试。 + +守的是**角色编码的格式红线**:编码会进入授权判断(`ROLE_ADMIN` 按编码 +比对)与操作日志,必须限定为小写字母开头的小写/数字/下划线组合 —— +放开大写或中文会让对照与检索困难,超长则与列宽(32)不符。 +""" + +import pytest +from pydantic import ValidationError + +from app.schemas.role import RoleCreateIn, RoleQuery, RoleStatusIn, RoleUpdateIn + + +def _create(**overrides: object) -> RoleCreateIn: + base: dict[str, object] = {"role_code": "finance", "role_name": "财务"} + base.update(overrides) + return RoleCreateIn(**base) + + +class TestRoleCodePattern: + """编码规则:^[a-z][a-z0-9_]{1,31}$""" + + def test_合法编码通过(self) -> None: + assert _create().role_code == "finance" + + def test_下划线与数字通过(self) -> None: + assert _create(role_code="sales_01").role_code == "sales_01" + + def test_大写字母被拒(self) -> None: + with pytest.raises(ValidationError): + _create(role_code="Finance") + + def test_中文被拒(self) -> None: + with pytest.raises(ValidationError): + _create(role_code="财务") + + def test_数字开头被拒(self) -> None: + with pytest.raises(ValidationError): + _create(role_code="1abc") + + def test_下划线开头被拒(self) -> None: + with pytest.raises(ValidationError): + _create(role_code="_abc") + + def test_单字符被拒(self) -> None: + with pytest.raises(ValidationError): + _create(role_code="a") + + def test_超长被拒(self) -> None: + with pytest.raises(ValidationError): + _create(role_code="a" * 33) + + def test_首尾空白被自动去除(self) -> None: + assert _create(role_code=" finance ").role_code == "finance" + + +class TestRoleUpdate: + """编辑模型:不含 role_code。""" + + def test_编辑不接受role_code(self) -> None: + """编码是授权判定锚点,编辑接口有意不接收;多余字段被忽略而非报错 + (前端表单可能整份回填提交)。""" + payload = RoleUpdateIn(role_name="财务二组", role_code="hacked") + assert not hasattr(payload, "role_code") + + def test_名称为空被拒(self) -> None: + with pytest.raises(ValidationError): + RoleUpdateIn(role_name="") + + def test_排序负数被拒(self) -> None: + with pytest.raises(ValidationError): + RoleUpdateIn(role_name="财务", sort_order=-1) + + +class TestRoleStatusAndQuery: + """状态与查询模型。""" + + def test_状态只能为0或1(self) -> None: + assert RoleStatusIn(status=0).status == 0 + assert RoleStatusIn(status=1).status == 1 + with pytest.raises(ValidationError): + RoleStatusIn(status=2) + + def test_查询默认分页(self) -> None: + query = RoleQuery() + assert query.page_num == 1 + assert query.page_size == 10 + assert query.keyword is None + assert query.status is None + + def test_分页别名照常工作(self) -> None: + """pageNum/pageSize 是前端契约(PageQuery 的 alias)。""" + query = RoleQuery(pageNum=3, pageSize=5) + assert query.offset == 10 + + def test_关键词超长被拒(self) -> None: + with pytest.raises(ValidationError): + RoleQuery(keyword="a" * 65) diff --git a/backend/tests/unit/test_secondary_allocation_schema.py b/backend/tests/unit/test_secondary_allocation_schema.py new file mode 100644 index 0000000..d4d657e --- /dev/null +++ b/backend/tests/unit/test_secondary_allocation_schema.py @@ -0,0 +1,111 @@ +"""二次分配方案入参校验(schema 层,秒级)。 + +守的是:比例红线(字符串入参、两位小数、超限拒绝不静默舍入)与 +参与人去重——uk(business_id, personnel_id) 的友好前置校验。 +明细行同批创建的正常路径由集成测试覆盖,这里集中打边界。 +""" + +import pytest +from pydantic import ValidationError + +from app.schemas.business import SecondaryAllocationIn + + +def _make(personnel_id: int = 1, ratio: str = "50.00") -> dict: + return {"personnel_id": personnel_id, "ratio": ratio} + + +class TestRatioBoundary: + """比例边界:0 / 满额 / 超限 / 精度 / 非数字。""" + + @pytest.mark.parametrize( + ("ratio", "ok"), + [ + ("0", False), # 必须大于 0 + ("0.01", True), + ("33.33", True), + ("100", True), + ("100.00", True), + ("100.01", False), # 超上限 + ("-5", False), # 负数 + ("33.333", False), # 三位小数:拒绝不静默舍入 + ("abc", False), # 非数字 + ("", False), # 空串 + ], + ) + def test_比例边界(self, ratio: str, *, ok: bool) -> None: + if ok: + assert SecondaryAllocationIn(**_make(ratio=ratio)).ratio == ratio + else: + with pytest.raises(ValidationError): + SecondaryAllocationIn(**_make(ratio=ratio)) + + def test_人员id必须为正整数(self) -> None: + with pytest.raises(ValidationError): + SecondaryAllocationIn(**_make(personnel_id=0)) + with pytest.raises(ValidationError): + SecondaryAllocationIn(**_make(personnel_id=-1)) + + +class TestDuplicate: + """参与人去重(Create/Update 两个入参模型同款校验)。""" + + def test_创建入参_重复参与人被拒(self) -> None: + from app.schemas.business import BusinessCreateIn + + payload = { + "customer_id": 1, + "items": [ + { + "product_name": "t_产品", + "product_category": "municipal_water", + "quantity": "1.00", + "sale_price": "10.00", + } + ], + "secondary_allocations": [ + _make(personnel_id=5, ratio="30.00"), + _make(personnel_id=5, ratio="20.00"), + ], + } + with pytest.raises(ValidationError, match="重复"): + BusinessCreateIn(**payload) + + def test_编辑入参_重复参与人被拒(self) -> None: + from app.schemas.business import BusinessUpdateIn + + payload = { + "customer_id": 1, + "items": [ + { + "product_name": "t_产品", + "product_category": "municipal_water", + "quantity": "1.00", + "sale_price": "10.00", + } + ], + "secondary_allocations": [_make(personnel_id=5), _make(personnel_id=5)], + } + with pytest.raises(ValidationError, match="重复"): + BusinessUpdateIn(**payload) + + def test_不同参与人合法(self) -> None: + from app.schemas.business import BusinessCreateIn + + payload = { + "customer_id": 1, + "items": [ + { + "product_name": "t_产品", + "product_category": "municipal_water", + "quantity": "1.00", + "sale_price": "10.00", + } + ], + "secondary_allocations": [ + _make(personnel_id=5, ratio="30.00"), + _make(personnel_id=6, ratio="20.00"), + ], + } + model = BusinessCreateIn(**payload) + assert len(model.secondary_allocations) == 2 diff --git a/backend/tests/unit/test_settlement_helpers.py b/backend/tests/unit/test_settlement_helpers.py new file mode 100644 index 0000000..d31debb --- /dev/null +++ b/backend/tests/unit/test_settlement_helpers.py @@ -0,0 +1,29 @@ +"""月结取参锚点测试。 + +守的是:月结参数快照按**所属月月末**解析,而不是生成日 —— 用 date.today() +取参会让补生成/重新生成历史月份的经理分成与月工资随操作日期漂移, +同一月份两次生成金额不同(违背「参数按生效日期时点化」红线)。 +""" + +from datetime import date + +from app.services.settlement_service import _month_end + + +class TestMonthEnd: + """YYYY-MM → 所属月最后一天(含大小月与闰年)。""" + + def test_大月31日(self) -> None: + assert _month_end("2026-10") == date(2026, 10, 31) + + def test_小月30日(self) -> None: + assert _month_end("2026-09") == date(2026, 9, 30) + + def test_二月平年28日(self) -> None: + assert _month_end("2026-02") == date(2026, 2, 28) + + def test_二月闰年29日(self) -> None: + assert _month_end("2028-02") == date(2028, 2, 29) + + def test_跨年十二月(self) -> None: + assert _month_end("2026-12") == date(2026, 12, 31) diff --git a/backend/uv.lock b/backend/uv.lock index d8159fe..d23538d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -221,6 +221,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "fakeredis" version = "2.39.0" @@ -378,6 +387,18 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.45.1" @@ -407,12 +428,14 @@ dependencies = [ { name = "alembic" }, { name = "captcha" }, { name = "fastapi" }, + { name = "openpyxl" }, { name = "pillow" }, { name = "pwdlib", extra = ["bcrypt"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "pymysql", extra = ["rsa"] }, + { name = "python-multipart" }, { name = "redis", extra = ["hiredis"] }, { name = "sqlalchemy" }, { name = "starlette" }, @@ -433,12 +456,14 @@ requires-dist = [ { name = "alembic", specifier = ">=1.14" }, { name = "captcha", specifier = ">=0.6" }, { name = "fastapi", specifier = ">=0.115" }, + { name = "openpyxl", specifier = ">=3.1.5" }, { name = "pillow", specifier = ">=11.0" }, { name = "pwdlib", extras = ["bcrypt"], specifier = ">=0.2" }, { name = "pydantic", specifier = ">=2.9" }, { name = "pydantic-settings", specifier = ">=2.6" }, { name = "pyjwt", specifier = ">=2.10" }, { name = "pymysql", extras = ["rsa"], specifier = ">=1.2.3" }, + { name = "python-multipart", specifier = ">=0.0.32" }, { name = "redis", extras = ["hiredis"], specifier = ">=5.2" }, { name = "sqlalchemy", specifier = ">=2.0.36" }, { name = "starlette", specifier = ">=1.7,<2" }, @@ -633,6 +658,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index 51f9c85..124a58b 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -5,16 +5,30 @@ -- 数据卷已存在时修改本文件不会生效,需要: -- docker compose -f docker/docker-compose.dev.yml down -v && up -d -- --- 内容 = V1 迁移的完整结构(额外带 DROP,开发环境允许重置) --- 与 db/migration/V1__init_rbac_and_login_logs.sql 必须保持一致, +-- 内容 = db/migration/ 下**全部迁移**的最终结构(额外带 DROP,开发环境允许重置) +-- 必须与 db/migration/V*.sql 的合集保持一致, -- 由 backend/tests/integration/test_schema_sync.py 自动校验,防止两份 DDL 漂移 -- --- 结尾写入 alembic_version 记录,使 `alembic upgrade head` 在本库上成为空操作 +-- 结尾写入 alembic_version 记录(**须等于当前 head**,否则开发库重建后 +-- 执行 alembic upgrade head 会重复建表并报 1050 Table already exists) -- ============================================================================ SET NAMES utf8mb4; -- 删除顺序遵循外键依赖:先删子表 +DROP TABLE IF EXISTS `performance_ledgers`; +DROP TABLE IF EXISTS `settlement_items`; +DROP TABLE IF EXISTS `adjustment_orders`; +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 `system_settings`; +DROP TABLE IF EXISTS `param_values`; +DROP TABLE IF EXISTS `param_change_batches`; +DROP TABLE IF EXISTS `param_definitions`; +DROP TABLE IF EXISTS `business_roles`; +DROP TABLE IF EXISTS `operation_logs`; DROP TABLE IF EXISTS `role_permissions`; DROP TABLE IF EXISTS `user_roles`; DROP TABLE IF EXISTS `login_logs`; @@ -134,6 +148,267 @@ CREATE TABLE user_roles ( CREATE INDEX idx_user_roles_role_id ON user_roles (role_id); +-- --------------------------------------------------------------------------- +-- 操作日志表(V2 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE operation_logs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + user_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + username VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余存储便于追溯)', + module VARCHAR(64) NOT NULL COMMENT '所属模块,如 用户管理', + action VARCHAR(32) NOT NULL COMMENT '操作类型:新增/修改/删除/导出', + summary VARCHAR(255) NOT NULL COMMENT '操作摘要(中文,供人阅读)', + method VARCHAR(8) COMMENT 'HTTP 方法', + path VARCHAR(255) COMMENT '请求路径', + ip VARCHAR(45) COMMENT '客户端IP(45位兼容IPv6)', + user_agent VARCHAR(255) COMMENT '客户端User-Agent(截断存储)', + request_data TEXT COMMENT '请求参数(已脱敏)', + success TINYINT NOT NULL COMMENT '是否成功:1成功 0失败' DEFAULT '0', + error_message VARCHAR(500) COMMENT '失败原因(写入方须截断)', + duration_ms INTEGER COMMENT '处理耗时(毫秒)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_operation_logs PRIMARY KEY (id), + CONSTRAINT fk_operation_logs_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='操作日志表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_operation_logs_create_time ON operation_logs (create_time); + +CREATE INDEX idx_operation_logs_module ON operation_logs (module); + +CREATE INDEX idx_operation_logs_user_id ON operation_logs (user_id); + +CREATE INDEX idx_operation_logs_username ON operation_logs (username); + +-- --------------------------------------------------------------------------- +-- 业务岗位角色表(V3 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE business_roles ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + role_code VARCHAR(32) NOT NULL COMMENT '岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改', + role_name VARCHAR(64) NOT NULL COMMENT '岗位名称', + calc_template VARCHAR(32) NOT NULL COMMENT '计提公式模板编码(以代码内模板注册表为准)', + description VARCHAR(255) COMMENT '岗位说明', + sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0', + status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1', + is_builtin TINYINT NOT NULL COMMENT '是否内置岗位:1内置(禁删禁停) 0自定义' DEFAULT '0', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_business_roles PRIMARY KEY (id), + CONSTRAINT uk_business_roles_role_code UNIQUE (role_code) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务岗位角色表(与登录角色roles区分,决定计提公式与参数)' COLLATE utf8mb4_general_ci; + +-- --------------------------------------------------------------------------- +-- 计提参数定义表(V3 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE param_definitions ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + param_code VARCHAR(64) NOT NULL COMMENT '参数编码,如 salesperson.rate.slope', + param_name VARCHAR(64) NOT NULL COMMENT '参数名称', + param_group VARCHAR(32) NOT NULL COMMENT '参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径', + value_type VARCHAR(16) NOT NULL COMMENT '值类型:DECIMAL/INT/BOOL/ENUM/TEXT', + unit VARCHAR(16) COMMENT '展示单位:‰/%/元/天,无单位为空', + dimension_type VARCHAR(32) NOT NULL COMMENT '维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE' DEFAULT 'NONE', + options_text VARCHAR(255) COMMENT '枚举选项(竖线分隔),仅 ENUM 使用', + default_value VARCHAR(64) COMMENT '系统默认值(Fraction 可解析字符串)', + value_min VARCHAR(64) COMMENT '取值下限(含),校验用', + value_max VARCHAR(64) COMMENT '取值上限(含),校验用', + description VARCHAR(255) COMMENT '口径说明(配置页展示)', + sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0', + status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_param_definitions PRIMARY KEY (id), + CONSTRAINT uk_param_definitions_param_code UNIQUE (param_code) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='计提参数定义表' COLLATE utf8mb4_general_ci; + +-- --------------------------------------------------------------------------- +-- 参数变更批次表(V3 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE param_change_batches ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + version_no VARCHAR(32) NOT NULL COMMENT '版本号,如 P20261012-01', + effective_date DATE NOT NULL COMMENT '生效日期(业务日期口径)', + change_type TINYINT NOT NULL COMMENT '生效方式:1立即生效 2指定日期生效', + remark VARCHAR(255) COMMENT '变更说明', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)', + item_count INTEGER NOT NULL COMMENT '变更参数项数' DEFAULT '0', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_param_change_batches PRIMARY KEY (id), + CONSTRAINT fk_param_change_batches_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT uk_param_change_batches_version_no UNIQUE (version_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数变更批次表' COLLATE utf8mb4_general_ci; + +-- --------------------------------------------------------------------------- +-- 参数版本条目表(V3 迁移,append-only) +-- --------------------------------------------------------------------------- +CREATE TABLE param_values ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + param_id BIGINT UNSIGNED NOT NULL COMMENT '参数定义ID(param_definitions.id)', + dimension_value VARCHAR(32) NOT NULL COMMENT '维度取值:NONE维度为空串;产品类别/岗位编码为具体值' DEFAULT '', + effective_date DATE NOT NULL COMMENT '生效日期(按业务日期时点化取值)', + value_text VARCHAR(64) NOT NULL COMMENT '参数值(Fraction 可解析字符串/枚举码/开关)', + old_value_text VARCHAR(64) COMMENT '变更前值(留痕)', + batch_id BIGINT UNSIGNED NOT NULL COMMENT '变更批次ID(param_change_batches.id)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_param_values PRIMARY KEY (id), + CONSTRAINT fk_param_values_batch_id FOREIGN KEY(batch_id) REFERENCES param_change_batches (id) ON DELETE RESTRICT, + CONSTRAINT fk_param_values_param_id FOREIGN KEY(param_id) REFERENCES param_definitions (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数版本条目表(append-only,按生效日期时点化取值)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_param_values_batch_id ON param_values (batch_id); + +CREATE INDEX idx_param_values_effective_date ON param_values (effective_date); + +CREATE INDEX idx_param_values_param_id_dimension_value_effective_date ON param_values (param_id, dimension_value, effective_date); + +-- --------------------------------------------------------------------------- +-- 营销人员表(V4 迁移;T+ 同步不覆盖岗位与工资标准) +-- --------------------------------------------------------------------------- +CREATE TABLE personnel ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + personnel_name VARCHAR(64) NOT NULL COMMENT '姓名', + business_role_id BIGINT UNSIGNED NOT NULL COMMENT '岗位角色ID(决定计提公式模板;T+同步不覆盖)', + manager_id BIGINT UNSIGNED COMMENT '直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)', + phone VARCHAR(20) COMMENT '联系电话', + hire_date DATE COMMENT '入职日期', + is_probation TINYINT NOT NULL COMMENT '是否试用期:1是 0否(试用期绩效照算不发放)' DEFAULT '0', + salary_standard DECIMAL(12, 2) COMMENT '月工资标准(元;T+同步不覆盖)', + status TINYINT NOT NULL COMMENT '状态:1在职 0离职(离职人员不可再被业务/客户归属引用)' DEFAULT '1', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_personnel PRIMARY KEY (id), + CONSTRAINT fk_personnel_business_role_id FOREIGN KEY(business_role_id) REFERENCES business_roles (id) ON DELETE RESTRICT, + CONSTRAINT fk_personnel_manager_id FOREIGN KEY(manager_id) REFERENCES personnel (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='营销人员表(T+同步不覆盖岗位与工资标准)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_personnel_business_role_id ON personnel (business_role_id); + +CREATE INDEX idx_personnel_manager_id ON personnel (manager_id); + +CREATE INDEX idx_personnel_source_no ON personnel (source_no); + +-- --------------------------------------------------------------------------- +-- 产品辐射区域表(V4 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE personnel_regions ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)', + region_name VARCHAR(128) NOT NULL COMMENT '区域名称(产品辐射地理范围)', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_personnel_regions PRIMARY KEY (id), + CONSTRAINT fk_personnel_regions_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品辐射区域表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_personnel_regions_personnel_id ON personnel_regions (personnel_id); + +-- --------------------------------------------------------------------------- +-- 市场拓展计划表(V4 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE personnel_plans ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)', + plan_period VARCHAR(32) NOT NULL COMMENT '计划期间(如 2026Q1、2026-05)', + plan_content VARCHAR(500) NOT NULL COMMENT '计划内容', + target_desc VARCHAR(255) COMMENT '指标说明(文字描述)', + source_no VARCHAR(64) COMMENT 'T+ 原始条目号(预留)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_personnel_plans PRIMARY KEY (id), + CONSTRAINT fk_personnel_plans_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='市场拓展计划表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_personnel_plans_personnel_id ON personnel_plans (personnel_id); + +-- --------------------------------------------------------------------------- +-- 产品表(V4 迁移;类别锚定折算阈值参数,T+ 同步不覆盖产品类别) +-- --------------------------------------------------------------------------- +CREATE TABLE products ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + product_name VARCHAR(128) NOT NULL COMMENT '产品名称', + spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)' DEFAULT '', + unit VARCHAR(16) COMMENT '计量单位', + stock_quantity DECIMAL(14, 2) COMMENT '数量(T+ 同步原样存储,语义待 IT 确认)', + sale_price DECIMAL(14, 4) COMMENT '销售单价(元,T+ 同步)', + product_category VARCHAR(32) NOT NULL COMMENT '产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)', + status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_products PRIMARY KEY (id), + CONSTRAINT uk_products_name_spec UNIQUE (product_name, spec_model) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_products_source_no ON products (source_no); + +-- --------------------------------------------------------------------------- +-- 客户表(V4 迁移;公司名称+负责人为业务唯一标识,T+ 同步不覆盖客户分类) +-- --------------------------------------------------------------------------- +CREATE TABLE customers ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + company_name VARCHAR(128) NOT NULL COMMENT '公司名称', + owner_name VARCHAR(64) NOT NULL COMMENT '客户负责人姓名(客户公司负责人的人名,非系统人员)', + contact_person VARCHAR(64) COMMENT '联系人', + contact_phone VARCHAR(20) COMMENT '联系电话', + address VARCHAR(255) COMMENT '地址', + industry VARCHAR(64) COMMENT '所属行业(自由文本)', + customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal', + owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_customers PRIMARY KEY (id), + CONSTRAINT uk_customers_company_owner UNIQUE (company_name, owner_name), + CONSTRAINT fk_customers_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_customers_customer_type ON customers (customer_type); + +CREATE INDEX idx_customers_owner_personnel_id ON customers (owner_personnel_id); + +CREATE INDEX idx_customers_source_no ON customers (source_no); + +-- --------------------------------------------------------------------------- +-- 归属变更记录表(V4 迁移,append-only;客户/业务归属变更留痕,不回溯历史) +-- --------------------------------------------------------------------------- +CREATE TABLE ownership_change_logs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + owner_type TINYINT NOT NULL COMMENT '归属类型:1客户归属 2业务归属', + customer_id BIGINT UNSIGNED COMMENT '客户ID(固化,owner_type=1 时非空)', + business_id BIGINT UNSIGNED COMMENT '业务ID(固化,owner_type=2 时非空)', + old_owner_personnel_id BIGINT UNSIGNED COMMENT '变更前负责营销人员ID(固化)', + new_owner_personnel_id BIGINT UNSIGNED COMMENT '变更后负责营销人员ID(固化)', + old_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更前协助营销人员ID(固化,仅业务归属使用)', + new_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更后协助营销人员ID(固化,仅业务归属使用)', + change_reason VARCHAR(255) COMMENT '变更原因', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_ownership_change_logs PRIMARY KEY (id), + CONSTRAINT fk_ownership_change_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_ownership_change_logs_customer_id ON ownership_change_logs (customer_id); + +CREATE INDEX idx_ownership_change_logs_business_id ON ownership_change_logs (business_id); + +CREATE INDEX idx_ownership_change_logs_create_time ON ownership_change_logs (create_time); + -- --------------------------------------------------------------------------- -- 字典数据:角色(与 V1 迁移中的初始化数据一致) -- --------------------------------------------------------------------------- @@ -141,12 +416,462 @@ INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('admin INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2); +-- 2026-10-15 复核审批补充需求:三个职能登录角色(与 V6 迁移种子一致; +-- 内置保护由 role_service 的 BUILTIN_ROLE_CODES 白名单承担) +INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('marketing_ops', '营销后勤', '提交与录入(审批链第一节点:月结/二次分配提交)', 3); + +INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('finance', '财务', '复核(审批链第二节点)', 4); + +INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('leader', '领导', '审核(审批链第三节点;全览只读)', 5); + -- --------------------------------------------------------------------------- --- 迁移版本记录:标记 V1 已应用,使 alembic upgrade head 成为空操作 +-- 字典数据:业务岗位与计提参数(与 V3 迁移种子一致;口径依据 2026-10-12 业务裁决) +-- 显式指定 id:param_values 按 param_id 引用定义,与迁移种子保持同源一致 +-- --------------------------------------------------------------------------- +INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES +(1, 'salesperson', '营销员', 'TPL_SALESPERSON_DECAY', '业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)', 1, 1), +(2, 'sales_manager', '营销经理', 'TPL_MANAGER_SHARE', '经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%', 2, 1), +(3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1); + +INSERT INTO param_change_batches (id, version_no, effective_date, change_type, remark, operator_name, item_count) VALUES +(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决与 2026-10-19 澄清答复)', 'system', 19); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES +(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1), +(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2), +(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3), +(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4), +(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5), +(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6), +(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7), +(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8), +(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9), +(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10), +(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11), +(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12), +(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13), +(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14), +(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15), +(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流。默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭', 17), +(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18), +(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,默认值业务未明确,暂按直接生效,业务可开启)', 19), +(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES +(1, 1, 'salesperson', '2026-02-17', '10', 1), +(2, 2, 'salesperson', '2026-02-17', '4/360', 1), +(3, 3, 'salesperson', '2026-02-17', '4', 1), +(4, 4, 'salesperson', '2026-02-17', '360', 1), +(5, 5, 'sales_manager', '2026-08-13', '12.5', 1), +(6, 6, 'sales_manager', '2026-08-13', '5', 1), +(7, 7, '', '2026-02-17', '2', 1), +(8, 7, '', '2026-08-13', '3', 1), +(9, 8, 'info_officer', '2026-08-13', '1', 1), +(10, 9, '', '2026-01-01', '10', 1), +(11, 10, '', '2026-01-01', '80', 1), +(12, 11, '', '2026-01-01', '60', 1), +(13, 12, '', '2026-01-01', 'FULL', 1), +(14, 13, 'municipal_water', '2026-02-17', '30', 1), +(15, 13, 'rail_transit', '2026-02-17', '20', 1), +(16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1), +(17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1), +(18, 15, '', '2026-01-01', '18000', 1), +-- 审批开关默认开启(澄清 #5/#7 答复 2026-10-19);试用期统一工资参数已撤 +-- (#16 答复:按人员档案 personnel.salary_standard 逐人维护),本脚本直达终态 +(20, 17, '', '2026-01-01', '1', 1), +(21, 18, '', '2026-01-01', '1', 1), +(22, 19, '', '2026-01-01', '0', 1), +(25, 20, '', '2026-01-01', '0', 1); + +-- --------------------------------------------------------------------------- +-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护) +-- --------------------------------------------------------------------------- +CREATE TABLE businesses ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_no VARCHAR(32) NOT NULL COMMENT '业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(customers.id)', + business_date DATE COMMENT '业务日期(T+ 同步口径)', + status TINYINT NOT NULL COMMENT '业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)' DEFAULT '1', + owner_personnel_id BIGINT UNSIGNED COMMENT '负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)', + assistant_personnel_id BIGINT UNSIGNED COMMENT '协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)', + is_special TINYINT NOT NULL COMMENT '特殊业务标记:1是 0否(公司参与等)' DEFAULT '0', + special_note VARCHAR(255) COMMENT '特殊业务说明', + total_sale_amount DECIMAL(14, 2) COMMENT '销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)' DEFAULT '0', + total_cost_amount DECIMAL(14, 2) COMMENT '成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + total_ship_amount DECIMAL(14, 2) COMMENT '发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + total_received_amount DECIMAL(14, 2) COMMENT '回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)' DEFAULT '0', + total_freight_amount DECIMAL(14, 2) COMMENT '运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_businesses PRIMARY KEY (id), + CONSTRAINT uk_businesses_business_no UNIQUE (business_no), + CONSTRAINT fk_businesses_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT, + CONSTRAINT fk_businesses_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT fk_businesses_assistant_personnel_id FOREIGN KEY(assistant_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务表(业务编号自动生成;T+同步不覆盖业务状态)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_businesses_business_date ON businesses (business_date); + +CREATE INDEX idx_businesses_customer_id ON businesses (customer_id); + +CREATE INDEX idx_businesses_owner_personnel_id ON businesses (owner_personnel_id); + +CREATE INDEX idx_businesses_source_no ON businesses (source_no); + +CREATE INDEX idx_businesses_status ON businesses (status); + +-- --------------------------------------------------------------------------- +-- 业务产品明细表(V5 迁移;行级类别为折算锚点,指导价 T+ 同步不覆盖) +-- --------------------------------------------------------------------------- +CREATE TABLE business_items ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)', + product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入,未入产品库)', + product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)', + spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '', + unit VARCHAR(16) COMMENT '计量单位', + product_category VARCHAR(32) NOT NULL COMMENT '产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)', + quantity DECIMAL(14, 2) NOT NULL COMMENT '数量(>0)', + sale_price DECIMAL(14, 4) COMMENT '销售单价(元;T+ 同步或人工)', + sale_amount DECIMAL(14, 2) NOT NULL COMMENT '销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)', + cost_amount DECIMAL(14, 2) COMMENT '成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)', + guided_price DECIMAL(14, 4) COMMENT '指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_business_items PRIMARY KEY (id), + CONSTRAINT fk_business_items_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT fk_business_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_business_items_business_id ON business_items (business_id); + +CREATE INDEX idx_business_items_product_category ON business_items (product_category); + +CREATE INDEX idx_business_items_product_id ON business_items (product_id); + +-- --------------------------------------------------------------------------- +-- 业务状态变更记录表(V5 迁移,append-only;创建时写首条 old=new=1) +-- --------------------------------------------------------------------------- +CREATE TABLE business_status_logs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化,无外键;删除保护由服务层承担)', + old_status TINYINT NOT NULL COMMENT '变更前状态(创建首条为 1)', + new_status TINYINT NOT NULL COMMENT '变更后状态', + change_reason VARCHAR(255) COMMENT '变更原因(终止时必填)', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_business_status_logs PRIMARY KEY (id), + CONSTRAINT fk_business_status_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务状态变更记录表(append-only)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_business_status_logs_business_id ON business_status_logs (business_id); + +CREATE INDEX idx_business_status_logs_create_time ON business_status_logs (create_time); + +-- --------------------------------------------------------------------------- +-- 发货单表(V5 迁移;发货日为回款天数基准日,发货日首日计第 1 天) +-- --------------------------------------------------------------------------- +CREATE TABLE shipments ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + shipment_no VARCHAR(32) NOT NULL COMMENT '发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)', + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;终态业务禁止新发货)', + ship_date DATE NOT NULL COMMENT '发货日期(回款天数基准日,发货日首日=第 1 天)', + freight_amount DECIMAL(14, 2) NOT NULL COMMENT '运费金额(仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0', + total_amount DECIMAL(14, 2) NOT NULL COMMENT '发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_shipments PRIMARY KEY (id), + CONSTRAINT uk_shipments_shipment_no UNIQUE (shipment_no), + CONSTRAINT fk_shipments_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货单表(发货日为回款天数基准日,发货日首日计第 1 天)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_shipments_business_id ON shipments (business_id); + +CREATE INDEX idx_shipments_ship_date ON shipments (ship_date); + +CREATE INDEX idx_shipments_source_no ON shipments (source_no); + +-- --------------------------------------------------------------------------- +-- 发货产品明细表(V5 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE shipment_items ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + shipment_id BIGINT UNSIGNED NOT NULL COMMENT '发货单ID(shipments.id)', + product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入)', + product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)', + spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '', + unit VARCHAR(16) COMMENT '计量单位', + quantity DECIMAL(14, 2) NOT NULL COMMENT '发货数量(>0)', + sale_price DECIMAL(14, 4) COMMENT '发货单价(元;T+ 同步或人工,口径待 IT 确认)', + amount DECIMAL(14, 2) NOT NULL COMMENT '行金额 = 数量×单价(服务层计算固化,不信任前端传值)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_shipment_items PRIMARY KEY (id), + CONSTRAINT fk_shipment_items_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT, + CONSTRAINT fk_shipment_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货产品明细表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_shipment_items_product_id ON shipment_items (product_id); + +CREATE INDEX idx_shipment_items_shipment_id ON shipment_items (shipment_id); + +-- --------------------------------------------------------------------------- +-- 回款表(V5 迁移;手动指定发货批次,已核算回款禁改禁删) +-- --------------------------------------------------------------------------- +CREATE TABLE receipts ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + receipt_batch_no VARCHAR(32) NOT NULL COMMENT '回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)', + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)', + shipment_id BIGINT UNSIGNED COMMENT '发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)', + receipt_date DATE NOT NULL COMMENT '回款到账日(M3 按此日匹配参数版本,对照表 #11)', + amount DECIMAL(14, 2) NOT NULL COMMENT '回款金额(≥0;一期禁负数/红字)', + is_barter TINYINT NOT NULL COMMENT '以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)' DEFAULT '0', + settlement_status TINYINT NOT NULL COMMENT '绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_receipts PRIMARY KEY (id), + CONSTRAINT uk_receipts_receipt_batch_no UNIQUE (receipt_batch_no), + CONSTRAINT fk_receipts_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT fk_receipts_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='回款表(手动指定发货批次;已核算回款禁改禁删)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_receipts_business_id ON receipts (business_id); + +CREATE INDEX idx_receipts_receipt_date ON receipts (receipt_date); + +CREATE INDEX idx_receipts_settlement_status ON receipts (settlement_status); + +CREATE INDEX idx_receipts_shipment_id ON receipts (shipment_id); + +CREATE INDEX idx_receipts_source_no ON receipts (source_no); + +-- --------------------------------------------------------------------------- +-- 客户评级等级配置表(V5 迁移;判定按 sort_order 升序首个 min_score≤总分) +-- --------------------------------------------------------------------------- +CREATE TABLE customer_rating_grades ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + grade_name VARCHAR(32) NOT NULL COMMENT '等级名称(如 A/优秀)', + min_score DECIMAL(5, 1) NOT NULL COMMENT '总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分', + max_score DECIMAL(5, 1) NOT NULL COMMENT '总分上限(展示用)', + sort_order INT NOT NULL COMMENT '排序(判定顺序,升序)' DEFAULT '0', + remark VARCHAR(255) COMMENT '说明', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_customer_rating_grades PRIMARY KEY (id), + CONSTRAINT uk_customer_rating_grades_grade_name UNIQUE (grade_name) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)' COLLATE utf8mb4_general_ci; + +-- --------------------------------------------------------------------------- +-- 字典数据:评级等级(与 V5 迁移种子一致;评分项固定 5 项×20 分为前端常量,对照表 #25) +-- --------------------------------------------------------------------------- +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES +('A', 90, 100, 1, '优秀'), +('B', 75, 89, 2, '良好'), +('C', 60, 74, 3, '合格'), +('D', 0, 59, 4, '待改进'); + +-- --------------------------------------------------------------------------- +-- 系统设置/二次分配/审批(V6 迁移;公司信息单行;分摊计算待澄清另批) +-- --------------------------------------------------------------------------- +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=未上传使用默认)', + 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; + +CREATE TABLE approval_orders ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + order_no VARCHAR(32) NOT NULL COMMENT '审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}', + 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 '引用对象单号(冗余固化,列表页免联查)', + 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 '提交人用户名(冗余固化)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + 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 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 TABLE approval_steps ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)', + node TINYINT NOT NULL COMMENT '审批节点:0撤回留痕 1财务复核 2领导审核', + action TINYINT NOT NULL COMMENT '动作:1通过 2驳回 3自动通过 4撤回', + opinion VARCHAR(255) COMMENT '审批意见(驳回时填写)', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(自动通过/系统动作置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化;系统动作记「系统」)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_approval_steps PRIMARY KEY (id), + CONSTRAINT fk_approval_steps_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT fk_approval_steps_order_id FOREIGN KEY(order_id) REFERENCES approval_orders (id) ON DELETE CASCADE +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批节点记录表(append-only;自动通过/撤回均有留痕)' COLLATE utf8mb4_general_ci; + +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;业务删除随删)', + 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)', + 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 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; + +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); + +-- --------------------------------------------------------------------------- +-- 绩效核算台账/月结/调整单(V7 迁移;append-only 版本行;通过即锁定走调整单) +-- --------------------------------------------------------------------------- +CREATE TABLE monthly_settlements ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + settlement_month VARCHAR(7) NOT NULL COMMENT '结算月份(YYYY-MM;全表唯一)', + status TINYINT NOT NULL COMMENT '状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)' DEFAULT '1', + total_performance NUMERIC(14, 2) COMMENT '绩效金额合计(可负)', + total_payable NUMERIC(14, 2) COMMENT '应付绩效合计(可负)', + person_count TINYINT NOT NULL COMMENT '结算人数' DEFAULT '0', + snapshot_json JSON COMMENT '月度总览快照(生成时刻的确认回款口径说明与汇总参数)', + approval_order_no VARCHAR(32) COMMENT '关联审批单号(冗余固化;开关关闭自动通过时也有单)', + submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)', + submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)', + locked_at DATETIME COMMENT '锁定时间(status=3 时回填)', + locked_by VARCHAR(64) 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_monthly_settlements PRIMARY KEY (id), + CONSTRAINT uk_monthly_settlements_settlement_month UNIQUE (settlement_month), + CONSTRAINT fk_monthly_settlements_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='月度结算表(按月一行;通过即锁定,锁定后走调整单)' COLLATE utf8mb4_general_ci; + +CREATE TABLE settlement_items ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + settlement_id BIGINT UNSIGNED NOT NULL COMMENT '月结ID(monthly_settlements.id;头删明细随删)', + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '结算人员ID(personnel.id)', + personnel_name VARCHAR(64) NOT NULL COMMENT '结算人员姓名(冗余固化)', + role_code VARCHAR(32) NOT NULL COMMENT '岗位编码(主岗,聚合锚点)', + calc_type TINYINT NOT NULL COMMENT '核算类型:1单笔回款 2经理团队聚合', + performance_sum NUMERIC(14, 2) NOT NULL COMMENT '绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)', + payable_sum NUMERIC(14, 2) NOT NULL COMMENT '应付绩效合计(可负)', + basic_amount NUMERIC(14, 2) COMMENT '月工资标准(personnel.salary_standard 当月快照)', + salary_mode VARCHAR(16) COMMENT '月工资口径快照:INCLUDE/DEDUCT/EXCLUDE', + net_amount NUMERIC(14, 2) COMMENT '净工资 = 绩效±基本工资(按口径;试用期 0)', + is_probation TINYINT NOT NULL COMMENT '试用期:1绩效照算不发放(net=0,超年累计记 note)0否' DEFAULT '0', + probation_note VARCHAR(255) COMMENT '试用期说明(年累计超阈值部分年底一次性结算)', + snapshot_json JSON COMMENT '个人明细快照(台账行引用列表)', + CONSTRAINT pk_settlement_items PRIMARY KEY (id), + CONSTRAINT fk_settlement_items_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE CASCADE, + CONSTRAINT fk_settlement_items_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='月结明细表(按人固化;支持负数工资;锁定后只读)' COLLATE utf8mb4_general_ci; + +CREATE TABLE adjustment_orders ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + order_no VARCHAR(32) NOT NULL COMMENT '调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}', + settlement_month VARCHAR(7) NOT NULL COMMENT '调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)', + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '调整人员ID(personnel.id)', + personnel_name VARCHAR(64) NOT NULL COMMENT '调整人员姓名(冗余固化)', + direction TINYINT NOT NULL COMMENT '方向:1补发 2扣减', + amount NUMERIC(14, 2) NOT NULL COMMENT '调整金额(恒正,方向表达语义)', + reason VARCHAR(255) NOT NULL COMMENT '扣补说明(必填,审批与追溯锚点)', + status TINYINT NOT NULL COMMENT '状态:1待审批 2已生效 3已驳回 4已撤回' DEFAULT '1', + submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)', + submitter_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_adjustment_orders PRIMARY KEY (id), + CONSTRAINT uk_adjustment_orders_order_no UNIQUE (order_no), + CONSTRAINT fk_adjustment_orders_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT fk_adjustment_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)' COLLATE utf8mb4_general_ci; + +CREATE TABLE performance_ledgers ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + receipt_id BIGINT UNSIGNED COMMENT '回款ID(receipts.id;聚合行可为 NULL)', + calc_version TINYINT NOT NULL COMMENT '核算版本(重算+1,旧行 is_active=0)' DEFAULT '1', + is_active TINYINT NOT NULL COMMENT '生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)' DEFAULT '1', + calc_type TINYINT NOT NULL COMMENT '核算类型:1单笔回款 2经理团队聚合(月度)' DEFAULT '1', + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)', + business_no VARCHAR(32) NOT NULL COMMENT '业务编号(冗余固化,列表免联查)', + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属营销人员ID(personnel.id;核算归属锚点)', + personnel_name VARCHAR(64) NOT NULL COMMENT '归属营销人员姓名(冗余固化)', + role_code VARCHAR(32) NOT NULL COMMENT '岗位编码(business_roles.role_code,计提公式锚点)', + customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal/manager_personal/enterprise', + product_category VARCHAR(32) NOT NULL COMMENT '产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)', + ship_date DATE COMMENT '发货日期(回款天数基准)', + receipt_date DATE NOT NULL COMMENT '回款日期(参数版本匹配日,裁决 #11)', + payment_days INTEGER COMMENT '回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)', + rate_text VARCHAR(32) NOT NULL COMMENT '计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)', + converted_profit NUMERIC(14, 2) COMMENT '折算毛利润额(可负;混合业务为各行汇总)', + performance_amount NUMERIC(14, 2) COMMENT '绩效金额(可负,裁决:负绩效照算不归零)', + barter_applied TINYINT NOT NULL COMMENT '以物抵顶折算:1已按 80% 折算 0否' DEFAULT '0', + weight_r INTEGER COMMENT '支付权重 R(回款占销售额百分比向下取整)', + payment_weight NUMERIC(7, 6) COMMENT '支付权重系数 R×R(展示用)', + payable_amount NUMERIC(14, 2) COMMENT '当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)', + settlement_month VARCHAR(7) NOT NULL COMMENT '归属月(回款日期所在月,YYYY-MM;月结与报表维度)', + is_probation TINYINT NOT NULL COMMENT '试用期:1照算不发放 0否' DEFAULT '0', + missing_cost TINYINT NOT NULL COMMENT '成本缺失:1有明细行缺成本(该行毛利按 0 计)0否' DEFAULT '0', + snapshot_json JSON COMMENT '计算快照(所用参数版本与行级折算明细,重算可回溯)', + settlement_id BIGINT UNSIGNED COMMENT '所属月结ID(月结生成时回填;NULL=未入结)', + calc_reason VARCHAR(255) COMMENT '核算/重算原因(重算必填留痕)', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_performance_ledgers PRIMARY KEY (id), + CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id), + CONSTRAINT fk_performance_ledgers_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE RESTRICT, + CONSTRAINT fk_performance_ledgers_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT fk_performance_ledgers_receipt_id FOREIGN KEY(receipt_id) REFERENCES receipts (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_performance_ledgers_business_id ON performance_ledgers (business_id); + +CREATE INDEX idx_performance_ledgers_month_active ON performance_ledgers (settlement_month, is_active); + +CREATE INDEX idx_performance_ledgers_month_personnel_type ON performance_ledgers (settlement_month, personnel_id, calc_type); + +CREATE INDEX idx_performance_ledgers_personnel_id ON performance_ledgers (personnel_id); + +ALTER TABLE approval_orders MODIFY order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算 3调整单'; + +-- 公司信息单行(system_settings 恒为一行,id=1 由迁移保底) +INSERT INTO system_settings (id, company_name, logo_path) VALUES (1, '衍水集团', NULL); + +-- --------------------------------------------------------------------------- +-- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作 -- --------------------------------------------------------------------------- CREATE TABLE alembic_version ( version_num VARCHAR(32) NOT NULL, CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; -INSERT INTO alembic_version (version_num) VALUES ('6165691326d4'); +INSERT INTO alembic_version (version_num) VALUES ('ba0217f5091b'); diff --git a/db/migration/V2__add_operation_logs.sql b/db/migration/V2__add_operation_logs.sql new file mode 100644 index 0000000..0a1e923 --- /dev/null +++ b/db/migration/V2__add_operation_logs.sql @@ -0,0 +1,32 @@ +-- Running upgrade 6165691326d4 -> a45a8087831b + +CREATE TABLE operation_logs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + user_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + username VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余存储便于追溯)', + module VARCHAR(64) NOT NULL COMMENT '所属模块,如 用户管理', + action VARCHAR(32) NOT NULL COMMENT '操作类型:新增/修改/删除/导出', + summary VARCHAR(255) NOT NULL COMMENT '操作摘要(中文,供人阅读)', + method VARCHAR(8) COMMENT 'HTTP 方法', + path VARCHAR(255) COMMENT '请求路径', + ip VARCHAR(45) COMMENT '客户端IP(45位兼容IPv6)', + user_agent VARCHAR(255) COMMENT '客户端User-Agent(截断存储)', + request_data TEXT COMMENT '请求参数(已脱敏)', + success TINYINT NOT NULL COMMENT '是否成功:1成功 0失败' DEFAULT '0', + error_message VARCHAR(500) COMMENT '失败原因(写入方须截断)', + duration_ms INTEGER COMMENT '处理耗时(毫秒)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_operation_logs PRIMARY KEY (id), + CONSTRAINT fk_operation_logs_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='操作日志表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_operation_logs_create_time ON operation_logs (create_time); + +CREATE INDEX idx_operation_logs_module ON operation_logs (module); + +CREATE INDEX idx_operation_logs_user_id ON operation_logs (user_id); + +CREATE INDEX idx_operation_logs_username ON operation_logs (username); + +UPDATE alembic_version SET version_num='a45a8087831b' WHERE alembic_version.version_num = '6165691326d4'; + diff --git a/db/migration/V3__add_business_roles_and_params.sql b/db/migration/V3__add_business_roles_and_params.sql new file mode 100644 index 0000000..15ad0b5 --- /dev/null +++ b/db/migration/V3__add_business_roles_and_params.sql @@ -0,0 +1,153 @@ +-- Running upgrade a45a8087831b -> bfbda9c3191d + +CREATE TABLE business_roles ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + role_code VARCHAR(32) NOT NULL COMMENT '岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改', + role_name VARCHAR(64) NOT NULL COMMENT '岗位名称', + calc_template VARCHAR(32) NOT NULL COMMENT '计提公式模板编码(以代码内模板注册表为准)', + description VARCHAR(255) COMMENT '岗位说明', + sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0', + status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1', + is_builtin TINYINT NOT NULL COMMENT '是否内置岗位:1内置(禁删禁停) 0自定义' DEFAULT '0', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_business_roles PRIMARY KEY (id), + CONSTRAINT uk_business_roles_role_code UNIQUE (role_code) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务岗位角色表(与登录角色roles区分,决定计提公式与参数)' COLLATE utf8mb4_general_ci; + +CREATE TABLE param_definitions ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + param_code VARCHAR(64) NOT NULL COMMENT '参数编码,如 salesperson.rate.slope', + param_name VARCHAR(64) NOT NULL COMMENT '参数名称', + param_group VARCHAR(32) NOT NULL COMMENT '参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径', + value_type VARCHAR(16) NOT NULL COMMENT '值类型:DECIMAL/INT/BOOL/ENUM/TEXT', + unit VARCHAR(16) COMMENT '展示单位:‰/%/元/天,无单位为空', + dimension_type VARCHAR(32) NOT NULL COMMENT '维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE' DEFAULT 'NONE', + options_text VARCHAR(255) COMMENT '枚举选项(竖线分隔),仅 ENUM 使用', + default_value VARCHAR(64) COMMENT '系统默认值(Fraction 可解析字符串)', + value_min VARCHAR(64) COMMENT '取值下限(含),校验用', + value_max VARCHAR(64) COMMENT '取值上限(含),校验用', + description VARCHAR(255) COMMENT '口径说明(配置页展示)', + sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0', + status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_param_definitions PRIMARY KEY (id), + CONSTRAINT uk_param_definitions_param_code UNIQUE (param_code) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='计提参数定义表' COLLATE utf8mb4_general_ci; + +CREATE TABLE param_change_batches ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + version_no VARCHAR(32) NOT NULL COMMENT '版本号,如 P20261012-01', + effective_date DATE NOT NULL COMMENT '生效日期(业务日期口径)', + change_type TINYINT NOT NULL COMMENT '生效方式:1立即生效 2指定日期生效', + remark VARCHAR(255) COMMENT '变更说明', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)', + item_count INTEGER NOT NULL COMMENT '变更参数项数' DEFAULT '0', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_param_change_batches PRIMARY KEY (id), + CONSTRAINT fk_param_change_batches_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT uk_param_change_batches_version_no UNIQUE (version_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数变更批次表' COLLATE utf8mb4_general_ci; + +CREATE TABLE param_values ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + param_id BIGINT UNSIGNED NOT NULL COMMENT '参数定义ID(param_definitions.id)', + dimension_value VARCHAR(32) NOT NULL COMMENT '维度取值:NONE维度为空串;产品类别/岗位编码为具体值' DEFAULT '', + effective_date DATE NOT NULL COMMENT '生效日期(按业务日期时点化取值)', + value_text VARCHAR(64) NOT NULL COMMENT '参数值(Fraction 可解析字符串/枚举码/开关)', + old_value_text VARCHAR(64) COMMENT '变更前值(留痕)', + batch_id BIGINT UNSIGNED NOT NULL COMMENT '变更批次ID(param_change_batches.id)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_param_values PRIMARY KEY (id), + CONSTRAINT fk_param_values_batch_id FOREIGN KEY(batch_id) REFERENCES param_change_batches (id) ON DELETE RESTRICT, + CONSTRAINT fk_param_values_param_id FOREIGN KEY(param_id) REFERENCES param_definitions (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数版本条目表(append-only,按生效日期时点化取值)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_param_values_batch_id ON param_values (batch_id); + +CREATE INDEX idx_param_values_effective_date ON param_values (effective_date); + +CREATE INDEX idx_param_values_param_id_dimension_value_effective_date ON param_values (param_id, dimension_value, effective_date); + +INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (1, 'salesperson', '营销员', 'TPL_SALESPERSON_DECAY', '业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)', 1, 1); + +INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (2, 'sales_manager', '营销经理', 'TPL_MANAGER_SHARE', '经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%', 2, 1); + +INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1); + +INSERT INTO param_change_batches (id, version_no, effective_date, change_type, remark, operator_name, item_count) VALUES (1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)', 'system', 19); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (1, 1, 'salesperson', '2026-02-17', '10', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (2, 2, 'salesperson', '2026-02-17', '4/360', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (3, 3, 'salesperson', '2026-02-17', '4', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (4, 4, 'salesperson', '2026-02-17', '360', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (5, 5, 'sales_manager', '2026-08-13', '12.5', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (6, 6, 'sales_manager', '2026-08-13', '5', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (7, 7, '', '2026-02-17', '2', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (8, 7, '', '2026-08-13', '3', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (9, 8, 'info_officer', '2026-08-13', '1', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (10, 9, '', '2026-01-01', '10', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (11, 10, '', '2026-01-01', '80', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (12, 11, '', '2026-01-01', '60', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (13, 12, '', '2026-01-01', 'FULL', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (14, 13, 'municipal_water', '2026-02-17', '30', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (15, 13, 'rail_transit', '2026-02-17', '20', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (18, 15, '', '2026-01-01', '18000', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (19, 16, '', '2026-01-01', '3000', 1); + +UPDATE alembic_version SET version_num='bfbda9c3191d' WHERE alembic_version.version_num = 'a45a8087831b'; + diff --git a/db/migration/V4__add_base_data_tables.sql b/db/migration/V4__add_base_data_tables.sql new file mode 100644 index 0000000..6f501bc --- /dev/null +++ b/db/migration/V4__add_base_data_tables.sql @@ -0,0 +1,133 @@ +-- Running upgrade bfbda9c3191d -> e1133a7c670a + +CREATE TABLE products ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + product_name VARCHAR(128) NOT NULL COMMENT '产品名称', + spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)' DEFAULT '', + unit VARCHAR(16) COMMENT '计量单位', + stock_quantity NUMERIC(14, 2) COMMENT '数量(T+ 同步原样存储,语义待 IT 确认)', + sale_price NUMERIC(14, 4) COMMENT '销售单价(元,T+ 同步)', + product_category VARCHAR(32) NOT NULL COMMENT '产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)', + status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_products PRIMARY KEY (id), + CONSTRAINT uk_products_name_spec UNIQUE (product_name, spec_model) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_products_source_no ON products (source_no); + +CREATE TABLE ownership_change_logs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + owner_type TINYINT NOT NULL COMMENT '归属类型:1客户归属 2业务归属', + customer_id BIGINT UNSIGNED COMMENT '客户ID(固化,owner_type=1 时非空)', + business_id BIGINT UNSIGNED COMMENT '业务ID(固化,owner_type=2 时非空)', + old_owner_personnel_id BIGINT UNSIGNED COMMENT '变更前负责营销人员ID(固化)', + new_owner_personnel_id BIGINT UNSIGNED COMMENT '变更后负责营销人员ID(固化)', + old_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更前协助营销人员ID(固化,仅业务归属使用)', + new_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更后协助营销人员ID(固化,仅业务归属使用)', + change_reason VARCHAR(255) COMMENT '变更原因', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_ownership_change_logs PRIMARY KEY (id), + CONSTRAINT fk_ownership_change_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_ownership_change_logs_customer_id ON ownership_change_logs (customer_id); + +CREATE INDEX idx_ownership_change_logs_business_id ON ownership_change_logs (business_id); + +CREATE INDEX idx_ownership_change_logs_create_time ON ownership_change_logs (create_time); + +CREATE TABLE personnel ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + personnel_name VARCHAR(64) NOT NULL COMMENT '姓名', + business_role_id BIGINT UNSIGNED NOT NULL COMMENT '岗位角色ID(决定计提公式模板;T+同步不覆盖)', + manager_id BIGINT UNSIGNED COMMENT '直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)', + phone VARCHAR(20) COMMENT '联系电话', + hire_date DATE COMMENT '入职日期', + is_probation TINYINT NOT NULL COMMENT '是否试用期:1是 0否(试用期绩效照算不发放)' DEFAULT '0', + salary_standard NUMERIC(12, 2) COMMENT '月工资标准(元;T+同步不覆盖)', + status TINYINT NOT NULL COMMENT '状态:1在职 0离职(离职人员不可再被业务/客户归属引用)' DEFAULT '1', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_personnel PRIMARY KEY (id), + CONSTRAINT fk_personnel_business_role_id FOREIGN KEY(business_role_id) REFERENCES business_roles (id) ON DELETE RESTRICT, + CONSTRAINT fk_personnel_manager_id FOREIGN KEY(manager_id) REFERENCES personnel (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='营销人员表(T+同步不覆盖岗位与工资标准)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_personnel_business_role_id ON personnel (business_role_id); + +CREATE INDEX idx_personnel_manager_id ON personnel (manager_id); + +CREATE INDEX idx_personnel_source_no ON personnel (source_no); + +CREATE TABLE customers ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + company_name VARCHAR(128) NOT NULL COMMENT '公司名称', + owner_name VARCHAR(64) NOT NULL COMMENT '客户负责人姓名(客户公司负责人的人名,非系统人员)', + contact_person VARCHAR(64) COMMENT '联系人', + contact_phone VARCHAR(20) COMMENT '联系电话', + address VARCHAR(255) COMMENT '地址', + industry VARCHAR(64) COMMENT '所属行业(自由文本)', + customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal', + owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_customers PRIMARY KEY (id), + CONSTRAINT fk_customers_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT uk_customers_company_owner UNIQUE (company_name, owner_name) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_customers_customer_type ON customers (customer_type); + +CREATE INDEX idx_customers_owner_personnel_id ON customers (owner_personnel_id); + +CREATE INDEX idx_customers_source_no ON customers (source_no); + +CREATE TABLE personnel_plans ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)', + plan_period VARCHAR(32) NOT NULL COMMENT '计划期间(如 2026Q1、2026-05)', + plan_content VARCHAR(500) NOT NULL COMMENT '计划内容', + target_desc VARCHAR(255) COMMENT '指标说明(文字描述)', + source_no VARCHAR(64) COMMENT 'T+ 原始条目号(预留)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_personnel_plans PRIMARY KEY (id), + CONSTRAINT fk_personnel_plans_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='市场拓展计划表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_personnel_plans_personnel_id ON personnel_plans (personnel_id); + +CREATE TABLE personnel_regions ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)', + region_name VARCHAR(128) NOT NULL COMMENT '区域名称(产品辐射地理范围)', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_personnel_regions PRIMARY KEY (id), + CONSTRAINT fk_personnel_regions_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品辐射区域表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_personnel_regions_personnel_id ON personnel_regions (personnel_id); + +UPDATE alembic_version SET version_num='e1133a7c670a' WHERE alembic_version.version_num = 'bfbda9c3191d'; + diff --git a/db/migration/V5__add_business_data_tables.sql b/db/migration/V5__add_business_data_tables.sql new file mode 100644 index 0000000..5fe52f1 --- /dev/null +++ b/db/migration/V5__add_business_data_tables.sql @@ -0,0 +1,186 @@ +-- Running upgrade e1133a7c670a -> 8efc189b131f + +CREATE TABLE customer_rating_grades ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + grade_name VARCHAR(32) NOT NULL COMMENT '等级名称(如 A/优秀)', + min_score NUMERIC(5, 1) NOT NULL COMMENT '总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分', + max_score NUMERIC(5, 1) NOT NULL COMMENT '总分上限(展示用)', + sort_order INTEGER NOT NULL COMMENT '排序(判定顺序,升序)' DEFAULT '0', + remark VARCHAR(255) COMMENT '说明', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_customer_rating_grades PRIMARY KEY (id), + CONSTRAINT uk_customer_rating_grades_grade_name UNIQUE (grade_name) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)' COLLATE utf8mb4_general_ci; + +CREATE TABLE business_status_logs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化,无外键;删除保护由服务层承担)', + old_status TINYINT NOT NULL COMMENT '变更前状态(创建首条为 1)', + new_status TINYINT NOT NULL COMMENT '变更后状态', + change_reason VARCHAR(255) COMMENT '变更原因(终止时必填)', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_business_status_logs PRIMARY KEY (id), + CONSTRAINT fk_business_status_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务状态变更记录表(append-only)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_business_status_logs_business_id ON business_status_logs (business_id); + +CREATE INDEX idx_business_status_logs_create_time ON business_status_logs (create_time); + +CREATE TABLE businesses ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_no VARCHAR(32) NOT NULL COMMENT '业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(customers.id)', + business_date DATE COMMENT '业务日期(T+ 同步口径)', + status TINYINT NOT NULL COMMENT '业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)' DEFAULT '1', + owner_personnel_id BIGINT UNSIGNED COMMENT '负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)', + assistant_personnel_id BIGINT UNSIGNED COMMENT '协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)', + is_special TINYINT NOT NULL COMMENT '特殊业务标记:1是 0否(公司参与等)' DEFAULT '0', + special_note VARCHAR(255) COMMENT '特殊业务说明', + total_sale_amount NUMERIC(14, 2) COMMENT '销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)' DEFAULT '0', + total_cost_amount NUMERIC(14, 2) COMMENT '成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + total_ship_amount NUMERIC(14, 2) COMMENT '发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + total_received_amount NUMERIC(14, 2) COMMENT '回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)' DEFAULT '0', + total_freight_amount NUMERIC(14, 2) COMMENT '运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_businesses PRIMARY KEY (id), + CONSTRAINT fk_businesses_assistant_personnel_id FOREIGN KEY(assistant_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT fk_businesses_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT, + CONSTRAINT fk_businesses_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT uk_businesses_business_no UNIQUE (business_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务表(业务编号自动生成;T+同步不覆盖业务状态)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_businesses_business_date ON businesses (business_date); + +CREATE INDEX idx_businesses_customer_id ON businesses (customer_id); + +CREATE INDEX idx_businesses_owner_personnel_id ON businesses (owner_personnel_id); + +CREATE INDEX idx_businesses_source_no ON businesses (source_no); + +CREATE INDEX idx_businesses_status ON businesses (status); + +CREATE TABLE business_items ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)', + product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入,未入产品库)', + product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)', + spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '', + unit VARCHAR(16) COMMENT '计量单位', + product_category VARCHAR(32) NOT NULL COMMENT '产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)', + quantity NUMERIC(14, 2) NOT NULL COMMENT '数量(>0)', + sale_price NUMERIC(14, 4) COMMENT '销售单价(元;T+ 同步或人工)', + sale_amount NUMERIC(14, 2) NOT NULL COMMENT '销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)', + cost_amount NUMERIC(14, 2) COMMENT '成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)', + guided_price NUMERIC(14, 4) COMMENT '指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_business_items PRIMARY KEY (id), + CONSTRAINT fk_business_items_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT fk_business_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_business_items_business_id ON business_items (business_id); + +CREATE INDEX idx_business_items_product_category ON business_items (product_category); + +CREATE INDEX idx_business_items_product_id ON business_items (product_id); + +CREATE TABLE shipments ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + shipment_no VARCHAR(32) NOT NULL COMMENT '发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)', + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;终态业务禁止新发货)', + ship_date DATE NOT NULL COMMENT '发货日期(回款天数基准日,发货日首日=第 1 天)', + freight_amount NUMERIC(14, 2) NOT NULL COMMENT '运费金额(仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0', + total_amount NUMERIC(14, 2) NOT NULL COMMENT '发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_shipments PRIMARY KEY (id), + CONSTRAINT fk_shipments_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT uk_shipments_shipment_no UNIQUE (shipment_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货单表(发货日为回款天数基准日,发货日首日计第 1 天)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_shipments_business_id ON shipments (business_id); + +CREATE INDEX idx_shipments_ship_date ON shipments (ship_date); + +CREATE INDEX idx_shipments_source_no ON shipments (source_no); + +CREATE TABLE receipts ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + receipt_batch_no VARCHAR(32) NOT NULL COMMENT '回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)', + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)', + shipment_id BIGINT UNSIGNED COMMENT '发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)', + receipt_date DATE NOT NULL COMMENT '回款到账日(M3 按此日匹配参数版本,对照表 #11)', + amount NUMERIC(14, 2) NOT NULL COMMENT '回款金额(≥0;一期禁负数/红字)', + is_barter TINYINT NOT NULL COMMENT '以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)' DEFAULT '0', + settlement_status TINYINT NOT NULL COMMENT '绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_receipts PRIMARY KEY (id), + CONSTRAINT fk_receipts_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT fk_receipts_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT, + CONSTRAINT uk_receipts_receipt_batch_no UNIQUE (receipt_batch_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='回款表(手动指定发货批次;已核算回款禁改禁删)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_receipts_business_id ON receipts (business_id); + +CREATE INDEX idx_receipts_receipt_date ON receipts (receipt_date); + +CREATE INDEX idx_receipts_settlement_status ON receipts (settlement_status); + +CREATE INDEX idx_receipts_shipment_id ON receipts (shipment_id); + +CREATE INDEX idx_receipts_source_no ON receipts (source_no); + +CREATE TABLE shipment_items ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + shipment_id BIGINT UNSIGNED NOT NULL COMMENT '发货单ID(shipments.id)', + product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入)', + product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)', + spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '', + unit VARCHAR(16) COMMENT '计量单位', + quantity NUMERIC(14, 2) NOT NULL COMMENT '发货数量(>0)', + sale_price NUMERIC(14, 4) COMMENT '发货单价(元;T+ 同步或人工,口径待 IT 确认)', + amount NUMERIC(14, 2) NOT NULL COMMENT '行金额 = 数量×单价(服务层计算固化,不信任前端传值)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_shipment_items PRIMARY KEY (id), + CONSTRAINT fk_shipment_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT, + CONSTRAINT fk_shipment_items_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货产品明细表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_shipment_items_product_id ON shipment_items (product_id); + +CREATE INDEX idx_shipment_items_shipment_id ON shipment_items (shipment_id); + +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('A', 90, 100, 1, '优秀'); + +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('B', 75, 89, 2, '良好'); + +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('C', 60, 74, 3, '合格'); + +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('D', 0, 59, 4, '待改进'); + +UPDATE alembic_version SET version_num='8efc189b131f' WHERE alembic_version.version_num = 'e1133a7c670a'; + diff --git a/db/migration/V6__add_settings_allocations_approvals.sql b/db/migration/V6__add_settings_allocations_approvals.sql new file mode 100644 index 0000000..26b98a6 --- /dev/null +++ b/db/migration/V6__add_settings_allocations_approvals.sql @@ -0,0 +1,81 @@ +-- Running upgrade 8efc189b131f -> 79bcc5aa98d4 + +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=未上传使用默认)', + 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; + +CREATE TABLE approval_orders ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + order_no VARCHAR(32) NOT NULL COMMENT '审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}', + 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 '引用对象单号(冗余固化,列表页免联查)', + 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 '提交人用户名(冗余固化)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_approval_orders PRIMARY KEY (id), + CONSTRAINT fk_approval_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT uk_approval_orders_order_no UNIQUE (order_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_approval_orders_type_status ON approval_orders (order_type, status); + +CREATE TABLE approval_steps ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)', + node TINYINT NOT NULL COMMENT '审批节点:0撤回留痕 1财务复核 2领导审核', + action TINYINT NOT NULL COMMENT '动作:1通过 2驳回 3自动通过 4撤回', + opinion VARCHAR(255) COMMENT '审批意见(驳回时填写)', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(自动通过/系统动作置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化;系统动作记「系统」)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_approval_steps PRIMARY KEY (id), + CONSTRAINT fk_approval_steps_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT fk_approval_steps_order_id FOREIGN KEY(order_id) REFERENCES approval_orders (id) ON DELETE CASCADE +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批节点记录表(append-only;自动通过/撤回均有留痕)' COLLATE utf8mb4_general_ci; + +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;业务删除随删)', + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '参与业务员ID(personnel.id;被方案引用禁删)', + personnel_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)', + ratio NUMERIC(5, 2) NOT NULL COMMENT '分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)', + 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 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, + CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' 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); + +INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('marketing_ops', '营销后勤', '提交与录入(审批链第一节点:月结/二次分配提交)', 3); + +INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('finance', '财务', '复核(审批链第二节点)', 4); + +INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('leader', '领导', '审核(审批链第三节点;全览只读)', 5); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)', 17, 1); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);0=结算生成即生效,1=走财务复核→领导审核审批流', 18, 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (20, 17, '', '2026-01-01', '0', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (21, 18, '', '2026-01-01', '0', 1); + +INSERT INTO system_settings (id, company_name, logo_path) VALUES (1, '衍水集团', NULL); + +UPDATE alembic_version SET version_num='79bcc5aa98d4' WHERE alembic_version.version_num = '8efc189b131f'; + diff --git a/db/migration/V7__add_ledger_settlements_adjustments.sql b/db/migration/V7__add_ledger_settlements_adjustments.sql new file mode 100644 index 0000000..5ea5704 --- /dev/null +++ b/db/migration/V7__add_ledger_settlements_adjustments.sql @@ -0,0 +1,117 @@ +-- Running upgrade 79bcc5aa98d4 -> 3bca400421c6 + +CREATE TABLE monthly_settlements ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + settlement_month VARCHAR(7) NOT NULL COMMENT '结算月份(YYYY-MM;全表唯一)', + status TINYINT NOT NULL COMMENT '状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)' DEFAULT '1', + total_performance NUMERIC(14, 2) COMMENT '绩效金额合计(可负)', + total_payable NUMERIC(14, 2) COMMENT '应付绩效合计(可负)', + person_count TINYINT NOT NULL COMMENT '结算人数' DEFAULT '0', + snapshot_json JSON COMMENT '月度总览快照(生成时刻的确认回款口径说明与汇总参数)', + approval_order_no VARCHAR(32) COMMENT '关联审批单号(冗余固化;开关关闭自动通过时也有单)', + submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)', + submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)', + locked_at DATETIME COMMENT '锁定时间(status=3 时回填)', + locked_by VARCHAR(64) 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_monthly_settlements PRIMARY KEY (id), + CONSTRAINT fk_monthly_settlements_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT uk_monthly_settlements_settlement_month UNIQUE (settlement_month) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='月度结算表(按月一行;通过即锁定,锁定后走调整单)' COLLATE utf8mb4_general_ci; + +CREATE TABLE adjustment_orders ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + order_no VARCHAR(32) NOT NULL COMMENT '调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}', + settlement_month VARCHAR(7) NOT NULL COMMENT '调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)', + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '调整人员ID(personnel.id)', + personnel_name VARCHAR(64) NOT NULL COMMENT '调整人员姓名(冗余固化)', + direction TINYINT NOT NULL COMMENT '方向:1补发 2扣减', + amount NUMERIC(14, 2) NOT NULL COMMENT '调整金额(恒正,方向表达语义)', + reason VARCHAR(255) NOT NULL COMMENT '扣补说明(必填,审批与追溯锚点)', + status TINYINT NOT NULL COMMENT '状态:1待审批 2已生效 3已驳回 4已撤回' DEFAULT '1', + submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)', + submitter_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_adjustment_orders PRIMARY KEY (id), + CONSTRAINT fk_adjustment_orders_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT fk_adjustment_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT uk_adjustment_orders_order_no UNIQUE (order_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)' COLLATE utf8mb4_general_ci; + +CREATE TABLE settlement_items ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + settlement_id BIGINT UNSIGNED NOT NULL COMMENT '月结ID(monthly_settlements.id;头删明细随删)', + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '结算人员ID(personnel.id)', + personnel_name VARCHAR(64) NOT NULL COMMENT '结算人员姓名(冗余固化)', + role_code VARCHAR(32) NOT NULL COMMENT '岗位编码(主岗,聚合锚点)', + calc_type TINYINT NOT NULL COMMENT '核算类型:1单笔回款 2经理团队聚合', + performance_sum NUMERIC(14, 2) NOT NULL COMMENT '绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)', + payable_sum NUMERIC(14, 2) NOT NULL COMMENT '应付绩效合计(可负)', + basic_amount NUMERIC(14, 2) COMMENT '月工资标准(personnel.salary_standard 当月快照)', + salary_mode VARCHAR(16) COMMENT '月工资口径快照:INCLUDE/DEDUCT/EXCLUDE', + net_amount NUMERIC(14, 2) COMMENT '净工资 = 绩效±基本工资(按口径;试用期 0)', + is_probation TINYINT NOT NULL COMMENT '试用期:1绩效照算不发放(net=0,超年累计记 note)0否' DEFAULT '0', + probation_note VARCHAR(255) COMMENT '试用期说明(年累计超阈值部分年底一次性结算)', + snapshot_json JSON COMMENT '个人明细快照(台账行引用列表)', + CONSTRAINT pk_settlement_items PRIMARY KEY (id), + CONSTRAINT fk_settlement_items_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT fk_settlement_items_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE CASCADE +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='月结明细表(按人固化;支持负数工资;锁定后只读)' COLLATE utf8mb4_general_ci; + +CREATE TABLE performance_ledgers ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + receipt_id BIGINT UNSIGNED COMMENT '回款ID(receipts.id;聚合行可为 NULL)', + calc_version TINYINT NOT NULL COMMENT '核算版本(重算+1,旧行 is_active=0)' DEFAULT '1', + is_active TINYINT NOT NULL COMMENT '生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)' DEFAULT '1', + calc_type TINYINT NOT NULL COMMENT '核算类型:1单笔回款 2经理团队聚合(月度)' DEFAULT '1', + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)', + business_no VARCHAR(32) NOT NULL COMMENT '业务编号(冗余固化,列表免联查)', + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属营销人员ID(personnel.id;核算归属锚点)', + personnel_name VARCHAR(64) NOT NULL COMMENT '归属营销人员姓名(冗余固化)', + role_code VARCHAR(32) NOT NULL COMMENT '岗位编码(business_roles.role_code,计提公式锚点)', + customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal/manager_personal/enterprise', + product_category VARCHAR(32) NOT NULL COMMENT '产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)', + ship_date DATE COMMENT '发货日期(回款天数基准)', + receipt_date DATE NOT NULL COMMENT '回款日期(参数版本匹配日,裁决 #11)', + payment_days INTEGER COMMENT '回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)', + rate_text VARCHAR(32) NOT NULL COMMENT '计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)', + converted_profit NUMERIC(14, 2) COMMENT '折算毛利润额(可负;混合业务为各行汇总)', + performance_amount NUMERIC(14, 2) COMMENT '绩效金额(可负,裁决:负绩效照算不归零)', + barter_applied TINYINT NOT NULL COMMENT '以物抵顶折算:1已按 80% 折算 0否' DEFAULT '0', + weight_r INTEGER COMMENT '支付权重 R(回款占销售额百分比向下取整)', + payment_weight NUMERIC(7, 6) COMMENT '支付权重系数 R×R(展示用)', + payable_amount NUMERIC(14, 2) COMMENT '当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)', + settlement_month VARCHAR(7) NOT NULL COMMENT '归属月(回款日期所在月,YYYY-MM;月结与报表维度)', + is_probation TINYINT NOT NULL COMMENT '试用期:1照算不发放 0否' DEFAULT '0', + missing_cost TINYINT NOT NULL COMMENT '成本缺失:1有明细行缺成本(该行毛利按 0 计)0否' DEFAULT '0', + snapshot_json JSON COMMENT '计算快照(所用参数版本与行级折算明细,重算可回溯)', + settlement_id BIGINT UNSIGNED COMMENT '所属月结ID(月结生成时回填;NULL=未入结)', + calc_reason VARCHAR(255) COMMENT '核算/重算原因(重算必填留痕)', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_performance_ledgers PRIMARY KEY (id), + CONSTRAINT fk_performance_ledgers_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT fk_performance_ledgers_receipt_id FOREIGN KEY(receipt_id) REFERENCES receipts (id) ON DELETE RESTRICT, + CONSTRAINT fk_performance_ledgers_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE RESTRICT, + CONSTRAINT uk_performance_ledgers_receipt_id_calc_version UNIQUE (receipt_id, calc_version) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_performance_ledgers_business_id ON performance_ledgers (business_id); + +CREATE INDEX idx_performance_ledgers_month_active ON performance_ledgers (settlement_month, is_active); + +CREATE INDEX idx_performance_ledgers_month_personnel_type ON performance_ledgers (settlement_month, personnel_id, calc_type); + +CREATE INDEX idx_performance_ledgers_personnel_id ON performance_ledgers (personnel_id); + +ALTER TABLE approval_orders MODIFY order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算 3调整单'; + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道', 19, 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (22, 19, '', '2026-01-01', '0', 1); + +UPDATE alembic_version SET version_num='3bca400421c6' WHERE alembic_version.version_num = '79bcc5aa98d4'; + diff --git a/db/migration/V8__seed_replies_and_ledger_uk.sql b/db/migration/V8__seed_replies_and_ledger_uk.sql new file mode 100644 index 0000000..9c3a73d --- /dev/null +++ b/db/migration/V8__seed_replies_and_ledger_uk.sql @@ -0,0 +1,22 @@ +-- Running upgrade 3bca400421c6 -> c7d2e5a81f30 + +UPDATE param_definitions SET default_value = '1' WHERE id IN (17, 18); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (23, 17, '', '2026-10-19', '1', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (24, 18, '', '2026-10-19', '1', 1); + +DELETE FROM param_values WHERE id = 19; + +DELETE FROM param_definitions WHERE id = 16; + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20, 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (25, 20, '', '2026-01-01', '0', 1); + +ALTER TABLE performance_ledgers ADD CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id); + +ALTER TABLE performance_ledgers DROP INDEX uk_performance_ledgers_receipt_id_calc_version; + +UPDATE alembic_version SET version_num='c7d2e5a81f30' WHERE alembic_version.version_num = '3bca400421c6'; + diff --git a/db/migration/V9__add_secondary_allocations_uk.sql b/db/migration/V9__add_secondary_allocations_uk.sql new file mode 100644 index 0000000..eac8f5a --- /dev/null +++ b/db/migration/V9__add_secondary_allocations_uk.sql @@ -0,0 +1,13 @@ +-- Running upgrade c7d2e5a81f30 -> ba0217f5091b + +SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'business_secondary_allocations' AND CONSTRAINT_NAME = 'uk_business_secondary_allocations_business_personnel'); + +SET @ddl := IF(@uk_exists = 0, 'ALTER TABLE business_secondary_allocations ADD CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id)', 'DO 0'); + +PREPARE stmt FROM @ddl; + +EXECUTE stmt; + +DEALLOCATE PREPARE stmt; + +UPDATE alembic_version SET version_num='ba0217f5091b' WHERE alembic_version.version_num = 'c7d2e5a81f30'; diff --git a/db/queries/业务数据.sql b/db/queries/业务数据.sql new file mode 100644 index 0000000..306402a --- /dev/null +++ b/db/queries/业务数据.sql @@ -0,0 +1,115 @@ +-- ============================================================================ +-- 业务数据查询留档(M2 业务数据管理) +-- +-- 设计要点: +-- - businesses 的 total_* 五列为服务层维护的汇总冗余,查询直接读冗余列 +-- 而非实时 JOIN 聚合;本文件同时给出「对账用」的实时聚合口径 +-- - 回款天数 = formula.payment_days(ship_date, receipt_date),即 +-- DATEDIFF(receipt_date, ship_date) + 1(发货日首日计第 1 天,对照表 #1), +-- 应用层以纯函数为准,SQL 版仅供人工对账 +-- - 毛利润率/折算毛利润额不落库:视图按行实时计算(阈值取 M1 参数快照) +-- ============================================================================ + +-- 1. 业务台账(列表页;冗余列直读) +SELECT b.id, + b.business_no, + c.company_name, + c.customer_type, + b.status, + b.business_date, + p.personnel_name AS owner_name, + pa.personnel_name AS assistant_name, + b.total_sale_amount, + b.total_cost_amount, + b.total_ship_amount, + b.total_received_amount, + b.total_freight_amount +FROM businesses b +JOIN customers c ON c.id = b.customer_id +LEFT JOIN personnel p ON p.id = b.owner_personnel_id +LEFT JOIN personnel pa ON pa.id = b.assistant_personnel_id +WHERE (:keyword IS NULL OR b.business_no LIKE CONCAT('%', :keyword, '%') + OR c.company_name LIKE CONCAT('%', :keyword, '%')) + AND (:status IS NULL OR b.status = :status) + AND (:customer_id IS NULL OR b.customer_id = :customer_id) + AND (:owner_personnel_id IS NULL OR b.owner_personnel_id = :owner_personnel_id) +ORDER BY b.id DESC +LIMIT :limit OFFSET :offset; + +-- 2. 汇总冗余对账(五列与明细/发货/回款实时聚合的一致性校验,T11 集成用例同口径) +SELECT b.id, + b.total_sale_amount, + COALESCE((SELECT SUM(i.sale_amount) FROM business_items i WHERE i.business_id = b.id), 0) AS live_sale, + b.total_ship_amount, + COALESCE((SELECT SUM(si.amount) FROM shipments s JOIN shipment_items si ON si.shipment_id = s.id + WHERE s.business_id = b.id), 0) AS live_ship, + b.total_received_amount, + COALESCE((SELECT SUM(r.amount) FROM receipts r WHERE r.business_id = b.id), 0) AS live_received, + b.total_freight_amount, + COALESCE((SELECT SUM(s.freight_amount) FROM shipments s WHERE s.business_id = b.id), 0) AS live_freight +FROM businesses b +WHERE b.total_sale_amount <> COALESCE((SELECT SUM(i.sale_amount) FROM business_items i WHERE i.business_id = b.id), 0) + OR b.total_ship_amount <> COALESCE((SELECT SUM(si.amount) FROM shipments s JOIN shipment_items si ON si.shipment_id = s.id + WHERE s.business_id = b.id), 0) + OR b.total_received_amount <> COALESCE((SELECT SUM(r.amount) FROM receipts r WHERE r.business_id = b.id), 0) + OR b.total_freight_amount <> COALESCE((SELECT SUM(s.freight_amount) FROM shipments s WHERE s.business_id = b.id), 0); + +-- 3. 未指定发货批次的回款(M3 核算前置校验名单;列表页筛选项) +SELECT r.id, r.receipt_batch_no, b.business_no, r.receipt_date, r.amount, r.is_barter +FROM receipts r +JOIN businesses b ON b.id = r.business_id +WHERE r.shipment_id IS NULL + AND r.settlement_status = 0 +ORDER BY r.receipt_date; + +-- 4. 回款天数对账视图(SQL 版仅供人工核对;应用层以 formula.payment_days 为准) +SELECT r.id, + r.receipt_batch_no, + b.business_no, + s.shipment_no, + s.ship_date, + r.receipt_date, + DATEDIFF(r.receipt_date, s.ship_date) + 1 AS payment_days +FROM receipts r +JOIN businesses b ON b.id = r.business_id +LEFT JOIN shipments s ON s.id = r.shipment_id +WHERE r.shipment_id IS NOT NULL +ORDER BY r.receipt_date DESC; + +-- 5. 按产品类别的毛利润汇总(毛利润视图的类别汇总口径;阈值需按 M1 参数快照逐日匹配, +-- 此处仅聚合源数据,折算在应用层以 formula.converted_profit 计算) +SELECT i.product_category, + SUM(i.sale_amount) AS sale_total, + SUM(i.cost_amount) AS cost_total, + SUM(i.sale_amount - COALESCE(i.cost_amount, 0)) AS profit_total +FROM business_items i +WHERE i.business_id = :business_id +GROUP BY i.product_category; + +-- 6. 业务状态流转时间线(详情页;创建首条 old=new=1) +SELECT l.old_status, l.new_status, l.change_reason, l.operator_name, l.create_time +FROM business_status_logs l +WHERE l.business_id = :business_id +ORDER BY l.create_time, l.id; + +-- 7. 发货单与明细(列表 + 行展开) +SELECT s.id, s.shipment_no, b.business_no, s.ship_date, s.freight_amount, s.total_amount +FROM shipments s +JOIN businesses b ON b.id = s.business_id +WHERE (:business_id IS NULL OR s.business_id = :business_id) + AND (:ship_date_from IS NULL OR s.ship_date >= :ship_date_from) + AND (:ship_date_to IS NULL OR s.ship_date <= :ship_date_to) +ORDER BY s.id DESC +LIMIT :limit OFFSET :offset; + +SELECT si.product_name, si.spec_model, si.unit, si.quantity, si.sale_price, si.amount +FROM shipment_items si +WHERE si.shipment_id = :shipment_id +ORDER BY si.id; + +-- 8. 评级等级判定(前端按 sort_order 升序首个 min_score ≤ 总分取等级;SQL 版供后端复用) +SELECT grade_name, min_score, max_score +FROM customer_rating_grades +WHERE min_score <= :total_score +ORDER BY sort_order +LIMIT 1; diff --git a/db/queries/基础数据.sql b/db/queries/基础数据.sql new file mode 100644 index 0000000..aa38ff5 --- /dev/null +++ b/db/queries/基础数据.sql @@ -0,0 +1,98 @@ +-- ============================================================================ +-- 基础数据查询留档(M2 基础数据管理) +-- +-- 设计要点: +-- - 人员团队关系用 manager_id 自引用展开(NULL=经理本人或未分配), +-- M3 经理 12.5% 聚合与 M4 部门汇总均按此关系,无独立团队表 +-- - 归属变更留痕(ownership_change_logs)对客户/业务/人员 ID 为无外键的 +-- 冗余固化:被引用方删除后记录仍须完整可读,join 不到时以固化值为准 +-- - T+ 预留列(source_no 等)当前全部为空,同步模块(M4)上线后启用 +-- ============================================================================ + +-- 1. 人员列表(含岗位名称与直属经理姓名;分页 + 关键字筛选) +SELECT p.id, + p.personnel_name, + p.phone, + p.hire_date, + p.is_probation, + p.salary_standard, + p.status, + br.role_name, + br.role_code, + m.personnel_name AS manager_name +FROM personnel p +JOIN business_roles br ON br.id = p.business_role_id +LEFT JOIN personnel m ON m.id = p.manager_id +WHERE (:keyword IS NULL OR p.personnel_name LIKE CONCAT('%', :keyword, '%')) + AND (:status IS NULL OR p.status = :status) +ORDER BY p.id DESC +LIMIT :limit OFFSET :offset; + +-- 2. 某经理的团队成员(M3 经理 12.5% 聚合的取数口径) +SELECT p.id, p.personnel_name, p.business_role_id, p.status +FROM personnel p +WHERE p.manager_id = :manager_id + AND p.status = 1; + +-- 3. 人员的辐射区域与拓展计划(编辑抽屉回显,随人员整表替换维护) +SELECT region_name, remark +FROM personnel_regions +WHERE personnel_id = :personnel_id +ORDER BY id; + +SELECT plan_period, plan_content, target_desc, source_no +FROM personnel_plans +WHERE personnel_id = :personnel_id +ORDER BY id; + +-- 4. 产品目录(名称/规格关键字 + 类别筛选;名称+规格为组合唯一键) +SELECT id, product_name, spec_model, unit, stock_quantity, sale_price, + product_category, status +FROM products +WHERE (:keyword IS NULL OR product_name LIKE CONCAT('%', :keyword, '%') + OR spec_model LIKE CONCAT('%', :keyword, '%')) + AND (:product_category IS NULL OR product_category = :product_category) + AND (:status IS NULL OR status = :status) +ORDER BY id DESC +LIMIT :limit OFFSET :offset; + +-- 5. 客户列表(公司/负责人关键字 + 分类 + 归属人员筛选) +SELECT c.id, c.company_name, c.owner_name, c.customer_type, c.status, c.owner_personnel_id, + p.personnel_name AS owner_personnel_name +FROM customers c +LEFT JOIN personnel p ON p.id = c.owner_personnel_id +WHERE (:keyword IS NULL OR c.company_name LIKE CONCAT('%', :keyword, '%') + OR c.owner_name LIKE CONCAT('%', :keyword, '%')) + AND (:customer_type IS NULL OR c.customer_type = :customer_type) + AND (:owner_personnel_id IS NULL OR c.owner_personnel_id = :owner_personnel_id) +ORDER BY c.id DESC +LIMIT :limit OFFSET :offset; + +-- 6. 归属变更留痕(客户或业务维度;join 人员出姓名,人员已删时显示固化 ID) +SELECT l.id, + l.owner_type, + l.customer_id, + l.business_id, + l.old_owner_personnel_id, + l.new_owner_personnel_id, + po.personnel_name AS old_owner_name, + pn.personnel_name AS new_owner_name, + l.change_reason, + l.operator_name, + l.create_time +FROM ownership_change_logs l +LEFT JOIN personnel po ON po.id = l.old_owner_personnel_id +LEFT JOIN personnel pn ON pn.id = l.new_owner_personnel_id +WHERE (:customer_id IS NULL OR l.customer_id = :customer_id) + AND (:business_id IS NULL OR l.business_id = :business_id) +ORDER BY l.create_time DESC, l.id DESC +LIMIT :limit OFFSET :offset; + +-- 7. 归属变更留痕(幂等校验辅助:人员删除保护——被归属引用者不可物理删除) +SELECT COUNT(*) AS ref_count +FROM customers c +WHERE c.owner_personnel_id = :personnel_id +UNION ALL +SELECT COUNT(*) +FROM personnel m +WHERE m.manager_id = :personnel_id; diff --git a/db/queries/计提参数.sql b/db/queries/计提参数.sql new file mode 100644 index 0000000..a4df868 --- /dev/null +++ b/db/queries/计提参数.sql @@ -0,0 +1,58 @@ +-- ============================================================================ +-- 计提参数查询留档(M1 参数体系) +-- +-- 时点化取值核心规则:按 (param_id, dimension_value) 分组,取 +-- effective_date <= :biz_date 的 ORDER BY effective_date DESC, id DESC 首条 +-- (同日多次变更取最新一条,不设唯一键以保证完整留痕)。 +-- 参数总量约百行级,全量快照一次取回后在应用层按覆盖链 +-- (角色维度 > 产品类别维度 > 默认行)选择,无需在 SQL 层做维度回退。 +-- ============================================================================ + +-- 1. 全量快照:某业务日期下每个(参数, 维度值)的最新生效值 +-- 窗口函数版(MySQL 8.0+),供 param_repository.resolve_values 使用 +SELECT t.param_code, t.dimension_value, t.value_text, t.effective_date +FROM ( + SELECT pd.param_code, + pv.dimension_value, + pv.value_text, + pv.effective_date, + ROW_NUMBER() OVER ( + PARTITION BY pv.param_id, pv.dimension_value + ORDER BY pv.effective_date DESC, pv.id DESC + ) AS rn + FROM param_values pv + JOIN param_definitions pd ON pd.id = pv.param_id + WHERE pv.effective_date <= :biz_date + AND pd.status = 1 +) t +WHERE t.rn = 1; + +-- 2. 单参数版本历史(变更留痕,供参数历史接口与导出) +SELECT pv.effective_date, + pv.dimension_value, + pv.old_value_text, + pv.value_text, + b.version_no, + b.operator_name, + b.create_time +FROM param_values pv +JOIN param_change_batches b ON b.id = pv.batch_id +WHERE pv.param_id = :param_id +ORDER BY pv.effective_date DESC, pv.id DESC; + +-- 3. 区间变更条目(对比视图辅助:应用层对两日期各执行查询 1 后逐项 diff, +-- 本查询列出区间内实际发生过的变更,供变更记录分页与导出) +SELECT pd.param_code, + pd.param_name, + pv.dimension_value, + pv.old_value_text, + pv.value_text, + pv.effective_date, + b.version_no, + b.operator_name +FROM param_values pv +JOIN param_definitions pd ON pd.id = pv.param_id +JOIN param_change_batches b ON b.id = pv.batch_id +WHERE pv.effective_date > :from_date + AND pv.effective_date <= :to_date +ORDER BY pv.effective_date, pv.id; diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 7cf69c3..3c242ec 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -38,6 +38,8 @@ services: restart: unless-stopped environment: TZ: Asia/Shanghai + # 健康检查脚本在容器内展开(密码不进宿主进程命令行,模式同 mysql) + REDIS_PASSWORD: ${REDIS_PASSWORD} # appendonly 开启 AOF,容器重启后验证码黑名单等数据不丢 command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} volumes: diff --git a/docs/T+对接数据需求清单_2026-10-15.xlsx b/docs/T+对接数据需求清单_2026-10-15.xlsx new file mode 100644 index 0000000..52fafbf Binary files /dev/null and b/docs/T+对接数据需求清单_2026-10-15.xlsx differ diff --git a/docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx b/docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx new file mode 100644 index 0000000..f3337d8 Binary files /dev/null and b/docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx differ diff --git a/docs/design/echarts.min.js b/docs/design/echarts.min.js new file mode 100644 index 0000000..835966f --- /dev/null +++ b/docs/design/echarts.min.js @@ -0,0 +1,45 @@ + +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).echarts={})}(this,(function(t){"use strict"; +/*! ***************************************************************************** + Copyright (c) Microsoft Corporation. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + PERFORMANCE OF THIS SOFTWARE. + ***************************************************************************** */var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},e(t,n)};function n(t,n){if("function"!=typeof n&&null!==n)throw new TypeError("Class extends value "+String(n)+" is not a constructor or null");function i(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(i.prototype=n.prototype,new i)}var i=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},r=new function(){this.browser=new i,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(r.wxa=!0,r.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?r.worker=!0:"undefined"==typeof navigator||0===navigator.userAgent.indexOf("Node.js")?(r.node=!0,r.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11),e.domSupported="undefined"!=typeof document;var s=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in s||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in s)&&!("OTransition"in s),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}(navigator.userAgent,r);var o="sans-serif",a="12px "+o;var s,l,u=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var c=0;c>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return n}(e,a),l=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,u=0;u<4;u++){var h=t[u].getBoundingClientRect(),c=2*u,p=h.left,d=h.top;a.push(p,d),l=l&&o&&p===o[c]&&d===o[c+1],s.push(t[u].offsetLeft,t[u].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?$t(s,a):$t(a,s))}(s,a,o);if(l)return l(t,n,i),!0}return!1}function ee(t){return"CANVAS"===t.nodeName.toUpperCase()}var ne=/([&<>"'])/g,ie={"&":"&","<":"<",">":">",'"':""","'":"'"};function re(t){return null==t?"":(t+"").replace(ne,(function(t,e){return ie[e]}))}var oe=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ae=[],se=r.browser.firefox&&+r.browser.version.split(".")[0]<39;function le(t,e,n,i){return n=n||{},i?ue(t,e,n):se&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):ue(t,e,n),n}function ue(t,e,n){if(r.domSupported&&t.getBoundingClientRect){var i=e.clientX,o=e.clientY;if(ee(t)){var a=t.getBoundingClientRect();return n.zrX=i-a.left,void(n.zrY=o-a.top)}if(te(ae,t,i,o))return n.zrX=ae[0],void(n.zrY=ae[1])}n.zrX=n.zrY=0}function he(t){return t||window.event}function ce(t,e,n){if(null!=(e=he(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&le(t,r,e,n)}else{le(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&oe.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function pe(t,e,n,i){t.addEventListener(e,n,i)}var de=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0};function fe(t){return 2===t.which||3===t.which}var ge=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=ye(r)/ye(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function me(){return[1,0,0,1,0,0]}function xe(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function _e(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function be(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function we(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function Se(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],u=e[5],h=Math.sin(n),c=Math.cos(n);return t[0]=r*c+s*h,t[1]=-r*h+s*c,t[2]=o*c+l*h,t[3]=-o*h+c*l,t[4]=c*(a-i[0])+h*(u-i[1])+i[0],t[5]=c*(u-i[1])-h*(a-i[0])+i[1],t}function Me(t,e,n){var i=n[0],r=n[1];return t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r,t}function Ie(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}function Te(t){var e=[1,0,0,1,0,0];return _e(e,t),e}var Ce=Object.freeze({__proto__:null,create:me,identity:xe,copy:_e,mul:be,translate:we,rotate:Se,scale:Me,invert:Ie,clone:Te}),De=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Ae=Math.min,ke=Math.max,Le=new De,Pe=new De,Oe=new De,Re=new De,Ne=new De,Ee=new De,ze=function(){function t(t,e,n,i){n<0&&(t+=n,n=-n),i<0&&(e+=i,i=-i),this.x=t,this.y=e,this.width=n,this.height=i}return t.prototype.union=function(t){var e=Ae(t.x,this.x),n=Ae(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=ke(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=ke(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return we(r,r,[-e.x,-e.y]),Me(r,r,[n,i]),we(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n){if(!e)return!1;e instanceof t||(e=t.create(e));var i=this,r=i.x,o=i.x+i.width,a=i.y,s=i.y+i.height,l=e.x,u=e.x+e.width,h=e.y,c=e.y+e.height,p=!(of&&(f=x,gf&&(f=_,v=n.x&&t<=n.x+n.width&&e>=n.y&&e<=n.y+n.height},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}Le.x=Oe.x=n.x,Le.y=Re.y=n.y,Pe.x=Re.x=n.x+n.width,Pe.y=Oe.y=n.y+n.height,Le.transform(i),Re.transform(i),Pe.transform(i),Oe.transform(i),e.x=Ae(Le.x,Pe.x,Oe.x,Re.x),e.y=Ae(Le.y,Pe.y,Oe.y,Re.y);var l=ke(Le.x,Pe.x,Oe.x,Re.x),u=ke(Le.y,Pe.y,Oe.y,Re.y);e.width=l-e.x,e.height=u-e.y}else e!==n&&t.copy(e,n)},t}(),Ve="silent";function Be(){de(this.event)}var Fe=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.handler=null,e}return n(e,t),e.prototype.dispose=function(){},e.prototype.setCursor=function(){},e}(jt),Ge=function(t,e){this.x=t,this.y=e},We=["click","dblclick","mousewheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],He=new ze(0,0,0,0),Ye=function(t){function e(e,n,i,r,o){var a=t.call(this)||this;return a._hovered=new Ge(0,0),a.storage=e,a.painter=n,a.painterRoot=r,a._pointerSize=o,i=i||new Fe,a.proxy=null,a.setHandlerProxy(i),a._draggingMgr=new Zt(a),a}return n(e,t),e.prototype.setHandlerProxy=function(t){this.proxy&&this.proxy.dispose(),t&&(E(We,(function(e){t.on&&t.on(e,this[e],this)}),this),t.handler=this),this.proxy=t},e.prototype.mousemove=function(t){var e=t.zrX,n=t.zrY,i=Ze(this,e,n),r=this._hovered,o=r.target;o&&!o.__zr&&(o=(r=this.findHover(r.x,r.y)).target);var a=this._hovered=i?new Ge(e,n):this.findHover(e,n),s=a.target,l=this.proxy;l.setCursor&&l.setCursor(s?s.cursor:"default"),o&&s!==o&&this.dispatchToElement(r,"mouseout",t),this.dispatchToElement(a,"mousemove",t),s&&s!==o&&this.dispatchToElement(a,"mouseover",t)},e.prototype.mouseout=function(t){var e=t.zrEventControl;"only_globalout"!==e&&this.dispatchToElement(this._hovered,"mouseout",t),"no_globalout"!==e&&this.trigger("globalout",{type:"globalout",event:t})},e.prototype.resize=function(){this._hovered=new Ge(0,0)},e.prototype.dispatch=function(t,e){var n=this[t];n&&n.call(this,e)},e.prototype.dispose=function(){this.proxy.dispose(),this.storage=null,this.proxy=null,this.painter=null},e.prototype.setCursorStyle=function(t){var e=this.proxy;e.setCursor&&e.setCursor(t)},e.prototype.dispatchToElement=function(t,e,n){var i=(t=t||{}).target;if(!i||!i.silent){for(var r="on"+e,o=function(t,e,n){return{type:t,event:n,target:e.target,topTarget:e.topTarget,cancelBubble:!1,offsetX:n.zrX,offsetY:n.zrY,gestureEvent:n.gestureEvent,pinchX:n.pinchX,pinchY:n.pinchY,pinchScale:n.pinchScale,wheelDelta:n.zrDelta,zrByTouch:n.zrByTouch,which:n.which,stop:Be}}(e,t,n);i&&(i[r]&&(o.cancelBubble=!!i[r].call(i,o)),i.trigger(e,o),i=i.__hostTarget?i.__hostTarget:i.parent,!o.cancelBubble););o.cancelBubble||(this.trigger(e,o),this.painter&&this.painter.eachOtherLayer&&this.painter.eachOtherLayer((function(t){"function"==typeof t[r]&&t[r].call(t,o),t.trigger&&t.trigger(e,o)})))}},e.prototype.findHover=function(t,e,n){var i=this.storage.getDisplayList(),r=new Ge(t,e);if(Ue(i,r,t,e,n),this._pointerSize&&!r.target){for(var o=[],a=this._pointerSize,s=a/2,l=new ze(t-s,e-s,a,a),u=i.length-1;u>=0;u--){var h=i[u];h===n||h.ignore||h.ignoreCoarsePointer||h.parent&&h.parent.ignoreCoarsePointer||(He.copy(h.getBoundingRect()),h.transform&&He.applyTransform(h.transform),He.intersect(l)&&o.push(h))}if(o.length)for(var c=Math.PI/12,p=2*Math.PI,d=0;d=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=Xe(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==Ve)){e.target=a;break}}}function Ze(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}E(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],(function(t){Ye.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=Ze(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||Vt(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}}));function je(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function qe(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var u=i-s;switch(u){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;u>0;)t[s+u]=t[s+u-1],u--}t[s]=a}}function Ke(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var u=a;a=r-l,l=r-u}for(a++;a>>1);o(t,e[n+h])>0?a=h+1:l=h}return l}function $e(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var u=a;a=r-l,l=r-u}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+h])<0?l=h:a=h+1}return l}function Je(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],u=i[s],h=n[s+1],c=i[s+1];i[s]=u+c,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var p=$e(t[h],t,l,u,0,e);l+=p,0!==(u-=p)&&0!==(c=Ke(t[l+u-1],t,h,c,c-1,e))&&(u<=c?function(n,i,o,s){var l=0;for(l=0;l=7||d>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[d+l]=t[p+l];return void(t[c]=a[h])}var f=r;for(;;){var g=0,y=0,v=!1;do{if(e(a[h],t[u])<0){if(t[c--]=t[u--],g++,y=0,0==--i){v=!0;break}}else if(t[c--]=a[h--],y++,g=0,1==--s){v=!0;break}}while((g|y)=0;l--)t[d+l]=t[p+l];if(0===i){v=!0;break}}if(t[c--]=a[h--],1==--s){v=!0;break}if(0!==(y=s-Ke(t[u],a,0,s,s-1,e))){for(s-=y,d=(c-=y)+1,p=(h-=y)+1,l=0;l=7||y>=7);if(v)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(d=(c-=i)+1,p=(u-=i)+1,l=i-1;l>=0;l--)t[d+l]=t[p+l];t[c]=a[h]}else{if(0===s)throw new Error;for(p=c-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=je(t,n,i,e))s&&(l=s),qe(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var tn=!1;function en(){tn||(tn=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function nn(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var rn=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=nn}return t.prototype.traverse=function(t,e){for(var n=0;n0&&(u.__clipPaths=[]),isNaN(u.z)&&(en(),u.z=0),isNaN(u.z2)&&(en(),u.z2=0),isNaN(u.zlevel)&&(en(),u.zlevel=0),this._displayList[this._displayListLen++]=u}var h=t.getDecalElement&&t.getDecalElement();h&&this._updateAndAddDisplayable(h,e,n);var c=t.getTextGuideLine();c&&this._updateAndAddDisplayable(c,e,n);var p=t.getTextContent();p&&this._updateAndAddDisplayable(p,e,n)}},t.prototype.addRoot=function(t){t.__zr&&t.__zr.storage===this||this._roots.push(t)},t.prototype.delRoot=function(t){if(t instanceof Array)for(var e=0,n=t.length;e=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}(),on=r.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)},an={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-an.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*an.bounceIn(2*t):.5*an.bounceOut(2*t-1)+.5}},sn=Math.pow,ln=Math.sqrt,un=1e-8,hn=1e-4,cn=ln(3),pn=1/3,dn=Mt(),fn=Mt(),gn=Mt();function yn(t){return t>-1e-8&&tun||t<-1e-8}function mn(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function xn(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function _n(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),u=t-r,h=s*s-3*a*l,c=s*l-9*a*u,p=l*l-3*s*u,d=0;if(yn(h)&&yn(c)){if(yn(s))o[0]=0;else(M=-l/s)>=0&&M<=1&&(o[d++]=M)}else{var f=c*c-4*h*p;if(yn(f)){var g=c/h,y=-g/2;(M=-s/a+g)>=0&&M<=1&&(o[d++]=M),y>=0&&y<=1&&(o[d++]=y)}else if(f>0){var v=ln(f),m=h*s+1.5*a*(-c+v),x=h*s+1.5*a*(-c-v);(M=(-s-((m=m<0?-sn(-m,pn):sn(m,pn))+(x=x<0?-sn(-x,pn):sn(x,pn))))/(3*a))>=0&&M<=1&&(o[d++]=M)}else{var _=(2*h*s-3*a*c)/(2*ln(h*h*h)),b=Math.acos(_)/3,w=ln(h),S=Math.cos(b),M=(-s-2*w*S)/(3*a),I=(y=(-s+w*(S+cn*Math.sin(b)))/(3*a),(-s+w*(S-cn*Math.sin(b)))/(3*a));M>=0&&M<=1&&(o[d++]=M),y>=0&&y<=1&&(o[d++]=y),I>=0&&I<=1&&(o[d++]=I)}}return d}function bn(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(yn(a)){if(vn(o))(h=-s/o)>=0&&h<=1&&(r[l++]=h)}else{var u=o*o-4*a*s;if(yn(u))r[0]=-o/(2*a);else if(u>0){var h,c=ln(u),p=(-o-c)/(2*a);(h=(-o+c)/(2*a))>=0&&h<=1&&(r[l++]=h),p>=0&&p<=1&&(r[l++]=p)}}return l}function wn(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,u=(s-a)*r+a,h=(l-s)*r+s,c=(h-u)*r+u;o[0]=t,o[1]=a,o[2]=u,o[3]=c,o[4]=c,o[5]=h,o[6]=l,o[7]=i}function Sn(t,e,n,i,r,o,a,s,l,u,h){var c,p,d,f,g,y=.005,v=1/0;dn[0]=l,dn[1]=u;for(var m=0;m<1;m+=.05)fn[0]=mn(t,n,r,a,m),fn[1]=mn(e,i,o,s,m),(f=Ft(dn,fn))=0&&f=0&&y=1?1:_n(0,i,o,1,t,s)&&mn(0,r,a,1,s[0])}}}var On=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||bt,this.ondestroy=t.ondestroy||bt,this.onrestart=t.onrestart||bt,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=X(t)?t:an[t]||Pn(t)},t}(),Rn=function(t){this.value=t},Nn=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new Rn(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),En=function(){function t(t){this._list=new Nn,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new Rn(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),zn={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function Vn(t){return(t=Math.round(t))<0?0:t>255?255:t}function Bn(t){return t<0?0:t>1?1:t}function Fn(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Vn(parseFloat(e)/100*255):Vn(parseInt(e,10))}function Gn(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Bn(parseFloat(e)/100):Bn(parseFloat(e))}function Wn(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function Hn(t,e,n){return t+(e-t)*n}function Yn(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function Xn(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var Un=new En(20),Zn=null;function jn(t,e){Zn&&Xn(Zn,e),Zn=Un.put(t,Zn||e.slice())}function qn(t,e){if(t){e=e||[];var n=Un.get(t);if(n)return Xn(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in zn)return Xn(e,zn[i]),jn(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(Yn(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),jn(t,e),e):void Yn(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(Yn(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),jn(t,e),e):void Yn(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),u=i.substr(a+1,s-(a+1)).split(","),h=1;switch(l){case"rgba":if(4!==u.length)return 3===u.length?Yn(e,+u[0],+u[1],+u[2],1):Yn(e,0,0,0,1);h=Gn(u.pop());case"rgb":return u.length>=3?(Yn(e,Fn(u[0]),Fn(u[1]),Fn(u[2]),3===u.length?h:Gn(u[3])),jn(t,e),e):void Yn(e,0,0,0,1);case"hsla":return 4!==u.length?void Yn(e,0,0,0,1):(u[3]=Gn(u[3]),Kn(u,e),jn(t,e),e);case"hsl":return 3!==u.length?void Yn(e,0,0,0,1):(Kn(u,e),jn(t,e),e);default:return}}Yn(e,0,0,0,1)}}function Kn(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=Gn(t[1]),r=Gn(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return Yn(e=e||[],Vn(255*Wn(a,o,n+1/3)),Vn(255*Wn(a,o,n)),Vn(255*Wn(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function $n(t,e){var n=qn(t);if(n){for(var i=0;i<3;i++)n[i]=e<0?n[i]*(1-e)|0:(255-n[i])*e+n[i]|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return ri(n,4===n.length?"rgba":"rgb")}}function Jn(t,e,n){if(e&&e.length&&t>=0&&t<=1){n=n||[];var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=e[r],s=e[o],l=i-r;return n[0]=Vn(Hn(a[0],s[0],l)),n[1]=Vn(Hn(a[1],s[1],l)),n[2]=Vn(Hn(a[2],s[2],l)),n[3]=Bn(Hn(a[3],s[3],l)),n}}var Qn=Jn;function ti(t,e,n){if(e&&e.length&&t>=0&&t<=1){var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=qn(e[r]),s=qn(e[o]),l=i-r,u=ri([Vn(Hn(a[0],s[0],l)),Vn(Hn(a[1],s[1],l)),Vn(Hn(a[2],s[2],l)),Bn(Hn(a[3],s[3],l))],"rgba");return n?{color:u,leftIndex:r,rightIndex:o,value:i}:u}}var ei=ti;function ni(t,e,n,i){var r=qn(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,u=(s+a)/2;if(0===l)e=0,n=0;else{n=u<.5?l/(s+a):l/(2-s-a);var h=((s-i)/6+l/2)/l,c=((s-r)/6+l/2)/l,p=((s-o)/6+l/2)/l;i===s?e=p-c:r===s?e=1/3+h-p:o===s&&(e=2/3+c-h),e<0&&(e+=1),e>1&&(e-=1)}var d=[360*e,n,u];return null!=t[3]&&d.push(t[3]),d}}(r),null!=e&&(r[0]=function(t){return(t=Math.round(t))<0?0:t>360?360:t}(e)),null!=n&&(r[1]=Gn(n)),null!=i&&(r[2]=Gn(i)),ri(Kn(r),"rgba")}function ii(t,e){var n=qn(t);if(n&&null!=e)return n[3]=Bn(e),ri(n,"rgba")}function ri(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function oi(t,e){var n=qn(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var ai=new En(100);function si(t){if(U(t)){var e=ai.get(t);return e||(e=$n(t,-.1),ai.put(t,e)),e}if(Q(t)){var n=A({},t);return n.colorStops=z(t.colorStops,(function(t){return{offset:t.offset,color:$n(t.color,-.1)}})),n}return t}var li=Object.freeze({__proto__:null,parse:qn,lift:$n,toHex:function(t){var e=qn(t);if(e)return((1<<24)+(e[0]<<16)+(e[1]<<8)+ +e[2]).toString(16).slice(1)},fastLerp:Jn,fastMapToColor:Qn,lerp:ti,mapToColor:ei,modifyHSL:ni,modifyAlpha:ii,stringify:ri,lum:oi,random:function(){return ri([Math.round(255*Math.random()),Math.round(255*Math.random()),Math.round(255*Math.random())],"rgb")},liftColor:si}),ui=Math.round;function hi(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=qn(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var ci=1e-4;function pi(t){return t-1e-4}function di(t){return ui(1e3*t)/1e3}function fi(t){return ui(1e4*t)/1e4}var gi={left:"start",right:"end",center:"middle",middle:"middle"};function yi(t){return t&&!!t.image}function vi(t){return yi(t)||function(t){return t&&!!t.svgElement}(t)}function mi(t){return"linear"===t.type}function xi(t){return"radial"===t.type}function _i(t){return t&&("linear"===t.type||"radial"===t.type)}function bi(t){return"url(#"+t+")"}function wi(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function Si(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*wt,r=rt(t.scaleX,1),o=rt(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+ui(a*wt)+"deg, "+ui(s*wt)+"deg)"),l.join(" ")}var Mi=r.hasGlobalWindow&&X(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},Ii=Array.prototype.slice;function Ti(t,e,n){return(e-t)*n+t}function Ci(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(N(e)){var l=function(t){return N(t&&t[0])?2:1}(e);a=l,(1===l&&!j(e[0])||2===l&&!j(e[0][0]))&&(o=!0)}else if(j(e)&&!nt(e))a=0;else if(U(e))if(isNaN(+e)){var u=qn(e);u&&(s=u,a=3)}else a=0;else if(Q(e)){var h=A({},s);h.colorStops=z(e.colorStops,(function(t){return{offset:t.offset,color:qn(t.color)}})),mi(e)?a=4:xi(e)&&(a=5),s=h}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var c={time:t,value:s,rawValue:e,percent:0};return n&&(c.easing=n,c.easingFunc=X(n)?n:an[n]||Pn(n)),i.push(c),c},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort((function(t,e){return t.time-e.time}));for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=Ni(i),l=Ri(i),u=0;u=0&&!(l[n].percent<=e);n--);n=d(n,u-2)}else{for(n=p;ne);n++);n=d(n-1,u-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:d((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var y=o?this._additiveValue:c?Ei:t[h];if(!Ni(s)&&!c||y||(y=this._additiveValue=[]),this.discrete)t[h]=g<1?i.rawValue:r.rawValue;else if(Ni(s))1===s?Ci(y,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,Pi(l),i),this._trackKeys.push(a)}s.addKeyframe(t,Pi(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function Bi(){return(new Date).getTime()}var Fi,Gi,Wi=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return n(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=Bi()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,on((function e(){t._running&&(on(e),!t._paused&&t.update())}))},e.prototype.start=function(){this._running||(this._time=Bi(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=Bi(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=Bi()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new Vi(t,e.loop);return this.addAnimator(n),n},e}(jt),Hi=r.domSupported,Yi=(Gi={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:Fi=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:z(Fi,(function(t){var e=t.replace("mouse","pointer");return Gi.hasOwnProperty(e)?e:t}))}),Xi=["mousemove","mouseup"],Ui=["pointermove","pointerup"],Zi=!1;function ji(t){var e=t.pointerType;return"pen"===e||"touch"===e}function qi(t){t&&(t.zrByTouch=!0)}function Ki(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var $i=function(t,e){this.stopPropagation=bt,this.stopImmediatePropagation=bt,this.preventDefault=bt,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},Ji={mousedown:function(t){t=ce(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=ce(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=ce(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){Ki(this,(t=ce(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){Zi=!0,t=ce(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){Zi||(t=ce(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){qi(t=ce(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),Ji.mousemove.call(this,t),Ji.mousedown.call(this,t)},touchmove:function(t){qi(t=ce(this.dom,t)),this.handler.processGesture(t,"change"),Ji.mousemove.call(this,t)},touchend:function(t){qi(t=ce(this.dom,t)),this.handler.processGesture(t,"end"),Ji.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&Ji.click.call(this,t)},pointerdown:function(t){Ji.mousedown.call(this,t)},pointermove:function(t){ji(t)||Ji.mousemove.call(this,t)},pointerup:function(t){Ji.mouseup.call(this,t)},pointerout:function(t){ji(t)||Ji.mouseout.call(this,t)}};E(["click","dblclick","contextmenu"],(function(t){Ji[t]=function(e){e=ce(this.dom,e),this.trigger(t,e)}}));var Qi={pointermove:function(t){ji(t)||Qi.mousemove.call(this,t)},pointerup:function(t){Qi.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function tr(t,e){var n=e.domHandlers;r.pointerEventsSupported?E(Yi.pointer,(function(i){nr(e,i,(function(e){n[i].call(t,e)}))})):(r.touchEventsSupported&&E(Yi.touch,(function(i){nr(e,i,(function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout((function(){t.touching=!1,t.touchTimer=null}),700)}(e)}))})),E(Yi.mouse,(function(i){nr(e,i,(function(r){r=he(r),e.touching||n[i].call(t,r)}))})))}function er(t,e){function n(n){nr(e,n,(function(i){i=he(i),Ki(t,i.target)||(i=function(t,e){return ce(t.dom,new $i(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))}),{capture:!0})}r.pointerEventsSupported?E(Ui,n):r.touchEventsSupported||E(Xi,n)}function nr(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,pe(t.domTarget,e,n,i)}function ir(t){var e,n,i,r,o=t.mounted;for(var a in o)o.hasOwnProperty(a)&&(e=t.domTarget,n=a,i=o[a],r=t.listenerOpts[a],e.removeEventListener(n,i,r));t.mounted={}}var rr=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},or=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new rr(e,Ji),Hi&&(i._globalHandlerScope=new rr(document,Qi)),tr(i,i._localHandlerScope),i}return n(e,t),e.prototype.dispose=function(){ir(this._localHandlerScope),Hi&&ir(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,Hi&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?er(this,e):ir(e)}},e}(jt),ar=1;r.hasGlobalWindow&&(ar=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var sr=ar,lr="#333",ur="#ccc",hr=xe,cr=5e-5;function pr(t){return t>cr||t<-5e-5}var dr=[],fr=[],gr=[1,0,0,1,0,0],yr=Math.abs,vr=function(){function t(){}return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return pr(this.rotation)||pr(this.x)||pr(this.y)||pr(this.scaleX-1)||pr(this.scaleY-1)||pr(this.skewX)||pr(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):hr(n),t&&(e?be(n,t,n):_e(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(hr(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(dr);var n=dr[0]<0?-1:1,i=dr[1]<0?-1:1,r=((dr[0]-n)*e+n)/dr[0]||0,o=((dr[1]-i)*e+i)/dr[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],Ie(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],be(fr,t.invTransform,e),e=fr);var n=this.originX,i=this.originY;(n||i)&&(gr[4]=n,gr[5]=i,be(fr,e,gr),fr[4]-=n,fr[5]-=i,e=fr),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&Wt(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&Wt(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&yr(t[0]-1)>1e-10&&yr(t[3]-1)>1e-10?Math.sqrt(yr(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){xr(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,u=t.x,h=t.y,c=t.skewX?Math.tan(t.skewX):0,p=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var d=n+a,f=i+s;e[4]=-d*r-c*f*o,e[5]=-f*o-p*d*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=p*r,e[2]=c*o,l&&Se(e,e,l),e[4]+=n+u,e[5]+=i+h,e},t.initDefaultProps=function(){var e=t.prototype;e.scaleX=e.scaleY=e.globalScaleRatio=1,e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0}(),t}(),mr=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function xr(t,e){for(var n=0;n=0?parseFloat(t)/100*e:parseFloat(t):t}function Dr(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,u=n.y,h="left",c="top";if(i instanceof Array)l+=Cr(i[0],n.width),u+=Cr(i[1],n.height),h=null,c=null;else switch(i){case"left":l-=r,u+=s,h="right",c="middle";break;case"right":l+=r+a,u+=s,c="middle";break;case"top":l+=a/2,u-=r,h="center",c="bottom";break;case"bottom":l+=a/2,u+=o+r,h="center";break;case"inside":l+=a/2,u+=s,h="center",c="middle";break;case"insideLeft":l+=r,u+=s,c="middle";break;case"insideRight":l+=a-r,u+=s,h="right",c="middle";break;case"insideTop":l+=a/2,u+=r,h="center";break;case"insideBottom":l+=a/2,u+=o-r,h="center",c="bottom";break;case"insideTopLeft":l+=r,u+=r;break;case"insideTopRight":l+=a-r,u+=r,h="right";break;case"insideBottomLeft":l+=r,u+=o-r,c="bottom";break;case"insideBottomRight":l+=a-r,u+=o-r,h="right",c="bottom"}return(t=t||{}).x=l,t.y=u,t.align=h,t.verticalAlign=c,t}var Ar="__zr_normal__",kr=mr.concat(["ignore"]),Lr=V(mr,(function(t,e){return t[e]=!0,t}),{ignore:!1}),Pr={},Or=new ze(0,0,0,0),Rr=function(){function t(t){this.id=M(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;if(r.copyTransform(e),null!=n.position){var u=Or;n.layoutRect?u.copy(n.layoutRect):u.copy(this.getBoundingRect()),i||u.applyTransform(this.transform),this.calculateTextPosition?this.calculateTextPosition(Pr,n,u):Dr(Pr,n,u),r.x=Pr.x,r.y=Pr.y,o=Pr.align,a=Pr.verticalAlign;var h=n.origin;if(h&&null!=n.rotation){var c=void 0,p=void 0;"center"===h?(c=.5*u.width,p=.5*u.height):(c=Cr(h[0],u.width),p=Cr(h[1],u.height)),l=!0,r.originX=-r.x+c+(i?0:u.x),r.originY=-r.y+p+(i?0:u.y)}}null!=n.rotation&&(r.rotation=n.rotation);var d=n.offset;d&&(r.x+=d[0],r.y+=d[1],l||(r.originX=-d[0],r.originY=-d[1]));var f=null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside,g=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={}),y=void 0,v=void 0,m=void 0;f&&this.canBeInsideText()?(y=n.insideFill,v=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=v&&"auto"!==v||(v=this.getInsideTextStroke(y),m=!0)):(y=n.outsideFill,v=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=v&&"auto"!==v||(v=this.getOutsideStroke(y),m=!0)),(y=y||"#000")===g.fill&&v===g.stroke&&m===g.autoStroke&&o===g.align&&a===g.verticalAlign||(s=!0,g.fill=y,g.stroke=v,g.autoStroke=m,g.align=o,g.verticalAlign=a,e.setDefaultTextStyle(g)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?ur:lr},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&qn(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,ri(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},A(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(q(t))for(var n=G(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(Ar,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===Ar;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(P(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var u=this._textContent,h=this._textGuide;return u&&u.useState(t,e,n,l),h&&h.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}I("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,d);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,c),g&&g.useStates(t,e,c),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!c&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this.silent,e=this.parent;!t&&e;){if(e.silent){t=!0;break}e=e.parent}return t},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=P(i,t),o=P(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)})),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during((function(t,e){n.during(e)}));for(var p=0;p0||r.force&&!a.length){var w,S=void 0,M=void 0,I=void 0;if(s){M={},p&&(S={});for(_=0;_=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=P(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=P(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;n0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*u+a}function $r(t,e){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return U(t)?(n=t,n.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e:parseFloat(t):null==t?NaN:+t;var n}function Jr(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function Qr(t){return t.sort((function(t,e){return t-e})),t}function to(t){if(t=+t,isNaN(t))return 0;if(t>1e-14)for(var e=1,n=0;n<15;n++,e*=10)if(Math.round(t*e)/e===t)return n;return eo(t)}function eo(t){var e=t.toString().toLowerCase(),n=e.indexOf("e"),i=n>0?+e.slice(n+1):0,r=n>0?n:e.length,o=e.indexOf("."),a=o<0?0:r-1-o;return Math.max(0,a-i)}function no(t,e){var n=Math.log,i=Math.LN10,r=Math.floor(n(t[1]-t[0])/i),o=Math.round(n(Math.abs(e[1]-e[0]))/i),a=Math.min(Math.max(-r+o,0),20);return isFinite(a)?a:20}function io(t,e){var n=V(t,(function(t,e){return t+(isNaN(e)?0:e)}),0);if(0===n)return[];for(var i=Math.pow(10,e),r=z(t,(function(t){return(isNaN(t)?0:t)/n*i*100})),o=100*i,a=z(r,(function(t){return Math.floor(t)})),s=V(a,(function(t,e){return t+e}),0),l=z(r,(function(t,e){return t-a[e]}));su&&(u=l[c],h=c);++a[h],l[h]=0,++s}return z(a,(function(t){return t/i}))}function ro(t,e){var n=Math.max(to(t),to(e)),i=t+e;return n>20?i:Jr(i,n)}var oo=9007199254740991;function ao(t){var e=2*Math.PI;return(t%e+e)%e}function so(t){return t>-1e-4&&t=10&&e++,e}function po(t,e){var n=co(t),i=Math.pow(10,n),r=t/i;return t=(e?r<1.5?1:r<2.5?2:r<4?3:r<7?5:10:r<1?1:r<2?2:r<3?3:r<5?5:10)*i,n>=-20?+t.toFixed(n<0?-n:0):t}function fo(t,e){var n=(t.length-1)*e+1,i=Math.floor(n),r=+t[i-1],o=n-i;return o?r+o*(t[i]-r):r}function go(t){t.sort((function(t,e){return s(t,e,0)?-1:1}));for(var e=-1/0,n=1,i=0;i=0||r&&P(r,s)<0)){var l=n.getShallow(s,e);null!=l&&(o[t[a][0]]=l)}}return o}}var ra=ia([["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]]),oa=function(){function t(){}return t.prototype.getAreaStyle=function(t,e){return ra(this,t,e)},t}(),aa=new En(50);function sa(t){if("string"==typeof t){var e=aa.get(t);return e&&e.image}return t}function la(t,e,n,i,r){if(t){if("string"==typeof t){if(e&&e.__zrImageSrc===t||!n)return e;var o=aa.get(t),a={hostEl:n,cb:i,cbPayload:r};return o?!ha(e=o.image)&&o.pending.push(a):((e=h.loadImage(t,ua,ua)).__zrImageSrc=t,aa.put(t,e.__cachedImgObj={image:e,pending:[a]})),e}return t}return e}function ua(){var t=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var e=0;e=a;l++)s-=a;var u=br(n,e);return u>s&&(n="",u=0),s=t-u,r.ellipsis=n,r.ellipsisWidth=u,r.contentWidth=s,r.containerWidth=t,r}function fa(t,e){var n=e.containerWidth,i=e.font,r=e.contentWidth;if(!n)return"";var o=br(t,i);if(o<=n)return t;for(var a=0;;a++){if(o<=r||a>=e.maxIterations){t+=e.ellipsis;break}var s=0===a?ga(t,r,e.ascCharWidth,e.cnCharWidth):o>0?Math.floor(t.length*r/o):0;o=br(t=t.substr(0,s),i)}return""===t&&(t=e.placeholder),t}function ga(t,e,n,i){for(var r=0,o=0,a=t.length;o0&&f+i.accumWidth>i.width&&(o=e.split("\n"),c=!0),i.accumWidth=f}else{var g=wa(e,h,i.width,i.breakAll,i.accumWidth);i.accumWidth=g.accumWidth+d,a=g.linesWidths,o=g.lines}}else o=e.split("\n");for(var y=0;y=32&&e<=591||e>=880&&e<=4351||e>=4608&&e<=5119||e>=7680&&e<=8303}(t)||!!_a[t]}function wa(t,e,n,i,r){for(var o=[],a=[],s="",l="",u=0,h=0,c=0;cn:r+h+d>n)?h?(s||l)&&(f?(s||(s=l,l="",h=u=0),o.push(s),a.push(h-u),l+=p,s="",h=u+=d):(l&&(s+=l,l="",u=0),o.push(s),a.push(h),s=p,h=d)):f?(o.push(l),a.push(u),l=p,u=d):(o.push(p),a.push(d)):(h+=d,f?(l+=p,u+=d):(l&&(s+=l,l="",u=0),s+=p))}else l&&(s+=l,h+=u),o.push(s),a.push(h),s="",l="",u=0,h=0}return o.length||s||(s=t,l="",u=0),l&&(s+=l),s&&(o.push(s),a.push(h)),1===o.length&&(h+=r),{accumWidth:h,lines:o,linesWidths:a}}var Sa="__zr_style_"+Math.round(10*Math.random()),Ma={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},Ia={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};Ma[Sa]=!0;var Ta=["z","z2","invisible"],Ca=["invisible"],Da=function(t){function e(e){return t.call(this,e)||this}var i;return n(e,t),e.prototype._init=function(e){for(var n=G(e),i=0;i1e-4)return s[0]=t-n,s[1]=e-i,l[0]=t+n,void(l[1]=e+i);if(Ea[0]=Ra(r)*n+t,Ea[1]=Oa(r)*i+e,za[0]=Ra(o)*n+t,za[1]=Oa(o)*i+e,u(s,Ea,za),h(l,Ea,za),(r%=Na)<0&&(r+=Na),(o%=Na)<0&&(o+=Na),r>o&&!a?o+=Na:rr&&(Va[0]=Ra(d)*n+t,Va[1]=Oa(d)*i+e,u(s,Va,s),h(l,Va,l))}var Ua={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},Za=[],ja=[],qa=[],Ka=[],$a=[],Ja=[],Qa=Math.min,ts=Math.max,es=Math.cos,ns=Math.sin,is=Math.abs,rs=Math.PI,os=2*rs,as="undefined"!=typeof Float32Array,ss=[];function ls(t){return Math.round(t/rs*1e8)/1e8%2*rs}function us(t,e){var n=ls(t[0]);n<0&&(n+=os);var i=n-t[0],r=t[1];r+=i,!e&&r-n>=os?r=n+os:e&&n-r>=os?r=n-os:!e&&n>r?r=n+(os-ls(n-r)):e&&n0&&(this._ux=is(n/sr/t)||0,this._uy=is(n/sr/e)||0)},t.prototype.setDPR=function(t){this.dpr=t},t.prototype.setContext=function(t){this._ctx=t},t.prototype.getContext=function(){return this._ctx},t.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},t.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},t.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(Ua.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},t.prototype.lineTo=function(t,e){var n=is(t-this._xi),i=is(e-this._yi),r=n>this._ux||i>this._uy;if(this.addData(Ua.L,t,e),this._ctx&&r&&this._ctx.lineTo(t,e),r)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=n*n+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){return this._drawPendingPt(),this.addData(Ua.C,t,e,n,i,r,o),this._ctx&&this._ctx.bezierCurveTo(t,e,n,i,r,o),this._xi=r,this._yi=o,this},t.prototype.quadraticCurveTo=function(t,e,n,i){return this._drawPendingPt(),this.addData(Ua.Q,t,e,n,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,n,i),this._xi=n,this._yi=i,this},t.prototype.arc=function(t,e,n,i,r,o){this._drawPendingPt(),ss[0]=i,ss[1]=r,us(ss,o),i=ss[0];var a=(r=ss[1])-i;return this.addData(Ua.A,t,e,n,n,i,a,0,o?0:1),this._ctx&&this._ctx.arc(t,e,n,i,r,o),this._xi=es(r)*n+t,this._yi=ns(r)*n+e,this},t.prototype.arcTo=function(t,e,n,i,r){return this._drawPendingPt(),this._ctx&&this._ctx.arcTo(t,e,n,i,r),this},t.prototype.rect=function(t,e,n,i){return this._drawPendingPt(),this._ctx&&this._ctx.rect(t,e,n,i),this.addData(Ua.R,t,e,n,i),this},t.prototype.closePath=function(){this._drawPendingPt(),this.addData(Ua.Z);var t=this._ctx,e=this._x0,n=this._y0;return t&&t.closePath(),this._xi=e,this._yi=n,this},t.prototype.fill=function(t){t&&t.fill(),this.toStatic()},t.prototype.stroke=function(t){t&&t.stroke(),this.toStatic()},t.prototype.len=function(){return this._len},t.prototype.setData=function(t){var e=t.length;this.data&&this.data.length===e||!as||(this.data=new Float32Array(e));for(var n=0;nu.length&&(this._expandData(),u=this.data);for(var h=0;h0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},t.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},t.prototype.getBoundingRect=function(){qa[0]=qa[1]=$a[0]=$a[1]=Number.MAX_VALUE,Ka[0]=Ka[1]=Ja[0]=Ja[1]=-Number.MAX_VALUE;var t,e=this.data,n=0,i=0,r=0,o=0;for(t=0;tn||is(y)>i||c===e-1)&&(f=Math.sqrt(A*A+y*y),r=g,o=x);break;case Ua.C:var v=t[c++],m=t[c++],x=(g=t[c++],t[c++]),_=t[c++],b=t[c++];f=Mn(r,o,v,m,g,x,_,b,10),r=_,o=b;break;case Ua.Q:f=kn(r,o,v=t[c++],m=t[c++],g=t[c++],x=t[c++],10),r=g,o=x;break;case Ua.A:var w=t[c++],S=t[c++],M=t[c++],I=t[c++],T=t[c++],C=t[c++],D=C+T;c+=1,d&&(a=es(T)*M+w,s=ns(T)*I+S),f=ts(M,I)*Qa(os,Math.abs(C)),r=es(D)*M+w,o=ns(D)*I+S;break;case Ua.R:a=r=t[c++],s=o=t[c++],f=2*t[c++]+2*t[c++];break;case Ua.Z:var A=a-r;y=s-o;f=Math.sqrt(A*A+y*y),r=a,o=s}f>=0&&(l[h++]=f,u+=f)}return this._pathLen=u,u},t.prototype.rebuildPath=function(t,e){var n,i,r,o,a,s,l,u,h,c,p=this.data,d=this._ux,f=this._uy,g=this._len,y=e<1,v=0,m=0,x=0;if(!y||(this._pathSegLen||this._calculateLength(),l=this._pathSegLen,u=e*this._pathLen))t:for(var _=0;_0&&(t.lineTo(h,c),x=0),b){case Ua.M:n=r=p[_++],i=o=p[_++],t.moveTo(r,o);break;case Ua.L:a=p[_++],s=p[_++];var S=is(a-r),M=is(s-o);if(S>d||M>f){if(y){if(v+(j=l[m++])>u){var I=(u-v)/j;t.lineTo(r*(1-I)+a*I,o*(1-I)+s*I);break t}v+=j}t.lineTo(a,s),r=a,o=s,x=0}else{var T=S*S+M*M;T>x&&(h=a,c=s,x=T)}break;case Ua.C:var C=p[_++],D=p[_++],A=p[_++],k=p[_++],L=p[_++],P=p[_++];if(y){if(v+(j=l[m++])>u){wn(r,C,A,L,I=(u-v)/j,Za),wn(o,D,k,P,I,ja),t.bezierCurveTo(Za[1],ja[1],Za[2],ja[2],Za[3],ja[3]);break t}v+=j}t.bezierCurveTo(C,D,A,k,L,P),r=L,o=P;break;case Ua.Q:C=p[_++],D=p[_++],A=p[_++],k=p[_++];if(y){if(v+(j=l[m++])>u){Dn(r,C,A,I=(u-v)/j,Za),Dn(o,D,k,I,ja),t.quadraticCurveTo(Za[1],ja[1],Za[2],ja[2]);break t}v+=j}t.quadraticCurveTo(C,D,A,k),r=A,o=k;break;case Ua.A:var O=p[_++],R=p[_++],N=p[_++],E=p[_++],z=p[_++],V=p[_++],B=p[_++],F=!p[_++],G=N>E?N:E,W=is(N-E)>.001,H=z+V,Y=!1;if(y)v+(j=l[m++])>u&&(H=z+V*(u-v)/j,Y=!0),v+=j;if(W&&t.ellipse?t.ellipse(O,R,N,E,B,z,H,F):t.arc(O,R,G,z,H,F),Y)break t;w&&(n=es(z)*N+O,i=ns(z)*E+R),r=es(H)*N+O,o=ns(H)*E+R;break;case Ua.R:n=r=p[_],i=o=p[_+1],a=p[_++],s=p[_++];var X=p[_++],U=p[_++];if(y){if(v+(j=l[m++])>u){var Z=u-v;t.moveTo(a,s),t.lineTo(a+Qa(Z,X),s),(Z-=X)>0&&t.lineTo(a+X,s+Qa(Z,U)),(Z-=U)>0&&t.lineTo(a+ts(X-Z,0),s+U),(Z-=X)>0&&t.lineTo(a,s+ts(U-Z,0));break t}v+=j}t.rect(a,s,X,U);break;case Ua.Z:if(y){var j;if(v+(j=l[m++])>u){I=(u-v)/j;t.lineTo(r*(1-I)+n*I,o*(1-I)+i*I);break t}v+=j}t.closePath(),r=n,o=i}}},t.prototype.clone=function(){var e=new t,n=this.data;return e.data=n.slice?n.slice():Array.prototype.slice.call(n),e._len=this._len,e},t.CMD=Ua,t.initDefaultProps=function(){var e=t.prototype;e._saveData=!0,e._ux=0,e._uy=0,e._pendingPtDist=0,e._version=0}(),t}();function cs(t,e,n,i,r,o,a){if(0===r)return!1;var s=r,l=0;if(a>e+s&&a>i+s||at+s&&o>n+s||oe+c&&h>i+c&&h>o+c&&h>s+c||ht+c&&u>n+c&&u>r+c&&u>a+c||ue+u&&l>i+u&&l>o+u||lt+u&&s>n+u&&s>r+u||sn||h+ur&&(r+=ys);var p=Math.atan2(l,s);return p<0&&(p+=ys),p>=i&&p<=r||p+ys>=i&&p+ys<=r}function ms(t,e,n,i,r,o){if(o>e&&o>i||or?s:0}var xs=hs.CMD,_s=2*Math.PI;var bs=[-1,-1,-1],ws=[-1,-1];function Ss(t,e,n,i,r,o,a,s,l,u){if(u>e&&u>i&&u>o&&u>s||u1&&(h=void 0,h=ws[0],ws[0]=ws[1],ws[1]=h),f=mn(e,i,o,s,ws[0]),d>1&&(g=mn(e,i,o,s,ws[1]))),2===d?ve&&s>i&&s>o||s=0&&h<=1&&(r[l++]=h);else{var u=a*a-4*o*s;if(yn(u))(h=-a/(2*o))>=0&&h<=1&&(r[l++]=h);else if(u>0){var h,c=ln(u),p=(-a-c)/(2*o);(h=(-a+c)/(2*o))>=0&&h<=1&&(r[l++]=h),p>=0&&p<=1&&(r[l++]=p)}}return l}(e,i,o,s,bs);if(0===l)return 0;var u=Cn(e,i,o);if(u>=0&&u<=1){for(var h=0,c=In(e,i,o,u),p=0;pn||s<-n)return 0;var l=Math.sqrt(n*n-s*s);bs[0]=-l,bs[1]=l;var u=Math.abs(i-r);if(u<1e-4)return 0;if(u>=_s-1e-4){i=0,r=_s;var h=o?1:-1;return a>=bs[0]+t&&a<=bs[1]+t?h:0}if(i>r){var c=i;i=r,r=c}i<0&&(i+=_s,r+=_s);for(var p=0,d=0;d<2;d++){var f=bs[d];if(f+t>a){var g=Math.atan2(s,f);h=o?1:-1;g<0&&(g=_s+g),(g>=i&&g<=r||g+_s>=i&&g+_s<=r)&&(g>Math.PI/2&&g<1.5*Math.PI&&(h=-h),p+=h)}}return p}function Ts(t,e,n,i,r){for(var o,a,s,l,u=t.data,h=t.len(),c=0,p=0,d=0,f=0,g=0,y=0;y1&&(n||(c+=ms(p,d,f,g,i,r))),m&&(f=p=u[y],g=d=u[y+1]),v){case xs.M:p=f=u[y++],d=g=u[y++];break;case xs.L:if(n){if(cs(p,d,u[y],u[y+1],e,i,r))return!0}else c+=ms(p,d,u[y],u[y+1],i,r)||0;p=u[y++],d=u[y++];break;case xs.C:if(n){if(ps(p,d,u[y++],u[y++],u[y++],u[y++],u[y],u[y+1],e,i,r))return!0}else c+=Ss(p,d,u[y++],u[y++],u[y++],u[y++],u[y],u[y+1],i,r)||0;p=u[y++],d=u[y++];break;case xs.Q:if(n){if(ds(p,d,u[y++],u[y++],u[y],u[y+1],e,i,r))return!0}else c+=Ms(p,d,u[y++],u[y++],u[y],u[y+1],i,r)||0;p=u[y++],d=u[y++];break;case xs.A:var x=u[y++],_=u[y++],b=u[y++],w=u[y++],S=u[y++],M=u[y++];y+=1;var I=!!(1-u[y++]);o=Math.cos(S)*b+x,a=Math.sin(S)*w+_,m?(f=o,g=a):c+=ms(p,d,o,a,i,r);var T=(i-x)*w/b+x;if(n){if(vs(x,_,w,S,S+M,I,e,T,r))return!0}else c+=Is(x,_,w,S,S+M,I,T,r);p=Math.cos(S+M)*b+x,d=Math.sin(S+M)*w+_;break;case xs.R:if(f=p=u[y++],g=d=u[y++],o=f+u[y++],a=g+u[y++],n){if(cs(f,g,o,g,e,i,r)||cs(o,g,o,a,e,i,r)||cs(o,a,f,a,e,i,r)||cs(f,a,f,g,e,i,r))return!0}else c+=ms(o,g,o,a,i,r),c+=ms(f,a,f,g,i,r);break;case xs.Z:if(n){if(cs(p,d,f,g,e,i,r))return!0}else c+=ms(p,d,f,g,i,r);p=f,d=g}}return n||(s=d,l=g,Math.abs(s-l)<1e-4)||(c+=ms(p,d,f,g,i,r)||0),0!==c}var Cs=k({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},Ma),Ds={style:k({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},Ia.style)},As=mr.concat(["invisible","culling","z","z2","zlevel","parent"]),ks=function(t){function e(e){return t.call(this,e)||this}var i;return n(e,t),e.prototype.update=function(){var n=this;t.prototype.update.call(this);var i=this.style;if(i.decal){var r=this._decalEl=this._decalEl||new e;r.buildPath===e.prototype.buildPath&&(r.buildPath=function(t){n.buildPath(t,n.shape)}),r.silent=!0;var o=r.style;for(var a in i)o[a]!==i[a]&&(o[a]=i[a]);o.fill=i.fill?i.decal:null,o.decal=null,o.shadowColor=null,i.strokeFirst&&(o.stroke=null);for(var s=0;s.5?lr:e>.2?"#eee":ur}if(t)return ur}return lr},e.prototype.getInsideTextStroke=function(t){var e=this.style.fill;if(U(e)){var n=this.__zr;if(!(!n||!n.isDarkMode())===oi(t,0)<.4)return e}},e.prototype.buildPath=function(t,e,n){},e.prototype.pathUpdated=function(){this.__dirty&=-5},e.prototype.getUpdatedPathProxy=function(t){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,t),this.path},e.prototype.createPathProxy=function(){this.path=new hs(!1)},e.prototype.hasStroke=function(){var t=this.style,e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.getBoundingRect=function(){var t=this._rect,e=this.style,n=!t;if(n){var i=!1;this.path||(i=!0,this.createPathProxy());var r=this.path;(i||4&this.__dirty)&&(r.beginPath(),this.buildPath(r,this.shape,!1),this.pathUpdated()),t=r.getBoundingRect()}if(this._rect=t,this.hasStroke()&&this.path&&this.path.len()>0){var o=this._rectStroke||(this._rectStroke=t.clone());if(this.__dirty||n){o.copy(t);var a=e.strokeNoScale?this.getLineScale():1,s=e.lineWidth;if(!this.hasFill()){var l=this.strokeContainThreshold;s=Math.max(s,null==l?4:l)}a>1e-10&&(o.width+=s/a,o.height+=s/a,o.x-=s/a/2,o.y-=s/a/2)}return o}return t},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect(),r=this.style;if(t=n[0],e=n[1],i.contain(t,e)){var o=this.path;if(this.hasStroke()){var a=r.lineWidth,s=r.strokeNoScale?this.getLineScale():1;if(s>1e-10&&(this.hasFill()||(a=Math.max(a,this.strokeContainThreshold)),function(t,e,n,i){return Ts(t,e,!0,n,i)}(o,a/s,t,e)))return!0}if(this.hasFill())return function(t,e,n){return Ts(t,0,!1,e,n)}(o,t,e)}return!1},e.prototype.dirtyShape=function(){this.__dirty|=4,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},e.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},e.prototype.animateShape=function(t){return this.animate("shape",t)},e.prototype.updateDuringAnimation=function(t){"style"===t?this.dirtyStyle():"shape"===t?this.dirtyShape():this.markRedraw()},e.prototype.attrKV=function(e,n){"shape"===e?this.setShape(n):t.prototype.attrKV.call(this,e,n)},e.prototype.setShape=function(t,e){var n=this.shape;return n||(n=this.shape={}),"string"==typeof t?n[t]=e:A(n,t),this.dirtyShape(),this},e.prototype.shapeChanged=function(){return!!(4&this.__dirty)},e.prototype.createStyle=function(t){return mt(Cs,t)},e.prototype._innerSaveToNormal=function(e){t.prototype._innerSaveToNormal.call(this,e);var n=this._normalState;e.shape&&!n.shape&&(n.shape=A({},this.shape))},e.prototype._applyStateObj=function(e,n,i,r,o,a){t.prototype._applyStateObj.call(this,e,n,i,r,o,a);var s,l=!(n&&r);if(n&&n.shape?o?r?s=n.shape:(s=A({},i.shape),A(s,n.shape)):(s=A({},r?this.shape:i.shape),A(s,n.shape)):l&&(s=i.shape),s)if(o){this.shape=A({},this.shape);for(var u={},h=G(s),c=0;c0},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.createStyle=function(t){return mt(Ls,t)},e.prototype.setBoundingRect=function(t){this._rect=t},e.prototype.getBoundingRect=function(){var t=this.style;if(!this._rect){var e=t.text;null!=e?e+="":e="";var n=Sr(e,t.font,t.textAlign,t.textBaseline);if(n.x+=t.x||0,n.y+=t.y||0,this.hasStroke()){var i=t.lineWidth;n.x-=i/2,n.y-=i/2,n.width+=i,n.height+=i}this._rect=n}return this._rect},e.initDefaultProps=void(e.prototype.dirtyRectTolerance=10),e}(Da);Ps.prototype.type="tspan";var Os=k({x:0,y:0},Ma),Rs={style:k({x:!0,y:!0,width:!0,height:!0,sx:!0,sy:!0,sWidth:!0,sHeight:!0},Ia.style)};var Ns=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.createStyle=function(t){return mt(Os,t)},e.prototype._getSize=function(t){var e=this.style,n=e[t];if(null!=n)return n;var i,r=(i=e.image)&&"string"!=typeof i&&i.width&&i.height?e.image:this.__image;if(!r)return 0;var o="width"===t?"height":"width",a=e[o];return null==a?r[t]:r[t]/r[o]*a},e.prototype.getWidth=function(){return this._getSize("width")},e.prototype.getHeight=function(){return this._getSize("height")},e.prototype.getAnimationStyleProps=function(){return Rs},e.prototype.getBoundingRect=function(){var t=this.style;return this._rect||(this._rect=new ze(t.x||0,t.y||0,this.getWidth(),this.getHeight())),this._rect},e}(Da);Ns.prototype.type="image";var Es=Math.round;function zs(t,e,n){if(e){var i=e.x1,r=e.x2,o=e.y1,a=e.y2;t.x1=i,t.x2=r,t.y1=o,t.y2=a;var s=n&&n.lineWidth;return s?(Es(2*i)===Es(2*r)&&(t.x1=t.x2=Bs(i,s,!0)),Es(2*o)===Es(2*a)&&(t.y1=t.y2=Bs(o,s,!0)),t):t}}function Vs(t,e,n){if(e){var i=e.x,r=e.y,o=e.width,a=e.height;t.x=i,t.y=r,t.width=o,t.height=a;var s=n&&n.lineWidth;return s?(t.x=Bs(i,s,!0),t.y=Bs(r,s,!0),t.width=Math.max(Bs(i+o,s,!1)-t.x,0===o?0:1),t.height=Math.max(Bs(r+a,s,!1)-t.y,0===a?0:1),t):t}}function Bs(t,e,n){if(!e)return t;var i=Es(2*t);return(i+Es(e))%2==0?i/2:(i+(n?1:-1))/2}var Fs=function(){this.x=0,this.y=0,this.width=0,this.height=0},Gs={},Ws=function(t){function e(e){return t.call(this,e)||this}return n(e,t),e.prototype.getDefaultShape=function(){return new Fs},e.prototype.buildPath=function(t,e){var n,i,r,o;if(this.subPixelOptimize){var a=Vs(Gs,e,this.style);n=a.x,i=a.y,r=a.width,o=a.height,a.r=e.r,e=a}else n=e.x,i=e.y,r=e.width,o=e.height;e.r?function(t,e){var n,i,r,o,a,s=e.x,l=e.y,u=e.width,h=e.height,c=e.r;u<0&&(s+=u,u=-u),h<0&&(l+=h,h=-h),"number"==typeof c?n=i=r=o=c:c instanceof Array?1===c.length?n=i=r=o=c[0]:2===c.length?(n=r=c[0],i=o=c[1]):3===c.length?(n=c[0],i=o=c[1],r=c[2]):(n=c[0],i=c[1],r=c[2],o=c[3]):n=i=r=o=0,n+i>u&&(n*=u/(a=n+i),i*=u/a),r+o>u&&(r*=u/(a=r+o),o*=u/a),i+r>h&&(i*=h/(a=i+r),r*=h/a),n+o>h&&(n*=h/(a=n+o),o*=h/a),t.moveTo(s+n,l),t.lineTo(s+u-i,l),0!==i&&t.arc(s+u-i,l+i,i,-Math.PI/2,0),t.lineTo(s+u,l+h-r),0!==r&&t.arc(s+u-r,l+h-r,r,0,Math.PI/2),t.lineTo(s+o,l+h),0!==o&&t.arc(s+o,l+h-o,o,Math.PI/2,Math.PI),t.lineTo(s,l+n),0!==n&&t.arc(s+n,l+n,n,Math.PI,1.5*Math.PI)}(t,e):t.rect(n,i,r,o)},e.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},e}(ks);Ws.prototype.type="rect";var Hs={fill:"#000"},Ys={style:k({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},Ia.style)},Xs=function(t){function e(e){var n=t.call(this)||this;return n.type="text",n._children=[],n._defaultStyle=Hs,n.attr(e),n}return n(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.update=function(){t.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;ed&&h){var f=Math.floor(d/l);n=n.slice(0,f)}if(t&&a&&null!=c)for(var g=da(c,o,e.ellipsis,{minChar:e.truncateMinChar,placeholder:e.placeholder}),y=0;y0,T=null!=t.width&&("truncate"===t.overflow||"break"===t.overflow||"breakAll"===t.overflow),C=i.calculatedLineHeight,D=0;Dl&&xa(n,t.substring(l,u),e,s),xa(n,i[2],e,s,i[1]),l=ca.lastIndex}lo){b>0?(m.tokens=m.tokens.slice(0,b),y(m,_,x),n.lines=n.lines.slice(0,v+1)):n.lines=n.lines.slice(0,v);break t}var C=w.width,D=null==C||"auto"===C;if("string"==typeof C&&"%"===C.charAt(C.length-1))P.percentWidth=C,h.push(P),P.contentWidth=br(P.text,I);else{if(D){var A=w.backgroundColor,k=A&&A.image;k&&ha(k=sa(k))&&(P.width=Math.max(P.width,k.width*T/k.height))}var L=f&&null!=r?r-_:null;null!=L&&L=0&&"right"===(C=x[T]).align;)this._placeToken(C,t,b,f,I,"right",y),w-=C.width,I-=C.width,T--;for(M+=(n-(M-d)-(g-I)-w)/2;S<=T;)C=x[S],this._placeToken(C,t,b,f,M+C.width/2,"center",y),M+=C.width,S++;f+=b}},e.prototype._placeToken=function(t,e,n,i,r,o,s){var l=e.rich[t.styleName]||{};l.text=t.text;var u=t.verticalAlign,h=i+n/2;"top"===u?h=i+t.height/2:"bottom"===u&&(h=i+n-t.height/2),!t.isLineHolder&&il(l)&&this._renderBackground(l,e,"right"===o?r-t.width:"center"===o?r-t.width/2:r,h-t.height/2,t.width,t.height);var c=!!l.backgroundColor,p=t.textPadding;p&&(r=el(r,o,p),h-=t.height/2-p[0]-t.innerHeight/2);var d=this._getOrCreateChild(Ps),f=d.createStyle();d.useStyle(f);var g=this._defaultStyle,y=!1,v=0,m=tl("fill"in l?l.fill:"fill"in e?e.fill:(y=!0,g.fill)),x=Qs("stroke"in l?l.stroke:"stroke"in e?e.stroke:c||s||g.autoStroke&&!y?null:(v=2,g.stroke)),_=l.textShadowBlur>0||e.textShadowBlur>0;f.text=t.text,f.x=r,f.y=h,_&&(f.shadowBlur=l.textShadowBlur||e.textShadowBlur||0,f.shadowColor=l.textShadowColor||e.textShadowColor||"transparent",f.shadowOffsetX=l.textShadowOffsetX||e.textShadowOffsetX||0,f.shadowOffsetY=l.textShadowOffsetY||e.textShadowOffsetY||0),f.textAlign=o,f.textBaseline="middle",f.font=t.font||a,f.opacity=ot(l.opacity,e.opacity,1),Ks(f,l),x&&(f.lineWidth=ot(l.lineWidth,e.lineWidth,v),f.lineDash=rt(l.lineDash,e.lineDash),f.lineDashOffset=e.lineDashOffset||0,f.stroke=x),m&&(f.fill=m);var b=t.contentWidth,w=t.contentHeight;d.setBoundingRect(new ze(Mr(f.x,b,f.textAlign),Ir(f.y,w,f.textBaseline),b,w))},e.prototype._renderBackground=function(t,e,n,i,r,o){var a,s,l,u=t.backgroundColor,h=t.borderWidth,c=t.borderColor,p=u&&u.image,d=u&&!p,f=t.borderRadius,g=this;if(d||t.lineHeight||h&&c){(a=this._getOrCreateChild(Ws)).useStyle(a.createStyle()),a.style.fill=null;var y=a.shape;y.x=n,y.y=i,y.width=r,y.height=o,y.r=f,a.dirtyShape()}if(d)(l=a.style).fill=u||null,l.fillOpacity=rt(t.fillOpacity,1);else if(p){(s=this._getOrCreateChild(Ns)).onload=function(){g.dirtyStyle()};var v=s.style;v.image=u.image,v.x=n,v.y=i,v.width=r,v.height=o}h&&c&&((l=a.style).lineWidth=h,l.stroke=c,l.strokeOpacity=rt(t.strokeOpacity,1),l.lineDash=t.borderDash,l.lineDashOffset=t.borderDashOffset||0,a.strokeContainThreshold=0,a.hasFill()&&a.hasStroke()&&(l.strokeFirst=!0,l.lineWidth*=2));var m=(a||s).style;m.shadowBlur=t.shadowBlur||0,m.shadowColor=t.shadowColor||"transparent",m.shadowOffsetX=t.shadowOffsetX||0,m.shadowOffsetY=t.shadowOffsetY||0,m.opacity=ot(t.opacity,e.opacity,1)},e.makeFont=function(t){var e="";return $s(t)&&(e=[t.fontStyle,t.fontWeight,qs(t.fontSize),t.fontFamily||"sans-serif"].join(" ")),e&&ut(e)||t.textFont||t.font},e}(Da),Us={left:!0,right:1,center:1},Zs={top:1,bottom:1,middle:1},js=["fontStyle","fontWeight","fontSize","fontFamily"];function qs(t){return"string"!=typeof t||-1===t.indexOf("px")&&-1===t.indexOf("rem")&&-1===t.indexOf("em")?isNaN(+t)?"12px":t+"px":t}function Ks(t,e){for(var n=0;n=0,o=!1;if(t instanceof ks){var a=ll(t),s=r&&a.selectFill||a.normalFill,l=r&&a.selectStroke||a.normalStroke;if(ml(s)||ml(l)){var u=(i=i||{}).style||{};"inherit"===u.fill?(o=!0,i=A({},i),(u=A({},u)).fill=s):!ml(u.fill)&&ml(s)?(o=!0,i=A({},i),(u=A({},u)).fill=si(s)):!ml(u.stroke)&&ml(l)&&(o||(i=A({},i),u=A({},u)),u.stroke=si(l)),i.style=u}}if(i&&null==i.z2){o||(i=A({},i));var h=t.z2EmphasisLift;i.z2=t.z2+(null!=h?h:pl)}return i}(this,0,e,n);if("blur"===t)return function(t,e,n){var i=P(t.currentStates,e)>=0,r=t.style.opacity,o=i?null:function(t,e,n,i){for(var r=t.style,o={},a=0;a0){var o={dataIndex:r,seriesIndex:t.seriesIndex};null!=i&&(o.dataType=i),e.push(o)}}))})),e}function Ul(t,e,n){Jl(t,!0),Cl(t,kl),jl(t,e,n)}function Zl(t,e,n,i){i?function(t){Jl(t,!1)}(t):Ul(t,e,n)}function jl(t,e,n){var i=rl(t);null!=e?(i.focus=e,i.blurScope=n):i.focus&&(i.focus=null)}var ql=["emphasis","blur","select"],Kl={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function $l(t,e,n,i){n=n||"itemStyle";for(var r=0;r1&&(a*=su(f),s*=su(f));var g=(r===o?-1:1)*su((a*a*(s*s)-a*a*(d*d)-s*s*(p*p))/(a*a*(d*d)+s*s*(p*p)))||0,y=g*a*d/s,v=g*-s*p/a,m=(t+n)/2+uu(c)*y-lu(c)*v,x=(e+i)/2+lu(c)*y+uu(c)*v,_=du([1,0],[(p-y)/a,(d-v)/s]),b=[(p-y)/a,(d-v)/s],w=[(-1*p-y)/a,(-1*d-v)/s],S=du(b,w);if(pu(b,w)<=-1&&(S=hu),pu(b,w)>=1&&(S=0),S<0){var M=Math.round(S/hu*1e6)/1e6;S=2*hu+M%2*hu}h.addData(u,m,x,a,s,_,S,c,o)}var gu=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/gi,yu=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;var vu=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.applyTransform=function(t){},e}(ks);function mu(t){return null!=t.setData}function xu(t,e){var n=function(t){var e=new hs;if(!t)return e;var n,i=0,r=0,o=i,a=r,s=hs.CMD,l=t.match(gu);if(!l)return e;for(var u=0;uk*k+L*L&&(M=T,I=C),{cx:M,cy:I,x0:-h,y0:-c,x1:M*(r/b-1),y1:I*(r/b-1)}}function Vu(t,e){var n,i=Ru(e.r,0),r=Ru(e.r0||0,0),o=i>0;if(o||r>0){if(o||(i=r,r=0),r>i){var a=i;i=r,r=a}var s=e.startAngle,l=e.endAngle;if(!isNaN(s)&&!isNaN(l)){var u=e.cx,h=e.cy,c=!!e.clockwise,p=Pu(l-s),d=p>Cu&&p%Cu;if(d>Eu&&(p=d),i>Eu)if(p>Cu-Eu)t.moveTo(u+i*Au(s),h+i*Du(s)),t.arc(u,h,i,s,l,!c),r>Eu&&(t.moveTo(u+r*Au(l),h+r*Du(l)),t.arc(u,h,r,l,s,c));else{var f=void 0,g=void 0,y=void 0,v=void 0,m=void 0,x=void 0,_=void 0,b=void 0,w=void 0,S=void 0,M=void 0,I=void 0,T=void 0,C=void 0,D=void 0,A=void 0,k=i*Au(s),L=i*Du(s),P=r*Au(l),O=r*Du(l),R=p>Eu;if(R){var N=e.cornerRadius;N&&(n=function(t){var e;if(Y(t)){var n=t.length;if(!n)return t;e=1===n?[t[0],t[0],0,0]:2===n?[t[0],t[0],t[1],t[1]]:3===n?t.concat(t[2]):t}else e=[t,t,t,t];return e}(N),f=n[0],g=n[1],y=n[2],v=n[3]);var E=Pu(i-r)/2;if(m=Nu(E,y),x=Nu(E,v),_=Nu(E,f),b=Nu(E,g),M=w=Ru(m,x),I=S=Ru(_,b),(w>Eu||S>Eu)&&(T=i*Au(l),C=i*Du(l),D=r*Au(s),A=r*Du(s),pEu){var X=Nu(y,M),U=Nu(v,M),Z=zu(D,A,k,L,i,X,c),j=zu(T,C,P,O,i,U,c);t.moveTo(u+Z.cx+Z.x0,h+Z.cy+Z.y0),M0&&t.arc(u+Z.cx,h+Z.cy,X,Lu(Z.y0,Z.x0),Lu(Z.y1,Z.x1),!c),t.arc(u,h,i,Lu(Z.cy+Z.y1,Z.cx+Z.x1),Lu(j.cy+j.y1,j.cx+j.x1),!c),U>0&&t.arc(u+j.cx,h+j.cy,U,Lu(j.y1,j.x1),Lu(j.y0,j.x0),!c))}else t.moveTo(u+k,h+L),t.arc(u,h,i,s,l,!c);else t.moveTo(u+k,h+L);if(r>Eu&&R)if(I>Eu){X=Nu(f,I),Z=zu(P,O,T,C,r,-(U=Nu(g,I)),c),j=zu(k,L,D,A,r,-X,c);t.lineTo(u+Z.cx+Z.x0,h+Z.cy+Z.y0),I0&&t.arc(u+Z.cx,h+Z.cy,U,Lu(Z.y0,Z.x0),Lu(Z.y1,Z.x1),!c),t.arc(u,h,r,Lu(Z.cy+Z.y1,Z.cx+Z.x1),Lu(j.cy+j.y1,j.cx+j.x1),c),X>0&&t.arc(u+j.cx,h+j.cy,X,Lu(j.y1,j.x1),Lu(j.y0,j.x0),!c))}else t.lineTo(u+P,h+O),t.arc(u,h,r,l,s,c);else t.lineTo(u+P,h+O)}else t.moveTo(u,h);t.closePath()}}}var Bu=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0,this.cornerRadius=0},Fu=function(t){function e(e){return t.call(this,e)||this}return n(e,t),e.prototype.getDefaultShape=function(){return new Bu},e.prototype.buildPath=function(t,e){Vu(t,e)},e.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},e}(ks);Fu.prototype.type="sector";var Gu=function(){this.cx=0,this.cy=0,this.r=0,this.r0=0},Wu=function(t){function e(e){return t.call(this,e)||this}return n(e,t),e.prototype.getDefaultShape=function(){return new Gu},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=2*Math.PI;t.moveTo(n+e.r,i),t.arc(n,i,e.r,0,r,!1),t.moveTo(n+e.r0,i),t.arc(n,i,e.r0,0,r,!0)},e}(ks);function Hu(t,e,n){var i=e.smooth,r=e.points;if(r&&r.length>=2){if(i){var o=function(t,e,n,i){var r,o,a,s,l=[],u=[],h=[],c=[];if(i){a=[1/0,1/0],s=[-1/0,-1/0];for(var p=0,d=t.length;plh[1]){if(a=!1,r)return a;var u=Math.abs(lh[0]-sh[1]),h=Math.abs(sh[0]-lh[1]);Math.min(u,h)>i.len()&&(u0){var c={duration:h.duration,delay:h.delay||0,easing:h.easing,done:o,force:!!o||!!a,setToFinal:!u,scope:t,during:a};l?e.animateFrom(n,c):e.animateTo(n,c)}else e.stopAnimation(),!l&&e.attr(n),a&&a(1),o&&o()}function vh(t,e,n,i,r,o){yh("update",t,e,n,i,r,o)}function mh(t,e,n,i,r,o){yh("enter",t,e,n,i,r,o)}function xh(t){if(!t.__zr)return!0;for(var e=0;eMath.abs(o[1])?o[0]>0?"right":"left":o[1]>0?"bottom":"top"}function Wh(t){return!t.isGroup}function Hh(t,e,n){if(t&&e){var i,r=(i={},t.traverse((function(t){Wh(t)&&t.anid&&(i[t.anid]=t)})),i);e.traverse((function(t){if(Wh(t)&&t.anid){var e=r[t.anid];if(e){var i=o(t);t.attr(o(e)),vh(t,i,n,rl(t).dataIndex)}}}))}function o(t){var e={x:t.x,y:t.y,rotation:t.rotation};return function(t){return null!=t.shape}(t)&&(e.shape=A({},t.shape)),e}}function Yh(t,e){return z(t,(function(t){var n=t[0];n=Mh(n,e.x),n=Ih(n,e.x+e.width);var i=t[1];return i=Mh(i,e.y),[n,i=Ih(i,e.y+e.height)]}))}function Xh(t,e){var n=Mh(t.x,e.x),i=Ih(t.x+t.width,e.x+e.width),r=Mh(t.y,e.y),o=Ih(t.y+t.height,e.y+e.height);if(i>=n&&o>=r)return{x:n,y:r,width:i-n,height:o-r}}function Uh(t,e,n){var i=A({rectHover:!0},e),r=i.style={strokeNoScale:!0};if(n=n||{x:-1,y:-1,width:2,height:2},t)return 0===t.indexOf("image://")?(r.image=t.slice(8),k(r,n),new Ns(i)):Ph(t.replace("path://",""),i,n,"center")}function Zh(t,e,n,i,r){for(var o=0,a=r[r.length-1];o=-1e-6)return!1;var f=t-r,g=e-o,y=qh(f,g,u,h)/d;if(y<0||y>1)return!1;var v=qh(f,g,c,p)/d;return!(v<0||v>1)}function qh(t,e,n,i){return t*i-n*e}function Kh(t){var e=t.itemTooltipOption,n=t.componentModel,i=t.itemName,r=U(e)?{formatter:e}:e,o=n.mainType,a=n.componentIndex,s={componentType:o,name:i,$vars:["name"]};s[o+"Index"]=a;var l=t.formatterParamsExtra;l&&E(G(l),(function(t){_t(s,t)||(s[t]=l[t],s.$vars.push(t))}));var u=rl(t.el);u.componentMainType=o,u.componentIndex=a,u.tooltipConfig={name:i,option:k({content:i,encodeHTMLContent:!0,formatterParams:s},r)}}function $h(t,e){var n;t.isGroup&&(n=e(t)),n||t.traverse(e)}function Jh(t,e){if(t)if(Y(t))for(var n=0;n-1?Lc:Oc;function zc(t,e){t=t.toUpperCase(),Nc[t]=new Cc(e),Rc[t]=e}function Vc(t){return Nc[t]}zc(Pc,{time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}}),zc(Lc,{time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}});var Bc=1e3,Fc=6e4,Gc=36e5,Wc=864e5,Hc=31536e6,Yc={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}",none:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},Xc="{yyyy}-{MM}-{dd}",Uc={year:"{yyyy}",month:"{yyyy}-{MM}",day:Xc,hour:Xc+" "+Yc.hour,minute:Xc+" "+Yc.minute,second:Xc+" "+Yc.second,millisecond:Yc.none},Zc=["year","month","day","hour","minute","second","millisecond"],jc=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function qc(t,e){return"0000".substr(0,e-(t+="").length)+t}function Kc(t){switch(t){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return t}}function $c(t){return t===Kc(t)}function Jc(t,e,n,i){var r=uo(t),o=r[ep(n)](),a=r[np(n)]()+1,s=Math.floor((a-1)/3)+1,l=r[ip(n)](),u=r["get"+(n?"UTC":"")+"Day"](),h=r[rp(n)](),c=(h-1)%12+1,p=r[op(n)](),d=r[ap(n)](),f=r[sp(n)](),g=h>=12?"pm":"am",y=g.toUpperCase(),v=(i instanceof Cc?i:Vc(i||Ec)||Nc[Oc]).getModel("time"),m=v.get("month"),x=v.get("monthAbbr"),_=v.get("dayOfWeek"),b=v.get("dayOfWeekAbbr");return(e||"").replace(/{a}/g,g+"").replace(/{A}/g,y+"").replace(/{yyyy}/g,o+"").replace(/{yy}/g,qc(o%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,m[a-1]).replace(/{MMM}/g,x[a-1]).replace(/{MM}/g,qc(a,2)).replace(/{M}/g,a+"").replace(/{dd}/g,qc(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,_[u]).replace(/{ee}/g,b[u]).replace(/{e}/g,u+"").replace(/{HH}/g,qc(h,2)).replace(/{H}/g,h+"").replace(/{hh}/g,qc(c+"",2)).replace(/{h}/g,c+"").replace(/{mm}/g,qc(p,2)).replace(/{m}/g,p+"").replace(/{ss}/g,qc(d,2)).replace(/{s}/g,d+"").replace(/{SSS}/g,qc(f,3)).replace(/{S}/g,f+"")}function Qc(t,e){var n=uo(t),i=n[np(e)]()+1,r=n[ip(e)](),o=n[rp(e)](),a=n[op(e)](),s=n[ap(e)](),l=0===n[sp(e)](),u=l&&0===s,h=u&&0===a,c=h&&0===o,p=c&&1===r;return p&&1===i?"year":p?"month":c?"day":h?"hour":u?"minute":l?"second":"millisecond"}function tp(t,e,n){var i=j(t)?uo(t):t;switch(e=e||Qc(t,n)){case"year":return i[ep(n)]();case"half-year":return i[np(n)]()>=6?1:0;case"quarter":return Math.floor((i[np(n)]()+1)/4);case"month":return i[np(n)]();case"day":return i[ip(n)]();case"half-day":return i[rp(n)]()/24;case"hour":return i[rp(n)]();case"minute":return i[op(n)]();case"second":return i[ap(n)]();case"millisecond":return i[sp(n)]()}}function ep(t){return t?"getUTCFullYear":"getFullYear"}function np(t){return t?"getUTCMonth":"getMonth"}function ip(t){return t?"getUTCDate":"getDate"}function rp(t){return t?"getUTCHours":"getHours"}function op(t){return t?"getUTCMinutes":"getMinutes"}function ap(t){return t?"getUTCSeconds":"getSeconds"}function sp(t){return t?"getUTCMilliseconds":"getMilliseconds"}function lp(t){return t?"setUTCFullYear":"setFullYear"}function up(t){return t?"setUTCMonth":"setMonth"}function hp(t){return t?"setUTCDate":"setDate"}function cp(t){return t?"setUTCHours":"setHours"}function pp(t){return t?"setUTCMinutes":"setMinutes"}function dp(t){return t?"setUTCSeconds":"setSeconds"}function fp(t){return t?"setUTCMilliseconds":"setMilliseconds"}function gp(t){if(!vo(t))return U(t)?t:"-";var e=(t+"").split(".");return e[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(e.length>1?"."+e[1]:"")}function yp(t,e){return t=(t||"").toLowerCase().replace(/-(.)/g,(function(t,e){return e.toUpperCase()})),e&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),t}var vp=st;function mp(t,e,n){function i(t){return t&&ut(t)?t:"-"}function r(t){return!(null==t||isNaN(t)||!isFinite(t))}var o="time"===e,a=t instanceof Date;if(o||a){var s=o?uo(t):t;if(!isNaN(+s))return Jc(s,"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",n);if(a)return"-"}if("ordinal"===e)return Z(t)?i(t):j(t)&&r(t)?t+"":"-";var l=yo(t);return r(l)?gp(l):Z(t)?i(t):"boolean"==typeof t?t+"":"-"}var xp=["a","b","c","d","e","f","g"],_p=function(t,e){return"{"+t+(null==e?"":e)+"}"};function bp(t,e,n){Y(e)||(e=[e]);var i=e.length;if(!i)return"";for(var r=e[0].$vars||[],o=0;o':'':{renderMode:o,content:"{"+(n.markerId||"markerX")+"|} ",style:"subItem"===r?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}:""}function Sp(t,e){return e=e||"transparent",U(t)?t:q(t)&&t.colorStops&&(t.colorStops[0]||{}).color||e}function Mp(t,e){if("_blank"===e||"blank"===e){var n=window.open();n.opener=null,n.location.href=t}else window.open(t,e)}var Ip=E,Tp=["left","right","top","bottom","width","height"],Cp=[["width","left","right"],["height","top","bottom"]];function Dp(t,e,n,i,r){var o=0,a=0;null==i&&(i=1/0),null==r&&(r=1/0);var s=0;e.eachChild((function(l,u){var h,c,p=l.getBoundingRect(),d=e.childAt(u+1),f=d&&d.getBoundingRect();if("horizontal"===t){var g=p.width+(f?-f.x+p.x:0);(h=o+g)>i||l.newline?(o=0,h=g,a+=s+n,s=p.height):s=Math.max(s,p.height)}else{var y=p.height+(f?-f.y+p.y:0);(c=a+y)>r||l.newline?(o+=s+n,a=0,c=y,s=p.width):s=Math.max(s,p.width)}l.newline||(l.x=o,l.y=a,l.markRedraw(),"horizontal"===t?o=h+n:a=c+n)}))}var Ap=Dp;H(Dp,"vertical"),H(Dp,"horizontal");function kp(t,e,n){n=vp(n||0);var i=e.width,r=e.height,o=$r(t.left,i),a=$r(t.top,r),s=$r(t.right,i),l=$r(t.bottom,r),u=$r(t.width,i),h=$r(t.height,r),c=n[2]+n[0],p=n[1]+n[3],d=t.aspect;switch(isNaN(u)&&(u=i-s-p-o),isNaN(h)&&(h=r-l-c-a),null!=d&&(isNaN(u)&&isNaN(h)&&(d>i/r?u=.8*i:h=.8*r),isNaN(u)&&(u=d*h),isNaN(h)&&(h=u/d)),isNaN(o)&&(o=i-s-u-p),isNaN(a)&&(a=r-l-h-c),t.left||t.right){case"center":o=i/2-u/2-n[3];break;case"right":o=i-u-p}switch(t.top||t.bottom){case"middle":case"center":a=r/2-h/2-n[0];break;case"bottom":a=r-h-c}o=o||0,a=a||0,isNaN(u)&&(u=i-p-o-(s||0)),isNaN(h)&&(h=r-c-a-(l||0));var f=new ze(o+n[3],a+n[0],u,h);return f.margin=n,f}function Lp(t,e,n,i,r,o){var a,s=!r||!r.hv||r.hv[0],l=!r||!r.hv||r.hv[1],u=r&&r.boundingMode||"all";if((o=o||t).x=t.x,o.y=t.y,!s&&!l)return!1;if("raw"===u)a="group"===t.type?new ze(0,0,+e.width||0,+e.height||0):t.getBoundingRect();else if(a=t.getBoundingRect(),t.needLocalTransform()){var h=t.getLocalTransform();(a=a.clone()).applyTransform(h)}var c=kp(k({width:a.width,height:a.height},e),n,i),p=s?c.x-a.x:0,d=l?c.y-a.y:0;return"raw"===u?(o.x=p,o.y=d):(o.x+=p,o.y+=d),o===t&&t.markRedraw(),!0}function Pp(t){var e=t.layoutMode||t.constructor.layoutMode;return q(e)?e:e?{type:e}:null}function Op(t,e,n){var i=n&&n.ignoreSize;!Y(i)&&(i=[i,i]);var r=a(Cp[0],0),o=a(Cp[1],1);function a(n,r){var o={},a=0,u={},h=0;if(Ip(n,(function(e){u[e]=t[e]})),Ip(n,(function(t){s(e,t)&&(o[t]=u[t]=e[t]),l(o,t)&&a++,l(u,t)&&h++})),i[r])return l(e,n[1])?u[n[2]]=null:l(e,n[2])&&(u[n[1]]=null),u;if(2!==h&&a){if(a>=2)return o;for(var c=0;c=0;a--)o=C(o,n[a],!0);e.defaultOption=o}return e.defaultOption},e.prototype.getReferringComponents=function(t,e){var n=t+"Index",i=t+"Id";return Yo(this.ecModel,t,{index:this.get(n,!0),id:this.get(i,!0)},e)},e.prototype.getBoxLayoutParams=function(){var t=this;return{left:t.get("left"),top:t.get("top"),right:t.get("right"),bottom:t.get("bottom"),width:t.get("width"),height:t.get("height")}},e.prototype.getZLevelKey=function(){return""},e.prototype.setZLevel=function(t){this.option.zlevel=t},e.protoInitialize=function(){var t=e.prototype;t.type="component",t.id="",t.name="",t.mainType="",t.subType="",t.componentIndex=0}(),e}(Cc);Jo(zp,Cc),na(zp),function(t){var e={};t.registerSubTypeDefaulter=function(t,n){var i=Ko(t);e[i.main]=n},t.determineSubType=function(n,i){var r=i.type;if(!r){var o=Ko(n).main;t.hasSubTypes(n)&&e[o]&&(r=e[o](i))}return r}}(zp),function(t,e){function n(t,e){return t[e]||(t[e]={predecessor:[],successor:[]}),t[e]}t.topologicalTravel=function(t,i,r,o){if(t.length){var a=function(t){var i={},r=[];return E(t,(function(o){var a=n(i,o),s=function(t,e){var n=[];return E(t,(function(t){P(e,t)>=0&&n.push(t)})),n}(a.originalDeps=e(o),t);a.entryCount=s.length,0===a.entryCount&&r.push(o),E(s,(function(t){P(a.predecessor,t)<0&&a.predecessor.push(t);var e=n(i,t);P(e.successor,t)<0&&e.successor.push(o)}))})),{graph:i,noEntryList:r}}(i),s=a.graph,l=a.noEntryList,u={};for(E(t,(function(t){u[t]=!0}));l.length;){var h=l.pop(),c=s[h],p=!!u[h];p&&(r.call(o,h,c.originalDeps.slice()),delete u[h]),E(c.successor,p?f:d)}E(u,(function(){var t="";throw new Error(t)}))}function d(t){s[t].entryCount--,0===s[t].entryCount&&l.push(t)}function f(t){u[t]=!0,d(t)}}}(zp,(function(t){var e=[];E(zp.getClassesByMainType(t),(function(t){e=e.concat(t.dependencies||t.prototype.dependencies||[])})),e=z(e,(function(t){return Ko(t).main})),"dataset"!==t&&P(e,"dataset")<=0&&e.unshift("dataset");return e}));var Vp="";"undefined"!=typeof navigator&&(Vp=navigator.platform||"");var Bp="rgba(0, 0, 0, 0.2)",Fp={darkMode:"auto",colorBy:"series",color:["#5470c6","#91cc75","#fac858","#ee6666","#73c0de","#3ba272","#fc8452","#9a60b4","#ea7ccc"],gradientColor:["#f6efa6","#d88273","#bf444c"],aria:{decal:{decals:[{color:Bp,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:Bp,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:Bp,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:Bp,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:Bp,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:Bp,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:Vp.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1},Gp=yt(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),Wp="original",Hp="arrayRows",Yp="objectRows",Xp="keyedColumns",Up="typedArray",Zp="unknown",jp="column",qp="row",Kp=1,$p=2,Jp=3,Qp=Vo();function td(t,e,n){var i={},r=nd(e);if(!r||!t)return i;var o,a,s=[],l=[],u=e.ecModel,h=Qp(u).datasetMap,c=r.uid+"_"+n.seriesLayoutBy;E(t=t.slice(),(function(e,n){var r=q(e)?e:t[n]={name:e};"ordinal"===r.type&&null==o&&(o=n,a=f(r)),i[r.name]=[]}));var p=h.get(c)||h.set(c,{categoryWayDim:a,valueWayDim:0});function d(t,e,n){for(var i=0;ie)return t[i];return t[n-1]}(i,a):n;if((h=h||n)&&h.length){var c=h[l];return r&&(u[r]=c),s.paletteIdx=(l+1)%h.length,c}}var fd="\0_ec_inner";var gd=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.init=function(t,e,n,i,r,o){i=i||{},this.option=null,this._theme=new Cc(i),this._locale=new Cc(r),this._optionManager=o},e.prototype.setOption=function(t,e,n){var i=md(e);this._optionManager.setOption(t,n,i),this._resetOption(null,i)},e.prototype.resetOption=function(t,e){return this._resetOption(t,md(e))},e.prototype._resetOption=function(t,e){var n=!1,i=this._optionManager;if(!t||"recreate"===t){var r=i.mountOption("recreate"===t);0,this.option&&"recreate"!==t?(this.restoreData(),this._mergeOption(r,e)):ld(this,r),n=!0}if("timeline"!==t&&"media"!==t||this.restoreData(),!t||"recreate"===t||"timeline"===t){var o=i.getTimelineOption(this);o&&(n=!0,this._mergeOption(o,e))}if(!t||"recreate"===t||"media"===t){var a=i.getMediaOption(this);a.length&&E(a,(function(t){n=!0,this._mergeOption(t,e)}),this)}return n},e.prototype.mergeOption=function(t){this._mergeOption(t,null)},e.prototype._mergeOption=function(t,e){var n=this.option,i=this._componentsMap,r=this._componentsCount,o=[],a=yt(),s=e&&e.replaceMergeMainTypeMap;Qp(this).datasetMap=yt(),E(t,(function(t,e){null!=t&&(zp.hasClass(e)?e&&(o.push(e),a.set(e,!0)):n[e]=null==n[e]?T(t):C(n[e],t,!0))})),s&&s.each((function(t,e){zp.hasClass(e)&&!a.get(e)&&(o.push(e),a.set(e,!0))})),zp.topologicalTravel(o,zp.getAllClassMainTypes(),(function(e){var o=function(t,e,n){var i=od.get(e);if(!i)return n;var r=i(t);return r?n.concat(r):n}(this,e,To(t[e])),a=i.get(e),l=a?s&&s.get(e)?"replaceMerge":"normalMerge":"replaceAll",u=Lo(a,o,l);(function(t,e,n){E(t,(function(t){var i=t.newOption;q(i)&&(t.keyInfo.mainType=e,t.keyInfo.subType=function(t,e,n,i){return e.type?e.type:n?n.subType:i.determineSubType(t,e)}(e,i,t.existing,n))}))})(u,e,zp),n[e]=null,i.set(e,null),r.set(e,0);var h,c=[],p=[],d=0;E(u,(function(t,n){var i=t.existing,r=t.newOption;if(r){var o="series"===e,a=zp.getClass(e,t.keyInfo.subType,!o);if(!a)return;if("tooltip"===e){if(h)return void 0;h=!0}if(i&&i.constructor===a)i.name=t.keyInfo.name,i.mergeOption(r,this),i.optionUpdated(r,!1);else{var s=A({componentIndex:n},t.keyInfo);A(i=new a(r,this,this,s),s),t.brandNew&&(i.__requireNewView=!0),i.init(r,this,this),i.optionUpdated(null,!0)}}else i&&(i.mergeOption({},this),i.optionUpdated({},!1));i?(c.push(i.option),p.push(i),d++):(c.push(void 0),p.push(void 0))}),this),n[e]=c,i.set(e,p),r.set(e,d),"series"===e&&ad(this)}),this),this._seriesIndices||ad(this)},e.prototype.getOption=function(){var t=T(this.option);return E(t,(function(e,n){if(zp.hasClass(n)){for(var i=To(e),r=i.length,o=!1,a=r-1;a>=0;a--)i[a]&&!Eo(i[a])?o=!0:(i[a]=null,!o&&r--);i.length=r,t[n]=i}})),delete t[fd],t},e.prototype.getTheme=function(){return this._theme},e.prototype.getLocaleModel=function(){return this._locale},e.prototype.setUpdatePayload=function(t){this._payload=t},e.prototype.getUpdatePayload=function(){return this._payload},e.prototype.getComponent=function(t,e){var n=this._componentsMap.get(t);if(n){var i=n[e||0];if(i)return i;if(null==e)for(var r=0;r=e:"max"===n?t<=e:t===e})(i[a],t,o)||(r=!1)}})),r}var Td=E,Cd=q,Dd=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function Ad(t){var e=t&&t.itemStyle;if(e)for(var n=0,i=Dd.length;n=0;g--){var y=t[g];if(s||(p=y.data.rawIndexOf(y.stackedByDimension,c)),p>=0){var v=y.data.getByRawIndex(y.stackResultDimension,p);if("all"===l||"positive"===l&&v>0||"negative"===l&&v<0||"samesign"===l&&d>=0&&v>0||"samesign"===l&&d<=0&&v<0){d=ro(d,v),f=v;break}}}return i[0]=d,i[1]=f,i}))}))}var Zd,jd,qd,Kd,$d,Jd=function(t){this.data=t.data||(t.sourceFormat===Xp?{}:[]),this.sourceFormat=t.sourceFormat||Zp,this.seriesLayoutBy=t.seriesLayoutBy||jp,this.startIndex=t.startIndex||0,this.dimensionsDetectedCount=t.dimensionsDetectedCount,this.metaRawOption=t.metaRawOption;var e=this.dimensionsDefine=t.dimensionsDefine;if(e)for(var n=0;nu&&(u=d)}s[0]=l,s[1]=u}},i=function(){return this._data?this._data.length/this._dimSize:0};function r(t){for(var e=0;e=0&&(s=o.interpolatedValue[l])}return null!=s?s+"":""})):void 0},t.prototype.getRawValue=function(t,e){return mf(this.getData(e),t)},t.prototype.formatTooltip=function(t,e,n){},t}();function bf(t){var e,n;return q(t)?t.type&&(n=t):e=t,{text:e,frag:n}}function wf(t){return new Sf(t)}var Sf=function(){function t(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return t.prototype.perform=function(t){var e,n=this._upstream,i=t&&t.skip;if(this._dirty&&n){var r=this.context;r.data=r.outputData=n.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this),this._plan&&!i&&(e=this._plan(this.context));var o,a=h(this._modBy),s=this._modDataCount||0,l=h(t&&t.modBy),u=t&&t.modDataCount||0;function h(t){return!(t>=1)&&(t=1),t}a===l&&s===u||(e="reset"),(this._dirty||"reset"===e)&&(this._dirty=!1,o=this._doReset(i)),this._modBy=l,this._modDataCount=u;var c=t&&t.step;if(this._dueEnd=n?n._outputDueEnd:this._count?this._count(this.context):1/0,this._progress){var p=this._dueIndex,d=Math.min(null!=c?this._dueIndex+c:1/0,this._dueEnd);if(!i&&(o||p1&&i>0?s:a}};return o;function a(){return e=t?null:oe},gte:function(t,e){return t>=e}},Af=function(){function t(t,e){if(!j(e)){var n="";0,wo(n)}this._opFn=Df[t],this._rvalFloat=yo(e)}return t.prototype.evaluate=function(t){return j(t)?this._opFn(t,this._rvalFloat):this._opFn(yo(t),this._rvalFloat)},t}(),kf=function(){function t(t,e){var n="desc"===t;this._resultLT=n?1:-1,null==e&&(e=n?"min":"max"),this._incomparable="min"===e?-1/0:1/0}return t.prototype.evaluate=function(t,e){var n=j(t)?t:yo(t),i=j(e)?e:yo(e),r=isNaN(n),o=isNaN(i);if(r&&(n=this._incomparable),o&&(i=this._incomparable),r&&o){var a=U(t),s=U(e);a&&(n=s?t:0),s&&(i=a?e:0)}return ni?-this._resultLT:0},t}(),Lf=function(){function t(t,e){this._rval=e,this._isEQ=t,this._rvalTypeof=typeof e,this._rvalFloat=yo(e)}return t.prototype.evaluate=function(t){var e=t===this._rval;if(!e){var n=typeof t;n===this._rvalTypeof||"number"!==n&&"number"!==this._rvalTypeof||(e=yo(t)===this._rvalFloat)}return this._isEQ?e:!e},t}();function Pf(t,e){return"eq"===t||"ne"===t?new Lf("eq"===t,e):_t(Df,t)?new Af(t,e):null}var Of=function(){function t(){}return t.prototype.getRawData=function(){throw new Error("not supported")},t.prototype.getRawDataItem=function(t){throw new Error("not supported")},t.prototype.cloneRawData=function(){},t.prototype.getDimensionInfo=function(t){},t.prototype.cloneAllDimensionInfo=function(){},t.prototype.count=function(){},t.prototype.retrieveValue=function(t,e){},t.prototype.retrieveValueFromItem=function(t,e){},t.prototype.convertValue=function(t,e){return If(t,e)},t}();function Rf(t){var e=t.sourceFormat;if(!Ff(e)){var n="";0,wo(n)}return t.data}function Nf(t){var e=t.sourceFormat,n=t.data;if(!Ff(e)){var i="";0,wo(i)}if(e===Hp){for(var r=[],o=0,a=n.length;o65535?Hf:Yf}function qf(t,e,n,i,r){var o=Zf[n||"float"];if(r){var a=t[e],s=a&&a.length;if(s!==i){for(var l=new o(i),u=0;ug[1]&&(g[1]=f)}return this._rawCount=this._count=s,{start:a,end:s}},t.prototype._initDataFromProvider=function(t,e,n){for(var i=this._provider,r=this._chunks,o=this._dimensions,a=o.length,s=this._rawExtent,l=z(o,(function(t){return t.property})),u=0;uy[1]&&(y[1]=g)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},t.prototype.count=function(){return this._count},t.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,n=e[t];if(null!=n&&nt))return o;r=o-1}}return-1},t.prototype.indicesOfNearest=function(t,e,n){var i=this._chunks[t],r=[];if(!i)return r;null==n&&(n=1/0);for(var o=1/0,a=-1,s=0,l=0,u=this.count();l=0&&a<0)&&(o=c,a=h,s=0),h===a&&(r[s++]=l))}return r.length=s,r},t.prototype.getIndices=function(){var t,e=this._indices;if(e){var n=e.constructor,i=this._count;if(n===Array){t=new n(i);for(var r=0;r=u&&x<=h||isNaN(x))&&(a[s++]=d),d++}p=!0}else if(2===r){f=c[i[0]];var y=c[i[1]],v=t[i[1]][0],m=t[i[1]][1];for(g=0;g=u&&x<=h||isNaN(x))&&(_>=v&&_<=m||isNaN(_))&&(a[s++]=d),d++}p=!0}}if(!p)if(1===r)for(g=0;g=u&&x<=h||isNaN(x))&&(a[s++]=b)}else for(g=0;gt[M][1])&&(w=!1)}w&&(a[s++]=e.getRawIndex(g))}return sy[1]&&(y[1]=g)}}}},t.prototype.lttbDownSample=function(t,e){var n,i,r,o=this.clone([t],!0),a=o._chunks[t],s=this.count(),l=0,u=Math.floor(1/e),h=this.getRawIndex(0),c=new(jf(this._rawCount))(Math.min(2*(Math.ceil(s/u)+2),s));c[l++]=h;for(var p=1;pn&&(n=i,r=I)}M>0&&M<_-x&&(c[l++]=Math.min(S,r),r=Math.max(S,r)),c[l++]=r,h=r}return c[l++]=this.getRawIndex(s-1),o._count=l,o._indices=c,o.getRawIndex=this._getRawIdx,o},t.prototype.downSample=function(t,e,n,i){for(var r=this.clone([t],!0),o=r._chunks,a=[],s=Math.floor(1/e),l=o[t],u=this.count(),h=r._rawExtent[t]=[1/0,-1/0],c=new(jf(this._rawCount))(Math.ceil(u/s)),p=0,d=0;du-d&&(s=u-d,a.length=s);for(var f=0;fh[1]&&(h[1]=y),c[p++]=v}return r._count=p,r._indices=c,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();ra&&(a=l)}return i=[o,a],this._extent[t]=i,i},t.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var n=[],i=this._chunks,r=0;r=0?this._indices[t]:-1},t.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},t.internalField=function(){function t(t,e,n,i){return If(t[i],this._dimensions[i])}Gf={arrayRows:t,objectRows:function(t,e,n,i){return If(t[e],this._dimensions[i])},keyedColumns:t,original:function(t,e,n,i){var r=t&&(null==t.value?t:t.value);return If(r instanceof Array?r[i]:r,this._dimensions[i])},typedArray:function(t,e,n,i){return t[i]}}}(),t}(),$f=function(){function t(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return t.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},t.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},t.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},t.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},t.prototype._createSource=function(){this._setLocalSource([],[]);var t,e,n=this._sourceHost,i=this._getUpstreamSourceManagers(),r=!!i.length;if(Qf(n)){var o=n,a=void 0,s=void 0,l=void 0;if(r){var u=i[0];u.prepareSource(),a=(l=u.getSource()).data,s=l.sourceFormat,e=[u._getVersionSign()]}else s=$(a=o.get("data",!0))?Up:Wp,e=[];var h=this._getSourceMetaRawOption()||{},c=l&&l.metaRawOption||{},p=rt(h.seriesLayoutBy,c.seriesLayoutBy)||null,d=rt(h.sourceHeader,c.sourceHeader),f=rt(h.dimensions,c.dimensions);t=p!==c.seriesLayoutBy||!!d!=!!c.sourceHeader||f?[tf(a,{seriesLayoutBy:p,sourceHeader:d,dimensions:f},s)]:[]}else{var g=n;if(r){var y=this._applyTransform(i);t=y.sourceList,e=y.upstreamSignList}else{t=[tf(g.get("source",!0),this._getSourceMetaRawOption(),null)],e=[]}}this._setLocalSource(t,e)},t.prototype._applyTransform=function(t){var e,n=this._sourceHost,i=n.get("transform",!0),r=n.get("fromTransformResult",!0);if(null!=r){var o="";1!==t.length&&tg(o)}var a,s=[],l=[];return E(t,(function(t){t.prepareSource();var e=t.getSource(r||0),n="";null==r||e||tg(n),s.push(e),l.push(t._getVersionSign())})),i?e=function(t,e,n){var i=To(t),r=i.length,o="";r||wo(o);for(var a=0,s=r;a1||n>0&&!t.noHeader;return E(t.blocks,(function(t){var n=lg(t);n>=e&&(e=n+ +(i&&(!n||ag(t)&&!t.noHeader)))})),e}return 0}function ug(t,e,n,i){var r,o=e.noHeader,a=(r=lg(e),{html:ig[r],richText:rg[r]}),s=[],l=e.blocks||[];lt(!l||Y(l)),l=l||[];var u=t.orderMode;if(e.sortBlocks&&u){l=l.slice();var h={valueAsc:"asc",valueDesc:"desc"};if(_t(h,u)){var c=new kf(h[u],null);l.sort((function(t,e){return c.evaluate(t.sortParam,e.sortParam)}))}else"seriesDesc"===u&&l.reverse()}E(l,(function(n,r){var o=e.valueFormatter,l=sg(n)(o?A(A({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)}));var p="richText"===t.renderMode?s.join(a.richText):pg(s.join(""),o?n:a.html);if(o)return p;var d=mp(e.header,"ordinal",t.useUTC),f=ng(i,t.renderMode).nameStyle;return"richText"===t.renderMode?dg(t,d,f)+a.richText+p:pg('
'+re(d)+"
"+p,n)}function hg(t,e,n,i){var r=t.renderMode,o=e.noName,a=e.noValue,s=!e.markerType,l=e.name,u=t.useUTC,h=e.valueFormatter||t.valueFormatter||function(t){return z(t=Y(t)?t:[t],(function(t,e){return mp(t,Y(d)?d[e]:d,u)}))};if(!o||!a){var c=s?"":t.markupStyleCreator.makeTooltipMarker(e.markerType,e.markerColor||"#333",r),p=o?"":mp(l,"ordinal",u),d=e.valueType,f=a?[]:h(e.value,e.dataIndex),g=!s||!o,y=!s&&o,v=ng(i,r),m=v.nameStyle,x=v.valueStyle;return"richText"===r?(s?"":c)+(o?"":dg(t,p,m))+(a?"":function(t,e,n,i,r){var o=[r],a=i?10:20;return n&&o.push({padding:[0,0,0,a],align:"right"}),t.markupStyleCreator.wrapRichTextStyle(Y(e)?e.join(" "):e,o)}(t,f,g,y,x)):pg((s?"":c)+(o?"":function(t,e,n){return''+re(t)+""}(p,!s,m))+(a?"":function(t,e,n,i){var r=n?"10px":"20px",o=e?"float:right;margin-left:"+r:"";return t=Y(t)?t:[t],''+z(t,(function(t){return re(t)})).join("  ")+""}(f,g,y,x)),n)}}function cg(t,e,n,i,r,o){if(t)return sg(t)({useUTC:r,renderMode:n,orderMode:i,markupStyleCreator:e,valueFormatter:t.valueFormatter},t,0,o)}function pg(t,e){return'
'+t+'
'}function dg(t,e,n){return t.markupStyleCreator.wrapRichTextStyle(e,n)}function fg(t,e){return Sp(t.getData().getItemVisual(e,"style")[t.visualDrawType])}function gg(t,e){var n=t.get("padding");return null!=n?n:"richText"===e?[8,10]:10}var yg=function(){function t(){this.richTextStyles={},this._nextStyleNameId=mo()}return t.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},t.prototype.makeTooltipMarker=function(t,e,n){var i="richText"===n?this._generateStyleName():null,r=wp({color:e,type:t,renderMode:n,markerId:i});return U(r)?r:(this.richTextStyles[i]=r.style,r.content)},t.prototype.wrapRichTextStyle=function(t,e){var n={};Y(e)?E(e,(function(t){return A(n,t)})):A(n,e);var i=this._generateStyleName();return this.richTextStyles[i]=n,"{"+i+"|"+t+"}"},t}();function vg(t){var e,n,i,r,o=t.series,a=t.dataIndex,s=t.multipleSeries,l=o.getData(),u=l.mapDimensionsAll("defaultedTooltip"),h=u.length,c=o.getRawValue(a),p=Y(c),d=fg(o,a);if(h>1||p&&!h){var f=function(t,e,n,i,r){var o=e.getData(),a=V(t,(function(t,e,n){var i=o.getDimensionInfo(n);return t||i&&!1!==i.tooltip&&null!=i.displayName}),!1),s=[],l=[],u=[];function h(t,e){var n=o.getDimensionInfo(e);n&&!1!==n.otherDims.tooltip&&(a?u.push(og("nameValue",{markerType:"subItem",markerColor:r,name:n.displayName,value:t,valueType:n.type})):(s.push(t),l.push(n.type)))}return i.length?E(i,(function(t){h(mf(o,n,t),t)})):E(t,h),{inlineValues:s,inlineValueTypes:l,blocks:u}}(c,o,a,u,d);e=f.inlineValues,n=f.inlineValueTypes,i=f.blocks,r=f.inlineValues[0]}else if(h){var g=l.getDimensionInfo(u[0]);r=e=mf(l,a,u[0]),n=g.type}else r=e=p?c[0]:c;var y=No(o),v=y&&o.name||"",m=l.getName(a),x=s?v:m;return og("section",{header:v,noHeader:s||!y,sortParam:r,blocks:[og("nameValue",{markerType:"item",markerColor:d,name:x,noName:!ut(x),value:e,valueType:n,dataIndex:a})].concat(i||[])})}var mg=Vo();function xg(t,e){return t.getName(e)||t.getId(e)}var _g="__universalTransitionEnabled",bg=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}return n(e,t),e.prototype.init=function(t,e,n){this.seriesIndex=this.componentIndex,this.dataTask=wf({count:Sg,reset:Mg}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(t,n),(mg(this).sourceManager=new $f(this)).prepareSource();var i=this.getInitialData(t,n);Tg(i,this),this.dataTask.context.data=i,mg(this).dataBeforeProcessed=i,wg(this),this._initSelectedMapFromData(i)},e.prototype.mergeDefaultAndTheme=function(t,e){var n=Pp(this),i=n?Rp(t):{},r=this.subType;zp.hasClass(r)&&(r+="Series"),C(t,e.getTheme().get(this.subType)),C(t,this.getDefaultOption()),Co(t,"label",["show"]),this.fillDataTextStyle(t.data),n&&Op(t,i,n)},e.prototype.mergeOption=function(t,e){t=C(this.option,t,!0),this.fillDataTextStyle(t.data);var n=Pp(this);n&&Op(this.option,t,n);var i=mg(this).sourceManager;i.dirty(),i.prepareSource();var r=this.getInitialData(t,e);Tg(r,this),this.dataTask.dirty(),this.dataTask.context.data=r,mg(this).dataBeforeProcessed=r,wg(this),this._initSelectedMapFromData(r)},e.prototype.fillDataTextStyle=function(t){if(t&&!$(t))for(var e=["show"],n=0;nthis.getShallow("animationThreshold")&&(e=!1),!!e},e.prototype.restoreData=function(){this.dataTask.dirty()},e.prototype.getColorFromPalette=function(t,e,n){var i=this.ecModel,r=cd.prototype.getColorFromPalette.call(this,t,e,n);return r||(r=i.getColorFromPalette(t,e,n)),r},e.prototype.coordDimToDataDim=function(t){return this.getRawData().mapDimensionsAll(t)},e.prototype.getProgressive=function(){return this.get("progressive")},e.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},e.prototype.select=function(t,e){this._innerSelect(this.getData(e),t)},e.prototype.unselect=function(t,e){var n=this.option.selectedMap;if(n){var i=this.option.selectedMode,r=this.getData(e);if("series"===i||"all"===n)return this.option.selectedMap={},void(this._selectedDataIndicesMap={});for(var o=0;o=0&&n.push(r)}return n},e.prototype.isSelected=function(t,e){var n=this.option.selectedMap;if(!n)return!1;var i=this.getData(e);return("all"===n||n[xg(i,t)])&&!i.getItemModel(t).get(["select","disabled"])},e.prototype.isUniversalTransitionEnabled=function(){if(this[_g])return!0;var t=this.option.universalTransition;return!!t&&(!0===t||t&&t.enabled)},e.prototype._innerSelect=function(t,e){var n,i,r=this.option,o=r.selectedMode,a=e.length;if(o&&a)if("series"===o)r.selectedMap="all";else if("multiple"===o){q(r.selectedMap)||(r.selectedMap={});for(var s=r.selectedMap,l=0;l0&&this._innerSelect(t,e)}},e.registerClass=function(t){return zp.registerClass(t)},e.protoInitialize=function(){var t=e.prototype;t.type="series.__base__",t.seriesIndex=0,t.ignoreStyleOnData=!1,t.hasSymbolVisual=!1,t.defaultSymbol="circle",t.visualStyleAccessPath="itemStyle",t.visualDrawType="fill"}(),e}(zp);function wg(t){var e=t.name;No(t)||(t.name=function(t){var e=t.getRawData(),n=e.mapDimensionsAll("seriesName"),i=[];return E(n,(function(t){var n=e.getDimensionInfo(t);n.displayName&&i.push(n.displayName)})),i.join(" ")}(t)||e)}function Sg(t){return t.model.getRawData().count()}function Mg(t){var e=t.model;return e.setData(e.getRawData().cloneShallow()),Ig}function Ig(t,e){e.outputData&&t.end>e.outputData.count()&&e.model.getRawData().cloneShallow(e.outputData)}function Tg(t,e){E(vt(t.CHANGABLE_METHODS,t.DOWNSAMPLE_METHODS),(function(n){t.wrapMethod(n,H(Cg,e))}))}function Cg(t,e){var n=Dg(t);return n&&n.setOutputEnd((e||this).count()),e}function Dg(t){var e=(t.ecModel||{}).scheduler,n=e&&e.getPipeline(t.uid);if(n){var i=n.currentTask;if(i){var r=i.agentStubMap;r&&(i=r.get(t.uid))}return i}}R(bg,_f),R(bg,cd),Jo(bg,zp);var Ag=function(){function t(){this.group=new Br,this.uid=Ac("viewComponent")}return t.prototype.init=function(t,e){},t.prototype.render=function(t,e,n,i){},t.prototype.dispose=function(t,e){},t.prototype.updateView=function(t,e,n,i){},t.prototype.updateLayout=function(t,e,n,i){},t.prototype.updateVisual=function(t,e,n,i){},t.prototype.toggleBlurSeries=function(t,e,n){},t.prototype.eachRendered=function(t){var e=this.group;e&&e.traverse(t)},t}();function kg(){var t=Vo();return function(e){var n=t(e),i=e.pipelineContext,r=!!n.large,o=!!n.progressiveRender,a=n.large=!(!i||!i.large),s=n.progressiveRender=!(!i||!i.progressiveRender);return!(r===a&&o===s)&&"reset"}}$o(Ag),na(Ag);var Lg=Vo(),Pg=kg(),Og=function(){function t(){this.group=new Br,this.uid=Ac("viewChart"),this.renderTask=wf({plan:Eg,reset:zg}),this.renderTask.context={view:this}}return t.prototype.init=function(t,e){},t.prototype.render=function(t,e,n,i){0},t.prototype.highlight=function(t,e,n,i){var r=t.getData(i&&i.dataType);r&&Ng(r,i,"emphasis")},t.prototype.downplay=function(t,e,n,i){var r=t.getData(i&&i.dataType);r&&Ng(r,i,"normal")},t.prototype.remove=function(t,e){this.group.removeAll()},t.prototype.dispose=function(t,e){},t.prototype.updateView=function(t,e,n,i){this.render(t,e,n,i)},t.prototype.updateLayout=function(t,e,n,i){this.render(t,e,n,i)},t.prototype.updateVisual=function(t,e,n,i){this.render(t,e,n,i)},t.prototype.eachRendered=function(t){Jh(this.group,t)},t.markUpdateMethod=function(t,e){Lg(t).updateMethod=e},t.protoInitialize=void(t.prototype.type="chart"),t}();function Rg(t,e,n){t&&Ql(t)&&("emphasis"===e?Ol:Rl)(t,n)}function Ng(t,e,n){var i=zo(t,e),r=e&&null!=e.highlightKey?function(t){var e=sl[t];return null==e&&al<=32&&(e=sl[t]=al++),e}(e.highlightKey):null;null!=i?E(To(i),(function(e){Rg(t.getItemGraphicEl(e),n,r)})):t.eachItemGraphicEl((function(t){Rg(t,n,r)}))}function Eg(t){return Pg(t.model)}function zg(t){var e=t.model,n=t.ecModel,i=t.api,r=t.payload,o=e.pipelineContext.progressiveRender,a=t.view,s=r&&Lg(r).updateMethod,l=o?"incrementalPrepareRender":s&&a[s]?s:"render";return"render"!==l&&a[l](e,n,i,r),Vg[l]}$o(Og),na(Og);var Vg={incrementalPrepareRender:{progress:function(t,e){e.view.incrementalRender(t,e.model,e.ecModel,e.api,e.payload)}},render:{forceFirstProgress:!0,progress:function(t,e){e.view.render(e.model,e.ecModel,e.api,e.payload)}}},Bg="\0__throttleOriginMethod",Fg="\0__throttleRate",Gg="\0__throttleType";function Wg(t,e,n){var i,r,o,a,s,l=0,u=0,h=null;function c(){u=(new Date).getTime(),h=null,t.apply(o,a||[])}e=e||0;var p=function(){for(var t=[],p=0;p=0?c():h=setTimeout(c,-r),l=i};return p.clear=function(){h&&(clearTimeout(h),h=null)},p.debounceNextCall=function(t){s=t},p}function Hg(t,e,n,i){var r=t[e];if(r){var o=r[Bg]||r,a=r[Gg];if(r[Fg]!==n||a!==i){if(null==n||!i)return t[e]=o;(r=t[e]=Wg(o,n,"debounce"===i))[Bg]=o,r[Gg]=i,r[Fg]=n}return r}}function Yg(t,e){var n=t[e];n&&n[Bg]&&(n.clear&&n.clear(),t[e]=n[Bg])}var Xg=Vo(),Ug={itemStyle:ia(Mc,!0),lineStyle:ia(bc,!0)},Zg={lineStyle:"stroke",itemStyle:"fill"};function jg(t,e){var n=t.visualStyleMapper||Ug[e];return n||(console.warn("Unknown style type '"+e+"'."),Ug.itemStyle)}function qg(t,e){var n=t.visualDrawType||Zg[e];return n||(console.warn("Unknown style type '"+e+"'."),"fill")}var Kg={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=t.getModel(i),o=jg(t,i)(r),a=r.getShallow("decal");a&&(n.setVisual("decal",a),a.dirty=!0);var s=qg(t,i),l=o[s],u=X(l)?l:null,h="auto"===o.fill||"auto"===o.stroke;if(!o[s]||u||h){var c=t.getColorFromPalette(t.name,null,e.getSeriesCount());o[s]||(o[s]=c,n.setVisual("colorFromPalette",!0)),o.fill="auto"===o.fill||X(o.fill)?c:o.fill,o.stroke="auto"===o.stroke||X(o.stroke)?c:o.stroke}if(n.setVisual("style",o),n.setVisual("drawType",s),!e.isSeriesFiltered(t)&&u)return n.setVisual("colorFromPalette",!1),{dataEach:function(e,n){var i=t.getDataParams(n),r=A({},o);r[s]=u(i),e.setItemVisual(n,"style",r)}}}},$g=new Cc,Jg={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){if(!t.ignoreStyleOnData&&!e.isSeriesFiltered(t)){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=jg(t,i),o=n.getVisual("drawType");return{dataEach:n.hasItemOption?function(t,e){var n=t.getRawDataItem(e);if(n&&n[i]){$g.option=n[i];var a=r($g);A(t.ensureUniqueItemVisual(e,"style"),a),$g.option.decal&&(t.setItemVisual(e,"decal",$g.option.decal),$g.option.decal.dirty=!0),o in a&&t.setItemVisual(e,"colorFromPalette",!1)}}:null}}}},Qg={performRawSeries:!0,overallReset:function(t){var e=yt();t.eachSeries((function(t){var n=t.getColorBy();if(!t.isColorBySeries()){var i=t.type+"-"+n,r=e.get(i);r||(r={},e.set(i,r)),Xg(t).scope=r}})),t.eachSeries((function(e){if(!e.isColorBySeries()&&!t.isSeriesFiltered(e)){var n=e.getRawData(),i={},r=e.getData(),o=Xg(e).scope,a=e.visualStyleAccessPath||"itemStyle",s=qg(e,a);r.each((function(t){var e=r.getRawIndex(t);i[e]=t})),n.each((function(t){var a=i[t];if(r.getItemVisual(a,"colorFromPalette")){var l=r.ensureUniqueItemVisual(a,"style"),u=n.getName(t)||t+"",h=n.count();l[s]=e.getColorFromPalette(u,o,h)}}))}}))}},ty=Math.PI;var ey=function(){function t(t,e,n,i){this._stageTaskMap=yt(),this.ecInstance=t,this.api=e,n=this._dataProcessorHandlers=n.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=n.concat(i)}return t.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each((function(t){var e=t.overallTask;e&&e.dirty()}))},t.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var n=this._pipelineMap.get(t.__pipeline.id),i=n.context,r=!e&&n.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>n.blockIndex?n.step:null,o=i&&i.modDataCount;return{step:r,modBy:null!=o?Math.ceil(o/r):null,modDataCount:o}}},t.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},t.prototype.updateStreamModes=function(t,e){var n=this._pipelineMap.get(t.uid),i=t.getData().count(),r=n.progressiveEnabled&&e.incrementalPrepareRender&&i>=n.threshold,o=t.get("large")&&i>=t.get("largeThreshold"),a="mod"===t.get("progressiveChunkMode")?i:null;t.pipelineContext=n.context={progressiveRender:r,modDataCount:a,large:o}},t.prototype.restorePipelines=function(t){var e=this,n=e._pipelineMap=yt();t.eachSeries((function(t){var i=t.getProgressive(),r=t.uid;n.set(r,{id:r,head:null,tail:null,threshold:t.getProgressiveThreshold(),progressiveEnabled:i&&!(t.preventIncremental&&t.preventIncremental()),blockIndex:-1,step:Math.round(i||700),count:0}),e._pipe(t,t.dataTask)}))},t.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),n=this.api;E(this._allHandlers,(function(i){var r=t.get(i.uid)||t.set(i.uid,{}),o="";lt(!(i.reset&&i.overallReset),o),i.reset&&this._createSeriesStageTask(i,r,e,n),i.overallReset&&this._createOverallStageTask(i,r,e,n)}),this)},t.prototype.prepareView=function(t,e,n,i){var r=t.renderTask,o=r.context;o.model=e,o.ecModel=n,o.api=i,r.__block=!t.incrementalPrepareRender,this._pipe(e,r)},t.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},t.prototype.performVisualTasks=function(t,e,n){this._performStageTasks(this._visualHandlers,t,e,n)},t.prototype._performStageTasks=function(t,e,n,i){i=i||{};var r=!1,o=this;function a(t,e){return t.setDirty&&(!t.dirtyMap||t.dirtyMap.get(e.__pipeline.id))}E(t,(function(t,s){if(!i.visualType||i.visualType===t.visualType){var l=o._stageTaskMap.get(t.uid),u=l.seriesTaskMap,h=l.overallTask;if(h){var c,p=h.agentStubMap;p.each((function(t){a(i,t)&&(t.dirty(),c=!0)})),c&&h.dirty(),o.updatePayload(h,n);var d=o.getPerformArgs(h,i.block);p.each((function(t){t.perform(d)})),h.perform(d)&&(r=!0)}else u&&u.each((function(s,l){a(i,s)&&s.dirty();var u=o.getPerformArgs(s,i.block);u.skip=!t.performRawSeries&&e.isSeriesFiltered(s.context.model),o.updatePayload(s,n),s.perform(u)&&(r=!0)}))}})),this.unfinished=r||this.unfinished},t.prototype.performSeriesTasks=function(t){var e;t.eachSeries((function(t){e=t.dataTask.perform()||e})),this.unfinished=e||this.unfinished},t.prototype.plan=function(){this._pipelineMap.each((function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)}))},t.prototype.updatePayload=function(t,e){"remain"!==e&&(t.context.payload=e)},t.prototype._createSeriesStageTask=function(t,e,n,i){var r=this,o=e.seriesTaskMap,a=e.seriesTaskMap=yt(),s=t.seriesType,l=t.getTargetSeries;function u(e){var s=e.uid,l=a.set(s,o&&o.get(s)||wf({plan:ay,reset:sy,count:hy}));l.context={model:e,ecModel:n,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:r},r._pipe(e,l)}t.createOnAllSeries?n.eachRawSeries(u):s?n.eachRawSeriesByType(s,u):l&&l(n,i).each(u)},t.prototype._createOverallStageTask=function(t,e,n,i){var r=this,o=e.overallTask=e.overallTask||wf({reset:ny});o.context={ecModel:n,api:i,overallReset:t.overallReset,scheduler:r};var a=o.agentStubMap,s=o.agentStubMap=yt(),l=t.seriesType,u=t.getTargetSeries,h=!0,c=!1,p="";function d(t){var e=t.uid,n=s.set(e,a&&a.get(e)||(c=!0,wf({reset:iy,onDirty:oy})));n.context={model:t,overallProgress:h},n.agent=o,n.__block=h,r._pipe(t,n)}lt(!t.createOnAllSeries,p),l?n.eachRawSeriesByType(l,d):u?u(n,i).each(d):(h=!1,E(n.getSeries(),d)),c&&o.dirty()},t.prototype._pipe=function(t,e){var n=t.uid,i=this._pipelineMap.get(n);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},t.wrapStageHandler=function(t,e){return X(t)&&(t={overallReset:t,seriesType:cy(t)}),t.uid=Ac("stageHandler"),e&&(t.visualType=e),t},t}();function ny(t){t.overallReset(t.ecModel,t.api,t.payload)}function iy(t){return t.overallProgress&&ry}function ry(){this.agent.dirty(),this.getDownstream().dirty()}function oy(){this.agent&&this.agent.dirty()}function ay(t){return t.plan?t.plan(t.model,t.ecModel,t.api,t.payload):null}function sy(t){t.useClearVisual&&t.data.clearAllVisual();var e=t.resetDefines=To(t.reset(t.model,t.ecModel,t.api,t.payload));return e.length>1?z(e,(function(t,e){return uy(e)})):ly}var ly=uy(0);function uy(t){return function(e,n){var i=n.data,r=n.resetDefines[t];if(r&&r.dataEach)for(var o=e.start;o0&&h===r.length-u.length){var c=r.slice(0,h);"data"!==c&&(e.mainType=c,e[u.toLowerCase()]=t,s=!0)}}a.hasOwnProperty(r)&&(n[r]=t,s=!0),s||(i[r]=t)}))}return{cptQuery:e,dataQuery:n,otherQuery:i}},t.prototype.filter=function(t,e){var n=this.eventInfo;if(!n)return!0;var i=n.targetEl,r=n.packedEvent,o=n.model,a=n.view;if(!o||!a)return!0;var s=e.cptQuery,l=e.dataQuery;return u(s,o,"mainType")&&u(s,o,"subType")&&u(s,o,"index","componentIndex")&&u(s,o,"name")&&u(s,o,"id")&&u(l,r,"name")&&u(l,r,"dataIndex")&&u(l,r,"dataType")&&(!a.filterForExposedEvent||a.filterForExposedEvent(t,e.otherQuery,i,r));function u(t,e,n,i){return null==t[n]||e[i||n]===t[n]}},t.prototype.afterTrigger=function(){this.eventInfo=null},t}(),My=["symbol","symbolSize","symbolRotate","symbolOffset"],Iy=My.concat(["symbolKeepAspect"]),Ty={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData();if(t.legendIcon&&n.setVisual("legendIcon",t.legendIcon),t.hasSymbolVisual){for(var i={},r={},o=!1,a=0;a=0&&jy(l)?l:.5,t.createRadialGradient(a,s,0,a,s,l)}(t,e,n):function(t,e,n){var i=null==e.x?0:e.x,r=null==e.x2?1:e.x2,o=null==e.y?0:e.y,a=null==e.y2?0:e.y2;return e.global||(i=i*n.width+n.x,r=r*n.width+n.x,o=o*n.height+n.y,a=a*n.height+n.y),i=jy(i)?i:0,r=jy(r)?r:1,o=jy(o)?o:0,a=jy(a)?a:0,t.createLinearGradient(i,o,r,a)}(t,e,n),r=e.colorStops,o=0;o0&&(e=i.lineDash,n=i.lineWidth,e&&"solid"!==e&&n>0?"dashed"===e?[4*n,2*n]:"dotted"===e?[n]:j(e)?[e]:Y(e)?e:null:null),o=i.lineDashOffset;if(r){var a=i.strokeNoScale&&t.getLineScale?t.getLineScale():1;a&&1!==a&&(r=z(r,(function(t){return t/a})),o/=a)}return[r,o]}var Qy=new hs(!0);function tv(t){var e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))}function ev(t){return"string"==typeof t&&"none"!==t}function nv(t){var e=t.fill;return null!=e&&"none"!==e}function iv(t,e){if(null!=e.fillOpacity&&1!==e.fillOpacity){var n=t.globalAlpha;t.globalAlpha=e.fillOpacity*e.opacity,t.fill(),t.globalAlpha=n}else t.fill()}function rv(t,e){if(null!=e.strokeOpacity&&1!==e.strokeOpacity){var n=t.globalAlpha;t.globalAlpha=e.strokeOpacity*e.opacity,t.stroke(),t.globalAlpha=n}else t.stroke()}function ov(t,e,n){var i=la(e.image,e.__image,n);if(ha(i)){var r=t.createPattern(i,e.repeat||"repeat");if("function"==typeof DOMMatrix&&r&&r.setTransform){var o=new DOMMatrix;o.translateSelf(e.x||0,e.y||0),o.rotateSelf(0,0,(e.rotation||0)*wt),o.scaleSelf(e.scaleX||1,e.scaleY||1),r.setTransform(o)}return r}}var av=["shadowBlur","shadowOffsetX","shadowOffsetY"],sv=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function lv(t,e,n,i,r){var o=!1;if(!i&&e===(n=n||{}))return!1;if(i||e.opacity!==n.opacity){cv(t,r),o=!0;var a=Math.max(Math.min(e.opacity,1),0);t.globalAlpha=isNaN(a)?Ma.opacity:a}(i||e.blend!==n.blend)&&(o||(cv(t,r),o=!0),t.globalCompositeOperation=e.blend||Ma.blend);for(var s=0;s0&&t.unfinished);t.unfinished||this._zr.flush()}}},e.prototype.getDom=function(){return this._dom},e.prototype.getId=function(){return this.id},e.prototype.getZr=function(){return this._zr},e.prototype.isSSR=function(){return this._ssr},e.prototype.setOption=function(t,e,n){if(!this[Dv])if(this._disposed)om(this.id);else{var i,r,o;if(q(e)&&(n=e.lazyUpdate,i=e.silent,r=e.replaceMerge,o=e.transition,e=e.notMerge),this[Dv]=!0,!this._model||e){var a=new Md(this._api),s=this._theme,l=this._model=new gd;l.scheduler=this._scheduler,l.ssr=this._ssr,l.init(null,null,null,s,this._locale,a)}this._model.setOption(t,{replaceMerge:r},um);var u={seriesTransition:o,optionChanged:!0};if(n)this[Av]={silent:i,updateParams:u},this[Dv]=!1,this.getZr().wakeUp();else{try{Ev(this),Bv.update.call(this,null,u)}catch(t){throw this[Av]=null,this[Dv]=!1,t}this._ssr||this._zr.flush(),this[Av]=null,this[Dv]=!1,Hv.call(this,i),Yv.call(this,i)}}},e.prototype.setTheme=function(){bo()},e.prototype.getModel=function(){return this._model},e.prototype.getOption=function(){return this._model&&this._model.getOption()},e.prototype.getWidth=function(){return this._zr.getWidth()},e.prototype.getHeight=function(){return this._zr.getHeight()},e.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||r.hasGlobalWindow&&window.devicePixelRatio||1},e.prototype.getRenderedCanvas=function(t){return this.renderToCanvas(t)},e.prototype.renderToCanvas=function(t){t=t||{};var e=this._zr.painter;return e.getRenderedCanvas({backgroundColor:t.backgroundColor||this._model.get("backgroundColor"),pixelRatio:t.pixelRatio||this.getDevicePixelRatio()})},e.prototype.renderToSVGString=function(t){t=t||{};var e=this._zr.painter;return e.renderToString({useViewBox:t.useViewBox})},e.prototype.getSvgDataURL=function(){if(r.svgSupported){var t=this._zr;return E(t.storage.getDisplayList(),(function(t){t.stopAnimation(null,!0)})),t.painter.toDataURL()}},e.prototype.getDataURL=function(t){if(!this._disposed){var e=(t=t||{}).excludeComponents,n=this._model,i=[],r=this;E(e,(function(t){n.eachComponent({mainType:t},(function(t){var e=r._componentsMap[t.__viewId];e.group.ignore||(i.push(e),e.group.ignore=!0)}))}));var o="svg"===this._zr.painter.getType()?this.getSvgDataURL():this.renderToCanvas(t).toDataURL("image/"+(t&&t.type||"png"));return E(i,(function(t){t.group.ignore=!1})),o}om(this.id)},e.prototype.getConnectedDataURL=function(t){if(!this._disposed){var e="svg"===t.type,n=this.group,i=Math.min,r=Math.max,o=1/0;if(fm[n]){var a=o,s=o,l=-1/0,u=-1/0,c=[],p=t&&t.pixelRatio||this.getDevicePixelRatio();E(dm,(function(o,h){if(o.group===n){var p=e?o.getZr().painter.getSvgDom().innerHTML:o.renderToCanvas(T(t)),d=o.getDom().getBoundingClientRect();a=i(d.left,a),s=i(d.top,s),l=r(d.right,l),u=r(d.bottom,u),c.push({dom:p,left:d.left,top:d.top})}}));var d=(l*=p)-(a*=p),f=(u*=p)-(s*=p),g=h.createCanvas(),y=Yr(g,{renderer:e?"svg":"canvas"});if(y.resize({width:d,height:f}),e){var v="";return E(c,(function(t){var e=t.left-a,n=t.top-s;v+=''+t.dom+""})),y.painter.getSvgRoot().innerHTML=v,t.connectedBackgroundColor&&y.painter.setBackgroundColor(t.connectedBackgroundColor),y.refreshImmediately(),y.painter.toDataURL()}return t.connectedBackgroundColor&&y.add(new Ws({shape:{x:0,y:0,width:d,height:f},style:{fill:t.connectedBackgroundColor}})),E(c,(function(t){var e=new Ns({style:{x:t.left*p-a,y:t.top*p-s,image:t.dom}});y.add(e)})),y.refreshImmediately(),g.toDataURL("image/"+(t&&t.type||"png"))}return this.getDataURL(t)}om(this.id)},e.prototype.convertToPixel=function(t,e){return Fv(this,"convertToPixel",t,e)},e.prototype.convertFromPixel=function(t,e){return Fv(this,"convertFromPixel",t,e)},e.prototype.containPixel=function(t,e){var n;if(!this._disposed)return E(Fo(this._model,t),(function(t,i){i.indexOf("Models")>=0&&E(t,(function(t){var r=t.coordinateSystem;if(r&&r.containPoint)n=n||!!r.containPoint(e);else if("seriesModels"===i){var o=this._chartsMap[t.__viewId];o&&o.containPoint&&(n=n||o.containPoint(e,t))}else 0}),this)}),this),!!n;om(this.id)},e.prototype.getVisual=function(t,e){var n=Fo(this._model,t,{defaultMainType:"series"}),i=n.seriesModel;var r=i.getData(),o=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?r.indexOfRawIndex(n.dataIndex):null;return null!=o?Dy(r,o,e):Ay(r,e)},e.prototype.getViewOfComponentModel=function(t){return this._componentsMap[t.__viewId]},e.prototype.getViewOfSeriesModel=function(t){return this._chartsMap[t.__viewId]},e.prototype._initEvents=function(){var t,e,n,i=this;E(rm,(function(t){var e=function(e){var n,r=i.getModel(),o=e.target,a="globalout"===t;if(a?n={}:o&&Oy(o,(function(t){var e=rl(t);if(e&&null!=e.dataIndex){var i=e.dataModel||r.getSeriesByIndex(e.seriesIndex);return n=i&&i.getDataParams(e.dataIndex,e.dataType,o)||{},!0}if(e.eventData)return n=A({},e.eventData),!0}),!0),n){var s=n.componentType,l=n.componentIndex;"markLine"!==s&&"markPoint"!==s&&"markArea"!==s||(s="series",l=n.seriesIndex);var u=s&&null!=l&&r.getComponent(s,l),h=u&&i["series"===u.mainType?"_chartsMap":"_componentsMap"][u.__viewId];0,n.event=e,n.type=t,i._$eventProcessor.eventInfo={targetEl:o,packedEvent:n,model:u,view:h},i.trigger(t,n)}};e.zrEventfulCallAtLast=!0,i._zr.on(t,e,i)})),E(sm,(function(t,e){i._messageCenter.on(e,(function(t){this.trigger(e,t)}),i)})),E(["selectchanged"],(function(t){i._messageCenter.on(t,(function(e){this.trigger(t,e)}),i)})),t=this._messageCenter,e=this,n=this._api,t.on("selectchanged",(function(t){var i=n.getModel();t.isFromClick?(Py("map","selectchanged",e,i,t),Py("pie","selectchanged",e,i,t)):"select"===t.fromAction?(Py("map","selected",e,i,t),Py("pie","selected",e,i,t)):"unselect"===t.fromAction&&(Py("map","unselected",e,i,t),Py("pie","unselected",e,i,t))}))},e.prototype.isDisposed=function(){return this._disposed},e.prototype.clear=function(){this._disposed?om(this.id):this.setOption({series:[]},!0)},e.prototype.dispose=function(){if(this._disposed)om(this.id);else{this._disposed=!0,this.getDom()&&Xo(this.getDom(),vm,"");var t=this,e=t._api,n=t._model;E(t._componentsViews,(function(t){t.dispose(n,e)})),E(t._chartsViews,(function(t){t.dispose(n,e)})),t._zr.dispose(),t._dom=t._model=t._chartsMap=t._componentsMap=t._chartsViews=t._componentsViews=t._scheduler=t._api=t._zr=t._throttledZrFlush=t._theme=t._coordSysMgr=t._messageCenter=null,delete dm[t.id]}},e.prototype.resize=function(t){if(!this[Dv])if(this._disposed)om(this.id);else{this._zr.resize(t);var e=this._model;if(this._loadingFX&&this._loadingFX.resize(),e){var n=e.resetOption("media"),i=t&&t.silent;this[Av]&&(null==i&&(i=this[Av].silent),n=!0,this[Av]=null),this[Dv]=!0;try{n&&Ev(this),Bv.update.call(this,{type:"resize",animation:A({duration:0},t&&t.animation)})}catch(t){throw this[Dv]=!1,t}this[Dv]=!1,Hv.call(this,i),Yv.call(this,i)}}},e.prototype.showLoading=function(t,e){if(this._disposed)om(this.id);else if(q(t)&&(e=t,t=""),t=t||"default",this.hideLoading(),pm[t]){var n=pm[t](this._api,e),i=this._zr;this._loadingFX=n,i.add(n)}},e.prototype.hideLoading=function(){this._disposed?om(this.id):(this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null)},e.prototype.makeActionFromEvent=function(t){var e=A({},t);return e.type=sm[t.type],e},e.prototype.dispatchAction=function(t,e){if(this._disposed)om(this.id);else if(q(e)||(e={silent:!!e}),am[t.type]&&this._model)if(this[Dv])this._pendingActions.push(t);else{var n=e.silent;Wv.call(this,t,n);var i=e.flush;i?this._zr.flush():!1!==i&&r.browser.weChat&&this._throttledZrFlush(),Hv.call(this,n),Yv.call(this,n)}},e.prototype.updateLabelLayout=function(){wv.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},e.prototype.appendData=function(t){if(this._disposed)om(this.id);else{var e=t.seriesIndex,n=this.getModel().getSeriesByIndex(e);0,n.appendData(t),this._scheduler.unfinished=!0,this.getZr().wakeUp()}},e.internalField=function(){function t(t){t.clearColorPalette(),t.eachSeries((function(t){t.clearColorPalette()}))}function e(t){for(var e=[],n=t.currentStates,i=0;i0?{duration:o,delay:i.get("delay"),easing:i.get("easing")}:null;n.eachRendered((function(t){if(t.states&&t.states.emphasis){if(xh(t))return;if(t instanceof ks&&function(t){var e=ll(t);e.normalFill=t.style.fill,e.normalStroke=t.style.stroke;var n=t.states.select||{};e.selectFill=n.style&&n.style.fill||null,e.selectStroke=n.style&&n.style.stroke||null}(t),t.__dirty){var n=t.prevStates;n&&t.useStates(n)}if(r){t.stateTransition=a;var i=t.getTextContent(),o=t.getTextGuideLine();i&&(i.stateTransition=a),o&&(o.stateTransition=a)}t.__dirty&&e(t)}}))}Ev=function(t){var e=t._scheduler;e.restorePipelines(t._model),e.prepareStageTasks(),zv(t,!0),zv(t,!1),e.plan()},zv=function(t,e){for(var n=t._model,i=t._scheduler,r=e?t._componentsViews:t._chartsViews,o=e?t._componentsMap:t._chartsMap,a=t._zr,s=t._api,l=0;le.get("hoverLayerThreshold")&&!r.node&&!r.worker&&e.eachSeries((function(e){if(!e.preventUsingHoverLayer){var n=t._chartsMap[e.__viewId];n.__alive&&n.eachRendered((function(t){t.states.emphasis&&(t.states.emphasis.hoverLayer=!0)}))}}))}(t,e),wv.trigger("series:afterupdate",e,n,l)},Jv=function(t){t[kv]=!0,t.getZr().wakeUp()},Qv=function(t){t[kv]&&(t.getZr().storage.traverse((function(t){xh(t)||e(t)})),t[kv]=!1)},Kv=function(t){return new(function(e){function i(){return null!==e&&e.apply(this,arguments)||this}return n(i,e),i.prototype.getCoordinateSystems=function(){return t._coordSysMgr.getCoordinateSystems()},i.prototype.getComponentByElement=function(e){for(;e;){var n=e.__ecComponentInfo;if(null!=n)return t._model.getComponent(n.mainType,n.index);e=e.parent}},i.prototype.enterEmphasis=function(e,n){Ol(e,n),Jv(t)},i.prototype.leaveEmphasis=function(e,n){Rl(e,n),Jv(t)},i.prototype.enterBlur=function(e){Nl(e),Jv(t)},i.prototype.leaveBlur=function(e){El(e),Jv(t)},i.prototype.enterSelect=function(e){zl(e),Jv(t)},i.prototype.leaveSelect=function(e){Vl(e),Jv(t)},i.prototype.getModel=function(){return t.getModel()},i.prototype.getViewOfComponentModel=function(e){return t.getViewOfComponentModel(e)},i.prototype.getViewOfSeriesModel=function(e){return t.getViewOfSeriesModel(e)},i}(_d))(t)},$v=function(t){function e(t,e){for(var n=0;n=0)){Lm.push(n);var o=ey.wrapStageHandler(n,r);o.__prio=e,o.__raw=n,t.push(o)}}function Om(t,e){pm[t]=e}function Rm(t,e,n){var i=Mv("registerMap");i&&i(t,e,n)}var Nm=function(t){var e=(t=T(t)).type,n="";e||wo(n);var i=e.split(":");2!==i.length&&wo(n);var r=!1;"echarts"===i[0]&&(e=i[1],r=!0),t.__isBuiltIn=r,Vf.set(e,t)};km(Iv,Kg),km(Tv,Jg),km(Tv,Qg),km(Iv,Ty),km(Tv,Cy),km(7e3,(function(t,e){t.eachRawSeries((function(n){if(!t.isSeriesFiltered(n)){var i=n.getData();i.hasItemVisual()&&i.each((function(t){var n=i.getItemVisual(t,"decal");n&&(i.ensureUniqueItemVisual(t,"style").decal=mv(n,e))}));var r=i.getVisual("decal");if(r)i.getVisual("style").decal=mv(r,e)}}))})),wm(Xd),Sm(900,(function(t){var e=yt();t.eachSeries((function(t){var n=t.get("stack");if(n){var i=e.get(n)||e.set(n,[]),r=t.getData(),o={stackResultDimension:r.getCalculationInfo("stackResultDimension"),stackedOverDimension:r.getCalculationInfo("stackedOverDimension"),stackedDimension:r.getCalculationInfo("stackedDimension"),stackedByDimension:r.getCalculationInfo("stackedByDimension"),isStackedByIndex:r.getCalculationInfo("isStackedByIndex"),data:r,seriesModel:t};if(!o.stackedDimension||!o.isStackedByIndex&&!o.stackedByDimension)return;i.length&&r.setCalculationInfo("stackedOnSeries",i[i.length-1].seriesModel),i.push(o)}})),e.each(Ud)})),Om("default",(function(t,e){k(e=e||{},{text:"loading",textColor:"#000",fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255, 255, 255, 0.8)",showSpinner:!0,color:"#5470c6",spinnerRadius:10,lineWidth:5,zlevel:0});var n=new Br,i=new Ws({style:{fill:e.maskColor},zlevel:e.zlevel,z:1e4});n.add(i);var r,o=new Xs({style:{text:e.text,fill:e.textColor,fontSize:e.fontSize,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontFamily:e.fontFamily},zlevel:e.zlevel,z:10001}),a=new Ws({style:{fill:"none"},textContent:o,textConfig:{position:"right",distance:10},zlevel:e.zlevel,z:10001});return n.add(a),e.showSpinner&&((r=new nh({shape:{startAngle:-ty/2,endAngle:-ty/2+.1,r:e.spinnerRadius},style:{stroke:e.color,lineCap:"round",lineWidth:e.lineWidth},zlevel:e.zlevel,z:10001})).animateShape(!0).when(1e3,{endAngle:3*ty/2}).start("circularInOut"),r.animateShape(!0).when(1e3,{startAngle:3*ty/2}).delay(300).start("circularInOut"),n.add(r)),n.resize=function(){var n=o.getBoundingRect().width,s=e.showSpinner?e.spinnerRadius:0,l=(t.getWidth()-2*s-(e.showSpinner&&n?10:0)-n)/2-(e.showSpinner&&n?0:5+n/2)+(e.showSpinner?0:n/2)+(n?0:s),u=t.getHeight()/2;e.showSpinner&&r.setShape({cx:l,cy:u}),a.setShape({x:l-s,y:u-s,width:2*s,height:2*s}),i.setShape({x:0,y:0,width:t.getWidth(),height:t.getHeight()})},n.resize(),n})),Cm({type:dl,event:dl,update:dl},bt),Cm({type:fl,event:fl,update:fl},bt),Cm({type:gl,event:gl,update:gl},bt),Cm({type:yl,event:yl,update:yl},bt),Cm({type:vl,event:vl,update:vl},bt),bm("light",vy),bm("dark",wy);var Em=[],zm={registerPreprocessor:wm,registerProcessor:Sm,registerPostInit:Mm,registerPostUpdate:Im,registerUpdateLifecycle:Tm,registerAction:Cm,registerCoordinateSystem:Dm,registerLayout:Am,registerVisual:km,registerTransform:Nm,registerLoading:Om,registerMap:Rm,registerImpl:function(t,e){Sv[t]=e},PRIORITY:Cv,ComponentModel:zp,ComponentView:Ag,SeriesModel:bg,ChartView:Og,registerComponentModel:function(t){zp.registerClass(t)},registerComponentView:function(t){Ag.registerClass(t)},registerSeriesModel:function(t){bg.registerClass(t)},registerChartView:function(t){Og.registerClass(t)},registerSubTypeDefaulter:function(t,e){zp.registerSubTypeDefaulter(t,e)},registerPainter:function(t,e){Xr(t,e)}};function Vm(t){Y(t)?E(t,(function(t){Vm(t)})):P(Em,t)>=0||(Em.push(t),X(t)&&(t={install:t}),t.install(zm))}function Bm(t){return null==t?0:t.length||1}function Fm(t){return t}var Gm=function(){function t(t,e,n,i,r,o){this._old=t,this._new=e,this._oldKeyGetter=n||Fm,this._newKeyGetter=i||Fm,this.context=r,this._diffModeMultiple="multiple"===o}return t.prototype.add=function(t){return this._add=t,this},t.prototype.update=function(t){return this._update=t,this},t.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},t.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},t.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},t.prototype.remove=function(t){return this._remove=t,this},t.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},t.prototype._executeOneToOne=function(){var t=this._old,e=this._new,n={},i=new Array(t.length),r=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,n,r,"_newKeyGetter");for(var o=0;o1){var u=s.shift();1===s.length&&(n[a]=s[0]),this._update&&this._update(u,o)}else 1===l?(n[a]=null,this._update&&this._update(s,o)):this._remove&&this._remove(o)}this._performRestAdd(r,n)},t.prototype._executeMultiple=function(){var t=this._old,e=this._new,n={},i={},r=[],o=[];this._initIndexMap(t,n,r,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var a=0;a1&&1===c)this._updateManyToOne&&this._updateManyToOne(u,l),i[s]=null;else if(1===h&&c>1)this._updateOneToMany&&this._updateOneToMany(u,l),i[s]=null;else if(1===h&&1===c)this._update&&this._update(u,l),i[s]=null;else if(h>1&&c>1)this._updateManyToMany&&this._updateManyToMany(u,l),i[s]=null;else if(h>1)for(var p=0;p1)for(var a=0;a30}var Qm,tx,ex,nx,ix,rx,ox,ax=q,sx=z,lx="undefined"==typeof Int32Array?Array:Int32Array,ux=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],hx=["_approximateExtent"],cx=function(){function t(t,e){var n;this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","lttbDownSample"];var i=!1;qm(t)?(n=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,n=t),n=n||["x","y"];for(var r={},o=[],a={},s=!1,l={},u=0;u=e)){var n=this._store.getProvider();this._updateOrdinalMeta();var i=this._nameList,r=this._idList;if(n.getSource().sourceFormat===Wp&&!n.pure)for(var o=[],a=t;a0},t.prototype.ensureUniqueItemVisual=function(t,e){var n=this._itemVisuals,i=n[t];i||(i=n[t]={});var r=i[e];return null==r&&(Y(r=this.getVisual(e))?r=r.slice():ax(r)&&(r=A({},r)),i[e]=r),r},t.prototype.setItemVisual=function(t,e,n){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,ax(e)?A(i,e):i[e]=n},t.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},t.prototype.setLayout=function(t,e){ax(t)?A(this._layout,t):this._layout[t]=e},t.prototype.getLayout=function(t){return this._layout[t]},t.prototype.getItemLayout=function(t){return this._itemLayouts[t]},t.prototype.setItemLayout=function(t,e,n){this._itemLayouts[t]=n?A(this._itemLayouts[t]||{},e):e},t.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},t.prototype.setItemGraphicEl=function(t,e){var n=this.hostModel&&this.hostModel.seriesIndex;ol(n,this.dataType,t,e),this._graphicEls[t]=e},t.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},t.prototype.eachItemGraphicEl=function(t,e){E(this._graphicEls,(function(n,i){n&&t&&t.call(e,n,i)}))},t.prototype.cloneShallow=function(e){return e||(e=new t(this._schema?this._schema:sx(this.dimensions,this._getDimInfo,this),this.hostModel)),ix(e,this),e._store=this._store,e},t.prototype.wrapMethod=function(t,e){var n=this[t];X(n)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var t=n.apply(this,arguments);return e.apply(this,[t].concat(at(arguments)))})},t.internalField=(Qm=function(t){var e=t._invertedIndicesMap;E(e,(function(n,i){var r=t._dimInfos[i],o=r.ordinalMeta,a=t._store;if(o){n=e[i]=new lx(o.categories.length);for(var s=0;s1&&(s+="__ec__"+u),i[e]=s}})),t}();function px(t,e){Qd(t)||(t=ef(t));var n=(e=e||{}).coordDimensions||[],i=e.dimensionsDefine||t.dimensionsDefine||[],r=yt(),o=[],a=function(t,e,n,i){var r=Math.max(t.dimensionsDetectedCount||1,e.length,n.length,i||0);return E(e,(function(t){var e;q(t)&&(e=t.dimsDef)&&(r=Math.max(r,e.length))})),r}(t,n,i,e.dimensionsCount),s=e.canOmitUnusedDimensions&&Jm(a),l=i===t.dimensionsDefine,u=l?$m(t):Km(i),h=e.encodeDefine;!h&&e.encodeDefaulter&&(h=e.encodeDefaulter(t,a));for(var c=yt(h),p=new Xf(a),d=0;d0&&(i.name=r+(o-1)),o++,e.set(r,o)}}(o),new jm({source:t,dimensions:o,fullDimensionCount:a,dimensionOmitted:s})}function dx(t,e,n){if(n||e.hasKey(t)){for(var i=0;e.hasKey(t+i);)i++;t+=i}return e.set(t,!0),t}var fx=function(t){this.coordSysDims=[],this.axisMap=yt(),this.categoryAxisMap=yt(),this.coordSysName=t};var gx={cartesian2d:function(t,e,n,i){var r=t.getReferringComponents("xAxis",Wo).models[0],o=t.getReferringComponents("yAxis",Wo).models[0];e.coordSysDims=["x","y"],n.set("x",r),n.set("y",o),yx(r)&&(i.set("x",r),e.firstCategoryDimIndex=0),yx(o)&&(i.set("y",o),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},singleAxis:function(t,e,n,i){var r=t.getReferringComponents("singleAxis",Wo).models[0];e.coordSysDims=["single"],n.set("single",r),yx(r)&&(i.set("single",r),e.firstCategoryDimIndex=0)},polar:function(t,e,n,i){var r=t.getReferringComponents("polar",Wo).models[0],o=r.findAxisModel("radiusAxis"),a=r.findAxisModel("angleAxis");e.coordSysDims=["radius","angle"],n.set("radius",o),n.set("angle",a),yx(o)&&(i.set("radius",o),e.firstCategoryDimIndex=0),yx(a)&&(i.set("angle",a),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},geo:function(t,e,n,i){e.coordSysDims=["lng","lat"]},parallel:function(t,e,n,i){var r=t.ecModel,o=r.getComponent("parallel",t.get("parallelIndex")),a=e.coordSysDims=o.dimensions.slice();E(o.parallelAxisIndex,(function(t,o){var s=r.getComponent("parallelAxis",t),l=a[o];n.set(l,s),yx(s)&&(i.set(l,s),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=o))}))}};function yx(t){return"category"===t.get("type")}function vx(t,e,n){var i,r,o,a=(n=n||{}).byIndex,s=n.stackedCoordDimension;!function(t){return!qm(t.schema)}(e)?(r=e.schema,i=r.dimensions,o=e.store):i=e;var l,u,h,c,p=!(!t||!t.get("stack"));if(E(i,(function(t,e){U(t)&&(i[e]=t={name:t}),p&&!t.isExtraCoord&&(a||l||!t.ordinalMeta||(l=t),u||"ordinal"===t.type||"time"===t.type||s&&s!==t.coordDim||(u=t))})),!u||a||l||(a=!0),u){h="__\0ecstackresult_"+t.id,c="__\0ecstackedover_"+t.id,l&&(l.createInvertedIndices=!0);var d=u.coordDim,f=u.type,g=0;E(i,(function(t){t.coordDim===d&&g++}));var y={name:h,coordDim:d,coordDimIndex:g,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length},v={name:c,coordDim:c,coordDimIndex:g+1,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length+1};r?(o&&(y.storeDimIndex=o.ensureCalculationDimension(c,f),v.storeDimIndex=o.ensureCalculationDimension(h,f)),r.appendCalculationDimension(y),r.appendCalculationDimension(v)):(i.push(y),i.push(v))}return{stackedDimension:u&&u.name,stackedByDimension:l&&l.name,isStackedByIndex:a,stackedOverDimension:c,stackResultDimension:h}}function mx(t,e){return!!e&&e===t.getCalculationInfo("stackedDimension")}function xx(t,e){return mx(t,e)?t.getCalculationInfo("stackResultDimension"):e}function _x(t,e,n){n=n||{};var i,r=e.getSourceManager(),o=!1;t?(o=!0,i=ef(t)):o=(i=r.getSource()).sourceFormat===Wp;var a=function(t){var e=t.get("coordinateSystem"),n=new fx(e),i=gx[e];if(i)return i(t,n,n.axisMap,n.categoryAxisMap),n}(e),s=function(t,e){var n,i=t.get("coordinateSystem"),r=wd.get(i);return e&&e.coordSysDims&&(n=z(e.coordSysDims,(function(t){var n={name:t},i=e.axisMap.get(t);if(i){var r=i.get("type");n.type=Ym(r)}return n}))),n||(n=r&&(r.getDimensionsInfo?r.getDimensionsInfo():r.dimensions.slice())||["x","y"]),n}(e,a),l=n.useEncodeDefaulter,u=X(l)?l:l?H(td,s,e):null,h=px(i,{coordDimensions:s,generateCoord:n.generateCoord,encodeDefine:e.getEncode(),encodeDefaulter:u,canOmitUnusedDimensions:!o}),c=function(t,e,n){var i,r;return n&&E(t,(function(t,o){var a=t.coordDim,s=n.categoryAxisMap.get(a);s&&(null==i&&(i=o),t.ordinalMeta=s.getOrdinalMeta(),e&&(t.createInvertedIndices=!0)),null!=t.otherDims.itemName&&(r=!0)})),r||null==i||(t[i].otherDims.itemName=0),i}(h.dimensions,n.createInvertedIndices,a),p=o?null:r.getSharedDataStore(h),d=vx(e,{schema:h,store:p}),f=new cx(h,e);f.setCalculationInfo(d);var g=null!=c&&function(t){if(t.sourceFormat===Wp){var e=function(t){var e=0;for(;ee[1]&&(e[1]=t[1])},t.prototype.unionExtentFromData=function(t,e){this.unionExtent(t.getApproximateExtent(e))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.setExtent=function(t,e){var n=this._extent;isNaN(t)||(n[0]=t),isNaN(e)||(n[1]=e)},t.prototype.isInExtentRange=function(t){return this._extent[0]<=t&&this._extent[1]>=t},t.prototype.isBlank=function(){return this._isBlank},t.prototype.setBlank=function(t){this._isBlank=t},t}();na(bx);var Sx=0,Mx=function(){function t(t){this.categories=t.categories||[],this._needCollect=t.needCollect,this._deduplication=t.deduplication,this.uid=++Sx}return t.createByAxisModel=function(e){var n=e.option,i=n.data,r=i&&z(i,Ix);return new t({categories:r,needCollect:!r,deduplication:!1!==n.dedplication})},t.prototype.getOrdinal=function(t){return this._getOrCreateMap().get(t)},t.prototype.parseAndCollect=function(t){var e,n=this._needCollect;if(!U(t)&&!n)return t;if(n&&!this._deduplication)return e=this.categories.length,this.categories[e]=t,e;var i=this._getOrCreateMap();return null==(e=i.get(t))&&(n?(e=this.categories.length,this.categories[e]=t,i.set(t,e)):e=NaN),e},t.prototype._getOrCreateMap=function(){return this._map||(this._map=yt(this.categories))},t}();function Ix(t){return q(t)&&null!=t.value?t.value:t+""}function Tx(t){return"interval"===t.type||"log"===t.type}function Cx(t,e,n,i){var r={},o=t[1]-t[0],a=r.interval=po(o/e,!0);null!=n&&ai&&(a=r.interval=i);var s=r.intervalPrecision=Ax(a);return function(t,e){!isFinite(t[0])&&(t[0]=e[0]),!isFinite(t[1])&&(t[1]=e[1]),kx(t,0,e),kx(t,1,e),t[0]>t[1]&&(t[0]=t[1])}(r.niceTickExtent=[Jr(Math.ceil(t[0]/a)*a,s),Jr(Math.floor(t[1]/a)*a,s)],t),r}function Dx(t){var e=Math.pow(10,co(t)),n=t/e;return n?2===n?n=3:3===n?n=5:n*=2:n=1,Jr(n*e)}function Ax(t){return to(t)+2}function kx(t,e,n){t[e]=Math.max(Math.min(t[e],n[1]),n[0])}function Lx(t,e){return t>=e[0]&&t<=e[1]}function Px(t,e){return e[1]===e[0]?.5:(t-e[0])/(e[1]-e[0])}function Ox(t,e){return t*(e[1]-e[0])+e[0]}var Rx=function(t){function e(e){var n=t.call(this,e)||this;n.type="ordinal";var i=n.getSetting("ordinalMeta");return i||(i=new Mx({})),Y(i)&&(i=new Mx({categories:z(i,(function(t){return q(t)?t.value:t}))})),n._ordinalMeta=i,n._extent=n.getSetting("extent")||[0,i.categories.length-1],n}return n(e,t),e.prototype.parse=function(t){return null==t?NaN:U(t)?this._ordinalMeta.getOrdinal(t):Math.round(t)},e.prototype.contain=function(t){return Lx(t=this.parse(t),this._extent)&&null!=this._ordinalMeta.categories[t]},e.prototype.normalize=function(t){return Px(t=this._getTickNumber(this.parse(t)),this._extent)},e.prototype.scale=function(t){return t=Math.round(Ox(t,this._extent)),this.getRawOrdinalNumber(t)},e.prototype.getTicks=function(){for(var t=[],e=this._extent,n=e[0];n<=e[1];)t.push({value:n}),n++;return t},e.prototype.getMinorTicks=function(t){},e.prototype.setSortInfo=function(t){if(null!=t){for(var e=t.ordinalNumbers,n=this._ordinalNumbersByTick=[],i=this._ticksByOrdinalNumber=[],r=0,o=this._ordinalMeta.categories.length,a=Math.min(o,e.length);r=0&&t=0&&t=t},e.prototype.getOrdinalMeta=function(){return this._ordinalMeta},e.prototype.calcNiceTicks=function(){},e.prototype.calcNiceExtent=function(){},e.type="ordinal",e}(bx);bx.registerClass(Rx);var Nx=Jr,Ex=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="interval",e._interval=0,e._intervalPrecision=2,e}return n(e,t),e.prototype.parse=function(t){return t},e.prototype.contain=function(t){return Lx(t,this._extent)},e.prototype.normalize=function(t){return Px(t,this._extent)},e.prototype.scale=function(t){return Ox(t,this._extent)},e.prototype.setExtent=function(t,e){var n=this._extent;isNaN(t)||(n[0]=parseFloat(t)),isNaN(e)||(n[1]=parseFloat(e))},e.prototype.unionExtent=function(t){var e=this._extent;t[0]e[1]&&(e[1]=t[1]),this.setExtent(e[0],e[1])},e.prototype.getInterval=function(){return this._interval},e.prototype.setInterval=function(t){this._interval=t,this._niceExtent=this._extent.slice(),this._intervalPrecision=Ax(t)},e.prototype.getTicks=function(t){var e=this._interval,n=this._extent,i=this._niceExtent,r=this._intervalPrecision,o=[];if(!e)return o;n[0]1e4)return[];var s=o.length?o[o.length-1].value:i[1];return n[1]>s&&(t?o.push({value:Nx(s+e,r)}):o.push({value:n[1]})),o},e.prototype.getMinorTicks=function(t){for(var e=this.getTicks(!0),n=[],i=this.getExtent(),r=1;ri[0]&&h0&&(o=null===o?s:Math.min(o,s))}n[i]=o}}return n}(t),n=[];return E(t,(function(t){var i,r=t.coordinateSystem.getBaseAxis(),o=r.getExtent();if("category"===r.type)i=r.getBandWidth();else if("value"===r.type||"time"===r.type){var a=r.dim+"_"+r.index,s=e[a],l=Math.abs(o[1]-o[0]),u=r.scale.getExtent(),h=Math.abs(u[1]-u[0]);i=s?l/h*s:l}else{var c=t.getData();i=Math.abs(o[1]-o[0])/c.count()}var p=$r(t.get("barWidth"),i),d=$r(t.get("barMaxWidth"),i),f=$r(t.get("barMinWidth")||(qx(t)?.5:1),i),g=t.get("barGap"),y=t.get("barCategoryGap");n.push({bandWidth:i,barWidth:p,barMaxWidth:d,barMinWidth:f,barGap:g,barCategoryGap:y,axisKey:Wx(r),stackId:Gx(t)})})),Xx(n)}function Xx(t){var e={};E(t,(function(t,n){var i=t.axisKey,r=t.bandWidth,o=e[i]||{bandWidth:r,remainedWidth:r,autoWidthCount:0,categoryGap:null,gap:"20%",stacks:{}},a=o.stacks;e[i]=o;var s=t.stackId;a[s]||o.autoWidthCount++,a[s]=a[s]||{width:0,maxWidth:0};var l=t.barWidth;l&&!a[s].width&&(a[s].width=l,l=Math.min(o.remainedWidth,l),o.remainedWidth-=l);var u=t.barMaxWidth;u&&(a[s].maxWidth=u);var h=t.barMinWidth;h&&(a[s].minWidth=h);var c=t.barGap;null!=c&&(o.gap=c);var p=t.barCategoryGap;null!=p&&(o.categoryGap=p)}));var n={};return E(e,(function(t,e){n[e]={};var i=t.stacks,r=t.bandWidth,o=t.categoryGap;if(null==o){var a=G(i).length;o=Math.max(35-4*a,15)+"%"}var s=$r(o,r),l=$r(t.gap,1),u=t.remainedWidth,h=t.autoWidthCount,c=(u-s)/(h+(h-1)*l);c=Math.max(c,0),E(i,(function(t){var e=t.maxWidth,n=t.minWidth;if(t.width){i=t.width;e&&(i=Math.min(i,e)),n&&(i=Math.max(i,n)),t.width=i,u-=i+l*i,h--}else{var i=c;e&&ei&&(i=n),i!==c&&(t.width=i,u-=i+l*i,h--)}})),c=(u-s)/(h+(h-1)*l),c=Math.max(c,0);var p,d=0;E(i,(function(t,e){t.width||(t.width=c),p=t,d+=t.width*(1+l)})),p&&(d-=p.width*l);var f=-d/2;E(i,(function(t,i){n[e][i]=n[e][i]||{bandWidth:r,offset:f,width:t.width},f+=t.width*(1+l)}))})),n}function Ux(t,e){var n=Hx(t,e),i=Yx(n);E(n,(function(t){var e=t.getData(),n=t.coordinateSystem.getBaseAxis(),r=Gx(t),o=i[Wx(n)][r],a=o.offset,s=o.width;e.setLayout({bandWidth:o.bandWidth,offset:a,size:s})}))}function Zx(t){return{seriesType:t,plan:kg(),reset:function(t){if(jx(t)){var e=t.getData(),n=t.coordinateSystem,i=n.getBaseAxis(),r=n.getOtherAxis(i),o=e.getDimensionIndex(e.mapDimension(r.dim)),a=e.getDimensionIndex(e.mapDimension(i.dim)),s=t.get("showBackground",!0),l=e.mapDimension(r.dim),u=e.getCalculationInfo("stackResultDimension"),h=mx(e,l)&&!!e.getCalculationInfo("stackedOnSeries"),c=r.isHorizontal(),p=function(t,e){var n=e.model.get("startValue");n||(n=0);return e.toGlobalCoord(e.dataToCoord("log"===e.type?n>0?n:1:n))}(0,r),d=qx(t),f=t.get("barMinHeight")||0,g=u&&e.getDimensionIndex(u),y=e.getLayout("size"),v=e.getLayout("offset");return{progress:function(t,e){for(var i,r=t.count,l=d&&Bx(3*r),u=d&&s&&Bx(3*r),m=d&&Bx(r),x=n.master.getRect(),_=c?x.width:x.height,b=e.getStore(),w=0;null!=(i=t.next());){var S=b.get(h?g:o,i),M=b.get(a,i),I=p,T=void 0;h&&(T=+S-b.get(o,i));var C=void 0,D=void 0,A=void 0,k=void 0;if(c){var L=n.dataToPoint([S,M]);if(h)I=n.dataToPoint([T,M])[0];C=I,D=L[1]+v,A=L[0]-I,k=y,Math.abs(A)0)for(var s=0;s=0;--s)if(l[u]){o=l[u];break}o=o||a.none}if(Y(o)){var h=null==t.level?0:t.level>=0?t.level:o.length+t.level;o=o[h=Math.min(h,o.length-1)]}}return Jc(new Date(t.value),o,r,i)}(t,e,n,this.getSetting("locale"),i)},e.prototype.getTicks=function(){var t=this._interval,e=this._extent,n=[];if(!t)return n;n.push({value:e[0],level:0});var i=this.getSetting("useUTC"),r=function(t,e,n,i){var r=1e4,o=jc,a=0;function s(t,e,n,r,o,a,s){for(var l=new Date(e),u=e,h=l[r]();u1&&0===u&&o.unshift({value:o[0].value-p})}}for(u=0;u=i[0]&&v<=i[1]&&c++)}var m=(i[1]-i[0])/e;if(c>1.5*m&&p>m/1.5)break;if(u.push(g),c>m||t===o[d])break}h=[]}}0;var x=B(z(u,(function(t){return B(t,(function(t){return t.value>=i[0]&&t.value<=i[1]&&!t.notAdd}))})),(function(t){return t.length>0})),_=[],b=x.length-1;for(d=0;dn&&(this._approxInterval=n);var o=$x.length,a=Math.min(function(t,e,n,i){for(;n>>1;t[r][1]16?16:t>7.5?7:t>3.5?4:t>1.5?2:1}function Qx(t){return(t/=2592e6)>6?6:t>3?3:t>2?2:1}function t_(t){return(t/=Gc)>12?12:t>6?6:t>3.5?4:t>2?2:1}function e_(t,e){return(t/=e?Fc:Bc)>30?30:t>20?20:t>15?15:t>10?10:t>5?5:t>2?2:1}function n_(t){return po(t,!0)}function i_(t,e,n){var i=new Date(t);switch(Kc(e)){case"year":case"month":i[up(n)](0);case"day":i[hp(n)](1);case"hour":i[cp(n)](0);case"minute":i[pp(n)](0);case"second":i[dp(n)](0),i[fp(n)](0)}return i.getTime()}bx.registerClass(Kx);var r_=bx.prototype,o_=Ex.prototype,a_=Jr,s_=Math.floor,l_=Math.ceil,u_=Math.pow,h_=Math.log,c_=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="log",e.base=10,e._originalScale=new Ex,e._interval=0,e}return n(e,t),e.prototype.getTicks=function(t){var e=this._originalScale,n=this._extent,i=e.getExtent();return z(o_.getTicks.call(this,t),(function(t){var e=t.value,r=Jr(u_(this.base,e));return r=e===n[0]&&this._fixMin?d_(r,i[0]):r,{value:r=e===n[1]&&this._fixMax?d_(r,i[1]):r}}),this)},e.prototype.setExtent=function(t,e){var n=h_(this.base);t=h_(Math.max(0,t))/n,e=h_(Math.max(0,e))/n,o_.setExtent.call(this,t,e)},e.prototype.getExtent=function(){var t=this.base,e=r_.getExtent.call(this);e[0]=u_(t,e[0]),e[1]=u_(t,e[1]);var n=this._originalScale.getExtent();return this._fixMin&&(e[0]=d_(e[0],n[0])),this._fixMax&&(e[1]=d_(e[1],n[1])),e},e.prototype.unionExtent=function(t){this._originalScale.unionExtent(t);var e=this.base;t[0]=h_(t[0])/h_(e),t[1]=h_(t[1])/h_(e),r_.unionExtent.call(this,t)},e.prototype.unionExtentFromData=function(t,e){this.unionExtent(t.getApproximateExtent(e))},e.prototype.calcNiceTicks=function(t){t=t||10;var e=this._extent,n=e[1]-e[0];if(!(n===1/0||n<=0)){var i=ho(n);for(t/n*i<=.5&&(i*=10);!isNaN(i)&&Math.abs(i)<1&&Math.abs(i)>0;)i*=10;var r=[Jr(l_(e[0]/i)*i),Jr(s_(e[1]/i)*i)];this._interval=i,this._niceExtent=r}},e.prototype.calcNiceExtent=function(t){o_.calcNiceExtent.call(this,t),this._fixMin=t.fixMin,this._fixMax=t.fixMax},e.prototype.parse=function(t){return t},e.prototype.contain=function(t){return Lx(t=h_(t)/h_(this.base),this._extent)},e.prototype.normalize=function(t){return Px(t=h_(t)/h_(this.base),this._extent)},e.prototype.scale=function(t){return t=Ox(t,this._extent),u_(this.base,t)},e.type="log",e}(bx),p_=c_.prototype;function d_(t,e){return a_(t,to(e))}p_.getMinorTicks=o_.getMinorTicks,p_.getLabel=o_.getLabel,bx.registerClass(c_);var f_=function(){function t(t,e,n){this._prepareParams(t,e,n)}return t.prototype._prepareParams=function(t,e,n){n[1]0&&s>0&&!l&&(a=0),a<0&&s<0&&!u&&(s=0));var c=this._determinedMin,p=this._determinedMax;return null!=c&&(a=c,l=!0),null!=p&&(s=p,u=!0),{min:a,max:s,minFixed:l,maxFixed:u,isBlank:h}},t.prototype.modifyDataMinMax=function(t,e){this[y_[t]]=e},t.prototype.setDeterminedMinMax=function(t,e){var n=g_[t];this[n]=e},t.prototype.freeze=function(){this.frozen=!0},t}(),g_={min:"_determinedMin",max:"_determinedMax"},y_={min:"_dataMin",max:"_dataMax"};function v_(t,e,n){var i=t.rawExtentInfo;return i||(i=new f_(t,e,n),t.rawExtentInfo=i,i)}function m_(t,e){return null==e?null:nt(e)?NaN:t.parse(e)}function x_(t,e){var n=t.type,i=v_(t,e,t.getExtent()).calculate();t.setBlank(i.isBlank);var r=i.min,o=i.max,a=e.ecModel;if(a&&"time"===n){var s=Hx("bar",a),l=!1;if(E(s,(function(t){l=l||t.getBaseAxis()===e.axis})),l){var u=Yx(s),h=function(t,e,n,i){var r=n.axis.getExtent(),o=r[1]-r[0],a=function(t,e,n){if(t&&e){var i=t[Wx(e)];return null!=i&&null!=n?i[Gx(n)]:i}}(i,n.axis);if(void 0===a)return{min:t,max:e};var s=1/0;E(a,(function(t){s=Math.min(t.offset,s)}));var l=-1/0;E(a,(function(t){l=Math.max(t.offset+t.width,l)})),s=Math.abs(s),l=Math.abs(l);var u=s+l,h=e-t,c=h/(1-(s+l)/o)-h;return e+=c*(l/u),t-=c*(s/u),{min:t,max:e}}(r,o,e,u);r=h.min,o=h.max}}return{extent:[r,o],fixMin:i.minFixed,fixMax:i.maxFixed}}function __(t,e){var n=e,i=x_(t,n),r=i.extent,o=n.get("splitNumber");t instanceof c_&&(t.base=n.get("logBase"));var a=t.type,s=n.get("interval"),l="interval"===a||"time"===a;t.setExtent(r[0],r[1]),t.calcNiceExtent({splitNumber:o,fixMin:i.fixMin,fixMax:i.fixMax,minInterval:l?n.get("minInterval"):null,maxInterval:l?n.get("maxInterval"):null}),null!=s&&t.setInterval&&t.setInterval(s)}function b_(t,e){if(e=e||t.get("type"))switch(e){case"category":return new Rx({ordinalMeta:t.getOrdinalMeta?t.getOrdinalMeta():t.getCategories(),extent:[1/0,-1/0]});case"time":return new Kx({locale:t.ecModel.getLocaleModel(),useUTC:t.ecModel.get("useUTC")});default:return new(bx.getClass(e)||Ex)}}function w_(t){var e,n,i=t.getLabelModel().get("formatter"),r="category"===t.type?t.scale.getExtent()[0]:null;return"time"===t.scale.type?(n=i,function(e,i){return t.scale.getFormattedLabel(e,i,n)}):U(i)?function(e){return function(n){var i=t.scale.getLabel(n);return e.replace("{value}",null!=i?i:"")}}(i):X(i)?(e=i,function(n,i){return null!=r&&(i=n.value-r),e(S_(t,n),i,null!=n.level?{level:n.level}:null)}):function(e){return t.scale.getLabel(e)}}function S_(t,e){return"category"===t.type?t.scale.getLabel(e):e.value}function M_(t,e){var n=e*Math.PI/180,i=t.width,r=t.height,o=i*Math.abs(Math.cos(n))+Math.abs(r*Math.sin(n)),a=i*Math.abs(Math.sin(n))+Math.abs(r*Math.cos(n));return new ze(t.x,t.y,o,a)}function I_(t){var e=t.get("interval");return null==e?"auto":e}function T_(t){return"category"===t.type&&0===I_(t.getLabelModel())}function C_(t,e){var n={};return E(t.mapDimensionsAll(e),(function(e){n[xx(t,e)]=!0})),G(n)}var D_=function(){function t(){}return t.prototype.getNeedCrossZero=function(){return!this.option.scale},t.prototype.getCoordSysModel=function(){},t}();var A_={isDimensionStacked:mx,enableDataStack:vx,getStackedDimension:xx};var k_=Object.freeze({__proto__:null,createList:function(t){return _x(null,t)},getLayoutRect:kp,dataStack:A_,createScale:function(t,e){var n=e;e instanceof Cc||(n=new Cc(e));var i=b_(n);return i.setExtent(t[0],t[1]),__(i,n),i},mixinAxisModelCommonMethods:function(t){R(t,D_)},getECData:rl,createTextStyle:function(t,e){return oc(t,null,null,"normal"!==(e=e||{}).state)},createDimensions:function(t,e){return px(t,e).dimensions},createSymbol:Xy,enableHoverEmphasis:Ul});function L_(t,e){return Math.abs(t-e)<1e-8}function P_(t,e,n){var i=0,r=t[0];if(!r)return!1;for(var o=1;on&&(t=r,n=a)}if(t)return function(t){for(var e=0,n=0,i=0,r=t.length,o=t[r-1][0],a=t[r-1][1],s=0;s>1^-(1&s),l=l>>1^-(1&l),r=s+=r,o=l+=o,i.push([s/n,l/n])}return i}function H_(t,e){return z(B((t=function(t){if(!t.UTF8Encoding)return t;var e=t,n=e.UTF8Scale;return null==n&&(n=1024),E(e.features,(function(t){var e=t.geometry,i=e.encodeOffsets,r=e.coordinates;if(i)switch(e.type){case"LineString":e.coordinates=W_(r,i,n);break;case"Polygon":case"MultiLineString":G_(r,i,n);break;case"MultiPolygon":E(r,(function(t,e){return G_(t,i[e],n)}))}})),e.UTF8Encoding=!1,e}(t)).features,(function(t){return t.geometry&&t.properties&&t.geometry.coordinates.length>0})),(function(t){var n=t.properties,i=t.geometry,r=[];switch(i.type){case"Polygon":var o=i.coordinates;r.push(new z_(o[0],o.slice(1)));break;case"MultiPolygon":E(i.coordinates,(function(t){t[0]&&r.push(new z_(t[0],t.slice(1)))}));break;case"LineString":r.push(new V_([i.coordinates]));break;case"MultiLineString":r.push(new V_(i.coordinates))}var a=new B_(n[e||"name"],r,n.cp);return a.properties=n,a}))}var Y_=Object.freeze({__proto__:null,linearMap:Kr,round:Jr,asc:Qr,getPrecision:to,getPrecisionSafe:eo,getPixelPrecision:no,getPercentWithPrecision:function(t,e,n){return t[e]&&io(t,n)[e]||0},MAX_SAFE_INTEGER:oo,remRadian:ao,isRadianAroundZero:so,parseDate:uo,quantity:ho,quantityExponent:co,nice:po,quantile:fo,reformIntervals:go,isNumeric:vo,numericToNumber:yo}),X_=Object.freeze({__proto__:null,parse:uo,format:Jc}),U_=Object.freeze({__proto__:null,extendShape:Ch,extendPath:Ah,makePath:Ph,makeImage:Oh,mergePath:Nh,resizePath:Eh,createIcon:Uh,updateProps:vh,initProps:mh,getTransform:Bh,clipPointsByRect:Yh,clipRectByRect:Xh,registerShape:kh,getShapeClass:Lh,Group:Br,Image:Ns,Text:Xs,Circle:Su,Ellipse:Iu,Sector:Fu,Ring:Wu,Polygon:Xu,Polyline:Zu,Rect:Ws,Line:Ku,BezierCurve:th,Arc:nh,IncrementalDisplayable:dh,CompoundPath:ih,LinearGradient:oh,RadialGradient:ah,BoundingRect:ze}),Z_=Object.freeze({__proto__:null,addCommas:gp,toCamelCase:yp,normalizeCssArray:vp,encodeHTML:re,formatTpl:bp,getTooltipMarker:wp,formatTime:function(t,e,n){"week"!==t&&"month"!==t&&"quarter"!==t&&"half-year"!==t&&"year"!==t||(t="MM-dd\nyyyy");var i=uo(e),r=n?"getUTC":"get",o=i[r+"FullYear"](),a=i[r+"Month"]()+1,s=i[r+"Date"](),l=i[r+"Hours"](),u=i[r+"Minutes"](),h=i[r+"Seconds"](),c=i[r+"Milliseconds"]();return t=t.replace("MM",qc(a,2)).replace("M",a).replace("yyyy",o).replace("yy",qc(o%100+"",2)).replace("dd",qc(s,2)).replace("d",s).replace("hh",qc(l,2)).replace("h",l).replace("mm",qc(u,2)).replace("m",u).replace("ss",qc(h,2)).replace("s",h).replace("SSS",qc(c,3))},capitalFirst:function(t){return t?t.charAt(0).toUpperCase()+t.substr(1):t},truncateText:pa,getTextRect:function(t,e,n,i,r,o,a,s){return new Xs({style:{text:t,font:e,align:n,verticalAlign:i,padding:r,rich:o,overflow:a?"truncate":null,lineHeight:s}}).getBoundingRect()}}),j_=Object.freeze({__proto__:null,map:z,each:E,indexOf:P,inherits:O,reduce:V,filter:B,bind:W,curry:H,isArray:Y,isString:U,isObject:q,isFunction:X,extend:A,defaults:k,clone:T,merge:C}),q_=Vo();function K_(t,e){var n=z(e,(function(e){return t.scale.parse(e)}));return"time"===t.type&&n.length>0&&(n.sort(),n.unshift(n[0]),n.push(n[n.length-1])),n}function $_(t){var e=t.getLabelModel().get("customValues");if(e){var n=w_(t);return{labels:K_(t,e).map((function(e){var i={value:e};return{formattedLabel:n(i),rawLabel:t.scale.getLabel(i),tickValue:e}}))}}return"category"===t.type?function(t){var e=t.getLabelModel(),n=Q_(t,e);return!e.get("show")||t.scale.isBlank()?{labels:[],labelCategoryInterval:n.labelCategoryInterval}:n}(t):function(t){var e=t.scale.getTicks(),n=w_(t);return{labels:z(e,(function(e,i){return{level:e.level,formattedLabel:n(e,i),rawLabel:t.scale.getLabel(e),tickValue:e.value}}))}}(t)}function J_(t,e){var n=t.getTickModel().get("customValues");return n?{ticks:K_(t,n)}:"category"===t.type?function(t,e){var n,i,r=tb(t,"ticks"),o=I_(e),a=eb(r,o);if(a)return a;e.get("show")&&!t.scale.isBlank()||(n=[]);if(X(o))n=rb(t,o,!0);else if("auto"===o){var s=Q_(t,t.getLabelModel());i=s.labelCategoryInterval,n=z(s.labels,(function(t){return t.tickValue}))}else n=ib(t,i=o,!0);return nb(r,o,{ticks:n,tickCategoryInterval:i})}(t,e):{ticks:z(t.scale.getTicks(),(function(t){return t.value}))}}function Q_(t,e){var n,i,r=tb(t,"labels"),o=I_(e),a=eb(r,o);return a||(X(o)?n=rb(t,o):(i="auto"===o?function(t){var e=q_(t).autoInterval;return null!=e?e:q_(t).autoInterval=t.calculateCategoryInterval()}(t):o,n=ib(t,i)),nb(r,o,{labels:n,labelCategoryInterval:i}))}function tb(t,e){return q_(t)[e]||(q_(t)[e]=[])}function eb(t,e){for(var n=0;n1&&h/l>2&&(u=Math.round(Math.ceil(u/l)*l));var c=T_(t),p=a.get("showMinLabel")||c,d=a.get("showMaxLabel")||c;p&&u!==o[0]&&g(o[0]);for(var f=u;f<=o[1];f+=l)g(f);function g(t){var e={value:t};s.push(n?t:{formattedLabel:i(e),rawLabel:r.getLabel(e),tickValue:t})}return d&&f-l!==o[1]&&g(o[1]),s}function rb(t,e,n){var i=t.scale,r=w_(t),o=[];return E(i.getTicks(),(function(t){var a=i.getLabel(t),s=t.value;e(t.value,a)&&o.push(n?s:{formattedLabel:r(t),rawLabel:a,tickValue:s})})),o}var ob=[0,1],ab=function(){function t(t,e,n){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=n||[0,0]}return t.prototype.contain=function(t){var e=this._extent,n=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=n&&t<=i},t.prototype.containData=function(t){return this.scale.contain(t)},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.getPixelPrecision=function(t){return no(t||this.scale.getExtent(),this._extent)},t.prototype.setExtent=function(t,e){var n=this._extent;n[0]=t,n[1]=e},t.prototype.dataToCoord=function(t,e){var n=this._extent,i=this.scale;return t=i.normalize(t),this.onBand&&"ordinal"===i.type&&sb(n=n.slice(),i.count()),Kr(t,ob,n,e)},t.prototype.coordToData=function(t,e){var n=this._extent,i=this.scale;this.onBand&&"ordinal"===i.type&&sb(n=n.slice(),i.count());var r=Kr(t,n,ob,e);return this.scale.scale(r)},t.prototype.pointToData=function(t,e){},t.prototype.getTicksCoords=function(t){var e=(t=t||{}).tickModel||this.getTickModel(),n=z(J_(this,e).ticks,(function(t){return{coord:this.dataToCoord("ordinal"===this.scale.type?this.scale.getRawOrdinalNumber(t):t),tickValue:t}}),this);return function(t,e,n,i){var r=e.length;if(!t.onBand||n||!r)return;var o,a,s=t.getExtent();if(1===r)e[0].coord=s[0],o=e[1]={coord:s[1]};else{var l=e[r-1].tickValue-e[0].tickValue,u=(e[r-1].coord-e[0].coord)/l;E(e,(function(t){t.coord-=u/2})),a=1+t.scale.getExtent()[1]-e[r-1].tickValue,o={coord:e[r-1].coord+u*a},e.push(o)}var h=s[0]>s[1];c(e[0].coord,s[0])&&(i?e[0].coord=s[0]:e.shift());i&&c(s[0],e[0].coord)&&e.unshift({coord:s[0]});c(s[1],o.coord)&&(i?o.coord=s[1]:e.pop());i&&c(o.coord,s[1])&&e.push({coord:s[1]});function c(t,e){return t=Jr(t),e=Jr(e),h?t>e:t0&&t<100||(t=5),z(this.scale.getMinorTicks(t),(function(t){return z(t,(function(t){return{coord:this.dataToCoord(t),tickValue:t}}),this)}),this)},t.prototype.getViewLabels=function(){return $_(this).labels},t.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},t.prototype.getTickModel=function(){return this.model.getModel("axisTick")},t.prototype.getBandWidth=function(){var t=this._extent,e=this.scale.getExtent(),n=e[1]-e[0]+(this.onBand?1:0);0===n&&(n=1);var i=Math.abs(t[1]-t[0]);return Math.abs(i)/n},t.prototype.calculateCategoryInterval=function(){return function(t){var e=function(t){var e=t.getLabelModel();return{axisRotate:t.getRotate?t.getRotate():t.isHorizontal&&!t.isHorizontal()?90:0,labelRotate:e.get("rotate")||0,font:e.getFont()}}(t),n=w_(t),i=(e.axisRotate-e.labelRotate)/180*Math.PI,r=t.scale,o=r.getExtent(),a=r.count();if(o[1]-o[0]<1)return 0;var s=1;a>40&&(s=Math.max(1,Math.floor(a/40)));for(var l=o[0],u=t.dataToCoord(l+1)-t.dataToCoord(l),h=Math.abs(u*Math.cos(i)),c=Math.abs(u*Math.sin(i)),p=0,d=0;l<=o[1];l+=s){var f,g,y=Sr(n({value:l}),e.font,"center","top");f=1.3*y.width,g=1.3*y.height,p=Math.max(p,f,7),d=Math.max(d,g,7)}var v=p/h,m=d/c;isNaN(v)&&(v=1/0),isNaN(m)&&(m=1/0);var x=Math.max(0,Math.floor(Math.min(v,m))),_=q_(t.model),b=t.getExtent(),w=_.lastAutoInterval,S=_.lastTickCount;return null!=w&&null!=S&&Math.abs(w-x)<=1&&Math.abs(S-a)<=1&&w>x&&_.axisExtent0===b[0]&&_.axisExtent1===b[1]?x=w:(_.lastTickCount=a,_.lastAutoInterval=x,_.axisExtent0=b[0],_.axisExtent1=b[1]),x}(this)},t}();function sb(t,e){var n=(t[1]-t[0])/e/2;t[0]+=n,t[1]-=n}var lb=2*Math.PI,ub=hs.CMD,hb=["top","right","bottom","left"];function cb(t,e,n,i,r){var o=n.width,a=n.height;switch(t){case"top":i.set(n.x+o/2,n.y-e),r.set(0,-1);break;case"bottom":i.set(n.x+o/2,n.y+a+e),r.set(0,1);break;case"left":i.set(n.x-e,n.y+a/2),r.set(-1,0);break;case"right":i.set(n.x+o+e,n.y+a/2),r.set(1,0)}}function pb(t,e,n,i,r,o,a,s,l){a-=t,s-=e;var u=Math.sqrt(a*a+s*s),h=(a/=u)*n+t,c=(s/=u)*n+e;if(Math.abs(i-r)%lb<1e-4)return l[0]=h,l[1]=c,u-n;if(o){var p=i;i=gs(r),r=gs(p)}else i=gs(i),r=gs(r);i>r&&(r+=lb);var d=Math.atan2(s,a);if(d<0&&(d+=lb),d>=i&&d<=r||d+lb>=i&&d+lb<=r)return l[0]=h,l[1]=c,u-n;var f=n*Math.cos(i)+t,g=n*Math.sin(i)+e,y=n*Math.cos(r)+t,v=n*Math.sin(r)+e,m=(f-a)*(f-a)+(g-s)*(g-s),x=(y-a)*(y-a)+(v-s)*(v-s);return m0){e=e/180*Math.PI,mb.fromArray(t[0]),xb.fromArray(t[1]),_b.fromArray(t[2]),De.sub(bb,mb,xb),De.sub(wb,_b,xb);var n=bb.len(),i=wb.len();if(!(n<.001||i<.001)){bb.scale(1/n),wb.scale(1/i);var r=bb.dot(wb);if(Math.cos(e)1&&De.copy(Ib,_b),Ib.toArray(t[1])}}}}function Cb(t,e,n){if(n<=180&&n>0){n=n/180*Math.PI,mb.fromArray(t[0]),xb.fromArray(t[1]),_b.fromArray(t[2]),De.sub(bb,xb,mb),De.sub(wb,_b,xb);var i=bb.len(),r=wb.len();if(!(i<.001||r<.001))if(bb.scale(1/i),wb.scale(1/r),bb.dot(e)=a)De.copy(Ib,_b);else{Ib.scaleAndAdd(wb,o/Math.tan(Math.PI/2-s));var l=_b.x!==xb.x?(Ib.x-xb.x)/(_b.x-xb.x):(Ib.y-xb.y)/(_b.y-xb.y);if(isNaN(l))return;l<0?De.copy(Ib,xb):l>1&&De.copy(Ib,_b)}Ib.toArray(t[1])}}}function Db(t,e,n,i){var r="normal"===n,o=r?t:t.ensureState(n);o.ignore=e;var a=i.get("smooth");a&&!0===a&&(a=.3),o.shape=o.shape||{},a>0&&(o.shape.smooth=a);var s=i.getModel("lineStyle").getLineStyle();r?t.useStyle(s):o.style=s}function Ab(t,e){var n=e.smooth,i=e.points;if(i)if(t.moveTo(i[0][0],i[0][1]),n>0&&i.length>=3){var r=Vt(i[0],i[1]),o=Vt(i[1],i[2]);if(!r||!o)return t.lineTo(i[1][0],i[1][1]),void t.lineTo(i[2][0],i[2][1]);var a=Math.min(r,o)*n,s=Gt([],i[1],i[0],a/r),l=Gt([],i[1],i[2],a/o),u=Gt([],s,l,.5);t.bezierCurveTo(s[0],s[1],s[0],s[1],u[0],u[1]),t.bezierCurveTo(l[0],l[1],l[0],l[1],i[2][0],i[2][1])}else for(var h=1;h0&&o&&_(-h/a,0,a);var f,g,y=t[0],v=t[a-1];return m(),f<0&&b(-f,.8),g<0&&b(g,.8),m(),x(f,g,1),x(g,f,-1),m(),f<0&&w(-f),g<0&&w(g),u}function m(){f=y.rect[e]-i,g=r-v.rect[e]-v.rect[n]}function x(t,e,n){if(t<0){var i=Math.min(e,-t);if(i>0){_(i*n,0,a);var r=i+t;r<0&&b(-r*n,1)}else b(-t*n,1)}}function _(n,i,r){0!==n&&(u=!0);for(var o=i;o0)for(l=0;l0;l--){_(-(o[l-1]*c),l,a)}}}function w(t){var e=t<0?-1:1;t=Math.abs(t);for(var n=Math.ceil(t/(a-1)),i=0;i0?_(n,0,i+1):_(-n,a-i-1,a),(t-=n)<=0)return}}function Rb(t,e,n,i){return Ob(t,"y","height",e,n,i)}function Nb(t){var e=[];t.sort((function(t,e){return e.priority-t.priority}));var n=new ze(0,0,0,0);function i(t){if(!t.ignore){var e=t.ensureState("emphasis");null==e.ignore&&(e.ignore=!1)}t.ignore=!0}for(var r=0;r=0&&n.attr(d.oldLayoutSelect),P(u,"emphasis")>=0&&n.attr(d.oldLayoutEmphasis)),vh(n,s,e,a)}else if(n.attr(s),!pc(n).valueAnimation){var h=rt(n.style.opacity,1);n.style.opacity=0,mh(n,{style:{opacity:h}},e,a)}if(d.oldLayout=s,n.states.select){var c=d.oldLayoutSelect={};Wb(c,s,Hb),Wb(c,n.states.select,Hb)}if(n.states.emphasis){var p=d.oldLayoutEmphasis={};Wb(p,s,Hb),Wb(p,n.states.emphasis,Hb)}fc(n,a,l,e,e)}if(i&&!i.ignore&&!i.invisible){r=(d=Gb(i)).oldLayout;var d,f={points:i.shape.points};r?(i.attr({shape:r}),vh(i,{shape:f},e)):(i.setShape(f),i.style.strokePercent=0,mh(i,{style:{strokePercent:1}},e)),d.oldLayout=f}},t}(),Xb=Vo();var Ub=Math.sin,Zb=Math.cos,jb=Math.PI,qb=2*Math.PI,Kb=180/jb,$b=function(){function t(){}return t.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},t.prototype.moveTo=function(t,e){this._add("M",t,e)},t.prototype.lineTo=function(t,e){this._add("L",t,e)},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){this._add("C",t,e,n,i,r,o)},t.prototype.quadraticCurveTo=function(t,e,n,i){this._add("Q",t,e,n,i)},t.prototype.arc=function(t,e,n,i,r,o){this.ellipse(t,e,n,n,0,i,r,o)},t.prototype.ellipse=function(t,e,n,i,r,o,a,s){var l=a-o,u=!s,h=Math.abs(l),c=pi(h-qb)||(u?l>=qb:-l>=qb),p=l>0?l%qb:l%qb+qb,d=!1;d=!!c||!pi(h)&&p>=jb==!!u;var f=t+n*Zb(o),g=e+i*Ub(o);this._start&&this._add("M",f,g);var y=Math.round(r*Kb);if(c){var v=1/this._p,m=(u?1:-1)*(qb-v);this._add("A",n,i,y,1,+u,t+n*Zb(o+m),e+i*Ub(o+m)),v>.01&&this._add("A",n,i,y,0,+u,f,g)}else{var x=t+n*Zb(a),_=e+i*Ub(a);this._add("A",n,i,y,+d,+u,x,_)}},t.prototype.rect=function(t,e,n,i){this._add("M",t,e),this._add("l",n,0),this._add("l",0,i),this._add("l",-n,0),this._add("Z")},t.prototype.closePath=function(){this._d.length>0&&this._add("Z")},t.prototype._add=function(t,e,n,i,r,o,a,s,l){for(var u=[],h=this._p,c=1;c"}(r,o)+("style"!==r?re(a):a||"")+(i?""+n+z(i,(function(e){return t(e)})).join(n)+n:"")+("")}(t)}function uw(t){return{zrId:t,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function hw(t,e,n,i){return sw("svg","root",{width:t,height:e,xmlns:iw,"xmlns:xlink":rw,version:"1.1",baseProfile:"full",viewBox:!!i&&"0 0 "+t+" "+e},n)}var cw=0;function pw(){return cw++}var dw={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},fw="transform-origin";function gw(t,e,n){var i=A({},t.shape);A(i,e),t.buildPath(n,i);var r=new $b;return r.reset(wi(t)),n.rebuildPath(r,1),r.generateStr(),r.getStr()}function yw(t,e){var n=e.originX,i=e.originY;(n||i)&&(t[fw]=n+"px "+i+"px")}var vw={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function mw(t,e){var n=e.zrId+"-ani-"+e.cssAnimIdx++;return e.cssAnims[n]=t,n}function xw(t){return U(t)?dw[t]?"cubic-bezier("+dw[t]+")":Pn(t)?t:"":""}function _w(t,e,n,i){var r=t.animators,o=r.length,a=[];if(t instanceof ih){var s=function(t,e,n){var i,r,o=t.shape.paths,a={};if(E(o,(function(t){var e=uw(n.zrId);e.animation=!0,_w(t,{},e,!0);var o=e.cssAnims,s=e.cssNodes,l=G(o),u=l.length;if(u){var h=o[r=l[u-1]];for(var c in h){var p=h[c];a[c]=a[c]||{d:""},a[c].d+=p.d||""}for(var d in s){var f=s[d].animation;f.indexOf(r)>=0&&(i=f)}}})),i){e.d=!1;var s=mw(a,n);return i.replace(r,s)}}(t,e,n);if(s)a.push(s);else if(!o)return}else if(!o)return;for(var l={},u=0;u0})).length)return mw(h,n)+" "+r[0]+" both"}for(var y in l){(s=g(l[y]))&&a.push(s)}if(a.length){var v=n.zrId+"-cls-"+pw();n.cssNodes["."+v]={animation:a.join(",")},e.class=v}}function bw(t,e,n,i){var r=JSON.stringify(t),o=n.cssStyleCache[r];o||(o=n.zrId+"-cls-"+pw(),n.cssStyleCache[r]=o,n.cssNodes["."+o+(i?":hover":"")]=t),e.class=e.class?e.class+" "+o:o}var ww=Math.round;function Sw(t){return t&&U(t.src)}function Mw(t){return t&&X(t.toDataURL)}function Iw(t,e,n,i){nw((function(r,o){var a="fill"===r||"stroke"===r;a&&_i(o)?Ew(e,t,r,i):a&&vi(o)?zw(n,t,r,i):t[r]=a&&"none"===o?"transparent":o}),e,n,!1),function(t,e,n){var i=t.style;if(function(t){return t&&(t.shadowBlur||t.shadowOffsetX||t.shadowOffsetY)}(i)){var r=function(t){var e=t.style,n=t.getGlobalScale();return[e.shadowColor,(e.shadowBlur||0).toFixed(2),(e.shadowOffsetX||0).toFixed(2),(e.shadowOffsetY||0).toFixed(2),n[0],n[1]].join(",")}(t),o=n.shadowCache,a=o[r];if(!a){var s=t.getGlobalScale(),l=s[0],u=s[1];if(!l||!u)return;var h=i.shadowOffsetX||0,c=i.shadowOffsetY||0,p=i.shadowBlur,d=hi(i.shadowColor),f=d.opacity,g=d.color,y=p/2/l+" "+p/2/u;a=n.zrId+"-s"+n.shadowIdx++,n.defs[a]=sw("filter",a,{id:a,x:"-100%",y:"-100%",width:"300%",height:"300%"},[sw("feDropShadow","",{dx:h/l,dy:c/u,stdDeviation:y,"flood-color":g,"flood-opacity":f})]),o[r]=a}e.filter=bi(a)}}(n,t,i)}function Tw(t,e){var n=Ur(e);n&&(n.each((function(e,n){null!=e&&(t[(ow+n).toLowerCase()]=e+"")})),e.isSilent()&&(t[ow+"silent"]="true"))}function Cw(t){return pi(t[0]-1)&&pi(t[1])&&pi(t[2])&&pi(t[3]-1)}function Dw(t,e,n){if(e&&(!function(t){return pi(t[4])&&pi(t[5])}(e)||!Cw(e))){var i=n?10:1e4;t.transform=Cw(e)?"translate("+ww(e[4]*i)/i+" "+ww(e[5]*i)/i+")":function(t){return"matrix("+di(t[0])+","+di(t[1])+","+di(t[2])+","+di(t[3])+","+fi(t[4])+","+fi(t[5])+")"}(e)}}function Aw(t,e,n){for(var i=t.points,r=[],o=0;o=0&&a||o;s&&(r=si(s))}var l=i.lineWidth;l&&(l/=!i.strokeNoScale&&t.transform?t.transform[0]:1);var u={cursor:"pointer"};r&&(u.fill=r),i.stroke&&(u.stroke=i.stroke),l&&(u["stroke-width"]=l),bw(u,e,n,!0)}}(t,o,e),sw(s,t.id+"",o)}function Nw(t,e){return t instanceof ks?Rw(t,e):t instanceof Ns?function(t,e){var n=t.style,i=n.image;if(i&&!U(i)&&(Sw(i)?i=i.src:Mw(i)&&(i=i.toDataURL())),i){var r=n.x||0,o=n.y||0,a={href:i,width:n.width,height:n.height};return r&&(a.x=r),o&&(a.y=o),Dw(a,t.transform),Iw(a,n,t,e),Tw(a,t),e.animation&&_w(t,a,e),sw("image",t.id+"",a)}}(t,e):t instanceof Ps?function(t,e){var n=t.style,i=n.text;if(null!=i&&(i+=""),i&&!isNaN(n.x)&&!isNaN(n.y)){var r=n.font||a,s=n.x||0,l=function(t,e,n){return"top"===n?t+=e/2:"bottom"===n&&(t-=e/2),t}(n.y||0,Tr(r),n.textBaseline),u={"dominant-baseline":"central","text-anchor":gi[n.textAlign]||n.textAlign};if($s(n)){var h="",c=n.fontStyle,p=qs(n.fontSize);if(!parseFloat(p))return;var d=n.fontFamily||o,f=n.fontWeight;h+="font-size:"+p+";font-family:"+d+";",c&&"normal"!==c&&(h+="font-style:"+c+";"),f&&"normal"!==f&&(h+="font-weight:"+f+";"),u.style=h}else u.style="font: "+r;return i.match(/\s/)&&(u["xml:space"]="preserve"),s&&(u.x=s),l&&(u.y=l),Dw(u,t.transform),Iw(u,n,t,e),Tw(u,t),e.animation&&_w(t,u,e),sw("text",t.id+"",u,void 0,i)}}(t,e):void 0}function Ew(t,e,n,i){var r,o=t[n],a={gradientUnits:o.global?"userSpaceOnUse":"objectBoundingBox"};if(mi(o))r="linearGradient",a.x1=o.x,a.y1=o.y,a.x2=o.x2,a.y2=o.y2;else{if(!xi(o))return void 0;r="radialGradient",a.cx=rt(o.x,.5),a.cy=rt(o.y,.5),a.r=rt(o.r,.5)}for(var s=o.colorStops,l=[],u=0,h=s.length;ul?Jw(t,null==n[c+1]?null:n[c+1].elm,n,s,c):Qw(t,e,a,l))}(n,i,r):jw(r)?(jw(t.text)&&Xw(n,""),Jw(n,null,r,0,r.length-1)):jw(i)?Qw(n,i,0,i.length-1):jw(t.text)&&Xw(n,""):t.text!==e.text&&(jw(i)&&Qw(n,i,0,i.length-1),Xw(n,e.text)))}var nS=0,iS=function(){function t(t,e,n){if(this.type="svg",this.refreshHover=rS("refreshHover"),this.configLayer=rS("configLayer"),this.storage=e,this._opts=n=A({},n),this.root=t,this._id="zr"+nS++,this._oldVNode=hw(n.width,n.height),t&&!n.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var r=this._svgDom=this._oldVNode.elm=aw("svg");tS(null,this._oldVNode),i.appendChild(r),t.appendChild(i)}this.resize(n.width,n.height)}return t.prototype.getType=function(){return this.type},t.prototype.getViewportRoot=function(){return this._viewport},t.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},t.prototype.getSvgDom=function(){return this._svgDom},t.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",function(t,e){if(Kw(t,e))eS(t,e);else{var n=t.elm,i=Hw(n);$w(e),null!==i&&(Fw(i,e.elm,Yw(n)),Qw(i,[t],0,0))}}(this._oldVNode,t),this._oldVNode=t}},t.prototype.renderOneToVNode=function(t){return Nw(t,uw(this._id))},t.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),n=this._width,i=this._height,r=uw(this._id);r.animation=t.animation,r.willUpdate=t.willUpdate,r.compress=t.compress,r.emphasis=t.emphasis;var o=[],a=this._bgVNode=function(t,e,n,i){var r;if(n&&"none"!==n)if(r=sw("rect","bg",{width:t,height:e,x:"0",y:"0"}),_i(n))Ew({fill:n},r.attrs,"fill",i);else if(vi(n))zw({style:{fill:n},dirty:bt,getBoundingRect:function(){return{width:t,height:e}}},r.attrs,"fill",i);else{var o=hi(n),a=o.color,s=o.opacity;r.attrs.fill=a,s<1&&(r.attrs["fill-opacity"]=s)}return r}(n,i,this._backgroundColor,r);a&&o.push(a);var s=t.compress?null:this._mainVNode=sw("g","main",{},[]);this._paintList(e,r,s?s.children:o),s&&o.push(s);var l=z(G(r.defs),(function(t){return r.defs[t]}));if(l.length&&o.push(sw("defs","defs",{},l)),t.animation){var u=function(t,e,n){var i=(n=n||{}).newline?"\n":"",r=" {"+i,o=i+"}",a=z(G(t),(function(e){return e+r+z(G(t[e]),(function(n){return n+":"+t[e][n]+";"})).join(i)+o})).join(i),s=z(G(e),(function(t){return"@keyframes "+t+r+z(G(e[t]),(function(n){return n+r+z(G(e[t][n]),(function(i){var r=e[t][n][i];return"d"===i&&(r='path("'+r+'")'),i+":"+r+";"})).join(i)+o})).join(i)+o})).join(i);return a||s?[""].join(i):""}(r.cssNodes,r.cssAnims,{newline:!0});if(u){var h=sw("style","stl",{},[],u);o.push(h)}}return hw(n,i,o,t.useViewBox)},t.prototype.renderToString=function(t){return t=t||{},lw(this.renderToVNode({animation:rt(t.cssAnimation,!0),emphasis:rt(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:rt(t.useViewBox,!0)}),{newline:!0})},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t},t.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},t.prototype._paintList=function(t,e,n){for(var i,r,o=t.length,a=[],s=0,l=0,u=0;u=0&&(!c||!r||c[f]!==r[f]);f--);for(var g=d-1;g>f;g--)i=a[--s-1];for(var y=f+1;y=a)}}for(var h=this.__startIndex;h15)break}n.prevElClipPaths&&u.restore()};if(p)if(0===p.length)s=l.__endIndex;else for(var _=d.dpr,b=0;b0&&t>i[0]){for(s=0;st);s++);a=n[i[s]]}if(i.splice(s+1,0,t),n[t]=e,!e.virtual)if(a){var l=a.dom;l.nextSibling?o.insertBefore(e.dom,l.nextSibling):o.appendChild(e.dom)}else o.firstChild?o.insertBefore(e.dom,o.firstChild):o.appendChild(e.dom);e.painter||(e.painter=this)}},t.prototype.eachLayer=function(t,e){for(var n=this._zlevelList,i=0;i0?uS:0),this._needsManuallyCompositing),u.__builtin__||I("ZLevel "+l+" has been used by unkown layer "+u.id),u!==o&&(u.__used=!0,u.__startIndex!==r&&(u.__dirty=!0),u.__startIndex=r,u.incremental?u.__drawIndex=-1:u.__drawIndex=r,e(r),o=u),1&s.__dirty&&!s.__inHover&&(u.__dirty=!0,u.incremental&&u.__drawIndex<0&&(u.__drawIndex=r))}e(r),this.eachBuiltinLayer((function(t,e){!t.__used&&t.getElementCount()>0&&(t.__dirty=!0,t.__startIndex=t.__endIndex=t.__drawIndex=0),t.__dirty&&t.__drawIndex<0&&(t.__drawIndex=t.__startIndex)}))},t.prototype.clear=function(){return this.eachBuiltinLayer(this._clearLayer),this},t.prototype._clearLayer=function(t){t.clear()},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t,E(this._layers,(function(t){t.setUnpainted()}))},t.prototype.configLayer=function(t,e){if(e){var n=this._layerConfig;n[t]?C(n[t],e,!0):n[t]=e;for(var i=0;i-1&&(s.style.stroke=s.style.fill,s.style.fill="#fff",s.style.lineWidth=2),e},e.type="series.line",e.dependencies=["grid","polar"],e.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:4,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1},e}(bg);function pS(t,e){var n=t.mapDimensionsAll("defaultedLabel"),i=n.length;if(1===i){var r=mf(t,e,n[0]);return null!=r?r+"":null}if(i){for(var o=[],a=0;a=0&&i.push(e[o])}return i.join(" ")}var fS=function(t){function e(e,n,i,r){var o=t.call(this)||this;return o.updateData(e,n,i,r),o}return n(e,t),e.prototype._createSymbol=function(t,e,n,i,r){this.removeAll();var o=Xy(t,-1,-1,2,2,null,r);o.attr({z2:100,culling:!0,scaleX:i[0]/2,scaleY:i[1]/2}),o.drift=gS,this._symbolType=t,this.add(o)},e.prototype.stopSymbolAnimation=function(t){this.childAt(0).stopAnimation(null,t)},e.prototype.getSymbolType=function(){return this._symbolType},e.prototype.getSymbolPath=function(){return this.childAt(0)},e.prototype.highlight=function(){Ol(this.childAt(0))},e.prototype.downplay=function(){Rl(this.childAt(0))},e.prototype.setZ=function(t,e){var n=this.childAt(0);n.zlevel=t,n.z=e},e.prototype.setDraggable=function(t,e){var n=this.childAt(0);n.draggable=t,n.cursor=!e&&t?"move":n.cursor},e.prototype.updateData=function(t,n,i,r){this.silent=!1;var o=t.getItemVisual(n,"symbol")||"circle",a=t.hostModel,s=e.getSymbolSize(t,n),l=o!==this._symbolType,u=r&&r.disableAnimation;if(l){var h=t.getItemVisual(n,"symbolKeepAspect");this._createSymbol(o,t,n,s,h)}else{(p=this.childAt(0)).silent=!1;var c={scaleX:s[0]/2,scaleY:s[1]/2};u?p.attr(c):vh(p,c,a,n),Sh(p)}if(this._updateCommon(t,n,s,i,r),l){var p=this.childAt(0);if(!u){c={scaleX:this._sizeX,scaleY:this._sizeY,style:{opacity:p.style.opacity}};p.scaleX=p.scaleY=0,p.style.opacity=0,mh(p,c,a,n)}}u&&this.childAt(0).stopAnimation("leave")},e.prototype._updateCommon=function(t,e,n,i,r){var o,a,s,l,u,h,c,p,d,f=this.childAt(0),g=t.hostModel;if(i&&(o=i.emphasisItemStyle,a=i.blurItemStyle,s=i.selectItemStyle,l=i.focus,u=i.blurScope,c=i.labelStatesModels,p=i.hoverScale,d=i.cursorStyle,h=i.emphasisDisabled),!i||t.hasItemOption){var y=i&&i.itemModel?i.itemModel:t.getItemModel(e),v=y.getModel("emphasis");o=v.getModel("itemStyle").getItemStyle(),s=y.getModel(["select","itemStyle"]).getItemStyle(),a=y.getModel(["blur","itemStyle"]).getItemStyle(),l=v.get("focus"),u=v.get("blurScope"),h=v.get("disabled"),c=rc(y),p=v.getShallow("scale"),d=y.getShallow("cursor")}var m=t.getItemVisual(e,"symbolRotate");f.attr("rotation",(m||0)*Math.PI/180||0);var x=Zy(t.getItemVisual(e,"symbolOffset"),n);x&&(f.x=x[0],f.y=x[1]),d&&f.attr("cursor",d);var _=t.getItemVisual(e,"style"),b=_.fill;if(f instanceof Ns){var w=f.style;f.useStyle(A({image:w.image,x:w.x,y:w.y,width:w.width,height:w.height},_))}else f.__isEmptyBrush?f.useStyle(A({},_)):f.useStyle(_),f.style.decal=null,f.setColor(b,r&&r.symbolInnerColor),f.style.strokeNoScale=!0;var S=t.getItemVisual(e,"liftZ"),M=this._z2;null!=S?null==M&&(this._z2=f.z2,f.z2+=S):null!=M&&(f.z2=M,this._z2=null);var I=r&&r.useNameLabel;ic(f,c,{labelFetcher:g,labelDataIndex:e,defaultText:function(e){return I?t.getName(e):pS(t,e)},inheritColor:b,defaultOpacity:_.opacity}),this._sizeX=n[0]/2,this._sizeY=n[1]/2;var T=f.ensureState("emphasis");T.style=o,f.ensureState("select").style=s,f.ensureState("blur").style=a;var C=null==p||!0===p?Math.max(1.1,3/this._sizeY):isFinite(p)&&p>0?+p:1;T.scaleX=this._sizeX*C,T.scaleY=this._sizeY*C,this.setSymbolScale(1),Zl(this,l,u,h)},e.prototype.setSymbolScale=function(t){this.scaleX=this.scaleY=t},e.prototype.fadeOut=function(t,e,n){var i=this.childAt(0),r=rl(this).dataIndex,o=n&&n.animation;if(this.silent=i.silent=!0,n&&n.fadeLabel){var a=i.getTextContent();a&&_h(a,{style:{opacity:0}},e,{dataIndex:r,removeOpt:o,cb:function(){i.removeTextContent()}})}else i.removeTextContent();_h(i,{style:{opacity:0},scaleX:0,scaleY:0},e,{dataIndex:r,cb:t,removeOpt:o})},e.getSymbolSize=function(t,e){return Uy(t.getItemVisual(e,"symbolSize"))},e}(Br);function gS(t,e){this.parent.drift(t,e)}function yS(t,e,n,i){return e&&!isNaN(e[0])&&!isNaN(e[1])&&!(i.isIgnore&&i.isIgnore(n))&&!(i.clipShape&&!i.clipShape.contain(e[0],e[1]))&&"none"!==t.getItemVisual(n,"symbol")}function vS(t){return null==t||q(t)||(t={isIgnore:t}),t||{}}function mS(t){var e=t.hostModel,n=e.getModel("emphasis");return{emphasisItemStyle:n.getModel("itemStyle").getItemStyle(),blurItemStyle:e.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:e.getModel(["select","itemStyle"]).getItemStyle(),focus:n.get("focus"),blurScope:n.get("blurScope"),emphasisDisabled:n.get("disabled"),hoverScale:n.get("scale"),labelStatesModels:rc(e),cursorStyle:e.get("cursor")}}var xS=function(){function t(t){this.group=new Br,this._SymbolCtor=t||fS}return t.prototype.updateData=function(t,e){this._progressiveEls=null,e=vS(e);var n=this.group,i=t.hostModel,r=this._data,o=this._SymbolCtor,a=e.disableAnimation,s=mS(t),l={disableAnimation:a},u=e.getSymbolPoint||function(e){return t.getItemLayout(e)};r||n.removeAll(),t.diff(r).add((function(i){var r=u(i);if(yS(t,r,i,e)){var a=new o(t,i,s,l);a.setPosition(r),t.setItemGraphicEl(i,a),n.add(a)}})).update((function(h,c){var p=r.getItemGraphicEl(c),d=u(h);if(yS(t,d,h,e)){var f=t.getItemVisual(h,"symbol")||"circle",g=p&&p.getSymbolType&&p.getSymbolType();if(!p||g&&g!==f)n.remove(p),(p=new o(t,h,s,l)).setPosition(d);else{p.updateData(t,h,s,l);var y={x:d[0],y:d[1]};a?p.attr(y):vh(p,y,i)}n.add(p),t.setItemGraphicEl(h,p)}else n.remove(p)})).remove((function(t){var e=r.getItemGraphicEl(t);e&&e.fadeOut((function(){n.remove(e)}),i)})).execute(),this._getSymbolPoint=u,this._data=t},t.prototype.updateLayout=function(){var t=this,e=this._data;e&&e.eachItemGraphicEl((function(e,n){var i=t._getSymbolPoint(n);e.setPosition(i),e.markRedraw()}))},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=mS(t),this._data=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e,n){function i(t){t.isGroup||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}this._progressiveEls=[],n=vS(n);for(var r=t.start;r0?n=i[0]:i[1]<0&&(n=i[1]);return n}(r,n),a=i.dim,s=r.dim,l=e.mapDimension(s),u=e.mapDimension(a),h="x"===s||"radius"===s?1:0,c=z(t.dimensions,(function(t){return e.mapDimension(t)})),p=!1,d=e.getCalculationInfo("stackResultDimension");return mx(e,c[0])&&(p=!0,c[0]=d),mx(e,c[1])&&(p=!0,c[1]=d),{dataDimsForPoint:c,valueStart:o,valueAxisDim:s,baseAxisDim:a,stacked:!!p,valueDim:l,baseDim:u,baseDataOffset:h,stackedOverDimension:e.getCalculationInfo("stackedOverDimension")}}function bS(t,e,n,i){var r=NaN;t.stacked&&(r=n.get(n.getCalculationInfo("stackedOverDimension"),i)),isNaN(r)&&(r=t.valueStart);var o=t.baseDataOffset,a=[];return a[o]=n.get(t.baseDim,i),a[1-o]=r,e.dataToPoint(a)}var wS=Math.min,SS=Math.max;function MS(t,e){return isNaN(t)||isNaN(e)}function IS(t,e,n,i,r,o,a,s,l){for(var u,h,c,p,d,f,g=n,y=0;y=r||g<0)break;if(MS(v,m)){if(l){g+=o;continue}break}if(g===n)t[o>0?"moveTo":"lineTo"](v,m),c=v,p=m;else{var x=v-u,_=m-h;if(x*x+_*_<.5){g+=o;continue}if(a>0){for(var b=g+o,w=e[2*b],S=e[2*b+1];w===v&&S===m&&y=i||MS(w,S))d=v,f=m;else{T=w-u,C=S-h;var k=v-u,L=w-v,P=m-h,O=S-m,R=void 0,N=void 0;if("x"===s){var E=T>0?1:-1;d=v-E*(R=Math.abs(k))*a,f=m,D=v+E*(N=Math.abs(L))*a,A=m}else if("y"===s){var z=C>0?1:-1;d=v,f=m-z*(R=Math.abs(P))*a,D=v,A=m+z*(N=Math.abs(O))*a}else R=Math.sqrt(k*k+P*P),d=v-T*a*(1-(I=(N=Math.sqrt(L*L+O*O))/(N+R))),f=m-C*a*(1-I),A=m+C*a*I,D=wS(D=v+T*a*I,SS(w,v)),A=wS(A,SS(S,m)),D=SS(D,wS(w,v)),f=m-(C=(A=SS(A,wS(S,m)))-m)*R/N,d=wS(d=v-(T=D-v)*R/N,SS(u,v)),f=wS(f,SS(h,m)),D=v+(T=v-(d=SS(d,wS(u,v))))*N/R,A=m+(C=m-(f=SS(f,wS(h,m))))*N/R}t.bezierCurveTo(c,p,d,f,v,m),c=D,p=A}else t.lineTo(v,m)}u=v,h=m,g+=o}return y}var TS=function(){this.smooth=0,this.smoothConstraint=!0},CS=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polyline",n}return n(e,t),e.prototype.getDefaultStyle=function(){return{stroke:"#000",fill:null}},e.prototype.getDefaultShape=function(){return new TS},e.prototype.buildPath=function(t,e){var n=e.points,i=0,r=n.length/2;if(e.connectNulls){for(;r>0&&MS(n[2*r-2],n[2*r-1]);r--);for(;i=0){var y=a?(h-i)*g+i:(u-n)*g+n;return a?[t,y]:[y,t]}n=u,i=h;break;case o.C:u=r[l++],h=r[l++],c=r[l++],p=r[l++],d=r[l++],f=r[l++];var v=a?_n(n,u,c,d,t,s):_n(i,h,p,f,t,s);if(v>0)for(var m=0;m=0){y=a?mn(i,h,p,f,x):mn(n,u,c,d,x);return a?[t,y]:[y,t]}}n=d,i=f}}},e}(ks),DS=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e}(TS),AS=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polygon",n}return n(e,t),e.prototype.getDefaultShape=function(){return new DS},e.prototype.buildPath=function(t,e){var n=e.points,i=e.stackedOnPoints,r=0,o=n.length/2,a=e.smoothMonotone;if(e.connectNulls){for(;o>0&&MS(n[2*o-2],n[2*o-1]);o--);for(;r=0;a--){var s=t.getDimensionInfo(i[a].dimension);if("x"===(r=s&&s.coordDim)||"y"===r){o=i[a];break}}if(o){var l=e.getAxis(r),u=z(o.stops,(function(t){return{coord:l.toGlobalCoord(l.dataToCoord(t.value)),color:t.color}})),h=u.length,c=o.outerColors.slice();h&&u[0].coord>u[h-1].coord&&(u.reverse(),c.reverse());var p=function(t,e){var n,i,r=[],o=t.length;function a(t,e,n){var i=t.coord;return{coord:n,color:ti((n-i)/(e.coord-i),[t.color,e.color])}}for(var s=0;se){i?r.push(a(i,l,e)):n&&r.push(a(n,l,0),a(n,l,e));break}n&&(r.push(a(n,l,0)),n=null),r.push(l),i=l}}return r}(u,"x"===r?n.getWidth():n.getHeight()),d=p.length;if(!d&&h)return u[0].coord<0?c[1]?c[1]:u[h-1].color:c[0]?c[0]:u[0].color;var f=p[0].coord-10,g=p[d-1].coord+10,y=g-f;if(y<.001)return"transparent";E(p,(function(t){t.offset=(t.coord-f)/y})),p.push({offset:d?p[d-1].offset:.5,color:c[1]||"transparent"}),p.unshift({offset:d?p[0].offset:.5,color:c[0]||"transparent"});var v=new oh(0,0,0,0,p,!0);return v[r]=f,v[r+"2"]=g,v}}}function FS(t,e,n){var i=t.get("showAllSymbol"),r="auto"===i;if(!i||r){var o=n.getAxesByScale("ordinal")[0];if(o&&(!r||!function(t,e){var n=t.getExtent(),i=Math.abs(n[1]-n[0])/t.scale.count();isNaN(i)&&(i=0);for(var r=e.count(),o=Math.max(1,Math.round(r/5)),a=0;ai)return!1;return!0}(o,e))){var a=e.mapDimension(o.dim),s={};return E(o.getViewLabels(),(function(t){var e=o.scale.getRawOrdinalNumber(t.tickValue);s[e]=1})),function(t){return!s.hasOwnProperty(e.get(a,t))}}}}function GS(t,e){return[t[2*e],t[2*e+1]]}function WS(t){if(t.get(["endLabel","show"]))return!0;for(var e=0;e0&&"bolder"===t.get(["emphasis","lineStyle","width"]))&&(d.getState("emphasis").style.lineWidth=+d.style.lineWidth+1);rl(d).seriesIndex=t.seriesIndex,Zl(d,L,P,O);var R=zS(t.get("smooth")),N=t.get("smoothMonotone");if(d.setShape({smooth:R,smoothMonotone:N,connectNulls:w}),f){var E=a.getCalculationInfo("stackedOnSeries"),z=0;f.useStyle(k(l.getAreaStyle(),{fill:C,opacity:.7,lineJoin:"bevel",decal:a.getVisual("style").decal})),E&&(z=zS(E.get("smooth"))),f.setShape({smooth:R,stackedOnSmooth:z,smoothMonotone:N,connectNulls:w}),$l(f,t,"areaStyle"),rl(f).seriesIndex=t.seriesIndex,Zl(f,L,P,O)}var V=function(t){i._changePolyState(t)};a.eachItemGraphicEl((function(t){t&&(t.onHoverStateChange=V)})),this._polyline.onHoverStateChange=V,this._data=a,this._coordSys=r,this._stackedOnPoints=_,this._points=u,this._step=T,this._valueOrigin=m,t.get("triggerLineEvent")&&(this.packEventData(t,d),f&&this.packEventData(t,f))},e.prototype.packEventData=function(t,e){rl(e).eventData={componentType:"series",componentSubType:"line",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"line"}},e.prototype.highlight=function(t,e,n,i){var r=t.getData(),o=zo(r,i);if(this._changePolyState("emphasis"),!(o instanceof Array)&&null!=o&&o>=0){var a=r.getLayout("points"),s=r.getItemGraphicEl(o);if(!s){var l=a[2*o],u=a[2*o+1];if(isNaN(l)||isNaN(u))return;if(this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(l,u))return;var h=t.get("zlevel")||0,c=t.get("z")||0;(s=new fS(r,o)).x=l,s.y=u,s.setZ(h,c);var p=s.getSymbolPath().getTextContent();p&&(p.zlevel=h,p.z=c,p.z2=this._polyline.z2+1),s.__temp=!0,r.setItemGraphicEl(o,s),s.stopSymbolAnimation(!0),this.group.add(s)}s.highlight()}else Og.prototype.highlight.call(this,t,e,n,i)},e.prototype.downplay=function(t,e,n,i){var r=t.getData(),o=zo(r,i);if(this._changePolyState("normal"),null!=o&&o>=0){var a=r.getItemGraphicEl(o);a&&(a.__temp?(r.setItemGraphicEl(o,null),this.group.remove(a)):a.downplay())}else Og.prototype.downplay.call(this,t,e,n,i)},e.prototype._changePolyState=function(t){var e=this._polygon;Dl(this._polyline,t),e&&Dl(e,t)},e.prototype._newPolyline=function(t){var e=this._polyline;return e&&this._lineGroup.remove(e),e=new CS({shape:{points:t},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(e),this._polyline=e,e},e.prototype._newPolygon=function(t,e){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new AS({shape:{points:t,stackedOnPoints:e},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},e.prototype._initSymbolLabelAnimation=function(t,e,n){var i,r,o=e.getBaseAxis(),a=o.inverse;"cartesian2d"===e.type?(i=o.isHorizontal(),r=!1):"polar"===e.type&&(i="angle"===o.dim,r=!0);var s=t.hostModel,l=s.get("animationDuration");X(l)&&(l=l(null));var u=s.get("animationDelay")||0,h=X(u)?u(null):u;t.eachItemGraphicEl((function(t,o){var s=t;if(s){var c=[t.x,t.y],p=void 0,d=void 0,f=void 0;if(n)if(r){var g=n,y=e.pointToCoord(c);i?(p=g.startAngle,d=g.endAngle,f=-y[1]/180*Math.PI):(p=g.r0,d=g.r,f=y[0])}else{var v=n;i?(p=v.x,d=v.x+v.width,f=t.x):(p=v.y+v.height,d=v.y,f=t.y)}var m=d===p?0:(f-p)/(d-p);a&&(m=1-m);var x=X(u)?u(o):l*m+h,_=s.getSymbolPath(),b=_.getTextContent();s.attr({scaleX:0,scaleY:0}),s.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:x}),b&&b.animateFrom({style:{opacity:0}},{duration:300,delay:x}),_.disableLabelAnimation=!0}}))},e.prototype._initOrUpdateEndLabel=function(t,e,n){var i=t.getModel("endLabel");if(WS(t)){var r=t.getData(),o=this._polyline,a=r.getLayout("points");if(!a)return o.removeTextContent(),void(this._endLabel=null);var s=this._endLabel;s||((s=this._endLabel=new Xs({z2:200})).ignoreClip=!0,o.setTextContent(this._endLabel),o.disableLabelAnimation=!0);var l=function(t){for(var e,n,i=t.length/2;i>0&&(e=t[2*i-2],n=t[2*i-1],isNaN(e)||isNaN(n));i--);return i-1}(a);l>=0&&(ic(o,rc(t,"endLabel"),{inheritColor:n,labelFetcher:t,labelDataIndex:l,defaultText:function(t,e,n){return null!=n?dS(r,n):pS(r,t)},enableTextSetter:!0},function(t,e){var n=e.getBaseAxis(),i=n.isHorizontal(),r=n.inverse,o=i?r?"right":"left":"center",a=i?"middle":r?"top":"bottom";return{normal:{align:t.get("align")||o,verticalAlign:t.get("verticalAlign")||a}}}(i,e)),o.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},e.prototype._endLabelOnDuring=function(t,e,n,i,r,o,a){var s=this._endLabel,l=this._polyline;if(s){t<1&&null==i.originalX&&(i.originalX=s.x,i.originalY=s.y);var u=n.getLayout("points"),h=n.hostModel,c=h.get("connectNulls"),p=o.get("precision"),d=o.get("distance")||0,f=a.getBaseAxis(),g=f.isHorizontal(),y=f.inverse,v=e.shape,m=y?g?v.x:v.y+v.height:g?v.x+v.width:v.y,x=(g?d:0)*(y?-1:1),_=(g?0:-d)*(y?-1:1),b=g?"x":"y",w=function(t,e,n){for(var i,r,o=t.length/2,a="x"===n?0:1,s=0,l=-1,u=0;u=e||i>=e&&r<=e){l=u;break}s=u,i=r}else i=r;return{range:[s,l],t:(e-i)/(r-i)}}(u,m,b),S=w.range,M=S[1]-S[0],I=void 0;if(M>=1){if(M>1&&!c){var T=GS(u,S[0]);s.attr({x:T[0]+x,y:T[1]+_}),r&&(I=h.getRawValue(S[0]))}else{(T=l.getPointOn(m,b))&&s.attr({x:T[0]+x,y:T[1]+_});var C=h.getRawValue(S[0]),D=h.getRawValue(S[1]);r&&(I=Zo(n,p,C,D,w.t))}i.lastFrameIndex=S[0]}else{var A=1===t||i.lastFrameIndex>0?S[0]:0;T=GS(u,A);r&&(I=h.getRawValue(A)),s.attr({x:T[0]+x,y:T[1]+_})}if(r){var k=pc(s);"function"==typeof k.setLabelText&&k.setLabelText(I)}}},e.prototype._doUpdateAnimation=function(t,e,n,i,r,o,a){var s=this._polyline,l=this._polygon,u=t.hostModel,h=function(t,e,n,i,r,o,a,s){for(var l=function(t,e){var n=[];return e.diff(t).add((function(t){n.push({cmd:"+",idx:t})})).update((function(t,e){n.push({cmd:"=",idx:e,idx1:t})})).remove((function(t){n.push({cmd:"-",idx:t})})).execute(),n}(t,e),u=[],h=[],c=[],p=[],d=[],f=[],g=[],y=_S(r,e,a),v=t.getLayout("points")||[],m=e.getLayout("points")||[],x=0;x3e3||l&&ES(p,f)>3e3)return s.stopAnimation(),s.setShape({points:d}),void(l&&(l.stopAnimation(),l.setShape({points:d,stackedOnPoints:f})));s.shape.__points=h.current,s.shape.points=c;var g={shape:{points:d}};h.current!==c&&(g.shape.__points=h.next),s.stopAnimation(),vh(s,g,u),l&&(l.setShape({points:c,stackedOnPoints:p}),l.stopAnimation(),vh(l,{shape:{stackedOnPoints:f}},u),s.shape.points!==l.shape.points&&(l.shape.points=s.shape.points));for(var y=[],v=h.status,m=0;me&&(e=t[n]);return isFinite(e)?e:NaN},min:function(t){for(var e=1/0,n=0;ne&&(e=o,n=r)}return isFinite(n)?n:NaN},nearest:function(t){return t[0]}},ZS=function(t){return Math.round(t.length/2)};function jS(t){return{seriesType:t,reset:function(t,e,n){var i=t.getData(),r=t.get("sampling"),o=t.coordinateSystem,a=i.count();if(a>10&&"cartesian2d"===o.type&&r){var s=o.getBaseAxis(),l=o.getOtherAxis(s),u=s.getExtent(),h=n.getDevicePixelRatio(),c=Math.abs(u[1]-u[0])*(h||1),p=Math.round(a/c);if(isFinite(p)&&p>1){"lttb"===r&&t.setData(i.lttbDownSample(i.mapDimension(l.dim),1/p));var d=void 0;U(r)?d=US[r]:X(r)&&(d=r),d&&t.setData(i.downSample(i.mapDimension(l.dim),1/p,d,ZS))}}}}}var qS=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.getInitialData=function(t,e){return _x(null,this,{useEncodeDefaulter:!0})},e.prototype.getMarkerPosition=function(t,e,n){var i=this.coordinateSystem;if(i&&i.clampData){var r=i.clampData(t),o=i.dataToPoint(r);if(n)E(i.getAxes(),(function(t,n){if("category"===t.type&&null!=e){var i=t.getTicksCoords(),a=t.getTickModel().get("alignWithLabel"),s=r[n],l="x1"===e[n]||"y1"===e[n];if(l&&!a&&(s+=1),i.length<2)return;if(2===i.length)return void(o[n]=t.toGlobalCoord(t.getExtent()[l?1:0]));for(var u=void 0,h=void 0,c=1,p=0;ps){h=(d+u)/2;break}1===p&&(c=f-i[0].tickValue)}null==h&&(u?u&&(h=i[i.length-1].coord):h=i[0].coord),o[n]=t.toGlobalCoord(h)}}));else{var a=this.getData(),s=a.getLayout("offset"),l=a.getLayout("size"),u=i.getBaseAxis().isHorizontal()?0:1;o[u]+=s+l/2}return o}return[NaN,NaN]},e.type="series.__base_bar__",e.defaultOption={z:2,coordinateSystem:"cartesian2d",legendHoverLink:!0,barMinHeight:0,barMinAngle:0,large:!1,largeThreshold:400,progressive:3e3,progressiveChunkMode:"mod"},e}(bg);bg.registerClass(qS);var KS=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.getInitialData=function(){return _x(null,this,{useEncodeDefaulter:!0,createInvertedIndices:!!this.get("realtimeSort",!0)||null})},e.prototype.getProgressive=function(){return!!this.get("large")&&this.get("progressive")},e.prototype.getProgressiveThreshold=function(){var t=this.get("progressiveThreshold"),e=this.get("largeThreshold");return e>t&&(t=e),t},e.prototype.brushSelector=function(t,e,n){return n.rect(e.getItemLayout(t))},e.type="series.bar",e.dependencies=["grid","polar"],e.defaultOption=kc(qS.defaultOption,{clip:!0,roundCap:!1,showBackground:!1,backgroundStyle:{color:"rgba(180, 180, 180, 0.2)",borderColor:null,borderWidth:0,borderType:"solid",borderRadius:0,shadowBlur:0,shadowColor:null,shadowOffsetX:0,shadowOffsetY:0,opacity:1},select:{itemStyle:{borderColor:"#212121"}},realtimeSort:!1}),e}(qS),$S=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0},JS=function(t){function e(e){var n=t.call(this,e)||this;return n.type="sausage",n}return n(e,t),e.prototype.getDefaultShape=function(){return new $S},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=Math.max(e.r0||0,0),o=Math.max(e.r,0),a=.5*(o-r),s=r+a,l=e.startAngle,u=e.endAngle,h=e.clockwise,c=2*Math.PI,p=h?u-lo)return!0;o=u}return!1},e.prototype._isOrderDifferentInView=function(t,e){for(var n=e.scale,i=n.getExtent(),r=Math.max(0,i[0]),o=Math.min(i[1],n.getOrdinalMeta().categories.length-1);r<=o;++r)if(t.ordinalNumbers[r]!==n.getRawOrdinalNumber(r))return!0},e.prototype._updateSortWithinSameData=function(t,e,n,i){if(this._isOrderChangedWithinSameData(t,e,n)){var r=this._dataSort(t,n,e);this._isOrderDifferentInView(r,n)&&(this._removeOnRenderedListener(i),i.dispatchAction({type:"changeAxisOrder",componentType:n.dim+"Axis",axisId:n.index,sortInfo:r}))}},e.prototype._dispatchInitSort=function(t,e,n){var i=e.baseAxis,r=this._dataSort(t,i,(function(n){return t.get(t.mapDimension(e.otherAxis.dim),n)}));n.dispatchAction({type:"changeAxisOrder",componentType:i.dim+"Axis",isInitSort:!0,axisId:i.index,sortInfo:r})},e.prototype.remove=function(t,e){this._clear(this._model),this._removeOnRenderedListener(e)},e.prototype.dispose=function(t,e){this._removeOnRenderedListener(e)},e.prototype._removeOnRenderedListener=function(t){this._onRendered&&(t.getZr().off("rendered",this._onRendered),this._onRendered=null)},e.prototype._clear=function(t){var e=this.group,n=this._data;t&&t.isAnimationEnabled()&&n&&!this._isLargeDraw?(this._removeBackground(),this._backgroundEls=[],n.eachItemGraphicEl((function(e){wh(e,t,rl(e).dataIndex)}))):e.removeAll(),this._data=null,this._isFirstFrame=!0},e.prototype._removeBackground=function(){this.group.remove(this._backgroundGroup),this._backgroundGroup=null},e.type="bar",e}(Og),oM={cartesian2d:function(t,e){var n=e.width<0?-1:1,i=e.height<0?-1:1;n<0&&(e.x+=e.width,e.width=-e.width),i<0&&(e.y+=e.height,e.height=-e.height);var r=t.x+t.width,o=t.y+t.height,a=nM(e.x,t.x),s=iM(e.x+e.width,r),l=nM(e.y,t.y),u=iM(e.y+e.height,o),h=sr?s:a,e.y=c&&l>o?u:l,e.width=h?0:s-a,e.height=c?0:u-l,n<0&&(e.x+=e.width,e.width=-e.width),i<0&&(e.y+=e.height,e.height=-e.height),h||c},polar:function(t,e){var n=e.r0<=e.r?1:-1;if(n<0){var i=e.r;e.r=e.r0,e.r0=i}var r=iM(e.r,t.r),o=nM(e.r0,t.r0);e.r=r,e.r0=o;var a=r-o<0;if(n<0){i=e.r;e.r=e.r0,e.r0=i}return a}},aM={cartesian2d:function(t,e,n,i,r,o,a,s,l){var u=new Ws({shape:A({},i),z2:1});(u.__dataIndex=n,u.name="item",o)&&(u.shape[r?"height":"width"]=0);return u},polar:function(t,e,n,i,r,o,a,s,l){var u=!r&&l?JS:Fu,h=new u({shape:i,z2:1});h.name="item";var c,p,d=dM(r);if(h.calculateTextPosition=(c=d,p=({isRoundCap:u===JS}||{}).isRoundCap,function(t,e,n){var i=e.position;if(!i||i instanceof Array)return Dr(t,e,n);var r=c(i),o=null!=e.distance?e.distance:5,a=this.shape,s=a.cx,l=a.cy,u=a.r,h=a.r0,d=(u+h)/2,f=a.startAngle,g=a.endAngle,y=(f+g)/2,v=p?Math.abs(u-h)/2:0,m=Math.cos,x=Math.sin,_=s+u*m(f),b=l+u*x(f),w="left",S="top";switch(r){case"startArc":_=s+(h-o)*m(y),b=l+(h-o)*x(y),w="center",S="top";break;case"insideStartArc":_=s+(h+o)*m(y),b=l+(h+o)*x(y),w="center",S="bottom";break;case"startAngle":_=s+d*m(f)+QS(f,o+v,!1),b=l+d*x(f)+tM(f,o+v,!1),w="right",S="middle";break;case"insideStartAngle":_=s+d*m(f)+QS(f,-o+v,!1),b=l+d*x(f)+tM(f,-o+v,!1),w="left",S="middle";break;case"middle":_=s+d*m(y),b=l+d*x(y),w="center",S="middle";break;case"endArc":_=s+(u+o)*m(y),b=l+(u+o)*x(y),w="center",S="bottom";break;case"insideEndArc":_=s+(u-o)*m(y),b=l+(u-o)*x(y),w="center",S="top";break;case"endAngle":_=s+d*m(g)+QS(g,o+v,!0),b=l+d*x(g)+tM(g,o+v,!0),w="left",S="middle";break;case"insideEndAngle":_=s+d*m(g)+QS(g,-o+v,!0),b=l+d*x(g)+tM(g,-o+v,!0),w="right",S="middle";break;default:return Dr(t,e,n)}return(t=t||{}).x=_,t.y=b,t.align=w,t.verticalAlign=S,t}),o){var f=r?"r":"endAngle",g={};h.shape[f]=r?i.r0:i.startAngle,g[f]=i[f],(s?vh:mh)(h,{shape:g},o)}return h}};function sM(t,e,n,i,r,o,a,s){var l,u;o?(u={x:i.x,width:i.width},l={y:i.y,height:i.height}):(u={y:i.y,height:i.height},l={x:i.x,width:i.width}),s||(a?vh:mh)(n,{shape:l},e,r,null),(a?vh:mh)(n,{shape:u},e?t.baseAxis.model:null,r)}function lM(t,e){for(var n=0;n0?1:-1,a=i.height>0?1:-1;return{x:i.x+o*r/2,y:i.y+a*r/2,width:i.width-o*r,height:i.height-a*r}},polar:function(t,e,n){var i=t.getItemLayout(e);return{cx:i.cx,cy:i.cy,r0:i.r0,r:i.r,startAngle:i.startAngle,endAngle:i.endAngle,clockwise:i.clockwise}}};function dM(t){return function(t){var e=t?"Arc":"Angle";return function(t){switch(t){case"start":case"insideStart":case"end":case"insideEnd":return t+e;default:return t}}}(t)}function fM(t,e,n,i,r,o,a,s){var l=e.getItemVisual(n,"style");if(s){if(!o.get("roundCap")){var u=t.shape;A(u,eM(i.getModel("itemStyle"),u,!0)),t.setShape(u)}}else{var h=i.get(["itemStyle","borderRadius"])||0;t.setShape("r",h)}t.useStyle(l);var c=i.getShallow("cursor");c&&t.attr("cursor",c);var p=s?a?r.r>=r.r0?"endArc":"startArc":r.endAngle>=r.startAngle?"endAngle":"startAngle":a?r.height>=0?"bottom":"top":r.width>=0?"right":"left",d=rc(i);ic(t,d,{labelFetcher:o,labelDataIndex:n,defaultText:pS(o.getData(),n),inheritColor:l.fill,defaultOpacity:l.opacity,defaultOutsidePosition:p});var f=t.getTextContent();if(s&&f){var g=i.get(["label","position"]);t.textConfig.inside="middle"===g||null,function(t,e,n,i){if(j(i))t.setTextConfig({rotation:i});else if(Y(e))t.setTextConfig({rotation:0});else{var r,o=t.shape,a=o.clockwise?o.startAngle:o.endAngle,s=o.clockwise?o.endAngle:o.startAngle,l=(a+s)/2,u=n(e);switch(u){case"startArc":case"insideStartArc":case"middle":case"insideEndArc":case"endArc":r=l;break;case"startAngle":case"insideStartAngle":r=a;break;case"endAngle":case"insideEndAngle":r=s;break;default:return void t.setTextConfig({rotation:0})}var h=1.5*Math.PI-r;"middle"===u&&h>Math.PI/2&&h<1.5*Math.PI&&(h-=Math.PI),t.setTextConfig({rotation:h})}}(t,"outside"===g?p:g,dM(a),i.get(["label","rotate"]))}dc(f,d,o.getRawValue(n),(function(t){return dS(e,t)}));var y=i.getModel(["emphasis"]);Zl(t,y.get("focus"),y.get("blurScope"),y.get("disabled")),$l(t,i),function(t){return null!=t.startAngle&&null!=t.endAngle&&t.startAngle===t.endAngle}(r)&&(t.style.fill="none",t.style.stroke="none",E(t.states,(function(t){t.style&&(t.style.fill=t.style.stroke="none")})))}var gM=function(){},yM=function(t){function e(e){var n=t.call(this,e)||this;return n.type="largeBar",n}return n(e,t),e.prototype.getDefaultShape=function(){return new gM},e.prototype.buildPath=function(t,e){for(var n=e.points,i=this.baseDimIdx,r=1-this.baseDimIdx,o=[],a=[],s=this.barWidth,l=0;l=s[0]&&e<=s[0]+l[0]&&n>=s[1]&&n<=s[1]+l[1])return a[h]}return-1}(this,t.offsetX,t.offsetY);rl(this).dataIndex=e>=0?e:null}),30,!1);function xM(t,e,n){if(OS(n,"cartesian2d")){var i=e,r=n.getArea();return{x:t?i.x:r.x,y:t?r.y:i.y,width:t?i.width:r.width,height:t?r.height:i.height}}var o=e;return{cx:(r=n.getArea()).cx,cy:r.cy,r0:t?r.r0:o.r0,r:t?r.r:o.r,startAngle:t?o.startAngle:0,endAngle:t?o.endAngle:2*Math.PI}}var _M=2*Math.PI,bM=Math.PI/180;function wM(t,e){return kp(t.getBoxLayoutParams(),{width:e.getWidth(),height:e.getHeight()})}function SM(t,e){var n=wM(t,e),i=t.get("center"),r=t.get("radius");Y(r)||(r=[0,r]);var o,a,s=$r(n.width,e.getWidth()),l=$r(n.height,e.getHeight()),u=Math.min(s,l),h=$r(r[0],u/2),c=$r(r[1],u/2),p=t.coordinateSystem;if(p){var d=p.dataToPoint(i);o=d[0]||0,a=d[1]||0}else Y(i)||(i=[i,i]),o=$r(i[0],s)+n.x,a=$r(i[1],l)+n.y;return{cx:o,cy:a,r0:h,r:c}}function MM(t,e,n){e.eachSeriesByType(t,(function(t){var e=t.getData(),i=e.mapDimension("value"),r=wM(t,n),o=SM(t,n),a=o.cx,s=o.cy,l=o.r,u=o.r0,h=-t.get("startAngle")*bM,c=t.get("endAngle"),p=t.get("padAngle")*bM;c="auto"===c?h-_M:-c*bM;var d=t.get("minAngle")*bM+p,f=0;e.each(i,(function(t){!isNaN(t)&&f++}));var g=e.getSum(i),y=Math.PI/(g||f)*2,v=t.get("clockwise"),m=t.get("roseType"),x=t.get("stillShowZeroSum"),_=e.getDataExtent(i);_[0]=0;var b=v?1:-1,w=[h,c],S=b*p/2;us(w,!v),h=w[0],c=w[1];var M=IM(t);M.startAngle=h,M.endAngle=c,M.clockwise=v;var I=Math.abs(c-h),T=I,C=0,D=h;if(e.setLayout({viewRect:r,r:l}),e.each(i,(function(t,n){var i;if(isNaN(t))e.setItemLayout(n,{angle:NaN,startAngle:NaN,endAngle:NaN,clockwise:v,cx:a,cy:s,r0:u,r:m?NaN:l});else{(i="area"!==m?0===g&&x?y:t*y:I/f)i?h=o=D+b*i/2:(o=D+S,h=r-S),e.setItemLayout(n,{angle:i,startAngle:o,endAngle:h,clockwise:v,cx:a,cy:s,r0:u,r:m?Kr(t,_,[u,l]):l}),D=r}})),T<_M&&f)if(T<=.001){var A=I/f;e.each(i,(function(t,n){if(!isNaN(t)){var i=e.getItemLayout(n);i.angle=A;var r=0,o=0;An?a:o,h=Math.abs(l.label.y-n);if(h>=u.maxY){var c=l.label.x-e-l.len2*r,p=i+l.len,f=Math.abs(c)t.unconstrainedWidth?null:d:null;i.setStyle("width",f)}var g=i.getBoundingRect();o.width=g.width;var y=(i.style.margin||0)+2.1;o.height=g.height+y,o.y-=(o.height-c)/2}}}function kM(t){return"center"===t.position}function LM(t){var e,n,i=t.getData(),r=[],o=!1,a=(t.get("minShowLabelAngle")||0)*CM,s=i.getLayout("viewRect"),l=i.getLayout("r"),u=s.width,h=s.x,c=s.y,p=s.height;function d(t){t.ignore=!0}i.each((function(t){var s=i.getItemGraphicEl(t),c=s.shape,p=s.getTextContent(),f=s.getTextGuideLine(),g=i.getItemModel(t),y=g.getModel("label"),v=y.get("position")||g.get(["emphasis","label","position"]),m=y.get("distanceToLabelLine"),x=y.get("alignTo"),_=$r(y.get("edgeDistance"),u),b=y.get("bleedMargin"),w=g.getModel("labelLine"),S=w.get("length");S=$r(S,u);var M=w.get("length2");if(M=$r(M,u),Math.abs(c.endAngle-c.startAngle)0?"right":"left":k>0?"left":"right"}var B=Math.PI,F=0,G=y.get("rotate");if(j(G))F=G*(B/180);else if("center"===v)F=0;else if("radial"===G||!0===G){F=k<0?-A+B:-A}else if("tangential"===G&&"outside"!==v&&"outer"!==v){var W=Math.atan2(k,L);W<0&&(W=2*B+W),L>0&&(W=B+W),F=W-B}if(o=!!F,p.x=I,p.y=T,p.rotation=F,p.setStyle({verticalAlign:"middle"}),P){p.setStyle({align:D});var H=p.states.select;H&&(H.x+=p.x,H.y+=p.y)}else{var Y=p.getBoundingRect().clone();Y.applyTransform(p.getComputedTransform());var X=(p.style.margin||0)+2.1;Y.y-=X/2,Y.height+=X,r.push({label:p,labelLine:f,position:v,len:S,len2:M,minTurnAngle:w.get("minTurnAngle"),maxSurfaceAngle:w.get("maxSurfaceAngle"),surfaceNormal:new De(k,L),linePoints:C,textAlign:D,labelDistance:m,labelAlignTo:x,edgeDistance:_,bleedMargin:b,rect:Y,unconstrainedWidth:Y.width,labelStyleWidth:p.style.width})}s.setTextConfig({inside:P})}})),!o&&t.get("avoidLabelOverlap")&&function(t,e,n,i,r,o,a,s){for(var l=[],u=[],h=Number.MAX_VALUE,c=-Number.MAX_VALUE,p=0;p0){for(var l=o.getItemLayout(0),u=1;isNaN(l&&l.startAngle)&&u=n.r0}},e.type="pie",e}(Og);function RM(t,e,n){e=Y(e)&&{coordDimensions:e}||A({encodeDefine:t.getEncode()},e);var i=t.getSource(),r=px(i,e).dimensions,o=new cx(r,t);return o.initData(i,n),o}var NM=function(){function t(t,e){this._getDataWithEncodedVisual=t,this._getRawData=e}return t.prototype.getAllNames=function(){var t=this._getRawData();return t.mapArray(t.getName)},t.prototype.containName=function(t){return this._getRawData().indexOfName(t)>=0},t.prototype.indexOfName=function(t){return this._getDataWithEncodedVisual().indexOfName(t)},t.prototype.getItemVisual=function(t,e){return this._getDataWithEncodedVisual().getItemVisual(t,e)},t}(),EM=Vo(),zM=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.init=function(e){t.prototype.init.apply(this,arguments),this.legendVisualProvider=new NM(W(this.getData,this),W(this.getRawData,this)),this._defaultLabelLine(e)},e.prototype.mergeOption=function(){t.prototype.mergeOption.apply(this,arguments)},e.prototype.getInitialData=function(){return RM(this,{coordDimensions:["value"],encodeDefaulter:H(ed,this)})},e.prototype.getDataParams=function(e){var n=this.getData(),i=EM(n),r=i.seats;if(!r){var o=[];n.each(n.mapDimension("value"),(function(t){o.push(t)})),r=i.seats=io(o,n.hostModel.get("percentPrecision"))}var a=t.prototype.getDataParams.call(this,e);return a.percent=r[e]||0,a.$vars.push("percent"),a},e.prototype._defaultLabelLine=function(t){Co(t,"labelLine",["show"]);var e=t.labelLine,n=t.emphasis.labelLine;e.show=e.show&&t.label.show,n.show=n.show&&t.emphasis.label.show},e.type="series.pie",e.defaultOption={z:2,legendHoverLink:!0,colorBy:"data",center:["50%","50%"],radius:[0,"75%"],clockwise:!0,startAngle:90,endAngle:"auto",padAngle:0,minAngle:0,minShowLabelAngle:0,selectedOffset:10,percentPrecision:2,stillShowZeroSum:!0,left:0,top:0,right:0,bottom:0,width:null,height:null,label:{rotate:0,show:!0,overflow:"truncate",position:"outer",alignTo:"none",edgeDistance:"25%",bleedMargin:10,distanceToLabelLine:5},labelLine:{show:!0,length:15,length2:15,smooth:!1,minTurnAngle:90,maxSurfaceAngle:90,lineStyle:{width:1,type:"solid"}},itemStyle:{borderWidth:1,borderJoin:"round"},showEmptyCircle:!0,emptyCircleStyle:{color:"lightgray",opacity:1},labelLayout:{hideOverlap:!0},emphasis:{scale:!0,scaleSize:5},avoidLabelOverlap:!0,animationType:"expansion",animationDuration:1e3,animationTypeUpdate:"transition",animationEasingUpdate:"cubicInOut",animationDurationUpdate:500,animationEasing:"cubicInOut"},e}(bg);var VM=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.hasSymbolVisual=!0,n}return n(e,t),e.prototype.getInitialData=function(t,e){return _x(null,this,{useEncodeDefaulter:!0})},e.prototype.getProgressive=function(){var t=this.option.progressive;return null==t?this.option.large?5e3:this.get("progressive"):t},e.prototype.getProgressiveThreshold=function(){var t=this.option.progressiveThreshold;return null==t?this.option.large?1e4:this.get("progressiveThreshold"):t},e.prototype.brushSelector=function(t,e,n){return n.point(e.getItemLayout(t))},e.prototype.getZLevelKey=function(){return this.getData().count()>this.getProgressiveThreshold()?this.id:""},e.type="series.scatter",e.dependencies=["grid","polar","geo","singleAxis","calendar"],e.defaultOption={coordinateSystem:"cartesian2d",z:2,legendHoverLink:!0,symbolSize:10,large:!1,largeThreshold:2e3,itemStyle:{opacity:.8},emphasis:{scale:!0},clip:!0,select:{itemStyle:{borderColor:"#212121"}},universalTransition:{divideShape:"clone"}},e}(bg),BM=function(){},FM=function(t){function e(e){var n=t.call(this,e)||this;return n._off=0,n.hoverDataIdx=-1,n}return n(e,t),e.prototype.getDefaultShape=function(){return new BM},e.prototype.reset=function(){this.notClear=!1,this._off=0},e.prototype.buildPath=function(t,e){var n,i=e.points,r=e.size,o=this.symbolProxy,a=o.shape,s=t.getContext?t.getContext():t,l=s&&r[0]<4,u=this.softClipShape;if(l)this._ctx=s;else{for(this._ctx=null,n=this._off;n=0;s--){var l=2*s,u=i[l]-o/2,h=i[l+1]-a/2;if(t>=u&&e>=h&&t<=u+o&&e<=h+a)return s}return-1},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect();return t=n[0],e=n[1],i.contain(t,e)?(this.hoverDataIdx=this.findDataIndex(t,e))>=0:(this.hoverDataIdx=-1,!1)},e.prototype.getBoundingRect=function(){var t=this._rect;if(!t){for(var e=this.shape,n=e.points,i=e.size,r=i[0],o=i[1],a=1/0,s=1/0,l=-1/0,u=-1/0,h=0;h=0&&(l.dataIndex=n+(t.startIndex||0))}))},t.prototype.remove=function(){this._clear()},t.prototype._clear=function(){this._newAdded=[],this.group.removeAll()},t}(),WM=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){var i=t.getData();this._updateSymbolDraw(i,t).updateData(i,{clipShape:this._getClipShape(t)}),this._finished=!0},e.prototype.incrementalPrepareRender=function(t,e,n){var i=t.getData();this._updateSymbolDraw(i,t).incrementalPrepareUpdate(i),this._finished=!1},e.prototype.incrementalRender=function(t,e,n){this._symbolDraw.incrementalUpdate(t,e.getData(),{clipShape:this._getClipShape(e)}),this._finished=t.end===e.getData().count()},e.prototype.updateTransform=function(t,e,n){var i=t.getData();if(this.group.dirty(),!this._finished||i.count()>1e4)return{update:!0};var r=XS("").reset(t,e,n);r.progress&&r.progress({start:0,end:i.count(),count:i.count()},i),this._symbolDraw.updateLayout(i)},e.prototype.eachRendered=function(t){this._symbolDraw&&this._symbolDraw.eachRendered(t)},e.prototype._getClipShape=function(t){if(t.get("clip",!0)){var e=t.coordinateSystem;return e&&e.getArea&&e.getArea(.1)}},e.prototype._updateSymbolDraw=function(t,e){var n=this._symbolDraw,i=e.pipelineContext.large;return n&&i===this._isLargeDraw||(n&&n.remove(),n=this._symbolDraw=i?new GM:new xS,this._isLargeDraw=i,this.group.removeAll()),this.group.add(n.group),n},e.prototype.remove=function(t,e){this._symbolDraw&&this._symbolDraw.remove(!0),this._symbolDraw=null},e.prototype.dispose=function(){},e.type="scatter",e}(Og),HM=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.type="grid",e.dependencies=["xAxis","yAxis"],e.layoutMode="box",e.defaultOption={show:!1,z:0,left:"10%",top:60,right:"10%",bottom:70,containLabel:!1,backgroundColor:"rgba(0,0,0,0)",borderWidth:1,borderColor:"#ccc"},e}(zp),YM=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.getCoordSysModel=function(){return this.getReferringComponents("grid",Wo).models[0]},e.type="cartesian2dAxis",e}(zp);R(YM,D_);var XM={show:!0,z:0,inverse:!1,name:"",nameLocation:"end",nameRotate:null,nameTruncate:{maxWidth:null,ellipsis:"...",placeholder:"."},nameTextStyle:{},nameGap:15,silent:!1,triggerEvent:!1,tooltip:{show:!1},axisPointer:{},axisLine:{show:!0,onZero:!0,onZeroAxisIndex:null,lineStyle:{color:"#6E7079",width:1,type:"solid"},symbol:["none","none"],symbolSize:[10,15]},axisTick:{show:!0,inside:!1,length:5,lineStyle:{width:1}},axisLabel:{show:!0,inside:!1,rotate:0,showMinLabel:null,showMaxLabel:null,margin:8,fontSize:12},splitLine:{show:!0,lineStyle:{color:["#E0E6F1"],width:1,type:"solid"}},splitArea:{show:!1,areaStyle:{color:["rgba(250,250,250,0.2)","rgba(210,219,238,0.2)"]}}},UM=C({boundaryGap:!0,deduplication:null,splitLine:{show:!1},axisTick:{alignWithLabel:!1,interval:"auto"},axisLabel:{interval:"auto"}},XM),ZM=C({boundaryGap:[0,0],axisLine:{show:"auto"},axisTick:{show:"auto"},splitNumber:5,minorTick:{show:!1,splitNumber:5,length:3,lineStyle:{}},minorSplitLine:{show:!1,lineStyle:{color:"#F4F7FD",width:1}}},XM),jM={category:UM,value:ZM,time:C({splitNumber:6,axisLabel:{showMinLabel:!1,showMaxLabel:!1,rich:{primary:{fontWeight:"bold"}}},splitLine:{show:!1}},ZM),log:k({logBase:10},ZM)},qM={value:1,category:1,time:1,log:1};function KM(t,e,i,r){E(qM,(function(o,a){var s=C(C({},jM[a],!0),r,!0),l=function(t){function i(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e+"Axis."+a,n}return n(i,t),i.prototype.mergeDefaultAndTheme=function(t,e){var n=Pp(this),i=n?Rp(t):{};C(t,e.getTheme().get(a+"Axis")),C(t,this.getDefaultOption()),t.type=$M(t),n&&Op(t,i,n)},i.prototype.optionUpdated=function(){"category"===this.option.type&&(this.__ordinalMeta=Mx.createByAxisModel(this))},i.prototype.getCategories=function(t){var e=this.option;if("category"===e.type)return t?e.data:this.__ordinalMeta.categories},i.prototype.getOrdinalMeta=function(){return this.__ordinalMeta},i.type=e+"Axis."+a,i.defaultOption=s,i}(i);t.registerComponentModel(l)})),t.registerSubTypeDefaulter(e+"Axis",$M)}function $M(t){return t.type||(t.data?"category":"value")}var JM=function(){function t(t){this.type="cartesian",this._dimList=[],this._axes={},this.name=t||""}return t.prototype.getAxis=function(t){return this._axes[t]},t.prototype.getAxes=function(){return z(this._dimList,(function(t){return this._axes[t]}),this)},t.prototype.getAxesByScale=function(t){return t=t.toLowerCase(),B(this.getAxes(),(function(e){return e.scale.type===t}))},t.prototype.addAxis=function(t){var e=t.dim;this._axes[e]=t,this._dimList.push(e)},t}(),QM=["x","y"];function tI(t){return"interval"===t.type||"time"===t.type}var eI=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="cartesian2d",e.dimensions=QM,e}return n(e,t),e.prototype.calcAffineTransform=function(){this._transform=this._invTransform=null;var t=this.getAxis("x").scale,e=this.getAxis("y").scale;if(tI(t)&&tI(e)){var n=t.getExtent(),i=e.getExtent(),r=this.dataToPoint([n[0],i[0]]),o=this.dataToPoint([n[1],i[1]]),a=n[1]-n[0],s=i[1]-i[0];if(a&&s){var l=(o[0]-r[0])/a,u=(o[1]-r[1])/s,h=r[0]-n[0]*l,c=r[1]-i[0]*u,p=this._transform=[l,0,0,u,h,c];this._invTransform=Ie([],p)}}},e.prototype.getBaseAxis=function(){return this.getAxesByScale("ordinal")[0]||this.getAxesByScale("time")[0]||this.getAxis("x")},e.prototype.containPoint=function(t){var e=this.getAxis("x"),n=this.getAxis("y");return e.contain(e.toLocalCoord(t[0]))&&n.contain(n.toLocalCoord(t[1]))},e.prototype.containData=function(t){return this.getAxis("x").containData(t[0])&&this.getAxis("y").containData(t[1])},e.prototype.containZone=function(t,e){var n=this.dataToPoint(t),i=this.dataToPoint(e),r=this.getArea(),o=new ze(n[0],n[1],i[0]-n[0],i[1]-n[1]);return r.intersect(o)},e.prototype.dataToPoint=function(t,e,n){n=n||[];var i=t[0],r=t[1];if(this._transform&&null!=i&&isFinite(i)&&null!=r&&isFinite(r))return Wt(n,t,this._transform);var o=this.getAxis("x"),a=this.getAxis("y");return n[0]=o.toGlobalCoord(o.dataToCoord(i,e)),n[1]=a.toGlobalCoord(a.dataToCoord(r,e)),n},e.prototype.clampData=function(t,e){var n=this.getAxis("x").scale,i=this.getAxis("y").scale,r=n.getExtent(),o=i.getExtent(),a=n.parse(t[0]),s=i.parse(t[1]);return(e=e||[])[0]=Math.min(Math.max(Math.min(r[0],r[1]),a),Math.max(r[0],r[1])),e[1]=Math.min(Math.max(Math.min(o[0],o[1]),s),Math.max(o[0],o[1])),e},e.prototype.pointToData=function(t,e){var n=[];if(this._invTransform)return Wt(n,t,this._invTransform);var i=this.getAxis("x"),r=this.getAxis("y");return n[0]=i.coordToData(i.toLocalCoord(t[0]),e),n[1]=r.coordToData(r.toLocalCoord(t[1]),e),n},e.prototype.getOtherAxis=function(t){return this.getAxis("x"===t.dim?"y":"x")},e.prototype.getArea=function(t){t=t||0;var e=this.getAxis("x").getGlobalExtent(),n=this.getAxis("y").getGlobalExtent(),i=Math.min(e[0],e[1])-t,r=Math.min(n[0],n[1])-t,o=Math.max(e[0],e[1])-i+t,a=Math.max(n[0],n[1])-r+t;return new ze(i,r,o,a)},e}(JM),nI=function(t){function e(e,n,i,r,o){var a=t.call(this,e,n,i)||this;return a.index=0,a.type=r||"value",a.position=o||"bottom",a}return n(e,t),e.prototype.isHorizontal=function(){var t=this.position;return"top"===t||"bottom"===t},e.prototype.getGlobalExtent=function(t){var e=this.getExtent();return e[0]=this.toGlobalCoord(e[0]),e[1]=this.toGlobalCoord(e[1]),t&&e[0]>e[1]&&e.reverse(),e},e.prototype.pointToData=function(t,e){return this.coordToData(this.toLocalCoord(t["x"===this.dim?0:1]),e)},e.prototype.setCategorySortInfo=function(t){if("category"!==this.type)return!1;this.model.option.categorySortInfo=t,this.scale.setSortInfo(t)},e}(ab);function iI(t,e,n){n=n||{};var i=t.coordinateSystem,r=e.axis,o={},a=r.getAxesOnZeroOf()[0],s=r.position,l=a?"onZero":s,u=r.dim,h=i.getRect(),c=[h.x,h.x+h.width,h.y,h.y+h.height],p={left:0,right:1,top:0,bottom:1,onZero:2},d=e.get("offset")||0,f="x"===u?[c[2]-d,c[3]+d]:[c[0]-d,c[1]+d];if(a){var g=a.toGlobalCoord(a.dataToCoord(0));f[p.onZero]=Math.max(Math.min(g,f[1]),f[0])}o.position=["y"===u?f[p[l]]:c[0],"x"===u?f[p[l]]:c[3]],o.rotation=Math.PI/2*("x"===u?0:1);o.labelDirection=o.tickDirection=o.nameDirection={top:-1,bottom:1,left:-1,right:1}[s],o.labelOffset=a?f[p[s]]-f[p.onZero]:0,e.get(["axisTick","inside"])&&(o.tickDirection=-o.tickDirection),it(n.labelInside,e.get(["axisLabel","inside"]))&&(o.labelDirection=-o.labelDirection);var y=e.get(["axisLabel","rotate"]);return o.labelRotate="top"===l?-y:y,o.z2=1,o}function rI(t){return"cartesian2d"===t.get("coordinateSystem")}function oI(t){var e={xAxisModel:null,yAxisModel:null};return E(e,(function(n,i){var r=i.replace(/Model$/,""),o=t.getReferringComponents(r,Wo).models[0];e[i]=o})),e}var aI=Math.log;function sI(t,e,n){var i=Ex.prototype,r=i.getTicks.call(n),o=i.getTicks.call(n,!0),a=r.length-1,s=i.getInterval.call(n),l=x_(t,e),u=l.extent,h=l.fixMin,c=l.fixMax;if("log"===t.type){var p=aI(t.base);u=[aI(u[0])/p,aI(u[1])/p]}t.setExtent(u[0],u[1]),t.calcNiceExtent({splitNumber:a,fixMin:h,fixMax:c});var d=i.getExtent.call(t);h&&(u[0]=d[0]),c&&(u[1]=d[1]);var f=i.getInterval.call(t),g=u[0],y=u[1];if(h&&c)f=(y-g)/a;else if(h)for(y=u[0]+f*a;yu[0]&&isFinite(g)&&isFinite(u[0]);)f=Dx(f),g=u[1]-f*a;else{t.getTicks().length-1>a&&(f=Dx(f));var v=f*a;(g=Jr((y=Math.ceil(u[1]/f)*f)-v))<0&&u[0]>=0?(g=0,y=Jr(v)):y>0&&u[1]<=0&&(y=0,g=-Jr(v))}var m=(r[0].value-o[0].value)/s,x=(r[a].value-o[a].value)/s;i.setExtent.call(t,g+f*m,y+f*x),i.setInterval.call(t,f),(m||x)&&i.setNiceExtent.call(t,g+f,y-f)}var lI=function(){function t(t,e,n){this.type="grid",this._coordsMap={},this._coordsList=[],this._axesMap={},this._axesList=[],this.axisPointerEnabled=!0,this.dimensions=QM,this._initCartesian(t,e,n),this.model=t}return t.prototype.getRect=function(){return this._rect},t.prototype.update=function(t,e){var n=this._axesMap;function i(t){var e,n=G(t),i=n.length;if(i){for(var r=[],o=i-1;o>=0;o--){var a=t[+n[o]],s=a.model,l=a.scale;Tx(l)&&s.get("alignTicks")&&null==s.get("interval")?r.push(a):(__(l,s),Tx(l)&&(e=a))}r.length&&(e||__((e=r.pop()).scale,e.model),E(r,(function(t){sI(t.scale,t.model,e.scale)})))}}this._updateScale(t,this.model),i(n.x),i(n.y);var r={};E(n.x,(function(t){hI(n,"y",t,r)})),E(n.y,(function(t){hI(n,"x",t,r)})),this.resize(this.model,e)},t.prototype.resize=function(t,e,n){var i=t.getBoxLayoutParams(),r=!n&&t.get("containLabel"),o=kp(i,{width:e.getWidth(),height:e.getHeight()});this._rect=o;var a=this._axesList;function s(){E(a,(function(t){var e=t.isHorizontal(),n=e?[0,o.width]:[0,o.height],i=t.inverse?1:0;t.setExtent(n[i],n[1-i]),function(t,e){var n=t.getExtent(),i=n[0]+n[1];t.toGlobalCoord="x"===t.dim?function(t){return t+e}:function(t){return i-t+e},t.toLocalCoord="x"===t.dim?function(t){return t-e}:function(t){return i-t+e}}(t,e?o.x:o.y)}))}s(),r&&(E(a,(function(t){if(!t.model.get(["axisLabel","inside"])){var e=function(t){var e=t.model,n=t.scale;if(e.get(["axisLabel","show"])&&!n.isBlank()){var i,r,o=n.getExtent();r=n instanceof Rx?n.count():(i=n.getTicks()).length;var a,s=t.getLabelModel(),l=w_(t),u=1;r>40&&(u=Math.ceil(r/40));for(var h=0;h0&&i>0||n<0&&i<0)}(t)}var pI=Math.PI,dI=function(){function t(t,e){this.group=new Br,this.opt=e,this.axisModel=t,k(e,{labelOffset:0,nameDirection:1,tickDirection:1,labelDirection:1,silent:!0,handleAutoShown:function(){return!0}});var n=new Br({x:e.position[0],y:e.position[1],rotation:e.rotation});n.updateTransform(),this._transformGroup=n}return t.prototype.hasBuilder=function(t){return!!fI[t]},t.prototype.add=function(t){fI[t](this.opt,this.axisModel,this.group,this._transformGroup)},t.prototype.getGroup=function(){return this.group},t.innerTextLayout=function(t,e,n){var i,r,o=ao(e-t);return so(o)?(r=n>0?"top":"bottom",i="center"):so(o-pI)?(r=n>0?"bottom":"top",i="center"):(r="middle",i=o>0&&o0?"right":"left":n>0?"left":"right"),{rotation:o,textAlign:i,textVerticalAlign:r}},t.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},t.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},t}(),fI={axisLine:function(t,e,n,i){var r=e.get(["axisLine","show"]);if("auto"===r&&t.handleAutoShown&&(r=t.handleAutoShown("axisLine")),r){var o=e.axis.getExtent(),a=i.transform,s=[o[0],0],l=[o[1],0],u=s[0]>l[0];a&&(Wt(s,s,a),Wt(l,l,a));var h=A({lineCap:"round"},e.getModel(["axisLine","lineStyle"]).getLineStyle()),c=new Ku({shape:{x1:s[0],y1:s[1],x2:l[0],y2:l[1]},style:h,strokeContainThreshold:t.strokeContainThreshold||5,silent:!0,z2:1});zh(c.shape,c.style.lineWidth),c.anid="line",n.add(c);var p=e.get(["axisLine","symbol"]);if(null!=p){var d=e.get(["axisLine","symbolSize"]);U(p)&&(p=[p,p]),(U(d)||j(d))&&(d=[d,d]);var f=Zy(e.get(["axisLine","symbolOffset"])||0,d),g=d[0],y=d[1];E([{rotate:t.rotation+Math.PI/2,offset:f[0],r:0},{rotate:t.rotation-Math.PI/2,offset:f[1],r:Math.sqrt((s[0]-l[0])*(s[0]-l[0])+(s[1]-l[1])*(s[1]-l[1]))}],(function(e,i){if("none"!==p[i]&&null!=p[i]){var r=Xy(p[i],-g/2,-y/2,g,y,h.stroke,!0),o=e.r+e.offset,a=u?l:s;r.attr({rotation:e.rotate,x:a[0]+o*Math.cos(t.rotation),y:a[1]-o*Math.sin(t.rotation),silent:!0,z2:11}),n.add(r)}}))}}},axisTickLabel:function(t,e,n,i){var r=function(t,e,n,i){var r=n.axis,o=n.getModel("axisTick"),a=o.get("show");"auto"===a&&i.handleAutoShown&&(a=i.handleAutoShown("axisTick"));if(!a||r.scale.isBlank())return;for(var s=o.getModel("lineStyle"),l=i.tickDirection*o.get("length"),u=mI(r.getTicksCoords(),e.transform,l,k(s.getLineStyle(),{stroke:n.get(["axisLine","lineStyle","color"])}),"ticks"),h=0;hc[1]?-1:1,d=["start"===s?c[0]-p*h:"end"===s?c[1]+p*h:(c[0]+c[1])/2,vI(s)?t.labelOffset+l*h:0],f=e.get("nameRotate");null!=f&&(f=f*pI/180),vI(s)?o=dI.innerTextLayout(t.rotation,null!=f?f:t.rotation,l):(o=function(t,e,n,i){var r,o,a=ao(n-t),s=i[0]>i[1],l="start"===e&&!s||"start"!==e&&s;so(a-pI/2)?(o=l?"bottom":"top",r="center"):so(a-1.5*pI)?(o=l?"top":"bottom",r="center"):(o="middle",r=a<1.5*pI&&a>pI/2?l?"left":"right":l?"right":"left");return{rotation:a,textAlign:r,textVerticalAlign:o}}(t.rotation,s,f||0,c),null!=(a=t.axisNameAvailableWidth)&&(a=Math.abs(a/Math.sin(o.rotation)),!isFinite(a)&&(a=null)));var g=u.getFont(),y=e.get("nameTruncate",!0)||{},v=y.ellipsis,m=it(t.nameTruncateMaxWidth,y.maxWidth,a),x=new Xs({x:d[0],y:d[1],rotation:o.rotation,silent:dI.isLabelSilent(e),style:oc(u,{text:r,font:g,overflow:"truncate",width:m,ellipsis:v,fill:u.getTextColor()||e.get(["axisLine","lineStyle","color"]),align:u.get("align")||o.textAlign,verticalAlign:u.get("verticalAlign")||o.textVerticalAlign}),z2:1});if(Kh({el:x,componentModel:e,itemName:r}),x.__fullText=r,x.anid="name",e.get("triggerEvent")){var _=dI.makeAxisEventDataBase(e);_.targetType="axisName",_.name=r,rl(x).eventData=_}i.add(x),x.updateTransform(),n.add(x),x.decomposeTransform()}}};function gI(t){t&&(t.ignore=!0)}function yI(t,e){var n=t&&t.getBoundingRect().clone(),i=e&&e.getBoundingRect().clone();if(n&&i){var r=xe([]);return Se(r,r,-t.rotation),n.applyTransform(be([],r,t.getLocalTransform())),i.applyTransform(be([],r,e.getLocalTransform())),n.intersect(i)}}function vI(t){return"middle"===t||"center"===t}function mI(t,e,n,i,r){for(var o=[],a=[],s=[],l=0;l=0||t===e}function bI(t){var e=wI(t);if(e){var n=e.axisPointerModel,i=e.axis.scale,r=n.option,o=n.get("status"),a=n.get("value");null!=a&&(a=i.parse(a));var s=SI(n);null==o&&(r.status=s?"show":"hide");var l=i.getExtent().slice();l[0]>l[1]&&l.reverse(),(null==a||a>l[1])&&(a=l[1]),a0&&!c.min?c.min=0:null!=c.min&&c.min<0&&!c.max&&(c.max=0);var p=a;null!=c.color&&(p=k({color:c.color},a));var d=C(T(c),{boundaryGap:t,splitNumber:e,scale:n,axisLine:i,axisTick:r,axisLabel:o,name:c.text,showName:s,nameLocation:"end",nameGap:u,nameTextStyle:p,triggerEvent:h},!1);if(U(l)){var f=d.name;d.name=l.replace("{value}",null!=f?f:"")}else X(l)&&(d.name=l(d.name,d));var g=new Cc(d,null,this.ecModel);return R(g,D_.prototype),g.mainType="radar",g.componentIndex=this.componentIndex,g}),this);this._indicatorModels=c},e.prototype.getIndicatorModels=function(){return this._indicatorModels},e.type="radar",e.defaultOption={z:0,center:["50%","50%"],radius:"75%",startAngle:90,axisName:{show:!0},boundaryGap:[0,0],splitNumber:5,axisNameGap:15,scale:!1,shape:"polygon",axisLine:C({lineStyle:{color:"#bbb"}},XI.axisLine),axisLabel:UI(XI.axisLabel,!1),axisTick:UI(XI.axisTick,!1),splitLine:UI(XI.splitLine,!0),splitArea:UI(XI.splitArea,!0),indicator:[]},e}(zp),jI=["axisLine","axisTickLabel","axisName"],qI=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){this.group.removeAll(),this._buildAxes(t),this._buildSplitLineAndArea(t)},e.prototype._buildAxes=function(t){var e=t.coordinateSystem;E(z(e.getIndicatorAxes(),(function(t){var n=t.model.get("showName")?t.name:"";return new dI(t.model,{axisName:n,position:[e.cx,e.cy],rotation:t.angle,labelDirection:-1,tickDirection:-1,nameDirection:1})})),(function(t){E(jI,t.add,t),this.group.add(t.getGroup())}),this)},e.prototype._buildSplitLineAndArea=function(t){var e=t.coordinateSystem,n=e.getIndicatorAxes();if(n.length){var i=t.get("shape"),r=t.getModel("splitLine"),o=t.getModel("splitArea"),a=r.getModel("lineStyle"),s=o.getModel("areaStyle"),l=r.get("show"),u=o.get("show"),h=a.get("color"),c=s.get("color"),p=Y(h)?h:[h],d=Y(c)?c:[c],f=[],g=[];if("circle"===i)for(var y=n[0].getTicksCoords(),v=e.cx,m=e.cy,x=0;x3?1.4:r>1?1.2:1.1;iT(this,"zoom","zoomOnMouseWheel",t,{scale:i>0?s:1/s,originX:o,originY:a,isAvailableBehavior:null})}if(n){var l=Math.abs(i);iT(this,"scrollMove","moveOnMouseWheel",t,{scrollDelta:(i>0?1:-1)*(l>3?.4:l>1?.15:.05),originX:o,originY:a,isAvailableBehavior:null})}}},e.prototype._pinchHandler=function(t){tT(this._zr,"globalPan")||iT(this,"zoom",null,t,{scale:t.pinchScale>1?1.1:1/1.1,originX:t.pinchX,originY:t.pinchY,isAvailableBehavior:null})},e}(jt);function iT(t,e,n,i,r){t.pointerChecker&&t.pointerChecker(i,r.originX,r.originY)&&(de(i.event),rT(t,e,n,i,r))}function rT(t,e,n,i,r){r.isAvailableBehavior=W(oT,null,n,i),t.trigger(e,r)}function oT(t,e,n){var i=n[t];return!t||i&&(!U(i)||e.event[i+"Key"])}function aT(t,e,n){var i=t.target;i.x+=e,i.y+=n,i.dirty()}function sT(t,e,n,i){var r=t.target,o=t.zoomLimit,a=t.zoom=t.zoom||1;if(a*=e,o){var s=o.min||0,l=o.max||1/0;a=Math.max(Math.min(l,a),s)}var u=a/t.zoom;t.zoom=a,r.x-=(n-r.x)*(u-1),r.y-=(i-r.y)*(u-1),r.scaleX*=u,r.scaleY*=u,r.dirty()}var lT,uT={axisPointer:1,tooltip:1,brush:1};function hT(t,e,n){var i=e.getComponentByElement(t.topTarget),r=i&&i.coordinateSystem;return i&&i!==n&&!uT.hasOwnProperty(i.mainType)&&r&&r.model!==n}function cT(t){U(t)&&(t=(new DOMParser).parseFromString(t,"text/xml"));var e=t;for(9===e.nodeType&&(e=e.firstChild);"svg"!==e.nodeName.toLowerCase()||1!==e.nodeType;)e=e.nextSibling;return e}var pT={fill:"fill",stroke:"stroke","stroke-width":"lineWidth",opacity:"opacity","fill-opacity":"fillOpacity","stroke-opacity":"strokeOpacity","stroke-dasharray":"lineDash","stroke-dashoffset":"lineDashOffset","stroke-linecap":"lineCap","stroke-linejoin":"lineJoin","stroke-miterlimit":"miterLimit","font-family":"fontFamily","font-size":"fontSize","font-style":"fontStyle","font-weight":"fontWeight","text-anchor":"textAlign",visibility:"visibility",display:"display"},dT=G(pT),fT={"alignment-baseline":"textBaseline","stop-color":"stopColor"},gT=G(fT),yT=function(){function t(){this._defs={},this._root=null}return t.prototype.parse=function(t,e){e=e||{};var n=cT(t);this._defsUsePending=[];var i=new Br;this._root=i;var r=[],o=n.getAttribute("viewBox")||"",a=parseFloat(n.getAttribute("width")||e.width),s=parseFloat(n.getAttribute("height")||e.height);isNaN(a)&&(a=null),isNaN(s)&&(s=null),wT(n,i,null,!0,!1);for(var l,u,h=n.firstChild;h;)this._parseNode(h,i,r,null,!1,!1),h=h.nextSibling;if(function(t,e){for(var n=0;n=4&&(l={x:parseFloat(c[0]||0),y:parseFloat(c[1]||0),width:parseFloat(c[2]),height:parseFloat(c[3])})}if(l&&null!=a&&null!=s&&(u=LT(l,{x:0,y:0,width:a,height:s}),!e.ignoreViewBox)){var p=i;(i=new Br).add(p),p.scaleX=p.scaleY=u.scale,p.x=u.x,p.y=u.y}return e.ignoreRootClip||null==a||null==s||i.setClipPath(new Ws({shape:{x:0,y:0,width:a,height:s}})),{root:i,width:a,height:s,viewBoxRect:l,viewBoxTransform:u,named:r}},t.prototype._parseNode=function(t,e,n,i,r,o){var a,s=t.nodeName.toLowerCase(),l=i;if("defs"===s&&(r=!0),"text"===s&&(o=!0),"defs"===s||"switch"===s)a=e;else{if(!r){var u=lT[s];if(u&&_t(lT,s)){a=u.call(this,t,e);var h=t.getAttribute("name");if(h){var c={name:h,namedFrom:null,svgNodeTagLower:s,el:a};n.push(c),"g"===s&&(l=c)}else i&&n.push({name:i.name,namedFrom:i,svgNodeTagLower:s,el:a});e.add(a)}}var p=vT[s];if(p&&_t(vT,s)){var d=p.call(this,t),f=t.getAttribute("id");f&&(this._defs[f]=d)}}if(a&&a.isGroup)for(var g=t.firstChild;g;)1===g.nodeType?this._parseNode(g,a,n,l,r,o):3===g.nodeType&&o&&this._parseText(g,a),g=g.nextSibling},t.prototype._parseText=function(t,e){var n=new Ps({style:{text:t.textContent},silent:!0,x:this._textX||0,y:this._textY||0});_T(e,n),wT(t,n,this._defsUsePending,!1,!1),function(t,e){var n=e.__selfStyle;if(n){var i=n.textBaseline,r=i;i&&"auto"!==i?"baseline"===i?r="alphabetic":"before-edge"===i||"text-before-edge"===i?r="top":"after-edge"===i||"text-after-edge"===i?r="bottom":"central"!==i&&"mathematical"!==i||(r="middle"):r="alphabetic",t.style.textBaseline=r}var o=e.__inheritedStyle;if(o){var a=o.textAlign,s=a;a&&("middle"===a&&(s="center"),t.style.textAlign=s)}}(n,e);var i=n.style,r=i.fontSize;r&&r<9&&(i.fontSize=9,n.scaleX*=r/9,n.scaleY*=r/9);var o=(i.fontSize||i.fontFamily)&&[i.fontStyle,i.fontWeight,(i.fontSize||12)+"px",i.fontFamily||"sans-serif"].join(" ");i.font=o;var a=n.getBoundingRect();return this._textX+=a.width,e.add(n),n},t.internalField=void(lT={g:function(t,e){var n=new Br;return _T(e,n),wT(t,n,this._defsUsePending,!1,!1),n},rect:function(t,e){var n=new Ws;return _T(e,n),wT(t,n,this._defsUsePending,!1,!1),n.setShape({x:parseFloat(t.getAttribute("x")||"0"),y:parseFloat(t.getAttribute("y")||"0"),width:parseFloat(t.getAttribute("width")||"0"),height:parseFloat(t.getAttribute("height")||"0")}),n.silent=!0,n},circle:function(t,e){var n=new Su;return _T(e,n),wT(t,n,this._defsUsePending,!1,!1),n.setShape({cx:parseFloat(t.getAttribute("cx")||"0"),cy:parseFloat(t.getAttribute("cy")||"0"),r:parseFloat(t.getAttribute("r")||"0")}),n.silent=!0,n},line:function(t,e){var n=new Ku;return _T(e,n),wT(t,n,this._defsUsePending,!1,!1),n.setShape({x1:parseFloat(t.getAttribute("x1")||"0"),y1:parseFloat(t.getAttribute("y1")||"0"),x2:parseFloat(t.getAttribute("x2")||"0"),y2:parseFloat(t.getAttribute("y2")||"0")}),n.silent=!0,n},ellipse:function(t,e){var n=new Iu;return _T(e,n),wT(t,n,this._defsUsePending,!1,!1),n.setShape({cx:parseFloat(t.getAttribute("cx")||"0"),cy:parseFloat(t.getAttribute("cy")||"0"),rx:parseFloat(t.getAttribute("rx")||"0"),ry:parseFloat(t.getAttribute("ry")||"0")}),n.silent=!0,n},polygon:function(t,e){var n,i=t.getAttribute("points");i&&(n=bT(i));var r=new Xu({shape:{points:n||[]},silent:!0});return _T(e,r),wT(t,r,this._defsUsePending,!1,!1),r},polyline:function(t,e){var n,i=t.getAttribute("points");i&&(n=bT(i));var r=new Zu({shape:{points:n||[]},silent:!0});return _T(e,r),wT(t,r,this._defsUsePending,!1,!1),r},image:function(t,e){var n=new Ns;return _T(e,n),wT(t,n,this._defsUsePending,!1,!1),n.setStyle({image:t.getAttribute("xlink:href")||t.getAttribute("href"),x:+t.getAttribute("x"),y:+t.getAttribute("y"),width:+t.getAttribute("width"),height:+t.getAttribute("height")}),n.silent=!0,n},text:function(t,e){var n=t.getAttribute("x")||"0",i=t.getAttribute("y")||"0",r=t.getAttribute("dx")||"0",o=t.getAttribute("dy")||"0";this._textX=parseFloat(n)+parseFloat(r),this._textY=parseFloat(i)+parseFloat(o);var a=new Br;return _T(e,a),wT(t,a,this._defsUsePending,!1,!0),a},tspan:function(t,e){var n=t.getAttribute("x"),i=t.getAttribute("y");null!=n&&(this._textX=parseFloat(n)),null!=i&&(this._textY=parseFloat(i));var r=t.getAttribute("dx")||"0",o=t.getAttribute("dy")||"0",a=new Br;return _T(e,a),wT(t,a,this._defsUsePending,!1,!0),this._textX+=parseFloat(r),this._textY+=parseFloat(o),a},path:function(t,e){var n=_u(t.getAttribute("d")||"");return _T(e,n),wT(t,n,this._defsUsePending,!1,!1),n.silent=!0,n}}),t}(),vT={lineargradient:function(t){var e=parseInt(t.getAttribute("x1")||"0",10),n=parseInt(t.getAttribute("y1")||"0",10),i=parseInt(t.getAttribute("x2")||"10",10),r=parseInt(t.getAttribute("y2")||"0",10),o=new oh(e,n,i,r);return mT(t,o),xT(t,o),o},radialgradient:function(t){var e=parseInt(t.getAttribute("cx")||"0",10),n=parseInt(t.getAttribute("cy")||"0",10),i=parseInt(t.getAttribute("r")||"0",10),r=new ah(e,n,i);return mT(t,r),xT(t,r),r}};function mT(t,e){"userSpaceOnUse"===t.getAttribute("gradientUnits")&&(e.global=!0)}function xT(t,e){for(var n=t.firstChild;n;){if(1===n.nodeType&&"stop"===n.nodeName.toLocaleLowerCase()){var i=n.getAttribute("offset"),r=void 0;r=i&&i.indexOf("%")>0?parseInt(i,10)/100:i?parseFloat(i):0;var o={};kT(n,o,o);var a=o.stopColor||n.getAttribute("stop-color")||"#000000";e.colorStops.push({offset:r,color:a})}n=n.nextSibling}}function _T(t,e){t&&t.__inheritedStyle&&(e.__inheritedStyle||(e.__inheritedStyle={}),k(e.__inheritedStyle,t.__inheritedStyle))}function bT(t){for(var e=TT(t),n=[],i=0;i0;o-=2){var a=i[o],s=i[o-1],l=TT(a);switch(r=r||[1,0,0,1,0,0],s){case"translate":we(r,r,[parseFloat(l[0]),parseFloat(l[1]||"0")]);break;case"scale":Me(r,r,[parseFloat(l[0]),parseFloat(l[1]||l[0])]);break;case"rotate":Se(r,r,-parseFloat(l[0])*DT,[parseFloat(l[1]||"0"),parseFloat(l[2]||"0")]);break;case"skewX":be(r,[1,0,Math.tan(parseFloat(l[0])*DT),1,0,0],r);break;case"skewY":be(r,[1,Math.tan(parseFloat(l[0])*DT),0,1,0,0],r);break;case"matrix":r[0]=parseFloat(l[0]),r[1]=parseFloat(l[1]),r[2]=parseFloat(l[2]),r[3]=parseFloat(l[3]),r[4]=parseFloat(l[4]),r[5]=parseFloat(l[5])}}e.setLocalTransform(r)}}(t,e),kT(t,a,s),i||function(t,e,n){for(var i=0;i0,f={api:n,geo:s,mapOrGeoModel:t,data:a,isVisualEncodedByVisualMap:d,isGeo:o,transformInfoRaw:c};"geoJSON"===s.resourceType?this._buildGeoJSON(f):"geoSVG"===s.resourceType&&this._buildSVG(f),this._updateController(t,e,n),this._updateMapSelectHandler(t,l,n,i)},t.prototype._buildGeoJSON=function(t){var e=this._regionsGroupByName=yt(),n=yt(),i=this._regionsGroup,r=t.transformInfoRaw,o=t.mapOrGeoModel,a=t.data,s=t.geo.projection,l=s&&s.stream;function u(t,e){return e&&(t=e(t)),t&&[t[0]*r.scaleX+r.x,t[1]*r.scaleY+r.y]}function h(t){for(var e=[],n=!l&&s&&s.project,i=0;i=0)&&(p=r);var d=a?{normal:{align:"center",verticalAlign:"middle"}}:null;ic(e,rc(i),{labelFetcher:p,labelDataIndex:c,defaultText:n},d);var f=e.getTextContent();if(f&&(JT(f).ignore=f.ignore,e.textConfig&&a)){var g=e.getBoundingRect().clone();e.textConfig.layoutRect=g,e.textConfig.position=[(a[0]-g.x)/g.width*100+"%",(a[1]-g.y)/g.height*100+"%"]}e.disableLabelAnimation=!0}else e.removeTextContent(),e.removeTextConfig(),e.disableLabelAnimation=null}function rC(t,e,n,i,r,o){t.data?t.data.setItemGraphicEl(o,e):rl(e).eventData={componentType:"geo",componentIndex:r.componentIndex,geoIndex:r.componentIndex,name:n,region:i&&i.option||{}}}function oC(t,e,n,i,r){t.data||Kh({el:e,componentModel:r,itemName:n,itemTooltipOption:i.get("tooltip")})}function aC(t,e,n,i,r){e.highDownSilentOnTouch=!!r.get("selectedMode");var o=i.getModel("emphasis"),a=o.get("focus");return Zl(e,a,o.get("blurScope"),o.get("disabled")),t.isGeo&&function(t,e,n){var i=rl(t);i.componentMainType=e.mainType,i.componentIndex=e.componentIndex,i.componentHighDownName=n}(e,r,n),a}function sC(t,e,n){var i,r=[];function o(){i=[]}function a(){i.length&&(r.push(i),i=[])}var s=e({polygonStart:o,polygonEnd:a,lineStart:o,lineEnd:a,point:function(t,e){isFinite(t)&&isFinite(e)&&i.push([t,e])},sphere:function(){}});return!n&&s.polygonStart(),E(t,(function(t){s.lineStart();for(var e=0;e-1&&(n.style.stroke=n.style.fill,n.style.fill="#fff",n.style.lineWidth=2),n},e.type="series.map",e.dependencies=["geo"],e.layoutMode="box",e.defaultOption={z:2,coordinateSystem:"geo",map:"",left:"center",top:"center",aspectScale:null,showLegendSymbol:!0,boundingCoords:null,center:null,zoom:1,scaleLimit:null,selectedMode:!0,label:{show:!1,color:"#000"},itemStyle:{borderWidth:.5,borderColor:"#444",areaColor:"#eee"},emphasis:{label:{show:!0,color:"rgb(100,0,0)"},itemStyle:{areaColor:"rgba(255,215,0,0.8)"}},select:{label:{show:!0,color:"rgb(100,0,0)"},itemStyle:{color:"rgba(255,215,0,0.8)"}},nameProperty:"name"},e}(bg);function hC(t){var e={};t.eachSeriesByType("map",(function(t){var n=t.getHostGeoModel(),i=n?"o"+n.id:"i"+t.getMapType();(e[i]=e[i]||[]).push(t)})),E(e,(function(t,e){for(var n,i,r,o=(n=z(t,(function(t){return t.getData()})),i=t[0].get("mapValueCalculation"),r={},E(n,(function(t){t.each(t.mapDimension("value"),(function(e,n){var i="ec-"+t.getName(n);r[i]=r[i]||[],isNaN(e)||r[i].push(e)}))})),n[0].map(n[0].mapDimension("value"),(function(t,e){for(var o="ec-"+n[0].getName(e),a=0,s=1/0,l=-1/0,u=r[o].length,h=0;h1?(d.width=p,d.height=p/x):(d.height=p,d.width=p*x),d.y=c[1]-d.height/2,d.x=c[0]-d.width/2;else{var b=t.getBoxLayoutParams();b.aspect=x,d=kp(b,{width:v,height:m})}this.setViewRect(d.x,d.y,d.width,d.height),this.setCenter(t.get("center"),e),this.setZoom(t.get("zoom"))}R(vC,dC);var _C=function(){function t(){this.dimensions=yC}return t.prototype.create=function(t,e){var n=[];function i(t){return{nameProperty:t.get("nameProperty"),aspectScale:t.get("aspectScale"),projection:t.get("projection")}}t.eachComponent("geo",(function(t,r){var o=t.get("map"),a=new vC(o+r,o,A({nameMap:t.get("nameMap")},i(t)));a.zoomLimit=t.get("scaleLimit"),n.push(a),t.coordinateSystem=a,a.model=t,a.resize=xC,a.resize(t,e)})),t.eachSeries((function(t){if("geo"===t.get("coordinateSystem")){var e=t.get("geoIndex")||0;t.coordinateSystem=n[e]}}));var r={};return t.eachSeriesByType("map",(function(t){if(!t.getHostGeoModel()){var e=t.getMapType();r[e]=r[e]||[],r[e].push(t)}})),E(r,(function(t,r){var o=z(t,(function(t){return t.get("nameMap")})),a=new vC(r,r,A({nameMap:D(o)},i(t[0])));a.zoomLimit=it.apply(null,z(t,(function(t){return t.get("scaleLimit")}))),n.push(a),a.resize=xC,a.resize(t[0],e),E(t,(function(t){t.coordinateSystem=a,function(t,e){E(e.get("geoCoord"),(function(e,n){t.addGeoCoord(n,e)}))}(a,t)}))})),n},t.prototype.getFilledRegions=function(t,e,n,i){for(var r=(t||[]).slice(),o=yt(),a=0;a=0;){var o=e[n];o.hierNode.prelim+=i,o.hierNode.modifier+=i,r+=o.hierNode.change,i+=o.hierNode.shift+r}}(t);var o=(n[0].hierNode.prelim+n[n.length-1].hierNode.prelim)/2;r?(t.hierNode.prelim=r.hierNode.prelim+e(t,r),t.hierNode.modifier=t.hierNode.prelim-o):t.hierNode.prelim=o}else r&&(t.hierNode.prelim=r.hierNode.prelim+e(t,r));t.parentNode.hierNode.defaultAncestor=function(t,e,n,i){if(e){for(var r=t,o=t,a=o.parentNode.children[0],s=e,l=r.hierNode.modifier,u=o.hierNode.modifier,h=a.hierNode.modifier,c=s.hierNode.modifier;s=PC(s),o=OC(o),s&&o;){r=PC(r),a=OC(a),r.hierNode.ancestor=t;var p=s.hierNode.prelim+c-o.hierNode.prelim-u+i(s,o);p>0&&(NC(RC(s,t,n),t,p),u+=p,l+=p),c+=s.hierNode.modifier,u+=o.hierNode.modifier,l+=r.hierNode.modifier,h+=a.hierNode.modifier}s&&!PC(r)&&(r.hierNode.thread=s,r.hierNode.modifier+=c-l),o&&!OC(a)&&(a.hierNode.thread=o,a.hierNode.modifier+=u-h,n=t)}return n}(t,r,t.parentNode.hierNode.defaultAncestor||i[0],e)}function AC(t){var e=t.hierNode.prelim+t.parentNode.hierNode.modifier;t.setLayout({x:e},!0),t.hierNode.modifier+=t.parentNode.hierNode.modifier}function kC(t){return arguments.length?t:EC}function LC(t,e){return t-=Math.PI/2,{x:e*Math.cos(t),y:e*Math.sin(t)}}function PC(t){var e=t.children;return e.length&&t.isExpand?e[e.length-1]:t.hierNode.thread}function OC(t){var e=t.children;return e.length&&t.isExpand?e[0]:t.hierNode.thread}function RC(t,e,n){return t.hierNode.ancestor.parentNode===e.parentNode?t.hierNode.ancestor:n}function NC(t,e,n){var i=n/(e.hierNode.i-t.hierNode.i);e.hierNode.change-=i,e.hierNode.shift+=n,e.hierNode.modifier+=n,e.hierNode.prelim+=n,t.hierNode.change+=i}function EC(t,e){return t.parentNode===e.parentNode?1:2}var zC=function(){this.parentPoint=[],this.childPoints=[]},VC=function(t){function e(e){return t.call(this,e)||this}return n(e,t),e.prototype.getDefaultStyle=function(){return{stroke:"#000",fill:null}},e.prototype.getDefaultShape=function(){return new zC},e.prototype.buildPath=function(t,e){var n=e.childPoints,i=n.length,r=e.parentPoint,o=n[0],a=n[i-1];if(1===i)return t.moveTo(r[0],r[1]),void t.lineTo(o[0],o[1]);var s=e.orient,l="TB"===s||"BT"===s?0:1,u=1-l,h=$r(e.forkPosition,1),c=[];c[l]=r[l],c[u]=r[u]+(a[u]-r[u])*h,t.moveTo(r[0],r[1]),t.lineTo(c[0],c[1]),t.moveTo(o[0],o[1]),c[l]=o[l],t.lineTo(c[0],c[1]),c[l]=a[l],t.lineTo(c[0],c[1]),t.lineTo(a[0],a[1]);for(var p=1;pm.x)||(_-=Math.PI);var S=b?"left":"right",M=s.getModel("label"),I=M.get("rotate"),T=I*(Math.PI/180),C=y.getTextContent();C&&(y.setTextConfig({position:M.get("position")||S,rotation:null==I?-_:T,origin:"center"}),C.setStyle("verticalAlign","middle"))}var D=s.get(["emphasis","focus"]),A="relative"===D?vt(a.getAncestorsIndices(),a.getDescendantIndices()):"ancestor"===D?a.getAncestorsIndices():"descendant"===D?a.getDescendantIndices():null;A&&(rl(n).focus=A),function(t,e,n,i,r,o,a,s){var l=e.getModel(),u=t.get("edgeShape"),h=t.get("layout"),c=t.getOrient(),p=t.get(["lineStyle","curveness"]),d=t.get("edgeForkPosition"),f=l.getModel("lineStyle").getLineStyle(),g=i.__edge;if("curve"===u)e.parentNode&&e.parentNode!==n&&(g||(g=i.__edge=new th({shape:XC(h,c,p,r,r)})),vh(g,{shape:XC(h,c,p,o,a)},t));else if("polyline"===u)if("orthogonal"===h){if(e!==n&&e.children&&0!==e.children.length&&!0===e.isExpand){for(var y=e.children,v=[],m=0;me&&(e=i.height)}this.height=e+1},t.prototype.getNodeById=function(t){if(this.getId()===t)return this;for(var e=0,n=this.children,i=n.length;e=0&&this.hostTree.data.setItemLayout(this.dataIndex,t,e)},t.prototype.getLayout=function(){return this.hostTree.data.getItemLayout(this.dataIndex)},t.prototype.getModel=function(t){if(!(this.dataIndex<0))return this.hostTree.data.getItemModel(this.dataIndex).getModel(t)},t.prototype.getLevelModel=function(){return(this.hostTree.levelModels||[])[this.depth]},t.prototype.setVisual=function(t,e){this.dataIndex>=0&&this.hostTree.data.setItemVisual(this.dataIndex,t,e)},t.prototype.getVisual=function(t){return this.hostTree.data.getItemVisual(this.dataIndex,t)},t.prototype.getRawIndex=function(){return this.hostTree.data.getRawIndex(this.dataIndex)},t.prototype.getId=function(){return this.hostTree.data.getId(this.dataIndex)},t.prototype.getChildIndex=function(){if(this.parentNode){for(var t=this.parentNode.children,e=0;e=0){var i=n.getData().tree.root,r=t.targetNode;if(U(r)&&(r=i.getNodeById(r)),r&&i.contains(r))return{node:r};var o=t.targetNodeId;if(null!=o&&(r=i.getNodeById(o)))return{node:r}}}function rD(t){for(var e=[];t;)(t=t.parentNode)&&e.push(t);return e.reverse()}function oD(t,e){return P(rD(t),e)>=0}function aD(t,e){for(var n=[];t;){var i=t.dataIndex;n.push({name:t.name,dataIndex:i,value:e.getRawValue(i)}),t=t.parentNode}return n.reverse(),n}var sD=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.hasSymbolVisual=!0,e.ignoreStyleOnData=!0,e}return n(e,t),e.prototype.getInitialData=function(t){var e={name:t.name,children:t.data},n=t.leaves||{},i=new Cc(n,this,this.ecModel),r=nD.createTree(e,this,(function(t){t.wrapMethod("getItemModel",(function(t,e){var n=r.getNodeByDataIndex(e);return n&&n.children.length&&n.isExpand||(t.parentModel=i),t}))}));var o=0;r.eachNode("preorder",(function(t){t.depth>o&&(o=t.depth)}));var a=t.expandAndCollapse&&t.initialTreeDepth>=0?t.initialTreeDepth:o;return r.root.eachNode("preorder",(function(t){var e=t.hostTree.data.getRawDataItem(t.dataIndex);t.isExpand=e&&null!=e.collapsed?!e.collapsed:t.depth<=a})),r.data},e.prototype.getOrient=function(){var t=this.get("orient");return"horizontal"===t?t="LR":"vertical"===t&&(t="TB"),t},e.prototype.setZoom=function(t){this.option.zoom=t},e.prototype.setCenter=function(t){this.option.center=t},e.prototype.formatTooltip=function(t,e,n){for(var i=this.getData().tree,r=i.root.children[0],o=i.getNodeByDataIndex(t),a=o.getValue(),s=o.name;o&&o!==r;)s=o.parentNode.name+"."+s,o=o.parentNode;return og("nameValue",{name:s,value:a,noValue:isNaN(a)||null==a})},e.prototype.getDataParams=function(e){var n=t.prototype.getDataParams.apply(this,arguments),i=this.getData().tree.getNodeByDataIndex(e);return n.treeAncestors=aD(i,this),n.collapsed=!i.isExpand,n},e.type="series.tree",e.layoutMode="box",e.defaultOption={z:2,coordinateSystem:"view",left:"12%",top:"12%",right:"12%",bottom:"12%",layout:"orthogonal",edgeShape:"curve",edgeForkPosition:"50%",roam:!1,nodeScaleRatio:.4,center:null,zoom:1,orient:"LR",symbol:"emptyCircle",symbolSize:7,expandAndCollapse:!0,initialTreeDepth:2,lineStyle:{color:"#ccc",width:1.5,curveness:.5},itemStyle:{color:"lightsteelblue",borderWidth:1.5},label:{show:!0},animationEasing:"linear",animationDuration:700,animationDurationUpdate:500},e}(bg);function lD(t,e){for(var n,i=[t];n=i.pop();)if(e(n),n.isExpand){var r=n.children;if(r.length)for(var o=r.length-1;o>=0;o--)i.push(r[o])}}function uD(t,e){t.eachSeriesByType("tree",(function(t){!function(t,e){var n=function(t,e){return kp(t.getBoxLayoutParams(),{width:e.getWidth(),height:e.getHeight()})}(t,e);t.layoutInfo=n;var i=t.get("layout"),r=0,o=0,a=null;"radial"===i?(r=2*Math.PI,o=Math.min(n.height,n.width)/2,a=kC((function(t,e){return(t.parentNode===e.parentNode?1:2)/t.depth}))):(r=n.width,o=n.height,a=kC());var s=t.getData().tree.root,l=s.children[0];if(l){!function(t){var e=t;e.hierNode={defaultAncestor:null,ancestor:e,prelim:0,modifier:0,change:0,shift:0,i:0,thread:null};for(var n,i,r=[e];n=r.pop();)if(i=n.children,n.isExpand&&i.length)for(var o=i.length-1;o>=0;o--){var a=i[o];a.hierNode={defaultAncestor:null,ancestor:a,prelim:0,modifier:0,change:0,shift:0,i:o,thread:null},r.push(a)}}(s),function(t,e,n){for(var i,r=[t],o=[];i=r.pop();)if(o.push(i),i.isExpand){var a=i.children;if(a.length)for(var s=0;sh.getLayout().x&&(h=t),t.depth>c.depth&&(c=t)}));var p=u===h?1:a(u,h)/2,d=p-u.getLayout().x,f=0,g=0,y=0,v=0;if("radial"===i)f=r/(h.getLayout().x+p+d),g=o/(c.depth-1||1),lD(l,(function(t){y=(t.getLayout().x+d)*f,v=(t.depth-1)*g;var e=LC(y,v);t.setLayout({x:e.x,y:e.y,rawX:y,rawY:v},!0)}));else{var m=t.getOrient();"RL"===m||"LR"===m?(g=o/(h.getLayout().x+p+d),f=r/(c.depth-1||1),lD(l,(function(t){v=(t.getLayout().x+d)*g,y="LR"===m?(t.depth-1)*f:r-(t.depth-1)*f,t.setLayout({x:y,y:v},!0)}))):"TB"!==m&&"BT"!==m||(f=r/(h.getLayout().x+p+d),g=o/(c.depth-1||1),lD(l,(function(t){y=(t.getLayout().x+d)*f,v="TB"===m?(t.depth-1)*g:o-(t.depth-1)*g,t.setLayout({x:y,y:v},!0)})))}}}(t,e)}))}function hD(t){t.eachSeriesByType("tree",(function(t){var e=t.getData();e.tree.eachNode((function(t){var n=t.getModel().getModel("itemStyle").getItemStyle();A(e.ensureUniqueItemVisual(t.dataIndex,"style"),n)}))}))}var cD=["treemapZoomToNode","treemapRender","treemapMove"];function pD(t){var e=t.getData().tree,n={};e.eachNode((function(e){for(var i=e;i&&i.depth>1;)i=i.parentNode;var r=pd(t.ecModel,i.name||i.dataIndex+"",n);e.setVisual("decal",r)}))}var dD=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.preventUsingHoverLayer=!0,n}return n(e,t),e.prototype.getInitialData=function(t,e){var n={name:t.name,children:t.data};fD(n);var i=t.levels||[],r=this.designatedVisualItemStyle={},o=new Cc({itemStyle:r},this,e);i=t.levels=function(t,e){var n,i,r=To(e.get("color")),o=To(e.get(["aria","decal","decals"]));if(!r)return;t=t||[],E(t,(function(t){var e=new Cc(t),r=e.get("color"),o=e.get("decal");(e.get(["itemStyle","color"])||r&&"none"!==r)&&(n=!0),(e.get(["itemStyle","decal"])||o&&"none"!==o)&&(i=!0)}));var a=t[0]||(t[0]={});n||(a.color=r.slice());!i&&o&&(a.decal=o.slice());return t}(i,e);var a=z(i||[],(function(t){return new Cc(t,o,e)}),this),s=nD.createTree(n,this,(function(t){t.wrapMethod("getItemModel",(function(t,e){var n=s.getNodeByDataIndex(e),i=n?a[n.depth]:null;return t.parentModel=i||o,t}))}));return s.data},e.prototype.optionUpdated=function(){this.resetViewRoot()},e.prototype.formatTooltip=function(t,e,n){var i=this.getData(),r=this.getRawValue(t);return og("nameValue",{name:i.getName(t),value:r})},e.prototype.getDataParams=function(e){var n=t.prototype.getDataParams.apply(this,arguments),i=this.getData().tree.getNodeByDataIndex(e);return n.treeAncestors=aD(i,this),n.treePathInfo=n.treeAncestors,n},e.prototype.setLayoutInfo=function(t){this.layoutInfo=this.layoutInfo||{},A(this.layoutInfo,t)},e.prototype.mapIdToIndex=function(t){var e=this._idIndexMap;e||(e=this._idIndexMap=yt(),this._idIndexMapCount=0);var n=e.get(t);return null==n&&e.set(t,n=this._idIndexMapCount++),n},e.prototype.getViewRoot=function(){return this._viewRoot},e.prototype.resetViewRoot=function(t){t?this._viewRoot=t:t=this._viewRoot;var e=this.getRawData().tree.root;t&&(t===e||e.contains(t))||(this._viewRoot=e)},e.prototype.enableAriaDecal=function(){pD(this)},e.type="series.treemap",e.layoutMode="box",e.defaultOption={progressive:0,left:"center",top:"middle",width:"80%",height:"80%",sort:!0,clipWindow:"origin",squareRatio:.5*(1+Math.sqrt(5)),leafDepth:null,drillDownIcon:"▶",zoomToNodeRatio:.1024,scaleLimit:null,roam:!0,nodeClick:"zoomToNode",animation:!0,animationDurationUpdate:900,animationEasing:"quinticInOut",breadcrumb:{show:!0,height:22,left:"center",top:"bottom",emptyItemWidth:25,itemStyle:{color:"rgba(0,0,0,0.7)",textStyle:{color:"#fff"}},emphasis:{itemStyle:{color:"rgba(0,0,0,0.9)"}}},label:{show:!0,distance:0,padding:5,position:"inside",color:"#fff",overflow:"truncate"},upperLabel:{show:!1,position:[0,"50%"],height:20,overflow:"truncate",verticalAlign:"middle"},itemStyle:{color:null,colorAlpha:null,colorSaturation:null,borderWidth:0,gapWidth:0,borderColor:"#fff",borderColorSaturation:null},emphasis:{upperLabel:{show:!0,position:[0,"50%"],overflow:"truncate",verticalAlign:"middle"}},visualDimension:0,visualMin:null,visualMax:null,color:[],colorAlpha:null,colorSaturation:null,colorMappingBy:"index",visibleMin:10,childrenVisibleMin:null,levels:[]},e}(bg);function fD(t){var e=0;E(t.children,(function(t){fD(t);var n=t.value;Y(n)&&(n=n[0]),e+=n}));var n=t.value;Y(n)&&(n=n[0]),(null==n||isNaN(n))&&(n=e),n<0&&(n=0),Y(t.value)?t.value[0]=n:t.value=n}var gD=function(){function t(t){this.group=new Br,t.add(this.group)}return t.prototype.render=function(t,e,n,i){var r=t.getModel("breadcrumb"),o=this.group;if(o.removeAll(),r.get("show")&&n){var a=r.getModel("itemStyle"),s=r.getModel("emphasis"),l=a.getModel("textStyle"),u=s.getModel(["itemStyle","textStyle"]),h={pos:{left:r.get("left"),right:r.get("right"),top:r.get("top"),bottom:r.get("bottom")},box:{width:e.getWidth(),height:e.getHeight()},emptyItemWidth:r.get("emptyItemWidth"),totalWidth:0,renderList:[]};this._prepare(n,h,l),this._renderContent(t,h,a,s,l,u,i),Lp(o,h.pos,h.box)}},t.prototype._prepare=function(t,e,n){for(var i=t;i;i=i.parentNode){var r=Ro(i.getModel().get("name"),""),o=n.getTextRect(r),a=Math.max(o.width+16,e.emptyItemWidth);e.totalWidth+=a+8,e.renderList.push({node:i,text:r,width:a})}},t.prototype._renderContent=function(t,e,n,i,r,o,a){for(var s,l,u,h,c,p,d,f,g,y=0,v=e.emptyItemWidth,m=t.get(["breadcrumb","height"]),x=(s=e.pos,l=e.box,h=l.width,c=l.height,p=$r(s.left,h),d=$r(s.top,c),f=$r(s.right,h),g=$r(s.bottom,c),(isNaN(p)||isNaN(parseFloat(s.left)))&&(p=0),(isNaN(f)||isNaN(parseFloat(s.right)))&&(f=h),(isNaN(d)||isNaN(parseFloat(s.top)))&&(d=0),(isNaN(g)||isNaN(parseFloat(s.bottom)))&&(g=c),u=vp(u||0),{width:Math.max(f-p-u[1]-u[3],0),height:Math.max(g-d-u[0]-u[2],0)}),_=e.totalWidth,b=e.renderList,w=i.getModel("itemStyle").getItemStyle(),S=b.length-1;S>=0;S--){var M=b[S],I=M.node,T=M.width,C=M.text;_>x.width&&(_-=T-v,T=v,C=null);var D=new Xu({shape:{points:yD(y,0,T,m,S===b.length-1,0===S)},style:k(n.getItemStyle(),{lineJoin:"bevel"}),textContent:new Xs({style:oc(r,{text:C})}),textConfig:{position:"inside"},z2:1e5,onclick:H(a,I)});D.disableLabelAnimation=!0,D.getTextContent().ensureState("emphasis").style=oc(o,{text:C}),D.ensureState("emphasis").style=w,Zl(D,i.get("focus"),i.get("blurScope"),i.get("disabled")),this.group.add(D),vD(D,t,I),y+=T+8}},t.prototype.remove=function(){this.group.removeAll()},t}();function yD(t,e,n,i,r,o){var a=[[r?t:t-5,e],[t+n,e],[t+n,e+i],[r?t:t-5,e+i]];return!o&&a.splice(2,0,[t+n+5,e+i/2]),!r&&a.push([t,e+i/2]),a}function vD(t,e,n){rl(t).eventData={componentType:"series",componentSubType:"treemap",componentIndex:e.componentIndex,seriesIndex:e.seriesIndex,seriesName:e.name,seriesType:"treemap",selfType:"breadcrumb",nodeData:{dataIndex:n&&n.dataIndex,name:n&&n.name},treePathInfo:n&&aD(n,e)}}var mD=function(){function t(){this._storage=[],this._elExistsMap={}}return t.prototype.add=function(t,e,n,i,r){return!this._elExistsMap[t.id]&&(this._elExistsMap[t.id]=!0,this._storage.push({el:t,target:e,duration:n,delay:i,easing:r}),!0)},t.prototype.finished=function(t){return this._finishedCallback=t,this},t.prototype.start=function(){for(var t=this,e=this._storage.length,n=function(){--e<=0&&(t._storage.length=0,t._elExistsMap={},t._finishedCallback&&t._finishedCallback())},i=0,r=this._storage.length;i3||Math.abs(t.dy)>3)){var e=this.seriesModel.getData().tree.root;if(!e)return;var n=e.getLayout();if(!n)return;this.api.dispatchAction({type:"treemapMove",from:this.uid,seriesId:this.seriesModel.id,rootRect:{x:n.x+t.dx,y:n.y+t.dy,width:n.width,height:n.height}})}},e.prototype._onZoom=function(t){var e=t.originX,n=t.originY,i=t.scale;if("animating"!==this._state){var r=this.seriesModel.getData().tree.root;if(!r)return;var o=r.getLayout();if(!o)return;var a,s=new ze(o.x,o.y,o.width,o.height),l=this._controllerHost;a=l.zoomLimit;var u=l.zoom=l.zoom||1;if(u*=i,a){var h=a.min||0,c=a.max||1/0;u=Math.max(Math.min(c,u),h)}var p=u/l.zoom;l.zoom=u;var d=this.seriesModel.layoutInfo,f=[1,0,0,1,0,0];we(f,f,[-(e-=d.x),-(n-=d.y)]),Me(f,f,[p,p]),we(f,f,[e,n]),s.applyTransform(f),this.api.dispatchAction({type:"treemapRender",from:this.uid,seriesId:this.seriesModel.id,rootRect:{x:s.x,y:s.y,width:s.width,height:s.height}})}},e.prototype._initEvents=function(t){var e=this;t.on("click",(function(t){if("ready"===e._state){var n=e.seriesModel.get("nodeClick",!0);if(n){var i=e.findTarget(t.offsetX,t.offsetY);if(i){var r=i.node;if(r.getLayout().isLeafRoot)e._rootToNode(i);else if("zoomToNode"===n)e._zoomToNode(i);else if("link"===n){var o=r.hostTree.data.getItemModel(r.dataIndex),a=o.get("link",!0),s=o.get("target",!0)||"blank";a&&Mp(a,s)}}}}}),this)},e.prototype._renderBreadcrumb=function(t,e,n){var i=this;n||(n=null!=t.get("leafDepth",!0)?{node:t.getViewRoot()}:this.findTarget(e.getWidth()/2,e.getHeight()/2))||(n={node:t.getData().tree.root}),(this._breadcrumb||(this._breadcrumb=new gD(this.group))).render(t,e,n.node,(function(e){"animating"!==i._state&&(oD(t.getViewRoot(),e)?i._rootToNode({node:e}):i._zoomToNode({node:e}))}))},e.prototype.remove=function(){this._clearController(),this._containerGroup&&this._containerGroup.removeAll(),this._storage={nodeGroup:[],background:[],content:[]},this._state="ready",this._breadcrumb&&this._breadcrumb.remove()},e.prototype.dispose=function(){this._clearController()},e.prototype._zoomToNode=function(t){this.api.dispatchAction({type:"treemapZoomToNode",from:this.uid,seriesId:this.seriesModel.id,targetNode:t.node})},e.prototype._rootToNode=function(t){this.api.dispatchAction({type:"treemapRootToNode",from:this.uid,seriesId:this.seriesModel.id,targetNode:t.node})},e.prototype.findTarget=function(t,e){var n;return this.seriesModel.getViewRoot().eachNode({attr:"viewChildren",order:"preorder"},(function(i){var r=this._storage.background[i.getRawIndex()];if(r){var o=r.transformCoordToLocal(t,e),a=r.shape;if(!(a.x<=o[0]&&o[0]<=a.x+a.width&&a.y<=o[1]&&o[1]<=a.y+a.height))return!1;n={node:i,offsetX:o[0],offsetY:o[1]}}}),this),n},e.type="treemap",e}(Og);var CD=E,DD=q,AD=-1,kD=function(){function t(e){var n=e.mappingMethod,i=e.type,r=this.option=T(e);this.type=i,this.mappingMethod=n,this._normalizeData=FD[n];var o=t.visualHandlers[i];this.applyVisual=o.applyVisual,this.getColorMapper=o.getColorMapper,this._normalizedToVisual=o._normalizedToVisual[n],"piecewise"===n?(LD(r),function(t){var e=t.pieceList;t.hasSpecialVisual=!1,E(e,(function(e,n){e.originIndex=n,null!=e.visual&&(t.hasSpecialVisual=!0)}))}(r)):"category"===n?r.categories?function(t){var e=t.categories,n=t.categoryMap={},i=t.visual;if(CD(e,(function(t,e){n[t]=e})),!Y(i)){var r=[];q(i)?CD(i,(function(t,e){var i=n[e];r[null!=i?i:AD]=t})):r[-1]=i,i=BD(t,r)}for(var o=e.length-1;o>=0;o--)null==i[o]&&(delete n[e[o]],e.pop())}(r):LD(r,!0):(lt("linear"!==n||r.dataExtent),LD(r))}return t.prototype.mapValueToVisual=function(t){var e=this._normalizeData(t);return this._normalizedToVisual(e,t)},t.prototype.getNormalizer=function(){return W(this._normalizeData,this)},t.listVisualTypes=function(){return G(t.visualHandlers)},t.isValidType=function(e){return t.visualHandlers.hasOwnProperty(e)},t.eachVisual=function(t,e,n){q(t)?E(t,e,n):e.call(n,t)},t.mapVisual=function(e,n,i){var r,o=Y(e)?[]:q(e)?{}:(r=!0,null);return t.eachVisual(e,(function(t,e){var a=n.call(i,t,e);r?o=a:o[e]=a})),o},t.retrieveVisuals=function(e){var n,i={};return e&&CD(t.visualHandlers,(function(t,r){e.hasOwnProperty(r)&&(i[r]=e[r],n=!0)})),n?i:null},t.prepareVisualTypes=function(t){if(Y(t))t=t.slice();else{if(!DD(t))return[];var e=[];CD(t,(function(t,n){e.push(n)})),t=e}return t.sort((function(t,e){return"color"===e&&"color"!==t&&0===t.indexOf("color")?1:-1})),t},t.dependsOn=function(t,e){return"color"===e?!(!t||0!==t.indexOf(e)):t===e},t.findPieceIndex=function(t,e,n){for(var i,r=1/0,o=0,a=e.length;ou[1]&&(u[1]=l);var h=e.get("colorMappingBy"),c={type:a.name,dataExtent:u,visual:a.range};"color"!==c.type||"index"!==h&&"id"!==h?c.mappingMethod="linear":(c.mappingMethod="category",c.loop=!0);var p=new kD(c);return WD(p).drColorMappingBy=h,p}(0,r,o,0,u,d);E(d,(function(t,e){if(t.depth>=n.length||t===n[t.depth]){var o=function(t,e,n,i,r,o){var a=A({},e);if(r){var s=r.type,l="color"===s&&WD(r).drColorMappingBy,u="index"===l?i:"id"===l?o.mapIdToIndex(n.getId()):n.getValue(t.get("visualDimension"));a[s]=r.mapValueToVisual(u)}return a}(r,u,t,e,f,i);YD(t,o,n,i)}}))}else s=XD(u),h.fill=s}}function XD(t){var e=UD(t,"color");if(e){var n=UD(t,"colorAlpha"),i=UD(t,"colorSaturation");return i&&(e=ni(e,null,null,i)),n&&(e=ii(e,n)),e}}function UD(t,e){var n=t[e];if(null!=n&&"none"!==n)return n}function ZD(t,e){var n=t.get(e);return Y(n)&&n.length?{name:e,range:n}:null}var jD=Math.max,qD=Math.min,KD=it,$D=E,JD=["itemStyle","borderWidth"],QD=["itemStyle","gapWidth"],tA=["upperLabel","show"],eA=["upperLabel","height"],nA={seriesType:"treemap",reset:function(t,e,n,i){var r=n.getWidth(),o=n.getHeight(),a=t.option,s=kp(t.getBoxLayoutParams(),{width:n.getWidth(),height:n.getHeight()}),l=a.size||[],u=$r(KD(s.width,l[0]),r),h=$r(KD(s.height,l[1]),o),c=i&&i.type,p=iD(i,["treemapZoomToNode","treemapRootToNode"],t),d="treemapRender"===c||"treemapMove"===c?i.rootRect:null,f=t.getViewRoot(),g=rD(f);if("treemapMove"!==c){var y="treemapZoomToNode"===c?function(t,e,n,i,r){var o,a=(e||{}).node,s=[i,r];if(!a||a===n)return s;var l=i*r,u=l*t.option.zoomToNodeRatio;for(;o=a.parentNode;){for(var h=0,c=o.children,p=0,d=c.length;poo&&(u=oo),a=o}ua[1]&&(a[1]=e)}))):a=[NaN,NaN];return{sum:i,dataExtent:a}}(e,a,s);if(0===u.sum)return t.viewChildren=[];if(u.sum=function(t,e,n,i,r){if(!i)return n;for(var o=t.get("visibleMin"),a=r.length,s=a,l=a-1;l>=0;l--){var u=r["asc"===i?a-l-1:l].getValue();u/n*ei&&(i=a));var l=t.area*t.area,u=e*e*n;return l?jD(u*i/l,l/(u*r)):1/0}function oA(t,e,n,i,r){var o=e===n.width?0:1,a=1-o,s=["x","y"],l=["width","height"],u=n[s[o]],h=e?t.area/e:0;(r||h>n[l[a]])&&(h=n[l[a]]);for(var c=0,p=t.length;ci&&(i=e);var o=i%2?i+2:i+3;r=[];for(var a=0;a0&&(m[0]=-m[0],m[1]=-m[1]);var _=v[0]<0?-1:1;if("start"!==i.__position&&"end"!==i.__position){var b=-Math.atan2(v[1],v[0]);u[0].8?"left":h[0]<-.8?"right":"center",p=h[1]>.8?"top":h[1]<-.8?"bottom":"middle";break;case"start":i.x=-h[0]*f+l[0],i.y=-h[1]*g+l[1],c=h[0]>.8?"right":h[0]<-.8?"left":"center",p=h[1]>.8?"bottom":h[1]<-.8?"top":"middle";break;case"insideStartTop":case"insideStart":case"insideStartBottom":i.x=f*_+l[0],i.y=l[1]+w,c=v[0]<0?"right":"left",i.originX=-f*_,i.originY=-w;break;case"insideMiddleTop":case"insideMiddle":case"insideMiddleBottom":case"middle":i.x=x[0],i.y=x[1]+w,c="center",i.originY=-w;break;case"insideEndTop":case"insideEnd":case"insideEndBottom":i.x=-f*_+u[0],i.y=u[1]+w,c=v[0]>=0?"right":"left",i.originX=f*_,i.originY=-w}i.scaleX=i.scaleY=r,i.setStyle({verticalAlign:i.__verticalAlign||p,align:i.__align||c})}}}function S(t,e){var n=t.__specifiedRotation;if(null==n){var i=a.tangentAt(e);t.attr("rotation",(1===e?-1:1)*Math.PI/2-Math.atan2(i[1],i[0]))}else t.attr("rotation",n)}},e}(Br),YA=function(){function t(t){this.group=new Br,this._LineCtor=t||HA}return t.prototype.updateData=function(t){var e=this;this._progressiveEls=null;var n=this,i=n.group,r=n._lineData;n._lineData=t,r||i.removeAll();var o=XA(t);t.diff(r).add((function(n){e._doAdd(t,n,o)})).update((function(n,i){e._doUpdate(r,t,i,n,o)})).remove((function(t){i.remove(r.getItemGraphicEl(t))})).execute()},t.prototype.updateLayout=function(){var t=this._lineData;t&&t.eachItemGraphicEl((function(e,n){e.updateLayout(t,n)}),this)},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=XA(t),this._lineData=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e){function n(t){t.isGroup||function(t){return t.animators&&t.animators.length>0}(t)||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}this._progressiveEls=[];for(var i=t.start;i=0?i+=u:i-=u:f>=0?i-=u:i+=u}return i}function ek(t,e){var n=[],i=Dn,r=[[],[],[]],o=[[],[]],a=[];e/=2,t.eachEdge((function(t,s){var l=t.getLayout(),u=t.getVisual("fromSymbol"),h=t.getVisual("toSymbol");l.__original||(l.__original=[Tt(l[0]),Tt(l[1])],l[2]&&l.__original.push(Tt(l[2])));var c=l.__original;if(null!=l[2]){if(It(r[0],c[0]),It(r[1],c[2]),It(r[2],c[1]),u&&"none"!==u){var p=SA(t.node1),d=tk(r,c[0],p*e);i(r[0][0],r[1][0],r[2][0],d,n),r[0][0]=n[3],r[1][0]=n[4],i(r[0][1],r[1][1],r[2][1],d,n),r[0][1]=n[3],r[1][1]=n[4]}if(h&&"none"!==h){p=SA(t.node2),d=tk(r,c[1],p*e);i(r[0][0],r[1][0],r[2][0],d,n),r[1][0]=n[1],r[2][0]=n[2],i(r[0][1],r[1][1],r[2][1],d,n),r[1][1]=n[1],r[2][1]=n[2]}It(l[0],r[0]),It(l[1],r[2]),It(l[2],r[1])}else{if(It(o[0],c[0]),It(o[1],c[1]),kt(a,o[1],o[0]),Et(a,a),u&&"none"!==u){p=SA(t.node1);At(o[0],o[0],a,p*e)}if(h&&"none"!==h){p=SA(t.node2);At(o[1],o[1],a,-p*e)}It(l[0],o[0]),It(l[1],o[1])}}))}function nk(t){return"view"===t.type}var ik=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(t,e){var n=new xS,i=new YA,r=this.group;this._controller=new nT(e.getZr()),this._controllerHost={target:r},r.add(n.group),r.add(i.group),this._symbolDraw=n,this._lineDraw=i,this._firstRender=!0},e.prototype.render=function(t,e,n){var i=this,r=t.coordinateSystem;this._model=t;var o=this._symbolDraw,a=this._lineDraw,s=this.group;if(nk(r)){var l={x:r.x,y:r.y,scaleX:r.scaleX,scaleY:r.scaleY};this._firstRender?s.attr(l):vh(s,l,t)}ek(t.getGraph(),wA(t));var u=t.getData();o.updateData(u);var h=t.getEdgeData();a.updateData(h),this._updateNodeAndLinkScale(),this._updateController(t,e,n),clearTimeout(this._layoutTimeout);var c=t.forceLayout,p=t.get(["force","layoutAnimation"]);c&&this._startForceLayoutIteration(c,p);var d=t.get("layout");u.graph.eachNode((function(e){var n=e.dataIndex,r=e.getGraphicEl(),o=e.getModel();if(r){r.off("drag").off("dragend");var a=o.get("draggable");a&&r.on("drag",(function(o){switch(d){case"force":c.warmUp(),!i._layouting&&i._startForceLayoutIteration(c,p),c.setFixed(n),u.setItemLayout(n,[r.x,r.y]);break;case"circular":u.setItemLayout(n,[r.x,r.y]),e.setLayout({fixed:!0},!0),TA(t,"symbolSize",e,[o.offsetX,o.offsetY]),i.updateLayout(t);break;default:u.setItemLayout(n,[r.x,r.y]),_A(t.getGraph(),t),i.updateLayout(t)}})).on("dragend",(function(){c&&c.setUnfixed(n)})),r.setDraggable(a,!!o.get("cursor")),"adjacency"===o.get(["emphasis","focus"])&&(rl(r).focus=e.getAdjacentDataIndices())}})),u.graph.eachEdge((function(t){var e=t.getGraphicEl(),n=t.getModel().get(["emphasis","focus"]);e&&"adjacency"===n&&(rl(e).focus={edge:[t.dataIndex],node:[t.node1.dataIndex,t.node2.dataIndex]})}));var f="circular"===t.get("layout")&&t.get(["circular","rotateLabel"]),g=u.getLayout("cx"),y=u.getLayout("cy");u.graph.eachNode((function(t){DA(t,f,g,y)})),this._firstRender=!1},e.prototype.dispose=function(){this.remove(),this._controller&&this._controller.dispose(),this._controllerHost=null},e.prototype._startForceLayoutIteration=function(t,e){var n=this;!function i(){t.step((function(t){n.updateLayout(n._model),(n._layouting=!t)&&(e?n._layoutTimeout=setTimeout(i,16):i())}))}()},e.prototype._updateController=function(t,e,n){var i=this,r=this._controller,o=this._controllerHost,a=this.group;r.setPointerChecker((function(e,i,r){var o=a.getBoundingRect();return o.applyTransform(a.transform),o.contain(i,r)&&!hT(e,n,t)})),nk(t.coordinateSystem)?(r.enable(t.get("roam")),o.zoomLimit=t.get("scaleLimit"),o.zoom=t.coordinateSystem.getZoom(),r.off("pan").off("zoom").on("pan",(function(e){aT(o,e.dx,e.dy),n.dispatchAction({seriesId:t.id,type:"graphRoam",dx:e.dx,dy:e.dy})})).on("zoom",(function(e){sT(o,e.scale,e.originX,e.originY),n.dispatchAction({seriesId:t.id,type:"graphRoam",zoom:e.scale,originX:e.originX,originY:e.originY}),i._updateNodeAndLinkScale(),ek(t.getGraph(),wA(t)),i._lineDraw.updateLayout(),n.updateLabelLayout()}))):r.disable()},e.prototype._updateNodeAndLinkScale=function(){var t=this._model,e=t.getData(),n=wA(t);e.eachItemGraphicEl((function(t,e){t&&t.setSymbolScale(n)}))},e.prototype.updateLayout=function(t){ek(t.getGraph(),wA(t)),this._symbolDraw.updateLayout(),this._lineDraw.updateLayout()},e.prototype.remove=function(){clearTimeout(this._layoutTimeout),this._layouting=!1,this._layoutTimeout=null,this._symbolDraw&&this._symbolDraw.remove(),this._lineDraw&&this._lineDraw.remove()},e.type="graph",e}(Og);function rk(t){return"_EC_"+t}var ok=function(){function t(t){this.type="graph",this.nodes=[],this.edges=[],this._nodesMap={},this._edgesMap={},this._directed=t||!1}return t.prototype.isDirected=function(){return this._directed},t.prototype.addNode=function(t,e){t=null==t?""+e:""+t;var n=this._nodesMap;if(!n[rk(t)]){var i=new ak(t,e);return i.hostGraph=this,this.nodes.push(i),n[rk(t)]=i,i}},t.prototype.getNodeByIndex=function(t){var e=this.data.getRawIndex(t);return this.nodes[e]},t.prototype.getNodeById=function(t){return this._nodesMap[rk(t)]},t.prototype.addEdge=function(t,e,n){var i=this._nodesMap,r=this._edgesMap;if(j(t)&&(t=this.nodes[t]),j(e)&&(e=this.nodes[e]),t instanceof ak||(t=i[rk(t)]),e instanceof ak||(e=i[rk(e)]),t&&e){var o=t.id+"-"+e.id,a=new sk(t,e,n);return a.hostGraph=this,this._directed&&(t.outEdges.push(a),e.inEdges.push(a)),t.edges.push(a),t!==e&&e.edges.push(a),this.edges.push(a),r[o]=a,a}},t.prototype.getEdgeByIndex=function(t){var e=this.edgeData.getRawIndex(t);return this.edges[e]},t.prototype.getEdge=function(t,e){t instanceof ak&&(t=t.id),e instanceof ak&&(e=e.id);var n=this._edgesMap;return this._directed?n[t+"-"+e]:n[t+"-"+e]||n[e+"-"+t]},t.prototype.eachNode=function(t,e){for(var n=this.nodes,i=n.length,r=0;r=0&&t.call(e,n[r],r)},t.prototype.eachEdge=function(t,e){for(var n=this.edges,i=n.length,r=0;r=0&&n[r].node1.dataIndex>=0&&n[r].node2.dataIndex>=0&&t.call(e,n[r],r)},t.prototype.breadthFirstTraverse=function(t,e,n,i){if(e instanceof ak||(e=this._nodesMap[rk(e)]),e){for(var r="out"===n?"outEdges":"in"===n?"inEdges":"edges",o=0;o=0&&n.node2.dataIndex>=0}));for(r=0,o=i.length;r=0&&this[t][e].setItemVisual(this.dataIndex,n,i)},getVisual:function(n){return this[t][e].getItemVisual(this.dataIndex,n)},setLayout:function(n,i){this.dataIndex>=0&&this[t][e].setItemLayout(this.dataIndex,n,i)},getLayout:function(){return this[t][e].getItemLayout(this.dataIndex)},getGraphicEl:function(){return this[t][e].getItemGraphicEl(this.dataIndex)},getRawIndex:function(){return this[t][e].getRawIndex(this.dataIndex)}}}function uk(t,e,n,i,r){for(var o=new ok(i),a=0;a "+p)),u++)}var d,f=n.get("coordinateSystem");if("cartesian2d"===f||"polar"===f)d=_x(t,n);else{var g=wd.get(f),y=g&&g.dimensions||[];P(y,"value")<0&&y.concat(["value"]);var v=px(t,{coordDimensions:y,encodeDefine:n.getEncode()}).dimensions;(d=new cx(v,n)).initData(t)}var m=new cx(["value"],n);return m.initData(l,s),r&&r(d,m),ZC({mainData:d,struct:o,structAttr:"graph",datas:{node:d,edge:m},datasAttr:{node:"data",edge:"edgeData"}}),o.update(),o}R(ak,lk("hostGraph","data")),R(sk,lk("hostGraph","edgeData"));var hk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.hasSymbolVisual=!0,n}return n(e,t),e.prototype.init=function(e){t.prototype.init.apply(this,arguments);var n=this;function i(){return n._categoriesData}this.legendVisualProvider=new NM(i,i),this.fillDataTextStyle(e.edges||e.links),this._updateCategoriesData()},e.prototype.mergeOption=function(e){t.prototype.mergeOption.apply(this,arguments),this.fillDataTextStyle(e.edges||e.links),this._updateCategoriesData()},e.prototype.mergeDefaultAndTheme=function(e){t.prototype.mergeDefaultAndTheme.apply(this,arguments),Co(e,"edgeLabel",["show"])},e.prototype.getInitialData=function(t,e){var n,i=t.edges||t.links||[],r=t.data||t.nodes||[],o=this;if(r&&i){dA(n=this)&&(n.__curvenessList=[],n.__edgeMap={},fA(n));var a=uk(r,i,this,!0,(function(t,e){t.wrapMethod("getItemModel",(function(t){var e=o._categoriesModels[t.getShallow("category")];return e&&(e.parentModel=t.parentModel,t.parentModel=e),t}));var n=Cc.prototype.getModel;function i(t,e){var i=n.call(this,t,e);return i.resolveParentPath=r,i}function r(t){if(t&&("label"===t[0]||"label"===t[1])){var e=t.slice();return"label"===t[0]?e[0]="edgeLabel":"label"===t[1]&&(e[1]="edgeLabel"),e}return t}e.wrapMethod("getItemModel",(function(t){return t.resolveParentPath=r,t.getModel=i,t}))}));return E(a.edges,(function(t){!function(t,e,n,i){if(dA(n)){var r=gA(t,e,n),o=n.__edgeMap,a=o[yA(r)];o[r]&&!a?o[r].isForward=!0:a&&o[r]&&(a.isForward=!0,o[r].isForward=!1),o[r]=o[r]||[],o[r].push(i)}}(t.node1,t.node2,this,t.dataIndex)}),this),a.data}},e.prototype.getGraph=function(){return this.getData().graph},e.prototype.getEdgeData=function(){return this.getGraph().edgeData},e.prototype.getCategoriesData=function(){return this._categoriesData},e.prototype.formatTooltip=function(t,e,n){if("edge"===n){var i=this.getData(),r=this.getDataParams(t,n),o=i.graph.getEdgeByIndex(t),a=i.getName(o.node1.dataIndex),s=i.getName(o.node2.dataIndex),l=[];return null!=a&&l.push(a),null!=s&&l.push(s),og("nameValue",{name:l.join(" > "),value:r.value,noValue:null==r.value})}return vg({series:this,dataIndex:t,multipleSeries:e})},e.prototype._updateCategoriesData=function(){var t=z(this.option.categories||[],(function(t){return null!=t.value?t:A({value:0},t)})),e=new cx(["value"],this);e.initData(t),this._categoriesData=e,this._categoriesModels=e.mapArray((function(t){return e.getItemModel(t)}))},e.prototype.setZoom=function(t){this.option.zoom=t},e.prototype.setCenter=function(t){this.option.center=t},e.prototype.isAnimationEnabled=function(){return t.prototype.isAnimationEnabled.call(this)&&!("force"===this.get("layout")&&this.get(["force","layoutAnimation"]))},e.type="series.graph",e.dependencies=["grid","polar","geo","singleAxis","calendar"],e.defaultOption={z:2,coordinateSystem:"view",legendHoverLink:!0,layout:null,circular:{rotateLabel:!1},force:{initLayout:null,repulsion:[0,50],gravity:.1,friction:.6,edgeLength:30,layoutAnimation:!0},left:"center",top:"center",symbol:"circle",symbolSize:10,edgeSymbol:["none","none"],edgeSymbolSize:10,edgeLabel:{position:"middle",distance:5},draggable:!1,roam:!1,center:null,zoom:1,nodeScaleRatio:.6,label:{show:!1,formatter:"{b}"},itemStyle:{},lineStyle:{color:"#aaa",width:1,opacity:.5},emphasis:{scale:!0,label:{show:!0}},select:{itemStyle:{borderColor:"#212121"}}},e}(bg),ck={type:"graphRoam",event:"graphRoam",update:"none"};var pk=function(){this.angle=0,this.width=10,this.r=10,this.x=0,this.y=0},dk=function(t){function e(e){var n=t.call(this,e)||this;return n.type="pointer",n}return n(e,t),e.prototype.getDefaultShape=function(){return new pk},e.prototype.buildPath=function(t,e){var n=Math.cos,i=Math.sin,r=e.r,o=e.width,a=e.angle,s=e.x-n(a)*o*(o>=r/3?1:2),l=e.y-i(a)*o*(o>=r/3?1:2);a=e.angle-Math.PI/2,t.moveTo(s,l),t.lineTo(e.x+n(a)*o,e.y+i(a)*o),t.lineTo(e.x+n(e.angle)*r,e.y+i(e.angle)*r),t.lineTo(e.x-n(a)*o,e.y-i(a)*o),t.lineTo(s,l)},e}(ks);function fk(t,e){var n=null==t?"":t+"";return e&&(U(e)?n=e.replace("{value}",n):X(e)&&(n=e(t))),n}var gk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){this.group.removeAll();var i=t.get(["axisLine","lineStyle","color"]),r=function(t,e){var n=t.get("center"),i=e.getWidth(),r=e.getHeight(),o=Math.min(i,r);return{cx:$r(n[0],e.getWidth()),cy:$r(n[1],e.getHeight()),r:$r(t.get("radius"),o/2)}}(t,n);this._renderMain(t,e,n,i,r),this._data=t.getData()},e.prototype.dispose=function(){},e.prototype._renderMain=function(t,e,n,i,r){var o=this.group,a=t.get("clockwise"),s=-t.get("startAngle")/180*Math.PI,l=-t.get("endAngle")/180*Math.PI,u=t.getModel("axisLine"),h=u.get("roundCap")?JS:Fu,c=u.get("show"),p=u.getModel("lineStyle"),d=p.get("width"),f=[s,l];us(f,!a);for(var g=(l=f[1])-(s=f[0]),y=s,v=[],m=0;c&&m=t&&(0===e?0:i[e-1][0])Math.PI/2&&(V+=Math.PI):"tangential"===z?V=-M-Math.PI/2:j(z)&&(V=z*Math.PI/180),0===V?c.add(new Xs({style:oc(x,{text:O,x:N,y:E,verticalAlign:h<-.8?"top":h>.8?"bottom":"middle",align:u<-.4?"left":u>.4?"right":"center"},{inheritColor:R}),silent:!0})):c.add(new Xs({style:oc(x,{text:O,x:N,y:E,verticalAlign:"middle",align:"center"},{inheritColor:R}),silent:!0,originX:N,originY:E,rotation:V}))}if(m.get("show")&&k!==_){P=(P=m.get("distance"))?P+l:l;for(var B=0;B<=b;B++){u=Math.cos(M),h=Math.sin(M);var F=new Ku({shape:{x1:u*(f-P)+p,y1:h*(f-P)+d,x2:u*(f-S-P)+p,y2:h*(f-S-P)+d},silent:!0,style:D});"auto"===D.stroke&&F.setStyle({stroke:i((k+B/b)/_)}),c.add(F),M+=T}M-=T}else M+=I}},e.prototype._renderPointer=function(t,e,n,i,r,o,a,s,l){var u=this.group,h=this._data,c=this._progressEls,p=[],d=t.get(["pointer","show"]),f=t.getModel("progress"),g=f.get("show"),y=t.getData(),v=y.mapDimension("value"),m=+t.get("min"),x=+t.get("max"),_=[m,x],b=[o,a];function w(e,n){var i,o=y.getItemModel(e).getModel("pointer"),a=$r(o.get("width"),r.r),s=$r(o.get("length"),r.r),l=t.get(["pointer","icon"]),u=o.get("offsetCenter"),h=$r(u[0],r.r),c=$r(u[1],r.r),p=o.get("keepAspect");return(i=l?Xy(l,h-a/2,c-s,a,s,null,p):new dk({shape:{angle:-Math.PI/2,width:a,r:s,x:h,y:c}})).rotation=-(n+Math.PI/2),i.x=r.cx,i.y=r.cy,i}function S(t,e){var n=f.get("roundCap")?JS:Fu,i=f.get("overlap"),a=i?f.get("width"):l/y.count(),u=i?r.r-a:r.r-(t+1)*a,h=i?r.r:r.r-t*a,c=new n({shape:{startAngle:o,endAngle:e,cx:r.cx,cy:r.cy,clockwise:s,r0:u,r:h}});return i&&(c.z2=x-y.get(v,t)%x),c}(g||d)&&(y.diff(h).add((function(e){var n=y.get(v,e);if(d){var i=w(e,o);mh(i,{rotation:-((isNaN(+n)?b[0]:Kr(n,_,b,!0))+Math.PI/2)},t),u.add(i),y.setItemGraphicEl(e,i)}if(g){var r=S(e,o),a=f.get("clip");mh(r,{shape:{endAngle:Kr(n,_,b,a)}},t),u.add(r),ol(t.seriesIndex,y.dataType,e,r),p[e]=r}})).update((function(e,n){var i=y.get(v,e);if(d){var r=h.getItemGraphicEl(n),a=r?r.rotation:o,s=w(e,a);s.rotation=a,vh(s,{rotation:-((isNaN(+i)?b[0]:Kr(i,_,b,!0))+Math.PI/2)},t),u.add(s),y.setItemGraphicEl(e,s)}if(g){var l=c[n],m=S(e,l?l.shape.endAngle:o),x=f.get("clip");vh(m,{shape:{endAngle:Kr(i,_,b,x)}},t),u.add(m),ol(t.seriesIndex,y.dataType,e,m),p[e]=m}})).execute(),y.each((function(t){var e=y.getItemModel(t),n=e.getModel("emphasis"),r=n.get("focus"),o=n.get("blurScope"),a=n.get("disabled");if(d){var s=y.getItemGraphicEl(t),l=y.getItemVisual(t,"style"),u=l.fill;if(s instanceof Ns){var h=s.style;s.useStyle(A({image:h.image,x:h.x,y:h.y,width:h.width,height:h.height},l))}else s.useStyle(l),"pointer"!==s.type&&s.setColor(u);s.setStyle(e.getModel(["pointer","itemStyle"]).getItemStyle()),"auto"===s.style.fill&&s.setStyle("fill",i(Kr(y.get(v,t),_,[0,1],!0))),s.z2EmphasisLift=0,$l(s,e),Zl(s,r,o,a)}if(g){var c=p[t];c.useStyle(y.getItemVisual(t,"style")),c.setStyle(e.getModel(["progress","itemStyle"]).getItemStyle()),c.z2EmphasisLift=0,$l(c,e),Zl(c,r,o,a)}})),this._progressEls=p)},e.prototype._renderAnchor=function(t,e){var n=t.getModel("anchor");if(n.get("show")){var i=n.get("size"),r=n.get("icon"),o=n.get("offsetCenter"),a=n.get("keepAspect"),s=Xy(r,e.cx-i/2+$r(o[0],e.r),e.cy-i/2+$r(o[1],e.r),i,i,null,a);s.z2=n.get("showAbove")?1:0,s.setStyle(n.getModel("itemStyle").getItemStyle()),this.group.add(s)}},e.prototype._renderTitleAndDetail=function(t,e,n,i,r){var o=this,a=t.getData(),s=a.mapDimension("value"),l=+t.get("min"),u=+t.get("max"),h=new Br,c=[],p=[],d=t.isAnimationEnabled(),f=t.get(["pointer","showAbove"]);a.diff(this._data).add((function(t){c[t]=new Xs({silent:!0}),p[t]=new Xs({silent:!0})})).update((function(t,e){c[t]=o._titleEls[e],p[t]=o._detailEls[e]})).execute(),a.each((function(e){var n=a.getItemModel(e),o=a.get(s,e),g=new Br,y=i(Kr(o,[l,u],[0,1],!0)),v=n.getModel("title");if(v.get("show")){var m=v.get("offsetCenter"),x=r.cx+$r(m[0],r.r),_=r.cy+$r(m[1],r.r);(D=c[e]).attr({z2:f?0:2,style:oc(v,{x:x,y:_,text:a.getName(e),align:"center",verticalAlign:"middle"},{inheritColor:y})}),g.add(D)}var b=n.getModel("detail");if(b.get("show")){var w=b.get("offsetCenter"),S=r.cx+$r(w[0],r.r),M=r.cy+$r(w[1],r.r),I=$r(b.get("width"),r.r),T=$r(b.get("height"),r.r),C=t.get(["progress","show"])?a.getItemVisual(e,"style").fill:y,D=p[e],A=b.get("formatter");D.attr({z2:f?0:2,style:oc(b,{x:S,y:M,text:fk(o,A),width:isNaN(I)?null:I,height:isNaN(T)?null:T,align:"center",verticalAlign:"middle"},{inheritColor:C})}),dc(D,{normal:b},o,(function(t){return fk(t,A)})),d&&fc(D,e,a,t,{getFormattedLabel:function(t,e,n,i,r,a){return fk(a?a.interpolatedValue:o,A)}}),g.add(D)}h.add(g)})),this.group.add(h),this._titleEls=c,this._detailEls=p},e.type="gauge",e}(Og),yk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.visualStyleAccessPath="itemStyle",n}return n(e,t),e.prototype.getInitialData=function(t,e){return RM(this,["value"])},e.type="series.gauge",e.defaultOption={z:2,colorBy:"data",center:["50%","50%"],legendHoverLink:!0,radius:"75%",startAngle:225,endAngle:-45,clockwise:!0,min:0,max:100,splitNumber:10,axisLine:{show:!0,roundCap:!1,lineStyle:{color:[[1,"#E6EBF8"]],width:10}},progress:{show:!1,overlap:!0,width:10,roundCap:!1,clip:!0},splitLine:{show:!0,length:10,distance:10,lineStyle:{color:"#63677A",width:3,type:"solid"}},axisTick:{show:!0,splitNumber:5,length:6,distance:10,lineStyle:{color:"#63677A",width:1,type:"solid"}},axisLabel:{show:!0,distance:15,color:"#464646",fontSize:12,rotate:0},pointer:{icon:null,offsetCenter:[0,0],show:!0,showAbove:!0,length:"60%",width:6,keepAspect:!1},anchor:{show:!1,showAbove:!1,size:6,icon:"circle",offsetCenter:[0,0],keepAspect:!1,itemStyle:{color:"#fff",borderWidth:0,borderColor:"#5470c6"}},title:{show:!0,offsetCenter:[0,"20%"],color:"#464646",fontSize:16,valueAnimation:!1},detail:{show:!0,backgroundColor:"rgba(0,0,0,0)",borderWidth:0,borderColor:"#ccc",width:100,height:null,padding:[5,10],offsetCenter:[0,"40%"],color:"#464646",fontSize:30,fontWeight:"bold",lineHeight:30,valueAnimation:!1}},e}(bg);var vk=["itemStyle","opacity"],mk=function(t){function e(e,n){var i=t.call(this)||this,r=i,o=new Zu,a=new Xs;return r.setTextContent(a),i.setTextGuideLine(o),i.updateData(e,n,!0),i}return n(e,t),e.prototype.updateData=function(t,e,n){var i=this,r=t.hostModel,o=t.getItemModel(e),a=t.getItemLayout(e),s=o.getModel("emphasis"),l=o.get(vk);l=null==l?1:l,n||Sh(i),i.useStyle(t.getItemVisual(e,"style")),i.style.lineJoin="round",n?(i.setShape({points:a.points}),i.style.opacity=0,mh(i,{style:{opacity:l}},r,e)):vh(i,{style:{opacity:l},shape:{points:a.points}},r,e),$l(i,o),this._updateLabel(t,e),Zl(this,s.get("focus"),s.get("blurScope"),s.get("disabled"))},e.prototype._updateLabel=function(t,e){var n=this,i=this.getTextGuideLine(),r=n.getTextContent(),o=t.hostModel,a=t.getItemModel(e),s=t.getItemLayout(e).label,l=t.getItemVisual(e,"style"),u=l.fill;ic(r,rc(a),{labelFetcher:t.hostModel,labelDataIndex:e,defaultOpacity:l.opacity,defaultText:t.getName(e)},{normal:{align:s.textAlign,verticalAlign:s.verticalAlign}}),n.setTextConfig({local:!0,inside:!!s.inside,insideStroke:u,outsideFill:u});var h=s.linePoints;i.setShape({points:h}),n.textGuideLineConfig={anchor:h?new De(h[0][0],h[0][1]):null},vh(r,{style:{x:s.x,y:s.y}},o,e),r.attr({rotation:s.rotation,originX:s.x,originY:s.y,z2:10}),kb(n,Lb(a),{stroke:u})},e}(Xu),xk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.ignoreLabelLineUpdate=!0,n}return n(e,t),e.prototype.render=function(t,e,n){var i=t.getData(),r=this._data,o=this.group;i.diff(r).add((function(t){var e=new mk(i,t);i.setItemGraphicEl(t,e),o.add(e)})).update((function(t,e){var n=r.getItemGraphicEl(e);n.updateData(i,t),o.add(n),i.setItemGraphicEl(t,n)})).remove((function(e){wh(r.getItemGraphicEl(e),t,e)})).execute(),this._data=i},e.prototype.remove=function(){this.group.removeAll(),this._data=null},e.prototype.dispose=function(){},e.type="funnel",e}(Og),_k=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(e){t.prototype.init.apply(this,arguments),this.legendVisualProvider=new NM(W(this.getData,this),W(this.getRawData,this)),this._defaultLabelLine(e)},e.prototype.getInitialData=function(t,e){return RM(this,{coordDimensions:["value"],encodeDefaulter:H(ed,this)})},e.prototype._defaultLabelLine=function(t){Co(t,"labelLine",["show"]);var e=t.labelLine,n=t.emphasis.labelLine;e.show=e.show&&t.label.show,n.show=n.show&&t.emphasis.label.show},e.prototype.getDataParams=function(e){var n=this.getData(),i=t.prototype.getDataParams.call(this,e),r=n.mapDimension("value"),o=n.getSum(r);return i.percent=o?+(n.get(r,e)/o*100).toFixed(2):0,i.$vars.push("percent"),i},e.type="series.funnel",e.defaultOption={z:2,legendHoverLink:!0,colorBy:"data",left:80,top:60,right:80,bottom:60,minSize:"0%",maxSize:"100%",sort:"descending",orient:"vertical",gap:0,funnelAlign:"center",label:{show:!0,position:"outer"},labelLine:{show:!0,length:20,lineStyle:{width:1}},itemStyle:{borderColor:"#fff",borderWidth:1},emphasis:{label:{show:!0}},select:{itemStyle:{borderColor:"#212121"}}},e}(bg);function bk(t,e){t.eachSeriesByType("funnel",(function(t){var n=t.getData(),i=n.mapDimension("value"),r=t.get("sort"),o=function(t,e){return kp(t.getBoxLayoutParams(),{width:e.getWidth(),height:e.getHeight()})}(t,e),a=t.get("orient"),s=o.width,l=o.height,u=function(t,e){for(var n=t.mapDimension("value"),i=t.mapArray(n,(function(t){return t})),r=[],o="ascending"===e,a=0,s=t.count();a5)return;var i=this._model.coordinateSystem.getSlidedAxisExpandWindow([t.offsetX,t.offsetY]);"none"!==i.behavior&&this._dispatchExpand({axisExpandWindow:i.axisExpandWindow})}this._mouseDownPoint=null},mousemove:function(t){if(!this._mouseDownPoint&&Rk(this,"mousemove")){var e=this._model,n=e.coordinateSystem.getSlidedAxisExpandWindow([t.offsetX,t.offsetY]),i=n.behavior;"jump"===i&&this._throttledDispatchExpand.debounceNextCall(e.get("axisExpandDebounce")),this._throttledDispatchExpand("none"===i?null:{axisExpandWindow:n.axisExpandWindow,animation:"jump"===i?null:{duration:0}})}}};function Rk(t,e){var n=t._model;return n.get("axisExpandable")&&n.get("axisExpandTriggerOn")===e}var Nk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(){t.prototype.init.apply(this,arguments),this.mergeOption({})},e.prototype.mergeOption=function(t){var e=this.option;t&&C(e,t,!0),this._initDimensions()},e.prototype.contains=function(t,e){var n=t.get("parallelIndex");return null!=n&&e.getComponent("parallel",n)===this},e.prototype.setAxisExpand=function(t){E(["axisExpandable","axisExpandCenter","axisExpandCount","axisExpandWidth","axisExpandWindow"],(function(e){t.hasOwnProperty(e)&&(this.option[e]=t[e])}),this)},e.prototype._initDimensions=function(){var t=this.dimensions=[],e=this.parallelAxisIndex=[];E(B(this.ecModel.queryComponents({mainType:"parallelAxis"}),(function(t){return(t.get("parallelIndex")||0)===this.componentIndex}),this),(function(n){t.push("dim"+n.get("dim")),e.push(n.componentIndex)}))},e.type="parallel",e.dependencies=["parallelAxis"],e.layoutMode="box",e.defaultOption={z:0,left:80,top:60,right:80,bottom:60,layout:"horizontal",axisExpandable:!1,axisExpandCenter:null,axisExpandCount:0,axisExpandWidth:50,axisExpandRate:17,axisExpandDebounce:50,axisExpandSlideTriggerArea:[-.15,.05,.4],axisExpandTriggerOn:"click",parallelAxisDefault:null},e}(zp),Ek=function(t){function e(e,n,i,r,o){var a=t.call(this,e,n,i)||this;return a.type=r||"value",a.axisIndex=o,a}return n(e,t),e.prototype.isHorizontal=function(){return"horizontal"!==this.coordinateSystem.getModel().get("layout")},e}(ab);function zk(t,e,n,i,r,o){t=t||0;var a=n[1]-n[0];if(null!=r&&(r=Bk(r,[0,a])),null!=o&&(o=Math.max(o,null!=r?r:0)),"all"===i){var s=Math.abs(e[1]-e[0]);s=Bk(s,[0,a]),r=o=Bk(s,[r,o]),i=0}e[0]=Bk(e[0],n),e[1]=Bk(e[1],n);var l=Vk(e,i);e[i]+=t;var u,h=r||0,c=n.slice();return l.sign<0?c[0]+=h:c[1]-=h,e[i]=Bk(e[i],c),u=Vk(e,i),null!=r&&(u.sign!==l.sign||u.spano&&(e[1-i]=e[i]+u.sign*o),e}function Vk(t,e){var n=t[e]-t[1-e];return{span:Math.abs(n),sign:n>0?-1:n<0?1:e?-1:1}}function Bk(t,e){return Math.min(null!=e[1]?e[1]:1/0,Math.max(null!=e[0]?e[0]:-1/0,t))}var Fk=E,Gk=Math.min,Wk=Math.max,Hk=Math.floor,Yk=Math.ceil,Xk=Jr,Uk=Math.PI,Zk=function(){function t(t,e,n){this.type="parallel",this._axesMap=yt(),this._axesLayout={},this.dimensions=t.dimensions,this._model=t,this._init(t,e,n)}return t.prototype._init=function(t,e,n){var i=t.dimensions,r=t.parallelAxisIndex;Fk(i,(function(t,n){var i=r[n],o=e.getComponent("parallelAxis",i),a=this._axesMap.set(t,new Ek(t,b_(o),[0,0],o.get("type"),i)),s="category"===a.type;a.onBand=s&&o.get("boundaryGap"),a.inverse=o.get("inverse"),o.axis=a,a.model=o,a.coordinateSystem=o.coordinateSystem=this}),this)},t.prototype.update=function(t,e){this._updateAxesFromSeries(this._model,t)},t.prototype.containPoint=function(t){var e=this._makeLayoutInfo(),n=e.axisBase,i=e.layoutBase,r=e.pixelDimIndex,o=t[1-r],a=t[r];return o>=n&&o<=n+e.axisLength&&a>=i&&a<=i+e.layoutLength},t.prototype.getModel=function(){return this._model},t.prototype._updateAxesFromSeries=function(t,e){e.eachSeries((function(n){if(t.contains(n,e)){var i=n.getData();Fk(this.dimensions,(function(t){var e=this._axesMap.get(t);e.scale.unionExtentFromData(i,i.mapDimension(t)),__(e.scale,e.model)}),this)}}),this)},t.prototype.resize=function(t,e){this._rect=kp(t.getBoxLayoutParams(),{width:e.getWidth(),height:e.getHeight()}),this._layoutAxes()},t.prototype.getRect=function(){return this._rect},t.prototype._makeLayoutInfo=function(){var t,e=this._model,n=this._rect,i=["x","y"],r=["width","height"],o=e.get("layout"),a="horizontal"===o?0:1,s=n[r[a]],l=[0,s],u=this.dimensions.length,h=jk(e.get("axisExpandWidth"),l),c=jk(e.get("axisExpandCount")||0,[0,u]),p=e.get("axisExpandable")&&u>3&&u>c&&c>1&&h>0&&s>0,d=e.get("axisExpandWindow");d?(t=jk(d[1]-d[0],l),d[1]=d[0]+t):(t=jk(h*(c-1),l),(d=[h*(e.get("axisExpandCenter")||Hk(u/2))-t/2])[1]=d[0]+t);var f=(s-t)/(u-c);f<3&&(f=0);var g=[Hk(Xk(d[0]/h,1))+1,Yk(Xk(d[1]/h,1))-1],y=f/h*d[0];return{layout:o,pixelDimIndex:a,layoutBase:n[i[a]],layoutLength:s,axisBase:n[i[1-a]],axisLength:n[r[1-a]],axisExpandable:p,axisExpandWidth:h,axisCollapseWidth:f,axisExpandWindow:d,axisCount:u,winInnerIndices:g,axisExpandWindow0Pos:y}},t.prototype._layoutAxes=function(){var t=this._rect,e=this._axesMap,n=this.dimensions,i=this._makeLayoutInfo(),r=i.layout;e.each((function(t){var e=[0,i.axisLength],n=t.inverse?1:0;t.setExtent(e[n],e[1-n])})),Fk(n,(function(e,n){var o=(i.axisExpandable?Kk:qk)(n,i),a={horizontal:{x:o.position,y:i.axisLength},vertical:{x:0,y:o.position}},s={horizontal:Uk/2,vertical:0},l=[a[r].x+t.x,a[r].y+t.y],u=s[r],h=[1,0,0,1,0,0];Se(h,h,u),we(h,h,l),this._axesLayout[e]={position:l,rotation:u,transform:h,axisNameAvailableWidth:o.axisNameAvailableWidth,axisLabelShow:o.axisLabelShow,nameTruncateMaxWidth:o.nameTruncateMaxWidth,tickDirection:1,labelDirection:1}}),this)},t.prototype.getAxis=function(t){return this._axesMap.get(t)},t.prototype.dataToPoint=function(t,e){return this.axisCoordToPoint(this._axesMap.get(e).dataToCoord(t),e)},t.prototype.eachActiveState=function(t,e,n,i){null==n&&(n=0),null==i&&(i=t.count());var r=this._axesMap,o=this.dimensions,a=[],s=[];E(o,(function(e){a.push(t.mapDimension(e)),s.push(r.get(e).model)}));for(var l=this.hasAxisBrushed(),u=n;ur*(1-h[0])?(l="jump",a=s-r*(1-h[2])):(a=s-r*h[1])>=0&&(a=s-r*(1-h[1]))<=0&&(a=0),(a*=e.axisExpandWidth/u)?zk(a,i,o,"all"):l="none";else{var p=i[1]-i[0];(i=[Wk(0,o[1]*s/p-p/2)])[1]=Gk(o[1],i[0]+p),i[0]=i[1]-p}return{axisExpandWindow:i,behavior:l}},t}();function jk(t,e){return Gk(Wk(t,e[0]),e[1])}function qk(t,e){var n=e.layoutLength/(e.axisCount-1);return{position:n*t,axisNameAvailableWidth:n,axisLabelShow:!0}}function Kk(t,e){var n,i,r=e.layoutLength,o=e.axisExpandWidth,a=e.axisCount,s=e.axisCollapseWidth,l=e.winInnerIndices,u=s,h=!1;return t=0;n--)Qr(e[n])},e.prototype.getActiveState=function(t){var e=this.activeIntervals;if(!e.length)return"normal";if(null==t||isNaN(+t))return"inactive";if(1===e.length){var n=e[0];if(n[0]<=t&&t<=n[1])return"active"}else for(var i=0,r=e.length;i6}(t)||o){if(a&&!o){"single"===s.brushMode&&vL(t);var l=T(s);l.brushType=RL(l.brushType,a),l.panelId=a===Qk?null:a.panelId,o=t._creatingCover=uL(t,l),t._covers.push(o)}if(o){var u=zL[RL(t._brushType,a)];o.__brushOption.range=u.getCreatingRange(kL(t,o,t._track)),i&&(hL(t,o),u.updateCommon(t,o)),cL(t,o),r={isEnd:i}}}else i&&"single"===s.brushMode&&s.removeOnClick&&gL(t,e,n)&&vL(t)&&(r={isEnd:i,removeOnClick:!0});return r}function RL(t,e){return"auto"===t?e.defaultBrushType:t}var NL={mousedown:function(t){if(this._dragging)EL(this,t);else if(!t.target||!t.target.draggable){LL(t);var e=this.group.transformCoordToLocal(t.offsetX,t.offsetY);this._creatingCover=null,(this._creatingPanel=gL(this,t,e))&&(this._dragging=!0,this._track=[e.slice()])}},mousemove:function(t){var e=t.offsetX,n=t.offsetY,i=this.group.transformCoordToLocal(e,n);if(function(t,e,n){if(t._brushType&&!function(t,e,n){var i=t._zr;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}(t,e.offsetX,e.offsetY)){var i=t._zr,r=t._covers,o=gL(t,e,n);if(!t._dragging)for(var a=0;a=0&&(o[r[a].depth]=new Cc(r[a],this,e));if(i&&n){var s=uk(i,n,this,!0,(function(t,e){t.wrapMethod("getItemModel",(function(t,e){var n=t.parentModel,i=n.getData().getItemLayout(e);if(i){var r=i.depth,o=n.levelModels[r];o&&(t.parentModel=o)}return t})),e.wrapMethod("getItemModel",(function(t,e){var n=t.parentModel,i=n.getGraph().getEdgeByIndex(e).node1.getLayout();if(i){var r=i.depth,o=n.levelModels[r];o&&(t.parentModel=o)}return t}))}));return s.data}},e.prototype.setNodePosition=function(t,e){var n=(this.option.data||this.option.nodes)[t];n.localX=e[0],n.localY=e[1]},e.prototype.getGraph=function(){return this.getData().graph},e.prototype.getEdgeData=function(){return this.getGraph().edgeData},e.prototype.formatTooltip=function(t,e,n){function i(t){return isNaN(t)||null==t}if("edge"===n){var r=this.getDataParams(t,n),o=r.data,a=r.value;return og("nameValue",{name:o.source+" -- "+o.target,value:a,noValue:i(a)})}var s=this.getGraph().getNodeByIndex(t).getLayout().value,l=this.getDataParams(t,n).data.name;return og("nameValue",{name:null!=l?l+"":null,value:s,noValue:i(s)})},e.prototype.optionUpdated=function(){},e.prototype.getDataParams=function(e,n){var i=t.prototype.getDataParams.call(this,e,n);if(null==i.value&&"node"===n){var r=this.getGraph().getNodeByIndex(e).getLayout().value;i.value=r}return i},e.type="series.sankey",e.defaultOption={z:2,coordinateSystem:"view",left:"5%",top:"5%",right:"20%",bottom:"5%",orient:"horizontal",nodeWidth:20,nodeGap:8,draggable:!0,layoutIterations:32,label:{show:!0,position:"right",fontSize:12},edgeLabel:{show:!1,fontSize:12},levels:[],nodeAlign:"justify",lineStyle:{color:"#314656",opacity:.2,curveness:.5},emphasis:{label:{show:!0},lineStyle:{opacity:.5}},select:{itemStyle:{borderColor:"#212121"}},animationEasing:"linear",animationDuration:1e3},e}(bg);function QL(t,e){t.eachSeriesByType("sankey",(function(t){var n=t.get("nodeWidth"),i=t.get("nodeGap"),r=function(t,e){return kp(t.getBoxLayoutParams(),{width:e.getWidth(),height:e.getHeight()})}(t,e);t.layoutInfo=r;var o=r.width,a=r.height,s=t.getGraph(),l=s.nodes,u=s.edges;!function(t){E(t,(function(t){var e=uP(t.outEdges,lP),n=uP(t.inEdges,lP),i=t.getValue()||0,r=Math.max(e,n,i);t.setLayout({value:r},!0)}))}(l),function(t,e,n,i,r,o,a,s,l){(function(t,e,n,i,r,o,a){for(var s=[],l=[],u=[],h=[],c=0,p=0;p=0;v&&y.depth>d&&(d=y.depth),g.setLayout({depth:v?y.depth:c},!0),"vertical"===o?g.setLayout({dy:n},!0):g.setLayout({dx:n},!0);for(var m=0;mc-1?d:c-1;a&&"left"!==a&&function(t,e,n,i){if("right"===e){for(var r=[],o=t,a=0;o.length;){for(var s=0;s0;o--)nP(s,l*=.99,a),eP(s,r,n,i,a),hP(s,l,a),eP(s,r,n,i,a)}(t,e,o,r,i,a,s),function(t,e){var n="vertical"===e?"x":"y";E(t,(function(t){t.outEdges.sort((function(t,e){return t.node2.getLayout()[n]-e.node2.getLayout()[n]})),t.inEdges.sort((function(t,e){return t.node1.getLayout()[n]-e.node1.getLayout()[n]}))})),E(t,(function(t){var e=0,n=0;E(t.outEdges,(function(t){t.setLayout({sy:e},!0),e+=t.getLayout().dy})),E(t.inEdges,(function(t){t.setLayout({ty:n},!0),n+=t.getLayout().dy}))}))}(t,s)}(l,u,n,i,o,a,0!==B(l,(function(t){return 0===t.getLayout().value})).length?0:t.get("layoutIterations"),t.get("orient"),t.get("nodeAlign"))}))}function tP(t){var e=t.hostGraph.data.getRawDataItem(t.dataIndex);return null!=e.depth&&e.depth>=0}function eP(t,e,n,i,r){var o="vertical"===r?"x":"y";E(t,(function(t){var a,s,l;t.sort((function(t,e){return t.getLayout()[o]-e.getLayout()[o]}));for(var u=0,h=t.length,c="vertical"===r?"dx":"dy",p=0;p0&&(a=s.getLayout()[o]+l,"vertical"===r?s.setLayout({x:a},!0):s.setLayout({y:a},!0)),u=s.getLayout()[o]+s.getLayout()[c]+e;if((l=u-e-("vertical"===r?i:n))>0){a=s.getLayout()[o]-l,"vertical"===r?s.setLayout({x:a},!0):s.setLayout({y:a},!0),u=a;for(p=h-2;p>=0;--p)(l=(s=t[p]).getLayout()[o]+s.getLayout()[c]+e-u)>0&&(a=s.getLayout()[o]-l,"vertical"===r?s.setLayout({x:a},!0):s.setLayout({y:a},!0)),u=s.getLayout()[o]}}))}function nP(t,e,n){E(t.slice().reverse(),(function(t){E(t,(function(t){if(t.outEdges.length){var i=uP(t.outEdges,iP,n)/uP(t.outEdges,lP);if(isNaN(i)){var r=t.outEdges.length;i=r?uP(t.outEdges,rP,n)/r:0}if("vertical"===n){var o=t.getLayout().x+(i-sP(t,n))*e;t.setLayout({x:o},!0)}else{var a=t.getLayout().y+(i-sP(t,n))*e;t.setLayout({y:a},!0)}}}))}))}function iP(t,e){return sP(t.node2,e)*t.getValue()}function rP(t,e){return sP(t.node2,e)}function oP(t,e){return sP(t.node1,e)*t.getValue()}function aP(t,e){return sP(t.node1,e)}function sP(t,e){return"vertical"===e?t.getLayout().x+t.getLayout().dx/2:t.getLayout().y+t.getLayout().dy/2}function lP(t){return t.getValue()}function uP(t,e,n){for(var i=0,r=t.length,o=-1;++oo&&(o=e)})),E(n,(function(e){var n=new kD({type:"color",mappingMethod:"linear",dataExtent:[r,o],visual:t.get("color")}).mapValueToVisual(e.getLayout().value),i=e.getModel().get(["itemStyle","color"]);null!=i?(e.setVisual("color",i),e.setVisual("style",{fill:i})):(e.setVisual("color",n),e.setVisual("style",{fill:n}))}))}i.length&&E(i,(function(t){var e=t.getModel().get("lineStyle");t.setVisual("style",e)}))}))}var pP=function(){function t(){}return t.prototype.getInitialData=function(t,e){var n,i,r=e.getComponent("xAxis",this.get("xAxisIndex")),o=e.getComponent("yAxis",this.get("yAxisIndex")),a=r.get("type"),s=o.get("type");"category"===a?(t.layout="horizontal",n=r.getOrdinalMeta(),i=!0):"category"===s?(t.layout="vertical",n=o.getOrdinalMeta(),i=!0):t.layout=t.layout||"horizontal";var l=["x","y"],u="horizontal"===t.layout?0:1,h=this._baseAxisDim=l[u],c=l[1-u],p=[r,o],d=p[u].get("type"),f=p[1-u].get("type"),g=t.data;if(g&&i){var y=[];E(g,(function(t,e){var n;Y(t)?(n=t.slice(),t.unshift(e)):Y(t.value)?((n=A({},t)).value=n.value.slice(),t.value.unshift(e)):n=t,y.push(n)})),t.data=y}var v=this.defaultValueDimensions,m=[{name:h,type:Ym(d),ordinalMeta:n,otherDims:{tooltip:!1,itemName:0},dimsDef:["base"]},{name:c,type:Ym(f),dimsDef:v.slice()}];return RM(this,{coordDimensions:m,dimensionsCount:v.length+1,encodeDefaulter:H(td,m,this)})},t.prototype.getBaseAxis=function(){var t=this._baseAxisDim;return this.ecModel.getComponent(t+"Axis",this.get(t+"AxisIndex")).axis},t}(),dP=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.defaultValueDimensions=[{name:"min",defaultTooltip:!0},{name:"Q1",defaultTooltip:!0},{name:"median",defaultTooltip:!0},{name:"Q3",defaultTooltip:!0},{name:"max",defaultTooltip:!0}],n.visualDrawType="stroke",n}return n(e,t),e.type="series.boxplot",e.dependencies=["xAxis","yAxis","grid"],e.defaultOption={z:2,coordinateSystem:"cartesian2d",legendHoverLink:!0,layout:null,boxWidth:[7,50],itemStyle:{color:"#fff",borderWidth:1},emphasis:{scale:!0,itemStyle:{borderWidth:2,shadowBlur:5,shadowOffsetX:1,shadowOffsetY:1,shadowColor:"rgba(0,0,0,0.2)"}},animationDuration:800},e}(bg);R(dP,pP,!0);var fP=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){var i=t.getData(),r=this.group,o=this._data;this._data||r.removeAll();var a="horizontal"===t.get("layout")?1:0;i.diff(o).add((function(t){if(i.hasValue(t)){var e=vP(i.getItemLayout(t),i,t,a,!0);i.setItemGraphicEl(t,e),r.add(e)}})).update((function(t,e){var n=o.getItemGraphicEl(e);if(i.hasValue(t)){var s=i.getItemLayout(t);n?(Sh(n),mP(s,n,i,t)):n=vP(s,i,t,a),r.add(n),i.setItemGraphicEl(t,n)}else r.remove(n)})).remove((function(t){var e=o.getItemGraphicEl(t);e&&r.remove(e)})).execute(),this._data=i},e.prototype.remove=function(t){var e=this.group,n=this._data;this._data=null,n&&n.eachItemGraphicEl((function(t){t&&e.remove(t)}))},e.type="boxplot",e}(Og),gP=function(){},yP=function(t){function e(e){var n=t.call(this,e)||this;return n.type="boxplotBoxPath",n}return n(e,t),e.prototype.getDefaultShape=function(){return new gP},e.prototype.buildPath=function(t,e){var n=e.points,i=0;for(t.moveTo(n[i][0],n[i][1]),i++;i<4;i++)t.lineTo(n[i][0],n[i][1]);for(t.closePath();ig){var _=[v,x];i.push(_)}}}return{boxData:n,outliers:i}}(e.getRawData(),t.config);return[{dimensions:["ItemName","Low","Q1","Q2","Q3","High"],data:i.boxData},{data:i.outliers}]}};var SP=["color","borderColor"],MP=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){this.group.removeClipPath(),this._progressiveEls=null,this._updateDrawMode(t),this._isLargeDraw?this._renderLarge(t):this._renderNormal(t)},e.prototype.incrementalPrepareRender=function(t,e,n){this._clear(),this._updateDrawMode(t)},e.prototype.incrementalRender=function(t,e,n,i){this._progressiveEls=[],this._isLargeDraw?this._incrementalRenderLarge(t,e):this._incrementalRenderNormal(t,e)},e.prototype.eachRendered=function(t){Jh(this._progressiveEls||this.group,t)},e.prototype._updateDrawMode=function(t){var e=t.pipelineContext.large;null!=this._isLargeDraw&&e===this._isLargeDraw||(this._isLargeDraw=e,this._clear())},e.prototype._renderNormal=function(t){var e=t.getData(),n=this._data,i=this.group,r=e.getLayout("isSimpleBox"),o=t.get("clip",!0),a=t.coordinateSystem,s=a.getArea&&a.getArea();this._data||i.removeAll(),e.diff(n).add((function(n){if(e.hasValue(n)){var a=e.getItemLayout(n);if(o&&DP(s,a))return;var l=CP(a,n,!0);mh(l,{shape:{points:a.ends}},t,n),AP(l,e,n,r),i.add(l),e.setItemGraphicEl(n,l)}})).update((function(a,l){var u=n.getItemGraphicEl(l);if(e.hasValue(a)){var h=e.getItemLayout(a);o&&DP(s,h)?i.remove(u):(u?(vh(u,{shape:{points:h.ends}},t,a),Sh(u)):u=CP(h),AP(u,e,a,r),i.add(u),e.setItemGraphicEl(a,u))}else i.remove(u)})).remove((function(t){var e=n.getItemGraphicEl(t);e&&i.remove(e)})).execute(),this._data=e},e.prototype._renderLarge=function(t){this._clear(),OP(t,this.group);var e=t.get("clip",!0)?PS(t.coordinateSystem,!1,t):null;e?this.group.setClipPath(e):this.group.removeClipPath()},e.prototype._incrementalRenderNormal=function(t,e){for(var n,i=e.getData(),r=i.getLayout("isSimpleBox");null!=(n=t.next());){var o=CP(i.getItemLayout(n));AP(o,i,n,r),o.incremental=!0,this.group.add(o),this._progressiveEls.push(o)}},e.prototype._incrementalRenderLarge=function(t,e){OP(e,this.group,this._progressiveEls,!0)},e.prototype.remove=function(t){this._clear()},e.prototype._clear=function(){this.group.removeAll(),this._data=null},e.type="candlestick",e}(Og),IP=function(){},TP=function(t){function e(e){var n=t.call(this,e)||this;return n.type="normalCandlestickBox",n}return n(e,t),e.prototype.getDefaultShape=function(){return new IP},e.prototype.buildPath=function(t,e){var n=e.points;this.__simpleBox?(t.moveTo(n[4][0],n[4][1]),t.lineTo(n[6][0],n[6][1])):(t.moveTo(n[0][0],n[0][1]),t.lineTo(n[1][0],n[1][1]),t.lineTo(n[2][0],n[2][1]),t.lineTo(n[3][0],n[3][1]),t.closePath(),t.moveTo(n[4][0],n[4][1]),t.lineTo(n[5][0],n[5][1]),t.moveTo(n[6][0],n[6][1]),t.lineTo(n[7][0],n[7][1]))},e}(ks);function CP(t,e,n){var i=t.ends;return new TP({shape:{points:n?kP(i,t):i},z2:100})}function DP(t,e){for(var n=!0,i=0;i0?"borderColor":"borderColor0"])||n.get(["itemStyle",t>0?"color":"color0"]);0===t&&(r=n.get(["itemStyle","borderColorDoji"]));var o=n.getModel("itemStyle").getItemStyle(SP);e.useStyle(o),e.style.fill=null,e.style.stroke=r}var NP=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.defaultValueDimensions=[{name:"open",defaultTooltip:!0},{name:"close",defaultTooltip:!0},{name:"lowest",defaultTooltip:!0},{name:"highest",defaultTooltip:!0}],n}return n(e,t),e.prototype.getShadowDim=function(){return"open"},e.prototype.brushSelector=function(t,e,n){var i=e.getItemLayout(t);return i&&n.rect(i.brushRect)},e.type="series.candlestick",e.dependencies=["xAxis","yAxis","grid"],e.defaultOption={z:2,coordinateSystem:"cartesian2d",legendHoverLink:!0,layout:null,clip:!0,itemStyle:{color:"#eb5454",color0:"#47b262",borderColor:"#eb5454",borderColor0:"#47b262",borderColorDoji:null,borderWidth:1},emphasis:{scale:!0,itemStyle:{borderWidth:2}},barMaxWidth:null,barMinWidth:null,barWidth:null,large:!0,largeThreshold:600,progressive:3e3,progressiveThreshold:1e4,progressiveChunkMode:"mod",animationEasing:"linear",animationDuration:300},e}(bg);function EP(t){t&&Y(t.series)&&E(t.series,(function(t){q(t)&&"k"===t.type&&(t.type="candlestick")}))}R(NP,pP,!0);var zP=["itemStyle","borderColor"],VP=["itemStyle","borderColor0"],BP=["itemStyle","borderColorDoji"],FP=["itemStyle","color"],GP=["itemStyle","color0"],WP={seriesType:"candlestick",plan:kg(),performRawSeries:!0,reset:function(t,e){function n(t,e){return e.get(t>0?FP:GP)}function i(t,e){return e.get(0===t?BP:t>0?zP:VP)}if(!e.isSeriesFiltered(t))return!t.pipelineContext.large&&{progress:function(t,e){for(var r;null!=(r=t.next());){var o=e.getItemModel(r),a=e.getItemLayout(r).sign,s=o.getItemStyle();s.fill=n(a,o),s.stroke=i(a,o)||s.fill,A(e.ensureUniqueItemVisual(r,"style"),s)}}}}},HP={seriesType:"candlestick",plan:kg(),reset:function(t){var e=t.coordinateSystem,n=t.getData(),i=function(t,e){var n,i=t.getBaseAxis(),r="category"===i.type?i.getBandWidth():(n=i.getExtent(),Math.abs(n[1]-n[0])/e.count()),o=$r(rt(t.get("barMaxWidth"),r),r),a=$r(rt(t.get("barMinWidth"),1),r),s=t.get("barWidth");return null!=s?$r(s,r):Math.max(Math.min(r/2,o),a)}(t,n),r=["x","y"],o=n.getDimensionIndex(n.mapDimension(r[0])),a=z(n.mapDimensionsAll(r[1]),n.getDimensionIndex,n),s=a[0],l=a[1],u=a[2],h=a[3];if(n.setLayout({candleWidth:i,isSimpleBox:i<=1.3}),!(o<0||a.length<4))return{progress:t.pipelineContext.large?function(n,i){var r,a,c=Bx(4*n.count),p=0,d=[],f=[],g=i.getStore(),y=!!t.get(["itemStyle","borderColorDoji"]);for(;null!=(a=n.next());){var v=g.get(o,a),m=g.get(s,a),x=g.get(l,a),_=g.get(u,a),b=g.get(h,a);isNaN(v)||isNaN(_)||isNaN(b)?(c[p++]=NaN,p+=3):(c[p++]=YP(g,a,m,x,l,y),d[0]=v,d[1]=_,r=e.dataToPoint(d,null,f),c[p++]=r?r[0]:NaN,c[p++]=r?r[1]:NaN,d[1]=b,r=e.dataToPoint(d,null,f),c[p++]=r?r[1]:NaN)}i.setLayout("largePoints",c)}:function(t,n){var r,a=n.getStore();for(;null!=(r=t.next());){var c=a.get(o,r),p=a.get(s,r),d=a.get(l,r),f=a.get(u,r),g=a.get(h,r),y=Math.min(p,d),v=Math.max(p,d),m=M(y,c),x=M(v,c),_=M(f,c),b=M(g,c),w=[];I(w,x,0),I(w,m,1),w.push(C(b),C(x),C(_),C(m));var S=!!n.getItemModel(r).get(["itemStyle","borderColorDoji"]);n.setItemLayout(r,{sign:YP(a,r,p,d,l,S),initBaseline:p>d?x[1]:m[1],ends:w,brushRect:T(f,g,c)})}function M(t,n){var i=[];return i[0]=n,i[1]=t,isNaN(n)||isNaN(t)?[NaN,NaN]:e.dataToPoint(i)}function I(t,e,n){var r=e.slice(),o=e.slice();r[0]=Vh(r[0]+i/2,1,!1),o[0]=Vh(o[0]-i/2,1,!0),n?t.push(r,o):t.push(o,r)}function T(t,e,n){var r=M(t,n),o=M(e,n);return r[0]-=i/2,o[0]-=i/2,{x:r[0],y:r[1],width:i,height:o[1]-r[1]}}function C(t){return t[0]=Vh(t[0],1),t}}}}};function YP(t,e,n,i,r,o){return n>i?-1:n0?t.get(r,e-1)<=i?1:-1:1}function XP(t,e){var n=e.rippleEffectColor||e.color;t.eachChild((function(t){t.attr({z:e.z,zlevel:e.zlevel,style:{stroke:"stroke"===e.brushType?n:null,fill:"fill"===e.brushType?n:null}})}))}var UP=function(t){function e(e,n){var i=t.call(this)||this,r=new fS(e,n),o=new Br;return i.add(r),i.add(o),i.updateData(e,n),i}return n(e,t),e.prototype.stopEffectAnimation=function(){this.childAt(1).removeAll()},e.prototype.startEffectAnimation=function(t){for(var e=t.symbolType,n=t.color,i=t.rippleNumber,r=this.childAt(1),o=0;o0&&(o=this._getLineLength(i)/l*1e3),o!==this._period||a!==this._loop||s!==this._roundTrip){i.stopAnimation();var h=void 0;h=X(u)?u(n):u,i.__t>0&&(h=-o*i.__t),this._animateSymbol(i,o,h,a,s)}this._period=o,this._loop=a,this._roundTrip=s}},e.prototype._animateSymbol=function(t,e,n,i,r){if(e>0){t.__t=0;var o=this,a=t.animate("",i).when(r?2*e:e,{__t:r?2:1}).delay(n).during((function(){o._updateSymbolPosition(t)}));i||a.done((function(){o.remove(t)})),a.start()}},e.prototype._getLineLength=function(t){return Vt(t.__p1,t.__cp1)+Vt(t.__cp1,t.__p2)},e.prototype._updateAnimationPoints=function(t,e){t.__p1=e[0],t.__p2=e[1],t.__cp1=e[2]||[(e[0][0]+e[1][0])/2,(e[0][1]+e[1][1])/2]},e.prototype.updateData=function(t,e,n){this.childAt(0).updateData(t,e,n),this._updateEffectSymbol(t,e)},e.prototype._updateSymbolPosition=function(t){var e=t.__p1,n=t.__p2,i=t.__cp1,r=t.__t<1?t.__t:2-t.__t,o=[t.x,t.y],a=o.slice(),s=In,l=Tn;o[0]=s(e[0],i[0],n[0],r),o[1]=s(e[1],i[1],n[1],r);var u=t.__t<1?l(e[0],i[0],n[0],r):l(n[0],i[0],e[0],1-r),h=t.__t<1?l(e[1],i[1],n[1],r):l(n[1],i[1],e[1],1-r);t.rotation=-Math.atan2(h,u)-Math.PI/2,"line"!==this._symbolType&&"rect"!==this._symbolType&&"roundRect"!==this._symbolType||(void 0!==t.__lastT&&t.__lastT=0&&!(i[o]<=e);o--);o=Math.min(o,r-2)}else{for(o=a;oe);o++);o=Math.min(o-1,r-2)}var s=(e-i[o])/(i[o+1]-i[o]),l=n[o],u=n[o+1];t.x=l[0]*(1-s)+s*u[0],t.y=l[1]*(1-s)+s*u[1];var h=t.__t<1?u[0]-l[0]:l[0]-u[0],c=t.__t<1?u[1]-l[1]:l[1]-u[1];t.rotation=-Math.atan2(c,h)-Math.PI/2,this._lastFrame=o,this._lastFramePercent=e,t.ignore=!1}},e}(qP),JP=function(){this.polyline=!1,this.curveness=0,this.segs=[]},QP=function(t){function e(e){var n=t.call(this,e)||this;return n._off=0,n.hoverDataIdx=-1,n}return n(e,t),e.prototype.reset=function(){this.notClear=!1,this._off=0},e.prototype.getDefaultStyle=function(){return{stroke:"#000",fill:null}},e.prototype.getDefaultShape=function(){return new JP},e.prototype.buildPath=function(t,e){var n,i=e.segs,r=e.curveness;if(e.polyline)for(n=this._off;n0){t.moveTo(i[n++],i[n++]);for(var a=1;a0){var c=(s+u)/2-(l-h)*r,p=(l+h)/2-(u-s)*r;t.quadraticCurveTo(c,p,u,h)}else t.lineTo(u,h)}this.incremental&&(this._off=n,this.notClear=!0)},e.prototype.findDataIndex=function(t,e){var n=this.shape,i=n.segs,r=n.curveness,o=this.style.lineWidth;if(n.polyline)for(var a=0,s=0;s0)for(var u=i[s++],h=i[s++],c=1;c0){if(ds(u,h,(u+p)/2-(h-d)*r,(h+d)/2-(p-u)*r,p,d,o,t,e))return a}else if(cs(u,h,p,d,o,t,e))return a;a++}return-1},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect();return t=n[0],e=n[1],i.contain(t,e)?(this.hoverDataIdx=this.findDataIndex(t,e))>=0:(this.hoverDataIdx=-1,!1)},e.prototype.getBoundingRect=function(){var t=this._rect;if(!t){for(var e=this.shape.segs,n=1/0,i=1/0,r=-1/0,o=-1/0,a=0;a0&&(o.dataIndex=n+t.__startIndex)}))},t.prototype._clear=function(){this._newAdded=[],this.group.removeAll()},t}(),eO={seriesType:"lines",plan:kg(),reset:function(t){var e=t.coordinateSystem;if(e){var n=t.get("polyline"),i=t.pipelineContext.large;return{progress:function(r,o){var a=[];if(i){var s=void 0,l=r.end-r.start;if(n){for(var u=0,h=r.start;h0&&(l||s.configLayer(o,{motionBlur:!0,lastFrameAlpha:Math.max(Math.min(a/10+.9,1),0)})),r.updateData(i);var u=t.get("clip",!0)&&PS(t.coordinateSystem,!1,t);u?this.group.setClipPath(u):this.group.removeClipPath(),this._lastZlevel=o,this._finished=!0},e.prototype.incrementalPrepareRender=function(t,e,n){var i=t.getData();this._updateLineDraw(i,t).incrementalPrepareUpdate(i),this._clearLayer(n),this._finished=!1},e.prototype.incrementalRender=function(t,e,n){this._lineDraw.incrementalUpdate(t,e.getData()),this._finished=t.end===e.getData().count()},e.prototype.eachRendered=function(t){this._lineDraw&&this._lineDraw.eachRendered(t)},e.prototype.updateTransform=function(t,e,n){var i=t.getData(),r=t.pipelineContext;if(!this._finished||r.large||r.progressiveRender)return{update:!0};var o=eO.reset(t,e,n);o.progress&&o.progress({start:0,end:i.count(),count:i.count()},i),this._lineDraw.updateLayout(),this._clearLayer(n)},e.prototype._updateLineDraw=function(t,e){var n=this._lineDraw,i=this._showEffect(e),r=!!e.get("polyline"),o=e.pipelineContext.large;return n&&i===this._hasEffet&&r===this._isPolyline&&o===this._isLargeDraw||(n&&n.remove(),n=this._lineDraw=o?new tO:new YA(r?i?$P:KP:i?qP:HA),this._hasEffet=i,this._isPolyline=r,this._isLargeDraw=o),this.group.add(n.group),n},e.prototype._showEffect=function(t){return!!t.get(["effect","show"])},e.prototype._clearLayer=function(t){var e=t.getZr();"svg"===e.painter.getType()||null==this._lastZlevel||e.painter.getLayer(this._lastZlevel).clear(!0)},e.prototype.remove=function(t,e){this._lineDraw&&this._lineDraw.remove(),this._lineDraw=null,this._clearLayer(e)},e.prototype.dispose=function(t,e){this.remove(t,e)},e.type="lines",e}(Og),iO="undefined"==typeof Uint32Array?Array:Uint32Array,rO="undefined"==typeof Float64Array?Array:Float64Array;function oO(t){var e=t.data;e&&e[0]&&e[0][0]&&e[0][0].coord&&(t.data=z(e,(function(t){var e={coords:[t[0].coord,t[1].coord]};return t[0].name&&(e.fromName=t[0].name),t[1].name&&(e.toName=t[1].name),D([e,t[0],t[1]])})))}var aO=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.visualStyleAccessPath="lineStyle",n.visualDrawType="stroke",n}return n(e,t),e.prototype.init=function(e){e.data=e.data||[],oO(e);var n=this._processFlatCoordsArray(e.data);this._flatCoords=n.flatCoords,this._flatCoordsOffset=n.flatCoordsOffset,n.flatCoords&&(e.data=new Float32Array(n.count)),t.prototype.init.apply(this,arguments)},e.prototype.mergeOption=function(e){if(oO(e),e.data){var n=this._processFlatCoordsArray(e.data);this._flatCoords=n.flatCoords,this._flatCoordsOffset=n.flatCoordsOffset,n.flatCoords&&(e.data=new Float32Array(n.count))}t.prototype.mergeOption.apply(this,arguments)},e.prototype.appendData=function(t){var e=this._processFlatCoordsArray(t.data);e.flatCoords&&(this._flatCoords?(this._flatCoords=vt(this._flatCoords,e.flatCoords),this._flatCoordsOffset=vt(this._flatCoordsOffset,e.flatCoordsOffset)):(this._flatCoords=e.flatCoords,this._flatCoordsOffset=e.flatCoordsOffset),t.data=new Float32Array(e.count)),this.getRawData().appendData(t.data)},e.prototype._getCoordsFromItemModel=function(t){var e=this.getData().getItemModel(t),n=e.option instanceof Array?e.option:e.getShallow("coords");return n},e.prototype.getLineCoordsCount=function(t){return this._flatCoordsOffset?this._flatCoordsOffset[2*t+1]:this._getCoordsFromItemModel(t).length},e.prototype.getLineCoords=function(t,e){if(this._flatCoordsOffset){for(var n=this._flatCoordsOffset[2*t],i=this._flatCoordsOffset[2*t+1],r=0;r ")})},e.prototype.preventIncremental=function(){return!!this.get(["effect","show"])},e.prototype.getProgressive=function(){var t=this.option.progressive;return null==t?this.option.large?1e4:this.get("progressive"):t},e.prototype.getProgressiveThreshold=function(){var t=this.option.progressiveThreshold;return null==t?this.option.large?2e4:this.get("progressiveThreshold"):t},e.prototype.getZLevelKey=function(){var t=this.getModel("effect"),e=t.get("trailLength");return this.getData().count()>this.getProgressiveThreshold()?this.id:t.get("show")&&e>0?e+"":""},e.type="series.lines",e.dependencies=["grid","polar","geo","calendar"],e.defaultOption={coordinateSystem:"geo",z:2,legendHoverLink:!0,xAxisIndex:0,yAxisIndex:0,symbol:["none","none"],symbolSize:[10,10],geoIndex:0,effect:{show:!1,period:4,constantSpeed:0,symbol:"circle",symbolSize:3,loop:!0,trailLength:.2},large:!1,largeThreshold:2e3,polyline:!1,clip:!0,label:{show:!1,position:"end"},lineStyle:{opacity:.5}},e}(bg);function sO(t){return t instanceof Array||(t=[t,t]),t}var lO={seriesType:"lines",reset:function(t){var e=sO(t.get("symbol")),n=sO(t.get("symbolSize")),i=t.getData();return i.setVisual("fromSymbol",e&&e[0]),i.setVisual("toSymbol",e&&e[1]),i.setVisual("fromSymbolSize",n&&n[0]),i.setVisual("toSymbolSize",n&&n[1]),{dataEach:i.hasItemOption?function(t,e){var n=t.getItemModel(e),i=sO(n.getShallow("symbol",!0)),r=sO(n.getShallow("symbolSize",!0));i[0]&&t.setItemVisual(e,"fromSymbol",i[0]),i[1]&&t.setItemVisual(e,"toSymbol",i[1]),r[0]&&t.setItemVisual(e,"fromSymbolSize",r[0]),r[1]&&t.setItemVisual(e,"toSymbolSize",r[1])}:null}}};var uO=function(){function t(){this.blurSize=30,this.pointSize=20,this.maxOpacity=1,this.minOpacity=0,this._gradientPixels={inRange:null,outOfRange:null};var t=h.createCanvas();this.canvas=t}return t.prototype.update=function(t,e,n,i,r,o){var a=this._getBrush(),s=this._getGradient(r,"inRange"),l=this._getGradient(r,"outOfRange"),u=this.pointSize+this.blurSize,h=this.canvas,c=h.getContext("2d"),p=t.length;h.width=e,h.height=n;for(var d=0;d0){var I=o(v)?s:l;v>0&&(v=v*S+w),x[_++]=I[M],x[_++]=I[M+1],x[_++]=I[M+2],x[_++]=I[M+3]*v*256}else _+=4}return c.putImageData(m,0,0),h},t.prototype._getBrush=function(){var t=this._brushCanvas||(this._brushCanvas=h.createCanvas()),e=this.pointSize+this.blurSize,n=2*e;t.width=n,t.height=n;var i=t.getContext("2d");return i.clearRect(0,0,n,n),i.shadowOffsetX=n,i.shadowBlur=this.blurSize,i.shadowColor="#000",i.beginPath(),i.arc(-e,e,this.pointSize,0,2*Math.PI,!0),i.closePath(),i.fill(),t},t.prototype._getGradient=function(t,e){for(var n=this._gradientPixels,i=n[e]||(n[e]=new Uint8ClampedArray(1024)),r=[0,0,0,0],o=0,a=0;a<256;a++)t[e](a/255,!0,r),i[o++]=r[0],i[o++]=r[1],i[o++]=r[2],i[o++]=r[3];return i},t}();function hO(t){var e=t.dimensions;return"lng"===e[0]&&"lat"===e[1]}var cO=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){var i;e.eachComponent("visualMap",(function(e){e.eachTargetSeries((function(n){n===t&&(i=e)}))})),this._progressiveEls=null,this.group.removeAll();var r=t.coordinateSystem;"cartesian2d"===r.type||"calendar"===r.type?this._renderOnCartesianAndCalendar(t,n,0,t.getData().count()):hO(r)&&this._renderOnGeo(r,t,i,n)},e.prototype.incrementalPrepareRender=function(t,e,n){this.group.removeAll()},e.prototype.incrementalRender=function(t,e,n,i){var r=e.coordinateSystem;r&&(hO(r)?this.render(e,n,i):(this._progressiveEls=[],this._renderOnCartesianAndCalendar(e,i,t.start,t.end,!0)))},e.prototype.eachRendered=function(t){Jh(this._progressiveEls||this.group,t)},e.prototype._renderOnCartesianAndCalendar=function(t,e,n,i,r){var o,a,s,l,u=t.coordinateSystem,h=OS(u,"cartesian2d");if(h){var c=u.getAxis("x"),p=u.getAxis("y");0,o=c.getBandWidth()+.5,a=p.getBandWidth()+.5,s=c.scale.getExtent(),l=p.scale.getExtent()}for(var d=this.group,f=t.getData(),g=t.getModel(["emphasis","itemStyle"]).getItemStyle(),y=t.getModel(["blur","itemStyle"]).getItemStyle(),v=t.getModel(["select","itemStyle"]).getItemStyle(),m=t.get(["itemStyle","borderRadius"]),x=rc(t),_=t.getModel("emphasis"),b=_.get("focus"),w=_.get("blurScope"),S=_.get("disabled"),M=h?[f.mapDimension("x"),f.mapDimension("y"),f.mapDimension("value")]:[f.mapDimension("time"),f.mapDimension("value")],I=n;Is[1]||Al[1])continue;var k=u.dataToPoint([D,A]);T=new Ws({shape:{x:k[0]-o/2,y:k[1]-a/2,width:o,height:a},style:C})}else{if(isNaN(f.get(M[1],I)))continue;T=new Ws({z2:1,shape:u.dataToRect([f.get(M[0],I)]).contentShape,style:C})}if(f.hasItemOption){var L=f.getItemModel(I),P=L.getModel("emphasis");g=P.getModel("itemStyle").getItemStyle(),y=L.getModel(["blur","itemStyle"]).getItemStyle(),v=L.getModel(["select","itemStyle"]).getItemStyle(),m=L.get(["itemStyle","borderRadius"]),b=P.get("focus"),w=P.get("blurScope"),S=P.get("disabled"),x=rc(L)}T.shape.r=m;var O=t.getRawValue(I),R="-";O&&null!=O[2]&&(R=O[2]+""),ic(T,x,{labelFetcher:t,labelDataIndex:I,defaultOpacity:C.opacity,defaultText:R}),T.ensureState("emphasis").style=g,T.ensureState("blur").style=y,T.ensureState("select").style=v,Zl(T,b,w,S),T.incremental=r,r&&(T.states.emphasis.hoverLayer=!0),d.add(T),f.setItemGraphicEl(I,T),this._progressiveEls&&this._progressiveEls.push(T)}},e.prototype._renderOnGeo=function(t,e,n,i){var r=n.targetVisuals.inRange,o=n.targetVisuals.outOfRange,a=e.getData(),s=this._hmLayer||this._hmLayer||new uO;s.blurSize=e.get("blurSize"),s.pointSize=e.get("pointSize"),s.minOpacity=e.get("minOpacity"),s.maxOpacity=e.get("maxOpacity");var l=t.getViewRect().clone(),u=t.getRoamTransform();l.applyTransform(u);var h=Math.max(l.x,0),c=Math.max(l.y,0),p=Math.min(l.width+l.x,i.getWidth()),d=Math.min(l.height+l.y,i.getHeight()),f=p-h,g=d-c,y=[a.mapDimension("lng"),a.mapDimension("lat"),a.mapDimension("value")],v=a.mapArray(y,(function(e,n,i){var r=t.dataToPoint([e,n]);return r[0]-=h,r[1]-=c,r.push(i),r})),m=n.getExtent(),x="visualMap.continuous"===n.type?function(t,e){var n=t[1]-t[0];return e=[(e[0]-t[0])/n,(e[1]-t[0])/n],function(t){return t>=e[0]&&t<=e[1]}}(m,n.option.range):function(t,e,n){var i=t[1]-t[0],r=(e=z(e,(function(e){return{interval:[(e.interval[0]-t[0])/i,(e.interval[1]-t[0])/i]}}))).length,o=0;return function(t){var i;for(i=o;i=0;i--){var a;if((a=e[i].interval)[0]<=t&&t<=a[1]){o=i;break}}return i>=0&&i0?1:-1}(n,o,r,i,c),function(t,e,n,i,r,o,a,s,l,u){var h,c=l.valueDim,p=l.categoryDim,d=Math.abs(n[p.wh]),f=t.getItemVisual(e,"symbolSize");h=Y(f)?f.slice():null==f?["100%","100%"]:[f,f];h[p.index]=$r(h[p.index],d),h[c.index]=$r(h[c.index],i?d:Math.abs(o)),u.symbolSize=h;var g=u.symbolScale=[h[0]/s,h[1]/s];g[c.index]*=(l.isHorizontal?-1:1)*a}(t,e,r,o,0,c.boundingLength,c.pxSign,u,i,c),function(t,e,n,i,r){var o=t.get(dO)||0;o&&(gO.attr({scaleX:e[0],scaleY:e[1],rotation:n}),gO.updateTransform(),o/=gO.getLineScale(),o*=e[i.valueDim.index]);r.valueLineWidth=o||0}(n,c.symbolScale,l,i,c);var p=c.symbolSize,d=Zy(n.get("symbolOffset"),p);return function(t,e,n,i,r,o,a,s,l,u,h,c){var p=h.categoryDim,d=h.valueDim,f=c.pxSign,g=Math.max(e[d.index]+s,0),y=g;if(i){var v=Math.abs(l),m=it(t.get("symbolMargin"),"15%")+"",x=!1;m.lastIndexOf("!")===m.length-1&&(x=!0,m=m.slice(0,m.length-1));var _=$r(m,e[d.index]),b=Math.max(g+2*_,0),w=x?0:2*_,S=vo(i),M=S?i:OO((v+w)/b);b=g+2*(_=(v-M*g)/2/(x?M:Math.max(M-1,1))),w=x?0:2*_,S||"fixed"===i||(M=u?OO((Math.abs(u)+w)/b):0),y=M*b-w,c.repeatTimes=M,c.symbolMargin=_}var I=f*(y/2),T=c.pathPosition=[];T[p.index]=n[p.wh]/2,T[d.index]="start"===a?I:"end"===a?l-I:l/2,o&&(T[0]+=o[0],T[1]+=o[1]);var C=c.bundlePosition=[];C[p.index]=n[p.xy],C[d.index]=n[d.xy];var D=c.barRectShape=A({},n);D[d.wh]=f*Math.max(Math.abs(n[d.wh]),Math.abs(T[d.index]+I)),D[p.wh]=n[p.wh];var k=c.clipShape={};k[p.xy]=-n[p.xy],k[p.wh]=h.ecSize[p.wh],k[d.xy]=0,k[d.wh]=n[d.wh]}(n,p,r,o,0,d,s,c.valueLineWidth,c.boundingLength,c.repeatCutLength,i,c),c}function mO(t,e){return t.toGlobalCoord(t.dataToCoord(t.scale.parse(e)))}function xO(t){var e=t.symbolPatternSize,n=Xy(t.symbolType,-e/2,-e/2,e,e);return n.attr({culling:!0}),"image"!==n.type&&n.setStyle({strokeNoScale:!0}),n}function _O(t,e,n,i){var r=t.__pictorialBundle,o=n.symbolSize,a=n.valueLineWidth,s=n.pathPosition,l=e.valueDim,u=n.repeatTimes||0,h=0,c=o[e.valueDim.index]+a+2*n.symbolMargin;for(kO(t,(function(t){t.__pictorialAnimationIndex=h,t.__pictorialRepeatTimes=u,h0:i<0)&&(r=u-1-t),e[l.index]=c*(r-u/2+.5)+s[l.index],{x:e[0],y:e[1],scaleX:n.symbolScale[0],scaleY:n.symbolScale[1],rotation:n.rotation}}}function bO(t,e,n,i){var r=t.__pictorialBundle,o=t.__pictorialMainPath;o?LO(o,null,{x:n.pathPosition[0],y:n.pathPosition[1],scaleX:n.symbolScale[0],scaleY:n.symbolScale[1],rotation:n.rotation},n,i):(o=t.__pictorialMainPath=xO(n),r.add(o),LO(o,{x:n.pathPosition[0],y:n.pathPosition[1],scaleX:0,scaleY:0,rotation:n.rotation},{scaleX:n.symbolScale[0],scaleY:n.symbolScale[1]},n,i))}function wO(t,e,n){var i=A({},e.barRectShape),r=t.__pictorialBarRect;r?LO(r,null,{shape:i},e,n):((r=t.__pictorialBarRect=new Ws({z2:2,shape:i,silent:!0,style:{stroke:"transparent",fill:"transparent",lineWidth:0}})).disableMorphing=!0,t.add(r))}function SO(t,e,n,i){if(n.symbolClip){var r=t.__pictorialClipPath,o=A({},n.clipShape),a=e.valueDim,s=n.animationModel,l=n.dataIndex;if(r)vh(r,{shape:o},s,l);else{o[a.wh]=0,r=new Ws({shape:o}),t.__pictorialBundle.setClipPath(r),t.__pictorialClipPath=r;var u={};u[a.wh]=n.clipShape[a.wh],Qh[i?"updateProps":"initProps"](r,{shape:u},s,l)}}}function MO(t,e){var n=t.getItemModel(e);return n.getAnimationDelayParams=IO,n.isAnimationEnabled=TO,n}function IO(t){return{index:t.__pictorialAnimationIndex,count:t.__pictorialRepeatTimes}}function TO(){return this.parentModel.isAnimationEnabled()&&!!this.getShallow("animation")}function CO(t,e,n,i){var r=new Br,o=new Br;return r.add(o),r.__pictorialBundle=o,o.x=n.bundlePosition[0],o.y=n.bundlePosition[1],n.symbolRepeat?_O(r,e,n):bO(r,0,n),wO(r,n,i),SO(r,e,n,i),r.__pictorialShapeStr=AO(t,n),r.__pictorialSymbolMeta=n,r}function DO(t,e,n,i){var r=i.__pictorialBarRect;r&&r.removeTextContent();var o=[];kO(i,(function(t){o.push(t)})),i.__pictorialMainPath&&o.push(i.__pictorialMainPath),i.__pictorialClipPath&&(n=null),E(o,(function(t){_h(t,{scaleX:0,scaleY:0},n,e,(function(){i.parent&&i.parent.remove(i)}))})),t.setItemGraphicEl(e,null)}function AO(t,e){return[t.getItemVisual(e.dataIndex,"symbol")||"none",!!e.symbolRepeat,!!e.symbolClip].join(":")}function kO(t,e,n){E(t.__pictorialBundle.children(),(function(i){i!==t.__pictorialBarRect&&e.call(n,i)}))}function LO(t,e,n,i,r,o){e&&t.attr(e),i.symbolClip&&!r?n&&t.attr(n):n&&Qh[r?"updateProps":"initProps"](t,n,i.animationModel,i.dataIndex,o)}function PO(t,e,n){var i=n.dataIndex,r=n.itemModel,o=r.getModel("emphasis"),a=o.getModel("itemStyle").getItemStyle(),s=r.getModel(["blur","itemStyle"]).getItemStyle(),l=r.getModel(["select","itemStyle"]).getItemStyle(),u=r.getShallow("cursor"),h=o.get("focus"),c=o.get("blurScope"),p=o.get("scale");kO(t,(function(t){if(t instanceof Ns){var e=t.style;t.useStyle(A({image:e.image,x:e.x,y:e.y,width:e.width,height:e.height},n.style))}else t.useStyle(n.style);var i=t.ensureState("emphasis");i.style=a,p&&(i.scaleX=1.1*t.scaleX,i.scaleY=1.1*t.scaleY),t.ensureState("blur").style=s,t.ensureState("select").style=l,u&&(t.cursor=u),t.z2=n.z2}));var d=e.valueDim.posDesc[+(n.boundingLength>0)],f=t.__pictorialBarRect;f.ignoreClip=!0,ic(f,rc(r),{labelFetcher:e.seriesModel,labelDataIndex:i,defaultText:pS(e.seriesModel.getData(),i),inheritColor:n.style.fill,defaultOpacity:n.style.opacity,defaultOutsidePosition:d}),Zl(t,h,c,o.get("disabled"))}function OO(t){var e=Math.round(t);return Math.abs(t-e)<1e-4?e:Math.ceil(t)}var RO=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.hasSymbolVisual=!0,n.defaultSymbol="roundRect",n}return n(e,t),e.prototype.getInitialData=function(e){return e.stack=null,t.prototype.getInitialData.apply(this,arguments)},e.type="series.pictorialBar",e.dependencies=["grid"],e.defaultOption=kc(qS.defaultOption,{symbol:"circle",symbolSize:null,symbolRotate:null,symbolPosition:null,symbolOffset:null,symbolMargin:null,symbolRepeat:!1,symbolRepeatDirection:"end",symbolClip:!1,symbolBoundingData:null,symbolPatternSize:400,barGap:"-100%",clip:!1,progressive:0,emphasis:{scale:!1},select:{itemStyle:{borderColor:"#212121"}}}),e}(qS);var NO=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n._layers=[],n}return n(e,t),e.prototype.render=function(t,e,n){var i=t.getData(),r=this,o=this.group,a=t.getLayerSeries(),s=i.getLayout("layoutInfo"),l=s.rect,u=s.boundaryGap;function h(t){return t.name}o.x=0,o.y=l.y+u[0];var c=new Gm(this._layersSeries||[],a,h,h),p=[];function d(e,n,s){var l=r._layers;if("remove"!==e){for(var u,h,c=[],d=[],f=a[n].indices,g=0;go&&(o=s),i.push(s)}for(var u=0;uo&&(o=c)}return{y0:r,max:o}}(l),h=u.y0,c=n/u.max,p=o.length,d=o[0].indices.length,f=0;fI&&!so(C-I)&&C0?(r.virtualPiece?r.virtualPiece.updateData(!1,i,t,e,n):(r.virtualPiece=new BO(i,t,e,n),l.add(r.virtualPiece)),o.piece.off("click"),r.virtualPiece.on("click",(function(t){r._rootToNode(o.parentNode)}))):r.virtualPiece&&(l.remove(r.virtualPiece),r.virtualPiece=null)}(a,s),this._initEvents(),this._oldChildren=h},e.prototype._initEvents=function(){var t=this;this.group.off("click"),this.group.on("click",(function(e){var n=!1;t.seriesModel.getViewRoot().eachNode((function(i){if(!n&&i.piece&&i.piece===e.target){var r=i.getModel().get("nodeClick");if("rootToNode"===r)t._rootToNode(i);else if("link"===r){var o=i.getModel(),a=o.get("link");if(a)Mp(a,o.get("target",!0)||"_blank")}n=!0}}))}))},e.prototype._rootToNode=function(t){t!==this.seriesModel.getViewRoot()&&this.api.dispatchAction({type:FO,from:this.uid,seriesId:this.seriesModel.id,targetNode:t})},e.prototype.containPoint=function(t,e){var n=e.getData().getItemLayout(0);if(n){var i=t[0]-n.cx,r=t[1]-n.cy,o=Math.sqrt(i*i+r*r);return o<=n.r&&o>=n.r0}},e.type="sunburst",e}(Og),HO=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.ignoreStyleOnData=!0,n}return n(e,t),e.prototype.getInitialData=function(t,e){var n={name:t.name,children:t.data};YO(n);var i=this._levelModels=z(t.levels||[],(function(t){return new Cc(t,this,e)}),this),r=nD.createTree(n,this,(function(t){t.wrapMethod("getItemModel",(function(t,e){var n=r.getNodeByDataIndex(e),o=i[n.depth];return o&&(t.parentModel=o),t}))}));return r.data},e.prototype.optionUpdated=function(){this.resetViewRoot()},e.prototype.getDataParams=function(e){var n=t.prototype.getDataParams.apply(this,arguments),i=this.getData().tree.getNodeByDataIndex(e);return n.treePathInfo=aD(i,this),n},e.prototype.getLevelModel=function(t){return this._levelModels&&this._levelModels[t.depth]},e.prototype.getViewRoot=function(){return this._viewRoot},e.prototype.resetViewRoot=function(t){t?this._viewRoot=t:t=this._viewRoot;var e=this.getRawData().tree.root;t&&(t===e||e.contains(t))||(this._viewRoot=e)},e.prototype.enableAriaDecal=function(){pD(this)},e.type="series.sunburst",e.defaultOption={z:2,center:["50%","50%"],radius:[0,"75%"],clockwise:!0,startAngle:90,minAngle:0,stillShowZeroSum:!0,nodeClick:"rootToNode",renderLabelForZeroData:!1,label:{rotate:"radial",show:!0,opacity:1,align:"center",position:"inside",distance:5,silent:!0},itemStyle:{borderWidth:1,borderColor:"white",borderType:"solid",shadowBlur:0,shadowColor:"rgba(0, 0, 0, 0.2)",shadowOffsetX:0,shadowOffsetY:0,opacity:1},emphasis:{focus:"descendant"},blur:{itemStyle:{opacity:.2},label:{opacity:.1}},animationType:"expansion",animationDuration:1e3,animationDurationUpdate:500,data:[],sort:"desc"},e}(bg);function YO(t){var e=0;E(t.children,(function(t){YO(t);var n=t.value;Y(n)&&(n=n[0]),e+=n}));var n=t.value;Y(n)&&(n=n[0]),(null==n||isNaN(n))&&(n=e),n<0&&(n=0),Y(t.value)?t.value[0]=n:t.value=n}var XO=Math.PI/180;function UO(t,e,n){e.eachSeriesByType(t,(function(t){var e=t.get("center"),i=t.get("radius");Y(i)||(i=[0,i]),Y(e)||(e=[e,e]);var r=n.getWidth(),o=n.getHeight(),a=Math.min(r,o),s=$r(e[0],r),l=$r(e[1],o),u=$r(i[0],a/2),h=$r(i[1],a/2),c=-t.get("startAngle")*XO,p=t.get("minAngle")*XO,d=t.getData().tree.root,f=t.getViewRoot(),g=f.depth,y=t.get("sort");null!=y&&ZO(f,y);var v=0;E(f.children,(function(t){!isNaN(t.getValue())&&v++}));var m=f.getValue(),x=Math.PI/(m||v)*2,_=f.depth>0,b=f.height-(_?-1:1),w=(h-u)/(b||1),S=t.get("clockwise"),M=t.get("stillShowZeroSum"),I=S?1:-1,T=function(e,n){if(e){var i=n;if(e!==d){var r=e.getValue(),o=0===m&&M?x:r*x;o1;)r=r.parentNode;var o=n.getColorFromPalette(r.name||r.dataIndex+"",e);return t.depth>1&&U(o)&&(o=$n(o,(t.depth-1)/(i-1)*.5)),o}(r,t,i.root.height)),A(n.ensureUniqueItemVisual(r.dataIndex,"style"),o)}))}))}var qO={color:"fill",borderColor:"stroke"},KO={symbol:1,symbolSize:1,symbolKeepAspect:1,legendIcon:1,visualMeta:1,liftZ:1,decal:1},$O=Vo(),JO=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.optionUpdated=function(){this.currentZLevel=this.get("zlevel",!0),this.currentZ=this.get("z",!0)},e.prototype.getInitialData=function(t,e){return _x(null,this)},e.prototype.getDataParams=function(e,n,i){var r=t.prototype.getDataParams.call(this,e,n);return i&&(r.info=$O(i).info),r},e.type="series.custom",e.dependencies=["grid","polar","geo","singleAxis","calendar"],e.defaultOption={coordinateSystem:"cartesian2d",z:2,legendHoverLink:!0,clip:!1},e}(bg);function QO(t,e){return e=e||[0,0],z(["x","y"],(function(n,i){var r=this.getAxis(n),o=e[i],a=t[i]/2;return"category"===r.type?r.getBandWidth():Math.abs(r.dataToCoord(o-a)-r.dataToCoord(o+a))}),this)}function tR(t,e){return e=e||[0,0],z([0,1],(function(n){var i=e[n],r=t[n]/2,o=[],a=[];return o[n]=i-r,a[n]=i+r,o[1-n]=a[1-n]=e[1-n],Math.abs(this.dataToPoint(o)[n]-this.dataToPoint(a)[n])}),this)}function eR(t,e){var n=this.getAxis(),i=e instanceof Array?e[0]:e,r=(t instanceof Array?t[0]:t)/2;return"category"===n.type?n.getBandWidth():Math.abs(n.dataToCoord(i-r)-n.dataToCoord(i+r))}function nR(t,e){return e=e||[0,0],z(["Radius","Angle"],(function(n,i){var r=this["get"+n+"Axis"](),o=e[i],a=t[i]/2,s="category"===r.type?r.getBandWidth():Math.abs(r.dataToCoord(o-a)-r.dataToCoord(o+a));return"Angle"===n&&(s=s*Math.PI/180),s}),this)}function iR(t,e,n,i){return t&&(t.legacy||!1!==t.legacy&&!n&&!i&&"tspan"!==e&&("text"===e||_t(t,"text")))}function rR(t,e,n){var i,r,o,a=t;if("text"===e)o=a;else{o={},_t(a,"text")&&(o.text=a.text),_t(a,"rich")&&(o.rich=a.rich),_t(a,"textFill")&&(o.fill=a.textFill),_t(a,"textStroke")&&(o.stroke=a.textStroke),_t(a,"fontFamily")&&(o.fontFamily=a.fontFamily),_t(a,"fontSize")&&(o.fontSize=a.fontSize),_t(a,"fontStyle")&&(o.fontStyle=a.fontStyle),_t(a,"fontWeight")&&(o.fontWeight=a.fontWeight),r={type:"text",style:o,silent:!0},i={};var s=_t(a,"textPosition");n?i.position=s?a.textPosition:"inside":s&&(i.position=a.textPosition),_t(a,"textPosition")&&(i.position=a.textPosition),_t(a,"textOffset")&&(i.offset=a.textOffset),_t(a,"textRotation")&&(i.rotation=a.textRotation),_t(a,"textDistance")&&(i.distance=a.textDistance)}return oR(o,t),E(o.rich,(function(t){oR(t,t)})),{textConfig:i,textContent:r}}function oR(t,e){e&&(e.font=e.textFont||e.font,_t(e,"textStrokeWidth")&&(t.lineWidth=e.textStrokeWidth),_t(e,"textAlign")&&(t.align=e.textAlign),_t(e,"textVerticalAlign")&&(t.verticalAlign=e.textVerticalAlign),_t(e,"textLineHeight")&&(t.lineHeight=e.textLineHeight),_t(e,"textWidth")&&(t.width=e.textWidth),_t(e,"textHeight")&&(t.height=e.textHeight),_t(e,"textBackgroundColor")&&(t.backgroundColor=e.textBackgroundColor),_t(e,"textPadding")&&(t.padding=e.textPadding),_t(e,"textBorderColor")&&(t.borderColor=e.textBorderColor),_t(e,"textBorderWidth")&&(t.borderWidth=e.textBorderWidth),_t(e,"textBorderRadius")&&(t.borderRadius=e.textBorderRadius),_t(e,"textBoxShadowColor")&&(t.shadowColor=e.textBoxShadowColor),_t(e,"textBoxShadowBlur")&&(t.shadowBlur=e.textBoxShadowBlur),_t(e,"textBoxShadowOffsetX")&&(t.shadowOffsetX=e.textBoxShadowOffsetX),_t(e,"textBoxShadowOffsetY")&&(t.shadowOffsetY=e.textBoxShadowOffsetY))}function aR(t,e,n){var i=t;i.textPosition=i.textPosition||n.position||"inside",null!=n.offset&&(i.textOffset=n.offset),null!=n.rotation&&(i.textRotation=n.rotation),null!=n.distance&&(i.textDistance=n.distance);var r=i.textPosition.indexOf("inside")>=0,o=t.fill||"#000";sR(i,e);var a=null==i.textFill;return r?a&&(i.textFill=n.insideFill||"#fff",!i.textStroke&&n.insideStroke&&(i.textStroke=n.insideStroke),!i.textStroke&&(i.textStroke=o),null==i.textStrokeWidth&&(i.textStrokeWidth=2)):(a&&(i.textFill=t.fill||n.outsideFill||"#000"),!i.textStroke&&n.outsideStroke&&(i.textStroke=n.outsideStroke)),i.text=e.text,i.rich=e.rich,E(e.rich,(function(t){sR(t,t)})),i}function sR(t,e){e&&(_t(e,"fill")&&(t.textFill=e.fill),_t(e,"stroke")&&(t.textStroke=e.fill),_t(e,"lineWidth")&&(t.textStrokeWidth=e.lineWidth),_t(e,"font")&&(t.font=e.font),_t(e,"fontStyle")&&(t.fontStyle=e.fontStyle),_t(e,"fontWeight")&&(t.fontWeight=e.fontWeight),_t(e,"fontSize")&&(t.fontSize=e.fontSize),_t(e,"fontFamily")&&(t.fontFamily=e.fontFamily),_t(e,"align")&&(t.textAlign=e.align),_t(e,"verticalAlign")&&(t.textVerticalAlign=e.verticalAlign),_t(e,"lineHeight")&&(t.textLineHeight=e.lineHeight),_t(e,"width")&&(t.textWidth=e.width),_t(e,"height")&&(t.textHeight=e.height),_t(e,"backgroundColor")&&(t.textBackgroundColor=e.backgroundColor),_t(e,"padding")&&(t.textPadding=e.padding),_t(e,"borderColor")&&(t.textBorderColor=e.borderColor),_t(e,"borderWidth")&&(t.textBorderWidth=e.borderWidth),_t(e,"borderRadius")&&(t.textBorderRadius=e.borderRadius),_t(e,"shadowColor")&&(t.textBoxShadowColor=e.shadowColor),_t(e,"shadowBlur")&&(t.textBoxShadowBlur=e.shadowBlur),_t(e,"shadowOffsetX")&&(t.textBoxShadowOffsetX=e.shadowOffsetX),_t(e,"shadowOffsetY")&&(t.textBoxShadowOffsetY=e.shadowOffsetY),_t(e,"textShadowColor")&&(t.textShadowColor=e.textShadowColor),_t(e,"textShadowBlur")&&(t.textShadowBlur=e.textShadowBlur),_t(e,"textShadowOffsetX")&&(t.textShadowOffsetX=e.textShadowOffsetX),_t(e,"textShadowOffsetY")&&(t.textShadowOffsetY=e.textShadowOffsetY))}var lR={position:["x","y"],scale:["scaleX","scaleY"],origin:["originX","originY"]},uR=G(lR),hR=(V(mr,(function(t,e){return t[e]=1,t}),{}),mr.join(", "),["","style","shape","extra"]),cR=Vo();function pR(t,e,n,i,r){var o=t+"Animation",a=gh(t,i,r)||{},s=cR(e).userDuring;return a.duration>0&&(a.during=s?W(xR,{el:e,userDuring:s}):null,a.setToFinal=!0,a.scope=t),A(a,n[o]),a}function dR(t,e,n,i){var r=(i=i||{}).dataIndex,o=i.isInit,a=i.clearStyle,s=n.isAnimationEnabled(),l=cR(t),u=e.style;l.userDuring=e.during;var h={},c={};if(function(t,e,n){for(var i=0;i=0)){var c=t.getAnimationStyleProps(),p=c?c.style:null;if(p){!r&&(r=i.style={});var d=G(n);for(u=0;u0&&t.animateFrom(p,d)}else!function(t,e,n,i,r){if(r){var o=pR("update",t,e,i,n);o.duration>0&&t.animateFrom(r,o)}}(t,e,r||0,n,h);fR(t,e),u?t.dirty():t.markRedraw()}function fR(t,e){for(var n=cR(t).leaveToProps,i=0;i=0){!o&&(o=i[t]={});var p=G(a);for(h=0;hi[1]&&i.reverse(),{coordSys:{type:"polar",cx:t.cx,cy:t.cy,r:i[1],r0:i[0]},api:{coord:function(i){var r=e.dataToRadius(i[0]),o=n.dataToAngle(i[1]),a=t.coordToPoint([r,o]);return a.push(r,o*Math.PI/180),a},size:W(nR,t)}}},calendar:function(t){var e=t.getRect(),n=t.getRangeInfo();return{coordSys:{type:"calendar",x:e.x,y:e.y,width:e.width,height:e.height,cellWidth:t.getCellWidth(),cellHeight:t.getCellHeight(),rangeInfo:{start:n.start,end:n.end,weeks:n.weeks,dayCount:n.allDay}},api:{coord:function(e,n){return t.dataToPoint(e,n)}}}}};function zR(t){return t instanceof ks}function VR(t){return t instanceof Da}var BR=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n,i){this._progressiveEls=null;var r=this._data,o=t.getData(),a=this.group,s=YR(t,o,e,n);r||a.removeAll(),o.diff(r).add((function(e){UR(n,null,e,s(e,i),t,a,o)})).remove((function(e){var n=r.getItemGraphicEl(e);n&&gR(n,$O(n).option,t)})).update((function(e,l){var u=r.getItemGraphicEl(l);UR(n,u,e,s(e,i),t,a,o)})).execute();var l=t.get("clip",!0)?PS(t.coordinateSystem,!1,t):null;l?a.setClipPath(l):a.removeClipPath(),this._data=o},e.prototype.incrementalPrepareRender=function(t,e,n){this.group.removeAll(),this._data=null},e.prototype.incrementalRender=function(t,e,n,i,r){var o=e.getData(),a=YR(e,o,n,i),s=this._progressiveEls=[];function l(t){t.isGroup||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}for(var u=t.start;u=0?e.getStore().get(r,n):void 0}var o=e.get(i.name,n),a=i&&i.ordinalMeta;return a?a.categories[o]:o},styleEmphasis:function(n,i){0;null==i&&(i=s);var r=m(i,CR).getItemStyle(),o=x(i,CR),a=oc(o,null,null,!0,!0);a.text=o.getShallow("show")?ot(t.getFormattedLabel(i,CR),t.getFormattedLabel(i,DR),pS(e,i)):null;var l=ac(o,null,!0);return b(n,r),r=aR(r,a,l),n&&_(r,n),r.legacy=!0,r},visual:function(t,n){if(null==n&&(n=s),_t(qO,t)){var i=e.getItemVisual(n,"style");return i?i[qO[t]]:null}if(_t(KO,t))return e.getItemVisual(n,t)},barLayout:function(t){if("cartesian2d"===o.type){return function(t){var e=[],n=t.axis,i="axis0";if("category"===n.type){for(var r=n.getBandWidth(),o=0;o=c;f--){var g=e.childAt(f);JR(e,g,r)}}(t,c,n,i,r),a>=0?o.replaceAt(c,a):o.add(c),c}function jR(t,e,n){var i,r=$O(t),o=e.type,a=e.shape,s=e.style;return n.isUniversalTransitionEnabled()||null!=o&&o!==r.customGraphicType||"path"===o&&((i=a)&&(_t(i,"pathData")||_t(i,"d")))&&nN(a)!==r.customPathData||"image"===o&&_t(s,"image")&&s.image!==r.customImagePath}function qR(t,e,n){var i=e?KR(t,e):t,r=e?$R(t,i,CR):t.style,o=t.type,a=i?i.textConfig:null,s=t.textContent,l=s?e?KR(s,e):s:null;if(r&&(n.isLegacy||iR(r,o,!!a,!!l))){n.isLegacy=!0;var u=rR(r,o,!e);!a&&u.textConfig&&(a=u.textConfig),!l&&u.textContent&&(l=u.textContent)}if(!e&&l){var h=l;!h.type&&(h.type="text")}var c=e?n[e]:n.normal;c.cfg=a,c.conOpt=l}function KR(t,e){return e?t?t[e]:null:t}function $R(t,e,n){var i=e&&e.style;return null==i&&n===CR&&t&&(i=t.styleEmphasis),i}function JR(t,e,n){e&&gR(e,$O(t).option,n)}function QR(t,e){var n=t&&t.name;return null!=n?n:"e\0\0"+e}function tN(t,e){var n=this.context,i=null!=t?n.newChildren[t]:null,r=null!=e?n.oldChildren[e]:null;ZR(n.api,r,n.dataIndex,i,n.seriesModel,n.group)}function eN(t){var e=this.context,n=e.oldChildren[t];n&&gR(n,$O(n).option,e.seriesModel)}function nN(t){return t&&(t.pathData||t.d)}var iN=Vo(),rN=T,oN=W,aN=function(){function t(){this._dragging=!1,this.animationThreshold=15}return t.prototype.render=function(t,e,n,i){var r=e.get("value"),o=e.get("status");if(this._axisModel=t,this._axisPointerModel=e,this._api=n,i||this._lastValue!==r||this._lastStatus!==o){this._lastValue=r,this._lastStatus=o;var a=this._group,s=this._handle;if(!o||"hide"===o)return a&&a.hide(),void(s&&s.hide());a&&a.show(),s&&s.show();var l={};this.makeElOption(l,r,t,e,n);var u=l.graphicKey;u!==this._lastGraphicKey&&this.clear(n),this._lastGraphicKey=u;var h=this._moveAnimation=this.determineAnimation(t,e);if(a){var c=H(sN,e,h);this.updatePointerEl(a,l,c),this.updateLabelEl(a,l,c,e)}else a=this._group=new Br,this.createPointerEl(a,l,t,e),this.createLabelEl(a,l,t,e),n.getZr().add(a);cN(a,e,!0),this._renderHandle(r)}},t.prototype.remove=function(t){this.clear(t)},t.prototype.dispose=function(t){this.clear(t)},t.prototype.determineAnimation=function(t,e){var n=e.get("animation"),i=t.axis,r="category"===i.type,o=e.get("snap");if(!o&&!r)return!1;if("auto"===n||null==n){var a=this.animationThreshold;if(r&&i.getBandWidth()>a)return!0;if(o){var s=wI(t).seriesDataCount,l=i.getExtent();return Math.abs(l[0]-l[1])/s>a}return!1}return!0===n},t.prototype.makeElOption=function(t,e,n,i,r){},t.prototype.createPointerEl=function(t,e,n,i){var r=e.pointer;if(r){var o=iN(t).pointerEl=new Qh[r.type](rN(e.pointer));t.add(o)}},t.prototype.createLabelEl=function(t,e,n,i){if(e.label){var r=iN(t).labelEl=new Xs(rN(e.label));t.add(r),uN(r,i)}},t.prototype.updatePointerEl=function(t,e,n){var i=iN(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),n(i,{shape:e.pointer.shape}))},t.prototype.updateLabelEl=function(t,e,n,i){var r=iN(t).labelEl;r&&(r.setStyle(e.label.style),n(r,{x:e.label.x,y:e.label.y}),uN(r,i))},t.prototype._renderHandle=function(t){if(!this._dragging&&this.updateHandleTransform){var e,n=this._axisPointerModel,i=this._api.getZr(),r=this._handle,o=n.getModel("handle"),a=n.get("status");if(!o.get("show")||!a||"hide"===a)return r&&i.remove(r),void(this._handle=null);this._handle||(e=!0,r=this._handle=Uh(o.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(t){de(t.event)},onmousedown:oN(this._onHandleDragMove,this,0,0),drift:oN(this._onHandleDragMove,this),ondragend:oN(this._onHandleDragEnd,this)}),i.add(r)),cN(r,n,!1),r.setStyle(o.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var s=o.get("size");Y(s)||(s=[s,s]),r.scaleX=s[0]/2,r.scaleY=s[1]/2,Hg(this,"_doDispatchAxisPointer",o.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,e)}},t.prototype._moveHandleToValue=function(t,e){sN(this._axisPointerModel,!e&&this._moveAnimation,this._handle,hN(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},t.prototype._onHandleDragMove=function(t,e){var n=this._handle;if(n){this._dragging=!0;var i=this.updateHandleTransform(hN(n),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,n.stopAnimation(),n.attr(hN(i)),iN(n).lastProp=null,this._doDispatchAxisPointer()}},t.prototype._doDispatchAxisPointer=function(){if(this._handle){var t=this._payloadInfo,e=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:t.cursorPoint[0],y:t.cursorPoint[1],tooltipOption:t.tooltipOption,axesInfo:[{axisDim:e.axis.dim,axisIndex:e.componentIndex}]})}},t.prototype._onHandleDragEnd=function(){if(this._dragging=!1,this._handle){var t=this._axisPointerModel.get("value");this._moveHandleToValue(t),this._api.dispatchAction({type:"hideTip"})}},t.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),n=this._group,i=this._handle;e&&n&&(this._lastGraphicKey=null,n&&e.remove(n),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),Yg(this,"_doDispatchAxisPointer")},t.prototype.doClear=function(){},t.prototype.buildLabel=function(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}},t}();function sN(t,e,n,i){lN(iN(n).lastProp,i)||(iN(n).lastProp=i,e?vh(n,i,t):(n.stopAnimation(),n.attr(i)))}function lN(t,e){if(q(t)&&q(e)){var n=!0;return E(e,(function(e,i){n=n&&lN(t[i],e)})),!!n}return t===e}function uN(t,e){t[e.get(["label","show"])?"show":"hide"]()}function hN(t){return{x:t.x||0,y:t.y||0,rotation:t.rotation||0}}function cN(t,e,n){var i=e.get("z"),r=e.get("zlevel");t&&t.traverse((function(t){"group"!==t.type&&(null!=i&&(t.z=i),null!=r&&(t.zlevel=r),t.silent=n)}))}function pN(t){var e,n=t.get("type"),i=t.getModel(n+"Style");return"line"===n?(e=i.getLineStyle()).fill=null:"shadow"===n&&((e=i.getAreaStyle()).stroke=null),e}function dN(t,e,n,i,r){var o=fN(n.get("value"),e.axis,e.ecModel,n.get("seriesDataIndices"),{precision:n.get(["label","precision"]),formatter:n.get(["label","formatter"])}),a=n.getModel("label"),s=vp(a.get("padding")||0),l=a.getFont(),u=Sr(o,l),h=r.position,c=u.width+s[1]+s[3],p=u.height+s[0]+s[2],d=r.align;"right"===d&&(h[0]-=c),"center"===d&&(h[0]-=c/2);var f=r.verticalAlign;"bottom"===f&&(h[1]-=p),"middle"===f&&(h[1]-=p/2),function(t,e,n,i){var r=i.getWidth(),o=i.getHeight();t[0]=Math.min(t[0]+e,r)-e,t[1]=Math.min(t[1]+n,o)-n,t[0]=Math.max(t[0],0),t[1]=Math.max(t[1],0)}(h,c,p,i);var g=a.get("backgroundColor");g&&"auto"!==g||(g=e.get(["axisLine","lineStyle","color"])),t.label={x:h[0],y:h[1],style:oc(a,{text:o,font:l,fill:a.getTextColor(),padding:s,backgroundColor:g}),z2:10}}function fN(t,e,n,i,r){t=e.scale.parse(t);var o=e.scale.getLabel({value:t},{precision:r.precision}),a=r.formatter;if(a){var s={value:S_(e,{value:t}),axisDimension:e.dim,axisIndex:e.index,seriesData:[]};E(i,(function(t){var e=n.getSeriesByIndex(t.seriesIndex),i=t.dataIndexInside,r=e&&e.getDataParams(i);r&&s.seriesData.push(r)})),U(a)?o=a.replace("{value}",o):X(a)&&(o=a(s))}return o}function gN(t,e,n){var i=[1,0,0,1,0,0];return Se(i,i,n.rotation),we(i,i,n.position),Fh([t.dataToCoord(e),(n.labelOffset||0)+(n.labelDirection||1)*(n.labelMargin||0)],i)}function yN(t,e,n,i,r,o){var a=dI.innerTextLayout(n.rotation,0,n.labelDirection);n.labelMargin=r.get(["label","margin"]),dN(e,i,r,o,{position:gN(i.axis,t,n),align:a.textAlign,verticalAlign:a.textVerticalAlign})}function vN(t,e,n){return{x1:t[n=n||0],y1:t[1-n],x2:e[n],y2:e[1-n]}}function mN(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}}function xN(t,e,n,i,r,o){return{cx:t,cy:e,r0:n,r:i,startAngle:r,endAngle:o,clockwise:!0}}var _N=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.grid,s=i.get("type"),l=bN(a,o).getOtherAxis(o).getGlobalExtent(),u=o.toGlobalCoord(o.dataToCoord(e,!0));if(s&&"none"!==s){var h=pN(i),c=wN[s](o,u,l);c.style=h,t.graphicKey=c.type,t.pointer=c}yN(e,t,iI(a.model,n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=iI(e.axis.grid.model,e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=gN(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.grid,a=r.getGlobalExtent(!0),s=bN(o,r).getOtherAxis(r).getGlobalExtent(),l="x"===r.dim?0:1,u=[t.x,t.y];u[l]+=e[l],u[l]=Math.min(a[1],u[l]),u[l]=Math.max(a[0],u[l]);var h=(s[1]+s[0])/2,c=[h,h];c[l]=u[l];return{x:u[0],y:u[1],rotation:t.rotation,cursorPoint:c,tooltipOption:[{verticalAlign:"middle"},{align:"center"}][l]}},e}(aN);function bN(t,e){var n={};return n[e.dim+"AxisIndex"]=e.index,t.getCartesian(n)}var wN={line:function(t,e,n){return{type:"Line",subPixelOptimize:!0,shape:vN([e,n[0]],[e,n[1]],SN(t))}},shadow:function(t,e,n){var i=Math.max(1,t.getBandWidth()),r=n[1]-n[0];return{type:"Rect",shape:mN([e-i/2,n[0]],[i,r],SN(t))}}};function SN(t){return"x"===t.dim?0:1}var MN=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="axisPointer",e.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:"#B9BEC9",width:1,type:"dashed"},shadowStyle:{color:"rgba(210,219,238,0.2)"},label:{show:!0,formatter:null,precision:"auto",margin:3,color:"#fff",padding:[5,7,5,7],backgroundColor:"auto",borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:"#333",shadowBlur:3,shadowColor:"#aaa",shadowOffsetX:0,shadowOffsetY:2,throttle:40}},e}(zp),IN=Vo(),TN=E;function CN(t,e,n){if(!r.node){var i=e.getZr();IN(i).records||(IN(i).records={}),function(t,e){if(IN(t).initialized)return;function n(n,i){t.on(n,(function(n){var r=function(t){var e={showTip:[],hideTip:[]},n=function(i){var r=e[i.type];r?r.push(i):(i.dispatchAction=n,t.dispatchAction(i))};return{dispatchAction:n,pendings:e}}(e);TN(IN(t).records,(function(t){t&&i(t,n,r.dispatchAction)})),function(t,e){var n,i=t.showTip.length,r=t.hideTip.length;i?n=t.showTip[i-1]:r&&(n=t.hideTip[r-1]);n&&(n.dispatchAction=null,e.dispatchAction(n))}(r.pendings,e)}))}IN(t).initialized=!0,n("click",H(AN,"click")),n("mousemove",H(AN,"mousemove")),n("globalout",DN)}(i,e),(IN(i).records[t]||(IN(i).records[t]={})).handler=n}}function DN(t,e,n){t.handler("leave",null,n)}function AN(t,e,n,i){e.handler(t,n,i)}function kN(t,e){if(!r.node){var n=e.getZr();(IN(n).records||{})[t]&&(IN(n).records[t]=null)}}var LN=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){var i=e.getComponent("tooltip"),r=t.get("triggerOn")||i&&i.get("triggerOn")||"mousemove|click";CN("axisPointer",n,(function(t,e,n){"none"!==r&&("leave"===t||r.indexOf(t)>=0)&&n({type:"updateAxisPointer",currTrigger:t,x:e&&e.offsetX,y:e&&e.offsetY})}))},e.prototype.remove=function(t,e){kN("axisPointer",e)},e.prototype.dispose=function(t,e){kN("axisPointer",e)},e.type="axisPointer",e}(Ag);function PN(t,e){var n,i=[],r=t.seriesIndex;if(null==r||!(n=e.getSeriesByIndex(r)))return{point:[]};var o=n.getData(),a=zo(o,t);if(null==a||a<0||Y(a))return{point:[]};var s=o.getItemGraphicEl(a),l=n.coordinateSystem;if(n.getTooltipPosition)i=n.getTooltipPosition(a)||[];else if(l&&l.dataToPoint)if(t.isStacked){var u=l.getBaseAxis(),h=l.getOtherAxis(u).dim,c=u.dim,p="x"===h||"radius"===h?1:0,d=o.mapDimension(c),f=[];f[p]=o.get(d,a),f[1-p]=o.get(o.getCalculationInfo("stackResultDimension"),a),i=l.dataToPoint(f)||[]}else i=l.dataToPoint(o.getValues(z(l.dimensions,(function(t){return o.mapDimension(t)})),a))||[];else if(s){var g=s.getBoundingRect().clone();g.applyTransform(s.transform),i=[g.x+g.width/2,g.y+g.height/2]}return{point:i,el:s}}var ON=Vo();function RN(t,e,n){var i=t.currTrigger,r=[t.x,t.y],o=t,a=t.dispatchAction||W(n.dispatchAction,n),s=e.getComponent("axisPointer").coordSysAxesInfo;if(s){BN(r)&&(r=PN({seriesIndex:o.seriesIndex,dataIndex:o.dataIndex},e).point);var l=BN(r),u=o.axesInfo,h=s.axesInfo,c="leave"===i||BN(r),p={},d={},f={list:[],map:{}},g={showPointer:H(EN,d),showTooltip:H(zN,f)};E(s.coordSysMap,(function(t,e){var n=l||t.containPoint(r);E(s.coordSysAxesInfo[e],(function(t,e){var i=t.axis,o=function(t,e){for(var n=0;n<(t||[]).length;n++){var i=t[n];if(e.axis.dim===i.axisDim&&e.axis.model.componentIndex===i.axisIndex)return i}}(u,t);if(!c&&n&&(!u||o)){var a=o&&o.value;null!=a||l||(a=i.pointToData(r)),null!=a&&NN(t,a,g,!1,p)}}))}));var y={};return E(h,(function(t,e){var n=t.linkGroup;n&&!d[e]&&E(n.axesInfo,(function(e,i){var r=d[i];if(e!==t&&r){var o=r.value;n.mapper&&(o=t.axis.scale.parse(n.mapper(o,VN(e),VN(t)))),y[t.key]=o}}))})),E(y,(function(t,e){NN(h[e],t,g,!0,p)})),function(t,e,n){var i=n.axesInfo=[];E(e,(function(e,n){var r=e.axisPointerModel.option,o=t[n];o?(!e.useHandle&&(r.status="show"),r.value=o.value,r.seriesDataIndices=(o.payloadBatch||[]).slice()):!e.useHandle&&(r.status="hide"),"show"===r.status&&i.push({axisDim:e.axis.dim,axisIndex:e.axis.model.componentIndex,value:r.value})}))}(d,h,p),function(t,e,n,i){if(BN(e)||!t.list.length)return void i({type:"hideTip"});var r=((t.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:e[0],y:e[1],tooltipOption:n.tooltipOption,position:n.position,dataIndexInside:r.dataIndexInside,dataIndex:r.dataIndex,seriesIndex:r.seriesIndex,dataByCoordSys:t.list})}(f,r,t,a),function(t,e,n){var i=n.getZr(),r="axisPointerLastHighlights",o=ON(i)[r]||{},a=ON(i)[r]={};E(t,(function(t,e){var n=t.axisPointerModel.option;"show"===n.status&&t.triggerEmphasis&&E(n.seriesDataIndices,(function(t){var e=t.seriesIndex+" | "+t.dataIndex;a[e]=t}))}));var s=[],l=[];E(o,(function(t,e){!a[e]&&l.push(t)})),E(a,(function(t,e){!o[e]&&s.push(t)})),l.length&&n.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&n.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}(h,0,n),p}}function NN(t,e,n,i,r){var o=t.axis;if(!o.scale.isBlank()&&o.containData(e))if(t.involveSeries){var a=function(t,e){var n=e.axis,i=n.dim,r=t,o=[],a=Number.MAX_VALUE,s=-1;return E(e.seriesModels,(function(e,l){var u,h,c=e.getData().mapDimensionsAll(i);if(e.getAxisTooltipData){var p=e.getAxisTooltipData(c,t,n);h=p.dataIndices,u=p.nestestValue}else{if(!(h=e.getData().indicesOfNearest(c[0],t,"category"===n.type?.5:null)).length)return;u=e.getData().get(c[0],h[0])}if(null!=u&&isFinite(u)){var d=t-u,f=Math.abs(d);f<=a&&((f=0&&s<0)&&(a=f,s=d,r=u,o.length=0),E(h,(function(t){o.push({seriesIndex:e.seriesIndex,dataIndexInside:t,dataIndex:e.getData().getRawIndex(t)})})))}})),{payloadBatch:o,snapToValue:r}}(e,t),s=a.payloadBatch,l=a.snapToValue;s[0]&&null==r.seriesIndex&&A(r,s[0]),!i&&t.snap&&o.containData(l)&&null!=l&&(e=l),n.showPointer(t,e,s),n.showTooltip(t,a,l)}else n.showPointer(t,e)}function EN(t,e,n,i){t[e.key]={value:n,payloadBatch:i}}function zN(t,e,n,i){var r=n.payloadBatch,o=e.axis,a=o.model,s=e.axisPointerModel;if(e.triggerTooltip&&r.length){var l=e.coordSys.model,u=MI(l),h=t.map[u];h||(h=t.map[u]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},t.list.push(h)),h.dataByAxis.push({axisDim:o.dim,axisIndex:a.componentIndex,axisType:a.type,axisId:a.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:r.slice()})}}function VN(t){var e=t.axis.model,n={},i=n.axisDim=t.axis.dim;return n.axisIndex=n[i+"AxisIndex"]=e.componentIndex,n.axisName=n[i+"AxisName"]=e.name,n.axisId=n[i+"AxisId"]=e.id,n}function BN(t){return!t||null==t[0]||isNaN(t[0])||null==t[1]||isNaN(t[1])}function FN(t){TI.registerAxisPointerClass("CartesianAxisPointer",_N),t.registerComponentModel(MN),t.registerComponentView(LN),t.registerPreprocessor((function(t){if(t){(!t.axisPointer||0===t.axisPointer.length)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!Y(e)&&(t.axisPointer.link=[e])}})),t.registerProcessor(t.PRIORITY.PROCESSOR.STATISTIC,(function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=xI(t,e)})),t.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},RN)}var GN=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis;"angle"===o.dim&&(this.animationThreshold=Math.PI/18);var a=o.polar,s=a.getOtherAxis(o).getExtent(),l=o.dataToCoord(e),u=i.get("type");if(u&&"none"!==u){var h=pN(i),c=WN[u](o,a,l,s);c.style=h,t.graphicKey=c.type,t.pointer=c}var p=function(t,e,n,i,r){var o=e.axis,a=o.dataToCoord(t),s=i.getAngleAxis().getExtent()[0];s=s/180*Math.PI;var l,u,h,c=i.getRadiusAxis().getExtent();if("radius"===o.dim){var p=[1,0,0,1,0,0];Se(p,p,s),we(p,p,[i.cx,i.cy]),l=Fh([a,-r],p);var d=e.getModel("axisLabel").get("rotate")||0,f=dI.innerTextLayout(s,d*Math.PI/180,-1);u=f.textAlign,h=f.textVerticalAlign}else{var g=c[1];l=i.coordToPoint([g+r,a]);var y=i.cx,v=i.cy;u=Math.abs(l[0]-y)/g<.3?"center":l[0]>y?"left":"right",h=Math.abs(l[1]-v)/g<.3?"middle":l[1]>v?"top":"bottom"}return{position:l,align:u,verticalAlign:h}}(e,n,0,a,i.get(["label","margin"]));dN(t,n,i,r,p)},e}(aN);var WN={line:function(t,e,n,i){return"angle"===t.dim?{type:"Line",shape:vN(e.coordToPoint([i[0],n]),e.coordToPoint([i[1],n]))}:{type:"Circle",shape:{cx:e.cx,cy:e.cy,r:n}}},shadow:function(t,e,n,i){var r=Math.max(1,t.getBandWidth()),o=Math.PI/180;return"angle"===t.dim?{type:"Sector",shape:xN(e.cx,e.cy,i[0],i[1],(-n-r/2)*o,(r/2-n)*o)}:{type:"Sector",shape:xN(e.cx,e.cy,n-r/2,n+r/2,0,2*Math.PI)}}},HN=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.findAxisModel=function(t){var e;return this.ecModel.eachComponent(t,(function(t){t.getCoordSysModel()===this&&(e=t)}),this),e},e.type="polar",e.dependencies=["radiusAxis","angleAxis"],e.defaultOption={z:0,center:["50%","50%"],radius:"80%"},e}(zp),YN=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.getCoordSysModel=function(){return this.getReferringComponents("polar",Wo).models[0]},e.type="polarAxis",e}(zp);R(YN,D_);var XN=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="angleAxis",e}(YN),UN=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="radiusAxis",e}(YN),ZN=function(t){function e(e,n){return t.call(this,"radius",e,n)||this}return n(e,t),e.prototype.pointToData=function(t,e){return this.polar.pointToData(t,e)["radius"===this.dim?0:1]},e}(ab);ZN.prototype.dataToRadius=ab.prototype.dataToCoord,ZN.prototype.radiusToData=ab.prototype.coordToData;var jN=Vo(),qN=function(t){function e(e,n){return t.call(this,"angle",e,n||[0,360])||this}return n(e,t),e.prototype.pointToData=function(t,e){return this.polar.pointToData(t,e)["radius"===this.dim?0:1]},e.prototype.calculateCategoryInterval=function(){var t=this,e=t.getLabelModel(),n=t.scale,i=n.getExtent(),r=n.count();if(i[1]-i[0]<1)return 0;var o=i[0],a=t.dataToCoord(o+1)-t.dataToCoord(o),s=Math.abs(a),l=Sr(null==o?"":o+"",e.getFont(),"center","top"),u=Math.max(l.height,7)/s;isNaN(u)&&(u=1/0);var h=Math.max(0,Math.floor(u)),c=jN(t.model),p=c.lastAutoInterval,d=c.lastTickCount;return null!=p&&null!=d&&Math.abs(p-h)<=1&&Math.abs(d-r)<=1&&p>h?h=p:(c.lastTickCount=r,c.lastAutoInterval=h),h},e}(ab);qN.prototype.dataToAngle=ab.prototype.dataToCoord,qN.prototype.angleToData=ab.prototype.coordToData;var KN=["radius","angle"],$N=function(){function t(t){this.dimensions=KN,this.type="polar",this.cx=0,this.cy=0,this._radiusAxis=new ZN,this._angleAxis=new qN,this.axisPointerEnabled=!0,this.name=t||"",this._radiusAxis.polar=this._angleAxis.polar=this}return t.prototype.containPoint=function(t){var e=this.pointToCoord(t);return this._radiusAxis.contain(e[0])&&this._angleAxis.contain(e[1])},t.prototype.containData=function(t){return this._radiusAxis.containData(t[0])&&this._angleAxis.containData(t[1])},t.prototype.getAxis=function(t){return this["_"+t+"Axis"]},t.prototype.getAxes=function(){return[this._radiusAxis,this._angleAxis]},t.prototype.getAxesByScale=function(t){var e=[],n=this._angleAxis,i=this._radiusAxis;return n.scale.type===t&&e.push(n),i.scale.type===t&&e.push(i),e},t.prototype.getAngleAxis=function(){return this._angleAxis},t.prototype.getRadiusAxis=function(){return this._radiusAxis},t.prototype.getOtherAxis=function(t){var e=this._angleAxis;return t===e?this._radiusAxis:e},t.prototype.getBaseAxis=function(){return this.getAxesByScale("ordinal")[0]||this.getAxesByScale("time")[0]||this.getAngleAxis()},t.prototype.getTooltipAxes=function(t){var e=null!=t&&"auto"!==t?this.getAxis(t):this.getBaseAxis();return{baseAxes:[e],otherAxes:[this.getOtherAxis(e)]}},t.prototype.dataToPoint=function(t,e){return this.coordToPoint([this._radiusAxis.dataToRadius(t[0],e),this._angleAxis.dataToAngle(t[1],e)])},t.prototype.pointToData=function(t,e){var n=this.pointToCoord(t);return[this._radiusAxis.radiusToData(n[0],e),this._angleAxis.angleToData(n[1],e)]},t.prototype.pointToCoord=function(t){var e=t[0]-this.cx,n=t[1]-this.cy,i=this.getAngleAxis(),r=i.getExtent(),o=Math.min(r[0],r[1]),a=Math.max(r[0],r[1]);i.inverse?o=a-360:a=o+360;var s=Math.sqrt(e*e+n*n);e/=s,n/=s;for(var l=Math.atan2(-n,e)/Math.PI*180,u=la;)l+=360*u;return[s,l]},t.prototype.coordToPoint=function(t){var e=t[0],n=t[1]/180*Math.PI;return[Math.cos(n)*e+this.cx,-Math.sin(n)*e+this.cy]},t.prototype.getArea=function(){var t=this.getAngleAxis(),e=this.getRadiusAxis().getExtent().slice();e[0]>e[1]&&e.reverse();var n=t.getExtent(),i=Math.PI/180;return{cx:this.cx,cy:this.cy,r0:e[0],r:e[1],startAngle:-n[0]*i,endAngle:-n[1]*i,clockwise:t.inverse,contain:function(t,e){var n=t-this.cx,i=e-this.cy,r=n*n+i*i-1e-4,o=this.r,a=this.r0;return r<=o*o&&r>=a*a}}},t.prototype.convertToPixel=function(t,e,n){return JN(e)===this?this.dataToPoint(n):null},t.prototype.convertFromPixel=function(t,e,n){return JN(e)===this?this.pointToData(n):null},t}();function JN(t){var e=t.seriesModel,n=t.polarModel;return n&&n.coordinateSystem||e&&e.coordinateSystem}function QN(t,e){var n=this,i=n.getAngleAxis(),r=n.getRadiusAxis();if(i.scale.setExtent(1/0,-1/0),r.scale.setExtent(1/0,-1/0),t.eachSeries((function(t){if(t.coordinateSystem===n){var e=t.getData();E(C_(e,"radius"),(function(t){r.scale.unionExtentFromData(e,t)})),E(C_(e,"angle"),(function(t){i.scale.unionExtentFromData(e,t)}))}})),__(i.scale,i.model),__(r.scale,r.model),"category"===i.type&&!i.onBand){var o=i.getExtent(),a=360/i.scale.count();i.inverse?o[1]+=a:o[1]-=a,i.setExtent(o[0],o[1])}}function tE(t,e){var n;if(t.type=e.get("type"),t.scale=b_(e),t.onBand=e.get("boundaryGap")&&"category"===t.type,t.inverse=e.get("inverse"),function(t){return"angleAxis"===t.mainType}(e)){t.inverse=t.inverse!==e.get("clockwise");var i=e.get("startAngle"),r=null!==(n=e.get("endAngle"))&&void 0!==n?n:i+(t.inverse?-360:360);t.setExtent(i,r)}e.axis=t,t.model=e}var eE={dimensions:KN,create:function(t,e){var n=[];return t.eachComponent("polar",(function(t,i){var r=new $N(i+"");r.update=QN;var o=r.getRadiusAxis(),a=r.getAngleAxis(),s=t.findAxisModel("radiusAxis"),l=t.findAxisModel("angleAxis");tE(o,s),tE(a,l),function(t,e,n){var i=e.get("center"),r=n.getWidth(),o=n.getHeight();t.cx=$r(i[0],r),t.cy=$r(i[1],o);var a=t.getRadiusAxis(),s=Math.min(r,o)/2,l=e.get("radius");null==l?l=[0,"100%"]:Y(l)||(l=[0,l]);var u=[$r(l[0],s),$r(l[1],s)];a.inverse?a.setExtent(u[1],u[0]):a.setExtent(u[0],u[1])}(r,t,e),n.push(r),t.coordinateSystem=r,r.model=t})),t.eachSeries((function(t){if("polar"===t.get("coordinateSystem")){var e=t.getReferringComponents("polar",Wo).models[0];0,t.coordinateSystem=e.coordinateSystem}})),n}},nE=["axisLine","axisLabel","axisTick","minorTick","splitLine","minorSplitLine","splitArea"];function iE(t,e,n){e[1]>e[0]&&(e=e.slice().reverse());var i=t.coordToPoint([e[0],n]),r=t.coordToPoint([e[1],n]);return{x1:i[0],y1:i[1],x2:r[0],y2:r[1]}}function rE(t){return t.getRadiusAxis().inverse?0:1}function oE(t){var e=t[0],n=t[t.length-1];e&&n&&Math.abs(Math.abs(e.coord-n.coord)-360)<1e-4&&t.pop()}var aE=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.axisPointerClass="PolarAxisPointer",n}return n(e,t),e.prototype.render=function(t,e){if(this.group.removeAll(),t.get("show")){var n=t.axis,i=n.polar,r=i.getRadiusAxis().getExtent(),o=n.getTicksCoords(),a=n.getMinorTicksCoords(),s=z(n.getViewLabels(),(function(t){t=T(t);var e=n.scale,i="ordinal"===e.type?e.getRawOrdinalNumber(t.tickValue):t.tickValue;return t.coord=n.dataToCoord(i),t}));oE(s),oE(o),E(nE,(function(e){!t.get([e,"show"])||n.scale.isBlank()&&"axisLine"!==e||sE[e](this.group,t,i,o,a,r,s)}),this)}},e.type="angleAxis",e}(TI),sE={axisLine:function(t,e,n,i,r,o){var a,s=e.getModel(["axisLine","lineStyle"]),l=n.getAngleAxis(),u=Math.PI/180,h=l.getExtent(),c=rE(n),p=c?0:1,d=360===Math.abs(h[1]-h[0])?"Circle":"Arc";(a=0===o[p]?new Qh[d]({shape:{cx:n.cx,cy:n.cy,r:o[c],startAngle:-h[0]*u,endAngle:-h[1]*u,clockwise:l.inverse},style:s.getLineStyle(),z2:1,silent:!0}):new Wu({shape:{cx:n.cx,cy:n.cy,r:o[c],r0:o[p]},style:s.getLineStyle(),z2:1,silent:!0})).style.fill=null,t.add(a)},axisTick:function(t,e,n,i,r,o){var a=e.getModel("axisTick"),s=(a.get("inside")?-1:1)*a.get("length"),l=o[rE(n)],u=z(i,(function(t){return new Ku({shape:iE(n,[l,l+s],t.coord)})}));t.add(Nh(u,{style:k(a.getModel("lineStyle").getLineStyle(),{stroke:e.get(["axisLine","lineStyle","color"])})}))},minorTick:function(t,e,n,i,r,o){if(r.length){for(var a=e.getModel("axisTick"),s=e.getModel("minorTick"),l=(a.get("inside")?-1:1)*s.get("length"),u=o[rE(n)],h=[],c=0;cf?"left":"right",v=Math.abs(d[1]-g)/p<.3?"middle":d[1]>g?"top":"bottom";if(s&&s[c]){var m=s[c];q(m)&&m.textStyle&&(a=new Cc(m.textStyle,l,l.ecModel))}var x=new Xs({silent:dI.isLabelSilent(e),style:oc(a,{x:d[0],y:d[1],fill:a.getTextColor()||e.get(["axisLine","lineStyle","color"]),text:i.formattedLabel,align:y,verticalAlign:v})});if(t.add(x),h){var _=dI.makeAxisEventDataBase(e);_.targetType="axisLabel",_.value=i.rawLabel,rl(x).eventData=_}}),this)},splitLine:function(t,e,n,i,r,o){var a=e.getModel("splitLine").getModel("lineStyle"),s=a.get("color"),l=0;s=s instanceof Array?s:[s];for(var u=[],h=0;h=0?"p":"n",C=b;m&&(i[s][I]||(i[s][I]={p:b,n:b}),C=i[s][I][T]);var D=void 0,A=void 0,k=void 0,L=void 0;if("radius"===c.dim){var P=c.dataToCoord(M)-b,O=o.dataToCoord(I);Math.abs(P)=L})}}}))}var gE={startAngle:90,clockwise:!0,splitNumber:12,axisLabel:{rotate:0}},yE={splitNumber:5},vE=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="polar",e}(Ag);function mE(t,e){e=e||{};var n=t.coordinateSystem,i=t.axis,r={},o=i.position,a=i.orient,s=n.getRect(),l=[s.x,s.x+s.width,s.y,s.y+s.height],u={horizontal:{top:l[2],bottom:l[3]},vertical:{left:l[0],right:l[1]}};r.position=["vertical"===a?u.vertical[o]:l[0],"horizontal"===a?u.horizontal[o]:l[3]];r.rotation=Math.PI/2*{horizontal:0,vertical:1}[a];r.labelDirection=r.tickDirection=r.nameDirection={top:-1,bottom:1,right:1,left:-1}[o],t.get(["axisTick","inside"])&&(r.tickDirection=-r.tickDirection),it(e.labelInside,t.get(["axisLabel","inside"]))&&(r.labelDirection=-r.labelDirection);var h=e.rotate;return null==h&&(h=t.get(["axisLabel","rotate"])),r.labelRotation="top"===o?-h:h,r.z2=1,r}var xE=["axisLine","axisTickLabel","axisName"],_E=["splitArea","splitLine"],bE=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.axisPointerClass="SingleAxisPointer",n}return n(e,t),e.prototype.render=function(e,n,i,r){var o=this.group;o.removeAll();var a=this._axisGroup;this._axisGroup=new Br;var s=mE(e),l=new dI(e,s);E(xE,l.add,l),o.add(this._axisGroup),o.add(l.getGroup()),E(_E,(function(t){e.get([t,"show"])&&wE[t](this,this.group,this._axisGroup,e)}),this),Hh(a,this._axisGroup,e),t.prototype.render.call(this,e,n,i,r)},e.prototype.remove=function(){AI(this)},e.type="singleAxis",e}(TI),wE={splitLine:function(t,e,n,i){var r=i.axis;if(!r.scale.isBlank()){var o=i.getModel("splitLine"),a=o.getModel("lineStyle"),s=a.get("color");s=s instanceof Array?s:[s];for(var l=a.get("width"),u=i.coordinateSystem.getRect(),h=r.isHorizontal(),c=[],p=0,d=r.getTicksCoords({tickModel:o}),f=[],g=[],y=0;y=e.y&&t[1]<=e.y+e.height:n.contain(n.toLocalCoord(t[1]))&&t[0]>=e.y&&t[0]<=e.y+e.height},t.prototype.pointToData=function(t){var e=this.getAxis();return[e.coordToData(e.toLocalCoord(t["horizontal"===e.orient?0:1]))]},t.prototype.dataToPoint=function(t){var e=this.getAxis(),n=this.getRect(),i=[],r="horizontal"===e.orient?0:1;return t instanceof Array&&(t=t[0]),i[r]=e.toGlobalCoord(e.dataToCoord(+t)),i[1-r]=0===r?n.y+n.height/2:n.x+n.width/2,i},t.prototype.convertToPixel=function(t,e,n){return CE(e)===this?this.dataToPoint(n):null},t.prototype.convertFromPixel=function(t,e,n){return CE(e)===this?this.pointToData(n):null},t}();function CE(t){var e=t.seriesModel,n=t.singleAxisModel;return n&&n.coordinateSystem||e&&e.coordinateSystem}var DE={create:function(t,e){var n=[];return t.eachComponent("singleAxis",(function(i,r){var o=new TE(i,t,e);o.name="single_"+r,o.resize(i,e),i.coordinateSystem=o,n.push(o)})),t.eachSeries((function(t){if("singleAxis"===t.get("coordinateSystem")){var e=t.getReferringComponents("singleAxis",Wo).models[0];t.coordinateSystem=e&&e.coordinateSystem}})),n},dimensions:IE},AE=["x","y"],kE=["width","height"],LE=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.coordinateSystem,s=RE(a,1-OE(o)),l=a.dataToPoint(e)[0],u=i.get("type");if(u&&"none"!==u){var h=pN(i),c=PE[u](o,l,s);c.style=h,t.graphicKey=c.type,t.pointer=c}yN(e,t,mE(n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=mE(e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=gN(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.coordinateSystem,a=OE(r),s=RE(o,a),l=[t.x,t.y];l[a]+=e[a],l[a]=Math.min(s[1],l[a]),l[a]=Math.max(s[0],l[a]);var u=RE(o,1-a),h=(u[1]+u[0])/2,c=[h,h];return c[a]=l[a],{x:l[0],y:l[1],rotation:t.rotation,cursorPoint:c,tooltipOption:{verticalAlign:"middle"}}},e}(aN),PE={line:function(t,e,n){return{type:"Line",subPixelOptimize:!0,shape:vN([e,n[0]],[e,n[1]],OE(t))}},shadow:function(t,e,n){var i=t.getBandWidth(),r=n[1]-n[0];return{type:"Rect",shape:mN([e-i/2,n[0]],[i,r],OE(t))}}};function OE(t){return t.isHorizontal()?0:1}function RE(t,e){var n=t.getRect();return[n[AE[e]],n[AE[e]]+n[kE[e]]]}var NE=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="single",e}(Ag);var EE=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(e,n,i){var r=Rp(e);t.prototype.init.apply(this,arguments),zE(e,r)},e.prototype.mergeOption=function(e){t.prototype.mergeOption.apply(this,arguments),zE(this.option,e)},e.prototype.getCellSize=function(){return this.option.cellSize},e.type="calendar",e.defaultOption={z:2,left:80,top:60,cellSize:20,orient:"horizontal",splitLine:{show:!0,lineStyle:{color:"#000",width:1,type:"solid"}},itemStyle:{color:"#fff",borderWidth:1,borderColor:"#ccc"},dayLabel:{show:!0,firstDay:0,position:"start",margin:"50%",color:"#000"},monthLabel:{show:!0,position:"start",margin:5,align:"center",formatter:null,color:"#000"},yearLabel:{show:!0,position:null,margin:30,formatter:null,color:"#ccc",fontFamily:"sans-serif",fontWeight:"bolder",fontSize:20}},e}(zp);function zE(t,e){var n,i=t.cellSize;1===(n=Y(i)?i:t.cellSize=[i,i]).length&&(n[1]=n[0]);var r=z([0,1],(function(t){return function(t,e){return null!=t[Cp[e][0]]||null!=t[Cp[e][1]]&&null!=t[Cp[e][2]]}(e,t)&&(n[t]="auto"),null!=n[t]&&"auto"!==n[t]}));Op(t,e,{type:"box",ignoreSize:r})}var VE=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){var i=this.group;i.removeAll();var r=t.coordinateSystem,o=r.getRangeInfo(),a=r.getOrient(),s=e.getLocaleModel();this._renderDayRect(t,o,i),this._renderLines(t,o,a,i),this._renderYearText(t,o,a,i),this._renderMonthText(t,s,a,i),this._renderWeekText(t,s,o,a,i)},e.prototype._renderDayRect=function(t,e,n){for(var i=t.coordinateSystem,r=t.getModel("itemStyle").getItemStyle(),o=i.getCellWidth(),a=i.getCellHeight(),s=e.start.time;s<=e.end.time;s=i.getNextNDay(s,1).time){var l=i.dataToRect([s],!1).tl,u=new Ws({shape:{x:l[0],y:l[1],width:o,height:a},cursor:"default",style:r});n.add(u)}},e.prototype._renderLines=function(t,e,n,i){var r=this,o=t.coordinateSystem,a=t.getModel(["splitLine","lineStyle"]).getLineStyle(),s=t.get(["splitLine","show"]),l=a.lineWidth;this._tlpoints=[],this._blpoints=[],this._firstDayOfMonth=[],this._firstDayPoints=[];for(var u=e.start,h=0;u.time<=e.end.time;h++){p(u.formatedDate),0===h&&(u=o.getDateInfo(e.start.y+"-"+e.start.m));var c=u.date;c.setMonth(c.getMonth()+1),u=o.getDateInfo(c)}function p(e){r._firstDayOfMonth.push(o.getDateInfo(e)),r._firstDayPoints.push(o.dataToRect([e],!1).tl);var l=r._getLinePointsOfOneWeek(t,e,n);r._tlpoints.push(l[0]),r._blpoints.push(l[l.length-1]),s&&r._drawSplitline(l,a,i)}p(o.getNextNDay(e.end.time,1).formatedDate),s&&this._drawSplitline(r._getEdgesPoints(r._tlpoints,l,n),a,i),s&&this._drawSplitline(r._getEdgesPoints(r._blpoints,l,n),a,i)},e.prototype._getEdgesPoints=function(t,e,n){var i=[t[0].slice(),t[t.length-1].slice()],r="horizontal"===n?0:1;return i[0][r]=i[0][r]-e/2,i[1][r]=i[1][r]+e/2,i},e.prototype._drawSplitline=function(t,e,n){var i=new Zu({z2:20,shape:{points:t},style:e});n.add(i)},e.prototype._getLinePointsOfOneWeek=function(t,e,n){for(var i=t.coordinateSystem,r=i.getDateInfo(e),o=[],a=0;a<7;a++){var s=i.getNextNDay(r.time,a),l=i.dataToRect([s.time],!1);o[2*s.day]=l.tl,o[2*s.day+1]=l["horizontal"===n?"bl":"tr"]}return o},e.prototype._formatterLabel=function(t,e){return U(t)&&t?(n=t,E(e,(function(t,e){n=n.replace("{"+e+"}",i?re(t):t)})),n):X(t)?t(e):e.nameMap;var n,i},e.prototype._yearTextPositionControl=function(t,e,n,i,r){var o=e[0],a=e[1],s=["center","bottom"];"bottom"===i?(a+=r,s=["center","top"]):"left"===i?o-=r:"right"===i?(o+=r,s=["center","top"]):a-=r;var l=0;return"left"!==i&&"right"!==i||(l=Math.PI/2),{rotation:l,x:o,y:a,style:{align:s[0],verticalAlign:s[1]}}},e.prototype._renderYearText=function(t,e,n,i){var r=t.getModel("yearLabel");if(r.get("show")){var o=r.get("margin"),a=r.get("position");a||(a="horizontal"!==n?"top":"left");var s=[this._tlpoints[this._tlpoints.length-1],this._blpoints[0]],l=(s[0][0]+s[1][0])/2,u=(s[0][1]+s[1][1])/2,h="horizontal"===n?0:1,c={top:[l,s[h][1]],bottom:[l,s[1-h][1]],left:[s[1-h][0],u],right:[s[h][0],u]},p=e.start.y;+e.end.y>+e.start.y&&(p=p+"-"+e.end.y);var d=r.get("formatter"),f={start:e.start.y,end:e.end.y,nameMap:p},g=this._formatterLabel(d,f),y=new Xs({z2:30,style:oc(r,{text:g})});y.attr(this._yearTextPositionControl(y,c[a],n,a,o)),i.add(y)}},e.prototype._monthTextPositionControl=function(t,e,n,i,r){var o="left",a="top",s=t[0],l=t[1];return"horizontal"===n?(l+=r,e&&(o="center"),"start"===i&&(a="bottom")):(s+=r,e&&(a="middle"),"start"===i&&(o="right")),{x:s,y:l,align:o,verticalAlign:a}},e.prototype._renderMonthText=function(t,e,n,i){var r=t.getModel("monthLabel");if(r.get("show")){var o=r.get("nameMap"),a=r.get("margin"),s=r.get("position"),l=r.get("align"),u=[this._tlpoints,this._blpoints];o&&!U(o)||(o&&(e=Vc(o)||e),o=e.get(["time","monthAbbr"])||[]);var h="start"===s?0:1,c="horizontal"===n?0:1;a="start"===s?-a:a;for(var p="center"===l,d=0;d=i.start.time&&n.timea.end.time&&t.reverse(),t},t.prototype._getRangeInfo=function(t){var e,n=[this.getDateInfo(t[0]),this.getDateInfo(t[1])];n[0].time>n[1].time&&(e=!0,n.reverse());var i=Math.floor(n[1].time/BE)-Math.floor(n[0].time/BE)+1,r=new Date(n[0].time),o=r.getDate(),a=n[1].date.getDate();r.setDate(o+i-1);var s=r.getDate();if(s!==a)for(var l=r.getTime()-n[1].time>0?1:-1;(s=r.getDate())!==a&&(r.getTime()-n[1].time)*l>0;)i-=l,r.setDate(s-l);var u=Math.floor((i+n[0].day+6)/7),h=e?1-u:u-1;return e&&n.reverse(),{range:[n[0].formatedDate,n[1].formatedDate],start:n[0],end:n[1],allDay:i,weeks:u,nthWeek:h,fweek:n[0].day,lweek:n[1].day}},t.prototype._getDateByWeeksAndDay=function(t,e,n){var i=this._getRangeInfo(n);if(t>i.weeks||0===t&&ei.lweek)return null;var r=7*(t-1)-i.fweek+e,o=new Date(i.start.time);return o.setDate(+i.start.d+r),this.getDateInfo(o)},t.create=function(e,n){var i=[];return e.eachComponent("calendar",(function(r){var o=new t(r,e,n);i.push(o),r.coordinateSystem=o})),e.eachSeries((function(t){"calendar"===t.get("coordinateSystem")&&(t.coordinateSystem=i[t.get("calendarIndex")||0])})),i},t.dimensions=["time","value"],t}();function GE(t){var e=t.calendarModel,n=t.seriesModel;return e?e.coordinateSystem:n?n.coordinateSystem:null}function WE(t,e){var n;return E(e,(function(e){null!=t[e]&&"auto"!==t[e]&&(n=!0)})),n}var HE=["transition","enterFrom","leaveTo"],YE=HE.concat(["enterAnimation","updateAnimation","leaveAnimation"]);function XE(t,e,n){if(n&&(!t[n]&&e[n]&&(t[n]={}),t=t[n],e=e[n]),t&&e)for(var i=n?HE:YE,r=0;r=0;l--){var p,d,f;if(f=null!=(d=Ro((p=n[l]).id,null))?r.get(d):null){var g=f.parent,y=(c=jE(g),{}),v=Lp(f,p,g===i?{width:o,height:a}:{width:c.width,height:c.height},null,{hv:p.hv,boundingMode:p.bounding},y);if(!jE(f).isNew&&v){for(var m=p.transition,x={},_=0;_=0)?x[b]=w:f[b]=w}vh(f,x,t,0)}else f.attr(y)}}},e.prototype._clear=function(){var t=this,e=this._elMap;e.each((function(n){JE(n,jE(n).option,e,t._lastGraphicModel)})),this._elMap=yt()},e.prototype.dispose=function(){this._clear()},e.type="graphic",e}(Ag);function KE(t){var e=_t(ZE,t)?ZE[t]:Lh(t);var n=new e({});return jE(n).type=t,n}function $E(t,e,n,i){var r=KE(n);return e.add(r),i.set(t,r),jE(r).id=t,jE(r).isNew=!0,r}function JE(t,e,n,i){t&&t.parent&&("group"===t.type&&t.traverse((function(t){JE(t,e,n,i)})),gR(t,e,i),n.removeKey(jE(t).id))}function QE(t,e,n,i){t.isGroup||E([["cursor",Da.prototype.cursor],["zlevel",i||0],["z",n||0],["z2",0]],(function(n){var i=n[0];_t(e,i)?t[i]=rt(e[i],n[1]):null==t[i]&&(t[i]=n[1])})),E(G(e),(function(n){if(0===n.indexOf("on")){var i=e[n];t[n]=X(i)?i:null}})),_t(e,"draggable")&&(t.draggable=e.draggable),null!=e.name&&(t.name=e.name),null!=e.id&&(t.id=e.id)}var tz=["x","y","radius","angle","single"],ez=["cartesian2d","polar","singleAxis"];function nz(t){return t+"Axis"}function iz(t,e){var n,i=yt(),r=[],o=yt();t.eachComponent({mainType:"dataZoom",query:e},(function(t){o.get(t.uid)||s(t)}));do{n=!1,t.eachComponent("dataZoom",a)}while(n);function a(t){!o.get(t.uid)&&function(t){var e=!1;return t.eachTargetAxis((function(t,n){var r=i.get(t);r&&r[n]&&(e=!0)})),e}(t)&&(s(t),n=!0)}function s(t){o.set(t.uid,!0),r.push(t),t.eachTargetAxis((function(t,e){(i.get(t)||i.set(t,[]))[e]=!0}))}return r}function rz(t){var e=t.ecModel,n={infoList:[],infoMap:yt()};return t.eachTargetAxis((function(t,i){var r=e.getComponent(nz(t),i);if(r){var o=r.getCoordSysModel();if(o){var a=o.uid,s=n.infoMap.get(a);s||(s={model:o,axisModels:[]},n.infoList.push(s),n.infoMap.set(a,s)),s.axisModels.push(r)}}})),n}var oz=function(){function t(){this.indexList=[],this.indexMap=[]}return t.prototype.add=function(t){this.indexMap[t]||(this.indexList.push(t),this.indexMap[t]=!0)},t}(),az=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n._autoThrottle=!0,n._noTarget=!0,n._rangePropMode=["percent","percent"],n}return n(e,t),e.prototype.init=function(t,e,n){var i=sz(t);this.settledOption=i,this.mergeDefaultAndTheme(t,n),this._doInit(i)},e.prototype.mergeOption=function(t){var e=sz(t);C(this.option,t,!0),C(this.settledOption,e,!0),this._doInit(e)},e.prototype._doInit=function(t){var e=this.option;this._setDefaultThrottle(t),this._updateRangeUse(t);var n=this.settledOption;E([["start","startValue"],["end","endValue"]],(function(t,i){"value"===this._rangePropMode[i]&&(e[t[0]]=n[t[0]]=null)}),this),this._resetTarget()},e.prototype._resetTarget=function(){var t=this.get("orient",!0),e=this._targetAxisInfoMap=yt();this._fillSpecifiedTargetAxis(e)?this._orient=t||this._makeAutoOrientByTargetAxis():(this._orient=t||"horizontal",this._fillAutoTargetAxisByOrient(e,this._orient)),this._noTarget=!0,e.each((function(t){t.indexList.length&&(this._noTarget=!1)}),this)},e.prototype._fillSpecifiedTargetAxis=function(t){var e=!1;return E(tz,(function(n){var i=this.getReferringComponents(nz(n),Ho);if(i.specified){e=!0;var r=new oz;E(i.models,(function(t){r.add(t.componentIndex)})),t.set(n,r)}}),this),e},e.prototype._fillAutoTargetAxisByOrient=function(t,e){var n=this.ecModel,i=!0;if(i){var r="vertical"===e?"y":"x";o(n.findComponents({mainType:r+"Axis"}),r)}i&&o(n.findComponents({mainType:"singleAxis",filter:function(t){return t.get("orient",!0)===e}}),"single");function o(e,n){var r=e[0];if(r){var o=new oz;if(o.add(r.componentIndex),t.set(n,o),i=!1,"x"===n||"y"===n){var a=r.getReferringComponents("grid",Wo).models[0];a&&E(e,(function(t){r.componentIndex!==t.componentIndex&&a===t.getReferringComponents("grid",Wo).models[0]&&o.add(t.componentIndex)}))}}}i&&E(tz,(function(e){if(i){var r=n.findComponents({mainType:nz(e),filter:function(t){return"category"===t.get("type",!0)}});if(r[0]){var o=new oz;o.add(r[0].componentIndex),t.set(e,o),i=!1}}}),this)},e.prototype._makeAutoOrientByTargetAxis=function(){var t;return this.eachTargetAxis((function(e){!t&&(t=e)}),this),"y"===t?"vertical":"horizontal"},e.prototype._setDefaultThrottle=function(t){if(t.hasOwnProperty("throttle")&&(this._autoThrottle=!1),this._autoThrottle){var e=this.ecModel.option;this.option.throttle=e.animation&&e.animationDurationUpdate>0?100:20}},e.prototype._updateRangeUse=function(t){var e=this._rangePropMode,n=this.get("rangeMode");E([["start","startValue"],["end","endValue"]],(function(i,r){var o=null!=t[i[0]],a=null!=t[i[1]];o&&!a?e[r]="percent":!o&&a?e[r]="value":n?e[r]=n[r]:o&&(e[r]="percent")}))},e.prototype.noTarget=function(){return this._noTarget},e.prototype.getFirstTargetAxisModel=function(){var t;return this.eachTargetAxis((function(e,n){null==t&&(t=this.ecModel.getComponent(nz(e),n))}),this),t},e.prototype.eachTargetAxis=function(t,e){this._targetAxisInfoMap.each((function(n,i){E(n.indexList,(function(n){t.call(e,i,n)}))}))},e.prototype.getAxisProxy=function(t,e){var n=this.getAxisModel(t,e);if(n)return n.__dzAxisProxy},e.prototype.getAxisModel=function(t,e){var n=this._targetAxisInfoMap.get(t);if(n&&n.indexMap[e])return this.ecModel.getComponent(nz(t),e)},e.prototype.setRawRange=function(t){var e=this.option,n=this.settledOption;E([["start","startValue"],["end","endValue"]],(function(i){null==t[i[0]]&&null==t[i[1]]||(e[i[0]]=n[i[0]]=t[i[0]],e[i[1]]=n[i[1]]=t[i[1]])}),this),this._updateRangeUse(t)},e.prototype.setCalculatedRange=function(t){var e=this.option;E(["start","startValue","end","endValue"],(function(n){e[n]=t[n]}))},e.prototype.getPercentRange=function(){var t=this.findRepresentativeAxisProxy();if(t)return t.getDataPercentWindow()},e.prototype.getValueRange=function(t,e){if(null!=t||null!=e)return this.getAxisProxy(t,e).getDataValueWindow();var n=this.findRepresentativeAxisProxy();return n?n.getDataValueWindow():void 0},e.prototype.findRepresentativeAxisProxy=function(t){if(t)return t.__dzAxisProxy;for(var e,n=this._targetAxisInfoMap.keys(),i=0;i=0}(e)){var n=nz(this._dimName),i=e.getReferringComponents(n,Wo).models[0];i&&this._axisIndex===i.componentIndex&&t.push(e)}}),this),t},t.prototype.getAxisModel=function(){return this.ecModel.getComponent(this._dimName+"Axis",this._axisIndex)},t.prototype.getMinMaxSpan=function(){return T(this._minMaxSpan)},t.prototype.calculateDataWindow=function(t){var e,n=this._dataExtent,i=this.getAxisModel().axis.scale,r=this._dataZoomModel.getRangePropMode(),o=[0,100],a=[],s=[];cz(["start","end"],(function(l,u){var h=t[l],c=t[l+"Value"];"percent"===r[u]?(null==h&&(h=o[u]),c=i.parse(Kr(h,o,n))):(e=!0,h=Kr(c=null==c?n[u]:i.parse(c),n,o)),s[u]=null==c||isNaN(c)?n[u]:c,a[u]=null==h||isNaN(h)?o[u]:h})),pz(s),pz(a);var l=this._minMaxSpan;function u(t,e,n,r,o){var a=o?"Span":"ValueSpan";zk(0,t,n,"all",l["min"+a],l["max"+a]);for(var s=0;s<2;s++)e[s]=Kr(t[s],n,r,!0),o&&(e[s]=i.parse(e[s]))}return e?u(s,a,n,o,!1):u(a,s,o,n,!0),{valueWindow:s,percentWindow:a}},t.prototype.reset=function(t){if(t===this._dataZoomModel){var e=this.getTargetSeriesModels();this._dataExtent=function(t,e,n){var i=[1/0,-1/0];cz(n,(function(t){!function(t,e,n){e&&E(C_(e,n),(function(n){var i=e.getApproximateExtent(n);i[0]t[1]&&(t[1]=i[1])}))}(i,t.getData(),e)}));var r=t.getAxisModel(),o=v_(r.axis.scale,r,i).calculate();return[o.min,o.max]}(this,this._dimName,e),this._updateMinMaxSpan();var n=this.calculateDataWindow(t.settledOption);this._valueWindow=n.valueWindow,this._percentWindow=n.percentWindow,this._setAxisModel()}},t.prototype.filterData=function(t,e){if(t===this._dataZoomModel){var n=this._dimName,i=this.getTargetSeriesModels(),r=t.get("filterMode"),o=this._valueWindow;"none"!==r&&cz(i,(function(t){var e=t.getData(),i=e.mapDimensionsAll(n);if(i.length){if("weakFilter"===r){var a=e.getStore(),s=z(i,(function(t){return e.getDimensionIndex(t)}),e);e.filterSelf((function(t){for(var e,n,r,l=0;lo[1];if(h&&!c&&!p)return!0;h&&(r=!0),c&&(e=!0),p&&(n=!0)}return r&&e&&n}))}else cz(i,(function(n){if("empty"===r)t.setData(e=e.map(n,(function(t){return function(t){return t>=o[0]&&t<=o[1]}(t)?t:NaN})));else{var i={};i[n]=o,e.selectRange(i)}}));cz(i,(function(t){e.setApproximateExtent(o,t)}))}}))}},t.prototype._updateMinMaxSpan=function(){var t=this._minMaxSpan={},e=this._dataZoomModel,n=this._dataExtent;cz(["min","max"],(function(i){var r=e.get(i+"Span"),o=e.get(i+"ValueSpan");null!=o&&(o=this.getAxisModel().axis.scale.parse(o)),null!=o?r=Kr(n[0]+o,n,[0,100],!0):null!=r&&(o=Kr(r,[0,100],n,!0)-n[0]),t[i+"Span"]=r,t[i+"ValueSpan"]=o}),this)},t.prototype._setAxisModel=function(){var t=this.getAxisModel(),e=this._percentWindow,n=this._valueWindow;if(e){var i=no(n,[0,500]);i=Math.min(i,20);var r=t.axis.scale.rawExtentInfo;0!==e[0]&&r.setDeterminedMinMax("min",+n[0].toFixed(i)),100!==e[1]&&r.setDeterminedMinMax("max",+n[1].toFixed(i)),r.freeze()}},t}();var fz={getTargetSeries:function(t){function e(e){t.eachComponent("dataZoom",(function(n){n.eachTargetAxis((function(i,r){var o=t.getComponent(nz(i),r);e(i,r,o,n)}))}))}e((function(t,e,n,i){n.__dzAxisProxy=null}));var n=[];e((function(e,i,r,o){r.__dzAxisProxy||(r.__dzAxisProxy=new dz(e,i,o,t),n.push(r.__dzAxisProxy))}));var i=yt();return E(n,(function(t){E(t.getTargetSeriesModels(),(function(t){i.set(t.uid,t)}))})),i},overallReset:function(t,e){t.eachComponent("dataZoom",(function(t){t.eachTargetAxis((function(e,n){t.getAxisProxy(e,n).reset(t)})),t.eachTargetAxis((function(n,i){t.getAxisProxy(n,i).filterData(t,e)}))})),t.eachComponent("dataZoom",(function(t){var e=t.findRepresentativeAxisProxy();if(e){var n=e.getDataPercentWindow(),i=e.getDataValueWindow();t.setCalculatedRange({start:n[0],end:n[1],startValue:i[0],endValue:i[1]})}}))}};var gz=!1;function yz(t){gz||(gz=!0,t.registerProcessor(t.PRIORITY.PROCESSOR.FILTER,fz),function(t){t.registerAction("dataZoom",(function(t,e){E(iz(e,t),(function(e){e.setRawRange({start:t.start,end:t.end,startValue:t.startValue,endValue:t.endValue})}))}))}(t),t.registerSubTypeDefaulter("dataZoom",(function(){return"slider"})))}function vz(t){t.registerComponentModel(lz),t.registerComponentView(hz),yz(t)}var mz=function(){},xz={};function _z(t,e){xz[t]=e}function bz(t){return xz[t]}var wz=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.optionUpdated=function(){t.prototype.optionUpdated.apply(this,arguments);var e=this.ecModel;E(this.option.feature,(function(t,n){var i=bz(n);i&&(i.getDefaultOption&&(i.defaultOption=i.getDefaultOption(e)),C(t,i.defaultOption))}))},e.type="toolbox",e.layoutMode={type:"box",ignoreSize:!0},e.defaultOption={show:!0,z:6,orient:"horizontal",left:"right",top:"top",backgroundColor:"transparent",borderColor:"#ccc",borderRadius:0,borderWidth:0,padding:5,itemSize:15,itemGap:8,showTitle:!0,iconStyle:{borderColor:"#666",color:"none"},emphasis:{iconStyle:{borderColor:"#3E98C5"}},tooltip:{show:!1,position:"bottom"}},e}(zp);function Sz(t,e){var n=vp(e.get("padding")),i=e.getItemStyle(["color","opacity"]);return i.fill=e.get("backgroundColor"),t=new Ws({shape:{x:t.x-n[3],y:t.y-n[0],width:t.width+n[1]+n[3],height:t.height+n[0]+n[2],r:e.get("borderRadius")},style:i,silent:!0,z2:-1})}var Mz=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.render=function(t,e,n,i){var r=this.group;if(r.removeAll(),t.get("show")){var o=+t.get("itemSize"),a="vertical"===t.get("orient"),s=t.get("feature")||{},l=this._features||(this._features={}),u=[];E(s,(function(t,e){u.push(e)})),new Gm(this._featureNames||[],u).add(h).update(h).remove(H(h,null)).execute(),this._featureNames=u,function(t,e,n){var i=e.getBoxLayoutParams(),r=e.get("padding"),o={width:n.getWidth(),height:n.getHeight()},a=kp(i,o,r);Ap(e.get("orient"),t,e.get("itemGap"),a.width,a.height),Lp(t,i,o,r)}(r,t,n),r.add(Sz(r.getBoundingRect(),t)),a||r.eachChild((function(t){var e=t.__title,i=t.ensureState("emphasis"),a=i.textConfig||(i.textConfig={}),s=t.getTextContent(),l=s&&s.ensureState("emphasis");if(l&&!X(l)&&e){var u=l.style||(l.style={}),h=Sr(e,Xs.makeFont(u)),c=t.x+r.x,p=!1;t.y+r.y+o+h.height>n.getHeight()&&(a.position="top",p=!0);var d=p?-5-h.height:o+10;c+h.width/2>n.getWidth()?(a.position=["100%",d],u.align="right"):c-h.width/2<0&&(a.position=[0,d],u.align="left")}}))}function h(h,c){var p,d=u[h],f=u[c],g=s[d],y=new Cc(g,t,t.ecModel);if(i&&null!=i.newTitle&&i.featureName===d&&(g.title=i.newTitle),d&&!f){if(function(t){return 0===t.indexOf("my")}(d))p={onclick:y.option.onclick,featureName:d};else{var v=bz(d);if(!v)return;p=new v}l[d]=p}else if(!(p=l[f]))return;p.uid=Ac("toolbox-feature"),p.model=y,p.ecModel=e,p.api=n;var m=p instanceof mz;d||!f?!y.get("show")||m&&p.unusable?m&&p.remove&&p.remove(e,n):(!function(i,s,l){var u,h,c=i.getModel("iconStyle"),p=i.getModel(["emphasis","iconStyle"]),d=s instanceof mz&&s.getIcons?s.getIcons():i.get("icon"),f=i.get("title")||{};U(d)?(u={})[l]=d:u=d;U(f)?(h={})[l]=f:h=f;var g=i.iconPaths={};E(u,(function(l,u){var d=Uh(l,{},{x:-o/2,y:-o/2,width:o,height:o});d.setStyle(c.getItemStyle()),d.ensureState("emphasis").style=p.getItemStyle();var f=new Xs({style:{text:h[u],align:p.get("textAlign"),borderRadius:p.get("textBorderRadius"),padding:p.get("textPadding"),fill:null,font:cc({fontStyle:p.get("textFontStyle"),fontFamily:p.get("textFontFamily"),fontSize:p.get("textFontSize"),fontWeight:p.get("textFontWeight")},e)},ignore:!0});d.setTextContent(f),Kh({el:d,componentModel:t,itemName:u,formatterParamsExtra:{title:h[u]}}),d.__title=h[u],d.on("mouseover",(function(){var e=p.getItemStyle(),i=a?null==t.get("right")&&"right"!==t.get("left")?"right":"left":null==t.get("bottom")&&"bottom"!==t.get("top")?"bottom":"top";f.setStyle({fill:p.get("textFill")||e.fill||e.stroke||"#000",backgroundColor:p.get("textBackgroundColor")}),d.setTextConfig({position:p.get("textPosition")||i}),f.ignore=!t.get("showTitle"),n.enterEmphasis(this)})).on("mouseout",(function(){"emphasis"!==i.get(["iconStatus",u])&&n.leaveEmphasis(this),f.hide()})),("emphasis"===i.get(["iconStatus",u])?Ol:Rl)(d),r.add(d),d.on("click",W(s.onclick,s,e,n,u)),g[u]=d}))}(y,p,d),y.setIconStatus=function(t,e){var n=this.option,i=this.iconPaths;n.iconStatus=n.iconStatus||{},n.iconStatus[t]=e,i[t]&&("emphasis"===e?Ol:Rl)(i[t])},p instanceof mz&&p.render&&p.render(y,e,n,i)):m&&p.dispose&&p.dispose(e,n)}},e.prototype.updateView=function(t,e,n,i){E(this._features,(function(t){t instanceof mz&&t.updateView&&t.updateView(t.model,e,n,i)}))},e.prototype.remove=function(t,e){E(this._features,(function(n){n instanceof mz&&n.remove&&n.remove(t,e)})),this.group.removeAll()},e.prototype.dispose=function(t,e){E(this._features,(function(n){n instanceof mz&&n.dispose&&n.dispose(t,e)}))},e.type="toolbox",e}(Ag);var Iz=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.onclick=function(t,e){var n=this.model,i=n.get("name")||t.get("title.0.text")||"echarts",o="svg"===e.getZr().painter.getType(),a=o?"svg":n.get("type",!0)||"png",s=e.getConnectedDataURL({type:a,backgroundColor:n.get("backgroundColor",!0)||t.get("backgroundColor")||"#fff",connectedBackgroundColor:n.get("connectedBackgroundColor"),excludeComponents:n.get("excludeComponents"),pixelRatio:n.get("pixelRatio")}),l=r.browser;if("function"!=typeof MouseEvent||!l.newEdge&&(l.ie||l.edge))if(window.navigator.msSaveOrOpenBlob||o){var u=s.split(","),h=u[0].indexOf("base64")>-1,c=o?decodeURIComponent(u[1]):u[1];h&&(c=window.atob(c));var p=i+"."+a;if(window.navigator.msSaveOrOpenBlob){for(var d=c.length,f=new Uint8Array(d);d--;)f[d]=c.charCodeAt(d);var g=new Blob([f]);window.navigator.msSaveOrOpenBlob(g,p)}else{var y=document.createElement("iframe");document.body.appendChild(y);var v=y.contentWindow,m=v.document;m.open("image/svg+xml","replace"),m.write(c),m.close(),v.focus(),m.execCommand("SaveAs",!0,p),document.body.removeChild(y)}}else{var x=n.get("lang"),_='',b=window.open();b.document.write(_),b.document.title=i}else{var w=document.createElement("a");w.download=i+"."+a,w.target="_blank",w.href=s;var S=new MouseEvent("click",{view:document.defaultView,bubbles:!0,cancelable:!1});w.dispatchEvent(S)}},e.getDefaultOption=function(t){return{show:!0,icon:"M4.7,22.9L29.3,45.5L54.7,23.4M4.6,43.6L4.6,58L53.8,58L53.8,43.6M29.2,45.1L29.2,0",title:t.getLocaleModel().get(["toolbox","saveAsImage","title"]),type:"png",connectedBackgroundColor:"#fff",name:"",excludeComponents:["toolbox"],lang:t.getLocaleModel().get(["toolbox","saveAsImage","lang"])}},e}(mz),Tz="__ec_magicType_stack__",Cz=[["line","bar"],["stack"]],Dz=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.getIcons=function(){var t=this.model,e=t.get("icon"),n={};return E(t.get("type"),(function(t){e[t]&&(n[t]=e[t])})),n},e.getDefaultOption=function(t){return{show:!0,type:[],icon:{line:"M4.1,28.9h7.1l9.3-22l7.4,38l9.7-19.7l3,12.8h14.9M4.1,58h51.4",bar:"M6.7,22.9h10V48h-10V22.9zM24.9,13h10v35h-10V13zM43.2,2h10v46h-10V2zM3.1,58h53.7",stack:"M8.2,38.4l-8.4,4.1l30.6,15.3L60,42.5l-8.1-4.1l-21.5,11L8.2,38.4z M51.9,30l-8.1,4.2l-13.4,6.9l-13.9-6.9L8.2,30l-8.4,4.2l8.4,4.2l22.2,11l21.5-11l8.1-4.2L51.9,30z M51.9,21.7l-8.1,4.2L35.7,30l-5.3,2.8L24.9,30l-8.4-4.1l-8.3-4.2l-8.4,4.2L8.2,30l8.3,4.2l13.9,6.9l13.4-6.9l8.1-4.2l8.1-4.1L51.9,21.7zM30.4,2.2L-0.2,17.5l8.4,4.1l8.3,4.2l8.4,4.2l5.5,2.7l5.3-2.7l8.1-4.2l8.1-4.2l8.1-4.1L30.4,2.2z"},title:t.getLocaleModel().get(["toolbox","magicType","title"]),option:{},seriesIndex:{}}},e.prototype.onclick=function(t,e,n){var i=this.model,r=i.get(["seriesIndex",n]);if(Az[n]){var o,a={series:[]};E(Cz,(function(t){P(t,n)>=0&&E(t,(function(t){i.setIconStatus(t,"normal")}))})),i.setIconStatus(n,"emphasis"),t.eachComponent({mainType:"series",query:null==r?null:{seriesIndex:r}},(function(t){var e=t.subType,r=t.id,o=Az[n](e,r,t,i);o&&(k(o,t.option),a.series.push(o));var s=t.coordinateSystem;if(s&&"cartesian2d"===s.type&&("line"===n||"bar"===n)){var l=s.getAxesByScale("ordinal")[0];if(l){var u=l.dim+"Axis",h=t.getReferringComponents(u,Wo).models[0].componentIndex;a[u]=a[u]||[];for(var c=0;c<=h;c++)a[u][h]=a[u][h]||{};a[u][h].boundaryGap="bar"===n}}}));var s=n;"stack"===n&&(o=C({stack:i.option.title.tiled,tiled:i.option.title.stack},i.option.title),"emphasis"!==i.get(["iconStatus",n])&&(s="tiled")),e.dispatchAction({type:"changeMagicType",currentType:s,newOption:a,newTitle:o,featureName:"magicType"})}},e}(mz),Az={line:function(t,e,n,i){if("bar"===t)return C({id:e,type:"line",data:n.get("data"),stack:n.get("stack"),markPoint:n.get("markPoint"),markLine:n.get("markLine")},i.get(["option","line"])||{},!0)},bar:function(t,e,n,i){if("line"===t)return C({id:e,type:"bar",data:n.get("data"),stack:n.get("stack"),markPoint:n.get("markPoint"),markLine:n.get("markLine")},i.get(["option","bar"])||{},!0)},stack:function(t,e,n,i){var r=n.get("stack")===Tz;if("line"===t||"bar"===t)return i.setIconStatus("stack",r?"normal":"emphasis"),C({id:e,stack:r?"":Tz},i.get(["option","stack"])||{},!0)}};Cm({type:"changeMagicType",event:"magicTypeChanged",update:"prepareAndUpdate"},(function(t,e){e.mergeOption(t.newOption)}));var kz=new Array(60).join("-"),Lz="\t";function Pz(t){return t.replace(/^\s\s*/,"").replace(/\s\s*$/,"")}var Oz=new RegExp("[\t]+","g");function Rz(t,e){var n=t.split(new RegExp("\n*"+kz+"\n*","g")),i={series:[]};return E(n,(function(t,n){if(function(t){if(t.slice(0,t.indexOf("\n")).indexOf(Lz)>=0)return!0}(t)){var r=function(t){for(var e=t.split(/\n+/g),n=[],i=z(Pz(e.shift()).split(Oz),(function(t){return{name:t,data:[]}})),r=0;r=0)&&t(r,i._targetInfoList)}))}return t.prototype.setOutputRanges=function(t,e){return this.matchOutputRanges(t,e,(function(t,e,n){if((t.coordRanges||(t.coordRanges=[])).push(e),!t.coordRange){t.coordRange=e;var i=jz[t.brushType](0,n,e);t.__rangeOffset={offset:Kz[t.brushType](i.values,t.range,[1,1]),xyMinMax:i.xyMinMax}}})),t},t.prototype.matchOutputRanges=function(t,e,n){E(t,(function(t){var i=this.findTargetInfo(t,e);i&&!0!==i&&E(i.coordSyses,(function(i){var r=jz[t.brushType](1,i,t.range,!0);n(t,r.values,i,e)}))}),this)},t.prototype.setInputRanges=function(t,e){E(t,(function(t){var n,i,r,o,a,s=this.findTargetInfo(t,e);if(t.range=t.range||[],s&&!0!==s){t.panelId=s.panelId;var l=jz[t.brushType](0,s.coordSys,t.coordRange),u=t.__rangeOffset;t.range=u?Kz[t.brushType](l.values,u.offset,(n=l.xyMinMax,i=u.xyMinMax,r=Jz(n),o=Jz(i),a=[r[0]/o[0],r[1]/o[1]],isNaN(a[0])&&(a[0]=1),isNaN(a[1])&&(a[1]=1),a)):l.values}}),this)},t.prototype.makePanelOpts=function(t,e){return z(this._targetInfoList,(function(n){var i=n.getPanelRect();return{panelId:n.panelId,defaultBrushType:e?e(n):null,clipPath:BL(i),isTargetByCursor:GL(i,t,n.coordSysModel),getLinearBrushOtherExtent:FL(i)}}))},t.prototype.controlSeries=function(t,e,n){var i=this.findTargetInfo(t,n);return!0===i||i&&P(i.coordSyses,e.coordinateSystem)>=0},t.prototype.findTargetInfo=function(t,e){for(var n=this._targetInfoList,i=Yz(e,t),r=0;rt[1]&&t.reverse(),t}function Yz(t,e){return Fo(t,e,{includeMainTypes:Gz})}var Xz={grid:function(t,e){var n=t.xAxisModels,i=t.yAxisModels,r=t.gridModels,o=yt(),a={},s={};(n||i||r)&&(E(n,(function(t){var e=t.axis.grid.model;o.set(e.id,e),a[e.id]=!0})),E(i,(function(t){var e=t.axis.grid.model;o.set(e.id,e),s[e.id]=!0})),E(r,(function(t){o.set(t.id,t),a[t.id]=!0,s[t.id]=!0})),o.each((function(t){var r=t.coordinateSystem,o=[];E(r.getCartesians(),(function(t,e){(P(n,t.getAxis("x").model)>=0||P(i,t.getAxis("y").model)>=0)&&o.push(t)})),e.push({panelId:"grid--"+t.id,gridModel:t,coordSysModel:t,coordSys:o[0],coordSyses:o,getPanelRect:Zz.grid,xAxisDeclared:a[t.id],yAxisDeclared:s[t.id]})})))},geo:function(t,e){E(t.geoModels,(function(t){var n=t.coordinateSystem;e.push({panelId:"geo--"+t.id,geoModel:t,coordSysModel:t,coordSys:n,coordSyses:[n],getPanelRect:Zz.geo})}))}},Uz=[function(t,e){var n=t.xAxisModel,i=t.yAxisModel,r=t.gridModel;return!r&&n&&(r=n.axis.grid.model),!r&&i&&(r=i.axis.grid.model),r&&r===e.gridModel},function(t,e){var n=t.geoModel;return n&&n===e.geoModel}],Zz={grid:function(){return this.coordSys.master.getRect().clone()},geo:function(){var t=this.coordSys,e=t.getBoundingRect().clone();return e.applyTransform(Bh(t)),e}},jz={lineX:H(qz,0),lineY:H(qz,1),rect:function(t,e,n,i){var r=t?e.pointToData([n[0][0],n[1][0]],i):e.dataToPoint([n[0][0],n[1][0]],i),o=t?e.pointToData([n[0][1],n[1][1]],i):e.dataToPoint([n[0][1],n[1][1]],i),a=[Hz([r[0],o[0]]),Hz([r[1],o[1]])];return{values:a,xyMinMax:a}},polygon:function(t,e,n,i){var r=[[1/0,-1/0],[1/0,-1/0]];return{values:z(n,(function(n){var o=t?e.pointToData(n,i):e.dataToPoint(n,i);return r[0][0]=Math.min(r[0][0],o[0]),r[1][0]=Math.min(r[1][0],o[1]),r[0][1]=Math.max(r[0][1],o[0]),r[1][1]=Math.max(r[1][1],o[1]),o})),xyMinMax:r}}};function qz(t,e,n,i){var r=n.getAxis(["x","y"][t]),o=Hz(z([0,1],(function(t){return e?r.coordToData(r.toLocalCoord(i[t]),!0):r.toGlobalCoord(r.dataToCoord(i[t]))}))),a=[];return a[t]=o,a[1-t]=[NaN,NaN],{values:o,xyMinMax:a}}var Kz={lineX:H($z,0),lineY:H($z,1),rect:function(t,e,n){return[[t[0][0]-n[0]*e[0][0],t[0][1]-n[0]*e[0][1]],[t[1][0]-n[1]*e[1][0],t[1][1]-n[1]*e[1][1]]]},polygon:function(t,e,n){return z(t,(function(t,i){return[t[0]-n[0]*e[i][0],t[1]-n[1]*e[i][1]]}))}};function $z(t,e,n,i){return[e[0]-i[t]*n[0],e[1]-i[t]*n[1]]}function Jz(t){return t?[t[0][1]-t[0][0],t[1][1]-t[1][0]]:[NaN,NaN]}var Qz,tV,eV=E,nV=Io+"toolbox-dataZoom_",iV=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.render=function(t,e,n,i){this._brushController||(this._brushController=new lL(n.getZr()),this._brushController.on("brush",W(this._onBrush,this)).mount()),function(t,e,n,i,r){var o=n._isZoomActive;i&&"takeGlobalCursor"===i.type&&(o="dataZoomSelect"===i.key&&i.dataZoomSelectActive);n._isZoomActive=o,t.setIconStatus("zoom",o?"emphasis":"normal");var a=new Wz(oV(t),e,{include:["grid"]}),s=a.makePanelOpts(r,(function(t){return t.xAxisDeclared&&!t.yAxisDeclared?"lineX":!t.xAxisDeclared&&t.yAxisDeclared?"lineY":"rect"}));n._brushController.setPanels(s).enableBrush(!(!o||!s.length)&&{brushType:"auto",brushStyle:t.getModel("brushStyle").getItemStyle()})}(t,e,this,i,n),function(t,e){t.setIconStatus("back",function(t){return Bz(t).length}(e)>1?"emphasis":"normal")}(t,e)},e.prototype.onclick=function(t,e,n){rV[n].call(this)},e.prototype.remove=function(t,e){this._brushController&&this._brushController.unmount()},e.prototype.dispose=function(t,e){this._brushController&&this._brushController.dispose()},e.prototype._onBrush=function(t){var e=t.areas;if(t.isEnd&&e.length){var n={},i=this.ecModel;this._brushController.updateCovers([]),new Wz(oV(this.model),i,{include:["grid"]}).matchOutputRanges(e,i,(function(t,e,n){if("cartesian2d"===n.type){var i=t.brushType;"rect"===i?(r("x",n,e[0]),r("y",n,e[1])):r({lineX:"x",lineY:"y"}[i],n,e)}})),function(t,e){var n=Bz(t);zz(e,(function(e,i){for(var r=n.length-1;r>=0&&!n[r][i];r--);if(r<0){var o=t.queryComponents({mainType:"dataZoom",subType:"select",id:i})[0];if(o){var a=o.getPercentRange();n[0][i]={dataZoomId:i,start:a[0],end:a[1]}}}})),n.push(e)}(i,n),this._dispatchZoomAction(n)}function r(t,e,r){var o=e.getAxis(t),a=o.model,s=function(t,e,n){var i;return n.eachComponent({mainType:"dataZoom",subType:"select"},(function(n){n.getAxisModel(t,e.componentIndex)&&(i=n)})),i}(t,a,i),l=s.findRepresentativeAxisProxy(a).getMinMaxSpan();null==l.minValueSpan&&null==l.maxValueSpan||(r=zk(0,r.slice(),o.scale.getExtent(),0,l.minValueSpan,l.maxValueSpan)),s&&(n[s.id]={dataZoomId:s.id,startValue:r[0],endValue:r[1]})}},e.prototype._dispatchZoomAction=function(t){var e=[];eV(t,(function(t,n){e.push(T(t))})),e.length&&this.api.dispatchAction({type:"dataZoom",from:this.uid,batch:e})},e.getDefaultOption=function(t){return{show:!0,filterMode:"filter",icon:{zoom:"M0,13.5h26.9 M13.5,26.9V0 M32.1,13.5H58V58H13.5 V32.1",back:"M22,1.4L9.9,13.5l12.3,12.3 M10.3,13.5H54.9v44.6 H10.3v-26"},title:t.getLocaleModel().get(["toolbox","dataZoom","title"]),brushStyle:{borderWidth:0,color:"rgba(210,219,238,0.2)"}}},e}(mz),rV={zoom:function(){var t=!this._isZoomActive;this.api.dispatchAction({type:"takeGlobalCursor",key:"dataZoomSelect",dataZoomSelectActive:t})},back:function(){this._dispatchZoomAction(function(t){var e=Bz(t),n=e[e.length-1];e.length>1&&e.pop();var i={};return zz(n,(function(t,n){for(var r=e.length-1;r>=0;r--)if(t=e[r][n]){i[n]=t;break}})),i}(this.ecModel))}};function oV(t){var e={xAxisIndex:t.get("xAxisIndex",!0),yAxisIndex:t.get("yAxisIndex",!0),xAxisId:t.get("xAxisId",!0),yAxisId:t.get("yAxisId",!0)};return null==e.xAxisIndex&&null==e.xAxisId&&(e.xAxisIndex="all"),null==e.yAxisIndex&&null==e.yAxisId&&(e.yAxisIndex="all"),e}Qz="dataZoom",tV=function(t){var e=t.getComponent("toolbox",0),n=["feature","dataZoom"];if(e&&null!=e.get(n)){var i=e.getModel(n),r=[],o=Fo(t,oV(i));return eV(o.xAxisModels,(function(t){return a(t,"xAxis","xAxisIndex")})),eV(o.yAxisModels,(function(t){return a(t,"yAxis","yAxisIndex")})),r}function a(t,e,n){var o=t.componentIndex,a={type:"select",$fromToolbox:!0,filterMode:i.get("filterMode",!0)||"filter",id:nV+e+o};a[n]=o,r.push(a)}},lt(null==od.get(Qz)&&tV),od.set(Qz,tV);var aV=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="tooltip",e.dependencies=["axisPointer"],e.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click",alwaysShowContent:!1,displayMode:"single",renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,enterable:!1,backgroundColor:"#fff",shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:"#999",width:1,type:"dashed",textStyle:{}}},textStyle:{color:"#666",fontSize:14}},e}(zp);function sV(t){var e=t.get("confine");return null!=e?!!e:"richText"===t.get("renderMode")}function lV(t){if(r.domSupported)for(var e=document.documentElement.style,n=0,i=t.length;n-1?(u+="top:50%",h+="translateY(-50%) rotate("+(a="left"===s?-225:-45)+"deg)"):(u+="left:50%",h+="translateX(-50%) rotate("+(a="top"===s?225:45)+"deg)");var c=a*Math.PI/180,p=l+r,d=p*Math.abs(Math.cos(c))+p*Math.abs(Math.sin(c)),f=e+" solid "+r+"px;";return'
'}(n,i,r)),U(t))o.innerHTML=t+a;else if(t){o.innerHTML="",Y(t)||(t=[t]);for(var s=0;s=0?this._tryShow(n,i):"leave"===e&&this._hide(i))}),this))},e.prototype._keepShow=function(){var t=this._tooltipModel,e=this._ecModel,n=this._api,i=t.get("triggerOn");if(null!=this._lastX&&null!=this._lastY&&"none"!==i&&"click"!==i){var r=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout((function(){!n.isDisposed()&&r.manuallyShowTip(t,e,n,{x:r._lastX,y:r._lastY,dataByCoordSys:r._lastDataByCoordSys})}))}},e.prototype.manuallyShowTip=function(t,e,n,i){if(i.from!==this.uid&&!r.node&&n.getDom()){var o=IV(i,n);this._ticket="";var a=i.dataByCoordSys,s=function(t,e,n){var i=Go(t).queryOptionMap,r=i.keys()[0];if(!r||"series"===r)return;var o=Yo(e,r,i.get(r),{useDefault:!1,enableAll:!1,enableNone:!1}),a=o.models[0];if(!a)return;var s,l=n.getViewOfComponentModel(a);if(l.group.traverse((function(e){var n=rl(e).tooltipConfig;if(n&&n.name===t.name)return s=e,!0})),s)return{componentMainType:r,componentIndex:a.componentIndex,el:s}}(i,e,n);if(s){var l=s.el.getBoundingRect().clone();l.applyTransform(s.el.transform),this._tryShow({offsetX:l.x+l.width/2,offsetY:l.y+l.height/2,target:s.el,position:i.position,positionDefault:"bottom"},o)}else if(i.tooltip&&null!=i.x&&null!=i.y){var u=wV;u.x=i.x,u.y=i.y,u.update(),rl(u).tooltipConfig={name:null,option:i.tooltip},this._tryShow({offsetX:i.x,offsetY:i.y,target:u},o)}else if(a)this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,dataByCoordSys:a,tooltipOption:i.tooltipOption},o);else if(null!=i.seriesIndex){if(this._manuallyAxisShowTip(t,e,n,i))return;var h=PN(i,e),c=h.point[0],p=h.point[1];null!=c&&null!=p&&this._tryShow({offsetX:c,offsetY:p,target:h.el,position:i.position,positionDefault:"bottom"},o)}else null!=i.x&&null!=i.y&&(n.dispatchAction({type:"updateAxisPointer",x:i.x,y:i.y}),this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,target:n.getZr().findHover(i.x,i.y).target},o))}},e.prototype.manuallyHideTip=function(t,e,n,i){var r=this._tooltipContent;this._tooltipModel&&r.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,i.from!==this.uid&&this._hide(IV(i,n))},e.prototype._manuallyAxisShowTip=function(t,e,n,i){var r=i.seriesIndex,o=i.dataIndex,a=e.getComponent("axisPointer").coordSysAxesInfo;if(null!=r&&null!=o&&null!=a){var s=e.getSeriesByIndex(r);if(s)if("axis"===MV([s.getData().getItemModel(o),s,(s.coordinateSystem||{}).model],this._tooltipModel).get("trigger"))return n.dispatchAction({type:"updateAxisPointer",seriesIndex:r,dataIndex:o,position:i.position}),!0}},e.prototype._tryShow=function(t,e){var n=t.target;if(this._tooltipModel){this._lastX=t.offsetX,this._lastY=t.offsetY;var i=t.dataByCoordSys;if(i&&i.length)this._showAxisTooltip(i,t);else if(n){var r,o;if("legend"===rl(n).ssrType)return;this._lastDataByCoordSys=null,Oy(n,(function(t){return null!=rl(t).dataIndex?(r=t,!0):null!=rl(t).tooltipConfig?(o=t,!0):void 0}),!0),r?this._showSeriesItemTooltip(t,r,e):o?this._showComponentItemTooltip(t,o,e):this._hide(e)}else this._lastDataByCoordSys=null,this._hide(e)}},e.prototype._showOrMove=function(t,e){var n=t.get("showDelay");e=W(e,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(e,n):e()},e.prototype._showAxisTooltip=function(t,e){var n=this._ecModel,i=this._tooltipModel,r=[e.offsetX,e.offsetY],o=MV([e.tooltipOption],i),a=this._renderMode,s=[],l=og("section",{blocks:[],noHeader:!0}),u=[],h=new yg;E(t,(function(t){E(t.dataByAxis,(function(t){var e=n.getComponent(t.axisDim+"Axis",t.axisIndex),r=t.value;if(e&&null!=r){var o=fN(r,e.axis,n,t.seriesDataIndices,t.valueLabelOpt),c=og("section",{header:o,noHeader:!ut(o),sortBlocks:!0,blocks:[]});l.blocks.push(c),E(t.seriesDataIndices,(function(l){var p=n.getSeriesByIndex(l.seriesIndex),d=l.dataIndexInside,f=p.getDataParams(d);if(!(f.dataIndex<0)){f.axisDim=t.axisDim,f.axisIndex=t.axisIndex,f.axisType=t.axisType,f.axisId=t.axisId,f.axisValue=S_(e.axis,{value:r}),f.axisValueLabel=o,f.marker=h.makeTooltipMarker("item",Sp(f.color),a);var g=bf(p.formatTooltip(d,!0,null)),y=g.frag;if(y){var v=MV([p],i).get("valueFormatter");c.blocks.push(v?A({valueFormatter:v},y):y)}g.text&&u.push(g.text),s.push(f)}}))}}))})),l.blocks.reverse(),u.reverse();var c=e.position,p=o.get("order"),d=cg(l,h,a,p,n.get("useUTC"),o.get("textStyle"));d&&u.unshift(d);var f="richText"===a?"\n\n":"
",g=u.join(f);this._showOrMove(o,(function(){this._updateContentNotChangedOnAxis(t,s)?this._updatePosition(o,c,r[0],r[1],this._tooltipContent,s):this._showTooltipContent(o,g,s,Math.random()+"",r[0],r[1],c,null,h)}))},e.prototype._showSeriesItemTooltip=function(t,e,n){var i=this._ecModel,r=rl(e),o=r.seriesIndex,a=i.getSeriesByIndex(o),s=r.dataModel||a,l=r.dataIndex,u=r.dataType,h=s.getData(u),c=this._renderMode,p=t.positionDefault,d=MV([h.getItemModel(l),s,a&&(a.coordinateSystem||{}).model],this._tooltipModel,p?{position:p}:null),f=d.get("trigger");if(null==f||"item"===f){var g=s.getDataParams(l,u),y=new yg;g.marker=y.makeTooltipMarker("item",Sp(g.color),c);var v=bf(s.formatTooltip(l,!1,u)),m=d.get("order"),x=d.get("valueFormatter"),_=v.frag,b=_?cg(x?A({valueFormatter:x},_):_,y,c,m,i.get("useUTC"),d.get("textStyle")):v.text,w="item_"+s.name+"_"+l;this._showOrMove(d,(function(){this._showTooltipContent(d,b,g,w,t.offsetX,t.offsetY,t.position,t.target,y)})),n({type:"showTip",dataIndexInside:l,dataIndex:h.getRawIndex(l),seriesIndex:o,from:this.uid})}},e.prototype._showComponentItemTooltip=function(t,e,n){var i="html"===this._renderMode,r=rl(e),o=r.tooltipConfig.option||{},a=o.encodeHTMLContent;if(U(o)){o={content:o,formatter:o},a=!0}a&&i&&o.content&&((o=T(o)).content=re(o.content));var s=[o],l=this._ecModel.getComponent(r.componentMainType,r.componentIndex);l&&s.push(l),s.push({formatter:o.content});var u=t.positionDefault,h=MV(s,this._tooltipModel,u?{position:u}:null),c=h.get("content"),p=Math.random()+"",d=new yg;this._showOrMove(h,(function(){var n=T(h.get("formatterParams")||{});this._showTooltipContent(h,c,n,p,t.offsetX,t.offsetY,t.position,e,d)})),n({type:"showTip",from:this.uid})},e.prototype._showTooltipContent=function(t,e,n,i,r,o,a,s,l){if(this._ticket="",t.get("showContent")&&t.get("show")){var u=this._tooltipContent;u.setEnterable(t.get("enterable"));var h=t.get("formatter");a=a||t.get("position");var c=e,p=this._getNearestPoint([r,o],n,t.get("trigger"),t.get("borderColor")).color;if(h)if(U(h)){var d=t.ecModel.get("useUTC"),f=Y(n)?n[0]:n;c=h,f&&f.axisType&&f.axisType.indexOf("time")>=0&&(c=Jc(f.axisValue,c,d)),c=bp(c,n,!0)}else if(X(h)){var g=W((function(e,i){e===this._ticket&&(u.setContent(i,l,t,p,a),this._updatePosition(t,a,r,o,u,n,s))}),this);this._ticket=i,c=h(n,i,g)}else c=h;u.setContent(c,l,t,p,a),u.show(t,p),this._updatePosition(t,a,r,o,u,n,s)}},e.prototype._getNearestPoint=function(t,e,n,i){return"axis"===n||Y(e)?{color:i||("html"===this._renderMode?"#fff":"none")}:Y(e)?void 0:{color:i||e.color||e.borderColor}},e.prototype._updatePosition=function(t,e,n,i,r,o,a){var s=this._api.getWidth(),l=this._api.getHeight();e=e||t.get("position");var u=r.getSize(),h=t.get("align"),c=t.get("verticalAlign"),p=a&&a.getBoundingRect().clone();if(a&&p.applyTransform(a.transform),X(e)&&(e=e([n,i],o,r.el,p,{viewSize:[s,l],contentSize:u.slice()})),Y(e))n=$r(e[0],s),i=$r(e[1],l);else if(q(e)){var d=e;d.width=u[0],d.height=u[1];var f=kp(d,{width:s,height:l});n=f.x,i=f.y,h=null,c=null}else if(U(e)&&a){var g=function(t,e,n,i){var r=n[0],o=n[1],a=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,u=e.width,h=e.height;switch(t){case"inside":s=e.x+u/2-r/2,l=e.y+h/2-o/2;break;case"top":s=e.x+u/2-r/2,l=e.y-o-a;break;case"bottom":s=e.x+u/2-r/2,l=e.y+h+a;break;case"left":s=e.x-r-a,l=e.y+h/2-o/2;break;case"right":s=e.x+u+a,l=e.y+h/2-o/2}return[s,l]}(e,p,u,t.get("borderWidth"));n=g[0],i=g[1]}else{g=function(t,e,n,i,r,o,a){var s=n.getSize(),l=s[0],u=s[1];null!=o&&(t+l+o+2>i?t-=l+o:t+=o);null!=a&&(e+u+a>r?e-=u+a:e+=a);return[t,e]}(n,i,r,s,l,h?null:20,c?null:20);n=g[0],i=g[1]}if(h&&(n-=TV(h)?u[0]/2:"right"===h?u[0]:0),c&&(i-=TV(c)?u[1]/2:"bottom"===c?u[1]:0),sV(t)){g=function(t,e,n,i,r){var o=n.getSize(),a=o[0],s=o[1];return t=Math.min(t+a,i)-a,e=Math.min(e+s,r)-s,t=Math.max(t,0),e=Math.max(e,0),[t,e]}(n,i,r,s,l);n=g[0],i=g[1]}r.moveTo(n,i)},e.prototype._updateContentNotChangedOnAxis=function(t,e){var n=this._lastDataByCoordSys,i=this._cbParamsList,r=!!n&&n.length===t.length;return r&&E(n,(function(n,o){var a=n.dataByAxis||[],s=(t[o]||{}).dataByAxis||[];(r=r&&a.length===s.length)&&E(a,(function(t,n){var o=s[n]||{},a=t.seriesDataIndices||[],l=o.seriesDataIndices||[];(r=r&&t.value===o.value&&t.axisType===o.axisType&&t.axisId===o.axisId&&a.length===l.length)&&E(a,(function(t,e){var n=l[e];r=r&&t.seriesIndex===n.seriesIndex&&t.dataIndex===n.dataIndex})),i&&E(t.seriesDataIndices,(function(t){var n=t.seriesIndex,o=e[n],a=i[n];o&&a&&a.data!==o.data&&(r=!1)}))}))})),this._lastDataByCoordSys=t,this._cbParamsList=e,!!r},e.prototype._hide=function(t){this._lastDataByCoordSys=null,t({type:"hideTip",from:this.uid})},e.prototype.dispose=function(t,e){!r.node&&e.getDom()&&(Yg(this,"_updatePosition"),this._tooltipContent.dispose(),kN("itemTooltip",e))},e.type="tooltip",e}(Ag);function MV(t,e,n){var i,r=e.ecModel;n?(i=new Cc(n,r,r),i=new Cc(e.option,i,r)):i=e;for(var o=t.length-1;o>=0;o--){var a=t[o];a&&(a instanceof Cc&&(a=a.get("tooltip",!0)),U(a)&&(a={formatter:a}),a&&(i=new Cc(a,i,r)))}return i}function IV(t,e){return t.dispatchAction||W(e.dispatchAction,e)}function TV(t){return"center"===t||"middle"===t}var CV=["rect","polygon","keep","clear"];function DV(t,e){var n=To(t?t.brush:[]);if(n.length){var i=[];E(n,(function(t){var e=t.hasOwnProperty("toolbox")?t.toolbox:[];e instanceof Array&&(i=i.concat(e))}));var r=t&&t.toolbox;Y(r)&&(r=r[0]),r||(r={feature:{}},t.toolbox=[r]);var o=r.feature||(r.feature={}),a=o.brush||(o.brush={}),s=a.type||(a.type=[]);s.push.apply(s,i),function(t){var e={};E(t,(function(t){e[t]=1})),t.length=0,E(e,(function(e,n){t.push(n)}))}(s),e&&!s.length&&s.push.apply(s,CV)}}var AV=E;function kV(t){if(t)for(var e in t)if(t.hasOwnProperty(e))return!0}function LV(t,e,n){var i={};return AV(e,(function(e){var r,o=i[e]=((r=function(){}).prototype.__hidden=r.prototype,new r);AV(t[e],(function(t,i){if(kD.isValidType(i)){var r={type:i,visual:t};n&&n(r,e),o[i]=new kD(r),"opacity"===i&&((r=T(r)).type="colorAlpha",o.__hidden.__alphaForOpacity=new kD(r))}}))})),i}function PV(t,e,n){var i;E(n,(function(t){e.hasOwnProperty(t)&&kV(e[t])&&(i=!0)})),i&&E(n,(function(n){e.hasOwnProperty(n)&&kV(e[n])?t[n]=T(e[n]):delete t[n]}))}var OV={lineX:RV(0),lineY:RV(1),rect:{point:function(t,e,n){return t&&n.boundingRect.contain(t[0],t[1])},rect:function(t,e,n){return t&&n.boundingRect.intersect(t)}},polygon:{point:function(t,e,n){return t&&n.boundingRect.contain(t[0],t[1])&&P_(n.range,t[0],t[1])},rect:function(t,e,n){var i=n.range;if(!t||i.length<=1)return!1;var r=t.x,o=t.y,a=t.width,s=t.height,l=i[0];return!!(P_(i,r,o)||P_(i,r+a,o)||P_(i,r,o+s)||P_(i,r+a,o+s)||ze.create(t).contain(l[0],l[1])||Zh(r,o,r+a,o,i)||Zh(r,o,r,o+s,i)||Zh(r+a,o,r+a,o+s,i)||Zh(r,o+s,r+a,o+s,i))||void 0}}};function RV(t){var e=["x","y"],n=["width","height"];return{point:function(e,n,i){if(e){var r=i.range;return NV(e[t],r)}},rect:function(i,r,o){if(i){var a=o.range,s=[i[e[t]],i[e[t]]+i[n[t]]];return s[1]e[0][1]&&(e[0][1]=o[0]),o[1]e[1][1]&&(e[1][1]=o[1])}return e&&YV(e)}};function YV(t){return new ze(t[0][0],t[1][0],t[0][1]-t[0][0],t[1][1]-t[1][0])}var XV=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(t,e){this.ecModel=t,this.api=e,this.model,(this._brushController=new lL(e.getZr())).on("brush",W(this._onBrush,this)).mount()},e.prototype.render=function(t,e,n,i){this.model=t,this._updateController(t,e,n,i)},e.prototype.updateTransform=function(t,e,n,i){BV(e),this._updateController(t,e,n,i)},e.prototype.updateVisual=function(t,e,n,i){this.updateTransform(t,e,n,i)},e.prototype.updateView=function(t,e,n,i){this._updateController(t,e,n,i)},e.prototype._updateController=function(t,e,n,i){(!i||i.$from!==t.id)&&this._brushController.setPanels(t.brushTargetManager.makePanelOpts(n)).enableBrush(t.brushOption).updateCovers(t.areas.slice())},e.prototype.dispose=function(){this._brushController.dispose()},e.prototype._onBrush=function(t){var e=this.model.id,n=this.model.brushTargetManager.setOutputRanges(t.areas,this.ecModel);(!t.isEnd||t.removeOnClick)&&this.api.dispatchAction({type:"brush",brushId:e,areas:T(n),$from:e}),t.isEnd&&this.api.dispatchAction({type:"brushEnd",brushId:e,areas:T(n),$from:e})},e.type="brush",e}(Ag),UV=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.areas=[],n.brushOption={},n}return n(e,t),e.prototype.optionUpdated=function(t,e){var n=this.option;!e&&PV(n,t,["inBrush","outOfBrush"]);var i=n.inBrush=n.inBrush||{};n.outOfBrush=n.outOfBrush||{color:"#ddd"},i.hasOwnProperty("liftZ")||(i.liftZ=5)},e.prototype.setAreas=function(t){t&&(this.areas=z(t,(function(t){return ZV(this.option,t)}),this))},e.prototype.setBrushOption=function(t){this.brushOption=ZV(this.option,t),this.brushType=this.brushOption.brushType},e.type="brush",e.dependencies=["geo","grid","xAxis","yAxis","parallel","series"],e.defaultOption={seriesIndex:"all",brushType:"rect",brushMode:"single",transformable:!0,brushStyle:{borderWidth:1,color:"rgba(210,219,238,0.3)",borderColor:"#D2DBEE"},throttleType:"fixRate",throttleDelay:0,removeOnClick:!0,z:1e4},e}(zp);function ZV(t,e){return C({brushType:t.brushType,brushMode:t.brushMode,transformable:t.transformable,brushStyle:new Cc(t.brushStyle).getItemStyle(),removeOnClick:t.removeOnClick,z:t.z},e,!0)}var jV=["rect","polygon","lineX","lineY","keep","clear"],qV=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return n(e,t),e.prototype.render=function(t,e,n){var i,r,o;e.eachComponent({mainType:"brush"},(function(t){i=t.brushType,r=t.brushOption.brushMode||"single",o=o||!!t.areas.length})),this._brushType=i,this._brushMode=r,E(t.get("type",!0),(function(e){t.setIconStatus(e,("keep"===e?"multiple"===r:"clear"===e?o:e===i)?"emphasis":"normal")}))},e.prototype.updateView=function(t,e,n){this.render(t,e,n)},e.prototype.getIcons=function(){var t=this.model,e=t.get("icon",!0),n={};return E(t.get("type",!0),(function(t){e[t]&&(n[t]=e[t])})),n},e.prototype.onclick=function(t,e,n){var i=this._brushType,r=this._brushMode;"clear"===n?(e.dispatchAction({type:"axisAreaSelect",intervals:[]}),e.dispatchAction({type:"brush",command:"clear",areas:[]})):e.dispatchAction({type:"takeGlobalCursor",key:"brush",brushOption:{brushType:"keep"===n?i:i!==n&&n,brushMode:"keep"===n?"multiple"===r?"single":"multiple":r}})},e.getDefaultOption=function(t){return{show:!0,type:jV.slice(),icon:{rect:"M7.3,34.7 M0.4,10V-0.2h9.8 M89.6,10V-0.2h-9.8 M0.4,60v10.2h9.8 M89.6,60v10.2h-9.8 M12.3,22.4V10.5h13.1 M33.6,10.5h7.8 M49.1,10.5h7.8 M77.5,22.4V10.5h-13 M12.3,31.1v8.2 M77.7,31.1v8.2 M12.3,47.6v11.9h13.1 M33.6,59.5h7.6 M49.1,59.5 h7.7 M77.5,47.6v11.9h-13",polygon:"M55.2,34.9c1.7,0,3.1,1.4,3.1,3.1s-1.4,3.1-3.1,3.1 s-3.1-1.4-3.1-3.1S53.5,34.9,55.2,34.9z M50.4,51c1.7,0,3.1,1.4,3.1,3.1c0,1.7-1.4,3.1-3.1,3.1c-1.7,0-3.1-1.4-3.1-3.1 C47.3,52.4,48.7,51,50.4,51z M55.6,37.1l1.5-7.8 M60.1,13.5l1.6-8.7l-7.8,4 M59,19l-1,5.3 M24,16.1l6.4,4.9l6.4-3.3 M48.5,11.6 l-5.9,3.1 M19.1,12.8L9.7,5.1l1.1,7.7 M13.4,29.8l1,7.3l6.6,1.6 M11.6,18.4l1,6.1 M32.8,41.9 M26.6,40.4 M27.3,40.2l6.1,1.6 M49.9,52.1l-5.6-7.6l-4.9-1.2",lineX:"M15.2,30 M19.7,15.6V1.9H29 M34.8,1.9H40.4 M55.3,15.6V1.9H45.9 M19.7,44.4V58.1H29 M34.8,58.1H40.4 M55.3,44.4 V58.1H45.9 M12.5,20.3l-9.4,9.6l9.6,9.8 M3.1,29.9h16.5 M62.5,20.3l9.4,9.6L62.3,39.7 M71.9,29.9H55.4",lineY:"M38.8,7.7 M52.7,12h13.2v9 M65.9,26.6V32 M52.7,46.3h13.2v-9 M24.9,12H11.8v9 M11.8,26.6V32 M24.9,46.3H11.8v-9 M48.2,5.1l-9.3-9l-9.4,9.2 M38.9-3.9V12 M48.2,53.3l-9.3,9l-9.4-9.2 M38.9,62.3V46.4",keep:"M4,10.5V1h10.3 M20.7,1h6.1 M33,1h6.1 M55.4,10.5V1H45.2 M4,17.3v6.6 M55.6,17.3v6.6 M4,30.5V40h10.3 M20.7,40 h6.1 M33,40h6.1 M55.4,30.5V40H45.2 M21,18.9h62.9v48.6H21V18.9z",clear:"M22,14.7l30.9,31 M52.9,14.7L22,45.7 M4.7,16.8V4.2h13.1 M26,4.2h7.8 M41.6,4.2h7.8 M70.3,16.8V4.2H57.2 M4.7,25.9v8.6 M70.3,25.9v8.6 M4.7,43.2v12.6h13.1 M26,55.8h7.8 M41.6,55.8h7.8 M70.3,43.2v12.6H57.2"},title:t.getLocaleModel().get(["toolbox","brush","title"])}},e}(mz);var KV=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.layoutMode={type:"box",ignoreSize:!0},n}return n(e,t),e.type="title",e.defaultOption={z:6,show:!0,text:"",target:"blank",subtext:"",subtarget:"blank",left:0,top:0,backgroundColor:"rgba(0,0,0,0)",borderColor:"#ccc",borderWidth:0,padding:5,itemGap:10,textStyle:{fontSize:18,fontWeight:"bold",color:"#464646"},subtextStyle:{fontSize:12,color:"#6E7079"}},e}(zp),$V=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.render=function(t,e,n){if(this.group.removeAll(),t.get("show")){var i=this.group,r=t.getModel("textStyle"),o=t.getModel("subtextStyle"),a=t.get("textAlign"),s=rt(t.get("textBaseline"),t.get("textVerticalAlign")),l=new Xs({style:oc(r,{text:t.get("text"),fill:r.getTextColor()},{disableBox:!0}),z2:10}),u=l.getBoundingRect(),h=t.get("subtext"),c=new Xs({style:oc(o,{text:h,fill:o.getTextColor(),y:u.height+t.get("itemGap"),verticalAlign:"top"},{disableBox:!0}),z2:10}),p=t.get("link"),d=t.get("sublink"),f=t.get("triggerEvent",!0);l.silent=!p&&!f,c.silent=!d&&!f,p&&l.on("click",(function(){Mp(p,"_"+t.get("target"))})),d&&c.on("click",(function(){Mp(d,"_"+t.get("subtarget"))})),rl(l).eventData=rl(c).eventData=f?{componentType:"title",componentIndex:t.componentIndex}:null,i.add(l),h&&i.add(c);var g=i.getBoundingRect(),y=t.getBoxLayoutParams();y.width=g.width,y.height=g.height;var v=kp(y,{width:n.getWidth(),height:n.getHeight()},t.get("padding"));a||("middle"===(a=t.get("left")||t.get("right"))&&(a="center"),"right"===a?v.x+=v.width:"center"===a&&(v.x+=v.width/2)),s||("center"===(s=t.get("top")||t.get("bottom"))&&(s="middle"),"bottom"===s?v.y+=v.height:"middle"===s&&(v.y+=v.height/2),s=s||"top"),i.x=v.x,i.y=v.y,i.markRedraw();var m={align:a,verticalAlign:s};l.setStyle(m),c.setStyle(m),g=i.getBoundingRect();var x=v.margin,_=t.getItemStyle(["color","opacity"]);_.fill=t.get("backgroundColor");var b=new Ws({shape:{x:g.x-x[3],y:g.y-x[0],width:g.width+x[1]+x[3],height:g.height+x[0]+x[2],r:t.get("borderRadius")},style:_,subPixelOptimize:!0,silent:!0});i.add(b)}},e.type="title",e}(Ag);var JV=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.layoutMode="box",n}return n(e,t),e.prototype.init=function(t,e,n){this.mergeDefaultAndTheme(t,n),this._initData()},e.prototype.mergeOption=function(e){t.prototype.mergeOption.apply(this,arguments),this._initData()},e.prototype.setCurrentIndex=function(t){null==t&&(t=this.option.currentIndex);var e=this._data.count();this.option.loop?t=(t%e+e)%e:(t>=e&&(t=e-1),t<0&&(t=0)),this.option.currentIndex=t},e.prototype.getCurrentIndex=function(){return this.option.currentIndex},e.prototype.isIndexMax=function(){return this.getCurrentIndex()>=this._data.count()-1},e.prototype.setPlayState=function(t){this.option.autoPlay=!!t},e.prototype.getPlayState=function(){return!!this.option.autoPlay},e.prototype._initData=function(){var t,e=this.option,n=e.data||[],i=e.axisType,r=this._names=[];"category"===i?(t=[],E(n,(function(e,n){var i,o=Ro(Ao(e),"");q(e)?(i=T(e)).value=n:i=n,t.push(i),r.push(o)}))):t=n;var o={category:"ordinal",time:"time",value:"number"}[i]||"number";(this._data=new cx([{name:"value",type:o}],this)).initData(t,r)},e.prototype.getData=function(){return this._data},e.prototype.getCategories=function(){if("category"===this.get("axisType"))return this._names.slice()},e.type="timeline",e.defaultOption={z:4,show:!0,axisType:"time",realtime:!0,left:"20%",top:null,right:"20%",bottom:0,width:null,height:40,padding:5,controlPosition:"left",autoPlay:!1,rewind:!1,loop:!0,playInterval:2e3,currentIndex:0,itemStyle:{},label:{color:"#000"},data:[]},e}(zp),QV=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="timeline.slider",e.defaultOption=kc(JV.defaultOption,{backgroundColor:"rgba(0,0,0,0)",borderColor:"#ccc",borderWidth:0,orient:"horizontal",inverse:!1,tooltip:{trigger:"item"},symbol:"circle",symbolSize:12,lineStyle:{show:!0,width:2,color:"#DAE1F5"},label:{position:"auto",show:!0,interval:"auto",rotate:0,color:"#A4B1D7"},itemStyle:{color:"#A4B1D7",borderWidth:1},checkpointStyle:{symbol:"circle",symbolSize:15,color:"#316bf3",borderColor:"#fff",borderWidth:2,shadowBlur:2,shadowOffsetX:1,shadowOffsetY:1,shadowColor:"rgba(0, 0, 0, 0.3)",animation:!0,animationDuration:300,animationEasing:"quinticInOut"},controlStyle:{show:!0,showPlayBtn:!0,showPrevBtn:!0,showNextBtn:!0,itemSize:24,itemGap:12,position:"left",playIcon:"path://M31.6,53C17.5,53,6,41.5,6,27.4S17.5,1.8,31.6,1.8C45.7,1.8,57.2,13.3,57.2,27.4S45.7,53,31.6,53z M31.6,3.3 C18.4,3.3,7.5,14.1,7.5,27.4c0,13.3,10.8,24.1,24.1,24.1C44.9,51.5,55.7,40.7,55.7,27.4C55.7,14.1,44.9,3.3,31.6,3.3z M24.9,21.3 c0-2.2,1.6-3.1,3.5-2l10.5,6.1c1.899,1.1,1.899,2.9,0,4l-10.5,6.1c-1.9,1.1-3.5,0.2-3.5-2V21.3z",stopIcon:"path://M30.9,53.2C16.8,53.2,5.3,41.7,5.3,27.6S16.8,2,30.9,2C45,2,56.4,13.5,56.4,27.6S45,53.2,30.9,53.2z M30.9,3.5C17.6,3.5,6.8,14.4,6.8,27.6c0,13.3,10.8,24.1,24.101,24.1C44.2,51.7,55,40.9,55,27.6C54.9,14.4,44.1,3.5,30.9,3.5z M36.9,35.8c0,0.601-0.4,1-0.9,1h-1.3c-0.5,0-0.9-0.399-0.9-1V19.5c0-0.6,0.4-1,0.9-1H36c0.5,0,0.9,0.4,0.9,1V35.8z M27.8,35.8 c0,0.601-0.4,1-0.9,1h-1.3c-0.5,0-0.9-0.399-0.9-1V19.5c0-0.6,0.4-1,0.9-1H27c0.5,0,0.9,0.4,0.9,1L27.8,35.8L27.8,35.8z",nextIcon:"M2,18.5A1.52,1.52,0,0,1,.92,18a1.49,1.49,0,0,1,0-2.12L7.81,9.36,1,3.11A1.5,1.5,0,1,1,3,.89l8,7.34a1.48,1.48,0,0,1,.49,1.09,1.51,1.51,0,0,1-.46,1.1L3,18.08A1.5,1.5,0,0,1,2,18.5Z",prevIcon:"M10,.5A1.52,1.52,0,0,1,11.08,1a1.49,1.49,0,0,1,0,2.12L4.19,9.64,11,15.89a1.5,1.5,0,1,1-2,2.22L1,10.77A1.48,1.48,0,0,1,.5,9.68,1.51,1.51,0,0,1,1,8.58L9,.92A1.5,1.5,0,0,1,10,.5Z",prevBtnSize:18,nextBtnSize:18,color:"#A4B1D7",borderColor:"#A4B1D7",borderWidth:1},emphasis:{label:{show:!0,color:"#6f778d"},itemStyle:{color:"#316BF3"},controlStyle:{color:"#316BF3",borderColor:"#316BF3",borderWidth:2}},progress:{lineStyle:{color:"#316BF3"},itemStyle:{color:"#316BF3"},label:{color:"#6f778d"}},data:[]}),e}(JV);R(QV,_f.prototype);var tB=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="timeline",e}(Ag),eB=function(t){function e(e,n,i,r){var o=t.call(this,e,n,i)||this;return o.type=r||"value",o}return n(e,t),e.prototype.getLabelModel=function(){return this.model.getModel("label")},e.prototype.isHorizontal=function(){return"horizontal"===this.model.get("orient")},e}(ab),nB=Math.PI,iB=Vo(),rB=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(t,e){this.api=e},e.prototype.render=function(t,e,n){if(this.model=t,this.api=n,this.ecModel=e,this.group.removeAll(),t.get("show",!0)){var i=this._layout(t,n),r=this._createGroup("_mainGroup"),o=this._createGroup("_labelGroup"),a=this._axis=this._createAxis(i,t);t.formatTooltip=function(t){return og("nameValue",{noName:!0,value:a.scale.getLabel({value:t})})},E(["AxisLine","AxisTick","Control","CurrentPointer"],(function(e){this["_render"+e](i,r,a,t)}),this),this._renderAxisLabel(i,o,a,t),this._position(i,t)}this._doPlayStop(),this._updateTicksStatus()},e.prototype.remove=function(){this._clearTimer(),this.group.removeAll()},e.prototype.dispose=function(){this._clearTimer()},e.prototype._layout=function(t,e){var n,i,r,o,a=t.get(["label","position"]),s=t.get("orient"),l=function(t,e){return kp(t.getBoxLayoutParams(),{width:e.getWidth(),height:e.getHeight()},t.get("padding"))}(t,e),u={horizontal:"center",vertical:(n=null==a||"auto"===a?"horizontal"===s?l.y+l.height/2=0||"+"===n?"left":"right"},h={horizontal:n>=0||"+"===n?"top":"bottom",vertical:"middle"},c={horizontal:0,vertical:nB/2},p="vertical"===s?l.height:l.width,d=t.getModel("controlStyle"),f=d.get("show",!0),g=f?d.get("itemSize"):0,y=f?d.get("itemGap"):0,v=g+y,m=t.get(["label","rotate"])||0;m=m*nB/180;var x=d.get("position",!0),_=f&&d.get("showPlayBtn",!0),b=f&&d.get("showPrevBtn",!0),w=f&&d.get("showNextBtn",!0),S=0,M=p;"left"===x||"bottom"===x?(_&&(i=[0,0],S+=v),b&&(r=[S,0],S+=v),w&&(o=[M-g,0],M-=v)):(_&&(i=[M-g,0],M-=v),b&&(r=[0,0],S+=v),w&&(o=[M-g,0],M-=v));var I=[S,M];return t.get("inverse")&&I.reverse(),{viewRect:l,mainLength:p,orient:s,rotation:c[s],labelRotation:m,labelPosOpt:n,labelAlign:t.get(["label","align"])||u[s],labelBaseline:t.get(["label","verticalAlign"])||t.get(["label","baseline"])||h[s],playPosition:i,prevBtnPosition:r,nextBtnPosition:o,axisExtent:I,controlSize:g,controlGap:y}},e.prototype._position=function(t,e){var n=this._mainGroup,i=this._labelGroup,r=t.viewRect;if("vertical"===t.orient){var o=[1,0,0,1,0,0],a=r.x,s=r.y+r.height;we(o,o,[-a,-s]),Se(o,o,-nB/2),we(o,o,[a,s]),(r=r.clone()).applyTransform(o)}var l=y(r),u=y(n.getBoundingRect()),h=y(i.getBoundingRect()),c=[n.x,n.y],p=[i.x,i.y];p[0]=c[0]=l[0][0];var d,f=t.labelPosOpt;null==f||U(f)?(v(c,u,l,1,d="+"===f?0:1),v(p,h,l,1,1-d)):(v(c,u,l,1,d=f>=0?0:1),p[1]=c[1]+f);function g(t){t.originX=l[0][0]-t.x,t.originY=l[1][0]-t.y}function y(t){return[[t.x,t.x+t.width],[t.y,t.y+t.height]]}function v(t,e,n,i,r){t[i]+=n[i][r]-e[i][r]}n.setPosition(c),i.setPosition(p),n.rotation=i.rotation=t.rotation,g(n),g(i)},e.prototype._createAxis=function(t,e){var n=e.getData(),i=e.get("axisType"),r=function(t,e){if(e=e||t.get("type"),e)switch(e){case"category":return new Rx({ordinalMeta:t.getCategories(),extent:[1/0,-1/0]});case"time":return new Kx({locale:t.ecModel.getLocaleModel(),useUTC:t.ecModel.get("useUTC")});default:return new Ex}}(e,i);r.getTicks=function(){return n.mapArray(["value"],(function(t){return{value:t}}))};var o=n.getDataExtent("value");r.setExtent(o[0],o[1]),r.calcNiceTicks();var a=new eB("value",r,t.axisExtent,i);return a.model=e,a},e.prototype._createGroup=function(t){var e=this[t]=new Br;return this.group.add(e),e},e.prototype._renderAxisLine=function(t,e,n,i){var r=n.getExtent();if(i.get(["lineStyle","show"])){var o=new Ku({shape:{x1:r[0],y1:0,x2:r[1],y2:0},style:A({lineCap:"round"},i.getModel("lineStyle").getLineStyle()),silent:!0,z2:1});e.add(o);var a=this._progressLine=new Ku({shape:{x1:r[0],x2:this._currentPointer?this._currentPointer.x:r[0],y1:0,y2:0},style:k({lineCap:"round",lineWidth:o.style.lineWidth},i.getModel(["progress","lineStyle"]).getLineStyle()),silent:!0,z2:1});e.add(a)}},e.prototype._renderAxisTick=function(t,e,n,i){var r=this,o=i.getData(),a=n.scale.getTicks();this._tickSymbols=[],E(a,(function(t){var a=n.dataToCoord(t.value),s=o.getItemModel(t.value),l=s.getModel("itemStyle"),u=s.getModel(["emphasis","itemStyle"]),h=s.getModel(["progress","itemStyle"]),c={x:a,y:0,onclick:W(r._changeTimeline,r,t.value)},p=oB(s,l,e,c);p.ensureState("emphasis").style=u.getItemStyle(),p.ensureState("progress").style=h.getItemStyle(),Ul(p);var d=rl(p);s.get("tooltip")?(d.dataIndex=t.value,d.dataModel=i):d.dataIndex=d.dataModel=null,r._tickSymbols.push(p)}))},e.prototype._renderAxisLabel=function(t,e,n,i){var r=this;if(n.getLabelModel().get("show")){var o=i.getData(),a=n.getViewLabels();this._tickLabels=[],E(a,(function(i){var a=i.tickValue,s=o.getItemModel(a),l=s.getModel("label"),u=s.getModel(["emphasis","label"]),h=s.getModel(["progress","label"]),c=n.dataToCoord(i.tickValue),p=new Xs({x:c,y:0,rotation:t.labelRotation-t.rotation,onclick:W(r._changeTimeline,r,a),silent:!1,style:oc(l,{text:i.formattedLabel,align:t.labelAlign,verticalAlign:t.labelBaseline})});p.ensureState("emphasis").style=oc(u),p.ensureState("progress").style=oc(h),e.add(p),Ul(p),iB(p).dataIndex=a,r._tickLabels.push(p)}))}},e.prototype._renderControl=function(t,e,n,i){var r=t.controlSize,o=t.rotation,a=i.getModel("controlStyle").getItemStyle(),s=i.getModel(["emphasis","controlStyle"]).getItemStyle(),l=i.getPlayState(),u=i.get("inverse",!0);function h(t,n,l,u){if(t){var h=Cr(rt(i.get(["controlStyle",n+"BtnSize"]),r),r),c=function(t,e,n,i){var r=i.style,o=Uh(t.get(["controlStyle",e]),i||{},new ze(n[0],n[1],n[2],n[3]));r&&o.setStyle(r);return o}(i,n+"Icon",[0,-h/2,h,h],{x:t[0],y:t[1],originX:r/2,originY:0,rotation:u?-o:0,rectHover:!0,style:a,onclick:l});c.ensureState("emphasis").style=s,e.add(c),Ul(c)}}h(t.nextBtnPosition,"next",W(this._changeTimeline,this,u?"-":"+")),h(t.prevBtnPosition,"prev",W(this._changeTimeline,this,u?"+":"-")),h(t.playPosition,l?"stop":"play",W(this._handlePlayClick,this,!l),!0)},e.prototype._renderCurrentPointer=function(t,e,n,i){var r=i.getData(),o=i.getCurrentIndex(),a=r.getItemModel(o).getModel("checkpointStyle"),s=this,l={onCreate:function(t){t.draggable=!0,t.drift=W(s._handlePointerDrag,s),t.ondragend=W(s._handlePointerDragend,s),aB(t,s._progressLine,o,n,i,!0)},onUpdate:function(t){aB(t,s._progressLine,o,n,i)}};this._currentPointer=oB(a,a,this._mainGroup,{},this._currentPointer,l)},e.prototype._handlePlayClick=function(t){this._clearTimer(),this.api.dispatchAction({type:"timelinePlayChange",playState:t,from:this.uid})},e.prototype._handlePointerDrag=function(t,e,n){this._clearTimer(),this._pointerChangeTimeline([n.offsetX,n.offsetY])},e.prototype._handlePointerDragend=function(t){this._pointerChangeTimeline([t.offsetX,t.offsetY],!0)},e.prototype._pointerChangeTimeline=function(t,e){var n=this._toAxisCoord(t)[0],i=Qr(this._axis.getExtent().slice());n>i[1]&&(n=i[1]),n=0&&(a[o]=+a[o].toFixed(c)),[a,h]}var vB={min:H(yB,"min"),max:H(yB,"max"),average:H(yB,"average"),median:H(yB,"median")};function mB(t,e){if(e){var n=t.getData(),i=t.coordinateSystem,r=i&&i.dimensions;if(!function(t){return!isNaN(parseFloat(t.x))&&!isNaN(parseFloat(t.y))}(e)&&!Y(e.coord)&&Y(r)){var o=xB(e,n,i,t);if((e=T(e)).type&&vB[e.type]&&o.baseAxis&&o.valueAxis){var a=P(r,o.baseAxis.dim),s=P(r,o.valueAxis.dim),l=vB[e.type](n,o.baseDataDim,o.valueDataDim,a,s);e.coord=l[0],e.value=l[1]}else e.coord=[null!=e.xAxis?e.xAxis:e.radiusAxis,null!=e.yAxis?e.yAxis:e.angleAxis]}if(null!=e.coord&&Y(r))for(var u=e.coord,h=0;h<2;h++)vB[u[h]]&&(u[h]=wB(n,n.mapDimension(r[h]),u[h]));else e.coord=[];return e}}function xB(t,e,n,i){var r={};return null!=t.valueIndex||null!=t.valueDim?(r.valueDataDim=null!=t.valueIndex?e.getDimension(t.valueIndex):t.valueDim,r.valueAxis=n.getAxis(function(t,e){var n=t.getData().getDimensionInfo(e);return n&&n.coordDim}(i,r.valueDataDim)),r.baseAxis=n.getOtherAxis(r.valueAxis),r.baseDataDim=e.mapDimension(r.baseAxis.dim)):(r.baseAxis=i.getBaseAxis(),r.valueAxis=n.getOtherAxis(r.baseAxis),r.baseDataDim=e.mapDimension(r.baseAxis.dim),r.valueDataDim=e.mapDimension(r.valueAxis.dim)),r}function _B(t,e){return!(t&&t.containData&&e.coord&&!gB(e))||t.containData(e.coord)}function bB(t,e){return t?function(t,n,i,r){return If(r<2?t.coord&&t.coord[r]:t.value,e[r])}:function(t,n,i,r){return If(t.value,e[r])}}function wB(t,e,n){if("average"===n){var i=0,r=0;return t.each(e,(function(t,e){isNaN(t)||(i+=t,r++)})),i/r}return"median"===n?t.getMedian(e):t.getDataExtent(e)["max"===n?1:0]}var SB=Vo(),MB=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(){this.markerGroupMap=yt()},e.prototype.render=function(t,e,n){var i=this,r=this.markerGroupMap;r.each((function(t){SB(t).keep=!1})),e.eachSeries((function(t){var r=dB.getMarkerModelFromSeries(t,i.type);r&&i.renderSeries(t,r,e,n)})),r.each((function(t){!SB(t).keep&&i.group.remove(t.group)}))},e.prototype.markKeep=function(t){SB(t).keep=!0},e.prototype.toggleBlurSeries=function(t,e){var n=this;E(t,(function(t){var i=dB.getMarkerModelFromSeries(t,n.type);i&&i.getData().eachItemGraphicEl((function(t){t&&(e?Nl(t):El(t))}))}))},e.type="marker",e}(Ag);function IB(t,e,n){var i=e.coordinateSystem;t.each((function(r){var o,a=t.getItemModel(r),s=$r(a.get("x"),n.getWidth()),l=$r(a.get("y"),n.getHeight());if(isNaN(s)||isNaN(l)){if(e.getMarkerPosition)o=e.getMarkerPosition(t.getValues(t.dimensions,r));else if(i){var u=t.get(i.dimensions[0],r),h=t.get(i.dimensions[1],r);o=i.dataToPoint([u,h])}}else o=[s,l];isNaN(s)||(o[0]=s),isNaN(l)||(o[1]=l),t.setItemLayout(r,o)}))}var TB=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.updateTransform=function(t,e,n){e.eachSeries((function(t){var e=dB.getMarkerModelFromSeries(t,"markPoint");e&&(IB(e.getData(),t,n),this.markerGroupMap.get(t.id).updateLayout())}),this)},e.prototype.renderSeries=function(t,e,n,i){var r=t.coordinateSystem,o=t.id,a=t.getData(),s=this.markerGroupMap,l=s.get(o)||s.set(o,new xS),u=function(t,e,n){var i;i=t?z(t&&t.dimensions,(function(t){return A(A({},e.getData().getDimensionInfo(e.getData().mapDimension(t))||{}),{name:t,ordinalMeta:null})})):[{name:"value",type:"float"}];var r=new cx(i,n),o=z(n.get("data"),H(mB,e));t&&(o=B(o,H(_B,t)));var a=bB(!!t,i);return r.initData(o,null,a),r}(r,t,e);e.setData(u),IB(e.getData(),t,i),u.each((function(t){var n=u.getItemModel(t),i=n.getShallow("symbol"),r=n.getShallow("symbolSize"),o=n.getShallow("symbolRotate"),s=n.getShallow("symbolOffset"),l=n.getShallow("symbolKeepAspect");if(X(i)||X(r)||X(o)||X(s)){var h=e.getRawValue(t),c=e.getDataParams(t);X(i)&&(i=i(h,c)),X(r)&&(r=r(h,c)),X(o)&&(o=o(h,c)),X(s)&&(s=s(h,c))}var p=n.getModel("itemStyle").getItemStyle(),d=Ay(a,"color");p.fill||(p.fill=d),u.setItemVisual(t,{symbol:i,symbolSize:r,symbolRotate:o,symbolOffset:s,symbolKeepAspect:l,style:p})})),l.updateData(u),this.group.add(l.group),u.eachItemGraphicEl((function(t){t.traverse((function(t){rl(t).dataModel=e}))})),this.markKeep(l),l.group.silent=e.get("silent")||t.get("silent")},e.type="markPoint",e}(MB);var CB=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.createMarkerModelFromSeries=function(t,n,i){return new e(t,n,i)},e.type="markLine",e.defaultOption={z:5,symbol:["circle","arrow"],symbolSize:[8,16],symbolOffset:0,precision:2,tooltip:{trigger:"item"},label:{show:!0,position:"end",distance:5},lineStyle:{type:"dashed"},emphasis:{label:{show:!0},lineStyle:{width:3}},animationEasing:"linear"},e}(dB),DB=Vo(),AB=function(t,e,n,i){var r,o=t.getData();if(Y(i))r=i;else{var a=i.type;if("min"===a||"max"===a||"average"===a||"median"===a||null!=i.xAxis||null!=i.yAxis){var s=void 0,l=void 0;if(null!=i.yAxis||null!=i.xAxis)s=e.getAxis(null!=i.yAxis?"y":"x"),l=it(i.yAxis,i.xAxis);else{var u=xB(i,o,e,t);s=u.valueAxis,l=wB(o,xx(o,u.valueDataDim),a)}var h="x"===s.dim?0:1,c=1-h,p=T(i),d={coord:[]};p.type=null,p.coord=[],p.coord[c]=-1/0,d.coord[c]=1/0;var f=n.get("precision");f>=0&&j(l)&&(l=+l.toFixed(Math.min(f,20))),p.coord[h]=d.coord[h]=l,r=[p,d,{type:a,valueIndex:i.valueIndex,value:l}]}else r=[]}var g=[mB(t,r[0]),mB(t,r[1]),A({},r[2])];return g[2].type=g[2].type||null,C(g[2],g[0]),C(g[2],g[1]),g};function kB(t){return!isNaN(t)&&!isFinite(t)}function LB(t,e,n,i){var r=1-t,o=i.dimensions[t];return kB(e[r])&&kB(n[r])&&e[t]===n[t]&&i.getAxis(o).containData(e[t])}function PB(t,e){if("cartesian2d"===t.type){var n=e[0].coord,i=e[1].coord;if(n&&i&&(LB(1,n,i,t)||LB(0,n,i,t)))return!0}return _B(t,e[0])&&_B(t,e[1])}function OB(t,e,n,i,r){var o,a=i.coordinateSystem,s=t.getItemModel(e),l=$r(s.get("x"),r.getWidth()),u=$r(s.get("y"),r.getHeight());if(isNaN(l)||isNaN(u)){if(i.getMarkerPosition)o=i.getMarkerPosition(t.getValues(t.dimensions,e));else{var h=a.dimensions,c=t.get(h[0],e),p=t.get(h[1],e);o=a.dataToPoint([c,p])}if(OS(a,"cartesian2d")){var d=a.getAxis("x"),f=a.getAxis("y");h=a.dimensions;kB(t.get(h[0],e))?o[0]=d.toGlobalCoord(d.getExtent()[n?0:1]):kB(t.get(h[1],e))&&(o[1]=f.toGlobalCoord(f.getExtent()[n?0:1]))}isNaN(l)||(o[0]=l),isNaN(u)||(o[1]=u)}else o=[l,u];t.setItemLayout(e,o)}var RB=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.updateTransform=function(t,e,n){e.eachSeries((function(t){var e=dB.getMarkerModelFromSeries(t,"markLine");if(e){var i=e.getData(),r=DB(e).from,o=DB(e).to;r.each((function(e){OB(r,e,!0,t,n),OB(o,e,!1,t,n)})),i.each((function(t){i.setItemLayout(t,[r.getItemLayout(t),o.getItemLayout(t)])})),this.markerGroupMap.get(t.id).updateLayout()}}),this)},e.prototype.renderSeries=function(t,e,n,i){var r=t.coordinateSystem,o=t.id,a=t.getData(),s=this.markerGroupMap,l=s.get(o)||s.set(o,new YA);this.group.add(l.group);var u=function(t,e,n){var i;i=t?z(t&&t.dimensions,(function(t){return A(A({},e.getData().getDimensionInfo(e.getData().mapDimension(t))||{}),{name:t,ordinalMeta:null})})):[{name:"value",type:"float"}];var r=new cx(i,n),o=new cx(i,n),a=new cx([],n),s=z(n.get("data"),H(AB,e,t,n));t&&(s=B(s,H(PB,t)));var l=bB(!!t,i);return r.initData(z(s,(function(t){return t[0]})),null,l),o.initData(z(s,(function(t){return t[1]})),null,l),a.initData(z(s,(function(t){return t[2]}))),a.hasItemOption=!0,{from:r,to:o,line:a}}(r,t,e),h=u.from,c=u.to,p=u.line;DB(e).from=h,DB(e).to=c,e.setData(p);var d=e.get("symbol"),f=e.get("symbolSize"),g=e.get("symbolRotate"),y=e.get("symbolOffset");function v(e,n,r){var o=e.getItemModel(n);OB(e,n,r,t,i);var s=o.getModel("itemStyle").getItemStyle();null==s.fill&&(s.fill=Ay(a,"color")),e.setItemVisual(n,{symbolKeepAspect:o.get("symbolKeepAspect"),symbolOffset:rt(o.get("symbolOffset",!0),y[r?0:1]),symbolRotate:rt(o.get("symbolRotate",!0),g[r?0:1]),symbolSize:rt(o.get("symbolSize"),f[r?0:1]),symbol:rt(o.get("symbol",!0),d[r?0:1]),style:s})}Y(d)||(d=[d,d]),Y(f)||(f=[f,f]),Y(g)||(g=[g,g]),Y(y)||(y=[y,y]),u.from.each((function(t){v(h,t,!0),v(c,t,!1)})),p.each((function(t){var e=p.getItemModel(t).getModel("lineStyle").getLineStyle();p.setItemLayout(t,[h.getItemLayout(t),c.getItemLayout(t)]),null==e.stroke&&(e.stroke=h.getItemVisual(t,"style").fill),p.setItemVisual(t,{fromSymbolKeepAspect:h.getItemVisual(t,"symbolKeepAspect"),fromSymbolOffset:h.getItemVisual(t,"symbolOffset"),fromSymbolRotate:h.getItemVisual(t,"symbolRotate"),fromSymbolSize:h.getItemVisual(t,"symbolSize"),fromSymbol:h.getItemVisual(t,"symbol"),toSymbolKeepAspect:c.getItemVisual(t,"symbolKeepAspect"),toSymbolOffset:c.getItemVisual(t,"symbolOffset"),toSymbolRotate:c.getItemVisual(t,"symbolRotate"),toSymbolSize:c.getItemVisual(t,"symbolSize"),toSymbol:c.getItemVisual(t,"symbol"),style:e})})),l.updateData(p),u.line.eachItemGraphicEl((function(t){rl(t).dataModel=e,t.traverse((function(t){rl(t).dataModel=e}))})),this.markKeep(l),l.group.silent=e.get("silent")||t.get("silent")},e.type="markLine",e}(MB);var NB=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.createMarkerModelFromSeries=function(t,n,i){return new e(t,n,i)},e.type="markArea",e.defaultOption={z:1,tooltip:{trigger:"item"},animation:!1,label:{show:!0,position:"top"},itemStyle:{borderWidth:0},emphasis:{label:{show:!0,position:"top"}}},e}(dB),EB=Vo(),zB=function(t,e,n,i){var r=i[0],o=i[1];if(r&&o){var a=mB(t,r),s=mB(t,o),l=a.coord,u=s.coord;l[0]=it(l[0],-1/0),l[1]=it(l[1],-1/0),u[0]=it(u[0],1/0),u[1]=it(u[1],1/0);var h=D([{},a,s]);return h.coord=[a.coord,s.coord],h.x0=a.x,h.y0=a.y,h.x1=s.x,h.y1=s.y,h}};function VB(t){return!isNaN(t)&&!isFinite(t)}function BB(t,e,n,i){var r=1-t;return VB(e[r])&&VB(n[r])}function FB(t,e){var n=e.coord[0],i=e.coord[1],r={coord:n,x:e.x0,y:e.y0},o={coord:i,x:e.x1,y:e.y1};return OS(t,"cartesian2d")?!(!n||!i||!BB(1,n,i)&&!BB(0,n,i))||function(t,e,n){return!(t&&t.containZone&&e.coord&&n.coord&&!gB(e)&&!gB(n))||t.containZone(e.coord,n.coord)}(t,r,o):_B(t,r)||_B(t,o)}function GB(t,e,n,i,r){var o,a=i.coordinateSystem,s=t.getItemModel(e),l=$r(s.get(n[0]),r.getWidth()),u=$r(s.get(n[1]),r.getHeight());if(isNaN(l)||isNaN(u)){if(i.getMarkerPosition){var h=t.getValues(["x0","y0"],e),c=t.getValues(["x1","y1"],e),p=a.clampData(h),d=a.clampData(c),f=[];"x0"===n[0]?f[0]=p[0]>d[0]?c[0]:h[0]:f[0]=p[0]>d[0]?h[0]:c[0],"y0"===n[1]?f[1]=p[1]>d[1]?c[1]:h[1]:f[1]=p[1]>d[1]?h[1]:c[1],o=i.getMarkerPosition(f,n,!0)}else{var g=[m=t.get(n[0],e),x=t.get(n[1],e)];a.clampData&&a.clampData(g,g),o=a.dataToPoint(g,!0)}if(OS(a,"cartesian2d")){var y=a.getAxis("x"),v=a.getAxis("y"),m=t.get(n[0],e),x=t.get(n[1],e);VB(m)?o[0]=y.toGlobalCoord(y.getExtent()["x0"===n[0]?0:1]):VB(x)&&(o[1]=v.toGlobalCoord(v.getExtent()["y0"===n[1]?0:1]))}isNaN(l)||(o[0]=l),isNaN(u)||(o[1]=u)}else o=[l,u];return o}var WB=[["x0","y0"],["x1","y0"],["x1","y1"],["x0","y1"]],HB=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.updateTransform=function(t,e,n){e.eachSeries((function(t){var e=dB.getMarkerModelFromSeries(t,"markArea");if(e){var i=e.getData();i.each((function(e){var r=z(WB,(function(r){return GB(i,e,r,t,n)}));i.setItemLayout(e,r),i.getItemGraphicEl(e).setShape("points",r)}))}}),this)},e.prototype.renderSeries=function(t,e,n,i){var r=t.coordinateSystem,o=t.id,a=t.getData(),s=this.markerGroupMap,l=s.get(o)||s.set(o,{group:new Br});this.group.add(l.group),this.markKeep(l);var u=function(t,e,n){var i,r,o=["x0","y0","x1","y1"];if(t){var a=z(t&&t.dimensions,(function(t){var n=e.getData();return A(A({},n.getDimensionInfo(n.mapDimension(t))||{}),{name:t,ordinalMeta:null})}));r=z(o,(function(t,e){return{name:t,type:a[e%2].type}})),i=new cx(r,n)}else i=new cx(r=[{name:"value",type:"float"}],n);var s=z(n.get("data"),H(zB,e,t,n));t&&(s=B(s,H(FB,t)));var l=t?function(t,e,n,i){return If(t.coord[Math.floor(i/2)][i%2],r[i])}:function(t,e,n,i){return If(t.value,r[i])};return i.initData(s,null,l),i.hasItemOption=!0,i}(r,t,e);e.setData(u),u.each((function(e){var n=z(WB,(function(n){return GB(u,e,n,t,i)})),o=r.getAxis("x").scale,s=r.getAxis("y").scale,l=o.getExtent(),h=s.getExtent(),c=[o.parse(u.get("x0",e)),o.parse(u.get("x1",e))],p=[s.parse(u.get("y0",e)),s.parse(u.get("y1",e))];Qr(c),Qr(p);var d=!!(l[0]>c[1]||l[1]p[1]||h[1]=0},e.prototype.getOrient=function(){return"vertical"===this.get("orient")?{index:1,name:"vertical"}:{index:0,name:"horizontal"}},e.type="legend.plain",e.dependencies=["series"],e.defaultOption={z:4,show:!0,orient:"horizontal",left:"center",top:0,align:"auto",backgroundColor:"rgba(0,0,0,0)",borderColor:"#ccc",borderRadius:0,borderWidth:0,padding:5,itemGap:10,itemWidth:25,itemHeight:14,symbolRotate:"inherit",symbolKeepAspect:!0,inactiveColor:"#ccc",inactiveBorderColor:"#ccc",inactiveBorderWidth:"auto",itemStyle:{color:"inherit",opacity:"inherit",borderColor:"inherit",borderWidth:"auto",borderCap:"inherit",borderJoin:"inherit",borderDashOffset:"inherit",borderMiterLimit:"inherit"},lineStyle:{width:"auto",color:"inherit",inactiveColor:"#ccc",inactiveWidth:2,opacity:"inherit",type:"inherit",cap:"inherit",join:"inherit",dashOffset:"inherit",miterLimit:"inherit"},textStyle:{color:"#333"},selectedMode:!0,selector:!1,selectorLabel:{show:!0,borderRadius:10,padding:[3,5,3,5],fontSize:12,fontFamily:"sans-serif",color:"#666",borderWidth:1,borderColor:"#666"},emphasis:{selectorLabel:{show:!0,color:"#eee",backgroundColor:"#666"}},selectorPosition:"auto",selectorItemGap:7,selectorButtonGap:10,tooltip:{show:!1}},e}(zp),XB=H,UB=E,ZB=Br,jB=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.newlineDisabled=!1,n}return n(e,t),e.prototype.init=function(){this.group.add(this._contentGroup=new ZB),this.group.add(this._selectorGroup=new ZB),this._isFirstRender=!0},e.prototype.getContentGroup=function(){return this._contentGroup},e.prototype.getSelectorGroup=function(){return this._selectorGroup},e.prototype.render=function(t,e,n){var i=this._isFirstRender;if(this._isFirstRender=!1,this.resetInner(),t.get("show",!0)){var r=t.get("align"),o=t.get("orient");r&&"auto"!==r||(r="right"===t.get("left")&&"vertical"===o?"right":"left");var a=t.get("selector",!0),s=t.get("selectorPosition",!0);!a||s&&"auto"!==s||(s="horizontal"===o?"end":"start"),this.renderInner(r,t,e,n,a,o,s);var l=t.getBoxLayoutParams(),u={width:n.getWidth(),height:n.getHeight()},h=t.get("padding"),c=kp(l,u,h),p=this.layoutInner(t,r,c,i,a,s),d=kp(k({width:p.width,height:p.height},l),u,h);this.group.x=d.x-p.x,this.group.y=d.y-p.y,this.group.markRedraw(),this.group.add(this._backgroundEl=Sz(p,t))}},e.prototype.resetInner=function(){this.getContentGroup().removeAll(),this._backgroundEl&&this.group.remove(this._backgroundEl),this.getSelectorGroup().removeAll()},e.prototype.renderInner=function(t,e,n,i,r,o,a){var s=this.getContentGroup(),l=yt(),u=e.get("selectedMode"),h=[];n.eachRawSeries((function(t){!t.get("legendHoverLink")&&h.push(t.id)})),UB(e.getData(),(function(r,o){var a=r.get("name");if(!this.newlineDisabled&&(""===a||"\n"===a)){var c=new ZB;return c.newline=!0,void s.add(c)}var p=n.getSeriesByName(a)[0];if(!l.get(a)){if(p){var d=p.getData(),f=d.getVisual("legendLineStyle")||{},g=d.getVisual("legendIcon"),y=d.getVisual("style"),v=this._createItem(p,a,o,r,e,t,f,y,g,u,i);v.on("click",XB(qB,a,null,i,h)).on("mouseover",XB($B,p.name,null,i,h)).on("mouseout",XB(JB,p.name,null,i,h)),n.ssr&&v.eachChild((function(t){var e=rl(t);e.seriesIndex=p.seriesIndex,e.dataIndex=o,e.ssrType="legend"})),l.set(a,!0)}else n.eachRawSeries((function(s){if(!l.get(a)&&s.legendVisualProvider){var c=s.legendVisualProvider;if(!c.containName(a))return;var p=c.indexOfName(a),d=c.getItemVisual(p,"style"),f=c.getItemVisual(p,"legendIcon"),g=qn(d.fill);g&&0===g[3]&&(g[3]=.2,d=A(A({},d),{fill:ri(g,"rgba")}));var y=this._createItem(s,a,o,r,e,t,{},d,f,u,i);y.on("click",XB(qB,null,a,i,h)).on("mouseover",XB($B,null,a,i,h)).on("mouseout",XB(JB,null,a,i,h)),n.ssr&&y.eachChild((function(t){var e=rl(t);e.seriesIndex=s.seriesIndex,e.dataIndex=o,e.ssrType="legend"})),l.set(a,!0)}}),this);0}}),this),r&&this._createSelector(r,e,i,o,a)},e.prototype._createSelector=function(t,e,n,i,r){var o=this.getSelectorGroup();UB(t,(function(t){var i=t.type,r=new Xs({style:{x:0,y:0,align:"center",verticalAlign:"middle"},onclick:function(){n.dispatchAction({type:"all"===i?"legendAllSelect":"legendInverseSelect"})}});o.add(r),ic(r,{normal:e.getModel("selectorLabel"),emphasis:e.getModel(["emphasis","selectorLabel"])},{defaultText:t.title}),Ul(r)}))},e.prototype._createItem=function(t,e,n,i,r,o,a,s,l,u,h){var c=t.visualDrawType,p=r.get("itemWidth"),d=r.get("itemHeight"),f=r.isSelected(e),g=i.get("symbolRotate"),y=i.get("symbolKeepAspect"),v=i.get("icon"),m=function(t,e,n,i,r,o,a){function s(t,e){"auto"===t.lineWidth&&(t.lineWidth=e.lineWidth>0?2:0),UB(t,(function(n,i){"inherit"===t[i]&&(t[i]=e[i])}))}var l=e.getModel("itemStyle"),u=l.getItemStyle(),h=0===t.lastIndexOf("empty",0)?"fill":"stroke",c=l.getShallow("decal");u.decal=c&&"inherit"!==c?mv(c,a):i.decal,"inherit"===u.fill&&(u.fill=i[r]);"inherit"===u.stroke&&(u.stroke=i[h]);"inherit"===u.opacity&&(u.opacity=("fill"===r?i:n).opacity);s(u,i);var p=e.getModel("lineStyle"),d=p.getLineStyle();if(s(d,n),"auto"===u.fill&&(u.fill=i.fill),"auto"===u.stroke&&(u.stroke=i.fill),"auto"===d.stroke&&(d.stroke=i.fill),!o){var f=e.get("inactiveBorderWidth"),g=u[h];u.lineWidth="auto"===f?i.lineWidth>0&&g?2:0:u.lineWidth,u.fill=e.get("inactiveColor"),u.stroke=e.get("inactiveBorderColor"),d.stroke=p.get("inactiveColor"),d.lineWidth=p.get("inactiveWidth")}return{itemStyle:u,lineStyle:d}}(l=v||l||"roundRect",i,a,s,c,f,h),x=new ZB,_=i.getModel("textStyle");if(!X(t.getLegendIcon)||v&&"inherit"!==v){var b="inherit"===v&&t.getData().getVisual("symbol")?"inherit"===g?t.getData().getVisual("symbolRotate"):g:0;x.add(function(t){var e=t.icon||"roundRect",n=Xy(e,0,0,t.itemWidth,t.itemHeight,t.itemStyle.fill,t.symbolKeepAspect);n.setStyle(t.itemStyle),n.rotation=(t.iconRotate||0)*Math.PI/180,n.setOrigin([t.itemWidth/2,t.itemHeight/2]),e.indexOf("empty")>-1&&(n.style.stroke=n.style.fill,n.style.fill="#fff",n.style.lineWidth=2);return n}({itemWidth:p,itemHeight:d,icon:l,iconRotate:b,itemStyle:m.itemStyle,lineStyle:m.lineStyle,symbolKeepAspect:y}))}else x.add(t.getLegendIcon({itemWidth:p,itemHeight:d,icon:l,iconRotate:g,itemStyle:m.itemStyle,lineStyle:m.lineStyle,symbolKeepAspect:y}));var w="left"===o?p+5:-5,S=o,M=r.get("formatter"),I=e;U(M)&&M?I=M.replace("{name}",null!=e?e:""):X(M)&&(I=M(e));var T=f?_.getTextColor():i.get("inactiveColor");x.add(new Xs({style:oc(_,{text:I,x:w,y:d/2,fill:T,align:S,verticalAlign:"middle"},{inheritColor:T})}));var C=new Ws({shape:x.getBoundingRect(),style:{fill:"transparent"}}),D=i.getModel("tooltip");return D.get("show")&&Kh({el:C,componentModel:r,itemName:e,itemTooltipOption:D.option}),x.add(C),x.eachChild((function(t){t.silent=!0})),C.silent=!u,this.getContentGroup().add(x),Ul(x),x.__legendDataIndex=n,x},e.prototype.layoutInner=function(t,e,n,i,r,o){var a=this.getContentGroup(),s=this.getSelectorGroup();Ap(t.get("orient"),a,t.get("itemGap"),n.width,n.height);var l=a.getBoundingRect(),u=[-l.x,-l.y];if(s.markRedraw(),a.markRedraw(),r){Ap("horizontal",s,t.get("selectorItemGap",!0));var h=s.getBoundingRect(),c=[-h.x,-h.y],p=t.get("selectorButtonGap",!0),d=t.getOrient().index,f=0===d?"width":"height",g=0===d?"height":"width",y=0===d?"y":"x";"end"===o?c[d]+=l[f]+p:u[d]+=h[f]+p,c[1-d]+=l[g]/2-h[g]/2,s.x=c[0],s.y=c[1],a.x=u[0],a.y=u[1];var v={x:0,y:0};return v[f]=l[f]+p+h[f],v[g]=Math.max(l[g],h[g]),v[y]=Math.min(0,h[y]+c[1-d]),v}return a.x=u[0],a.y=u[1],this.group.getBoundingRect()},e.prototype.remove=function(){this.getContentGroup().removeAll(),this._isFirstRender=!0},e.type="legend.plain",e}(Ag);function qB(t,e,n,i){JB(t,e,n,i),n.dispatchAction({type:"legendToggleSelect",name:null!=t?t:e}),$B(t,e,n,i)}function KB(t){for(var e,n=t.getZr().storage.getDisplayList(),i=0,r=n.length;in[r],f=[-c.x,-c.y];e||(f[i]=l[s]);var g=[0,0],y=[-p.x,-p.y],v=rt(t.get("pageButtonGap",!0),t.get("itemGap",!0));d&&("end"===t.get("pageButtonPosition",!0)?y[i]+=n[r]-p[r]:g[i]+=p[r]+v);y[1-i]+=c[o]/2-p[o]/2,l.setPosition(f),u.setPosition(g),h.setPosition(y);var m={x:0,y:0};if(m[r]=d?n[r]:c[r],m[o]=Math.max(c[o],p[o]),m[a]=Math.min(0,p[a]+y[1-i]),u.__rectSize=n[r],d){var x={x:0,y:0};x[r]=Math.max(n[r]-p[r]-v,0),x[o]=m[o],u.setClipPath(new Ws({shape:x})),u.__rectSize=x[r]}else h.eachChild((function(t){t.attr({invisible:!0,silent:!0})}));var _=this._getPageInfo(t);return null!=_.pageIndex&&vh(l,{x:_.contentPosition[0],y:_.contentPosition[1]},d?t:null),this._updatePageInfoView(t,_),m},e.prototype._pageGo=function(t,e,n){var i=this._getPageInfo(e)[t];null!=i&&n.dispatchAction({type:"legendScroll",scrollDataIndex:i,legendId:e.id})},e.prototype._updatePageInfoView=function(t,e){var n=this._controllerGroup;E(["pagePrev","pageNext"],(function(i){var r=null!=e[i+"DataIndex"],o=n.childOfName(i);o&&(o.setStyle("fill",r?t.get("pageIconColor",!0):t.get("pageIconInactiveColor",!0)),o.cursor=r?"pointer":"default")}));var i=n.childOfName("pageText"),r=t.get("pageFormatter"),o=e.pageIndex,a=null!=o?o+1:0,s=e.pageCount;i&&r&&i.setStyle("text",U(r)?r.replace("{current}",null==a?"":a+"").replace("{total}",null==s?"":s+""):r({current:a,total:s}))},e.prototype._getPageInfo=function(t){var e=t.get("scrollDataIndex",!0),n=this.getContentGroup(),i=this._containerGroup.__rectSize,r=t.getOrient().index,o=oF[r],a=aF[r],s=this._findTargetItemIndex(e),l=n.children(),u=l[s],h=l.length,c=h?1:0,p={contentPosition:[n.x,n.y],pageCount:c,pageIndex:c-1,pagePrevDataIndex:null,pageNextDataIndex:null};if(!u)return p;var d=m(u);p.contentPosition[r]=-d.s;for(var f=s+1,g=d,y=d,v=null;f<=h;++f)(!(v=m(l[f]))&&y.e>g.s+i||v&&!x(v,g.s))&&(g=y.i>g.i?y:v)&&(null==p.pageNextDataIndex&&(p.pageNextDataIndex=g.i),++p.pageCount),y=v;for(f=s-1,g=d,y=d,v=null;f>=-1;--f)(v=m(l[f]))&&x(y,v.s)||!(g.i=e&&t.s<=e+i}},e.prototype._findTargetItemIndex=function(t){return this._showController?(this.getContentGroup().eachChild((function(i,r){var o=i.__legendDataIndex;null==n&&null!=o&&(n=r),o===t&&(e=r)})),null!=e?e:n):0;var e,n},e.type="legend.scroll",e}(jB);function lF(t){Vm(eF),t.registerComponentModel(nF),t.registerComponentView(sF),function(t){t.registerAction("legendScroll","legendscroll",(function(t,e){var n=t.scrollDataIndex;null!=n&&e.eachComponent({mainType:"legend",subType:"scroll",query:t},(function(t){t.setScrollDataIndex(n)}))}))}(t)}var uF=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="dataZoom.inside",e.defaultOption=kc(az.defaultOption,{disabled:!1,zoomLock:!1,zoomOnMouseWheel:!0,moveOnMouseMove:!0,moveOnMouseWheel:!1,preventDefaultMouseMove:!0}),e}(az),hF=Vo();function cF(t,e,n){hF(t).coordSysRecordMap.each((function(t){var i=t.dataZoomInfoMap.get(e.uid);i&&(i.getRange=n)}))}function pF(t,e){if(e){t.removeKey(e.model.uid);var n=e.controller;n&&n.dispose()}}function dF(t,e){t.isDisposed()||t.dispatchAction({type:"dataZoom",animation:{easing:"cubicOut",duration:100},batch:e})}function fF(t,e,n,i){return t.coordinateSystem.containPoint([n,i])}function gF(t){t.registerProcessor(t.PRIORITY.PROCESSOR.FILTER,(function(t,e){var n=hF(e),i=n.coordSysRecordMap||(n.coordSysRecordMap=yt());i.each((function(t){t.dataZoomInfoMap=null})),t.eachComponent({mainType:"dataZoom",subType:"inside"},(function(t){E(rz(t).infoList,(function(n){var r=n.model.uid,o=i.get(r)||i.set(r,function(t,e){var n={model:e,containsPoint:H(fF,e),dispatchAction:H(dF,t),dataZoomInfoMap:null,controller:null},i=n.controller=new nT(t.getZr());return E(["pan","zoom","scrollMove"],(function(t){i.on(t,(function(e){var i=[];n.dataZoomInfoMap.each((function(r){if(e.isAvailableBehavior(r.model.option)){var o=(r.getRange||{})[t],a=o&&o(r.dzReferCoordSysInfo,n.model.mainType,n.controller,e);!r.model.get("disabled",!0)&&a&&i.push({dataZoomId:r.model.id,start:a[0],end:a[1]})}})),i.length&&n.dispatchAction(i)}))})),n}(e,n.model));(o.dataZoomInfoMap||(o.dataZoomInfoMap=yt())).set(t.uid,{dzReferCoordSysInfo:n,model:t,getRange:null})}))})),i.each((function(t){var e,n=t.controller,r=t.dataZoomInfoMap;if(r){var o=r.keys()[0];null!=o&&(e=r.get(o))}if(e){var a=function(t){var e,n="type_",i={type_true:2,type_move:1,type_false:0,type_undefined:-1},r=!0;return t.each((function(t){var o=t.model,a=!o.get("disabled",!0)&&(!o.get("zoomLock",!0)||"move");i[n+a]>i[n+e]&&(e=a),r=r&&o.get("preventDefaultMouseMove",!0)})),{controlType:e,opt:{zoomOnMouseWheel:!0,moveOnMouseMove:!0,moveOnMouseWheel:!0,preventDefaultMouseMove:!!r}}}(r);n.enable(a.controlType,a.opt),n.setPointerChecker(t.containsPoint),Hg(t,"dispatchAction",e.model.get("throttle",!0),"fixRate")}else pF(i,t)}))}))}var yF=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="dataZoom.inside",e}return n(e,t),e.prototype.render=function(e,n,i){t.prototype.render.apply(this,arguments),e.noTarget()?this._clear():(this.range=e.getPercentRange(),cF(i,e,{pan:W(vF.pan,this),zoom:W(vF.zoom,this),scrollMove:W(vF.scrollMove,this)}))},e.prototype.dispose=function(){this._clear(),t.prototype.dispose.apply(this,arguments)},e.prototype._clear=function(){!function(t,e){for(var n=hF(t).coordSysRecordMap,i=n.keys(),r=0;r0?s.pixelStart+s.pixelLength-s.pixel:s.pixel-s.pixelStart)/s.pixelLength*(o[1]-o[0])+o[0],u=Math.max(1/i.scale,0);o[0]=(o[0]-l)*u+l,o[1]=(o[1]-l)*u+l;var h=this.dataZoomModel.findRepresentativeAxisProxy().getMinMaxSpan();return zk(0,o,[0,100],0,h.minSpan,h.maxSpan),this.range=o,r[0]!==o[0]||r[1]!==o[1]?o:void 0}},pan:mF((function(t,e,n,i,r,o){var a=xF[i]([o.oldX,o.oldY],[o.newX,o.newY],e,r,n);return a.signal*(t[1]-t[0])*a.pixel/a.pixelLength})),scrollMove:mF((function(t,e,n,i,r,o){return xF[i]([0,0],[o.scrollDelta,o.scrollDelta],e,r,n).signal*(t[1]-t[0])*o.scrollDelta}))};function mF(t){return function(e,n,i,r){var o=this.range,a=o.slice(),s=e.axisModels[0];if(s)return zk(t(a,s,e,n,i,r),a,[0,100],"all"),this.range=a,o[0]!==a[0]||o[1]!==a[1]?a:void 0}}var xF={grid:function(t,e,n,i,r){var o=n.axis,a={},s=r.model.coordinateSystem.getRect();return t=t||[0,0],"x"===o.dim?(a.pixel=e[0]-t[0],a.pixelLength=s.width,a.pixelStart=s.x,a.signal=o.inverse?1:-1):(a.pixel=e[1]-t[1],a.pixelLength=s.height,a.pixelStart=s.y,a.signal=o.inverse?-1:1),a},polar:function(t,e,n,i,r){var o=n.axis,a={},s=r.model.coordinateSystem,l=s.getRadiusAxis().getExtent(),u=s.getAngleAxis().getExtent();return t=t?s.pointToCoord(t):[0,0],e=s.pointToCoord(e),"radiusAxis"===n.mainType?(a.pixel=e[0]-t[0],a.pixelLength=l[1]-l[0],a.pixelStart=l[0],a.signal=o.inverse?1:-1):(a.pixel=e[1]-t[1],a.pixelLength=u[1]-u[0],a.pixelStart=u[0],a.signal=o.inverse?-1:1),a},singleAxis:function(t,e,n,i,r){var o=n.axis,a=r.model.coordinateSystem.getRect(),s={};return t=t||[0,0],"horizontal"===o.orient?(s.pixel=e[0]-t[0],s.pixelLength=a.width,s.pixelStart=a.x,s.signal=o.inverse?1:-1):(s.pixel=e[1]-t[1],s.pixelLength=a.height,s.pixelStart=a.y,s.signal=o.inverse?-1:1),s}};function _F(t){yz(t),t.registerComponentModel(uF),t.registerComponentView(yF),gF(t)}var bF=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.type="dataZoom.slider",e.layoutMode="box",e.defaultOption=kc(az.defaultOption,{show:!0,right:"ph",top:"ph",width:"ph",height:"ph",left:null,bottom:null,borderColor:"#d2dbee",borderRadius:3,backgroundColor:"rgba(47,69,84,0)",dataBackground:{lineStyle:{color:"#d2dbee",width:.5},areaStyle:{color:"#d2dbee",opacity:.2}},selectedDataBackground:{lineStyle:{color:"#8fb0f7",width:.5},areaStyle:{color:"#8fb0f7",opacity:.2}},fillerColor:"rgba(135,175,274,0.2)",handleIcon:"path://M-9.35,34.56V42m0-40V9.5m-2,0h4a2,2,0,0,1,2,2v21a2,2,0,0,1-2,2h-4a2,2,0,0,1-2-2v-21A2,2,0,0,1-11.35,9.5Z",handleSize:"100%",handleStyle:{color:"#fff",borderColor:"#ACB8D1"},moveHandleSize:7,moveHandleIcon:"path://M-320.9-50L-320.9-50c18.1,0,27.1,9,27.1,27.1V85.7c0,18.1-9,27.1-27.1,27.1l0,0c-18.1,0-27.1-9-27.1-27.1V-22.9C-348-41-339-50-320.9-50z M-212.3-50L-212.3-50c18.1,0,27.1,9,27.1,27.1V85.7c0,18.1-9,27.1-27.1,27.1l0,0c-18.1,0-27.1-9-27.1-27.1V-22.9C-239.4-41-230.4-50-212.3-50z M-103.7-50L-103.7-50c18.1,0,27.1,9,27.1,27.1V85.7c0,18.1-9,27.1-27.1,27.1l0,0c-18.1,0-27.1-9-27.1-27.1V-22.9C-130.9-41-121.8-50-103.7-50z",moveHandleStyle:{color:"#D2DBEE",opacity:.7},showDetail:!0,showDataShadow:"auto",realtime:!0,zoomLock:!1,textStyle:{color:"#6E7079"},brushSelect:!0,brushStyle:{color:"rgba(135,175,274,0.15)"},emphasis:{handleStyle:{borderColor:"#8FB0F7"},moveHandleStyle:{color:"#8FB0F7"}}}),e}(az),wF=Ws,SF="horizontal",MF="vertical",IF=["line","bar","candlestick","scatter"],TF={easing:"cubicOut",duration:100,delay:0},CF=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n._displayables={},n}return n(e,t),e.prototype.init=function(t,e){this.api=e,this._onBrush=W(this._onBrush,this),this._onBrushEnd=W(this._onBrushEnd,this)},e.prototype.render=function(e,n,i,r){if(t.prototype.render.apply(this,arguments),Hg(this,"_dispatchZoomAction",e.get("throttle"),"fixRate"),this._orient=e.getOrient(),!1!==e.get("show")){if(e.noTarget())return this._clear(),void this.group.removeAll();r&&"dataZoom"===r.type&&r.from===this.uid||this._buildView(),this._updateView()}else this.group.removeAll()},e.prototype.dispose=function(){this._clear(),t.prototype.dispose.apply(this,arguments)},e.prototype._clear=function(){Yg(this,"_dispatchZoomAction");var t=this.api.getZr();t.off("mousemove",this._onBrush),t.off("mouseup",this._onBrushEnd)},e.prototype._buildView=function(){var t=this.group;t.removeAll(),this._brushing=!1,this._displayables.brushRect=null,this._resetLocation(),this._resetInterval();var e=this._displayables.sliderGroup=new Br;this._renderBackground(),this._renderHandle(),this._renderDataShadow(),t.add(e),this._positionGroup()},e.prototype._resetLocation=function(){var t=this.dataZoomModel,e=this.api,n=t.get("brushSelect")?7:0,i=this._findCoordRect(),r={width:e.getWidth(),height:e.getHeight()},o=this._orient===SF?{right:r.width-i.x-i.width,top:r.height-30-7-n,width:i.width,height:30}:{right:7,top:i.y,width:30,height:i.height},a=Rp(t.option);E(["right","top","width","height"],(function(t){"ph"===a[t]&&(a[t]=o[t])}));var s=kp(a,r);this._location={x:s.x,y:s.y},this._size=[s.width,s.height],this._orient===MF&&this._size.reverse()},e.prototype._positionGroup=function(){var t=this.group,e=this._location,n=this._orient,i=this.dataZoomModel.getFirstTargetAxisModel(),r=i&&i.get("inverse"),o=this._displayables.sliderGroup,a=(this._dataShadowInfo||{}).otherAxisInverse;o.attr(n!==SF||r?n===SF&&r?{scaleY:a?1:-1,scaleX:-1}:n!==MF||r?{scaleY:a?-1:1,scaleX:-1,rotation:Math.PI/2}:{scaleY:a?-1:1,scaleX:1,rotation:Math.PI/2}:{scaleY:a?1:-1,scaleX:1});var s=t.getBoundingRect([o]);t.x=e.x-s.x,t.y=e.y-s.y,t.markRedraw()},e.prototype._getViewExtent=function(){return[0,this._size[0]]},e.prototype._renderBackground=function(){var t=this.dataZoomModel,e=this._size,n=this._displayables.sliderGroup,i=t.get("brushSelect");n.add(new wF({silent:!0,shape:{x:0,y:0,width:e[0],height:e[1]},style:{fill:t.get("backgroundColor")},z2:-40}));var r=new wF({shape:{x:0,y:0,width:e[0],height:e[1]},style:{fill:"transparent"},z2:0,onclick:W(this._onClickPanel,this)}),o=this.api.getZr();i?(r.on("mousedown",this._onBrushStart,this),r.cursor="crosshair",o.on("mousemove",this._onBrush),o.on("mouseup",this._onBrushEnd)):(o.off("mousemove",this._onBrush),o.off("mouseup",this._onBrushEnd)),n.add(r)},e.prototype._renderDataShadow=function(){var t=this._dataShadowInfo=this._prepareDataShadowInfo();if(this._displayables.dataShadowSegs=[],t){var e=this._size,n=this._shadowSize||[],i=t.series,r=i.getRawData(),o=i.getShadowDim&&i.getShadowDim(),a=o&&r.getDimensionInfo(o)?i.getShadowDim():t.otherDim;if(null!=a){var s=this._shadowPolygonPts,l=this._shadowPolylinePts;if(r!==this._shadowData||a!==this._shadowDim||e[0]!==n[0]||e[1]!==n[1]){var u=r.getDataExtent(a),h=.3*(u[1]-u[0]);u=[u[0]-h,u[1]+h];var c,p=[0,e[1]],d=[0,e[0]],f=[[e[0],0],[0,0]],g=[],y=d[1]/(r.count()-1),v=0,m=Math.round(r.count()/e[0]);r.each([a],(function(t,e){if(m>0&&e%m)v+=y;else{var n=null==t||isNaN(t)||""===t,i=n?0:Kr(t,u,p,!0);n&&!c&&e?(f.push([f[f.length-1][0],0]),g.push([g[g.length-1][0],0])):!n&&c&&(f.push([v,0]),g.push([v,0])),f.push([v,i]),g.push([v,i]),v+=y,c=n}})),s=this._shadowPolygonPts=f,l=this._shadowPolylinePts=g}this._shadowData=r,this._shadowDim=a,this._shadowSize=[e[0],e[1]];for(var x=this.dataZoomModel,_=0;_<3;_++){var b=w(1===_);this._displayables.sliderGroup.add(b),this._displayables.dataShadowSegs.push(b)}}}function w(t){var e=x.getModel(t?"selectedDataBackground":"dataBackground"),n=new Br,i=new Xu({shape:{points:s},segmentIgnoreThreshold:1,style:e.getModel("areaStyle").getAreaStyle(),silent:!0,z2:-20}),r=new Zu({shape:{points:l},segmentIgnoreThreshold:1,style:e.getModel("lineStyle").getLineStyle(),silent:!0,z2:-19});return n.add(i),n.add(r),n}},e.prototype._prepareDataShadowInfo=function(){var t=this.dataZoomModel,e=t.get("showDataShadow");if(!1!==e){var n,i=this.ecModel;return t.eachTargetAxis((function(r,o){E(t.getAxisProxy(r,o).getTargetSeriesModels(),(function(t){if(!(n||!0!==e&&P(IF,t.get("type"))<0)){var a,s=i.getComponent(nz(r),o).axis,l=function(t){var e={x:"y",y:"x",radius:"angle",angle:"radius"};return e[t]}(r),u=t.coordinateSystem;null!=l&&u.getOtherAxis&&(a=u.getOtherAxis(s).inverse),l=t.getData().mapDimension(l),n={thisAxis:s,series:t,thisDim:r,otherDim:l,otherAxisInverse:a}}}),this)}),this),n}},e.prototype._renderHandle=function(){var t=this.group,e=this._displayables,n=e.handles=[null,null],i=e.handleLabels=[null,null],r=this._displayables.sliderGroup,o=this._size,a=this.dataZoomModel,s=this.api,l=a.get("borderRadius")||0,u=a.get("brushSelect"),h=e.filler=new wF({silent:u,style:{fill:a.get("fillerColor")},textConfig:{position:"inside"}});r.add(h),r.add(new wF({silent:!0,subPixelOptimize:!0,shape:{x:0,y:0,width:o[0],height:o[1],r:l},style:{stroke:a.get("dataBackgroundColor")||a.get("borderColor"),lineWidth:1,fill:"rgba(0,0,0,0)"}})),E([0,1],(function(e){var o=a.get("handleIcon");!Wy[o]&&o.indexOf("path://")<0&&o.indexOf("image://")<0&&(o="path://"+o);var s=Xy(o,-1,0,2,2,null,!0);s.attr({cursor:DF(this._orient),draggable:!0,drift:W(this._onDragMove,this,e),ondragend:W(this._onDragEnd,this),onmouseover:W(this._showDataInfo,this,!0),onmouseout:W(this._showDataInfo,this,!1),z2:5});var l=s.getBoundingRect(),u=a.get("handleSize");this._handleHeight=$r(u,this._size[1]),this._handleWidth=l.width/l.height*this._handleHeight,s.setStyle(a.getModel("handleStyle").getItemStyle()),s.style.strokeNoScale=!0,s.rectHover=!0,s.ensureState("emphasis").style=a.getModel(["emphasis","handleStyle"]).getItemStyle(),Ul(s);var h=a.get("handleColor");null!=h&&(s.style.fill=h),r.add(n[e]=s);var c=a.getModel("textStyle");t.add(i[e]=new Xs({silent:!0,invisible:!0,style:oc(c,{x:0,y:0,text:"",verticalAlign:"middle",align:"center",fill:c.getTextColor(),font:c.getFont()}),z2:10}))}),this);var c=h;if(u){var p=$r(a.get("moveHandleSize"),o[1]),d=e.moveHandle=new Ws({style:a.getModel("moveHandleStyle").getItemStyle(),silent:!0,shape:{r:[0,0,2,2],y:o[1]-.5,height:p}}),f=.8*p,g=e.moveHandleIcon=Xy(a.get("moveHandleIcon"),-f/2,-f/2,f,f,"#fff",!0);g.silent=!0,g.y=o[1]+p/2-.5,d.ensureState("emphasis").style=a.getModel(["emphasis","moveHandleStyle"]).getItemStyle();var y=Math.min(o[1]/2,Math.max(p,10));(c=e.moveZone=new Ws({invisible:!0,shape:{y:o[1]-y,height:p+y}})).on("mouseover",(function(){s.enterEmphasis(d)})).on("mouseout",(function(){s.leaveEmphasis(d)})),r.add(d),r.add(g),r.add(c)}c.attr({draggable:!0,cursor:DF(this._orient),drift:W(this._onDragMove,this,"all"),ondragstart:W(this._showDataInfo,this,!0),ondragend:W(this._onDragEnd,this),onmouseover:W(this._showDataInfo,this,!0),onmouseout:W(this._showDataInfo,this,!1)})},e.prototype._resetInterval=function(){var t=this._range=this.dataZoomModel.getPercentRange(),e=this._getViewExtent();this._handleEnds=[Kr(t[0],[0,100],e,!0),Kr(t[1],[0,100],e,!0)]},e.prototype._updateInterval=function(t,e){var n=this.dataZoomModel,i=this._handleEnds,r=this._getViewExtent(),o=n.findRepresentativeAxisProxy().getMinMaxSpan(),a=[0,100];zk(e,i,r,n.get("zoomLock")?"all":t,null!=o.minSpan?Kr(o.minSpan,a,r,!0):null,null!=o.maxSpan?Kr(o.maxSpan,a,r,!0):null);var s=this._range,l=this._range=Qr([Kr(i[0],r,a,!0),Kr(i[1],r,a,!0)]);return!s||s[0]!==l[0]||s[1]!==l[1]},e.prototype._updateView=function(t){var e=this._displayables,n=this._handleEnds,i=Qr(n.slice()),r=this._size;E([0,1],(function(t){var i=e.handles[t],o=this._handleHeight;i.attr({scaleX:o/2,scaleY:o/2,x:n[t]+(t?-1:1),y:r[1]/2-o/2})}),this),e.filler.setShape({x:i[0],y:0,width:i[1]-i[0],height:r[1]});var o={x:i[0],width:i[1]-i[0]};e.moveHandle&&(e.moveHandle.setShape(o),e.moveZone.setShape(o),e.moveZone.getBoundingRect(),e.moveHandleIcon&&e.moveHandleIcon.attr("x",o.x+o.width/2));for(var a=e.dataShadowSegs,s=[0,i[0],i[1],r[0]],l=0;le[0]||n[1]<0||n[1]>e[1])){var i=this._handleEnds,r=(i[0]+i[1])/2,o=this._updateInterval("all",n[0]-r);this._updateView(),o&&this._dispatchZoomAction(!1)}},e.prototype._onBrushStart=function(t){var e=t.offsetX,n=t.offsetY;this._brushStart=new De(e,n),this._brushing=!0,this._brushStartTime=+new Date},e.prototype._onBrushEnd=function(t){if(this._brushing){var e=this._displayables.brushRect;if(this._brushing=!1,e){e.attr("ignore",!0);var n=e.shape;if(!(+new Date-this._brushStartTime<200&&Math.abs(n.width)<5)){var i=this._getViewExtent(),r=[0,100];this._range=Qr([Kr(n.x,i,r,!0),Kr(n.x+n.width,i,r,!0)]),this._handleEnds=[n.x,n.x+n.width],this._updateView(),this._dispatchZoomAction(!1)}}}},e.prototype._onBrush=function(t){this._brushing&&(de(t.event),this._updateBrushRect(t.offsetX,t.offsetY))},e.prototype._updateBrushRect=function(t,e){var n=this._displayables,i=this.dataZoomModel,r=n.brushRect;r||(r=n.brushRect=new wF({silent:!0,style:i.getModel("brushStyle").getItemStyle()}),n.sliderGroup.add(r)),r.attr("ignore",!1);var o=this._brushStart,a=this._displayables.sliderGroup,s=a.transformCoordToLocal(t,e),l=a.transformCoordToLocal(o.x,o.y),u=this._size;s[0]=Math.max(Math.min(u[0],s[0]),0),r.setShape({x:l[0],y:0,width:s[0]-l[0],height:u[1]})},e.prototype._dispatchZoomAction=function(t){var e=this._range;this.api.dispatchAction({type:"dataZoom",from:this.uid,dataZoomId:this.dataZoomModel.id,animation:t?TF:null,start:e[0],end:e[1]})},e.prototype._findCoordRect=function(){var t,e=rz(this.dataZoomModel).infoList;if(!t&&e.length){var n=e[0].model.coordinateSystem;t=n.getRect&&n.getRect()}if(!t){var i=this.api.getWidth(),r=this.api.getHeight();t={x:.2*i,y:.2*r,width:.6*i,height:.6*r}}return t},e.type="dataZoom.slider",e}(uz);function DF(t){return"vertical"===t?"ns-resize":"ew-resize"}function AF(t){t.registerComponentModel(bF),t.registerComponentView(CF),yz(t)}var kF=function(t,e,n){var i=T((LF[t]||{})[e]);return n&&Y(i)?i[i.length-1]:i},LF={color:{active:["#006edd","#e0ffff"],inactive:["rgba(0,0,0,0)"]},colorHue:{active:[0,360],inactive:[0,0]},colorSaturation:{active:[.3,1],inactive:[0,0]},colorLightness:{active:[.9,.5],inactive:[0,0]},colorAlpha:{active:[.3,1],inactive:[0,0]},opacity:{active:[.3,1],inactive:[0,0]},symbol:{active:["circle","roundRect","diamond"],inactive:["none"]},symbolSize:{active:[10,50],inactive:[0,0]}},PF=kD.mapVisual,OF=kD.eachVisual,RF=Y,NF=E,EF=Qr,zF=Kr,VF=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n.stateList=["inRange","outOfRange"],n.replacableOptionKeys=["inRange","outOfRange","target","controller","color"],n.layoutMode={type:"box",ignoreSize:!0},n.dataBound=[-1/0,1/0],n.targetVisuals={},n.controllerVisuals={},n}return n(e,t),e.prototype.init=function(t,e,n){this.mergeDefaultAndTheme(t,n)},e.prototype.optionUpdated=function(t,e){var n=this.option;!e&&PV(n,t,this.replacableOptionKeys),this.textStyleModel=this.getModel("textStyle"),this.resetItemSize(),this.completeVisualOption()},e.prototype.resetVisual=function(t){var e=this.stateList;t=W(t,this),this.controllerVisuals=LV(this.option.controller,e,t),this.targetVisuals=LV(this.option.target,e,t)},e.prototype.getItemSymbol=function(){return null},e.prototype.getTargetSeriesIndices=function(){var t=this.option.seriesIndex,e=[];return null==t||"all"===t?this.ecModel.eachSeries((function(t,n){e.push(n)})):e=To(t),e},e.prototype.eachTargetSeries=function(t,e){E(this.getTargetSeriesIndices(),(function(n){var i=this.ecModel.getSeriesByIndex(n);i&&t.call(e,i)}),this)},e.prototype.isTargetSeries=function(t){var e=!1;return this.eachTargetSeries((function(n){n===t&&(e=!0)})),e},e.prototype.formatValueText=function(t,e,n){var i,r=this.option,o=r.precision,a=this.dataBound,s=r.formatter;n=n||["<",">"],Y(t)&&(t=t.slice(),i=!0);var l=e?t:i?[u(t[0]),u(t[1])]:u(t);return U(s)?s.replace("{value}",i?l[0]:l).replace("{value2}",i?l[1]:l):X(s)?i?s(t[0],t[1]):s(t):i?t[0]===a[0]?n[0]+" "+l[1]:t[1]===a[1]?n[1]+" "+l[0]:l[0]+" - "+l[1]:l;function u(t){return t===a[0]?"min":t===a[1]?"max":(+t).toFixed(Math.min(o,20))}},e.prototype.resetExtent=function(){var t=this.option,e=EF([t.min,t.max]);this._dataExtent=e},e.prototype.getDataDimensionIndex=function(t){var e=this.option.dimension;if(null!=e)return t.getDimensionIndex(e);for(var n=t.dimensions,i=n.length-1;i>=0;i--){var r=n[i],o=t.getDimensionInfo(r);if(!o.isCalculationCoord)return o.storeDimIndex}},e.prototype.getExtent=function(){return this._dataExtent.slice()},e.prototype.completeVisualOption=function(){var t=this.ecModel,e=this.option,n={inRange:e.inRange,outOfRange:e.outOfRange},i=e.target||(e.target={}),r=e.controller||(e.controller={});C(i,n),C(r,n);var o=this.isCategory();function a(n){RF(e.color)&&!n.inRange&&(n.inRange={color:e.color.slice().reverse()}),n.inRange=n.inRange||{color:t.get("gradientColor")}}a.call(this,i),a.call(this,r),function(t,e,n){var i=t[e],r=t[n];i&&!r&&(r=t[n]={},NF(i,(function(t,e){if(kD.isValidType(e)){var n=kF(e,"inactive",o);null!=n&&(r[e]=n,"color"!==e||r.hasOwnProperty("opacity")||r.hasOwnProperty("colorAlpha")||(r.opacity=[0,0]))}})))}.call(this,i,"inRange","outOfRange"),function(t){var e=(t.inRange||{}).symbol||(t.outOfRange||{}).symbol,n=(t.inRange||{}).symbolSize||(t.outOfRange||{}).symbolSize,i=this.get("inactiveColor"),r=this.getItemSymbol()||"roundRect";NF(this.stateList,(function(a){var s=this.itemSize,l=t[a];l||(l=t[a]={color:o?i:[i]}),null==l.symbol&&(l.symbol=e&&T(e)||(o?r:[r])),null==l.symbolSize&&(l.symbolSize=n&&T(n)||(o?s[0]:[s[0],s[0]])),l.symbol=PF(l.symbol,(function(t){return"none"===t?r:t}));var u=l.symbolSize;if(null!=u){var h=-1/0;OF(u,(function(t){t>h&&(h=t)})),l.symbolSize=PF(u,(function(t){return zF(t,[0,h],[0,s[0]],!0)}))}}),this)}.call(this,r)},e.prototype.resetItemSize=function(){this.itemSize=[parseFloat(this.get("itemWidth")),parseFloat(this.get("itemHeight"))]},e.prototype.isCategory=function(){return!!this.option.categories},e.prototype.setSelected=function(t){},e.prototype.getSelected=function(){return null},e.prototype.getValueState=function(t){return null},e.prototype.getVisualMeta=function(t){return null},e.type="visualMap",e.dependencies=["series"],e.defaultOption={show:!0,z:4,seriesIndex:"all",min:0,max:200,left:0,right:null,top:null,bottom:0,itemWidth:null,itemHeight:null,inverse:!1,orient:"vertical",backgroundColor:"rgba(0,0,0,0)",borderColor:"#ccc",contentColor:"#5793f3",inactiveColor:"#aaa",borderWidth:0,padding:5,textGap:10,precision:0,textStyle:{color:"#333"}},e}(zp),BF=[20,140],FF=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.optionUpdated=function(e,n){t.prototype.optionUpdated.apply(this,arguments),this.resetExtent(),this.resetVisual((function(t){t.mappingMethod="linear",t.dataExtent=this.getExtent()})),this._resetRange()},e.prototype.resetItemSize=function(){t.prototype.resetItemSize.apply(this,arguments);var e=this.itemSize;(null==e[0]||isNaN(e[0]))&&(e[0]=BF[0]),(null==e[1]||isNaN(e[1]))&&(e[1]=BF[1])},e.prototype._resetRange=function(){var t=this.getExtent(),e=this.option.range;!e||e.auto?(t.auto=1,this.option.range=t):Y(e)&&(e[0]>e[1]&&e.reverse(),e[0]=Math.max(e[0],t[0]),e[1]=Math.min(e[1],t[1]))},e.prototype.completeVisualOption=function(){t.prototype.completeVisualOption.apply(this,arguments),E(this.stateList,(function(t){var e=this.option.controller[t].symbolSize;e&&e[0]!==e[1]&&(e[0]=e[1]/3)}),this)},e.prototype.setSelected=function(t){this.option.range=t.slice(),this._resetRange()},e.prototype.getSelected=function(){var t=this.getExtent(),e=Qr((this.get("range")||[]).slice());return e[0]>t[1]&&(e[0]=t[1]),e[1]>t[1]&&(e[1]=t[1]),e[0]=n[1]||t<=e[1])?"inRange":"outOfRange"},e.prototype.findTargetDataIndices=function(t){var e=[];return this.eachTargetSeries((function(n){var i=[],r=n.getData();r.each(this.getDataDimensionIndex(r),(function(e,n){t[0]<=e&&e<=t[1]&&i.push(n)}),this),e.push({seriesId:n.id,dataIndex:i})}),this),e},e.prototype.getVisualMeta=function(t){var e=GF(this,"outOfRange",this.getExtent()),n=GF(this,"inRange",this.option.range.slice()),i=[];function r(e,n){i.push({value:e,color:t(e,n)})}for(var o=0,a=0,s=n.length,l=e.length;at[1])break;n.push({color:this.getControllerVisual(o,"color",e),offset:r/100})}return n.push({color:this.getControllerVisual(t[1],"color",e),offset:1}),n},e.prototype._createBarPoints=function(t,e){var n=this.visualMapModel.itemSize;return[[n[0]-e[0],t[0]],[n[0],t[0]],[n[0],t[1]],[n[0]-e[1],t[1]]]},e.prototype._createBarGroup=function(t){var e=this._orient,n=this.visualMapModel.get("inverse");return new Br("horizontal"!==e||n?"horizontal"===e&&n?{scaleX:"bottom"===t?-1:1,rotation:-Math.PI/2}:"vertical"!==e||n?{scaleX:"left"===t?1:-1}:{scaleX:"left"===t?1:-1,scaleY:-1}:{scaleX:"bottom"===t?1:-1,rotation:Math.PI/2})},e.prototype._updateHandle=function(t,e){if(this._useHandle){var n=this._shapes,i=this.visualMapModel,r=n.handleThumbs,o=n.handleLabels,a=i.itemSize,s=i.getExtent();ZF([0,1],(function(l){var u=r[l];u.setStyle("fill",e.handlesColor[l]),u.y=t[l];var h=UF(t[l],[0,a[1]],s,!0),c=this.getControllerVisual(h,"symbolSize");u.scaleX=u.scaleY=c/a[0],u.x=a[0]-c/2;var p=Fh(n.handleLabelPoints[l],Bh(u,this.group));o[l].setStyle({x:p[0],y:p[1],text:i.formatValueText(this._dataInterval[l]),verticalAlign:"middle",align:"vertical"===this._orient?this._applyTransform("left",n.mainGroup):"center"})}),this)}},e.prototype._showIndicator=function(t,e,n,i){var r=this.visualMapModel,o=r.getExtent(),a=r.itemSize,s=[0,a[1]],l=this._shapes,u=l.indicator;if(u){u.attr("invisible",!1);var h=this.getControllerVisual(t,"color",{convertOpacityToAlpha:!0}),c=this.getControllerVisual(t,"symbolSize"),p=UF(t,o,s,!0),d=a[0]-c/2,f={x:u.x,y:u.y};u.y=p,u.x=d;var g=Fh(l.indicatorLabelPoint,Bh(u,this.group)),y=l.indicatorLabel;y.attr("invisible",!1);var v=this._applyTransform("left",l.mainGroup),m="horizontal"===this._orient;y.setStyle({text:(n||"")+r.formatValueText(e),verticalAlign:m?v:"middle",align:m?"center":v});var x={x:d,y:p,style:{fill:h}},_={style:{x:g[0],y:g[1]}};if(r.ecModel.isAnimationEnabled()&&!this._firstShowIndicator){var b={duration:100,easing:"cubicInOut",additive:!0};u.x=f.x,u.y=f.y,u.animateTo(x,b),y.animateTo(_,b)}else u.attr(x),y.attr(_);this._firstShowIndicator=!1;var w=this._shapes.handleLabels;if(w)for(var S=0;Sr[1]&&(u[1]=1/0),e&&(u[0]===-1/0?this._showIndicator(l,u[1],"< ",a):u[1]===1/0?this._showIndicator(l,u[0],"> ",a):this._showIndicator(l,l,"≈ ",a));var h=this._hoverLinkDataIndices,c=[];(e||JF(n))&&(c=this._hoverLinkDataIndices=n.findTargetDataIndices(u));var p=function(t,e){var n={},i={};return r(t||[],n),r(e||[],i,n),[o(n),o(i)];function r(t,e,n){for(var i=0,r=t.length;i=0&&(r.dimension=o,i.push(r))}})),t.getData().setVisual("visualMeta",i)}}];function iG(t,e,n,i){for(var r=e.targetVisuals[i],o=kD.prepareVisualTypes(r),a={color:Ay(t.getData(),"color")},s=0,l=o.length;s0:t.splitNumber>0)&&!t.calculable?"piecewise":"continuous"})),t.registerAction(tG,eG),E(nG,(function(e){t.registerVisual(t.PRIORITY.VISUAL.COMPONENT,e)})),t.registerPreprocessor(oG))}function uG(t){t.registerComponentModel(FF),t.registerComponentView(KF),lG(t)}var hG=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n._pieceList=[],n}return n(e,t),e.prototype.optionUpdated=function(e,n){t.prototype.optionUpdated.apply(this,arguments),this.resetExtent();var i=this._mode=this._determineMode();this._pieceList=[],cG[this._mode].call(this,this._pieceList),this._resetSelected(e,n);var r=this.option.categories;this.resetVisual((function(t,e){"categories"===i?(t.mappingMethod="category",t.categories=T(r)):(t.dataExtent=this.getExtent(),t.mappingMethod="piecewise",t.pieceList=z(this._pieceList,(function(t){return t=T(t),"inRange"!==e&&(t.visual=null),t})))}))},e.prototype.completeVisualOption=function(){var e=this.option,n={},i=kD.listVisualTypes(),r=this.isCategory();function o(t,e,n){return t&&t[e]&&t[e].hasOwnProperty(n)}E(e.pieces,(function(t){E(i,(function(e){t.hasOwnProperty(e)&&(n[e]=1)}))})),E(n,(function(t,n){var i=!1;E(this.stateList,(function(t){i=i||o(e,t,n)||o(e.target,t,n)}),this),!i&&E(this.stateList,(function(t){(e[t]||(e[t]={}))[n]=kF(n,"inRange"===t?"active":"inactive",r)}))}),this),t.prototype.completeVisualOption.apply(this,arguments)},e.prototype._resetSelected=function(t,e){var n=this.option,i=this._pieceList,r=(e?n:t).selected||{};if(n.selected=r,E(i,(function(t,e){var n=this.getSelectedMapKey(t);r.hasOwnProperty(n)||(r[n]=!0)}),this),"single"===n.selectedMode){var o=!1;E(i,(function(t,e){var n=this.getSelectedMapKey(t);r[n]&&(o?r[n]=!1:o=!0)}),this)}},e.prototype.getItemSymbol=function(){return this.get("itemSymbol")},e.prototype.getSelectedMapKey=function(t){return"categories"===this._mode?t.value+"":t.index+""},e.prototype.getPieceList=function(){return this._pieceList},e.prototype._determineMode=function(){var t=this.option;return t.pieces&&t.pieces.length>0?"pieces":this.option.categories?"categories":"splitNumber"},e.prototype.setSelected=function(t){this.option.selected=T(t)},e.prototype.getValueState=function(t){var e=kD.findPieceIndex(t,this._pieceList);return null!=e&&this.option.selected[this.getSelectedMapKey(this._pieceList[e])]?"inRange":"outOfRange"},e.prototype.findTargetDataIndices=function(t){var e=[],n=this._pieceList;return this.eachTargetSeries((function(i){var r=[],o=i.getData();o.each(this.getDataDimensionIndex(o),(function(e,i){kD.findPieceIndex(e,n)===t&&r.push(i)}),this),e.push({seriesId:i.id,dataIndex:r})}),this),e},e.prototype.getRepresentValue=function(t){var e;if(this.isCategory())e=t.value;else if(null!=t.value)e=t.value;else{var n=t.interval||[];e=n[0]===-1/0&&n[1]===1/0?0:(n[0]+n[1])/2}return e},e.prototype.getVisualMeta=function(t){if(!this.isCategory()){var e=[],n=["",""],i=this,r=this._pieceList.slice();if(r.length){var o=r[0].interval[0];o!==-1/0&&r.unshift({interval:[-1/0,o]}),(o=r[r.length-1].interval[1])!==1/0&&r.push({interval:[o,1/0]})}else r.push({interval:[-1/0,1/0]});var a=-1/0;return E(r,(function(t){var e=t.interval;e&&(e[0]>a&&s([a,e[0]],"outOfRange"),s(e.slice()),a=e[1])}),this),{stops:e,outerColors:n}}function s(r,o){var a=i.getRepresentValue({interval:r});o||(o=i.getValueState(a));var s=t(a,o);r[0]===-1/0?n[0]=s:r[1]===1/0?n[1]=s:e.push({value:r[0],color:s},{value:r[1],color:s})}},e.type="visualMap.piecewise",e.defaultOption=kc(VF.defaultOption,{selected:null,minOpen:!1,maxOpen:!1,align:"auto",itemWidth:20,itemHeight:14,itemSymbol:"roundRect",pieces:null,categories:null,splitNumber:5,selectedMode:"multiple",itemGap:10,hoverLink:!0}),e}(VF),cG={splitNumber:function(t){var e=this.option,n=Math.min(e.precision,20),i=this.getExtent(),r=e.splitNumber;r=Math.max(parseInt(r,10),1),e.splitNumber=r;for(var o=(i[1]-i[0])/r;+o.toFixed(n)!==o&&n<5;)n++;e.precision=n,o=+o.toFixed(n),e.minOpen&&t.push({interval:[-1/0,i[0]],close:[0,0]});for(var a=0,s=i[0];a","≥"][e[0]]];t.text=t.text||this.formatValueText(null!=t.value?t.value:t.interval,!1,n)}),this)}};function pG(t,e){var n=t.inverse;("vertical"===t.orient?!n:n)&&e.reverse()}var dG=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.doRender=function(){var t=this.group;t.removeAll();var e=this.visualMapModel,n=e.get("textGap"),i=e.textStyleModel,r=i.getFont(),o=i.getTextColor(),a=this._getItemAlign(),s=e.itemSize,l=this._getViewData(),u=l.endsText,h=it(e.get("showLabel",!0),!u);u&&this._renderEndsText(t,u[0],s,h,a),E(l.viewPieceList,(function(i){var l=i.piece,u=new Br;u.onclick=W(this._onItemClick,this,l),this._enableHoverLink(u,i.indexInModelPieceList);var c=e.getRepresentValue(l);if(this._createItemSymbol(u,c,[0,0,s[0],s[1]]),h){var p=this.visualMapModel.getValueState(c);u.add(new Xs({style:{x:"right"===a?-n:s[0]+n,y:s[1]/2,text:l.text,verticalAlign:"middle",align:a,font:r,fill:o,opacity:"outOfRange"===p?.5:1}}))}t.add(u)}),this),u&&this._renderEndsText(t,u[1],s,h,a),Ap(e.get("orient"),t,e.get("itemGap")),this.renderBackground(t),this.positionGroup(t)},e.prototype._enableHoverLink=function(t,e){var n=this;t.on("mouseover",(function(){return i("highlight")})).on("mouseout",(function(){return i("downplay")}));var i=function(t){var i=n.visualMapModel;i.option.hoverLink&&n.api.dispatchAction({type:t,batch:XF(i.findTargetDataIndices(e),i)})}},e.prototype._getItemAlign=function(){var t=this.visualMapModel,e=t.option;if("vertical"===e.orient)return YF(t,this.api,t.itemSize);var n=e.align;return n&&"auto"!==n||(n="left"),n},e.prototype._renderEndsText=function(t,e,n,i,r){if(e){var o=new Br,a=this.visualMapModel.textStyleModel;o.add(new Xs({style:oc(a,{x:i?"right"===r?n[0]:0:n[0]/2,y:n[1]/2,verticalAlign:"middle",align:i?r:"center",text:e})})),t.add(o)}},e.prototype._getViewData=function(){var t=this.visualMapModel,e=z(t.getPieceList(),(function(t,e){return{piece:t,indexInModelPieceList:e}})),n=t.get("text"),i=t.get("orient"),r=t.get("inverse");return("horizontal"===i?r:!r)?e.reverse():n&&(n=n.slice().reverse()),{viewPieceList:e,endsText:n}},e.prototype._createItemSymbol=function(t,e,n){t.add(Xy(this.getControllerVisual(e,"symbol"),n[0],n[1],n[2],n[3],this.getControllerVisual(e,"color")))},e.prototype._onItemClick=function(t){var e=this.visualMapModel,n=e.option,i=n.selectedMode;if(i){var r=T(n.selected),o=e.getSelectedMapKey(t);"single"===i||!0===i?(r[o]=!0,E(r,(function(t,e){r[e]=e===o}))):r[o]=!r[o],this.api.dispatchAction({type:"selectDataRange",from:this.uid,visualMapId:this.visualMapModel.id,selected:r})}},e.type="visualMap.piecewise",e}(WF);function fG(t){t.registerComponentModel(hG),t.registerComponentView(dG),lG(t)}var gG={label:{enabled:!0},decal:{show:!1}},yG=Vo(),vG={};function mG(t,e){var n=t.getModel("aria");if(n.get("enabled")){var i=T(gG);C(i.label,t.getLocaleModel().get("aria"),!1),C(n.option,i,!1),function(){if(n.getModel("decal").get("show")){var e=yt();t.eachSeries((function(t){if(!t.isColorBySeries()){var n=e.get(t.type);n||(n={},e.set(t.type,n)),yG(t).scope=n}})),t.eachRawSeries((function(e){if(!t.isSeriesFiltered(e))if(X(e.enableAriaDecal))e.enableAriaDecal();else{var n=e.getData();if(e.isColorBySeries()){var i=pd(e.ecModel,e.name,vG,t.getSeriesCount()),r=n.getVisual("decal");n.setVisual("decal",u(r,i))}else{var o=e.getRawData(),a={},s=yG(e).scope;n.each((function(t){var e=n.getRawIndex(t);a[e]=t}));var l=o.count();o.each((function(t){var i=a[t],r=o.getName(t)||t+"",h=pd(e.ecModel,r,s,l),c=n.getItemVisual(i,"decal");n.setItemVisual(i,"decal",u(c,h))}))}}function u(t,e){var n=t?A(A({},e),t):e;return n.dirty=!0,n}}))}}(),function(){var i=e.getZr().dom;if(!i)return;var o=t.getLocaleModel().get("aria"),a=n.getModel("label");if(a.option=k(a.option,o),!a.get("enabled"))return;if(a.get("description"))return void i.setAttribute("aria-label",a.get("description"));var s,l=t.getSeriesCount(),u=a.get(["data","maxCount"])||10,h=a.get(["series","maxCount"])||10,c=Math.min(l,h);if(l<1)return;var p=function(){var e=t.get("title");e&&e.length&&(e=e[0]);return e&&e.text}();s=p?r(a.get(["general","withTitle"]),{title:p}):a.get(["general","withoutTitle"]);var d=[];s+=r(l>1?a.get(["series","multiple","prefix"]):a.get(["series","single","prefix"]),{seriesCount:l}),t.eachSeries((function(e,n){if(n1?a.get(["series","multiple",o]):a.get(["series","single",o]),{seriesId:e.seriesIndex,seriesName:e.get("name"),seriesType:(x=e.subType,_=t.getLocaleModel().get(["series","typeNames"]),_[x]||_.chart)});var s=e.getData();if(s.count()>u)i+=r(a.get(["data","partialData"]),{displayCnt:u});else i+=a.get(["data","allData"]);for(var h=a.get(["data","separator","middle"]),p=a.get(["data","separator","end"]),f=[],g=0;g":"gt",">=":"gte","=":"eq","!=":"ne","<>":"ne"},bG=function(){function t(t){if(null==(this._condVal=U(t)?new RegExp(t):et(t)?t:null)){var e="";0,wo(e)}}return t.prototype.evaluate=function(t){var e=typeof t;return U(e)?this._condVal.test(t):!!j(e)&&this._condVal.test(t+"")},t}(),wG=function(){function t(){}return t.prototype.evaluate=function(){return this.value},t}(),SG=function(){function t(){}return t.prototype.evaluate=function(){for(var t=this.children,e=0;e2&&l.push(e),e=[t,n]}function f(t,n,i,r){EG(t,i)&&EG(n,r)||e.push(t,n,i,r,i,r)}function g(t,n,i,r,o,a){var s=Math.abs(n-t),l=4*Math.tan(s/4)/3,u=nM:C2&&l.push(e),l}function VG(t,e,n,i,r,o,a,s,l,u){if(EG(t,n)&&EG(e,i)&&EG(r,a)&&EG(o,s))l.push(a,s);else{var h=2/u,c=h*h,p=a-t,d=s-e,f=Math.sqrt(p*p+d*d);p/=f,d/=f;var g=n-t,y=i-e,v=r-a,m=o-s,x=g*g+y*y,_=v*v+m*m;if(x=0&&_-w*w=0)l.push(a,s);else{var S=[],M=[];wn(t,n,r,a,.5,S),wn(e,i,o,s,.5,M),VG(S[0],M[0],S[1],M[1],S[2],M[2],S[3],M[3],l,u),VG(S[4],M[4],S[5],M[5],S[6],M[6],S[7],M[7],l,u)}}}}function BG(t,e,n){var i=t[e],r=t[1-e],o=Math.abs(i/r),a=Math.ceil(Math.sqrt(o*n)),s=Math.floor(n/a);0===s&&(s=1,a=n);for(var l=[],u=0;u0)for(u=0;uMath.abs(u),c=BG([l,u],h?0:1,e),p=(h?s:u)/c.length,d=0;d1?null:new De(d*l+t,d*u+e)}function HG(t,e,n){var i=new De;De.sub(i,n,e),i.normalize();var r=new De;return De.sub(r,t,e),r.dot(i)}function YG(t,e){var n=t[t.length-1];n&&n[0]===e[0]&&n[1]===e[1]||t.push(e)}function XG(t){var e=t.points,n=[],i=[];Ba(e,n,i);var r=new ze(n[0],n[1],i[0]-n[0],i[1]-n[1]),o=r.width,a=r.height,s=r.x,l=r.y,u=new De,h=new De;return o>a?(u.x=h.x=s+o/2,u.y=l,h.y=l+a):(u.y=h.y=l+a/2,u.x=s,h.x=s+o),function(t,e,n){for(var i=t.length,r=[],o=0;or,a=BG([i,r],o?0:1,e),s=o?"width":"height",l=o?"height":"width",u=o?"x":"y",h=o?"y":"x",c=t[s]/a.length,p=0;p0)for(var b=i/n,w=-i/2;w<=i/2;w+=b){var S=Math.sin(w),M=Math.cos(w),I=0;for(x=0;x0;l/=2){var u=0,h=0;(t&l)>0&&(u=1),(e&l)>0&&(h=1),s+=l*l*(3*u^h),0===h&&(1===u&&(t=l-1-t,e=l-1-e),a=t,t=e,e=a)}return s}function lW(t){var e=1/0,n=1/0,i=-1/0,r=-1/0,o=z(t,(function(t){var o=t.getBoundingRect(),a=t.getComputedTransform(),s=o.x+o.width/2+(a?a[4]:0),l=o.y+o.height/2+(a?a[5]:0);return e=Math.min(s,e),n=Math.min(l,n),i=Math.max(s,i),r=Math.max(l,r),[s,l]}));return z(o,(function(o,a){return{cp:o,z:sW(o[0],o[1],e,n,i,r),path:t[a]}})).sort((function(t,e){return t.z-e.z})).map((function(t){return t.path}))}function uW(t){return jG(t.path,t.count)}function hW(t){return Y(t[0])}function cW(t,e){for(var n=[],i=t.length,r=0;r=0;r--)if(!n[r].many.length){var l=n[s].many;if(l.length<=1){if(!s)return n;s=0}o=l.length;var u=Math.ceil(o/2);n[r].many=l.slice(u,o),n[s].many=l.slice(0,u),s++}return n}var pW={clone:function(t){for(var e=[],n=1-Math.pow(1-t.path.style.opacity,1/t.count),i=0;i0){var s,l,u=i.getModel("universalTransition").get("delay"),h=Object.assign({setToFinal:!0},a);hW(t)&&(s=t,l=e),hW(e)&&(s=e,l=t);for(var c=s?s===t:t.length>e.length,p=s?cW(l,s):cW(c?e:t,[c?t:e]),d=0,f=0;f1e4))for(var r=n.getIndices(),o=0;o0&&i.group.traverse((function(t){t instanceof ks&&!t.animators.length&&t.animateFrom({style:{opacity:0}},r)}))}))}function SW(t){var e=t.getModel("universalTransition").get("seriesKey");return e||t.id}function MW(t){return Y(t)?t.sort().join(","):t}function IW(t){if(t.hostModel)return t.hostModel.getModel("universalTransition").get("divideShape")}function TW(t,e){for(var n=0;n=0&&r.push({dataGroupId:e.oldDataGroupIds[n],data:e.oldData[n],divide:IW(e.oldData[n]),groupIdDim:t.dimension})})),E(To(t.to),(function(t){var i=TW(n.updatedSeries,t);if(i>=0){var r=n.updatedSeries[i].getData();o.push({dataGroupId:e.oldDataGroupIds[i],data:r,divide:IW(r),groupIdDim:t.dimension})}})),r.length>0&&o.length>0&&wW(r,o,i)}(t,i,n,e)}));else{var o=function(t,e){var n=yt(),i=yt(),r=yt();return E(t.oldSeries,(function(e,n){var o=t.oldDataGroupIds[n],a=t.oldData[n],s=SW(e),l=MW(s);i.set(l,{dataGroupId:o,data:a}),Y(s)&&E(s,(function(t){r.set(t,{key:l,dataGroupId:o,data:a})}))})),E(e.updatedSeries,(function(t){if(t.isUniversalTransitionEnabled()&&t.isAnimationEnabled()){var e=t.get("dataGroupId"),o=t.getData(),a=SW(t),s=MW(a),l=i.get(s);if(l)n.set(s,{oldSeries:[{dataGroupId:l.dataGroupId,divide:IW(l.data),data:l.data}],newSeries:[{dataGroupId:e,divide:IW(o),data:o}]});else if(Y(a)){var u=[];E(a,(function(t){var e=i.get(t);e.data&&u.push({dataGroupId:e.dataGroupId,divide:IW(e.data),data:e.data})})),u.length&&n.set(s,{oldSeries:u,newSeries:[{dataGroupId:e,data:o,divide:IW(o)}]})}else{var h=r.get(a);if(h){var c=n.get(h.key);c||(c={oldSeries:[{dataGroupId:h.dataGroupId,data:h.data,divide:IW(h.data)}],newSeries:[]},n.set(h.key,c)),c.newSeries.push({dataGroupId:e,data:o,divide:IW(o)})}}}})),n}(i,n);E(o.keys(),(function(t){var n=o.get(t);wW(n.oldSeries,n.newSeries,e)}))}E(n.updatedSeries,(function(t){t[_g]&&(t[_g]=!1)}))}for(var a=t.getSeries(),s=i.oldSeries=[],l=i.oldDataGroupIds=[],u=i.oldData=[],h=0;h + + + +衍水工资系统 · 视觉方向预览 + + + +
+ + +
+
衍水集团
+

营销人员工资管理系统 · 视觉方向

+
企业级扁平 · 轻质感 · Bento 布局 · 品牌色点缀 · 微动效 · 精致文案
+
+
+ 表面微渐变(受光) + 半透明描边 + 分层阴影 + 内高光 / 内阴影 + 2% 噪点 + 等宽数字 + 120–180ms 轻动效 +
+
+ + +
+

01 · 色板与材质

+
+
+
+
靛蓝
#2E4BA8 ↑#3A58BC
+
品牌色。按钮/链接/焦点/当前菜单,配微渐变与内高光
+
+
+
+
侧栏灰
#F2F4F8 → #ECEDF3
+
侧栏微渐变(自上而下微沉)
+
+
+
+
雾灰
#F4F5F8
+
页面底
+
+
+
+
纸白
#FFFFFF → #FCFCFE
+
卡片/表格:顶白底微灰的「受光」渐变
+
+
+
+
界线
rgba(28,35,51,.09)
+
1px 半透明描边:在不同底色上自然融合
+
+
+
+
墨色
#1C2333
+
主文字(冷调近黑)
+
+
+
+
次墨
#5B6577
+
次级文字、表头、说明
+
+
+
+
语义色
#1E9E6A · #C98A1B · #C94F4F
+
成功/警示/危险,浅底微渐变 + 深字
+
+
+
+ + +
+

02 · 字体与数字

+
+
页面标题岗位角色18px / 600 · 字距 −0.01em
+
卡片标题计提参数 · 业务员个人户15px / 600
+
正文 / 表格回款天数 = 发货日首日起算,按自然日累计14px / 400 · 13px / 400
+
表头 / 标签岗位编码 公式模板 生效日期12px / 500 · 字距 +0.04em
+
品牌字衍水集团无衬线 600 · 大字距 · 仅品牌落款一处
+
+
+ + + + + + + +
回款日期回款金额(元)回款天数计提比例绩效金额(元)
2026-01-10386,200.00279.7‰14,062.31
2026-02-181,240,000.003614‰19,840.00
合计1,626,200.00——33,902.31
+
金额与比例一律等宽数字(tabular-nums)右对齐,合计行加粗放大半档——列对列、位对位,扫一眼就能核对。
+
+
+
+
衍水集团
+
A · 思源黑体 500 · 0.32em 当前应用
+
+
+
衍水集团
+
B · 思源黑体 300 细体 · 0.42em
+
+
+
衍水集团
+
C · 系统默认 600(不指定字体,展示回退效果)· 0.25em
+
+
+
+ + +
+

03 · 组件基调

+
+
+

按钮 · 三态

+
+
静置
+
悬停:抬升 1px · 影加深
+
按下:回落 · 内阴影
+
+
微渐变 + 内高光给按钮「实体感」;次级按钮一律白底细边框,无阴影污染。
+
+
+

输入框 · 状态

+
+ + +
+
+ 启用 + 禁用 + 待生效 +
+
输入框带微内阴影(凹陷暗示);聚焦 = 靛蓝描边 + 3px 柔光外环。
+
+
+

分页

+
+ ‹123› + 共 28 条 · 每页 10 条 +
+
当前页与主按钮同材质:微渐变 + 内高光。
+
+
+

动效原则

+
+ 过渡 120–180ms,只做颜色、阴影与 1px 内的位移;弹层淡入 + 4px 上移; + 数字与表格不加动画;尊重 prefers-reduced-motion。 + 动效的目标是「顺滑可信」,不是表演。 +
+
+
+
+ + +
+

04 · 应用界面(布局与列表页)

+
+
+ +
+
+
系统管理 / 岗位角色
+
管管理员退出登录
+
+
+
+
+

岗位角色

+
决定人员适用的计提公式模板与参数;新增岗位无需改代码
+
+
+ +
+
+ + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
岗位编码岗位名称公式模板说明状态操作
salesperson营销员业务员个人户 · 天数线性递减回款天数线性递减(360 天=6‰,361 天起 4‰)启用编辑删除
sales_manager营销经理营销经理 · 团队与全公司分成本团队 12.5% + 全公司 5%启用编辑删除
info_officer信息员信息员 · 销售额固定比例本人销售额 × 1‰启用编辑删除
senior_sales高级业务员业务员个人户 · 天数线性递减自定义岗位(新增岗位无需改代码)禁用编辑删除
+
+
+ ‹1› + 共 4 条 +
+
+
+
+
+
+
+
+ 质感的三层材料:侧栏灰渐变(沉)→ 内容底雾灰 + 顶部受光 → 卡片纸白微渐变 + 半透明描边 + 分层阴影。 + 当前项白底浮起,白色被灰托住,不再扎眼。 +
+
+ + +
+

05 · 首页仪表盘(Bento Grid)

+
+
+ +
+
+
仪表盘
+
管管理员退出登录
+
+
+
+
+

仪表盘

+
数据截至今日 06:00 · 12 笔回款待核算
+
+
+
+
+
+
本月绩效总额
+
268,430.16元
+
较上月 +12.4% · 已核销 86 笔
+
+
+
+
近 6 个月回款
+
单位:万元
+
+
+
+
待办
+
+
12 笔回款待核算
+
3 笔业务归属待确认
+
+
+
+
营销人员
+ +
+
36人
+
+
+
岗位角色
+ +
+
3个
+
+
+
常用操作
+
+ + + +
+
+
+
计提参数
+ +
+
16项
+
版本 P20260101-00
+
+
+
T+ 同步
+ +
+
正常
+
每周一 06:00
+
+
+
+
+
+
+
+ Bento Grid:大小不一的卡片按重要性排布——绩效总额与回款趋势占 2×2 主角位,数字型指标 1×1 紧凑排列。 + 卡片入场为 45ms 交错上浮(320ms),悬停抬升 2px、阴影加深——打开预览即可看到微动效。 +
+
+ + +
+

06 · 登录页(重做)

+ +
+ 背景三层:点阵网格(秩序)+ 顶部靛蓝天光(氛围)+ 右上涟漪同心圆(「衍水」的品牌装饰); + 卡片内品牌区承担门面——品牌字、标语、水线;表单以「欢迎回来」承接。装饰与文案都来自「衍水」本身。 +
+
+ + +
+

07 · 签名元素:水线

+
+
计提参数
+
+
+ 页面标题下的渐隐靛蓝细线(56px,左实右虚)——「衍水」之水的提纯,全站唯一的装饰性元素, + 只在每个页面标题处出现一次。它与登录页的水纹层理同源:品牌连续,而非推倒重来。 +
+
+
+ + +
+

08 · 微动效与精致文案(全局规范)

+
+
+

微动效 · 全站统一

+ + + + + + + + + +
页面 / 卡片入场淡入 + 上浮 6px,45ms 交错320ms
按钮悬停 / 按下抬升 1px、影加深 / 回落内阴影150ms
卡片悬停抬升 2px、阴影加深180ms
输入聚焦靛蓝描边 + 3px 柔光外环150ms
弹层 / 抽屉淡入 + 上移 4px160ms
表格行底色渐显(无位移)120ms
数据刷新数字不做滚动动画(核对场景,动即干扰)—
无障碍prefers-reduced-motion 时全部关闭—
+
目标不是「有动画」,而是顺滑可信:每次操作都有轻微、及时、可预期的反馈。
+
+
+

精致文案 · 全站统一

+ + + + + + + +
按钮提交保存变更 · 录入回款
空态暂无数据还没有岗位角色,点「新增岗位」创建第一个
错误操作失败岗位编码已存在,请换一个
危险确认确定删除吗?删除后无法恢复,该岗位下的人员需重新归属
成功提示操作成功岗位已创建 · 参数 11-01 起生效
页面描述本页面用于管理决定人员适用的计提公式模板与参数;新增岗位无需改代码
+
说清楚会发生什么;动词开头、句子短;不道歉、不卖萌;空态是邀请,错误是路标。
+
+
+
+ +
+ + + + diff --git a/docs/superpowers/plans/2026-10-12-M1-系统设置与参数体系.md b/docs/superpowers/plans/2026-10-12-M1-系统设置与参数体系.md new file mode 100644 index 0000000..70715c5 --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-M1-系统设置与参数体系.md @@ -0,0 +1,268 @@ +# M1 开发计划:系统设置与参数体系(#28) + +## Context + +《剩余开发计划》已定稿(`docs/superpowers/plans/2026-10-12-剩余开发计划.md`),M1 是首个工作块。本文档即 M1 的正式开发计划(2026-10-12 批准):涵盖设计(四表/参数/模板/接口)、任务分解(T1–T8 步骤级)、执行安排与验收标准;开工后按第二章逐任务执行。 + +**为什么先做 M1**:岗位角色被基础数据的人员管理引用(外键上游);参数快照与公式模板是绩效核算的输入契约。先立地基可避免后续模块加表、加外键、改表单的连锁返工;计算预览还可作为业务对账工具先行验收公式口径。 + +**#28 需求原文要点**:① 人员岗位角色(营销员/营销经理/信息员等)支持增删,新增岗位时在系统设置中绑定计提公式与参数,**无需改代码**;② 全部计提比例、系数、阈值、分成比例、起付比例统一在系统设置维护,**支持版本管理与留痕**。 + +## 现状(开工前已核查) + +| 项 | 状态 | +| --- | --- | +| 分支 | `feat/system-params`(基于 `feature/system-management` HEAD;后者领先 main 5 提交、未合并——是否先合并 main 由用户决定,不阻塞开发) | +| 容器 | Docker Desktop 位于 `D:\work\app\Docker\Docker Desktop.exe`(非默认路径) | +| 工作区 | docs/ 下有用户侧整理改动(业务清单删除、v2 新增、superpowers 目录),与 M1 无关,不动 | +| 基线 | 301 用例全绿(系统管理交付时);开工先复跑确认 | + +## 范围 + +**做**:四张新表与种子(V3 迁移双轨);岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数与模板注册表;计算预览;前端两个页面(岗位角色、计提参数)。 + +**不做**:核算引擎与台账(M3);基础数据与业务数据(M2);参数 Redis 缓存(一期不做,保留键位);规则引擎(既有决策)。 + +--- + +## 一、技术设计 + +### 1.1 数据模型(四张新表,V3 迁移) + +**`business_roles` 业务岗位角色表**(与系统登录角色 `roles` 严格区分,表注释写明) + +| 列 | 类型 | 约束/说明 | +| --- | --- | --- | +| id | BIGINT UNSIGNED | PK AUTO_INCREMENT | +| role_code | VARCHAR(32) | NOT NULL,uk;创建后不可改 | +| role_name | VARCHAR(64) | NOT NULL | +| calc_template | VARCHAR(32) | NOT NULL,公式模板编码(以代码注册表为准) | +| description | VARCHAR(255) | NULL | +| sort_order | INT | NOT NULL DEFAULT 0 | +| status | TINYINT | NOT NULL DEFAULT 1(1 启用 0 禁用) | +| is_builtin | TINYINT | NOT NULL DEFAULT 0(1 内置禁删) | +| create_time / update_time | DATETIME | TimestampMixin(update 用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")`) | + +**`param_definitions` 参数定义表**:`param_code` VARCHAR(64) uk;`param_name` VARCHAR(64);`param_group` VARCHAR(32);`value_type` VARCHAR(16)(DECIMAL/INT/BOOL/ENUM/TEXT);`unit` VARCHAR(16)(‰/%/元/天);`dimension_type` VARCHAR(32) DEFAULT 'NONE'(NONE/PRODUCT_CATEGORY/BUSINESS_ROLE);`options_text` VARCHAR(255)(枚举竖线分隔);`default_value`/`value_min`/`value_max` VARCHAR(64)(Fraction 可解析字符串);`description` VARCHAR(255);`sort_order` INT;`status` TINYINT;TimestampMixin。 + +**`param_change_batches` 参数变更批次表**:`version_no` VARCHAR(32) uk(`P{YYYYMMDD}-{当日序号}`);`effective_date` DATE NOT NULL idx;`change_type` TINYINT(1 立即 2 指定日期);`remark` VARCHAR(255);`operator_id` FK→users ON DELETE SET NULL + `operator_name` VARCHAR(64) 冗余固化;`item_count` INT DEFAULT 0;仅 `create_time`(append-only,不继承 TimestampMixin)。 + +**`param_values` 参数版本条目表(append-only)**:`param_id` FK→param_definitions RESTRICT idx;`dimension_value` VARCHAR(32) NOT NULL DEFAULT `''`(用空串不用 NULL);`effective_date` DATE NOT NULL idx;`value_text` VARCHAR(64) NOT NULL;`old_value_text` VARCHAR(64);`batch_id` FK→param_change_batches RESTRICT idx;仅 `create_time`。热路径索引 `idx_param_values_param_id_dimension_value_effective_date`。已生效条目禁改禁删;未生效批次可整批撤回。 + +**种子**(迁移 + init-dev 双写):3 个内置岗位(`salesperson 营销员`/TPL_SALESPERSON_DECAY、`sales_manager 营销经理`/TPL_MANAGER_SHARE、`info_officer 信息员`/TPL_INFO_SALES_FIXED)+ 16 项参数定义 + 版本条目 + 一条初始批次(`P20260101-00`)。 + +### 1.2 参数清单(16 项) + +| param_code | 名称 | 类型/单位 | 维度 | 默认值 | 生效日期 | +| --- | --- | --- | --- | --- | --- | +| salesperson.rate.start | 业务员起始计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 10 | 2026-02-17 | +| salesperson.rate.slope | 业务员日递减斜率 | DECIMAL ‰/天 | BUSINESS_ROLE | **4/360** | 2026-02-17 | +| salesperson.rate.floor | 业务员计提下限 | DECIMAL ‰ | BUSINESS_ROLE | 4 | 2026-02-17 | +| salesperson.days.boundary | 线性递减分界天数 | INT 天 | BUSINESS_ROLE | 360 | 2026-02-17 | +| manager.share.team | 经理本团队分成 | DECIMAL % | BUSINESS_ROLE | 12.5 | 2026-08-13 | +| manager.share.company | 经理全公司分成 | DECIMAL % | BUSINESS_ROLE | 5 | 2026-08-13 | +| enterprise.rate | 企业户计提比例 | DECIMAL ‰ | PRODUCT_CATEGORY | **2(02-17 起)/ 3(08-13 起)** | 双行 | +| info.rate | 信息员计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 1 | 2026-08-13 | +| prepay.rate | 预付款计提比例 | DECIMAL ‰ | NONE | 10 | 2026-01-01 | +| barter.discount | 以物抵顶折算比例 | DECIMAL % | NONE | 80 | 2026-01-01 | +| payment.start.ratio | 支付起付比例 | DECIMAL % | NONE | 60 | 2026-01-01 | +| payment.weight.mode | 支付权重应用方式 | ENUM FULL\|BALANCE | NONE | FULL | 2026-01-01 | +| product.threshold | 折算毛利润额阈值 | DECIMAL % | PRODUCT_CATEGORY | 市政水利 30 / 轨交 20 | 2026-02-17 | +| salary.basic.mode | 月工资基本工资口径 | ENUM INCLUDE\|DEDUCT\|EXCLUDE | BUSINESS_ROLE | 业务员 INCLUDE / 经理 DEDUCT | 2026-01-01 | +| probation.annual.threshold | 试用期年累计结算阈值 | DECIMAL 元 | NONE | 18000 | 2026-01-01 | +| probation.salary.basic | 试用期基本工资标准 | DECIMAL 元 | NONE | 3000 | 2026-01-01 | + +**不设参数**(作废条款落地):经理个人户 8‰ 起始/斜率;协助 25% 分成;含运费口径开关。 + +**值校验规则**(`validate_and_normalize`):DECIMAL 用 `Fraction(text)` 解析(拒绝 `1/0`、非数字),并按 `value_min`/`value_max` 校验;INT 必须整数;BOOL 仅 0/1;ENUM 必须在 `options_text` 内;规范化去首尾空白、分数式原样存储。 + +### 1.3 公式模板与消费契约 + +**模板注册表**(`app/services/calc_templates.py`,代码内有限集合,一期不引入规则引擎的落地方式): + +| 模板编码 | 形态 | 声明参数 | +| --- | --- | --- | +| TPL_SALESPERSON_DECAY | 分段:≤分界 = 起始 − 天数×斜率;> 分界 = 下限 | salesperson.rate.* / days.boundary | +| TPL_MANAGER_SHARE | 聚合:∑本团队×12.5% + ∑全公司×5% | manager.share.* | +| TPL_ENTERPRISE_FIXED | 乘法:折算毛利润额 × 比例 | enterprise.rate | +| TPL_INFO_SALES_FIXED | 乘法:本人销售额 × 比例 | info.rate | +| TPL_PREPAY_FIXED | 乘法:回款额 × 比例 | prepay.rate | + +修饰器(不绑角色,参数驱动):以物抵顶 ×80%、支付权重 R×R、起付 60%、试用期缓发。**绑定模型**:`business_roles.calc_template` → 模板;角色级覆盖经 `dimension_type=BUSINESS_ROLE` 条目;新增岗位 = 新增行 + 选模板(零代码)。 + +**消费接口契约**(M3 核算与预览共用): + +```python +# app/services/param_service.py +@dataclass(frozen=True, slots=True) +class ParamSnapshot: + biz_date: date + def get(self, code, *, role_code=None, product_category=None) -> str: ... # 覆盖链:角色 > 产品类别 > 默认 + def fraction(self, code, **dims) -> Fraction: ... + def integer(self, code, **dims) -> int: ... + def boolean(self, code, **dims) -> bool: ... + def text(self, code, **dims) -> str: ... + +def resolve_snapshot(session, biz_date: date) -> ParamSnapshot +def submit_change(session, payload, *, operator, meta=None) -> ParamChangeBatchOut +def compare(session, *, from_date: date, to_date: date) -> list[ParamDiffOut] + +# app/services/formula.py(纯函数,禁 float) +def payment_days(ship_date, receipt_date) -> int # 发货日首日=1 +def salesperson_rate(days, *, start, slope, floor, boundary_days) -> Fraction +def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction # 不含运费;revenue=0 防除零 +def converted_profit(revenue, rate, threshold) -> Decimal # 化简式:销售额 ×(毛利润率−阈值) +def performance_amount(base, rate) -> Decimal # 乘法方向(对照表 #3) +def apply_barter(amount, discount) -> Decimal +def payment_weight(r: int) -> Fraction # r×r +def start_payment_ok(received, revenue, ratio) -> bool +``` + +### 1.4 API 设计(/api/v1/system/*,AdminUser) + +**岗位角色**:`GET/POST /business-roles`、`GET/PUT/DELETE /business-roles/{id}`、`PUT /business-roles/{id}/status`、`GET /business-roles/meta/templates`(模板下拉,含声明参数说明)。 + +**计提参数**:`GET /params`(目录+当前快照,支持 `asOf`)、`GET /params/{param_code}/history`、`POST /params/changes`(提交变更批次)、`DELETE /params/changes/{batch_id}`(撤回未生效)、`GET /params/changes`(记录分页)、`GET /params/changes/{batch_id}`(明细)、`GET /params/compare`(两日期对比)、`GET /params/export`(Excel,`StreamingResponse` 非统一信封)、`POST /params/preview`(计算预览)。 + +**关键示例 1:提交变更** + +```json +// POST /api/v1/system/params/changes +{ + "changeType": 2, + "effectiveDate": "2026-11-01", + "remark": "四季度系数调整", + "items": [ + { "paramCode": "enterprise.rate", "dimensionValue": "", "value": "3" }, + { "paramCode": "product.threshold", "dimensionValue": "rail_transit", "value": "22" } + ] +} +// data: +{ "batchId": 12, "versionNo": "P20261012-01", "effectiveDate": "2026-11-01", + "changeType": 2, "itemCount": 2, "operatorName": "admin", "createTime": "..." } +``` + +**关键示例 2:计算预览**(示意值已验算) + +```json +// POST /api/v1/system/params/preview +{ "businessRole": "salesperson", "customerType": "personal", + "productCategory": "municipal_water", "shipDate": "2025-12-15", + "receiptDate": "2026-01-10", "revenue": "100000", "profit": "45000", + "receivedAmount": "60000", "barter": false, "probation": false } +// data: +{ "paymentDays": 27, "rate": "97/10000", "rateText": "9.7‰(10‰ − 27 天 × 4/360‰)", + "profitRate": "9/20", "convertedProfit": "15000", "performanceAmount": "145.5", + "startPaymentOk": true, "paymentWeightR": 60, "paymentWeight": "9/25", + "payableAmount": "52.38", "paramVersion": "P20260101-00", "paramEffectiveDate": "2026-02-17" } +``` + +### 1.5 前端设计 + +**岗位角色页**(`views/system/business-roles/index.vue`):列表(复用 `usePagedList`/`AppPagination`/`StatusTag`)+ 新增/编辑弹窗(模板下拉,选中后展示模板声明的参数清单与当前值)+ 启停/删除(内置岗位按钮禁用并 title 提示原因)。路由 meta `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`。 + +**计提参数页**(`views/system/params/index.vue`):按 `param_group` 分组展示(名称/当前值+单位/生效日期/口径说明);编辑后进入「待提交」区,顶部选生效方式(立即/指定日期)+ 备注;「提交变更」二次确认(列出每项 旧值→新值);版本管理抽屉(变更记录分页 + 批次明细 + 两日期对比 + Excel 导出下载);预览抽屉(表单 → 结果卡,注明「斜率精确值 4/360」)。路由 meta order 6。导出接口走 axios 实例取 blob(非统一信封,api 文件内注明)。 + +--- + +## 二、任务分解(T1–T8) + +任务依赖:T1 → T2 → T3 → (T4 ∥ T5) → (T6 ∥ T7) → T8。每任务遵循「写测试 → 确认失败 → 实现 → 确认通过 → commit」。 + +### T1 迁移与模型(四表 + 种子) + +**文件**:`app/models/business_role.py`、`app/models/param.py`(创建);`app/models/__init__.py`(改);`alembic/versions/xxxx_*.py`(autogenerate+种子);`db/migration/V3__add_business_roles_and_params.sql`、`db/init/init-dev.sql`(改)、`db/queries/计提参数.sql`(创建)。 + +- [ ] 写两个模型文件(参照 `app/models/role.py` 写法;`ParamChangeBatch`/`ParamValue` 不继承 TimestampMixin) +- [ ] 注册进 `models/__init__.py`(漏注册会让 autogenerate 生成删表迁移) +- [ ] `uv run alembic revision --autogenerate -m "新增岗位角色与计提参数四表"` → 人工检查命名/注释/ON UPDATE → 补种子(3 岗位/16 定义/版本条目/初始批次) +- [ ] `uv run alembic upgrade head` → `uv run alembic upgrade :head --sql > db/migration/V3__*.sql` +- [ ] 同步 `db/init/init-dev.sql`(建表 + 种子 + alembic_version 更新) +- [ ] 写 `db/queries/计提参数.sql`(时点化解析与对比查询留档) +- [ ] 验证:`uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q` 全绿 +- [ ] Commit:`feat(system): 新增岗位角色与计提参数四表及 V3 迁移` + +### T2 参数注册表 + 公式纯函数 + +**文件**:`app/services/param_registry.py`、`calc_templates.py`、`formula.py`(创建);`app/core/constants.py`(改);`tests/unit/test_formula.py`、`test_param_registry.py`。 + +- [ ] 写失败测试(关键断言):`salesperson_rate(360,...) == Fraction(6,1000)`;`361 天 == Fraction(4,1000)`;`第 1 天 == Fraction(10) - Fraction(1,90)`;`payment_days(2025-12-15, 2026-01-10) == 27`;同日回款 == 1;`converted_profit(0,...) == 0`(防除零);负绩效不归零;`payment_weight(60) == Fraction(36,100)`;`parse_fraction("4/360") == Fraction(1,90)`;注册表 16 项且模板声明参数均存在 +- [ ] 运行确认失败 → 实现三个文件 → 运行 `uv run pytest tests/unit -q` 全绿 +- [ ] Commit:`feat(system): 计提公式模板注册表与精确分数计算函数` + +### T3 参数仓储 + 取值服务(时点化解析) + +**文件**:`app/repositories/param_repository.py`、`app/services/param_service.py`(解析部分);`tests/unit/test_param_snapshot.py`。 + +- [ ] 写失败测试:覆盖链(角色 > 产品类别 > 默认);未定义参数按注册表默认值兜底;同日多次变更取 id 最大 +- [ ] 实现 `resolve_values(session, biz_date)`(一条 SQL 取全量快照)+ `ParamSnapshot`/`resolve_snapshot` → `uv run pytest tests/unit -q` 全绿 +- [ ] Commit:`feat(system): 计提参数按业务日期时点化取值服务` + +### T4 岗位角色 CRUD 后端(含审计与保护规则) + +**文件**:`app/repositories/business_role_repository.py`、`app/schemas/business_role.py`、`app/services/business_role_service.py`、`app/api/v1/system/business_roles.py`(创建);`app/api/router.py`、`app/core/constants.py`(改)。整体参照 `roles` 模块模式。 + +- [ ] 实现(保护规则:内置禁删禁停、code 不可改、模板必须存在于注册表、人员引用计数钩子预留;写操作包 `audit_service.audit()`) +- [ ] 冒烟:新增自定义岗位 ✓、删除内置 → 403、非法模板 → 400 +- [ ] Commit:`feat(system): 业务岗位角色管理接口与保护规则` + +### T5 参数变更/历史/对比/导出/预览后端 + +**文件**:`app/schemas/param.py`、`app/api/v1/system/params.py`(创建);`param_service.py` 扩展、`app/api/router.py`、`constants.py`(改);`pyproject.toml`(`uv add openpyxl`)。 + +- [ ] 实现 9 个端点(提交/撤回/历史/分页/明细/对比/导出/预览/目录快照);撤回已生效批次 → 400;导出列含变更人/时间/项/旧值/新值/生效日期 +- [ ] 冒烟链路:提交(指定未来日期)→ 当前快照不变 → 立即生效提交 → 快照变化 → 撤回未生效 → 回退 → 预览 360/361 → 导出可打开 +- [ ] Commit:`feat(system): 计提参数版本管理、变更留痕与计算预览接口` + +### T6 前端岗位角色页 + +**文件**:`src/api/businessRoles.ts`、`src/views/system/business-roles/index.vue`(创建);`router/index.ts`、`utils/constants.ts`(改)。 + +- [ ] 实现页面与路由 → `pnpm type-check && pnpm build` 通过 +- [ ] Commit:`feat(system): 前端岗位角色管理页面` + +### T7 前端参数配置页(版本管理 + 预览) + +**文件**:`src/api/params.ts`、`src/views/system/params/index.vue`(创建);`router/index.ts`、`utils/constants.ts`(改)。 + +- [ ] 实现页面(分组表/生效方式/二次确认/版本抽屉/预览抽屉)→ `pnpm type-check` 通过 +- [ ] e2e 链路:改参数 → 变更记录出现 → 预览值变化 → 导出可下载 +- [ ] Commit:`feat(system): 前端计提参数配置与版本管理页面` + +### T8 模块测试统一编写与文档 + +**文件**:`tests/unit/test_param_service.py`、`tests/integration/test_business_roles.py`、`test_param_management.py`(创建);`backend/tests/README.md`、`frontend/README.md`、`backend/README.md`、`frontend/e2e/*`(改)。 + +- [ ] 集成用例:参数精度全链路(注册表→种子→解析→预览一致);未来生效不泄漏;撤回回退;同日多次变更;审计留痕;权限 403;岗位保护规则 +- [ ] 全量回归 `uv run pytest -q`(301 基线 + 新增全绿);e2e;三份 README 更新 +- [ ] Commit:`test(system): 参数体系模块用例与测试记录` + +--- + +## 三、执行安排 + +- **分支**:`feat/system-params`(已创建);`feature/system-management` 是否先合并 main 由用户决定,不阻塞 +- **环境**:启动 `D:\work\app\Docker\Docker Desktop.exe` → `docker compose -f docker/docker-compose.dev.yml up -d`;T2 纯函数开发不依赖容器 +- **方式**:原生执行(当前会话逐任务推进);每任务后跑验证命令;T8 全量回归;收尾一轮 code review +- **注意**:Windows uvicorn `--reload` 偶发卡死——验证 reload 以「新 worker 的 startup complete」为准;开放问题 7 项(见下)按默认处理,不阻塞 + +## 四、验收标准(M1 完成的定义) + +1. 管理员可新增岗位并绑定公式模板(无需改代码);内置岗位禁删禁停 +2. 任一参数变更产生含变更人/时间/项/旧值/新值/生效日期的记录(批次 + 操作日志双保险) +3. 任意业务日期可解析出当时快照(08-12 企业户=2‰、08-13=3‰ 回溯正确) +4. 计算预览精确复现:360 天=6‰、361 天=4‰、12-15→01-10=27 天 +5. `uv run pytest -q` 301 基线 + 新增全绿;`pnpm type-check && pnpm build` 通过;e2e 链路通过 +6. `db/migration/V3__*.sql` 与 `db/init/init-dev.sql` 双轨同步(`test_schema_sync.py` 把关) + +## 五、风险与开放问题 + +**风险(已定对策)**:参数缓存一期不做(保留 `ys:param.ver:*` 键位,未来按批次版本号精确失效);同日多次变更 UI 明示「按最新一条生效」;`4/360` 展示注明精确值;模板扩展摩擦一期接受(受控下拉);预览出参与需规 2.4.1 明细列同名(减少 M3 映射);openpyxl 在 T5 增补依赖。 + +**开放问题(不阻塞,按默认处理)**:① 第 1 天 10‰ vs 9.9889‰ → 按公式实现并参数化;② 企业户 2‰ 历史种子 → 已双种子;③ BALANCE 模式语义 → M3 前确认;④ `probation.salary.basic` 归属 → M2 时复核;⑤ 参数查询权限 → 暂 AdminUser;⑥ 导出列集合 → 以需规 2.3.1(四) 为准;⑦ 经理个人户业绩进入 12.5%+5% 的衔接 → **M3 开工前必须确认**。 + +## 六、验证方式 + +1. 按 T1–T8 的验证节点逐任务执行(见第二章各任务);T8 后全量回归 + e2e; +2. 验收标准 6 条逐条核对(见第四章); +3. 与 `ai-aux/AI_CONTEXT.md` 需求裁决节交叉核对口径无冲突。 diff --git a/docs/superpowers/plans/2026-10-12-剩余开发计划.md b/docs/superpowers/plans/2026-10-12-剩余开发计划.md new file mode 100644 index 0000000..96683ec --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-剩余开发计划.md @@ -0,0 +1,744 @@ +# 剩余开发计划(路线图 + 首工作块实施计划) + +> **面向 AI 代理的工作者:** 首工作块(M1)实施时使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现本计划第二章;步骤使用复选框(`- [ ]`)语法跟踪进度。第一章路线图供全局导航。 + +**目标:** 在需求裁决全部落定(2026-10-12)后,为剩余 5 大模块与 T+ 同步给出推进路线;并交付首个工作块「系统设置与参数体系(#28)」的可执行实施计划(任务级,含测试与验证)。2026-10-15 功能清单核查补充(二次分配方案、工作台、复核审批、月结与调整单、备份恢复运维、公司信息配置)已并入路线图(1.1/1.3/1.4)并收录为第三章。 + +**架构:** 沿用现有六层(api → services → repositories → models,schemas 承载请求/响应,core 横切);新增「公式模板注册表(代码内有限集合)+ 参数按生效日期时点化存储 + 计算预览」作为核算模块的输入契约,不引入规则引擎。 + +**技术栈:** 后端 Python 3.12 + FastAPI + SQLAlchemy 2.0(同步)+ MySQL 8.0 + Redis 7;前端 Vue 3.5 + TS 5.9 + Vite 8 + Element Plus 2.14 + Pinia 4。 + +**规格:** `ai-aux/AI_CONTEXT.md`「需求裁决」节(权威口径);`docs/问题与解决方案对照表_v2.xlsx`(28 项裁决);`docs/2026.9.8修改方案.docx`(公式与生效日期)。需规(`docs/衍水绩效管理系统功能需规.docx`)中 4 类作废条款不再引用:26 天示例、含运费公式、协助 25%、经理个人户 8‰。 + +## 全局约束 + +- 表:小写蛇形复数、列小写蛇形、每列中文 COMMENT、主键 `id`、`uk_`/`idx_`/`fk_` 前缀;金额类 DECIMAL;时间列用 `TimestampMixin`(`ON UPDATE CURRENT_TIMESTAMP` 须 `text()` 显式写);append-only 表不继承 TimestampMixin(与 `login_logs` 先例一致) +- 迁移双轨:Alembic 生成迁移 → 导出 SQL 到 `db/migration/V{n}__*.sql`(按序编号)→ 同步 `db/init/init-dev.sql`;`tests/integration/test_schema_sync.py` 拦截漂移 +- 六层红线:api 只参数绑定(写 `def` 非 `async def`)→ services 唯一事务边界 → repositories 只 `flush()`;统一响应 `{code,message,data}`,分页 `pageNum/pageSize` → `list/total/pageNum/pageSize` +- 写操作接入 `audit_service.audit()`;审计脱敏在格式化后执行(顺序不可倒) +- 金额全程 `Decimal`/`Fraction`,**禁 float**;`4/360` 按精确分数存储(`"4/360"`,禁 `0.011111`) +- Redis 键前缀 `ys:`,TTL 按业务语义;禁 `KEYS *` / `FLUSHALL` / `FLUSHDB` +- 前端:TS 锁 `~5.9.3`(不可 7.x);复用 `usePagedList`/`useConfirmAction`/`AppPagination`/`StatusTag`;视觉沿用 `--ys-*` token;新页面必须写 `meta.title` +- 测试:业务模块整体功能完成时统一编写用例;记录写入 `backend/tests/README.md` / `frontend/README.md` +- 提交:`feat(scope): 中文说明`;不直推 main/develop + +## 审查重点(Review Focus) + +以下输入/失败模式最可能伤到使用者,对应测试已钉进第二章各任务: + +1. 畸形参数值(`"1/0"` 除零分数、非数字、超取值域)→ 保存必须 400 拒绝且不落库(T2/T5) +2. 指定未来生效的变更泄漏进当前快照 → 快照解析必须不含(T3/T5) +3. 撤回未生效批次后解析未回退 → 必须回退到撤回前值(T5) +4. 回款天数边界:同日回款 = 1 天;12-15 → 01-10 = 27 天;跨年连续(T2) +5. 计提比例边界:360 天 = 6‰、361 天 = 4‰、第 1 天 = 10 − 4/360 ≈ 9.9889‰(精确分数断言)(T2) +6. 毛利润额 = 0 → 折算额 0、绩效 0 不除零;负绩效按公式正常计算不归零(T2) + +--- + +## 第一章 模块级路线图 + +### 1.1 推荐顺序与依赖 + +```text +① 系统设置与参数体系(#28)✅ ← 地基:岗位角色 + 参数 + 公式模板 +│ 增量(2026-10-15):公司信息配置;审批开关走参数体系 +② 基础数据管理 ✅ ← 人员引用 ① 岗位角色(外键上游) +③ 业务数据管理 ✅ ← 引用 ② 人员/产品/客户 +│ 增量(2026-10-15):二次分配方案(合同刚建立时录入) +④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据 +│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(语义待澄清) +⑦ 复核审批流(新) ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展 +⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约 +⑧ 工作台(新) ← 消费 ④⑤⑥⑦ 数据的聚合展示层 +⑨ 备份恢复与运维检查(新) ← 独立于业务块,可与任一批次并行,建议尽早 +``` + +### 1.2 顺序论证(要点) + +**① 先于基础数据:** 外键方向在上游(人员→岗位,先建避免迁移返工);参数取值契约是 ④ 的输入面,先定型让后续模块按契约编码;计算预览(需规 2.3.1 四)首块即可交给业务对账「360 天=6‰、361 天=4‰」,验收前移;参数清单提炼本身 = 28 项裁决逐条复核。阈值参数归 ① 有明文依据(#28「全部阈值统一在系统设置维护」)。 + +**⑥ 放最后:** 字段映射未定(对照表 #26 ⏳IT),放前部会把外部不确定性引入关键路径;目标表由 ②③ 定稿后同步才有意义;一期可手工录入闭环。**但预留前移**:②③ 表设计必须含外部唯一键列(`source_no`/`t_plus_id`)、同步批次时间列,并约定「同步不覆盖人工字段(指导价、归属、岗位)」。 + +**⑤ 可后置:** 纯消费端无下游,口径依赖真实核算数据校准;唯一约束是 ④ 设计台账时对照需规 2.4.1 明细列(合同编号/发货日/回款日/回款天数/产品类别/客户类型/人员岗位/计提比例/支付权重R/绩效金额)一次性建全。 + +### 1.3 各块范围与验收标准 + +| 块 | 范围界定 | 验收标准(一句话) | +| --- | --- | --- | +| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览。**✅ 已完成**。增量(2026-10-15):公司信息配置(公司名称、logo 等基本信息);审批开关配置走参数体系;新增参数一律进参数注册表(用户重申:参数配置尽量灵活) | (原验收已达成)增量:公司信息可配置并用于登录页与导出页眉;审批开关参数化,关闭直接生效、开启走审批 | +| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更。**✅ 已完成** | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 | +| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器。**✅ 已完成**。增量(2026-10-15):二次分配方案 —— 只出现在合同刚建立时;合同建立时可手动选择参与二次分配的业务员;分配比例不要求合计 100%;审批开关开启时走审批 | (原验收已达成)增量:合同建立时可录入参与业务员与比例(不强制合计 100%);开关关闭直接生效;方案修改不影响已结算月份 | +| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(语义待澄清,见 3.3) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 | +| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 | +| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 | +| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions) | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 | +| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态 | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 | +| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本 | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 | + +### 1.4 里程碑(交付批次) + +| 批次 | 内容 | 说明 | +| --- | --- | --- | +| **M1** | ① 系统设置与参数体系 | ✅ 已完成(2026-10-13 推送 feat/system-params) | +| **M2** | ② + ③ | ✅ 已完成(2026-10-13 分支 feat/base-business-data) | +| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流 | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」;开工前必须澄清:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7);内部建议顺序 ①增量 → ③增量 → ⑦ 审批框架 → ④ 核算 → 月结与审批串联 | +| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台 | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 | +| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付) | 数据持续积累,备份先行;不依赖业务模块进度 | + +### 1.5 需求依据对照 + +| 块 | 依据 | 关联作废条款(不引用) | +| --- | --- | --- | +| ① | 需规 2.3.1(四)/2.5.2/2.1.2;对照表 #28/#5/#8/#20–#24;增量见 3.1(五)(六) | 经理个人户 8‰(不设参数);含运费口径;协助 25%(不设参数) | +| ② | 需规 2.1.1–2.1.3;对照表 #9/#19/#26 | 信息部负责人条款(信息员独立角色) | +| ③ | 需规 2.2.1–2.2.5;对照表 #1/#2/#12/#13/#14/#18/#25;增量见 3.1(一) | 26 天示例;含运费公式(2.2.4/2.3.3 整段) | +| ④ | 需规 2.3.1–2.3.3;对照表 #3/#4/#6/#7/#11/#15/#16/#17/#20–#24;增量见 3.1(一)(四) | 协助 25%;经理 8‰;26 天示例;含运费公式 | +| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R);增量见 3.1(四) | — | +| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — | +| ⑦⑧⑨ 及各块增量 | **2026-10-15 功能清单核查补充(本文第三章,原文照录)**——尚未回填需规与对照表,引用以第三章原文为准 | 二次分配 ≠ 协助 25%:#15 作废的是「协助 25% 分成」条款,二次分配是合同建立时的新机制,关系待业务澄清(3.3-1) | + +--- + +## 第二章 首工作块「系统设置与参数体系(#28)」实施计划 + +### 2.1 关键设计取舍(已定) + +1. **参数存储 = 参数定义 + 逐项版本条目(时点化 append-only)+ 变更批次表承载「版本」**;否决全量快照(改一项复制全量、单参数历史需遍历)与单行 JSON(无回溯、无字段校验); +2. 数值一律以 **Fraction 可解析字符串**存储(`"4/360"`,禁 `0.011111`),解析走 `Fraction(text)` 不经 float; +3. **不设日期唯一键**:同日多次修正是合法留痕,解析按 `effective_date DESC, id DESC` 取最新; +4. **一期不做参数 Redis 缓存**(算钱系统缓存失效即错钱;20 行级查询成本可忽略),保留 `ys:param:ver:*` 键位待实测后启用; +5. 维度覆盖链:**角色维度 > 产品类别维度 > 默认行**;`dimension_value` 用空串不用 NULL(MySQL 组合索引对 NULL 不去重); +6. append-only 表(`param_values`/`param_change_batches`)**不继承 TimestampMixin**(无 update_time,与 `login_logs` 先例一致)。 + +### 2.2 表设计(V3 迁移,四张新表) + +**`business_roles` 业务岗位角色表**(与系统登录角色 `roles` 严格区分,表注释写明;这是 #28「人员岗位角色」的落点) + +| 列 | 类型 | 约束/说明 | +| --- | --- | --- | +| id | BIGINT UNSIGNED | PK AUTO_INCREMENT | +| role_code | VARCHAR(32) | NOT NULL,uk;创建后不可改(同 roles 保护) | +| role_name | VARCHAR(64) | NOT NULL | +| calc_template | VARCHAR(32) | NOT NULL,计提公式模板编码(以代码注册表为准) | +| description | VARCHAR(255) | NULL | +| sort_order | INT | NOT NULL DEFAULT 0 | +| status | TINYINT | NOT NULL DEFAULT 1(1 启用 0 禁用) | +| is_builtin | TINYINT | NOT NULL DEFAULT 0(1 内置禁删) | +| create_time / update_time | DATETIME | TimestampMixin | + +种子(迁移 + init-dev 双写):`salesperson 营销员`(TPL_SALESPERSON_DECAY)、`sales_manager 营销经理`(TPL_MANAGER_SHARE)、`info_officer 信息员`(TPL_INFO_SALES_FIXED),均内置。 + +**`param_definitions` 计提参数定义表**:`param_code` VARCHAR(64) uk;`param_name` VARCHAR(64);`param_group` VARCHAR(32);`value_type` VARCHAR(16)(DECIMAL/INT/BOOL/ENUM/TEXT);`unit` VARCHAR(16)(‰/%/元/天,可 NULL);`dimension_type` VARCHAR(32) NOT NULL DEFAULT 'NONE'(NONE/PRODUCT_CATEGORY/BUSINESS_ROLE);`options_text` VARCHAR(255)(枚举竖线分隔);`default_value` / `value_min` / `value_max` VARCHAR(64)(Fraction 可解析字符串);`description` VARCHAR(255);`sort_order` INT;`status` TINYINT;TimestampMixin。 + +**`param_change_batches` 参数变更批次表**:`version_no` VARCHAR(32) uk(如 `P20261012-01`);`effective_date` DATE NOT NULL idx;`change_type` TINYINT(1 立即 2 指定日期);`remark` VARCHAR(255);`operator_id` BIGINT UNSIGNED FK→users ON DELETE SET NULL + `operator_name` VARCHAR(64) 冗余固化;`item_count` INT NOT NULL DEFAULT 0;仅 `create_time`。 + +**`param_values` 参数版本条目表(append-only)**:`param_id` FK→param_definitions RESTRICT idx;`dimension_value` VARCHAR(32) NOT NULL DEFAULT `''`;`effective_date` DATE NOT NULL idx;`value_text` VARCHAR(64) NOT NULL;`old_value_text` VARCHAR(64) NULL;`batch_id` FK→param_change_batches RESTRICT idx;仅 `create_time`。索引 `idx_param_values_param_id_dimension_value_effective_date`(解析热路径)。已生效条目禁改禁删;未生效批次可整批撤回(删批次+条目)。解析 SQL 留存 `db/queries/计提参数.sql`。 + +### 2.3 参数清单(16 项,迁移种子) + +| param_code | 名称 | 类型/单位 | 维度 | 默认值 | 依据 | +| --- | --- | --- | --- | --- | --- | +| salesperson.rate.start | 业务员起始计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 10 | 需规 2.3.1(一)1 | +| salesperson.rate.slope | 业务员日递减斜率 | DECIMAL ‰/天 | BUSINESS_ROLE | **4/360** | 对照表 #4 | +| salesperson.rate.floor | 业务员计提下限 | DECIMAL ‰ | BUSINESS_ROLE | 4 | 对照表 #4 | +| salesperson.days.boundary | 线性递减分界天数 | INT 天 | BUSINESS_ROLE | 360 | 对照表 #4 | +| manager.share.team | 经理本团队分成 | DECIMAL % | BUSINESS_ROLE | 12.5 | #6/#15/#16 | +| manager.share.company | 经理全公司分成 | DECIMAL % | BUSINESS_ROLE | 5 | 同上 | +| enterprise.rate | 企业户计提比例 | DECIMAL ‰ | PRODUCT_CATEGORY | 3 | #20 | +| info.rate | 信息员计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 1 | #9 | +| prepay.rate | 预付款计提比例 | DECIMAL ‰ | NONE | 10 | #23 | +| barter.discount | 以物抵顶折算比例 | DECIMAL % | NONE | 80 | #24 | +| payment.start.ratio | 支付起付比例 | DECIMAL % | NONE | 60 | #21 | +| payment.weight.mode | 支付权重应用方式 | ENUM FULL\|BALANCE | NONE | FULL | #22 | +| product.threshold | 折算毛利润额阈值 | DECIMAL % | PRODUCT_CATEGORY | 市政水利 30 / 轨交 20 | #12、需规 2.1.2 | +| salary.basic.mode | 月工资基本工资口径 | ENUM INCLUDE\|DEDUCT\|EXCLUDE | BUSINESS_ROLE | 业务员 INCLUDE / 经理 DEDUCT | #8 | +| probation.annual.threshold | 试用期年累计结算阈值 | DECIMAL 元 | NONE | 18000 | #17 | +| probation.salary.basic | 试用期基本工资标准 | DECIMAL 元 | NONE | 3000 | 需规(归人员字段则撤,见开放问题) | + +**不设参数**(作废条款的落地):经理个人户 8‰ 起始/斜率;协助 25% 分成;含运费口径开关。 + +**种子生效日期**(迁移与 init-dev 双写):`product.threshold`/`salesperson.*` = 2026-02-17;`enterprise.rate` = **2026-02-17 起 2‰ + 2026-08-13 起 3‰ 两行**;`manager.share.*`/`info.rate` = 2026-08-13;支付环节/预付款/以物抵顶/月工资口径 = 2026-01-01。 + +### 2.4 公式模板与角色绑定 + +**模板注册表**(`app/services/calc_templates.py`,代码内有限集合): + +| 模板编码 | 名称 | 形态 | 声明参数 | +| --- | --- | --- | --- | +| TPL_SALESPERSON_DECAY | 业务员个人户·天数线性递减 | 分段:≤分界 = 起始 − 天数×斜率;> 分界 = 下限 | salesperson.rate.* / days.boundary | +| TPL_MANAGER_SHARE | 经理·团队与全公司分成 | 聚合:∑本团队×12.5% + ∑全公司×5% | manager.share.* | +| TPL_ENTERPRISE_FIXED | 企业户·固定计提 | 乘法:折算毛利润额 × 比例 | enterprise.rate | +| TPL_INFO_SALES_FIXED | 信息员·销售额固定比例 | 乘法:本人销售额 × 比例 | info.rate | +| TPL_PREPAY_FIXED | 预付款·固定计提 | 乘法:回款额 × 比例 | prepay.rate | + +修饰器(不绑角色,参数驱动):以物抵顶 ×80%、支付权重 R×R、起付 60%、试用期缓发。 + +**绑定模型**:`business_roles.calc_template` → 模板;角色级覆盖经 `dimension_type=BUSINESS_ROLE` 条目。新增岗位 = 新增行 + 选模板(零代码,满足 #28 ①)。 + +**消费接口契约**(④ 与预览共用,函数签名级): + +```python +# app/services/param_service.py +@dataclass(frozen=True, slots=True) +class ParamSnapshot: + biz_date: date + def get(self, code, *, role_code=None, product_category=None) -> str: ... # 覆盖链解析后原始值 + def fraction(self, code, **dims) -> Fraction: ... + def integer(self, code, **dims) -> int: ... + def boolean(self, code, **dims) -> bool: ... + def text(self, code, **dims) -> str: ... + +def resolve_snapshot(session, biz_date: date) -> ParamSnapshot +def submit_change(session, payload, *, operator, meta=None) -> ParamChangeBatchOut +def compare(session, *, from_date: date, to_date: date) -> list[ParamDiffOut] + +# app/services/formula.py(纯函数,预览与 ④ 共用;禁 float) +def payment_days(ship_date, receipt_date) -> int # 发货日首日=1 +def salesperson_rate(days, *, start, slope, floor, boundary_days) -> Fraction +def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction # 不含运费;revenue=0 防除零 +def converted_profit(revenue, rate, threshold) -> Decimal # 化简式:销售额 ×(毛利润率−阈值) +def performance_amount(base, rate) -> Decimal # 乘法方向(对照表 #3) +def apply_barter(amount, discount) -> Decimal +def payment_weight(r: int) -> Fraction # r×r +def start_payment_ok(received, revenue, ratio) -> bool +``` + +### 2.5 文件清单 + +#### 后端 + +| 层 | 文件 | 内容 | +| --- | --- | --- | +| models | `app/models/business_role.py`、`app/models/param.py`、`app/models/__init__.py`(改) | 四张表;**必须注册进 `__init__`**(漏注册会让 autogenerate 生成删表迁移) | +| repositories | `app/repositories/business_role_repository.py`、`param_repository.py` | 只 flush;`resolve_values(session, biz_date)` 一条 SQL 取全量快照;批次撤回用 `for_update` 锁行 | +| schemas | `app/schemas/business_role.py`、`param.py` | Out/CreateIn/UpdateIn/StatusIn/Query、TemplateOptionOut;ParamDefinitionOut/SnapshotOut/ChangeIn/Out/BatchOut/BatchDetailOut/DiffOut/PreviewIn/Out | +| services | `app/services/param_registry.py`、`calc_templates.py`、`formula.py`、`business_role_service.py`、`param_service.py` | 参数目录注册表(种子唯一来源 + 运行期校验);模板注册表;纯函数;岗位 CRUD+保护规则;参数提交/历史/对比/预览/导出 | +| api | `app/api/v1/system/business_roles.py`、`params.py`、`app/api/router.py`(改) | 挂载 `/api/v1/system/business-roles`、`/api/v1/system/params`(AdminUser) | +| core | `app/core/constants.py`(改) | `MODULE_BUSINESS_ROLE`/`MODULE_PARAM`、内置岗位编码、产品类别码 | +| 迁移 | `alembic/versions/xxxx_*.py`、`db/migration/V3__add_business_roles_and_params.sql`、`db/init/init-dev.sql`(改)、`db/queries/计提参数.sql` | 四表 + 种子;双轨同步 | +| 依赖 | `backend/pyproject.toml`(改) | 增补 `openpyxl`(参数导出用;`uv add openpyxl`) | + +**接口集合**:business-roles 列表/新增/详情/编辑/删除/启停 + `meta/templates` 模板下拉;params 目录快照(`asOf`)、单参数历史、变更批次提交/撤回/分页/明细、双日期对比、Excel 导出(`StreamingResponse`,非统一信封)、计算预览。写操作全走 `audit_service.audit()`;变更批次表与操作日志互为双保险。 + +#### 前端 + +| 类型 | 文件 | 说明 | +| --- | --- | --- | +| api | `src/api/businessRoles.ts`、`src/api/params.ts` | params 的导出接口直接用 axios 实例(响应为 blob,非统一信封,文件内注明例外) | +| 页面 | `src/views/system/business-roles/index.vue` | 列表+表单+启停+删除(保护提示)+模板选择(展示模板声明参数) | +| 页面 | `src/views/system/params/index.vue` | 按 param_group 分组;生效方式(立即/指定日期)+备注;保存二次确认;版本管理抽屉(记录/明细/对比/导出);预览抽屉 | +| 路由 | `src/router/index.ts`(改) | `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`、`{ title: '计提参数', …, order: 6 }` | +| 常量 | `src/utils/constants.ts`(改) | MODULE_OPTIONS 增补;参数分组/单位展示映射 | +| 复用 | `usePagedList`/`useConfirmAction`/`AppPagination`/`StatusTag`/`--ys-*` | 不新增组件 | + +### 2.6 任务序列(步骤级) + +任务依赖:T1 → T2 → T3 → (T4 ∥ T5) → (T6 ∥ T7) → T8。 + +--- + +#### 任务 T1:迁移与模型(四张新表 + 种子) + +**文件:** + +- 创建:`backend/app/models/business_role.py`、`backend/app/models/param.py` +- 修改:`backend/app/models/__init__.py` +- 创建:`backend/alembic/versions/xxxx_add_business_roles_and_params.py`(autogenerate 后人工补种子) +- 创建:`db/migration/V3__add_business_roles_and_params.sql` +- 修改:`db/init/init-dev.sql` +- 创建:`db/queries/计提参数.sql` + +- [ ] **步骤 1:写两个模型文件** + +按 2.2 字段设计实现 `BusinessRole`、`ParamDefinition`、`ParamChangeBatch`、`ParamValue`。写法参照 `backend/app/models/role.py`(`Mapped`/`mapped_column`、`mysql_table_args("中文表注释")`、`BIGINT_UNSIGNED`、`TINYINT_TYPE`);`ParamChangeBatch`/`ParamValue` 不继承 `TimestampMixin`,仅手写 `create_time`(`server_default=text("CURRENT_TIMESTAMP")`);外键目标类型必须与 `users.id` 一致(`BIGINT_UNSIGNED`)。 + +- [ ] **步骤 2:注册进 `models/__init__.py`** + +四个类加入导入与 `__all__`(漏注册会让 autogenerate 把新表当成待删除)。 + +- [ ] **步骤 3:生成迁移并补种子** + +```bash +cd backend && uv run alembic revision --autogenerate -m "新增岗位角色与计提参数四表" +``` + +人工检查:表/列中文 COMMENT、`uk_`/`idx_`/`fk_` 命名、`ON UPDATE CURRENT_TIMESTAMP` 子句;然后按 2.2/2.3 用 `op.bulk_insert` 或 `op.execute` 补种子:3 个内置岗位、16 项参数定义、版本条目(`enterprise.rate` 两行:2026-02-17=2、2026-08-13=3;其余按 2.3 生效日期表;维度行展开——`product.threshold` 两行市政/轨交、`salary.basic.mode` 两行业务员/经理)。种子条目的 `batch_id` 需要一条「初始版本」批次(`version_no` 如 `P20260101-00`、`remark` 初始版本)。 + +- [ ] **步骤 4:应用迁移并导出 SQL** + +```bash +cd backend && uv run alembic upgrade head +uv run alembic upgrade :head --sql > ../db/migration/V3__add_business_roles_and_params.sql +``` + +(V2 revision 从 `alembic/versions/` 中操作日志迁移文件读取。) + +- [ ] **步骤 5:同步 `db/init/init-dev.sql`** + +追加四表 DDL(与迁移产出一致)+ 种子 INSERT + 更新末尾 `alembic_version` 为 V3 revision id。 + +- [ ] **步骤 6:写 `db/queries/计提参数.sql`** + +留存解析查询(时点化取值:`WHERE effective_date <= :biz_date ORDER BY effective_date DESC, id DESC` 取每组最新)与对比查询。 + +- [ ] **步骤 7:验证** + +```bash +cd backend && uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q +``` + +预期:全部 PASS(schema 双轨无漂移、新表规范断言通过)。 + +- [ ] **步骤 8:Commit** + +```bash +git add backend/app/models backend/alembic db/init/init-dev.sql db/migration db/queries +git commit -m "feat(system): 新增岗位角色与计提参数四表及 V3 迁移" +``` + +--- + +#### 任务 T2:参数注册表 + 公式纯函数 + +**文件:** + +- 创建:`backend/app/services/param_registry.py`、`backend/app/services/calc_templates.py`、`backend/app/services/formula.py` +- 修改:`backend/app/core/constants.py`(内置岗位编码、产品类别码常量) +- 测试:`backend/tests/unit/test_formula.py`、`backend/tests/unit/test_param_registry.py` + +- [ ] **步骤 1:编写失败的测试(公式,算钱核心)** + +代表性用例(全部精确分数断言,禁近似): + +```python +def test_业务员360天_比例恰为6‰(): + rate = salesperson_rate(360, start=Fraction(10), slope=Fraction(4, 360), + floor=Fraction(4), boundary_days=360) + assert rate == Fraction(6, 1000) + +def test_业务员361天_固定下限4‰_分界线(): + rate = salesperson_rate(361, start=Fraction(10), slope=Fraction(4, 360), + floor=Fraction(4), boundary_days=360) + assert rate == Fraction(4, 1000) + +def test_业务员第1天_按公式为899比90000_精确分数(): + rate = salesperson_rate(1, start=Fraction(10), slope=Fraction(4, 360), + floor=Fraction(4), boundary_days=360) + assert rate == Fraction(10) - Fraction(1, 90) # 899/90 ‰ + +def test_回款天数_发货日首日为第1天_12月15日到1月10日为27天(): + assert payment_days(date(2025, 12, 15), date(2026, 1, 10)) == 27 + +def test_回款天数_同日回款为1天(): + d = date(2026, 1, 1) + assert payment_days(d, d) == 1 + +def test_毛利润额为零_折算额与绩效均为零_不除零(): + assert converted_profit(Decimal("0"), Fraction(0), Fraction(3, 10)) == Decimal("0") + +def test_毛利润率低于阈值_折算为负_绩效负值不归零(): + # 销售额 100、毛利润率 10%、阈值 30% → 折算 -20 + assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(3, 10)) == Decimal("-20") + assert performance_amount(Decimal("-20"), Fraction(6, 1000)) == Decimal("-0.12") + +def test_支付权重_R为60时权重0点36(): + assert payment_weight(60) == Fraction(36, 100) + +def test_注册表_16项_编码唯一_必填字段非空(): + assert len(PARAM_REGISTRY) == 16 + assert len({s.code for s in PARAM_REGISTRY.values()}) == 16 + # 每项 name/group/value_type 非空 + +def test_解析分数_4除以360_按Fraction不丢精度(): + assert parse_fraction("4/360") == Fraction(1, 90) + +def test_模板注册表_五个模板_声明参数均在注册表内(): + # TEMPLATES 的 params 引用必须全部存在于 PARAM_REGISTRY(跨表一致性) +``` + +- [ ] **步骤 2:运行确认失败** + +```bash +cd backend && uv run pytest tests/unit/test_formula.py tests/unit/test_param_registry.py -q +``` + +预期:FAIL(`ModuleNotFoundError` / `NameError`)。 + +- [ ] **步骤 3:实现三个 service 文件** + +`formula.py`:8 个纯函数(签名见 2.4 契约)。要点:`salesperson_rate` 中 `days <= boundary_days` 用线性式、否则取 `floor`;`converted_profit` 用化简式 `revenue × (rate − threshold)`;`performance_amount` 为乘法;`payment_days` 为 `(receipt_date - ship_date).days + 1`。 + +`param_registry.py`:`ParamSpec` 冻结 dataclass + `PARAM_REGISTRY`(16 项,值与 2.3 种子一致)+ `get_spec`(未注册编码抛 500 级异常,属代码 bug)+ `parse_fraction`(`Fraction(text)`,捕获 `ZeroDivisionError`/`ValueError` → 400)+ `validate_and_normalize`(类型/取值域/枚举校验,返回规范串)。 + +`calc_templates.py`:`TemplateSpec` + `TEMPLATES`(5 个模板,见 2.4)+ `get_template`/`required_params`。 + +- [ ] **步骤 4:运行确认通过** + +```bash +cd backend && uv run pytest tests/unit -q +``` + +预期:全部 PASS(含既有单元基线)。 + +- [ ] **步骤 5:Commit** + +```bash +git add backend/app/services/formula.py backend/app/services/param_registry.py backend/app/services/calc_templates.py backend/app/core/constants.py backend/tests/unit/test_formula.py backend/tests/unit/test_param_registry.py +git commit -m "feat(system): 计提公式模板注册表与精确分数计算函数" +``` + +--- + +#### 任务 T3:参数仓储 + 取值服务(时点化解析) + +**文件:** + +- 创建:`backend/app/repositories/param_repository.py` +- 创建:`backend/app/services/param_service.py`(先实现 `ParamSnapshot`/`resolve_snapshot` 与覆盖链选择) +- 测试:`backend/tests/unit/test_param_snapshot.py`(覆盖链纯逻辑;真实 DB 解析在 T8 统一集成测试) + +- [ ] **步骤 1:编写失败的测试(覆盖链与兜底)** + +```python +def test_覆盖链_角色维度优先于产品类别与默认(): + # rows: (code, dimension) 含 ('enterprise.rate', ''), ('enterprise.rate', 'municipal_water') + # 取 role_code=None, product_category='municipal_water' → 返回产品类别行 + +def test_未定义参数_按注册表默认值兜底(): + # 某参数无任何版本条目 → snapshot.fraction(code) == 注册表 default_value 的 Fraction + +def test_同日多次变更_取id最大的一条(): + # 同日两行 → 取 id 大者 +``` + +- [ ] **步骤 2:运行确认失败**(`uv run pytest tests/unit/test_param_snapshot.py -q`,预期 FAIL) + +- [ ] **步骤 3:实现** + +`param_repository.py`:`resolve_values(session, biz_date)` 一条 SQL 取全量快照(按 `(param_id, dimension_value)` 分组取 `effective_date DESC, id DESC` 首行;可用窗口函数或子查询,SQL 留存 `db/queries/计提参数.sql`);批次 CRUD 与 `for_update` 加锁函数(签名参照 2.5 与设计报告)。 + +`param_service.py`:`ParamSnapshot`(frozen dataclass,持有解析后 `dict[(code, dim)] → value` 与 `biz_date`)+ `resolve_snapshot(session, biz_date)`(读定义 + 读条目 + 合并注册表默认值);`get/fraction/integer/boolean/text` 五类取值(覆盖链:role_code 维度 > product_category 维度 > 默认行;`fraction` 走 `parse_fraction`)。 + +- [ ] **步骤 4:运行确认通过**(`uv run pytest tests/unit -q`,预期 PASS) + +- [ ] **步骤 5:Commit** + +```bash +git add backend/app/repositories/param_repository.py backend/app/services/param_service.py backend/tests/unit/test_param_snapshot.py +git commit -m "feat(system): 计提参数按业务日期时点化取值服务" +``` + +--- + +#### 任务 T4:岗位角色 CRUD 后端(含审计与保护规则) + +**文件:** + +- 创建:`backend/app/repositories/business_role_repository.py`、`backend/app/schemas/business_role.py`、`backend/app/services/business_role_service.py`、`backend/app/api/v1/system/business_roles.py` +- 修改:`backend/app/api/router.py`、`backend/app/core/constants.py`(MODULE_BUSINESS_ROLE) + +- [ ] **步骤 1:实现四层文件** + +整体参照 `roles` 模块(`role_repository.py`/`role_service.py`/`api/v1/system/roles.py`)的写法与保护规则模式。接口:列表(分页+关键字+状态)、新增、详情、编辑、删除、启停、`GET /meta/templates` 模板下拉。保护规则:`is_builtin` 禁删禁停;`role_code` 创建后不可改;`calc_template` 必须存在于 `TEMPLATES`(否则 400);删除前检查人员引用(② 落地前以引用计数钩子预留,接入后生效);写操作全部包 `audit_service.audit()`。 + +- [ ] **步骤 2:冒烟验证(容器 + 后端在跑)** + +```bash +cd backend && uv run uvicorn app.main:app --reload --port 8000 +# 另一终端:curl 登录取 token 后依次打 7 个端点;重点验证: +# - 新增自定义岗位(绑 TPL_ENTERPRISE_FIXED)→ 201/200 +# - 删除内置 salesperson → 403;删除自定义岗位 → 成功 +# - calc_template 传不存在的编码 → 400 +``` + +- [ ] **步骤 3:Commit** + +```bash +git add backend/app/repositories/business_role_repository.py backend/app/schemas/business_role.py backend/app/services/business_role_service.py backend/app/api/v1/system/business_roles.py backend/app/api/router.py backend/app/core/constants.py +git commit -m "feat(system): 业务岗位角色管理接口与保护规则" +``` + +(模块级正式用例按项目约定在 T8 统一编写。) + +--- + +#### 任务 T5:参数变更/历史/对比/导出/预览后端 + +**文件:** + +- 创建:`backend/app/schemas/param.py`、`backend/app/api/v1/system/params.py` +- 修改:`backend/app/services/param_service.py`(提交/撤回/历史/对比/预览/导出)、`backend/app/api/router.py`、`backend/app/core/constants.py`(MODULE_PARAM)、`backend/pyproject.toml`(`uv add openpyxl`) + +- [ ] **步骤 1:加依赖**:`cd backend && uv add openpyxl`(参数导出用) + +- [ ] **步骤 2:实现 schemas 与 service 扩展** + +`submit_change`:事务内校验全部 items(`validate_and_normalize`)→ 建批次(`version_no` 规则 `P{YYYYMMDD}-{当日序号}`)→ 逐项写 `param_values`(`old_value_text` 取当前生效值)→ `item_count` → commit。`withdraw_batch`:已生效(`effective_date <= today`)禁撤(400);`for_update` 锁批次行后删条目与批次。`compare`:两日期各取快照后逐项 diff。`preview`:入参(产品类别/客户类型/岗位/发货日/回款日/回款金额/销售额/毛利润额/是否以物抵顶/是否试用期)→ 输出(回款天数/计提比例/折算毛利润额/单笔绩效/起付判定/R×R 结果/所用参数生效版本)。`export_changes`:openpyxl 流式写变更记录(列:变更人/时间/参数/旧值/新值/生效日期/批次号)。 + +- [ ] **步骤 3:实现 api 层** + +9 个端点(目录快照/历史/提交/撤回/批次分页/批次明细/对比/导出/预览);写操作包审计;导出返回 `StreamingResponse`(非统一信封,前端 api 层注明)。 + +- [ ] **步骤 4:冒烟验证** + +```bash +# 提交一次变更(3 项参数,指定未来日期)→ 当前快照不变 +# 到该日期(或提交立即生效)→ 快照变化、历史出现记录 +# 撤回未生效批次 → 快照回退;撤回已生效批次 → 400 +# 预览:360 天/361 天边界、12-15→01-10=27 天精确复现 +# 导出 → xlsx 可打开且列齐全 +``` + +- [ ] **步骤 5:Commit** + +```bash +git add backend/app/schemas/param.py backend/app/services/param_service.py backend/app/api/v1/system/params.py backend/app/api/router.py backend/app/core/constants.py backend/pyproject.toml backend/uv.lock +git commit -m "feat(system): 计提参数版本管理、变更留痕与计算预览接口" +``` + +--- + +#### 任务 T6:前端岗位角色页 + +**文件:** + +- 创建:`frontend/src/api/businessRoles.ts`、`frontend/src/views/system/business-roles/index.vue` +- 修改:`frontend/src/router/index.ts`、`frontend/src/utils/constants.ts` + +- [ ] **步骤 1:api 封装 + 页面** + +页面整体复用用户管理页模式(`usePagedList` + `useConfirmAction` + `AppPagination` + `StatusTag`);表单含模板下拉(选中后展示模板声明的参数清单与当前值);内置岗位删除/停用按钮禁用并给 title 提示;路由 meta `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`。 + +- [ ] **步骤 2:验证** + +```bash +cd frontend && pnpm type-check && pnpm build +``` + +预期:类型检查与构建通过。 + +- [ ] **步骤 3:Commit** + +```bash +git add frontend/src/api/businessRoles.ts frontend/src/views/system/business-roles frontend/src/router/index.ts frontend/src/utils/constants.ts +git commit -m "feat(system): 前端岗位角色管理页面" +``` + +--- + +#### 任务 T7:前端参数配置页(含版本管理抽屉与预览) + +**文件:** + +- 创建:`frontend/src/api/params.ts`、`frontend/src/views/system/params/index.vue` +- 修改:`frontend/src/router/index.ts`、`frontend/src/utils/constants.ts` + +- [ ] **步骤 1:api 封装**(导出接口用 axios 实例取 blob,注明非统一信封例外) + +- [ ] **步骤 2:页面实现** + +按 `param_group` 分组展示参数(名称/当前值+单位/生效日期/口径说明);顶部「生效方式(立即/指定日期)+ 生效日期 + 备注」;保存前二次确认(列出改动项与旧值→新值);版本管理抽屉(变更记录分页 + 批次明细 + 两日期对比 + 导出下载);预览抽屉(表单 → 结果卡,注明「斜率精确值 4/360」);路由 meta order 6。 + +- [ ] **步骤 3:验证** + +```bash +cd frontend && pnpm type-check +# e2e(需容器+前后端在跑):改一个参数 → 变更记录出现 → 预览值变化 → 导出可下载 +``` + +- [ ] **步骤 4:Commit** + +```bash +git add frontend/src/api/params.ts frontend/src/views/system/params frontend/src/router/index.ts frontend/src/utils/constants.ts +git commit -m "feat(system): 前端计提参数配置与版本管理页面" +``` + +--- + +#### 任务 T8:模块测试统一编写与文档更新 + +**文件:** + +- 创建:`backend/tests/unit/test_param_service.py`(可 fakeredis 化的部分)、`backend/tests/integration/test_business_roles.py`、`backend/tests/integration/test_param_management.py` +- 修改:`backend/tests/README.md`、`frontend/README.md`、`backend/README.md`(接口清单)、`frontend/e2e/system_management.py` 或新增 `frontend/e2e/param_management.py` + +- [ ] **步骤 1:编写集成用例**(覆盖审查重点 1/2/3) + +参数精度专项(Fraction/1/90 全链路:注册表 → 种子 → 解析 → 预览一致);未来生效不泄漏;撤回回退;同日多次变更取最新;审计留痕(成功与失败);权限(普通用户 403);岗位保护规则(内置禁删、模板校验)。 + +- [ ] **步骤 2:全量回归** + +```bash +cd backend && uv run pytest -q +``` + +预期:301 基线 + 新增用例全绿。 + +- [ ] **步骤 3:e2e 与文档** + +前端 e2e 步骤(菜单出现两个新页、改参数→记录→预览链路);更新三份 README(用例清单/运行记录/发现缺陷/待补用例、接口清单)。 + +- [ ] **步骤 4:Commit** + +```bash +git add backend/tests frontend/e2e backend/README.md frontend/README.md +git commit -m "test(system): 参数体系模块用例与测试记录" +``` + +--- + +### 2.7 风险与开放问题 + +#### 风险(已定对策) + +1. 参数缓存:一期不做(见 2.1-4);若未来启用必须按批次版本号精确失效,禁通配删除; +2. 同日多次变更:UI 明示「同日多次变更按最新一条生效」; +3. `4/360` 展示:存储分数、展示格式化(预览页注明「精确值 4/360,非截断 0.011111」); +4. 模板扩展摩擦:新公式形态仍需加模板(一期接受,与「不引入规则引擎」一致);岗位表单把模板做成带说明的受控下拉; +5. 报表字段前移:预览出参尽量与需规 2.4.1 明细列同名,减少 ④ 映射工作; +6. 参数导出用 openpyxl:已在 T5 增补依赖(AI_CONTEXT 已认可方向)。 + +#### 开放问题(不阻塞首块开工,逐项有默认处理) + +| # | 问题 | 默认处理 | +| --- | --- | --- | +| 1 | 第 1 天计提:需规图示「第 1 天 10‰」vs 公式 `10 − 4/360 ≈ 9.9889‰` | 按公式实现(上限为参考);起始值参数化,业务如需 10‰ 可在系统设置调整 | +| 2 | 企业户 02-17 前历史(2‰ 是否回溯) | 已按双种子(02-17=2‰、08-13=3‰)实现;如无需回溯可撤首行 | +| 3 | `payment.weight.mode` BALANCE 模式语义(余额口径) | 首块只存枚举;语义在 ④ 开工前确认 | +| 4 | `probation.salary.basic`(3000 元)是参数还是人员工资标准字段 | 暂列参数;② 开工时如归人员字段则移除 | +| 5 | 参数查询权限(普通用户可否只读快照) | 建议「查看可、修改仅管理员」,实现为 AdminUser 时可放宽——待确认 | +| 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 | +| 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) | + +--- + +## 第三章 2026-10-15 功能清单核查补充(新增需求) + +> **来源与地位:** 用户以现有功能清单核查后补充的需求(2026-10-15)。本章 3.1 为**原文照录**(未做任何改写),3.2 起为开发侧归属与推演,**两者严格区分**;引用新需求一律以 3.1 原文为准。本批内容尚未回填需规文档与《问题与解决方案对照表》,是否回填由用户决定。任务级实施计划在对应里程碑开工前按第二章同粒度补齐。 + +### 3.1 需求原文(照录) + +#### (一)二次分配方案 + +- 二次分配只出现在合同刚建立时。 +- 合同建立时可手动选择参与二次分配的业务员。 +- 分配比例不要求合计 100%。 +- 审批流程可增加开关配置;关闭时直接生效,开启时走审批。 +- 修改二次分配方案不影响已经结算的月份。 +- 已结算月份如需调整,只能通过调整单或后续月份扣补处理。 + +#### (二)工作台 + +- 经营绩效总览 +- 待办事项 +- 风险预警 +- 最近同步/计算/审批动态 + +#### (三)复核审批 + +- 营销后勤提交 +- 财务复核 +- 领导审核 +- 审批开关配置 +- 审批意见与版本 + +#### (四)月结、审批、报表 + +- 月度结算按确认回款和计算快照生成。 +- 支持负数工资、扣补说明和调整单。 +- 结算后锁定,修改源数据不影响已结算结果,只产生提示、历史记录和后续调整。 +- 导出模板围绕财务复核和领导签字设计。 + +#### (五)备份恢复与运维检查 + +- (标题级条目,无细则,用户强调「这个备份恢复很重要」) + +#### (六)系统设置 + +- 公司信息配置:公司名称、logo 等基本信息 + +#### (七)参数配置强调 + +- (用户原话):「业务强调参数配置尽量还是灵活一点,能省去以后很多麻烦」 + +### 3.2 模块归属与设计要点(开发侧推演,非需求原文) + +#### (一) 二次分配方案 → ③ 录入 + ④ 分摊消费 + ⑦ 审批联动 + ④ 调整单衔接 + +- 「只出现在合同刚建立时」→ 录入入口放业务创建流程(与 `businesses` 创建同事务或紧随其后);合同建立后是否允许补录/修改未明说,方案修改的留痕方式待设计; +- 「分配比例不要求合计 100%」→ 余额归属规则需求未说明(3.3-1),存储上只存各参与人比例、不强制校验合计; +- 「修改不影响已结算月份」→ 月结快照天然隔离(快照固化当月结果),方案表需支持编辑 + 修改留痕; +- 「已结算月份调整只能通过调整单或后续月份扣补」→ 调整单为统一承载机制,见 (四)。 + +#### (二) 工作台 → ⑧ 新页面(升级现有 Bento 仪表盘占位) + +| 区块 | 数据源(推演) | +| --- | --- | +| 经营绩效总览 | ④ 台账/月结(按人/团队/月份汇总) | +| 待办事项 | ⑦ 待审批单、⑥ 同步失败、回款未指定批次(M2 已有该状态可复用) | +| 风险预警 | 规则未定义(3.3-5),先落参数化规则容器 | +| 最近同步/计算/审批动态 | ⑥ 同步日志 + ④ 核算/月结记录 + ⑦ 审批记录 | + +#### (三) 复核审批 → ⑦ 新模块 + +- 三级节点:营销后勤提交 → 财务复核 → 领导审核; +- 审批对象:月度结算((四) 联动);二次分配方案审批((一) 开关联动); +- 「审批开关配置」→ 进 ① 参数体系(BOOL 参数,按审批对象各一);「关闭时直接生效」= 提交即生成自动通过的审批记录(留痕不断链); +- 「审批意见与版本」→ 审批单带版本号:重算/改单产生新版本,旧版本与意见留痕不覆盖; +- **登录角色体系扩展**:营销后勤/财务/领导是系统新登录角色(`roles` 表基础设施已有;`permissions`/`role_permissions` 自 M1 起闲置待权限清单,此处激活)——打破一期「营销人员不登录」的前提,但与该原则不矛盾(营销人员仍不登录,后勤/财务/领导是职能角色)。 + +#### (四) 月结、审批、报表 → ④ 月结子块 + ⑦ 审批 + ⑤ 导出模板 + +- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」定义待澄清(3.3-3); +- 「支持负数工资」与既有裁决「负绩效按公式正常计算(不归零)」一致,无需改公式口径; +- 调整单:新单据类型(补发/扣减方向、关联月份、扣补说明、审批状态); +- 「结算后锁定」:已锁月份的快照金额不可变;源数据修改只产生提示、历史记录和后续调整 —— 与 #19「归属变更不回溯历史」同一哲学,扩展到全部源数据; +- 导出模板含财务复核栏、领导签字栏、公司信息页眉(依赖 (六) 公司信息配置)。 + +#### (五) 备份恢复与运维检查 → ⑨(原文无细则,范围由开发定义后交用户确认) + +- 备份:MySQL 定时全量(mysqldump,命名卷外置)+ 保留策略 + **恢复演练文档与脚本**(备份不演练 = 没有备份); +- 运维检查:容器状态、磁盘空间、备份新鲜度、Redis 键 TTL 抽查、同步日志异常、登录日志异常 → 脚本化健康报告; +- 用户强调重要 → 建议 M3 期间并行尽早交付(不依赖业务模块)。 + +#### (六) 系统设置-公司信息 + 参数灵活性 → ① 增量 + +- 公司信息配置:公司名称、logo(登录页与 ⑤ 导出页眉消费); +- 参数灵活性(用户重申方向):新增审批开关、二次分配、月结相关配置**一律进参数注册表**(16 项清单将扩充),沿用「公式模板(代码内有限集合)+ 参数绑定」既定路线;新增公式形态仍需加模板(一期接受,与「不引入规则引擎」一致)。 + +### 3.3 待澄清问题(不阻塞收录,对应模块开工前须澄清) + +| # | 事项 | 影响模块 | 建议默认处理 | +| --- | --- | --- | --- | +| 1 | 二次分配的**分配标的**(绩效金额?折算毛利润?业绩额?)与**余额归属**(合计 ≠100% 时差额归谁);与已作废的「协助 25%」(#15)是何关系 | ④ 分摊逻辑与台账归属字段 | 未确认前 ④ 只落录入与存储,不实现分摊 | +| 2 | 二次分配审批开关默认值(开/关) | ⑦ 参数种子 | 按「支付环节按需规默认值参数化」先例:默认关闭(直接生效),可开 —— 待业务确认 | +| 3 | 「确认回款」的定义:登记即确认 / T+ 同步即确认 / 需人工确认动作? | ④ 月结取数口径 | 建议需人工确认动作(可在回款确认状态上扩展),待确认 | +| 4 | 审批角色权限清单:营销后勤/财务/领导的账号归属、可见菜单、操作权限点 | ⑦ 与系统管理 | 开工前业务方给出清单;技术侧激活 permissions/role_permissions | +| 5 | 风险预警规则:哪些情形预警、阈值多少 | ⑧ | ④⑤ 落地后与业务确认首批规则,先落参数化容器 | +| 6 | 调整单是否走同一审批链;能否跨月调整已锁定月份 | ④⑦ | 建议走同一审批链;「后续月份扣补」为默认路径,跨月调整需授权留痕 —— 待确认 | +| 7 | 待办事项的范围(除审批/同步失败外还含哪些,回款未指定批次是否算待办) | ⑧ | 按 3.2(二) 默认清单起步,业务确认后扩充 | + +### 3.3.1 答复回填(2026-10-19) + +> 业务方 24 项答复已到(2026-10-16,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列),2026-10-19 全部落实并实现;本节为 3.3 各项的落定状态,供后续引用。答复与默认不同处以答复为准;仍未答复项见 `docs/待答复催办清单_2026-10-19.md`。 + +| 3.3# | 事项 | 落定状态 | +| --- | --- | --- | +| 1 | 二次分配标的与余额归属(含与 #15 协助 25% 关系) | **答复仍空白;2026-10-19 已按建议默认实现分摊拆行**——分配标的=绩效金额份额(参与人份额×比例、负责人拿余额、行合计守恒、合计>100% 与负责人自分 400 拦截);协助 25% 维持仅记录不核算;业务答复后如有变更走口径调整 | +| 2 | 二次分配审批开关默认值 | 已答复:**默认开启**,可手动关闭(已落实 V8 参数种子) | +| 3 | 「确认回款」定义 | 已答复:需人工确认动作,可手动关闭(系统已按此预留并实现) | +| 4 | 审批角色权限清单 | 部分答复:先预设角色,名称与职权待客户确认;现按最小授权运行(后勤=提交录入,财务=复核+导出,领导=审核只读),营销后勤暂不可见审批列表 | +| 5 | 风险预警规则 | 已答复:方向确认参数化;首批三条(逾期 90 天/同步失败/超时 3 天)未点名,按默认起步——⑧ 工作台实现 | +| 6 | 调整单是否走同一审批链;能否跨月调整已锁月份 | 已答复:同一审批链;跨月调整需管理员授权并留痕(已实现) | +| 7 | 待办事项范围 | 已答复:待审批 + 同步失败 + 回款未指定批次起步——⑧ 工作台实现 | + +### 3.4 对路线图的影响(本次已修订) + +1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容;第三章待 3.3 各项澄清后,按第二章同粒度补齐 M3 各增量块的任务级实施计划。 + +## 验证方式 + +1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款); +2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose); +3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测); +4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量 301+ 用例与 e2e 全绿。 diff --git a/docs/superpowers/plans/2026-10-13-M2-基础数据与业务数据.md b/docs/superpowers/plans/2026-10-13-M2-基础数据与业务数据.md new file mode 100644 index 0000000..9434353 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-M2-基础数据与业务数据.md @@ -0,0 +1,106 @@ +# M2 实施计划:基础数据管理(②)+ 业务数据管理(③) + +## 背景 + +- 需求 28 项裁决已落定(2026-10-12),M1(系统设置与参数体系)已完成并推送 `feat/system-params`。按剩余开发计划,M2 = ②基础数据 + ③业务数据 **合并交付**,一次表结构评审定稿 T+ 预留列与报表字段清单(即本计划 A 节)。 +- 目标:三类基础数据(人员/产品/客户)+ 四类业务数据(业务/发货/回款/评级)全流程可维护、可留痕,为 M3 绩效核算供数。 +- 设计调研已完成并抽查核对(`models/base.py` 命名约定、`formula.py` 纯函数、`test_models.py` 白名单、迁移现状 V3 均与设计一致)。 + +## 开工前处置 + +1. 当前 `feat/system-params` 工作区有未提交内容(2 个已删除 docs 文件、若干未跟踪的测试/文档)——先提交或与用户确认处置。 +2. 从 `feat/system-params` 切出 `feat/base-business-data`(M2 外键依赖 M1 的 `business_roles`/参数体系)。 + +## A. 表结构(V4 基础数据 6 表 + V5 业务数据 7 表) + +公共约定(照 `models/base.py` 现状):表名小写蛇形复数;`NAMING_CONVENTION` 自动产出 `uk_/idx_/fk_`;`mysql_table_args("中文表注释")`;可编辑表继承 `TimestampMixin`;**append-only 留痕表不继承**(仅手写 `create_time`,`login_logs` 先例)。金额 `DECIMAL(14,2)`、单价 `(14,4)`,schema 入出参一律 `str`,服务层 `Decimal`/`Fraction`。 + +### T+ 预留四列(覆盖 6 张主表:personnel/products/customers/businesses/shipments/receipts) + +| 列 | 类型 | 说明 | +| --- | --- | --- | +| `source_no` | VARCHAR(64) NULL | T+ 原始单据号,普通索引(幂等去重锚点;唯一键待 IT 定键后小迁移升级) | +| `t_plus_id` | VARCHAR(64) NULL | T+ 内部标识 | +| `sync_batch_no` | VARCHAR(32) NULL | 同步批次号(M4 写入) | +| `synced_at` | DATETIME NULL | 最近同步时间 | + +全部可空、不设唯一键(对照表 #26 键语义未定)。`personnel_plans` 仅 `source_no` 单列;明细行表不加(随主表同步)。**「同步不覆盖人工字段」写入表注释**:`business_items.guided_price`、各归属列、`personnel.business_role_id`/`salary_standard`、`customers.customer_type`、`businesses.status`。 + +### V4 六表 + +1. **`personnel`** 人员表:`personnel_name`、`business_role_id`(FK→business_roles RESTRICT,同步不覆盖)、`manager_id`(FK→personnel 自引用,NULL=经理本人/未分配,支撑「本团队业务员」)、`phone`、`hire_date`、`is_probation`(试用期照算不发放,裁决 #17)、`salary_standard DECIMAL(12,2)`、`status`(1在职 0离职)、T+4 列、`remark`。不与 `users` 关联(一期营销人员不登录)。 +2. **`personnel_regions`** 产品辐射区域:`personnel_id` FK、`region_name`、`remark`(1:N 子表,随人员维护)。 +3. **`personnel_plans`** 市场拓展计划:`personnel_id` FK、`plan_period`、`plan_content`、`target_desc`、`source_no`。 +4. **`products`** 产品表:`product_name`、`spec_model NOT NULL DEFAULT ''`(空串参与唯一键,对齐 MySQL 组合索引 NULL 不去重先例)、`unit`、`stock_quantity`(T+ 语义待确认,原样存储)、`sale_price`、`product_category`(`municipal_water`/`rail_transit`,**与 M1 `product.threshold` 参数维度同一值域常量 `PRODUCT_CATEGORIES`**,阈值由 M1 参数承担不建列)、`status`、T+4 列、`remark`。`uk_products_name_spec(product_name, spec_model)`。 +5. **`customers`** 客户表:`company_name`、`owner_name`(客户负责人**人名字符串**,非系统人员引用)、`contact_person`、`contact_phone`、`address`、`industry`、`customer_type`(`personal` 个人户 / `manager_personal` 经理个人户 / `enterprise` 企业户,计提规则匹配锚点,同步不覆盖)、`owner_personnel_id` FK(归属,变更不回溯,裁决 #19)、T+4 列、`remark`。`uk_customers_company_owner(company_name, owner_name)`。 +6. **`ownership_change_logs`** 归属变更留痕(**append-only**):`owner_type`(1客户 2业务)、`customer_id`/`business_id` 可空 FK、`old/new_owner_personnel_id`、`old/new_assistant_personnel_id`(冗余固化防人员删除后不可读)、`change_reason`、`operator_id`(FK→users SET NULL)、`operator_name`、`create_time`。独立表而非 JSON:可分页/join 人员/导出,与 M1 `param_values` 同一哲学。 + +### V5 七表 + +7. **`businesses`** 业务表:`business_no`(自动生成 `YW{YYYYMMDD}-{4位序号}`,即报表「合同编号」,uk)、`customer_id` FK、`business_date`、`status`(1执行中 2已完成 3已终止,终态不可变更)、`owner_personnel_id`(可空=待指定,M3 核算前置校验非空)、`assistant_personnel_id`(一期核算不使用,裁决 #15 剔除协助 25%,仅记录事实)、`is_special`/`special_note`、**五个汇总冗余列** `total_sale_amount`/`total_cost_amount`/`total_ship_amount`/`total_received_amount`/`total_freight_amount`(服务层事务内单点重算,禁手工直改;运费不计毛利润口径,裁决 #2)、T+4 列、`remark`。 +8. **`business_items`** 业务产品明细:`business_id` FK、`product_id` 可空 FK(可空=自由录入,配合「数据提取」)、`product_name`/`spec_model`/`unit` 冗余固化(防产品改名影响历史)、`product_category`(**行级折算锚点**,混合业务按产品逐条算,裁决 #14)、`quantity`、`sale_price`、`sale_amount`(服务层按 数量×单价 固化)、`cost_amount`(可空,来源待 T+ 确认)、`guided_price`(人工录入,同步不覆盖)、`remark`。**不建** `profit_amount`/`profit_rate`/`converted_profit` 落库列——派生值落库会在成本调整/参数改版后漂移,M2 以只读毛利润视图出数,M3 台账才是固化点。 +9. **`business_status_logs`** 状态流转留痕(**append-only**):`business_id` FK、`old_status`、`new_status`、`change_reason`、`operator_id`/`operator_name`、`create_time`。 +10. **`shipments`** 发货单:`shipment_no`(`SH{YYYYMMDD}-{序号}`,uk)、`business_id` FK、`ship_date`(**回款天数基准日,发货日首日=第 1 天**,裁决 #1)、`freight_amount`(仅记录展示)、`total_amount`(明细汇总冗余)、T+4 列、`remark`。 +11. **`shipment_items`** 发货明细:`shipment_id` FK、`product_id` 可空、`product_name`/`spec_model`/`unit` 冗余、`quantity`、`sale_price`、`amount`。 +12. **`receipts`** 回款表:`receipt_batch_no`(`HK{YYYYMMDD}-{序号}`,uk)、`business_id` FK、`shipment_id` **可空** FK(**手动指定发货批次**,裁决 #18;须与 business_id 同属一个业务,服务层强校验;指定后回款天数方可计算)、`receipt_date`(M3 按此日匹配参数版本)、`amount`(≥0,一期禁负数)、`is_barter`(以物抵顶标记,M3 按 80% 折算,M2 仅标记)、`settlement_status`(0未核算 1已核算,**M3 防重锚点**,预置避免 M3 反向迁移)、T+4 列、`remark`。 +13. **`customer_rating_grades`** 评级等级配置:`grade_name` uk、`min_score`/`max_score DECIMAL(5,1)`、`sort_order`(判定=按序首个 min_score ≤ 总分,避免区间边界歧义)、`remark`。种子 4 条(迁移 + init-dev 双写):A 优秀 90-100、B 良好 75-89、C 合格 60-74、D 待改进 0-59。评分项固定 5 项 × 20 分为前端常量(#25:仅打分工具,不影响绩效金额)。 + +### 编号生成规则(`core/constants.py` 集中前缀常量) + +`{前缀}{YYYYMMDD}-{当日4位序号}`,序号按自然日重置。服务层 `SELECT MAX(no) ... FOR UPDATE` 后 +1;撞 uk(1062)兜底重试一次后仍失败返回 400(沿用 M1 code-review 修复过的并发兜底模式)。 + +### 客户分类 × M1 参数对应(M3 消费契约) + +| customer_type | 核算规则 | M1 参数 | +| --- | --- | --- | +| `personal` | `10‰ − 天数×4/360‰`,361 天起 4‰ | `salesperson.rate.*`/`days.boundary` | +| `manager_personal` | 归经理本人,12.5%+5% 结构 | `manager.share.*` | +| `enterprise` | 固定计提(2‰/3‰ 按日期) | `enterprise.rate`(与产品类别交叉) | + +## B. 接口与前端 + +- 新增 `app/api/v1/base/`(`/api/v1/base`,人员/产品/客户)与 `app/api/v1/business/`(`/api/v1/business`,业务/发货/回款/评级),与 `system/*` 平级。约 30 个端点。 +- **权限**:全部登录即可(`get_current_user`,不套 `AdminUser`)——M2 是业务操作(需规 2.5.1「普通用户」),M1 的 `AdminUser` 仅用于 system/*。写操作全部 `audit_service.audit()` 留痕。 +- 关键端点:人员 CRUD+区域/计划全量替换(**clear 后立即 flush**,`replace_roles` 先例坑);产品 CRUD+`POST /products/extract`(业务明细未入库产品按名称+规格批量建产品,幂等);客户归属变更(事务内更新+插留痕);业务 CRUD(明细行数组、汇总重算 `_recalc_totals`、状态机 1→2/3 终态 400、毛利润视图 `GET /businesses/{id}/gross-profit?asOf=`);发货/回款 CRUD(回款跨业务指定批次 400、已核算禁改禁删);评级等级 CRUD(区间不重叠校验)。 +- 关键校验:删除保护三类(被引用→400 引导离职/停用/改终止);金额 ≥0;`quantity>0`;销售额 0 防除零(`formula.profit_rate` 已内置)。 +- 前端 7 页(复用 `usePagedList`/`useConfirmAction`/`AppPagination`/`StatusTag`/`--ys-*` token,不新增基础组件):`/base/personnel`(编辑抽屉内子表编辑区域/计划)、`/base/products`(类别下拉展示当前阈值、提取产品按钮)、`/base/customers`(归属变更二次确认文案含「不回溯历史」)、`/business/businesses`(明细行编辑器、详情抽屉含状态时间线/毛利润卡片)、`/business/shipments`、`/business/receipts`(选业务级联加载该业务发货单,可清空)、`/business/rating`(5 项评分实时出等级+映射维护抽屉,标注「仅打分工具」)。菜单新增「基础数据」「业务数据」两组,均不设 `requiresAdmin`。api 封装 7 个文件(蛇形字段直出,同 `businessRoles.ts` 模式)。金额展示字符串直出,禁 float 格式化。 + +## C. 任务分解(T1–T15) + +依赖链:`T1 → (T2∥T3∥T4) → T5 → T6 → T7 → T8 → (T9∥T10) → T11 → (T12∥T13∥T14) → T15`。每任务「实现 → 测试/冒烟 → commit(`feat(scope): 中文说明`)」。 + +| # | 任务 | 要点 | +| --- | --- | --- | +| T1 | V4 迁移 + 基础数据模型 | `models/personnel.py`/`product.py`/`customer.py`;**注册进 `models/__init__.py`(漏注册会生成删表迁移)**;`test_models.py` EXPECTED_TABLES +6;autogenerate → 人工核对 → 导出 `db/migration/V4__*.sql`;同步 `init-dev.sql`(含 alembic_version 标记);`db/queries/基础数据.sql` 留档 | +| T2 | 人员管理后端 | schemas/repositories/services/api 四层;岗位存在且启用、manager 非自己且在职、删除保护→提示改离职、区域/计划全量替换 clear 后立即 flush | +| T3 | 产品管理后端 | 类别校验复用 `PRODUCT_CATEGORIES`;1062→400;删除保护;`extract` 幂等 | +| T4 | 客户管理后端 | 公司+负责人唯一;归属变更(更新+留痕同事务);留痕分页查询 | +| T5 | 基础数据集成测试(统一编写点) | `test_personnel.py`/`test_products.py`/`test_customers.py`:唯一冲突、删除保护、留痕内容、未登录 401、审计双记录、非法枚举;更新 `backend/tests/README.md` | +| T6 | V5 迁移 + 业务数据模型 | `models/business.py`/`shipment.py`/`receipt.py`/`rating.py`;EXPECTED_TABLES +7;等级种子 4 条双写;`db/migration/V5__*.sql` + `init-dev.sql` + `db/queries/业务数据.sql` | +| T7 | 业务管理后端 | 编号生成;明细全量替换+五项汇总重算;状态机终态 400;归属留痕;毛利润视图(`resolve_snapshot` + `formula.profit_rate`/`converted_profit`,asOf 参数) | +| T8 | 发货管理后端 | 金额/运费汇总回写业务冗余列;终态业务禁新发货;被回款指定禁删 | +| T9 | 回款管理后端 | 批次号;**shipment_id 同业务强校验**;已核算禁改禁删;合计回写;DTO 出 `paymentDays`(`formula.payment_days`) | +| T10 | 评级等级配置后端 | 区间不重叠/0≤min≤max≤100 校验;名称唯一 | +| T11 | 业务数据集成测试(统一编写点) | 编号格式与序号递增、终态拦截、三处汇总冗余一致性、跨业务 400、已核算禁改、毛利润视图(27 天回款天数/防除零/取参)、审计、401;**全量 pytest 回归** | +| T12 | 前端基础数据三页 | 人员/产品/客户;归属变更确认文案含「不回溯历史」;`pnpm type-check && pnpm build` | +| T13 | 前端业务/发货/回款三页 | 明细行编辑器、详情抽屉、回款级联指定批次、金额字符串直出 | +| T14 | 前端评级计算器页 | 5 项评分→实时等级;映射维护抽屉;「仅打分工具」标注 | +| T15 | e2e 与文档收尾 | `e2e/base_data.py`/`business_data.py` 全链路(建数据→业务→发货→回款→回款天数→毛利润→评级);全量回归(后端基线 406+ 新增全绿、前端构建、e2e);三份 README 更新 | + +## 验证方式 + +- T1/T6:`uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q` 全绿(双轨零漂移是硬关口)。 +- T5/T11:模块集成用例统一编写并执行;T11 后 `uv run pytest -q` 全量回归。 +- T12–T14:`pnpm type-check && pnpm build`。 +- T15:后端全量 pytest + 前端构建 + 两个 e2e 脚本全过;`ai-aux/AI_CONTEXT.md` 历史任务摘要由主会话收尾时更新。 + +## 风险与开放问题(默认处理,不阻塞开工) + +1. 毛利润成本来源待 T+:`cost_amount` 可空+人工可录先行;视图中成本缺失的行标注「无法计算」。 +2. 人员条目 T+ 边界:一期手工 CRUD 完整可用,同步以 `source_no` 幂等、不覆盖人工字段。 +3. 拓展计划粒度:自由条目容器(期间+内容+指标说明)。 +4. 业务编号前缀:一期代码常量 YW/SH/HK,如需配置走 M1 参数体系。 +5. 终态回退:一期不可变更(400);如需重开再加 2→1 规则。 +6. 负数回款/红字:一期禁负金额;冲销需求出现时 M3 前补设计。 +7. 回款未指定批次:允许保存,列表提供筛选,M3 核算前置校验非空。 +8. T+ 幂等键:`source_no` 普通索引先行,IT 定键后小迁移升级。 diff --git a/docs/superpowers/plans/2026-10-19-M4-报表统计.md b/docs/superpowers/plans/2026-10-19-M4-报表统计.md new file mode 100644 index 0000000..2a43978 --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-M4-报表统计.md @@ -0,0 +1,160 @@ +# M4 ⑤ 报表统计 · 任务级实施计划 + +> 立项日期 2026-10-19;分支 feat/m3-increments。依据:需规 2.4 /《需求规格说明书》FR-029~FR-032 / 开发计划第五章 1.3 验收行 / 2026-10-15 补充(四)「导出模板围绕财务复核和领导签字设计」。 +> 前置已满足:④ 台账/月结/调整单全部交付(台账即「报表 2.4.1 的数据源」,`performance_ledger.py` 注释锚点)。 +> **纯只读模块,无数据库迁移**。 + +## 〇、代码核实结论(实施时按此避坑) + +| # | 结论 | 影响 | +| --- | --- | --- | +| 1 | `require_roles` 403 文案硬编码「无权访问审批管理」(`api/deps.py:135`) | 给 `require_roles` 增可选 `message` 参数(默认值不变),报表传「无权访问报表统计」 | +| 2 | `PageQuery.page_size` 上限 200(`core/response.py:57`) | 打印视图循环翻页取全量;导出走专用无分页查询 | +| 3 | `settlement_service._confirm_scope` 带 `settlement_id IS NULL`(可入结口径) | 报表取数**不复用**,新增同月全量生效单笔行查询 | +| 4 | `list_adjustments` 无月份筛选、limit 200、含全部状态 | 调整单并入需新写仓储查询(月份 + status=2) | +| 5 | `GET /system/settings` 是 AdminUser,`/public` 才无角色限制 | 打印视图页眉必须走 `getPublicSettings()` | +| 6 | 台账 `payment_days` 可空可负(先款后货);进度登记回款无台账行(#25) | 区间桶含「≤0 天」「无天数」兜底;回款统计注明「不含进度登记回款」 | +| 7 | 二次分摊拆行使同一回款多行(负责人+参与人,business_id 相同) | 回款金额按 receipt_id 去重;个人明细不放「关联回款金额」列 | +| 8 | 经理聚合行 calc_type=2 的 business_id 是锚点、receipt_id=NULL、payment_days=NULL | 业务统计/个人明细/回款统计一律 `calc_type=1 AND is_active=1`,防聚合额污染 | +| 9 | openpyxl `NUMERIC_TYPES` 含 Decimal,原样落数值不转 float | 精度红线导出侧成立;`number_format='#,##0.00'` 控显示 | +| 10 | `test_param_registry.py` 锁 registry 19 项 | 新增参数需四处同改(本计划不新增参数,规避) | +| 11 | `PRODUCT_CATEGORIES` 常量不含 `mixed`,台账行有 `mixed` | 标签映射额外覆盖 mixed,单测防漂移 | +| 12 | `audit_service.audit` 可用于只读导出留痕(独立 commit) | 直接复用;上下文内 bytes 在 commit 前固化 | +| 13 | `test_settlement_helpers.py` 直接 import `settlement_service._month_end` | 抽共享模块时保留薄委托包装 | +| 14 | 前端无任何打印/PDF/xlsx 代码 | 打印从零建:独立顶层路由 + `@media print` | +| 15 | Starlette 响应头 latin-1,中文文件名会 UnicodeEncodeError | 导出文件名服务端 ASCII、中文名前端 `link.download` 赋 | + +## 一、全局口径(写进 report_service docstring) + +1. 数据源 `performance_ledgers` 固定 `is_active=1 AND calc_type=1`;聚合行只在部门汇总作冻结对照单读;月份维度 `settlement_month`。 +2. 金额全程 `Decimal` 出参字符串;比例原样 `rate_text`,禁 float/截断。 +3. 调整单并入:仅 `status=2`,按 `(settlement_month, personnel_id)` 并入,`direction=1` 记 +、`2` 记 −;归属按实时 `manager_id`。 +4. 离职人员不按 `status` 过滤(历史业绩留在原团队);零行经理枚举仅在职经理。 +5. 汇总 = 明细合计(团队/公司基数 == 成员明细绩效合计,测试逐项断言)。 + +### 已定设计决策 + +- 部门口径 = `personnel.manager_id` 实时上溯(无独立部门表,人员模型 docstring 已声明;已入催办清单 R1 向业务确认) +- 导出 = 后端 openpyxl + StreamingResponse(ASCII 文件名);打印 = 前端独立打印视图 + `@media print`,PDF 走浏览器另存为;不引前端 PDF/xlsx 依赖 +- 不做图表(需求无要求,不引 ECharts) +- 区间桶 = 代码常量 + 服务端下发元数据(前端零硬编码);不进参数体系避免迁移;配置缝收在 `get_payment_day_buckets()` 单函数,升级路径:`report.payment_days.buckets` 参数 → V10 迁移种子 → init-dev → registry 测试 19→20 +- 权限 `require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER)` 粗粒度,前后端双层(admin 显式列入) + +## 二、后端任务 + +### T1 常量与元数据(`core/constants.py` 改 + `core/report_meta.py` 新) + +> 实施修订(2026-10-19):`report_meta` 放在 `core/` 而非 `services/`——仓储层 +> 的区间下钻需要 `DayBucket` 类型,放 services 会让 repository 反向依赖 +> service 层(违反六层红线)。 + +- `MODULE_REPORT = "报表统计"`、`ACTION_EXPORT = "导出"` +- `report_meta.py`(叶子模块,禁 app 内导入防循环依赖):`DayBucket` frozen dataclass + `PAYMENT_DAY_BUCKETS` 8 档(≤0 天(先款后货)/1-30/31-60/61-90/91-180/181-360/361+/无天数)、`CUSTOMER_TYPE_LABELS`、`PRODUCT_CATEGORY_LABELS`(含 mixed)、`REPORT_TEMPLATE_VERSION = "V1.0"`、`REPORT_EXPORT_MAX_ROWS = 20000` + +### T2 仓储 `repositories/report_repository.py`(新) + +| 函数 | 说明 | +| --- | --- | +| `list_personal_detail / count_personal_detail` | 单笔生效行分页(month/personnel/customer_type/product_category/role_code 筛选,id desc) | +| `sum_personal_detail` | 全筛选口径 SUM(分页合计与页内合计分开,禁内存当前页求和) | +| `list_month_receipt_rows` | 同月全量生效单笔行(部门汇总取数,不复用 _confirm_scope) | +| `list_month_manager_aggregates` | 同月 calc_type=2 生效行(冻结对照) | +| `list_month_adjustments` | status=2 + 月份 | +| `list_business_stats / count / sum` | Business×Customer×Personnel 左联 + 三个 scalar_subquery(发货额 SUM(shipments.total_amount)、回款额 SUM(receipts.amount)、绩效 SUM(台账 performance_amount WHERE calc_type=1 AND is_active=1));keyword 双列 OR contains(autoescape=True) | +| `list_payment_group_by_days` | GROUP BY (payment_days, customer_type, product_category) 出 SUM 绩效/应付/COUNT | +| `list_payment_receipt_group_by_days` | 先 DISTINCT receipt_id 再 JOIN receipts 求和(拆行防翻倍) | +| `list_payment_detail / count` | 区间下钻分页(闭区间 between、开区间 >=、none 桶 IS NULL),JOIN receipts 取金额与批次号 | + +### T3 经理分成共享口径(`services/manager_share.py` 新 + `settlement_service.py` 改薄委托) + +- 迁出(复制实现 + 薄委托,行为零变化):`month_end`、`ManagerShareParams`(含 `manager_share_params(session, month)`,锚定月末 resolve_snapshot)、`collect_team_buckets`、`company_base_of` +- 报表侧分成额调用 `formula.manager_team_share(...)`——月结聚合行与报表汇总同源,杜绝口径漂移 +- **协调点**:84 会话已拆 settlement_service(028a94a);本任务在其基础上做,验证 `test_settlement_helpers.py` 全绿 + +### T4 服务与 schemas(`services/report_service.py` + `schemas/report.py` 新) + +1. **个人明细**:`ReportPage[T]` = 标准分页四字段 + `summary`(全筛选合计);行含 ledger_id/business_id/business_no/人员/岗位/客户类型/产品类别/ship_date/receipt_date/payment_days/rate_text/weight_r/payment_weight/performance_amount/payable_amount;**不含回款金额列** +2. **部门汇总**(月份必填):`collect_team_buckets` 分团队 → 团队基数/成员内联明细(personnel_id 聚合 + 台账 id 列表 + 调整 extra/deduct/net);`company` 块 5% 单列(team_base_total/company_base/company_share_amount;各团队 5% 之和 ≠ 全局 5%,UI 注明);零行经理并集枚举(在职 sales_manager JOIN business_roles + 数据中出现的 manager_id);对比 `company_base_ratio` 除零→0;冻结对照 `aggregate_ledger_id + frozen_amount`;出参 `params`(team_pct/company_pct/manager_personal_included/params_as_of) +3. **业务统计**:分页 + summary;回款/绩效为该业务累计值(不随日期筛选变,页面 hint) +4. **回款统计**:SQL 精确值聚合 → Python 按 `PAYMENT_DAY_BUCKETS` 分桶(恒含全桶、空桶 0);正负绩效直接求和不 ABS;出参 buckets/by_customer_type/by_product_category/totals +5. **区间下钻**:`GET /reports/payment-detail`(bucket 必填,分页) +6. **导出编排** `export_report(session, code, q, *, operator, meta)`:先 count 判上限(超限 400 中文提示按月缩小);`audit_service.audit(MODULE_REPORT, ACTION_EXPORT, ...)` 包取数+构建;出参 `(bytes, filename)` 在 commit 语义闭合前固化 + +### T5 Excel 模板(`services/report_export.py` 新,纯函数) + +- 普通 `Workbook`(上限封顶 2 万行内存可承受);版式:公司页眉行(logo `read_logo` try/except 降级纯文本)→ 标题(合并居中)→ 筛选条件行 → 导出时间/人/模板版本 → 表头(加粗底纹边框 + freeze_panes)→ 数据行 → 合计行(service 侧 Decimal SUM quantize HALF_UP,**不写 Excel 公式**)→ 空两行 → 财务复核栏 / 领导签字栏 / 日期栏 +- 精度:金额 Decimal 直接写 + `#,##0.00`;比例 `'0.00"%"'`;`rate_text` 原样文本;日期 `date` + `YYYY-MM-DD` +- 防公式注入:文本以 `= + - @ \t` 开头前置 `'`;CJK 列宽;部门汇总/回款统计多 sheet(各带页眉与签字栏);空数据允许(表头 + 提示行 + 签字栏) + +### T6 API 与路由(`api/v1/reports.py` 新 + `router.py` 改 + `deps.py` 改) + +| 方法 | 路径 | 入参(Query 蛇形) | +| --- | --- | --- | +| GET | `/api/v1/reports/personal-detail` | pageNum/pageSize + settlement_month, personnel_id, customer_type, product_category, role_code | +| GET | `/api/v1/reports/department-summary` | settlement_month(必填), manager_id | +| GET | `/api/v1/reports/business-stats` | pageNum/pageSize + keyword, customer_id, owner_personnel_id, status, date_from, date_to | +| GET | `/api/v1/reports/payment-stats` | settlement_month, date_from, date_to, customer_type, product_category | +| GET | `/api/v1/reports/payment-detail` | pageNum/pageSize + bucket(必填) + 同上 | +| GET | `/api/v1/reports/{code}/export` | code ∈ 四表,参数同对应页 → StreamingResponse xlsx | + +- 全部 `def` 同步路由 + `Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问报表统计"))]` +- Content-Disposition 文件名 ASCII(`personal_detail_20261019.xlsx`),沿用 `system/params.py` 导出先例 + +### T7 后端文档(`backend/README.md` 改) + +报表接口表 + 「报表口径」小节(生效行/调整单并入/回款去重/不含进度登记回款/区间桶常量与升级路径)。 + +## 三、前端任务 + +### T8 API 与常量(`api/reports.ts` 新 + `utils/constants.ts` 改) + +类型逐字段对齐(金额 string);`exportReport(code, params)` 裸 axios `responseType:'blob'`;MODULE_OPTIONS 与后端 `core/constants.py` 全量对齐 + +### T9 四个页面(`views/reports/{personal-detail,department-summary,business-stats,payment-stats}/index.vue` 新) + +统一骨架:`filters(reactive)` + `usePagedList`(非分页表 ref+手动 load)+ `el-table stripe` + `AppPagination` + 工具条「导出 Excel」「打印」+ `handledError`;日期区间 `el-date-picker value-format="YYYY-MM-DD"`(必写);下拉复用 `useDropdownOptions` + +| 页面 | 路由 order | 关键列与下钻 | +| --- | --- | --- | +| 个人绩效明细表 | `/reports/personal-detail` 19 | 需规 10 列 + 页脚全筛选合计(注明非当前页);expand 懒加载 `getLedger(ledger_id)` 快照下钻 | +| 部门绩效汇总表 | `/reports/department-summary` 20 | 团队表 + 全公司 5% 卡片 + 参数取值提示;展开成员 → 跨页下钻个人明细(router.push query)→ 台账下钻复用 `listLedger` | +| 业务统计表 | `/reports/business-stats` 21 | 发货额/回款额/绩效三列;expand 两小表(复用 `listReceipts`/`listLedger`);hint「累计值」 | +| 回款统计表 | `/reports/payment-stats` 22 | buckets 元数据渲染(空桶 0)+ 桶展开下钻 `payment-detail` + 客户类型/产品类别对比小表 | + +路由 meta:`{ title, group: '报表统计', order: 19..22, allowedRoles: [ROLE_ADMIN, 'finance', 'leader'] }`(admin 显式列入) + +### T10 打印视图(`views/reports/print/index.vue` 新 + `router/index.ts` 改) + +- 顶层路由 `/reports/print/:code`(不挂 BasicLayout),`menu:false` + allowedRoles 同上 +- 循环翻页取全量(pageSize 200、上限 2000 截断提示);页眉 `getPublicSettings()`(非管理员拿不到 `/system/settings`) +- `window.print()`(按钮区 `@media print { display:none }`);`@page A4 landscape`;`thead { display: table-header-group }` 每页带表头;打印态白底黑字沿用 `--ys-*` + +### T11 前端文档(`frontend/README.md` 改) + +报表页与打印视图小节 + 权限矩阵 + +## 四、测试(模块整体完成时统一编写) + +### T12 后端(`tests/unit/test_report_helpers.py` + `tests/integration/test_reports.py` 新) + +- 单元:分桶边界(0/1/30/31/60/61/180/181/360/361/负值/NULL);标签键集合 ⊇ CUSTOMER_TYPES ∪ PRODUCT_CATEGORIES ∪ {"mixed"};Excel 结构断言(load_workbook 读回:页眉/合计/签字栏/number_format/注入前缀/空数据);占比除零→0 +- 集成(factories 造数):① 个人明细对拍 `/calc/ledger`、summary==全量和;② 部门汇总成员合计==团队基数==公司基数、`aggregate_amount==manager_team_share` 预期、生成月结后 computed==frozen;③ 无团队成员经理 0 行、离职成员计入原团队;④ 调整单生效行并入(status≠2 不计);⑤ 回款统计空桶恒在、负绩效计入、**分摊拆行金额只计一次**;⑥ 业务统计排除 calc_type=2;⑦ 导出 200/400/审计留痕(成功与失败);⑧ 普通 user 403(文案「无权访问报表统计」) + +### T13 e2e(`frontend/e2e/report_flow.py` 新) + +菜单可见 → 四页筛选与数字 → `page.expect_download()` 断言导出 → 打印视图注入 `window.print` 桩断言 → console 无错;造数用 API + 登录 token(远未来月份隔离,参照 calc_settlement_flow.py) + +### T14 收尾 + +回归(pytest 全量 + ruff + type-check + build + e2e report_flow 与复跑两条既有脚本);三 README 更新;提交拆分 `feat(report): 报表统计后端取数与导出` → `feat(report): 报表统计前端四页与打印视图` → `test(report): 报表模块用例与测试记录` + +## 五、开放项(不阻塞,随催办确认) + +| 事项 | 默认 | +| --- | --- | +| 部门口径(R1) | manager_id 实时上溯 | +| 区间档位(R2) | 8 档代码常量 | +| 签字栏要素(R3) | 页眉/标题/筛选/时间人/版本/复核/签字/日期 | +| 导出上限 | 20000 行超限 400 | +| 打印上限 | 2000 行截断提示 | +| Excel logo | 有则嵌、无则纯文本 | diff --git a/docs/业务确认问题清单.docx b/docs/业务确认问题清单.docx deleted file mode 100644 index 5b0d35c..0000000 Binary files a/docs/业务确认问题清单.docx and /dev/null differ diff --git a/docs/备份恢复手册.md b/docs/备份恢复手册.md new file mode 100644 index 0000000..0b51780 --- /dev/null +++ b/docs/备份恢复手册.md @@ -0,0 +1,101 @@ +# 备份恢复手册(⑨ 备份恢复与运维检查) + +> 用户强调「这个备份恢复很重要」。**备份不演练 = 没有备份**——本手册的一页纸目标:任何运维/开发拿到都能完成「备份 → 恢复到临时库核对 → 删临时库」全链路。 + +## 一、备份(每日一次,建议凌晨低峰) + +```bash +# 项目根目录执行 +bash scripts/backup_full.sh +``` + +- 产物:`db/backups/payroll_YYYYMMDD_HHMMSS.sql.gz`(gzip 压缩) +- 保留策略:自动清理 30 天前的旧备份 +- 凭据:从 `docker/.env` 读取(不入脚本、不入版本库) + +**Windows 定时任务**(任务计划程序 / `schtasks`): + +```cmd +:: 每日 02:30 备份(经 Git Bash) +schtasks /create /tn "ys-payroll-backup" /tr "\"C:\Program Files\Git\bin\bash.exe\" -lc \"bash scripts/backup_full.sh\"" /sc daily /st 02:30 /ru SYSTEM +``` + +## 二、恢复演练(备份后顺手跑一次,验证备份真能用) + +```bash +bash scripts/restore_drill.sh # 恢复到临时库 + 行数核对,临时库默认保留 +bash scripts/restore_drill.sh --drop-exec # 核对通过后再加此参数删除临时库 +``` + +- **绝不直接覆盖生产库**——演练恢复到独立临时库 `payroll_drill_<时间戳>` +- 行数对比覆盖 16 张关键表(users/roles/param_*/business*/personnel/products/customers/shipments*/receipts/approval_*/system_settings) +- 全部 ✓ 即备份可恢复;出现 ✗ 时临时库保留供排查 + +## 二·五、增量备份(澄清 #19 答复:每日全量 + 有更新时增量补充) + +全量之间的数据变更由 **binlog 归档**覆盖(MySQL 8 默认开启 binlog,ROW 格式、服务端保留 30 天): + +```bash +bash scripts/backup_incremental.sh +``` + +- 产物:`db/backups/binlog.NNNNNN.gz`(原始 binlog 文件逐个压缩) +- **有更新时才产出**:状态文件 `db/backups/.incremental_state` 记录上次归档到的文件,无新变更时直接退出 +- 只归档「已写完」的 binlog(正在写的最后一个留到下次收取) +- 保留策略同全量:自动清理 30 天前的增量包 + +**配套**:`backup_full.sh` 每次全量会同时产出位点文件 `payroll_*.sql.gz.pos`(binlog 文件名 + 位点),标记「该全量覆盖到此」——恢复重放从位点之后开始。 + +**恢复顺序**(真出事时):最新全量 → 依次 `gunzip` 其后的增量包 → 用 `mysqlbinlog` 重放(镜像内无此客户端,宿主机或临时容器执行): + +```bash +# 解出增量包后重放(示例:从全量位点之后开始) +gunzip -c db/backups/binlog.000003.gz > /tmp/binlog.000003 +mysqlbinlog --start-position=<全量位点> /tmp/binlog.000003 | \ + docker exec -i ys-mysql-dev sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" payroll' +``` + +**建议定时**:增量与全量同节奏(每日 02:30 全量后接一次增量;变更频繁的日子可在中午加跑一次): + +```cmd +schtasks /create /tn "ys-payroll-backup-incr" /tr "\"C:\Program Files\Git\bin\bash.exe\" -lc \"bash scripts/backup_incremental.sh\"" /sc daily /st 02:50 /ru SYSTEM +``` + +## 三、生产恢复(真出事了,七步) + +> ⚠️ 生产恢复是**高危操作**:先把容器停了再恢复,避免写冲突。下面假设恢复到**全新空数据卷**(最干净路径)。 + +1. **停容器保卷**:`docker compose -f docker/docker-compose.dev.yml down` +2. **删旧卷(谨慎!确认要全量重来才执行)**:`docker volume rm ys-payroll_mysql-dev-data` + - 若只想恢复到某个时间点**且不丢其它数据**,改用「恢复到临时库 → mysqldump 单表导出 → 手工回灌」的细粒度路径,勿整体覆盖。 +3. **起空库**:`docker compose -f docker/docker-compose.dev.yml up -d`(首启自动跑 `db/init/init-dev.sql` 建表 + 种子) +4. **选一份备份**:`ls -t db/backups/payroll_*.sql.gz | head` +5. **导入该备份**: + ```bash + gunzip -c db/backups/payroll_XXXXXXXX_XXXXXX.sql.gz | \ + docker exec -i ys-mysql-dev sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" payroll' + ``` +6. **核对行数**:再跑一次 `bash scripts/restore_drill.sh`(此时源库=刚恢复的库,应全 ✓) +7. **重启后端**:`cd backend && uv run uvicorn app.main:app --reload --port 8000`,登录抽查几个页面确认数据回来 + +## 四、运维健康检查(每日一次) + +```bash +bash scripts/health_check.sh +``` + +六项检查:容器健康 ×2 / 磁盘水位(80% 阈值)/ 备份新鲜度(24h 内)/ Redis 探活 / 24h 登录失败次数(暴力破解预警)。任一项 WARN/FAIL 时退出码非零,方便监控采集。 + +**Windows 定时**: + +```cmd +:: 每日 08:50 检查(上班前出报告) +schtasks /create /tn "ys-payroll-health" /tr "\"C:\Program Files\Git\bin\bash.exe\" -lc \"bash scripts/health_check.sh >> db/backups/health.log 2>&1\"" /sc daily /st 08:50 /ru SYSTEM +``` + +## 五、注意事项 + +- 备份含**全部业务数据与参数种子**,属敏感文件——`db/backups/` 已加入 `.gitignore`,**不要**把它提交到版本库 +- 恢复演练产生的 `payroll_drill_*` 库会占磁盘,核对完及时 `--drop-exec` 清理 +- 备份脚本与恢复脚本都只依赖 Docker(无需宿主机装 MySQL 客户端) +- 增量重放的 `mysqlbinlog` 是唯一需要宿主机(或临时容器)具备的客户端——官方 mysql:8.0 镜像内不含;重放前务必确认 binlog 序号连续(中间断号说明有未归档文件,先跑一次增量归档) diff --git a/docs/待澄清问题清单_2026-10-15(1)(2).xlsx b/docs/待澄清问题清单_2026-10-15(1)(2).xlsx new file mode 100644 index 0000000..dd2e2c8 Binary files /dev/null and b/docs/待澄清问题清单_2026-10-15(1)(2).xlsx differ diff --git a/docs/待澄清问题清单_2026-10-15.xlsx b/docs/待澄清问题清单_2026-10-15.xlsx new file mode 100644 index 0000000..c2215b5 Binary files /dev/null and b/docs/待澄清问题清单_2026-10-15.xlsx differ diff --git a/docs/待确认事项排序表_2026-10-16.xlsx b/docs/待确认事项排序表_2026-10-16.xlsx new file mode 100644 index 0000000..1595f8a Binary files /dev/null and b/docs/待确认事项排序表_2026-10-16.xlsx differ diff --git a/docs/待确认事项排序表_2026-10-16_new.xlsx b/docs/待确认事项排序表_2026-10-16_new.xlsx new file mode 100644 index 0000000..327d07b Binary files /dev/null and b/docs/待确认事项排序表_2026-10-16_new.xlsx differ diff --git a/docs/待确认事项汇总_2026-10-16.md b/docs/待确认事项汇总_2026-10-16.md new file mode 100644 index 0000000..cd1bcef --- /dev/null +++ b/docs/待确认事项汇总_2026-10-16.md @@ -0,0 +1,97 @@ +# 待确认事项汇总(2026-10-16) + +> 汇总当前所有「待执行 / 待答复 / 待恢复」事项,供项目例会与对外跟催使用。 +> 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。 +> 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。 +> **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。 + +## 一、立即待办(需项目内处理) + +| # | 事项 | 状态 | 下一步动作 | +| --- | --- | --- | --- | +| 1 | **推送 feat/m3-increments** | ✅ 已完成(2026-10-19) | 远端 Git 服务恢复后已推送,远端现为 e48cfb7(含 code-review 修复 4 提交 + #14 首日口径 + V8 系列 + 测试/文档);并行会话(88:calc_service 分摊拆行)收口后继续推送其后提交 | +| 2 | **M2 分支合并 main** | 未开始 | feat/base-business-data(M2 全部 + code-review 32 条修复)尚未合入 main;与 #1 一并处理(PR 评审或本地合并) | + +## 二、业务方澄清(24 项,**2026-10-16 答复已到**,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列) + +> 答复汇总:**12 项确认按默认执行**(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#23/#24);**4 项答复与默认不同,以答复为准**——#5 二次分配审批开关**默认开启**、#14 **首日整 10‰**、#16 试用期基本工资**按人员档案逐人维护**、#19 备份**加增量补充**;仍未落定见下表「答复状态」列。答复后待回填开发计划 3.3 与需求记录。 + +### 高优先级(阻塞对应模块开工,8 项) + +| 清单编号 | 问题 | 建议默认(未答复时执行) | 阻塞模块 | 答复状态 | +| --- | --- | --- | --- | --- | +| 1 | 二次分配的分配标的:绩效金额、折算毛利润还是业绩额? | 未答复前只做方案录入,不实现分摊(已按此交付) | ④ 分摊计算 | ❌ 未答复 | +| 2 | 分配比例合计不足 100% 时余额归谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 | ❌ 未答复 | +| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ | ❌ 未答复 | +| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定;分摊实现(88 批次)进行中 | +| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认;**#17 参数查看放开已落实(2026-10-19)** | +| 8 | 回款何时算「确认回款」? | 需人工确认动作(系统已按此预留) | ④ 月结取数 | ✅ 确认:人工确认,可手动关闭 | +| 9 | 调整单是否走同一审批链?能否跨月调整已锁月份? | 同一审批链;后续月份扣补为默认路径 | ④/⑦ | ✅ 确认默认;跨月需管理员授权并留痕 | +| 12 | **经理个人户业绩是否计入其 12.5% 基数?(无默认,必须答复)** | —— | ④ 台账表结构设计 | ✅ **已落实为参数(2026-10-19)**:答复「做参数可调、初始值未给」→ 按默认 0=不计入落地(`manager.personal.included`),可手工调 1=计入 | + +### 中优先级(模块实现期内答复即可,9 项) + +| 清单编号 | 问题 | 答复状态(2026-10-16) | +| --- | --- | --- | +| 5 | 二次分配审批开关默认值? | ✅ 已落实(2026-10-19,V8):默认开启 + 当日生效行;调整单/二次分配/月结三类审批开关均已可读可调 | +| 7 | 月度结算审批链默认开还是关? | ✅ 已落实(2026-10-19,V8):默认开启;财务节点通过仅流转,终审(领导)通过才锁定月结 | +| 10 | 「风险预警」包含哪些情形与阈值? | ✅ 方向确认:参数化;首批三条(逾期 90 天/同步失败/超时 3 天)未点名,按默认起步 | +| 11 | 「待办事项」范围? | ✅ 确认:待审批+同步失败+回款未指定批次起步 | +| 13 | 支付环节 5 项默认值顺手确认(起付 60%/R×R/预付 10‰/抵顶 80%/月工资口径) | ✅ 确认按默认执行 | +| 15 | 支付权重「余额模式」是否需要? | ✅ 一期只用 FULL | +| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | ✅ 已落实(2026-10-19,V8):撤销 `probation.salary.basic` 参数,基本工资读 `personnel.salary_standard` | +| 20 | 含税口径:毛利/运费/价税合计是否含税? | ❌ 未答复:「需与客户再次确认具体计算方式」 | +| 22 | 预收款/保证金/代收款是否计入绩效回款? | ❌ 未答复:「当前未提到预收款情况」,暂按不计入 | + +### 低优先级(已有默认口径,顺手确认,7 项) + +| 清单编号 | 问题 | 答复状态(2026-10-16) | +| --- | --- | --- | +| 14 | 需规图示「第 1 天 10‰」与公式不一致,是否认可按公式? | ✅ 已落实(2026-10-19,按 A 口径):首日整 10‰ 特判、次日起按公式(保「360 天=6‰」锚点);B 口径(整体平移)追问保留 | +| 17 | 计提参数等敏感配置普通用户可否查看? | ✅ 已落实(2026-10-19):查看放开登录用户、修改仅管理员 | +| 18 | 企业户 02-17 之前的业务是否按 2‰ 回溯? | ✅ 确认按回溯 | +| 19 | 备份保留周期与恢复目标? | ✅ 已落实(2026-10-19):全量 + binlog 增量归档补位 | +| 21 | 退货/红字是否冲减绩效? | ✅ 当期冲减(未结算月),已结算走调整单 | +| 23 | 回款按到账金额还是扣手续费净额? | ✅ 按到账金额 | +| 24 | 「首期不生成负绩效」与裁决「负绩效不归零」冲突,以哪个为准? | ✅ 维持裁决:负数照算不归零、月结支持扣补(业务补注:按当前算法首期不会出现负绩效) | + +## 三、待 IT / T+ 侧答复(18 项,见《T+对接数据需求清单》v1.3「待IT确认事项」表) + +> **2026-10-19 清单已升 v1.4**(`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`,七节:人员/产品/业务单据/发货单/回款单/客户档案/同步完整性与补充字段)——本节口径待收编到 v1.4 后再对外;待 IT 逐项确认的状态以最新版文件为准。 +> **2026-10-16 业务方已对「口径与用途说明」修订,已收编清单升 v1.3**:#18 回款↔发货批次定稿**人工指定**(T+ 核销关系仅作辅助,`shipment_id` 保持「同步不覆盖」,v1.2.1「T+ 主源」修订作废,M2 回款页现状不动);「确认回款」两处 #8 开放引用收敛;特殊业务标记待确认降为低优。 +> **新增追问 2 条**(随下一轮回执发业务方):① 「负责营销人员」口径被改为「人员编码**以姓名**为唯一识别」,与「编码是业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误;② 数据清单回款「关联订单号」仍写「由 T+ 收款分配关系指定,人工辅助」,与不覆盖表「人工指定」主次相反,疑漏改。 + +### 高优先级(8 项) + +| # | 事项 | +| --- | --- | +| 1 | **成本额取数口径**(阻塞核算:成本类型/毛利计算方式/更新时间/精度) | +| 2 | 发货日期口径(出库日/签收日) | +| 3 | 到账日期口径(银行到账日/核销日) | +| 4 | 幂等唯一键规则(作废重开是否换号) | +| 5 | 三单关联键(订单/发货/回款如何关联) | +| 6 | **人员唯一编码(工号)+ 业务员↔经理隶属**(隶属已列必须;识别口径疑笔误见上方追问①) | +| 7 | 对接方式与安全(REST/视图/中间表/文件) | +| 8 | 负数/红字/退货/冲销处理规则与原单关联 | +| 16 | 回款确认/核销状态字段与枚举 | + +### 中低优先级(9 项) + +存量起始日期与增量口径、客户档案匹配与唯一键、收款方式枚举清单、产品「数量」语义、发货单价口径、特殊业务标记字段、同步窗口时间、市场拓展计划数据、口径裁决 5 项(#20–#24 已转业务方)。 + +## 四、开发里程碑待办 + +| 里程碑 | 内容 | 前置条件 | +| --- | --- | --- | +| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;**余**:二次分摊实现(#1–#3 答复空白)、业务答复回填批次收口(88 会话) | #20(含税口径)、#1–#3 答复后按同粒度推进 | +| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 | +| M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 | +| code-review 遗留 2 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 | +| 杂项收口(低优) | 超长函数收敛:calc_receipt 与 settlement_service(生成/聚合/调整单)已拆(2026-10-19);剩 preview/change_ownership/create_receipt/update_receipt 等;散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 | + +## 五、文档待办(低优) + +- 需规过时条款未修订(26 天示例、含运费公式、协助 25%);2026-10-15 补充需求未回填需规——对外引用需规前须先修订或注明以对照表/计划第三章为准 +- 澄清答复已到(2026-10-16):待回填开发计划 3.3、AI_CONTEXT、《需求规格说明书》升 V2.1;《T+对接数据需求清单》已升 **v1.4**(2026-10-19,本节口径待收编;生成脚本 build_tplus_xlsx.py 已对齐 v1.3、待对齐 v1.4) +- ~~新口径待落实(业务答复与默认不同的 4 项)~~ ✅ **已全部落实(2026-10-19,见第二节标注)** +- **修复批次引入口径记录(2026-10-19,供业务知会)**:月结的经理聚合与月工资口径按**所属月月末**取参(单笔台账行仍按回款日取参,裁决 #11)——补生成历史月份时结果不随生成日漂移;如业务对月末时点有异议,改 `settlement_service._month_end` 单点即可 diff --git a/docs/待答复催办清单_2026-10-19.md b/docs/待答复催办清单_2026-10-19.md new file mode 100644 index 0000000..0a3540f --- /dev/null +++ b/docs/待答复催办清单_2026-10-19.md @@ -0,0 +1,82 @@ +# 待答复事项催办清单(2026-10-19) + +> **用途**:对外跟催材料,分三块可直接拆分发送——一、业务方;二、IT/T+ 侧;三、报表口径补充确认(本轮新增)。 +> **基线**:《待澄清问题清单_2026-10-15(1)(2).xlsx》(业务 24 项,2026-10-16 已回 15 项)、《T+对接数据需求清单》v1.3「待IT确认事项」18 项 + v1.4(2026-10-19 勘误)、《问题与解决方案对照表_v2》(28 项已裁决口径)。 +> **原则**:已答复项不重复询问;未答复项按清单内「建议默认」执行中,答复后以答复为准并回填文档。 + +## 一、业务方(建议回复期限:____) + +### A. 仍未答复 —— 阻塞对应模块开工(4 项) + +| # | 问题(原文口径) | 当前执行默认 | 阻塞 | +| --- | --- | --- | --- | +| 1 | 二次分配的分配标的是什么——绩效工资金额、折算毛利润额,还是销售额(业绩额)? | 未答复前只做方案录入,**不实现分摊计算**(已按此交付) | ④ 分摊计算 | +| 2 | 分配比例合计不足 100% 时,剩余部分归属谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 | +| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ 分摊 | +| 20 | 含税口径:销售/成本/毛利润/运费各项是否含税?价税合计与折扣折让如何处理? | 按不含税口径实现中 | ④ 核算口径终验、⑥ 同步建列(是否建税率/税额列) | + +> 说明:#1–#3 未答复期间,二次分配方案可正常录入与审批,但**分摊金额不会出现在绩效中**;#20 影响历史算出的绩效是否需要按含税口径重算,越早答复返工面越小。 + +### B. 已有答复但需补具体值 / 勾选(4 项) + +| # | 事项 | 需补充 | +| --- | --- | --- | +| 12 | 经理个人户业绩是否计入其 12.5% 团队基数 | 答复为「做参数可调」,**请给出初始值**:计入 / 不计入(系统已实现为开关参数,当前默认不计入) | +| 14 | 「第 1 天 10‰」实现口径 | 请勾选:**A 仅首日覆盖**(推荐,保「360 天 = 6‰」锚点)或 B 整体平移(破坏锚点)。系统当前按 A 实现(首日 10‰、其余按公式 10 − 天数 × 4/360) | +| 4 | 二次分配方案修改窗口与权限 | 答复「暂定允许修改,走调整单」——请定:首次月结前可改(留痕)?月结后只读、仅走调整单?谁有权改(管理员 / 营销后勤)? | +| 6 | 营销后勤/财务/领导三类角色的账号归属、可见菜单、可操作点 | 答复「先预设角色,需与客户确认」——请给权限清单(三类角色各能看什么、能操作什么)。系统已按最小授权运行:后勤=提交录入,财务=复核+导出,领导=审核只读,营销后勤暂不可见审批列表 | + +### C. 请确认两处勘误(已按勘误实施,需业务回执存档)(2 项) + +| 项 | 原表述 | 现行勘误(v1.4) | +| --- | --- | --- | +| 人员识别 | 「负责营销人员以姓名为唯一识别」 | 改为「以人员编码(工号)为唯一识别、姓名辅助核对」——原表述与「编码为业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误,请确认 | +| 回款↔发货批次 | 「由 T+ 收款分配关系指定,人工辅助」 | 改为「**系统人工指定**(对照表 #18 定稿),T+ 收款分配关系仅作辅助」——主次相反疑漏改,请确认 | + +### 已按默认执行、无需回复的项(供核阅) + +#7 月结审批链默认开启、#8 人工确认回款可手动关闭、#9 调整单同链且跨月需授权、#10 风险预警参数化、#11 待办范围(待审批+同步失败+回款未指定批次)、#13 支付环节 5 默认值、#15 一期仅 FULL 模式、#17 参数查看放开/修改限管理员、#18 企业户 2‰ 回溯、#21 退货当期冲减已结算走调整单、#22 预收/保证金/代收款暂不计入、#23 按到账金额、#24 负绩效照算不归零。 + +## 二、IT / T+ 侧(建议回复期限:____) + +> 2026-10-15 回稿收到后,v1.3「待IT确认事项」18 项**未逐项答复**(回稿仅做了必要性升级与第七节补充),机制性内容(同步频率/幂等/格式要求)未包含,「同步不覆盖字段」表被引用但未附。现有 v1.4 清单中「系统侧目标表.字段」列**全表留空待回填**。 + +### A. 高优先(9 项,阻塞核算口径与同步程序开工) + +| # | 事项 | 为什么高 | +| --- | --- | --- | +| 1 | **成本额取数口径**:成本类型/毛利计算方式/更新时间/精度 | 毛利润 = 销售额 − 成本额,是绩效金额全部源头;缺此则同步无法自动跑,只能人工录入 | +| 2 | 发货日期口径:出库日 / 签收日 / 开票日 | 回款天数基准日(发货日首日计第 1 天),直接决定计提比例 | +| 3 | 到账日期口径:收款日 / 银行到账日 / 核销日 | 计提参数版本匹配日,直接决定适用哪一版比例 | +| 4 | 幂等唯一键规则:作废重开是否换号 | 重复同步不重复入库的前提 | +| 5 | 三单关联键:订单 / 发货 / 回款如何互相关联 | 一笔业务可分批发货;回款↔批次系统内人工指定,但订单↔发货关联仍必须由 T+ 提供 | +| 6 | **人员唯一编码(工号)** + 业务员↔营销经理隶属关系 | 业绩归属唯一锚点(重名即错钱);经理 12.5% 团队聚合依隶属关系展开 | +| 7 | 对接方式与安全:REST / 数据库视图 / 中间表 / 文件交换 | 未定则同步程序无法开工(一期人工录入可闭环) | +| 8 | 负数 / 红字 / 退货 / 冲销处理规则与原单关联 | 决定第七节补充字段如何建列;如不提供则只能靠人工补录 | +| 16 | 回款确认 / 核销状态字段与枚举 | 月度结算按「确认回款」生成,需 T+ 核销/确认类字段作人工确认依据 | + +### B. 回稿三缺口(请补) + +1. v1.3 第七节曾漏「人员唯一编码(工号)」——v1.4 数据清单第一节已补★项并注明「待IT确认 #19:T+ 档案编码是否即工号,若为自动内部编码请指明工号字段」; +2. 「客户档案是否同步」(待IT确认 #10)未点名答复——v1.4 第六节已列客户档案 5 项,请确认可提供; +3. 「系统侧目标表.字段(T+ 确认映射)」列全表留空——请回填,这是同步程序的字段契约。 + +### C. 中低优先(9 项,模块实现期内答复即可) + +存量数据起始日期与增量口径(影响能出多早的报表)、客户匹配与唯一键、收款方式完整枚举清单、产品「数量」语义(库存量/在途/累计销量)、发货单价口径(订单价/发货时价)、特殊业务标记字段、同步窗口时间、市场拓展计划数据、待IT确认 #18(口径裁决 5 项已转业务方,见第一部分)。 + +## 三、报表口径补充确认(2026-10-19 新增,随本轮开发一并排) + +> 报表统计模块(需规 2.4 / FR-029~FR-032)开工在即,以下三项请确认;未答复按「建议默认」实现,后续可调。 + +| # | 事项 | 建议默认(将按此实现) | +| --- | --- | --- | +| R1 | 部门绩效汇总表的「部门」口径 | 部门 = 经理团队(经理 + 其直属业务员),按人员档案的隶属关系展开;离职人员历史业绩留在原团队;经理的 5% 全公司部分单独一列 | +| R2 | 回款统计的「回款天数区间」档位 | ≤0 天(先款后货)|1-30|31-60|61-90|91-180|181-360|361 以上|无回款天数,共 8 档;未命中区间显示 0 | +| R3 | 导出/打印模板要素 | 公司信息页眉(名称+logo)、报表标题、筛选条件、导出时间与导出人、模板版本号、财务复核栏、领导签字栏、日期栏 | + +另:报表查看权限(FR-029 参与者含「营销后勤(授权范围内)」)——请随第一部分 B-6 权限清单一并明确营销后勤是否需要查看报表、可见哪些列。 + +--- + +**发送建议**:第一部分发业务方(含 C 两项勘误确认);第二部分发 IT/T+(v1.4 附表一并);第三部分随开发进度与业务方顺带确认。 diff --git a/docs/模块页面功能清单_2026-10-13.xlsx b/docs/模块页面功能清单_2026-10-13.xlsx new file mode 100644 index 0000000..0c76747 Binary files /dev/null and b/docs/模块页面功能清单_2026-10-13.xlsx differ diff --git a/docs/问题与解决方案对照表_v2.xlsx b/docs/问题与解决方案对照表_v2.xlsx new file mode 100644 index 0000000..3283b8a Binary files /dev/null and b/docs/问题与解决方案对照表_v2.xlsx differ diff --git a/docs/需求规格说明书.docx b/docs/需求规格说明书.docx new file mode 100644 index 0000000..175574b Binary files /dev/null and b/docs/需求规格说明书.docx differ diff --git a/docs/需求规格说明书_V2.1_2026-10-19.docx b/docs/需求规格说明书_V2.1_2026-10-19.docx new file mode 100644 index 0000000..fd2fd2d Binary files /dev/null and b/docs/需求规格说明书_V2.1_2026-10-19.docx differ diff --git a/docs/项目阶段报告_2026-10-13.md b/docs/项目阶段报告_2026-10-13.md new file mode 100644 index 0000000..16f459b --- /dev/null +++ b/docs/项目阶段报告_2026-10-13.md @@ -0,0 +1,154 @@ +# 衍水集团营销人员工资管理系统项目阶段报告 + +- **报告日期**: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) diff --git a/frontend/README.md b/frontend/README.md index c86e097..2ef7f88 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,11 +1,13 @@ # 前端 -> AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)、`src/views/login/index.vue`(登录页)。当前完成登录链路,业务模块页面待接入。 +> AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/components/layout/BasicLayout.vue`(应用布局与导航)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)。已实现登录链路(2026-10 视觉方向重做:点阵底/涟漪/天光/居中卡片)、系统管理页面、岗位角色与计提参数配置页、Bento 仪表盘首页;视觉体系与设计预览见 `docs/design/视觉方向预览.html`。 ## 职责 - 登录页与图形验证码交互 - 会话状态管理与路由守卫 +- 岗位角色管理页(模板绑定)、计提参数配置页(分组目录/批次提交/版本对比/导出/计算预览) +- 首页 Bento 仪表盘(M1 空态设计;业务上线后接入真实统计,图表用 ECharts) - 后续:基础数据、业务数据、绩效核算、报表等业务页面 ## 技术栈与版本约束 @@ -26,18 +28,26 @@ src/ ├── api/ │ ├── request.ts # axios 实例、拦截器、统一信封解包 -│ └── auth.ts # 认证接口封装与类型 -├── assets/styles/ # 全局样式 -├── components/ # 通用组件(待补充) -├── composables/ # 组合式函数(待补充) -├── router/index.ts # 路由表与全局守卫 +│ ├── auth.ts # 认证接口封装与类型 +│ ├── users.ts / roles.ts / logs.ts # 系统管理接口封装 +│ └── types.ts # 分页等公共类型 +├── assets/styles/ # 全局样式与品牌 token(:root 的 --ys-*) +├── components/ +│ ├── layout/ # BasicLayout(侧边栏/顶栏/面包屑)、AppMenu +│ └── table/ # StatusTag(0/1 状态标签)、AppPagination +├── composables/ +│ ├── useAppMenu.ts # 菜单从路由表派生(meta.title/group/order/requiresAdmin) +│ ├── useConfirmAction.ts # 确认框→执行→提示→刷新 动作流(启停/删除共用) +│ └── usePagedList.ts # 分页列表状态(请求序号防竞态、删除后页码回退) +├── router/index.ts # 路由表与全局守卫(四层,含 requiresAdmin) ├── store/ │ ├── index.ts # Pinia 实例 │ └── user.ts # 会话状态:token、用户信息、登录/登出 -├── utils/storage.ts # localStorage 读写 +├── utils/ # storage/format/validate/constants/error(handledError) └── views/ ├── login/ # 登录页 - └── dashboard/ # 首页(登录链路验证页) + ├── dashboard/ # 首页(登录链路验证页) + └── system/ # 系统管理:users(用户)/ roles(角色)/ logs(日志查询) ``` ## 启动 @@ -69,49 +79,61 @@ pnpm build # 类型检查 + 生产构建 ## 视觉设计 -**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:深水面板 + 流动的层理线,即公司名的意象,也是业务的隐喻。 +**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:中性灰白的水面纸感 + 靛蓝点缀,安静、克制,长时间核数不累眼。完整规范见 `docs/design/视觉方向预览.html`(2026-10-12 起为 v3「质感版」)。 -**配色**(金色分两档是有意的:亮金只用于深色底、深金只用于浅色底,两处对比度都达标) +**配色**(靛蓝只作重点点缀——主按钮 / 链接 / 焦点 / 当前菜单,大面积用中性灰白) | 名 | 值 | 用途 | | --- | --- | --- | -| 深渊 | `#061A22` | 品牌面板底色 | -| 沉碧 | `#0F3646` | 面板渐变、水纹线 | -| 青金 | `#C9A227` | **深色底上**的细线与边框 | -| 赭金 | `#8F6520` | **浅色底上**的主按钮(配白字约 6.2:1) | -| 素纸 | `#FAFAF7` | 表单侧底色 | -| 墨 | `#16262D` | 正文 | +| 靛蓝 | `#2e4ba8` | 主按钮、链接、焦点、当前菜单(配白字约 7.5:1) | +| 深靛 | `#223a85` | 主色深阶(按压态) | +| 淡靛 | `#edf1fa` | 选中底色、浅色强调底 | +| 纸白 | `#ffffff` | 卡片与面板底色 | +| 雾灰 | `#f4f5f8` | 页面底色 | +| 墨 | `#1c2333` | 正文 | +| 墨灰 | `#5b6577` / `#9aa3b2` | 次级文本 / 辅助文本 | -**字体**:品牌名用**宋体**(衬线,拟正式、拟账册),正文用系统无衬线,数字与验证码用等宽。 +**字体**:品牌名用**思源黑体**(`Noto Sans SC` / `Source Han Sans SC`,开源可商用),正文用同一无衬线栈,数字与验证码用等宽。**禁止引入微软雅黑、宋体等需商业授权的字体**——CSS 字体栈、设计稿、导出物一律只用开源或免费商用字体(如阿里巴巴普惠体、HarmonyOS Sans)。 **界面不引入网络字体**——内网环境字体加载不可靠,靠字重、字距与留白做层次更稳妥。新页面请沿用这套字号节奏。 -**签名元素**:品牌面板的水纹层理线,越往下越密(如潜向深处),载入时用 `stroke-dashoffset` 逐条画出——**一个统一的入场动作,而不是散落的特效**。 +**质感来源**(而非装饰):表面 1–2% 明度差的微渐变、半透明描边、近距 + 远距分层阴影、内高光 / 内阴影、极微噪点(内联 SVG)、精确排版(字距 / 等宽数字 / 行高节律)。 -**构图**:品牌内容(名称 / 金线 / 系统名 / 主标语 / 环节行)**垂直居中聚成一块**,配一层柔和径向光晕作落点。 +**落地方式**:品牌色已提升为全局 CSS 变量(`src/assets/styles/index.css` 的 `:root`,`--ys-*` 前缀),登录页、布局与各业务页面共用;全局把 `--el-color-primary` 指向靛蓝,并按 Element Plus 公式预计算了 light-3/5/7/8/9 与 dark-2 色阶(不用 `color-mix()`,避免依赖较新浏览器特性)。旧 token 名(深水青 / 金色系列)在 `:root` 中保留映射,存量页面迁移完成后移除。 + +**签名元素**:**渐隐靛蓝水线**——仪表盘等页面标题下的细线(56px,左实右虚)与登录页品牌名下的短线(40px),「衍水」之水的提纯,全站唯一的装饰性元素;登录页右上角另有涟漪同心圆与顶部靛蓝天光作背景(低透明度)。与早期深色面板 + 水纹层理同一意象,品牌连续而非推倒重来。 + +**构图**:登录页品牌内容(名称 / 标语 / 水线)**垂直居中聚成一块**,配一层柔和径向光晕作落点。 早期版本用 `space-between` 把品牌与标语顶到两端,中间空出大半屏,两处都成了孤岛——超宽屏会把这种疏离放大得更明显。**内容块要限宽**(`max-width`),否则宽屏下文字会被拉散。 -### 写代码时的三条约定 +### 写代码时的四条约定 - 覆盖 Element Plus 主题请改 **CSS 变量**(`--el-button-bg-color` 等),不要直接写 `background-color` —— 后者与 `.el-button--primary` 同权重,会受样式注入顺序影响而不稳定 - 中文可在任意字间断行,**关键文案的断行位置要显式控制**(如主标语拆成两个 ``),交给浏览器自动换行会出现单字独占一行的难看结果 - 新页面需响应式到移动端、保留键盘焦点可见、并尊重 `prefers-reduced-motion` +- **确认框统一加 `customClass: 'ys-confirm-box'`,不要再传 `type: 'warning'`** —— Element Plus 的默认 warning 图标会渲染在正文左侧独立成列(多行正文时图标悬在文字块中间),与 v3 靛蓝体系也冲突。全局样式(`src/assets/styles/index.css`)已把图标改为 18px 描边样式并放在标题行左侧,正文与标题文字对齐 ## 验证码图片 -后端自绘渲染(`backend/app/core/captcha_image.py`),**不再使用 `captcha` 库的默认输出**——那是随机彩色字符 + 重噪点,与界面视觉体系冲突。自绘后字符为墨蓝、底为素纸、干扰线与噪点取自同一套配色。 +后端自绘渲染(`backend/app/core/captcha_image.py`),**不再使用 `captcha` 库的默认输出**——那是随机彩色字符 + 重噪点,与界面视觉体系冲突。自绘后字符为深青色、底为暖白、干扰线与噪点取自同一套配色。 识别难度由**逐字随机旋转(±14°)+ 垂直抖动(±2px)+ 正弦干扰曲线 + 双层噪点**共同保证,与原实现相当;笔画加了 1px 描边——本系统用户是营销人员,屏幕与年龄跨度大,验证码过难会直接变成支持负担。 ## 端到端测试 -`e2e/login_flow.py` 用 Playwright 驱动真实浏览器,覆盖 8 项:路由守卫、验证码渲染、失败后自动刷新、登录跳转、用户信息展示、刷新恢复会话、登出、登出后令牌失效。 +`e2e/` 下两个脚本用 Playwright 驱动真实浏览器,公共工具抽在 `e2e/common.py`(登录动作、从 Redis 取验证码答案、提示等待、失败断言与控制台错误收集): + +- `login_flow.py`(8 项):路由守卫、验证码渲染、失败后自动刷新、登录跳转、用户信息展示、刷新恢复会话、登出、登出后令牌失效 +- `system_management.py`(13 项):菜单导航、用户增删改查(含重名负例)、重置密码、启用/禁用、角色保护与 CRUD、操作日志留痕与详情懒加载脱敏、登录日志、普通用户菜单隐藏与路由守卫 ```bash # 前置:MySQL/Redis 容器、后端、前端 dev server 均在运行 python e2e/login_flow.py +# 管理员密码经环境变量传入(不写死在源码里) +E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py ``` 需要 Python `playwright` 包与 chromium(`python -m playwright install chromium`)。 +脚本自建的测试数据(`e2e_` 前缀)在正常流程内清理;中途失败会残留在开发库。 ## 常见问题 @@ -120,3 +142,71 @@ python e2e/login_flow.py **验证码图片不显示** —— 多为代理未生效。访问 `http://localhost:5173/api/health` 应返回统一信封格式 JSON。 **`pnpm install` 报 peer 依赖错误** —— 确认 `@vue/devtools-api` 在 `dependencies` 中(pinia 4 的必需 peer)。 + +## 端到端测试(2026-10-13 追加 · M1 参数体系) + +| 脚本 | 覆盖 | 结果 | +| --- | --- | --- | +| `e2e/login_flow.py` | 登录链路 8 项 | ✓ 全通过(登录页视觉重做后回归) | +| `e2e/system_management.py` | 系统管理 13 项 | ✓ 全通过(布局白化后回归) | +| `e2e/param_management.py` | 计提参数 7 步:目录分组 → 修改入待提交 → 批次提交(立即生效)→ 目录反映新值 → 版本记录 → 恢复种子值 → 计算预览金标准(27 天 / 9.7‰ / 145.5 元) | ✓ 全通过(首跑) | + +运行前置:容器 + 后端(8000)+ 前端 dev(5173);`E2E_ADMIN_PASSWORD` 传管理员密码。 + +## 前端页面(2026-10-17 追加 · M3-core ④ 核算与月结) + +| 页面 | 说明 | +| --- | --- | +| `/calc/ledger` 绩效台账(新组「绩效工资」order17) | 月/版本筛选、行展开计算快照、重算(原因对话框,管理员/财务) | +| `/settlements` 月度结算(order18) | 生成快照(月份输入)→ 状态筛选 → 提交审批(开关关=自动通过锁定)→ 详情抽屉(按人三口径明细/试用期说明/审批单号) | + +路由组「绩效工资」;api 封装 `src/api/{calc,settlements}.ts`。type-check 与 build 通过(2026-10-17)。 + +## 端到端测试(2026-10-13 追加 · M2 基础数据与业务数据) + +| 脚本 | 覆盖 | 结果 | +| --- | --- | --- | +| `e2e/base_data.py` | 基础数据 13 步:菜单两组 → 人员建(经理/业务员指派)→ 编辑抽屉加辐射区域回显 → 删除保护(400 引导改离职)→ 产品建 + 重名 400 → 客户建 → 归属变更(确认文案含「不回溯历史」)→ 留痕抽屉可见 → 控制台无 JS 错误 | ✓ 全通过(首跑) | +| `e2e/business_data.py` | 业务全链路 16 步:基础建数 → 业务建(双类别明细)→ 编辑汇总重算(65000→115000)→ 详情抽屉(时间线/毛利润卡片)→ 归属调整 → 发货(SH 编号)→ 回款级联指定批次 → 回款天数「6 天」→ 未指定批次文案 → 状态流转已完成 → 终态禁发货 → 评级计算器出 A 级 → 清理留档 | ✓ 全通过(修复单价小数位/删除保护后) | + +运行前置同上;测试数据 `e2e_` 前缀,正常流程内清理(业务含发货/回款时按设计不可删,留档说明)。 +**期间修复的缺陷**:后端删除保护两处漏检(客户被业务引用/人员被业务归属引用 → 500)、 +单价列 `DECIMAL(14,4)` 被两位小数校验拒收(前后端同源)、`wait_toast` 超时丢失诊断信息。 + +## 端到端测试(2026-10-16 追加 · M3 增量批次) + +| 脚本 | 覆盖 | 结果 | +| --- | --- | --- | +| `e2e/settings_flow.py` | 公司信息 4 步:进设置页改公司名(随机名防撞)→ 保存 → 退出登录 → **登录页品牌位与页脚即时显示新名**(public 端点)→ finally 还原默认名(防污染其它 e2e 的品牌断言) | ✓ 全通过(修复 public 缓存缺陷后) | +| `e2e/secondary_allocation_flow.py` | 二次分配 6 步:路由直达业务管理 → 建客户 → 建 2 名在职营销员 → 新增业务录两行参与人(30%+40%)→ **编辑回显 2 行 + 合计条「余额 30.00%(归属待业务确认)」** → 删除清理 | ✓ 全通过(修复 logo 无关的三处 e2e 选择器后) | + +运行前置同上(需 `E2E_ADMIN_PASSWORD`)。**期间修复的缺陷**:public 端点 300s 响应缓存让登录页拿旧公司名(改 `fetch cache: no-store` + 时间戳绕过磁盘缓存);业务弹窗分配表与产品明细共用 `items-table` 类导致选择器错位(分配表加专属 `alloc-table` 类)。 +**回归**:既有五脚本(登录 8 / 系统管理 13 / 基础数据 12 / 业务 16 / 参数 7 步)在 allowedRoles 路由扩展与品牌名动态化后全部复跑通过。 + +## 前端页面(2026-10-19 追加 · M4 ⑤ 报表统计) + +| 页面 | 说明 | +| --- | --- | +| `/reports/personal-detail` 个人绩效明细(新组「报表统计」order19) | 人员/月份/客户类型/产品类别/岗位筛选;需规 2.4.1 全部列;行展开懒加载台账快照;页脚**全筛选合计**(注明非当前页);导出/打印 | +| `/reports/department-summary` 部门绩效汇总(order20) | 月份必填;全公司 5% 独立卡片;团队表(基数/12.5%/5%/占比/调整/净合计/月结冻结额);展开成员 → 跨页下钻个人明细;未分配桶 | +| `/reports/business-stats` 业务统计(order21) | 关键字/客户/归属人/状态/业务日期;发货额/回款额/绩效(累计口径提示);展开下钻回款与台账明细 | +| `/reports/payment-stats` 回款统计(order22) | 月份/回款日期/客户类型/产品类别;8 档区间表(空桶 0,档位由后端下发);展开桶下钻分页明细;客户类型/产品类别对比表 | +| `/reports/print/:code` 报表打印(顶层路由,`menu:false`) | 不挂 BasicLayout(打印不带侧栏/顶栏);页眉公司信息走 public 端点;循环翻页取全量(上限 2000 行截断提示);`@media print` A4 横版 + 每页表头;签字栏;PDF 由浏览器「另存为」覆盖 | + +- 路由组「报表统计」四页 `allowedRoles: [admin, finance, leader]`(前端菜单 + 后端 `require_roles` 双层;admin 须显式列入)。 +- api 封装 `src/api/reports.ts`(类型 + 查询 + `exportReport` 裸 axios blob;产品类别标签额外覆盖 `mixed`)。 +- 权限:营销后勤「授权范围内」待业务权限清单 #6,当前不开放报表。 +- type-check 与 build 通过(2026-10-19)。e2e `report_flow.py` 待补(需 `E2E_ADMIN_PASSWORD` 环境)。 + +## 端到端测试(2026-10-19 追加 · M3 台账与月结) + +| 脚本 | 覆盖 | 结果 | +| --- | --- | --- | +| `e2e/calc_settlement_flow.py` | 绩效核算与月度结算 8 步:登录进台账页 → API 预置(客户/人员/业务/发货/回款/核算,随机远未来月份防碰撞)→ 按归属月筛选见 v1 行 → 重算对话框(原因必填)→ v2 生效 → 版本三态筛选(仅历史 / 仅当前 / 清空=全部)→ 生成月结草稿 → 提交审批(开关显式关闭=自动通过锁定)→ 详情抽屉(按人明细/净工资/锁定状态) | ✓ 全通过(2026-10-19 首跑,修复下述缺陷后) | + +运行前置同上(需 `E2E_ADMIN_PASSWORD`);数据 `e2e_` 前缀 + 随机远未来月份(209x),核算/月结按设计不可删,行数据留档开发库。 +**首跑即抓出三处真实前端缺陷**(M3 台账/月结两页自 2026-10-17 交付起一直「暂无数据」): +① `src/api/{calc,settlements,approvals}.ts` 误用默认导出 `request`(axios 实例,返回 AxiosResponse 未解包),列表 `list` 恒为 undefined → 三页永远「暂无数据」;改为具名导入 `{ request }`(信封解包版)。 +② 三页均无 `onMounted` 初始加载 → 进页不取数;补 `onMounted(() => void load())`。 +③ 三页 `AppPagination` 用 `v-model:page-num` + `@change`(组件无该事件、也未实现 `update:*`)→ 翻页失效;统一为 `:page-num/:page-size` + `@page-change/@size-change`(usePagedList 的 onPageChange/onSizeChange)。 +**注意**:e2e 与 `pytest tests/integration` **不可并发**——conftest 每用例整体清空开发库,会清掉 e2e 造的数据造成假失败(本次排查耗时即源于此,两次假失败的报错均为「台账行消失 / 401 登录失效」)。 diff --git a/frontend/e2e/base_data.py b/frontend/e2e/base_data.py new file mode 100644 index 0000000..2d31f59 --- /dev/null +++ b/frontend/e2e/base_data.py @@ -0,0 +1,519 @@ +"""基础数据端到端验证:人员 / 产品 / 客户。 + +覆盖的链路(只有真实浏览器才能验证的组合): + +- 侧边菜单「基础数据」分组展开与三页导航 +- 人员:新增(营销经理 / 挂经理的营销员)、直属经理名回显、辐射区域 + 编辑抽屉行内添加保存并重开回显 +- 人员删除保护:被团队成员引用 → 400 引导「改为离职」,改为离职成功 +- 产品:新增(轨道交通类别)、同名称同规格重复 → 400 +- 客户:新增(企业户 + 初始归属业务员)、归属调整二次确认文案含 + 「不回溯历史」(对照表 #19 业务口径)、变更留痕展示旧 → 新归属 +- 控制台无 JS 错误(4xx 请求的网络资源日志属预期,见 step_console_assertion) + +**运行前置**(缺一不可):: + + docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis + cd backend && uv run uvicorn app.main:app --reload --port 8000 # 后端 + cd frontend && pnpm dev # 前端 + +管理员密码经环境变量传入(不写死在源码里):: + + cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/base_data.py + +脚本自建的测试数据(`e2e_` 前缀)在流程内清理;被引用保护拦截的清理项 +打印留档说明而非失败(删除保护本身是预期行为)。公共工具见 `e2e/common.py`。 +""" + +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(__file__)) + +from playwright.sync_api import Page, sync_playwright + +from common import ( + E2EFailure, + click_query, + collect_console_errors, + confirm_message_box, + current_path, + expect_toast, + fail, + fill_filter, + login, + open_menu, + select_filter_option, + table_rows, + toast_text, + visible_dialog, + wait_toast, +) + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +ADMIN = "admin" + +SUFFIX = str(int(time.time())) +MANAGER_NAME = f"e2e_经理_{SUFFIX}" +SALESMAN_NAME = f"e2e_业务员_{SUFFIX}" +MANAGER_PHONE = "13800000001" +MANAGER_SALARY = "5000" +SALESMAN_SALARY = "4000" +PRODUCT_NAME = f"e2e_产品_{SUFFIX}" +PRODUCT_SPEC = f"e2e_规格_{SUFFIX}" +CUSTOMER_COMPANY = f"e2e_企业_{SUFFIX}" +CUSTOMER_OWNER = "e2e_客户负责人" +REGION_NAME = "沈阳市铁西区" + +# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取 +console_errors: list[str] = [] + + +# ==================== 页面操作辅助 ==================== + + +def confirm_text(page: Page) -> str: + """当前确认框的完整文本(业务口径文案断言用),轮询等待出现。""" + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if page.locator(".el-message-box:visible").count(): + break + page.wait_for_timeout(200) + return page.locator(".el-message-box:visible").last.inner_text() + + +def fill_form_item(page: Page, scope, label: str, value: str) -> None: + """在表单(dialog / drawer)范围内按 form-item 标签填写输入框。 + + 标签用 `:text-is` 精确匹配(子串会让「姓名」命中「真实姓名」一类歧义); + 只用于真实 el-input 的表单项(el-select 的表单项走 select_form_option)。 + """ + item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))') + item.locator("input").first.fill(value) + + +def fill_date(page: Page, scope, label: str, value: str) -> None: + """el-date-picker(type=date)按 form-item 标签填日期:点输入框 → 键入 → 回车。""" + item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))') + item.locator("input").first.click() + page.keyboard.type(value) + page.keyboard.press("Enter") + page.wait_for_timeout(300) + + +def select_form_option(page: Page, scope, label: str, option: str) -> None: + """在表单范围内展开指定标签的下拉并选择选项。 + + 下拉面板 teleport 到 body,展开与选项均按 `:visible` 过滤;选项渲染 + (filterable / 页面挂载时拉取的数据源)可能滞后,轮询等待再点击。 + """ + item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))') + item.locator(".el-select").first.click() + option_item = page.locator( + ".el-select-dropdown:visible .el-select-dropdown__item", has_text=option + ) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if option_item.count(): + break + page.wait_for_timeout(200) + if option_item.count() == 0: + fail("下拉选择", f"下拉「{label}」中未出现选项「{option}」") + option_item.first.click() + page.wait_for_timeout(200) + + +def refresh_page_data(page: Page) -> None: + """刷新当前页面(保持路径):重新挂载页面并重拉挂载时的一次性数据源。""" + page.reload(wait_until="networkidle") + page.wait_for_timeout(800) + + +def find_row(page: Page, filter_label: str, keyword: str): + """按筛选条件查询并返回含 keyword 的第一行(找不到时 count() 为 0)。""" + fill_filter(page, filter_label, keyword) + click_query(page) + return table_rows(page).filter(has_text=keyword).first + + +def delete_row(page: Page, row, success_toast: str) -> None: + """删除行:点「删除」→ 确认「删除」→ 断言成功提示(各模块成功文案不同)。""" + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "删除") + expect_toast(page, success_toast, "数据清理") + page.wait_for_timeout(600) + + +# ==================== 基础数据创建(本脚本与 business_data 复用) ==================== + + +def create_manager_via_ui(page: Page, name: str) -> None: + """导航到人员管理并经界面新增营销经理 → 列表岗位回显。""" + open_menu(page, "人员管理", "基础数据") + if current_path(page) != "/base/personnel": + fail("菜单导航", f"未进入人员管理页,当前 {page.url}") + + page.click("button:has-text('新增人员')") + page.wait_for_timeout(500) + dialog = visible_dialog(page) + fill_form_item(page, dialog, "姓名", name) + select_form_option(page, dialog, "岗位", "营销经理") + fill_form_item(page, dialog, "联系电话", MANAGER_PHONE) + fill_form_item(page, dialog, "工资标准", MANAGER_SALARY) + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "人员已创建", "新增人员") + + row = find_row(page, "姓名", name) + if row.count() == 0: + fail("新增人员", f"列表中未找到新建的 {name}") + if "营销经理" not in row.inner_text(): + fail("新增人员", "列表岗位列未回显「营销经理」") + page.wait_for_timeout(400) + + +def create_salesman_via_ui(page: Page, name: str, manager_name: str) -> None: + """在人员管理页新增营销员并挂直属经理(先刷新页面重拉经理下拉)。 + + 经理下拉数据源只在页面挂载时拉取一次 —— 同一页面连续「新建经理再 + 新建业务员」时下拉不含新经理,必须先刷新页面。 + """ + open_menu(page, "人员管理", "基础数据") + refresh_page_data(page) + if current_path(page) != "/base/personnel": + fail("菜单导航", f"刷新后未回到人员管理页,当前 {page.url}") + + page.click("button:has-text('新增人员')") + page.wait_for_timeout(500) + dialog = visible_dialog(page) + fill_form_item(page, dialog, "姓名", name) + select_form_option(page, dialog, "岗位", "营销员") + select_form_option(page, dialog, "直属经理", manager_name) + fill_form_item(page, dialog, "工资标准", SALESMAN_SALARY) + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "人员已创建", "新增业务员") + + row = find_row(page, "姓名", name) + if row.count() == 0: + fail("新增业务员", f"列表中未找到新建的 {name}") + if manager_name not in row.inner_text(): + fail("新增业务员", "列表「直属经理」列未回显经理姓名") + page.wait_for_timeout(400) + + +def create_product_via_ui(page: Page, product_name: str, spec: str) -> None: + """导航到产品管理并经界面新增产品(类别=轨道交通)→ 列表回显。""" + open_menu(page, "产品管理", "基础数据") + if current_path(page) != "/base/products": + fail("菜单导航", f"未进入产品管理页,当前 {page.url}") + + page.click("button:has-text('新增产品')") + page.wait_for_timeout(500) + dialog = visible_dialog(page) + fill_form_item(page, dialog, "产品名称", product_name) + fill_form_item(page, dialog, "规格型号", spec) + fill_form_item(page, dialog, "数量", "100") + fill_form_item(page, dialog, "单价(元)", "3000") + select_form_option(page, dialog, "产品类别", "轨道交通") + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "产品已创建", "新增产品") + + row = find_row(page, "关键词", product_name) + if row.count() == 0: + fail("新增产品", f"列表中未找到新建的 {product_name}") + if "轨道交通" not in row.inner_text(): + fail("新增产品", "列表类别列未回显「轨道交通」") + page.wait_for_timeout(400) + + +def create_customer_via_ui(page: Page, company: str, owner: str, initial_owner: str) -> None: + """导航到客户管理并经界面新增客户(企业户 + 初始归属)→ 列表回显。""" + open_menu(page, "客户管理", "基础数据") + if current_path(page) != "/base/customers": + fail("菜单导航", f"未进入客户管理页,当前 {page.url}") + + page.click("button:has-text('新增客户')") + page.wait_for_timeout(500) + dialog = visible_dialog(page) + fill_form_item(page, dialog, "公司名称", company) + fill_form_item(page, dialog, "负责人", owner) + fill_form_item(page, dialog, "行业", "轨道交通") + select_form_option(page, dialog, "客户分类", "企业户") + select_form_option(page, dialog, "初始归属", initial_owner) + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "客户已创建", "新增客户") + + row = find_row(page, "关键词", company) + if row.count() == 0: + fail("新增客户", f"列表中未找到新建的 {company}") + if initial_owner not in row.inner_text(): + fail("新增客户", "列表「归属人员」列未回显初始归属") + if "企业户" not in row.inner_text(): + fail("新增客户", "列表分类列未回显「企业户」") + page.wait_for_timeout(400) + + +# ==================== 流程步骤 ==================== + + +def step_open_group_and_personnel(page: Page, admin_password: str) -> None: + """登录、「基础数据」分组出现且含三页,点开「人员管理」。""" + login(page, ADMIN, admin_password) + menu = page.locator(".app-menu") + if menu.locator(".el-sub-menu__title", has_text="基础数据").count() == 0: + fail("菜单分组", "侧边菜单未出现「基础数据」分组") + for title in ("人员管理", "产品管理", "客户管理"): + if menu.locator(".el-menu-item", has_text=title).count() == 0: + fail("菜单分组", f"「基础数据」分组内未找到「{title}」") + + open_menu(page, "人员管理", "基础数据") + if current_path(page) != "/base/personnel": + fail("菜单导航", f"未进入人员管理页,当前 {page.url}") + print("✓ 1. 菜单导航 「基础数据」分组出现、三页可点开,已进入人员管理") + + +def step_person_create_manager(page: Page) -> None: + create_manager_via_ui(page, MANAGER_NAME) + print(f"✓ 2. 新增经理 创建 {MANAGER_NAME}(岗位=营销经理),列表可见") + + +def step_person_create_salesman(page: Page) -> None: + create_salesman_via_ui(page, SALESMAN_NAME, MANAGER_NAME) + print(f"✓ 3. 新增业务员 创建 {SALESMAN_NAME}(岗位=营销员),经理名回显") + + +def step_person_edit_regions(page: Page) -> None: + """编辑抽屉给经理添加一条辐射区域行 → 保存 → 重开回显。""" + row = find_row(page, "姓名", MANAGER_NAME) + row.locator("button:has-text('区域与计划')").click() + page.wait_for_timeout(600) + drawer = page.locator(".el-drawer:visible").last + drawer.locator("button:has-text('添加区域')").click() + page.wait_for_timeout(300) + drawer.locator('input[placeholder="如 沈阳市铁西区"]').last.fill(REGION_NAME) + drawer.locator("button:has-text('保存区域')").click() + expect_toast(page, "辐射区域已保存", "编辑辐射区域") + + # 重开抽屉验证回显(保存走全量替换接口,详情须持久化) + page.keyboard.press("Escape") + page.wait_for_timeout(600) + row = find_row(page, "姓名", MANAGER_NAME) + row.locator("button:has-text('区域与计划')").click() + page.wait_for_timeout(600) + drawer = page.locator(".el-drawer:visible").last + region_input = drawer.locator('input[placeholder="如 沈阳市铁西区"]').first + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if region_input.count() and region_input.input_value() == REGION_NAME: + break + page.wait_for_timeout(200) + if region_input.count() == 0 or region_input.input_value() != REGION_NAME: + fail("辐射区域回显", "重新打开抽屉未回显已保存的区域名称") + page.keyboard.press("Escape") + page.wait_for_timeout(500) + print("✓ 4. 辐射区域 编辑抽屉添加区域保存,重开回显正确") + + +def step_person_delete_protection(page: Page) -> None: + """删除被引用的经理被 400 拦截并引导「改为离职」。 + + 此处**不真改离职**——后续归属调整步骤要以在职经理为目标; + 「改为离职」的动作移到清理步骤执行(删除保护路径已在本步断言)。 + """ + row = find_row(page, "姓名", MANAGER_NAME) + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + # 确认框文案本身已含「此时应改为离职」引导;确认后走后端校验 + confirm_message_box(page, "删除") + expect_toast(page, "请改为离职", "人员删除保护") + print("✓ 5. 删除保护 删除被引人员被 400 拦截,引导改为离职") + + +def step_products(page: Page) -> None: + """新增产品(轨道交通)与同名称同规格重复负例。""" + create_product_via_ui(page, PRODUCT_NAME, PRODUCT_SPEC) + print(f"✓ 6. 新增产品 创建 {PRODUCT_NAME}(类别=轨道交通),列表可见") + + # 同名称同规格重复 → 400(唯一键 = 名称 + 规格) + page.click("button:has-text('新增产品')") + page.wait_for_timeout(500) + dialog = visible_dialog(page) + fill_form_item(page, dialog, "产品名称", PRODUCT_NAME) + fill_form_item(page, dialog, "规格型号", PRODUCT_SPEC) + select_form_option(page, dialog, "产品类别", "轨道交通") + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + if "已存在同名称同规格的产品" not in wait_toast(page, "已存在"): + fail("产品重复负例", f"重复产品未提示已存在,当前提示:{toast_text(page) or '(无)'}") + visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click() + page.wait_for_timeout(500) + print("✓ 7. 产品重复负例 同名称同规格被 400 拒绝并给出中文文案") + + +def step_customers(page: Page) -> None: + create_customer_via_ui(page, CUSTOMER_COMPANY, CUSTOMER_OWNER, SALESMAN_NAME) + print(f"✓ 8. 新增客户 创建 {CUSTOMER_COMPANY}(企业户,初始归属=业务员)") + + +def step_ownership_change(page: Page) -> None: + """归属调整到经理:二次确认含「不回溯历史」,留痕展示旧 → 新。""" + row = find_row(page, "关键词", CUSTOMER_COMPANY) + row.locator("button:has-text('归属变更')").click() + page.wait_for_timeout(500) + dialog = visible_dialog(page) + if SALESMAN_NAME not in dialog.inner_text(): + fail("归属变更", "变更弹窗未回显当前归属业务员") + select_form_option(page, dialog, "新归属人员", MANAGER_NAME) + dialog.locator("button:has-text('提交变更')").click() + page.wait_for_timeout(500) + + # 业务口径(对照表 #19):二次确认文案必须含「不回溯历史」 + box = confirm_text(page) + if "不回溯历史" not in box: + fail("归属变更确认", f"二次确认文案缺少「不回溯历史」:{box[:120]}") + confirm_message_box(page, "确认变更") + expect_toast(page, "归属已调整", "归属调整") + + row = find_row(page, "关键词", CUSTOMER_COMPANY) + if row.count() == 0: + fail("归属调整", "按关键词查询未找到客户行") + if MANAGER_NAME not in row.inner_text(): + fail("归属调整", "列表「归属人员」列未更新为经理") + print("✓ 9. 归属调整 二次确认含「不回溯历史」,归属已改到经理") + + row.locator("button:has-text('变更记录')").click() + page.wait_for_timeout(800) + drawer = page.locator(".el-drawer:visible").last + transition = f"{SALESMAN_NAME} → {MANAGER_NAME}" + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if drawer.locator(".el-table__row", has_text=transition).count(): + break + page.wait_for_timeout(200) + if drawer.locator(".el-table__row", has_text=transition).count() == 0: + fail("归属留痕", f"变更记录抽屉未看到「{transition}」的留痕行") + page.keyboard.press("Escape") + page.wait_for_timeout(500) + print("✓ 10. 归属留痕 留痕抽屉展示旧 → 新归属一条记录") + + +def step_cleanup(page: Page) -> None: + """清理测试数据:按 客户 → 产品 → 业务员 → 经理 依赖序删除。 + + 经理此刻仍在职(删除保护步骤刻意未改);删除被拦时先「设为离职」 + 再删——届时客户/业务员均已删除,经理应无引用、可正常删除。 + """ + open_menu(page, "客户管理", "基础数据") + row = find_row(page, "关键词", CUSTOMER_COMPANY) + if row.count() > 0: + delete_row(page, row, "客户已删除") + + open_menu(page, "产品管理", "基础数据") + row = find_row(page, "关键词", PRODUCT_NAME) + if row.count() > 0: + delete_row(page, row, "产品已删除") + + open_menu(page, "人员管理", "基础数据") + row = find_row(page, "姓名", SALESMAN_NAME) + if row.count() > 0: + delete_row(page, row, "人员已删除") + + row = find_row(page, "姓名", MANAGER_NAME) + if row.count() > 0: + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "删除") + text = wait_toast(page, "已删除") + if "人员已删除" in text: + pass + elif "请改为离职" in text or "存在团队成员" in text or "业务归属" in text: + # 引导路径照做:设为离职后再删(引用已随客户/业务员删除解除) + row = find_row(page, "姓名", MANAGER_NAME) + row.locator("button:has-text('设为离职')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "设为离职") + expect_toast(page, "已设为离职", "清理·经理离职") + row = find_row(page, "姓名", MANAGER_NAME) + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "删除") + expect_toast(page, "人员已删除", "清理·经理删除") + else: + fail("数据清理", f"清理经理出现未预期提示:{text or '(无)'}") + print("✓ 12. 数据清理 测试数据按依赖序清理,被保护项按留档说明处理") + + +def step_console_assertion() -> None: + """断言无 JS 控制台错误。 + + 口径:4xx 请求触发的浏览器网络层日志(Failed to load resource)在 + 负例步骤属预期,不算 JS 错误;只拦脚本错误(console.error / pageerror)。 + 网络资源日志仍收集在 console_errors 供失败诊断。 + """ + real_errors = [e for e in console_errors if "Failed to load resource" not in e] + if real_errors: + fail("控制台检查", f"发现 {len(real_errors)} 条 JS 错误:{real_errors[:3]}") + print("✓ 13. 控制台检查 无 JS 控制台错误") + + +# ==================== 入口 ==================== + + +def run(page: Page) -> None: + collect_console_errors(page, console_errors) + + admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "") + if not admin_password: + fail( + "环境变量", + "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)," + "示例:E2E_ADMIN_PASSWORD=xxx python e2e/base_data.py", + ) + + step_open_group_and_personnel(page, admin_password) + step_person_create_manager(page) + step_person_create_salesman(page) + step_person_edit_regions(page) + step_person_delete_protection(page) + step_products(page) + step_customers(page) + step_ownership_change(page) + step_cleanup(page) + step_console_assertion() + + if console_errors: + print(f" (网络资源日志 {len(console_errors)} 条,属 4xx 请求的预期噪声)") + + +def main() -> int: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1440, "height": 860}) + page.set_default_timeout(10_000) + try: + run(page) + except E2EFailure as exc: + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ {exc}") + if console_errors: + print(f" 控制台错误:{console_errors[-5:]}") + print(" 已保存截图 e2e/failure.png") + return 1 + except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因 + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") + if console_errors: + print(f" 控制台错误:{console_errors[-5:]}") + print(" 已保存截图 e2e/failure.png") + return 1 + finally: + browser.close() + + print("\n全部通过。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/e2e/business_data.py b/frontend/e2e/business_data.py new file mode 100644 index 0000000..45af1cc --- /dev/null +++ b/frontend/e2e/business_data.py @@ -0,0 +1,623 @@ +"""业务数据全链路端到端验证:业务 / 发货 / 回款 / 状态机 / 评级计算器。 + +覆盖的链路(只有真实浏览器才能验证的组合): + +- 基础数据准备(复用 `e2e/base_data.py` 建数):经理 / 业务员(挂经理)/ + 产品(轨道交通)/ 客户(企业户,归属=业务员) +- 业务:创建(两行明细:市政+轨交,数量/单价/成本)→ YW 业务编号与销售额 + 显示;编辑明细数量 → 汇总重算(销售额变化) +- 详情抽屉:产品明细回显、状态时间线「业务创建」首条、毛利润类别汇总卡片 + 与「不含运费」口径注释 +- 归属调整(业务员 → 经理):二次确认含「不回溯历史」(对照表 #19)、 + 详情留痕表可见旧 → 新 +- 发货管理:挂执行中业务创建发货单(日期/运费/明细行)→ SH 单号与发货总额 +- 回款管理:选业务后级联加载其发货单(断言下拉出现刚建发货单号)→ 指定 + 批次 → 回款天数派生显示「6 天」(发货日计第 1 天);第二笔不指定批次 → + 显示「未指定批次」 +- 状态机:执行中 → 已完成(详情时间线新增记录)→ 终态业务新发货被 400 + 拦截(「禁止新发货」) +- 客户评级计算器:五项各 20 分 → 总分 100 → 等级 A;等级映射抽屉 A/B/C/D +- 控制台无 JS 错误;清理按 回款 → 发货 → 业务 → 客户 → 产品 → 人员 依赖序 + +**运行前置**(缺一不可):: + + docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis + cd backend && uv run uvicorn app.main:app --reload --port 8000 # 后端 + cd frontend && pnpm dev # 前端 + +管理员密码经环境变量传入(不写死在源码里):: + + cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/business_data.py + +脚本自建的测试数据(`e2e_` 前缀)在流程内清理;删除保护拦截属预期行为, +被拦截时打印留档说明。页面辅助与基础数据建数复用 `e2e/base_data.py`。 +""" + +import os +import re +import sys +import time + +sys.path.insert(0, os.path.dirname(__file__)) + +from playwright.sync_api import Page, sync_playwright + +from common import ( + E2EFailure, + collect_console_errors, + current_path, + expect_toast, + fail, + login, + wait_toast, +) + +import base_data as base + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +ADMIN = "admin" + +# 明细两行:市政 10×5000(成本 3000)+ 轨交 5×3000(成本 2000) +ITEM_NAME_MUNICIPAL = f"e2e_市政产品_{base.SUFFIX}" +BUSINESS_DATE = "2026-01-05" +SHIP_DATE = "2026-01-10" +RECEIPT_DATE = "2026-01-15" +INIT_SALE_AMOUNT = "65000" # 10×5000 + 5×3000 +EDITED_SALE_AMOUNT = "115000" # 编辑后 20×5000 + 5×3000 +SHIP_FREIGHT = "100" +SHIP_ITEM_QUANTITY = "6" +SHIP_ITEM_PRICE = "4800" +SHIP_TOTAL = "28800" # 6×4800 +RECEIPT_AMOUNT_1 = "20000" +RECEIPT_AMOUNT_2 = "5000" +RECEIPT_DAYS = "6 天" # 回款日相对发货日 2026-01-10(发货日计第 1 天) + +# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取 +console_errors: list[str] = [] + +# 运行时提取的单据编号(新增业务/发货单后从列表文本提取,级联与清理用) +business_no = "" +shipment_no = "" + + +# ==================== 流程步骤 ==================== + + +def step_setup_base(page: Page, admin_password: str) -> None: + """登录并复用基础数据建数:经理 / 业务员(挂经理)/ 产品 / 客户(归属=业务员)。""" + login(page, ADMIN, admin_password) + base.create_manager_via_ui(page, base.MANAGER_NAME) + print(f"✓ 1. 建经理 {base.MANAGER_NAME}(岗位=营销经理)") + + base.create_salesman_via_ui(page, base.SALESMAN_NAME, base.MANAGER_NAME) + print(f"✓ 2. 建业务员 {base.SALESMAN_NAME}(岗位=营销员,挂经理)") + + base.create_product_via_ui(page, base.PRODUCT_NAME, base.PRODUCT_SPEC) + print(f"✓ 3. 建产品 {base.PRODUCT_NAME}(类别=轨道交通)") + + base.create_customer_via_ui( + page, base.CUSTOMER_COMPANY, base.CUSTOMER_OWNER, base.SALESMAN_NAME + ) + print(f"✓ 4. 建客户 {base.CUSTOMER_COMPANY}(企业户,归属=业务员)") + + +def fill_item_row(row, name: str, quantity: str, price: str, cost: str) -> None: + """按 placeholder 填写业务明细行:名称 / 数量 / 销售单价 / 成本额。 + + 「元,选填」的 placeholder 在单价/成本/指导价三列重复,按列序 nth 取 + (单价在前、成本居次),指导价不填。 + """ + row.locator('input[placeholder="产品名称,可手填"]').fill(name) + row.locator('input[placeholder=">0"]').fill(quantity) + row.locator('input[placeholder="元,选填"]').nth(0).fill(price) + row.locator('input[placeholder="元,选填"]').nth(1).fill(cost) + + +def select_row_option(page: Page, row, option: str) -> None: + """点开表格行内的下拉(明细行「类别」列,非 form-item 结构)并选择选项。""" + row.locator(".el-select").first.click() + option_item = page.locator( + ".el-select-dropdown:visible .el-select-dropdown__item", has_text=option + ) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if option_item.count(): + break + page.wait_for_timeout(200) + if option_item.count() == 0: + fail("下拉选择", f"行内下拉未出现选项「{option}」") + option_item.first.click() + page.wait_for_timeout(200) + + +def step_business_create(page: Page) -> None: + """创建业务:选客户、两行明细(市政+轨交)、负责人员=业务员。""" + global business_no + base.open_menu(page, "业务管理", "业务数据") + if current_path(page) != "/business/businesses": + fail("菜单导航", f"未进入业务管理页,当前 {page.url}") + + page.click("button:has-text('新增业务')") + page.wait_for_timeout(500) + dialog = base.visible_dialog(page) + base.select_form_option(page, dialog, "客户", base.CUSTOMER_COMPANY) + base.fill_date(page, dialog, "业务日期", BUSINESS_DATE) + base.select_form_option(page, dialog, "负责人员", base.SALESMAN_NAME) + + item_rows = dialog.locator(".items-table .el-table__row") + fill_item_row(item_rows.nth(0), ITEM_NAME_MUNICIPAL, "10", "5000", "3000") + + dialog.locator("button:has-text('添加一行')").click() + page.wait_for_timeout(300) + item_rows = dialog.locator(".items-table .el-table__row") + select_row_option(page, item_rows.nth(1), "轨道交通") + fill_item_row(item_rows.nth(1), base.PRODUCT_NAME, "5", "3000", "2000") + + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "业务已创建", "新增业务") + + row = base.find_row(page, "关键词", base.CUSTOMER_COMPANY) + if row.count() == 0: + fail("新增业务", f"列表中未找到新建业务(客户 {base.CUSTOMER_COMPANY})") + text = row.inner_text() + no_match = re.search(r"YW\d{8}-\d+", text) + if not no_match: + fail("新增业务", f"列表未显示 YW 前缀业务编号:{text[:120]}") + business_no = no_match.group(0) + if INIT_SALE_AMOUNT not in text: + fail("新增业务", f"列表销售额未显示 {INIT_SALE_AMOUNT}:{text[:160]}") + print(f"✓ 5. 新增业务 两行明细创建成功,业务编号 {business_no},销售额 {INIT_SALE_AMOUNT}") + + +def step_business_edit(page: Page) -> None: + """编辑业务:第一行数量 10 → 20 → 保存 → 列表销售额汇总重算。""" + row = base.find_row(page, "关键词", business_no) + row.locator("button:has-text('编辑')").click() + # openEdit 异步拉详情后全量替换明细草稿(此时预置草稿仍显示旧值), + # 固定等待让「回显替换」先完成,避免填到将被丢弃的草稿上 + page.wait_for_timeout(1500) + dialog = base.visible_dialog(page) + item_rows = dialog.locator(".items-table .el-table__row") + if item_rows.count() < 2: + fail("编辑业务", "编辑弹窗未回显两行明细") + item_rows.nth(0).locator('input[placeholder=">0"]').fill("20") + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "已保存", "编辑业务") + + row = base.find_row(page, "关键词", business_no) + if EDITED_SALE_AMOUNT not in row.inner_text(): + fail("编辑汇总重算", f"保存后销售额未更新为 {EDITED_SALE_AMOUNT}:{row.inner_text()[:160]}") + print(f"✓ 6. 编辑重算 明细数量 10→20,销售额 {INIT_SALE_AMOUNT} → {EDITED_SALE_AMOUNT}") + + +def step_business_detail(page: Page) -> None: + """详情抽屉:明细表回显、时间线「业务创建」首条、毛利润类别汇总与口径注释。""" + row = base.find_row(page, "关键词", business_no) + row.locator("button:has-text('详情')").click() + page.wait_for_timeout(1000) + drawer = page.locator(".el-drawer:visible").last + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if drawer.locator(".detail-desc").count(): + break + page.wait_for_timeout(200) + + detail_text = drawer.inner_text() + if ITEM_NAME_MUNICIPAL not in detail_text or base.PRODUCT_NAME not in detail_text: + fail("业务详情", "详情抽屉产品明细未回显两行产品") + timeline = drawer.locator(".status-timeline .el-timeline-item") + if timeline.count() == 0: + fail("业务详情", "状态时间线为空") + if "业务创建" not in (timeline.first.inner_text() or ""): + fail("业务详情", "时间线首条不是「业务创建」") + if ( + drawer.locator(".gp-card", has_text="市政水利").count() == 0 + or drawer.locator(".gp-card", has_text="轨道交通").count() == 0 + ): + fail("毛利润视图", "类别汇总卡片未展示市政水利/轨道交通") + note = drawer.locator(".gp-note").inner_text() + if "不含运费" not in note: + fail("毛利润注释", f"口径注释缺少「不含运费」:{note[:120]}") + page.keyboard.press("Escape") + page.wait_for_timeout(600) + print("✓ 7. 详情抽屉 明细回显、时间线「业务创建」首条、毛利润汇总与口径注释") + + +def step_business_ownership(page: Page) -> None: + """归属调整(业务员 → 经理):确认含「不回溯历史」→ 详情留痕表可见。""" + row = base.find_row(page, "关键词", business_no) + row.locator("button:has-text('归属')").click() + page.wait_for_timeout(500) + dialog = base.visible_dialog(page) + if business_no not in dialog.inner_text(): + fail("归属调整", "归属调整弹窗未回显业务编号") + base.select_form_option(page, dialog, "新负责人员", base.MANAGER_NAME) + dialog.locator("button:has-text('提交调整')").click() + page.wait_for_timeout(500) + + box = base.confirm_text(page) + if "不回溯历史" not in box: + fail("归属调整确认", f"二次确认文案缺少「不回溯历史」:{box[:120]}") + base.confirm_message_box(page, "确认调整") + expect_toast(page, "归属已调整", "业务归属调整") + + row = base.find_row(page, "关键词", business_no) + if row.count() == 0: + fail("业务归属调整", "按关键词查询未找到业务行") + if base.MANAGER_NAME not in row.inner_text(): + fail("业务归属调整", "列表「负责人员」列未更新为经理") + + row.locator("button:has-text('详情')").click() + page.wait_for_timeout(1000) + drawer = page.locator(".el-drawer:visible").last + transition = f"负责 {base.SALESMAN_NAME} → {base.MANAGER_NAME}" + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if drawer.locator(".el-table__row", has_text=transition).count(): + break + page.wait_for_timeout(200) + if drawer.locator(".el-table__row", has_text=transition).count() == 0: + fail("归属留痕", f"详情抽屉归属变更记录未看到「{transition}」") + page.keyboard.press("Escape") + page.wait_for_timeout(600) + print("✓ 8. 归属调整 业务员→经理确认含「不回溯历史」,详情留痕表可见") + + +def step_shipment_create(page: Page) -> None: + """发货管理:挂执行中业务创建发货单(日期/运费/明细行)→ SH 单号。""" + global shipment_no + base.open_menu(page, "发货管理", "业务数据") + if current_path(page) != "/business/shipments": + fail("菜单导航", f"未进入发货管理页,当前 {page.url}") + + page.click("button:has-text('新增发货单')") + page.wait_for_timeout(500) + dialog = base.visible_dialog(page) + base.select_form_option(page, dialog, "业务", business_no) + base.fill_date(page, dialog, "发货日期", SHIP_DATE) + base.fill_form_item(page, dialog, "运费(元)", SHIP_FREIGHT) + item_row = dialog.locator(".items-table .el-table__row").first + item_row.locator('input[placeholder="产品名称,可手填"]').fill(base.PRODUCT_NAME) + item_row.locator('input[placeholder=">0"]').fill(SHIP_ITEM_QUANTITY) + item_row.locator('input[placeholder="可空=待人工补价"]').fill(SHIP_ITEM_PRICE) + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + page.wait_for_timeout(500) + base.confirm_message_box(page, "确认创建") + expect_toast(page, "发货单已创建", "新增发货单") + + base.select_filter_option(page, "业务", business_no) + base.click_query(page) + ship_row = base.table_rows(page).filter(has_text=business_no).first + if ship_row.count() == 0: + fail("新增发货单", "发货列表未显示该业务的发货单") + ship_text = ship_row.inner_text() + no_match = re.search(r"SH\d{8}-\d+", ship_text) + if not no_match: + fail("新增发货单", f"发货单号未显示 SH 前缀:{ship_text[:120]}") + shipment_no = no_match.group(0) + if SHIP_TOTAL not in ship_text: + fail("新增发货单", f"发货总额未显示 {SHIP_TOTAL}:{ship_text[:160]}") + print(f"✓ 9. 发货单 挂业务创建成功,发货单号 {shipment_no},发货总额 {SHIP_TOTAL}") + + +def step_receipt_with_shipment(page: Page) -> None: + """第一笔回款:选业务 → 级联加载发货单并断言下拉出现刚建单号 → 天数派生。""" + base.open_menu(page, "回款管理", "业务数据") + if current_path(page) != "/business/receipts": + fail("菜单导航", f"未进入回款管理页,当前 {page.url}") + + page.click("button:has-text('新增回款')") + page.wait_for_timeout(500) + dialog = base.visible_dialog(page) + base.select_form_option(page, dialog, "业务", business_no) + page.wait_for_timeout(600) # 等发货批次级联加载完成 + + # 级联断言:发货批次下拉必须出现刚建的发货单号 + batch_item = dialog.locator('.el-form-item:has(.el-form-item__label:text-is("发货批次"))') + batch_item.locator(".el-select").first.click() + batch_option = page.locator( + ".el-select-dropdown:visible .el-select-dropdown__item", has_text=shipment_no + ) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if batch_option.count(): + break + page.wait_for_timeout(200) + if batch_option.count() == 0: + fail("回款级联", f"发货批次下拉未级联出刚建的发货单 {shipment_no}") + batch_option.first.click() + page.wait_for_timeout(200) + + base.fill_date(page, dialog, "回款到账日", RECEIPT_DATE) + base.fill_form_item(page, dialog, "金额(元)", RECEIPT_AMOUNT_1) + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "回款已登记", "新增回款") + + base.select_filter_option(page, "业务", business_no) + base.click_query(page) + rows_text = " ".join(base.table_rows(page).all_inner_texts()) + if RECEIPT_DAYS not in rows_text: + fail("回款天数派生", f"指定批次后回款天数未显示「{RECEIPT_DAYS}」:{rows_text[:200]}") + if "未指定批次" in rows_text: + fail("回款天数派生", "指定批次后不应显示「未指定批次」") + print(f"✓ 10. 回款第一笔 级联出 {shipment_no} 并指定,回款天数显示「{RECEIPT_DAYS}」") + + +def step_receipt_without_shipment(page: Page) -> None: + """第二笔回款:不指定批次 → 列表显示「未指定批次」,第一笔天数不消失。""" + page.click("button:has-text('新增回款')") + page.wait_for_timeout(500) + dialog = base.visible_dialog(page) + base.select_form_option(page, dialog, "业务", business_no) + page.wait_for_timeout(600) + base.fill_date(page, dialog, "回款到账日", RECEIPT_DATE) + base.fill_form_item(page, dialog, "金额(元)", RECEIPT_AMOUNT_2) + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "回款已登记", "新增第二笔回款") + + base.click_query(page) + rows_text = " ".join(base.table_rows(page).all_inner_texts()) + if "未指定批次" not in rows_text: + fail("未指定批次", "第二笔回款(未指定批次)未显示「未指定批次」") + if RECEIPT_DAYS not in rows_text: + fail("未指定批次", f"第一笔回款的「{RECEIPT_DAYS}」不应消失") + print("✓ 11. 回款第二笔 不指定批次显示「未指定批次」,第一笔天数保留") + + +def step_status_flow(page: Page) -> None: + """状态机:执行中 → 已完成(详情时间线新增记录)。""" + base.open_menu(page, "业务管理", "业务数据") + row = base.find_row(page, "关键词", business_no) + if "执行中" not in row.inner_text(): + fail("状态流转", f"业务当前状态不是执行中:{row.inner_text()[:120]}") + row.locator("button:has-text('状态')").click() + page.wait_for_timeout(500) + dialog = base.visible_dialog(page) + # 目标状态默认「已完成」(openStatus 预设 status=2),直接确认 + dialog.locator("button:has-text('确认变更')").click() + page.wait_for_timeout(500) + + box = base.confirm_text(page) + if "终态不可再变更" not in box: + fail("状态流转确认", f"二次确认文案缺少「终态不可再变更」:{box[:120]}") + base.confirm_message_box(page, "已完成") + expect_toast(page, "状态已变更", "状态流转") + + row = base.find_row(page, "关键词", business_no) + if row.count() == 0: + fail("状态流转", "按关键词查询未找到业务行") + if "已完成" not in row.inner_text(): + fail("状态流转", "列表状态未更新为已完成") + + row.locator("button:has-text('详情')").click() + page.wait_for_timeout(1000) + drawer = page.locator(".el-drawer:visible").last + timeline = drawer.locator(".status-timeline .el-timeline-item") + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if timeline.count() >= 2: + break + page.wait_for_timeout(200) + if timeline.count() < 2: + fail("状态时间线", f"流转后时间线未新增记录({timeline.count()} 条)") + if "已完成" not in (timeline.last.inner_text() or ""): + fail("状态时间线", "时间线末条未显示已完成状态") + if "业务创建" not in (timeline.first.inner_text() or ""): + fail("状态时间线", "时间线首条「业务创建」不应消失") + page.keyboard.press("Escape") + page.wait_for_timeout(600) + print("✓ 12. 状态流转 执行中→已完成,详情时间线新增一条记录") + + +def step_terminal_shipment_blocked(page: Page) -> None: + """终态业务(已完成)再建发货单被 400 拦截(「禁止新发货」)。""" + base.open_menu(page, "发货管理", "业务数据") + page.click("button:has-text('新增发货单')") + page.wait_for_timeout(500) + dialog = base.visible_dialog(page) + base.select_form_option(page, dialog, "业务", business_no) + base.fill_date(page, dialog, "发货日期", SHIP_DATE) + item_row = dialog.locator(".items-table .el-table__row").first + item_row.locator('input[placeholder="产品名称,可手填"]').fill(base.PRODUCT_NAME) + item_row.locator('input[placeholder=">0"]').fill("1") + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + page.wait_for_timeout(500) + base.confirm_message_box(page, "确认创建") + expect_toast(page, "禁止新发货", "终态业务发货拦截") + base.visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click() + page.wait_for_timeout(500) + print("✓ 13. 终态拦截 已完成业务新发货被 400 拒绝(禁止新发货)") + + +def step_rating(page: Page) -> None: + """客户评级计算器:五项各 20 → 总分 100 → 等级 A;映射抽屉 A/B/C/D。""" + base.open_menu(page, "客户评级计算器", "业务数据") + if current_path(page) != "/business/rating": + fail("菜单导航", f"未进入客户评级计算器页,当前 {page.url}") + + for label in ("发货额", "回款率", "毛利率", "合同履约配合能力", "现场规范施工能力"): + score_row = page.locator(".calc-form .score-row", has_text=label).first + score_row.locator(".el-input-number input").fill("20") + score_row.locator(".el-input-number input").press("Enter") + page.wait_for_timeout(200) + + total = (page.locator(".total-num").first.text_content() or "").strip() + if total != "100": + fail("评级总分", f"五项各 20 分后总分未显示 100:{total or '(无)'}") + grade = (page.locator(".grade-name").first.text_content() or "").strip() + if grade != "A": + fail("评级等级", f"总分 100 未命中等级 A:{grade or '(无)'}") + + page.click("button:has-text('等级映射设置')") + page.wait_for_timeout(600) + drawer = page.locator(".el-drawer").filter(has_text="等级映射设置").last + table_text = drawer.inner_text() + for letter in ("A", "B", "C", "D"): + if letter not in table_text: + fail("等级映射抽屉", f"映射抽屉表格缺少等级 {letter}") + page.keyboard.press("Escape") + page.wait_for_timeout(500) + print("✓ 14. 评级计算器 五项满 20 → 总分 100 → 等级 A;映射抽屉 A/B/C/D") + + +def step_cleanup(page: Page) -> None: + """清理:按 回款 → 发货 → 业务 → 客户 → 产品 → 人员 依赖序删除。 + + 删除保护拦截属预期行为:被拦截时打印留档说明,不做假绿也不误伤主流程。 + 特别地,步骤 12 已把业务流转为「已完成」——终态业务禁删回款/发货是 + 设计保护(单据金额参与核算口径),此处删除被拦即留档,不视为失败。 + """ + # 回款(业务此刻已是终态:删除必然被拦,留档说明) + base.open_menu(page, "回款管理", "业务数据") + base.select_filter_option(page, "业务", business_no) + base.click_query(page) + while base.table_rows(page).count() > 0: + base.table_rows(page).first.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + base.confirm_message_box(page, "删除") + text = wait_toast(page, "已删除") + if "回款已删除" not in text: + print(" 数据清理 回款删除被拦(业务已终态属预期保护),留档说明:回款保留在开发库") + break + page.wait_for_timeout(600) + base.click_query(page) + + # 发货单(回款已删,无批次指定;删除连带明细并回写业务汇总) + base.open_menu(page, "发货管理", "业务数据") + base.select_filter_option(page, "业务", business_no) + base.click_query(page) + while base.table_rows(page).count() > 0: + base.table_rows(page).first.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + base.confirm_message_box(page, "删除") + text = wait_toast(page, "已删除") + if "发货单已删除" not in text: + print(" 数据清理 发货单删除被拦(属预期保护),留档说明:发货单保留在开发库") + break + page.wait_for_timeout(600) + base.click_query(page) + + # 业务(发货/回款均已删则可删;仍被拦时留档) + base.open_menu(page, "业务管理", "业务数据") + row = base.find_row(page, "关键词", business_no) + if row.count() > 0: + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + base.confirm_message_box(page, "删除") + text = wait_toast(page, "已删除") + if "业务已删除" in text: + print(f" 数据清理 业务 {business_no} 已删除") + else: + print( + f" 数据清理 业务删除被拦(属预期保护),留档说明:业务 {business_no} 保留在开发库" + ) + + # 基础数据:客户 → 产品 → 业务员 → 经理 + # 客户被留档的业务引用时删除被拦(属预期保护),宽清处理不误伤主流程 + base.open_menu(page, "客户管理", "基础数据") + row = base.find_row(page, "关键词", base.CUSTOMER_COMPANY) + if row.count() > 0: + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + base.confirm_message_box(page, "删除") + text = wait_toast(page, "已删除") + if "客户已删除" in text: + pass + else: + print(" 数据清理 客户删除被拦(被留档业务引用属预期保护),留档说明:客户保留在开发库") + + base.open_menu(page, "产品管理", "基础数据") + row = base.find_row(page, "关键词", base.PRODUCT_NAME) + if row.count() > 0: + base.delete_row(page, row, "产品已删除") + + base.open_menu(page, "人员管理", "基础数据") + for person_name in (base.SALESMAN_NAME, base.MANAGER_NAME): + row = base.find_row(page, "姓名", person_name) + if row.count() == 0: + continue + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + base.confirm_message_box(page, "删除") + text = wait_toast(page, "已删除") + if "人员已删除" in text: + continue + if "请改为离职" in text or "存在团队成员" in text or "业务归属" in text: + print(f" 数据清理 {person_name} 仍被引用(属预期保护),留档说明:保留人员") + continue + fail("数据清理", f"清理人员出现未预期提示:{text or '(无)'}") + print("✓ 15. 数据清理 回款/发货/业务与基础数据按依赖序清理,被保护项留档") + + +def step_console_assertion() -> None: + """断言无 JS 控制台错误(口径同 base_data.py:只拦脚本错误)。""" + real_errors = [e for e in console_errors if "Failed to load resource" not in e] + if real_errors: + fail("控制台检查", f"发现 {len(real_errors)} 条 JS 错误:{real_errors[:3]}") + print("✓ 16. 控制台检查 无 JS 控制台错误") + + +# ==================== 入口 ==================== + + +def run(page: Page) -> None: + collect_console_errors(page, console_errors) + + admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "") + if not admin_password: + fail( + "环境变量", + "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)," + "示例:E2E_ADMIN_PASSWORD=xxx python e2e/business_data.py", + ) + + step_setup_base(page, admin_password) + step_business_create(page) + step_business_edit(page) + step_business_detail(page) + step_business_ownership(page) + step_shipment_create(page) + step_receipt_with_shipment(page) + step_receipt_without_shipment(page) + step_status_flow(page) + step_terminal_shipment_blocked(page) + step_rating(page) + step_cleanup(page) + step_console_assertion() + + if console_errors: + print(f" (网络资源日志 {len(console_errors)} 条,属 4xx 请求的预期噪声)") + + +def main() -> int: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1440, "height": 860}) + page.set_default_timeout(10_000) + try: + run(page) + except E2EFailure as exc: + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ {exc}") + if console_errors: + print(f" 控制台错误:{console_errors[-5:]}") + print(" 已保存截图 e2e/failure.png") + return 1 + except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因 + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") + if console_errors: + print(f" 控制台错误:{console_errors[-5:]}") + print(" 已保存截图 e2e/failure.png") + return 1 + finally: + browser.close() + + print("\n全部通过。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/e2e/calc_settlement_flow.py b/frontend/e2e/calc_settlement_flow.py new file mode 100644 index 0000000..79be522 --- /dev/null +++ b/frontend/e2e/calc_settlement_flow.py @@ -0,0 +1,353 @@ +"""绩效核算与月度结算端到端验证(M3 ④:台账/月结两页)。 + +覆盖真实浏览器链路(重点守 code-review 修复过的两处前端缺陷): +- 台账页「重算」对话框按**回款 ID** 发起重算(修复前传台账行主键,会 + 作废并重算无关回款的台账);重算后新版本生效、旧版本翻「已替代」 +- 版本筛选三态:仅当前生效(1)/ 仅历史版本(0)/ 清空=全部(不过滤) + (修复前「含历史版本」传 0 只回历史行) +- 月结页:生成快照(草稿)→ 提交审批(自动通过并锁定)→ 详情抽屉展示 + 按人明细(净工资/试用期),锁定人回显 + +前置(API 预置;前端无「核算」入口——核算为批量动作走接口): +客户/人员/业务/发货/回款经 HTTP API 直建(与集成测试同形入参),核算经 +API 触发;浏览器只负责台账/月结两页的真实交互。 + +数据说明:核算后的回款与已生成月结按设计不可删(变更只走调整单), +本流程按 `e2e_` 前缀 + **随机远未来月份(209x)**造数——每次运行独占 +一个月份,重复运行互不碰撞;行数据留档开发库(与 business_data.py 同)。 + +运行前置(缺一不可): + docker compose up -d + cd backend && uv run uvicorn app.main:app --port 8000 + cd frontend && pnpm dev + cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/calc_settlement_flow.py +""" + +import json +import os +import random +import time +import urllib.error +import urllib.request + +from playwright.sync_api import Page, sync_playwright + +from common import ( + BASE_URL, + E2EFailure, + collect_console_errors, + current_path, + expect_toast, + fail, + login, +) + +ADMIN = "admin" +API_BASE = "http://127.0.0.1:8000" +SUFFIX = f"{int(time.time())}" +console_errors: list[str] = [] + +_SETTLEMENT_SWITCH = "approval.monthly_settlement.enabled" + + +def _api(method: str, path: str, token: str, body: dict | None = None) -> dict: + """直连后端接口(仅用于 API 预置;浏览器交互一律走页面)。""" + 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_settlement_switch(token: str, value: str) -> None: + """显式控制月结审批开关(用例不依赖种子默认值;结束还原默认 1)。""" + _api( + "POST", + "/api/v1/system/params/changes", + token, + { + "change_type": 1, + "remark": f"e2e calc_settlement_flow 设置月结审批开关={value}", + "items": [ + {"param_code": _SETTLEMENT_SWITCH, "dimension_value": "", "value": value} + ], + }, + ) + + +def _filter_item(page: Page, label: str): + """可见筛选表单中指定标签的表单项(select 带 :visible 避开隐藏实例)。""" + return page.locator( + f'.filters:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))' + ).first + + +def _query(page: Page) -> None: + page.locator(".filters:visible button:has-text('查询')").first.click() + page.wait_for_timeout(800) + + +def _select_filter(page: Page, label: str, option: str) -> None: + item = _filter_item(page, label) + item.locator(".el-select").click() + page.wait_for_timeout(300) + page.locator( + ".el-select-dropdown:visible .el-select-dropdown__item", has_text=option + ).first.click() + page.wait_for_timeout(200) + + +def step_login_and_open_ledger(page: Page, admin_password: str) -> None: + login(page, ADMIN, admin_password) + page.goto(f"{BASE_URL}/calc/ledger", wait_until="networkidle") + page.wait_for_timeout(600) + if current_path(page) != "/calc/ledger": + fail("导航", f"未进入绩效台账页,当前 {page.url}") + print("✓ 1. 登录与导航 进入绩效台账页") + + +def step_setup_via_api(page: Page, month: str) -> dict: + """API 预置完整链路:角色 → 客户/人员 → 业务 → 发货 → 回款 → 核算。""" + token = page.evaluate("localStorage.getItem('ys_token')") + if not token: + fail("API 预置", "localStorage 中未取到 ys_token") + + roles = _api("GET", "/api/v1/system/business-roles?pageNum=1&pageSize=50", token)["list"] + role_id = next((r["id"] for r in roles if r["role_code"] == "salesperson"), None) + if role_id is None: + fail("API 预置", "未找到 salesperson 岗位种子") + + customer = _api( + "POST", + "/api/v1/base/customers", + token, + { + "company_name": f"e2e_月结客户_{SUFFIX}", + "owner_name": "张老板", + "customer_type": "personal", + }, + ) + person = _api( + "POST", + "/api/v1/base/personnel", + token, + { + "personnel_name": f"e2e_月结人员_{SUFFIX}", + "business_role_id": role_id, + "salary_standard": "3000.00", + }, + ) + business = _api( + "POST", + "/api/v1/business/businesses", + token, + { + "customer_id": customer["id"], + "business_date": f"{month}-01", + "owner_personnel_id": person["id"], + "items": [ + { + "product_name": f"e2e_月结产品_{SUFFIX}", + "product_category": "municipal_water", + "quantity": "100", + "sale_price": "100.00", + "cost_amount": "3000.00", + } + ], + }, + ) + shipment = _api( + "POST", + "/api/v1/business/shipments", + token, + { + "business_id": business["id"], + "ship_date": f"{month}-02", + "items": [{"product_name": "e2e_月结发货", "quantity": "1", "sale_price": "1.00"}], + }, + ) + receipt = _api( + "POST", + "/api/v1/business/receipts", + token, + { + "business_id": business["id"], + "receipt_date": f"{month}-15", + "amount": "20000.00", + "shipment_id": shipment["id"], + }, + ) + calc = _api("POST", f"/api/v1/calc/receipts/{receipt['id']}/calc", token) + if calc.get("repeated") is not False: + fail("API 预置", f"核算应为首算,实际 repeated={calc.get('repeated')}") + print(f"✓ 2. API 预置 业务 {business['business_no']} 已核算({month})") + return { + "token": token, + "month": month, + "business_no": business["business_no"], + "personnel_name": person["personnel_name"], + } + + +def step_ledger_row_visible(page: Page, ctx: dict) -> None: + _filter_item(page, "归属月").locator("input").first.fill(ctx["month"]) + _query(page) + row = page.locator(".el-table__row", has_text=ctx["business_no"]).first + if row.count() == 0: + fail("台账列表", f"筛选 {ctx['month']} 未见业务 {ctx['business_no']} 的台账行") + text = row.text_content() + if "v1" not in text or "已替代" in text: + fail("台账列表", f"首算行应为当前生效 v1,实际:「{text.strip()[:80]}」") + print("✓ 3. 台账筛选 首算 v1 行可见且为当前生效") + + +def step_recalc_via_dialog(page: Page, ctx: dict) -> None: + """重算对话框:修复前按台账行主键发起,会作废无关回款的台账。""" + row = page.locator(".el-table__row", has_text=ctx["business_no"]).first + row.locator("button:has-text('重算')").click() + dialog = page.locator(".el-dialog:visible").last + dialog.wait_for(state="visible") + dialog.locator("textarea").fill(f"e2e 重算验证 {SUFFIX}") + dialog.locator("button:has-text('确认重算')").click() + expect_toast(page, "重算完成", "重算") + page.wait_for_timeout(600) + + row = page.locator(".el-table__row", has_text=ctx["business_no"]).first + text = row.text_content() + if "v2" not in text: + fail("重算", f"重算后应显示 v2,实际:「{text.strip()[:80]}」") + print("✓ 4. 重算对话框 按回款 ID 重算成功,v2 生效") + + +def step_version_filter(page: Page, ctx: dict) -> None: + # 仅历史版本:0 必须精确命中历史行(修复前「含历史版本」即传 0,语义相反) + _select_filter(page, "版本", "仅历史版本") + _query(page) + rows = page.locator(".el-table__row", has_text=ctx["business_no"]) + if rows.count() != 1 or "已替代" not in rows.first.text_content(): + fail("版本筛选", "「仅历史版本」应只剩一条带「已替代」的 v1 行") + + # 清空 = 不过滤(全部版本):v1+v2 双版本可见 + item = _filter_item(page, "版本") + item.hover() + clear = item.locator(".el-select__clear").first + if clear.count() == 0: + fail("版本筛选", "未找到下拉清空按钮") + clear.click() + page.wait_for_timeout(200) + _query(page) + rows = page.locator(".el-table__row", has_text=ctx["business_no"]) + texts = [rows.nth(i).text_content() for i in range(rows.count())] + if rows.count() != 2 or not any("已替代" in t for t in texts): + fail("版本筛选", f"清空后应见 v1+v2 两行,实际 {rows.count()} 行:{texts}") + print("✓ 5. 版本筛选 仅历史/全部三态正确(v1 已替代 + v2 生效)") + + +def step_generate_settlement(page: Page, ctx: dict) -> None: + page.goto(f"{BASE_URL}/settlements", wait_until="networkidle") + page.wait_for_timeout(600) + page.locator('input[placeholder="结算月份 YYYY-MM"]').fill(ctx["month"]) + page.click("button:has-text('生成月结快照')") + expect_toast(page, "月结已生成", "生成月结") + + row = page.locator(".el-table__row", has_text=ctx["month"]).first + row.wait_for(state="visible") + text = row.text_content() + if "草稿" not in text: + fail("月结生成", f"生成后应为草稿状态,实际:「{text.strip()[:80]}」") + print("✓ 6. 月结生成 快照生成,草稿状态") + + +def step_submit_and_locked(page: Page, ctx: dict) -> None: + row = page.locator(".el-table__row", has_text=ctx["month"]).first + row.locator("button:has-text('提交审批')").click() + # 开关已显式关闭(见 run 的 finally 还原)→ 自动通过并锁定 + expect_toast(page, "月结已锁定", "提交审批") + page.wait_for_timeout(600) + row = page.locator(".el-table__row", has_text=ctx["month"]).first + if "已审定" not in row.text_content(): + fail("提交审批", "提交后应显示已审定(锁定)") + print("✓ 7. 提交审批 自动通过并锁定(已审定)") + + +def step_detail_drawer(page: Page, ctx: dict) -> None: + row = page.locator(".el-table__row", has_text=ctx["month"]).first + row.locator("button:has-text('详情')").click() + drawer = page.locator(".el-drawer:visible").last + drawer.wait_for(state="visible") + page.wait_for_timeout(500) + text = drawer.text_content() + if ctx["month"] not in text or "已审定" not in text: + fail("月结详情", f"抽屉应含月份与已审定状态,实际:「{text.strip()[:120]}」") + if ctx["personnel_name"] not in text: + fail("月结详情", f"明细应含本次人员 {ctx['personnel_name']}") + if "净工资" not in text: + fail("月结详情", "明细应含净工资列") + print("✓ 8. 月结详情 抽屉明细含人员/净工资/锁定状态") + + +def run(page: Page) -> None: + collect_console_errors(page, console_errors) + admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "") + if not admin_password: + fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)") + + # 随机远未来月份:每次运行独占一月,重复运行互不碰撞(数据留档) + month = f"20{random.randint(90, 99)}-{random.randint(1, 12):02d}" + + step_login_and_open_ledger(page, admin_password) + ctx = step_setup_via_api(page, month) + _set_settlement_switch(ctx["token"], "0") + try: + step_ledger_row_visible(page, ctx) + step_recalc_via_dialog(page, ctx) + step_version_filter(page, ctx) + step_generate_settlement(page, ctx) + step_submit_and_locked(page, ctx) + step_detail_drawer(page, ctx) + finally: + # 还原审批开关默认(V8 起默认开启),中断也不遗留 + _set_settlement_switch(ctx["token"], "1") + + print(f" (本次数据留档:{month} 已锁月结 + 客户/人员/业务 e2e_ 前缀,不可删)") + if console_errors: + fail("控制台", f"存在 {len(console_errors)} 条 JS 错误:{console_errors[:2]}") + + +def main() -> int: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1600, "height": 1000}) + page.set_default_timeout(15000) + try: + run(page) + except E2EFailure as exc: + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ {exc}") + print(" 已保存截图 e2e/failure.png") + return 1 + except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因 + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") + return 1 + finally: + browser.close() + print("\n全部通过。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/e2e/common.py b/frontend/e2e/common.py new file mode 100644 index 0000000..7b56dee --- /dev/null +++ b/frontend/e2e/common.py @@ -0,0 +1,221 @@ +"""端到端测试公共工具。 + +抽取自 `login_flow.py`,供各流程脚本复用:环境常量、Redis 读取(取验证码 +答案,仅自动化测试可用)、完整登录动作、失败处理与控制台错误收集。 + +约定:断言失败抛 `E2EFailure`(消息格式 `步骤:原因`),由各脚本的 `main()` +统一截图并返回退出码 1。 +""" + +import os +import subprocess +import sys +import time +from urllib.parse import urlparse + +from playwright.sync_api import Page + +BASE_URL = "http://127.0.0.1:5173" +REDIS_CONTAINER = "ys-redis-dev" + +# Windows 终端默认 cp936,✓/✗ 等符号会抛 UnicodeEncodeError 而非打印失败原因。 +# 与后端 core/logging.py 的处理一致,显式重配标准输出编码。 +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + + +class E2EFailure(Exception): + """步骤断言失败。""" + + +def redis_password() -> str: + """从 docker/.env 读取 Redis 密码。 + + 测试脚本无法用应用配置(那需要后端 venv),故直接读环境文件。 + """ + if value := os.environ.get("REDIS_PASSWORD"): + return value + env_file = os.path.join(os.path.dirname(__file__), "..", "..", "docker", ".env") + with open(env_file, encoding="utf-8") as handle: + for line in handle: + if line.startswith("REDIS_PASSWORD="): + return line.split("=", 1)[1].strip() + raise RuntimeError("未能从 docker/.env 读取 REDIS_PASSWORD") + + +def redis_get(key: str) -> str | None: + """读取 Redis 键值。仅用于自动化测试 —— 生产当然不能这样取验证码答案。""" + result = subprocess.run( + ["docker", "exec", REDIS_CONTAINER, "redis-cli", "-a", redis_password(), + "--no-auth-warning", "get", key], + capture_output=True, text=True, encoding="utf-8", check=False, + ) + return result.stdout.strip() or None + + +def fail(step: str, detail: str) -> None: + """断言失败:抛异常而非进程退出 —— 由 main() 统一截图后再退出。""" + raise E2EFailure(f"{step}:{detail}") + + +def current_path(page: Page) -> str: + """当前 URL 的路径部分。 + + 比较路径**不能**用 `**/dashboard` 这类 glob —— 它会把 + `/login?redirect=/dashboard` 也匹配上,"登录失败"会被误判为成功。 + """ + return urlparse(page.url).path + + +def collect_console_errors(page: Page, sink: list[str] | None = None) -> list[str]: + """注册控制台错误收集(失败诊断时打印最近几条)。 + + :param sink: 可选的外部列表(如脚本的模块级变量,供 main() 的失败路径读取) + """ + errors = sink if sink is not None else [] + page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None) + page.on("pageerror", lambda exc: errors.append(str(exc))) + return errors + + +def toast_text(page: Page) -> str: + """当前显示的全局提示文本(el-message),无提示时为空串。""" + locator = page.locator(".el-message") + return locator.first.text_content().strip() if locator.count() else "" + + +def wait_toast(page: Page, expected: str, timeout_ms: int = 5000) -> str: + """等待包含 expected 的全局提示出现,返回其文本(超时返回最后见到的非空提示)。 + + 直接读 `toast_text` 会在「上一条提示尚未消失、新提示尚未出现」的窗口里 + 读到旧文本,产生误判;轮询等待把这类时序问题挡在断言之外。 + 返回值语义:匹配成功返回该条;超时返回**见过的最后一条非空提示** —— + toast 只有 3 秒生命周期,等到超时才回来断言的场景(如「请求已 400、 + 错误 toast 一闪而过」)若返回最后一次的空串,诊断信息会全部丢失。 + """ + deadline = time.monotonic() + timeout_ms / 1000 + last_seen = "" + while time.monotonic() < deadline: + text = toast_text(page) + if text: + last_seen = text + if expected in text: + return text + page.wait_for_timeout(200) + return last_seen + + +def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) -> None: + """断言出现了包含指定文本的全局提示。""" + text = wait_toast(page, expected, timeout_ms) + if expected not in text: + fail(step, f"未出现提示「{expected}」,当前提示:{text or '(无)'}") + + +def login(page: Page, username: str, password: str) -> None: + """完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入首页。 + + 验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。 + """ + captcha_ids: list[str] = [] + + def on_response(response) -> None: + if "/api/v1/auth/captcha" not in response.url or response.status != 200: + return + try: + captcha_ids.append(response.json()["data"]["captcha_id"]) + except Exception: + # 页面导航后响应体已不可读(会抛协议错误),属可忽略的噪声 + pass + + page.on("response", on_response) + try: + page.goto(f"{BASE_URL}/login", wait_until="networkidle") + for _ in range(50): + if captcha_ids: + break + page.wait_for_timeout(100) + if not captcha_ids: + fail("登录", "未捕获到验证码请求") + + captcha_id = captcha_ids[-1] + answer = redis_get(f"ys:captcha:{captcha_id}") + if not answer: + fail("登录", f"Redis 中未找到验证码 ys:captcha:{captcha_id}") + + page.fill('input[autocomplete="username"]', username) + page.fill('input[autocomplete="current-password"]', password) + page.fill('input[autocomplete="off"]', answer) + page.click("button:has-text('登 录')") + page.wait_for_timeout(2000) + if current_path(page) != "/dashboard": + detail = f"未跳转到首页,当前 {page.url}" + if message := toast_text(page): + detail += f",提示:{message}" + fail("登录", detail) + page.wait_for_load_state("networkidle") + finally: + page.remove_listener("response", on_response) + + +# ==================== 列表页 UI 操作(base_data/system_management 共用) ==================== + + +def open_menu(page: Page, title: str, group: str) -> None: + """点击侧边菜单项,必要时先展开指定分组。 + + 分组(el-sub-menu)已展开时再次点击会收拢,因此先判断菜单项是否可见。 + """ + menu = page.locator(".app-menu") + item = menu.locator(".el-menu-item", has_text=title).first + if not item.is_visible(): + menu.locator(".el-sub-menu__title", has_text=group).click() + page.wait_for_timeout(400) + item.click() + page.wait_for_timeout(800) + + +def fill_filter(page: Page, label: str, value: str) -> None: + """在**当前可见**的筛选表单中按标签填写输入框。 + + 标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」; + 选择器带 `:visible` —— 同页存在多个表单实例(v-show 隐藏未销毁)时 + 会命中隐藏元素。 + """ + form_item = page.locator( + f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))' + ).first + form_item.locator("input").first.fill(value) + + +def select_filter_option(page: Page, label: str, option: str) -> None: + """展开可见筛选表单中指定标签的下拉并选择选项。""" + item = page.locator( + f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))' + ) + item.locator(".el-select").click() + page.wait_for_timeout(300) + page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click() + page.wait_for_timeout(200) + + +def click_query(page: Page) -> None: + """点击可见筛选表单中的查询按钮。""" + page.locator(".filter-form:visible button:has-text('查询')").first.click() + page.wait_for_timeout(800) + + +def table_rows(page: Page): + """当前可见的表格行(选择器带 :visible,避开隐藏实例)。""" + return page.locator(".el-table__body .el-table__row:visible") + + +def visible_dialog(page: Page): + """当前可见的对话框(同页可能存在多个 dialog 实例,按可见性取最后一个)。""" + return page.locator(".el-dialog:visible").last + + +def confirm_message_box(page: Page, button_text: str) -> None: + """点击确认框(ElMessageBox)中的指定按钮。""" + page.locator(".el-message-box:visible").locator(f"button:has-text('{button_text}')").click() + page.wait_for_timeout(800) diff --git a/frontend/e2e/login_flow.py b/frontend/e2e/login_flow.py index 8fbfa89..110e4db 100644 --- a/frontend/e2e/login_flow.py +++ b/frontend/e2e/login_flow.py @@ -1,4 +1,4 @@ -"""浏览器端端到端验证:登录 + 图形验证码。 +"""浏览器端到端验证:登录 + 图形验证码。 验证的是**只有真实浏览器才能暴露**的问题: @@ -20,53 +20,32 @@ cd frontend && python e2e/login_flow.py 依赖:Python `playwright` 包与 chromium(`python -m playwright install chromium`)。 +公共工具见 `e2e/common.py`。 """ import os -import subprocess -import sys from playwright.sync_api import Page, sync_playwright -BASE_URL = "http://127.0.0.1:5173" +from common import ( + BASE_URL, + E2EFailure, + collect_console_errors, + current_path, + fail, + redis_get, + toast_text, +) + USERNAME = "admin" -PASSWORD = "Admin@2026" -REDIS_CONTAINER = "ys-redis-dev" - - -def _redis_password() -> str: - """从 docker/.env 读取 Redis 密码。 - - 测试脚本无法用应用配置(那需要后端 venv),故直接读环境文件。 - """ - if value := os.environ.get("REDIS_PASSWORD"): - return value - env_file = os.path.join(os.path.dirname(__file__), "..", "..", "docker", ".env") - with open(env_file, encoding="utf-8") as handle: - for line in handle: - if line.startswith("REDIS_PASSWORD="): - return line.split("=", 1)[1].strip() - raise RuntimeError("未能从 docker/.env 读取 REDIS_PASSWORD") - - -def redis_get(key: str) -> str | None: - """读取 Redis 键值。仅用于自动化测试 —— 生产当然不能这样取验证码答案。""" - result = subprocess.run( - ["docker", "exec", REDIS_CONTAINER, "redis-cli", "-a", _redis_password(), - "--no-auth-warning", "get", key], - capture_output=True, text=True, encoding="utf-8", check=False, - ) - return result.stdout.strip() or None - - -def _fail(step: str, detail: str) -> None: - print(f" ✗ {step}:{detail}") - sys.exit(1) +# 与 system_management 同一约定:开发库 admin 密码从环境变量传入(各库不同, +# 硬编码会在他机上假绿为「凭据错误」),缺省保留历史默认值兜底本机老库 +PASSWORD = os.environ.get("E2E_ADMIN_PASSWORD", "Admin@2026") def run(page: Page) -> None: captured_captcha_ids: list[str] = [] - console_errors: list[str] = [] + console_errors = collect_console_errors(page) def on_response(response) -> None: if "/api/v1/auth/captcha" not in response.url or response.status != 200: @@ -79,27 +58,11 @@ def run(page: Page) -> None: pass page.on("response", on_response) - page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) - page.on("pageerror", lambda exc: console_errors.append(str(exc))) - - def at_dashboard() -> bool: - """判断是否真的在首页。 - - 不能用 `**/dashboard` 这类 glob —— 它会把 - `/login?redirect=/dashboard` 也匹配上,导致"登录失败"被误判为成功。 - """ - from urllib.parse import urlparse - - return urlparse(page.url).path == "/dashboard" - - def toast_text() -> str: - locator = page.locator(".el-message") - return locator.first.text_content().strip() if locator.count() else "" # ---- 1. 路由守卫:未登录访问首页应被拦回登录页 ---- page.goto(BASE_URL, wait_until="networkidle") if "/login" not in page.url: - _fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}") + fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}") print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}") # ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)---- @@ -107,9 +70,9 @@ def run(page: Page) -> None: image.wait_for(state="visible", timeout=10_000) src = image.get_attribute("src") or "" if not src.startswith("data:image/png;base64,"): - _fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}") + fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}") if len(src) < 1000: - _fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染") + fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染") print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)") # ---- 3. 输错验证码 → 应提示且验证码自动刷新 ---- @@ -121,38 +84,38 @@ def run(page: Page) -> None: page.wait_for_timeout(1500) if "/login" not in page.url: - _fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}") + fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}") second_id = captured_captcha_ids[-1] if second_id == first_id: - _fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」") + fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」") print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)") # ---- 4. 正确登录 ---- answer = redis_get(f"ys:captcha:{second_id}") if not answer: - _fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}") + fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}") page.fill('input[autocomplete="off"]', answer) page.click("button:has-text('登 录')") page.wait_for_timeout(2000) - if not at_dashboard(): + if current_path(page) != "/dashboard": detail = f"仍停留在 {page.url}" - if message := toast_text(): + if message := toast_text(page): detail += f",提示:{message}" if console_errors: detail += f",控制台错误:{console_errors[:3]}" - _fail("登录", detail) + fail("登录", detail) page.wait_for_load_state("networkidle") print(f"✓ 4. 登录成功 跳转到 {page.url}") if USERNAME not in page.content(): - _fail("用户信息", "首页未显示当前用户名") + fail("用户信息", "首页未显示当前用户名") print("✓ 5. 用户信息 首页已展示当前账号") # ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ---- page.reload(wait_until="networkidle") - if "/dashboard" not in page.url: - _fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}") + if current_path(page) != "/dashboard": + fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}") print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)") # ---- 7. 登出 → 令牌立即失效 ---- @@ -162,8 +125,8 @@ def run(page: Page) -> None: page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle") page.wait_for_timeout(1000) - if at_dashboard(): - _fail("登出失效", "登出后仍能访问受保护页面") + if current_path(page) == "/dashboard": + fail("登出失效", "登出后仍能访问受保护页面") print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页") @@ -174,9 +137,14 @@ def main() -> int: page.set_default_timeout(10_000) try: run(page) + except E2EFailure as exc: + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ {exc}") + print(" 已保存截图 e2e/failure.png") + return 1 except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因 page.screenshot(path="e2e/failure.png", full_page=True) - print(f"\n✗ 执行中断:{type(exc).__name__}: {exc}") + print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") print(" 已保存截图 e2e/failure.png") return 1 finally: diff --git a/frontend/e2e/param_management.py b/frontend/e2e/param_management.py new file mode 100644 index 0000000..2da1ee5 --- /dev/null +++ b/frontend/e2e/param_management.py @@ -0,0 +1,145 @@ +"""计提参数管理端到端测试(需要容器、后端、前端都在运行)。 + +覆盖链路(M1 验收):目录分组展示 → 修改参数入「待提交」→ 批次提交(立即 +生效)→ 目录反映新值 → 版本记录可见 → 计算预览金标准复现(27 天 / 9.7‰ / +145.5 元)→ 值改回种子值恢复现场。 + +运行:``E2E_ADMIN_PASSWORD=... python e2e/param_management.py`` +""" + +import sys + +sys.path.insert(0, __import__("os").path.dirname(__file__)) + +from playwright.sync_api import sync_playwright + +from common import ( + BASE_URL, + E2EFailure, + collect_console_errors, + expect_toast, + fail, + login, + wait_toast, +) + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +PARAMS_URL = f"{BASE_URL}/system/params" +console_errors: list[str] = [] + + +def step(title: str) -> None: + print(f"—— {title}") + + +def main() -> int: + failures: list[str] = [] + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page(viewport={"width": 1440, "height": 860}) + errors = collect_console_errors(page, console_errors) + + try: + admin_password = __import__("os").environ.get("E2E_ADMIN_PASSWORD") + if not admin_password: + print("✗ 缺少环境变量 E2E_ADMIN_PASSWORD(管理员密码不入库)") + return 1 + login(page, "admin", admin_password) + page.goto(PARAMS_URL, wait_until="networkidle") + + step("1. 目录分组展示") + if page.locator(".group-title", has_text="支付环节").count() == 0: + fail("目录分组", "未找到「支付环节」分组") + # 展开「支付环节」分组 + page.locator(".el-collapse-item__header", has_text="支付环节").click() + page.wait_for_timeout(300) + + step("2. 修改预付款比例 10 → 12(立即生效)") + row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first + row.get_by_role("button", name="改", exact=True).click() + dialog = page.locator(".el-dialog") + dialog.locator("input").fill("12") + dialog.get_by_role("button", name="加入待提交").click() + if "待改为 12" not in page.content(): + fail("待提交", "参数行未出现「待改为 12」标记") + + step("3. 提交批次") + page.get_by_role("button", name="提交变更").click() + page.locator(".el-message-box").get_by_role("button", name="提交").click() + expect_toast(page, "已提交", "提交批次") + + step("4. 目录反映新值") + page.wait_for_timeout(500) + row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first + if "12" not in row.text_content(): + fail("目录生效", "提交后目录未显示新值 12") + + step("5. 版本记录可见") + page.get_by_role("button", name="版本管理").click() + page.wait_for_timeout(500) + first_version = page.locator(".el-drawer .el-table tbody tr").first.text_content() + if "P2026" not in first_version: + fail("版本记录", f"最新记录异常:{first_version}") + page.keyboard.press("Escape") + page.wait_for_timeout(300) + + step("6. 恢复种子值 10") + row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first + row.get_by_role("button", name="改", exact=True).click() + dialog = page.locator(".el-dialog") + dialog.locator("input").fill("10") + dialog.get_by_role("button", name="加入待提交").click() + page.get_by_role("button", name="提交变更").click() + page.locator(".el-message-box").get_by_role("button", name="提交").click() + wait_toast(page, "已提交") + page.wait_for_timeout(400) + + step("7. 计算预览金标准(27 天 / 9.7‰ / 145.5)") + page.get_by_role("button", name="计算预览").click() + page.wait_for_timeout(600) + # 多个抽屉组件常驻 DOM(隐藏未销毁),按标题过滤出预览抽屉 + drawer = page.locator(".el-drawer").filter(has_text="计算预览").last + # 岗位下拉默认 salesperson(v-model 初始值),无需操作 + drawer.locator(".el-form-item", has_text="发货日").locator("input").click() + page.keyboard.type("2025-12-15") + page.keyboard.press("Enter") + drawer.locator(".el-form-item", has_text="回款到账日").locator("input").click() + page.keyboard.type("2026-01-10") + page.keyboard.press("Enter") + drawer.locator(".el-form-item", has_text="销售额").locator("input").fill("100000") + drawer.locator(".el-form-item", has_text="毛利润额").locator("input").fill("45000") + drawer.locator(".el-form-item", has_text="本笔回款").locator("input").fill("60000") + drawer.get_by_role("button", name="试 算").click() + page.wait_for_timeout(900) + result_text = drawer.locator(".preview-result").text_content() + for expected in ["27 天", "145.5", "97/10000"]: + if expected not in result_text: + fail("预览金标准", f"结果缺少 {expected}:{result_text[:200]}") + + except E2EFailure as exc: + failures.append(str(exc)) + page.screenshot(path="e2e/failure.png") + except Exception as exc: # noqa: BLE001 + import traceback + traceback.print_exc() + failures.append(f"未预期异常:{exc}") + page.screenshot(path="e2e/failure.png") + finally: + browser.close() + + if failures: + print(f"✗ {failures[0]}") + if console_errors: + print(f" 控制台错误(最近 3 条):{console_errors[-3:]}") + return 1 + + print("✓ 计提参数管理端到端:7 步全部通过(目录/待提交/提交/生效/版本/恢复/预览金标准)") + if console_errors: + print(f" (控制台非致命消息 {len(console_errors)} 条,见上方日志)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/e2e/report_flow.py b/frontend/e2e/report_flow.py new file mode 100644 index 0000000..1998fbf --- /dev/null +++ b/frontend/e2e/report_flow.py @@ -0,0 +1,300 @@ +"""报表统计端到端验证(M4 ⑤:四张报表 + 打印视图)。 + +覆盖真实浏览器链路(接口测试守不住的部分): +- 「报表统计」组四页菜单可见、可进入 +- 个人明细:API 预置的已核算业务出现在列表;导出触发真实下载 +- 部门汇总:经理团队行出现且团队基数非 0;全公司 5% 卡片可见 +- 业务统计:关键字筛选命中;三列统计非空 +- 回款统计:8 档区间表;目标桶(61-90 天)有金额;空桶显示 0 +- 打印视图:独立路由带出报表与签字栏;window.print 被调用 + +前置(缺一不可): + docker compose up -d + cd backend && uv run uvicorn app.main:app --port 8000 + cd frontend && pnpm dev + cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/report_flow.py + +数据说明:核算后数据不可删(业务规则),按 `e2e_` 前缀留档开发库; +每次运行用随机后缀隔离,断言以本次业务编号定位。 +""" + +import json +import os +import time +import urllib.error +import urllib.request + +from playwright.sync_api import Page, sync_playwright + +from common import ( + BASE_URL, + E2EFailure, + collect_console_errors, + current_path, + fail, + login, +) + +ADMIN = "admin" +SUFFIX = f"{int(time.time())}" +console_errors: list[str] = [] +MONTH = time.strftime("%Y-%m") + + +def _api(method: str, path: str, token: str, body: dict | None = None) -> dict: + """带令牌的轻量 API 调用(urllib,避免额外依赖)。""" + request = urllib.request.Request( + f"{BASE_URL}{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 step_login(page: Page, admin_password: str) -> None: + login(page, ADMIN, admin_password) + print("✓ 1. 登录") + + +def step_setup_via_api(page: Page) -> dict: + """预置:经理 + 业务员 → 客户/业务 → 发货(60 天前)→ 回款 → 核算。""" + token = page.evaluate("localStorage.getItem('ys_token')") + if not token: + fail("API 预置", "localStorage 中未取到 ys_token") + + roles = _api("GET", "/api/v1/system/business-roles?pageNum=1&pageSize=50", token)["list"] + salesperson_role = next((r["id"] for r in roles if r["role_code"] == "salesperson"), None) + manager_role = next((r["id"] for r in roles if r["role_code"] == "sales_manager"), None) + if salesperson_role is None or manager_role is None: + fail("API 预置", "未找到 salesperson / sales_manager 岗位种子") + + manager = _api( + "POST", + "/api/v1/base/personnel", + token, + { + "personnel_name": f"e2e_报表现经理_{SUFFIX}", + "business_role_id": manager_role, + "salary_standard": "3000.00", + }, + ) + person = _api( + "POST", + "/api/v1/base/personnel", + token, + { + "personnel_name": f"e2e_报表现业务员_{SUFFIX}", + "business_role_id": salesperson_role, + "salary_standard": "3000.00", + "manager_id": manager["id"], + }, + ) + customer = _api( + "POST", + "/api/v1/base/customers", + token, + { + "company_name": f"e2e_报表客户_{SUFFIX}", + "owner_name": "张老板", + "customer_type": "personal", + }, + ) + business = _api( + "POST", + "/api/v1/business/businesses", + token, + { + "customer_id": customer["id"], + "business_date": f"{MONTH}-01", + "owner_personnel_id": person["id"], + "items": [ + { + "product_name": f"e2e_报表产品_{SUFFIX}", + "product_category": "municipal_water", + "quantity": "100", + "sale_price": "100.00", + "cost_amount": "3000.00", + } + ], + }, + ) + from datetime import date, timedelta + + ship_date = (date.today() - timedelta(days=60)).isoformat() + shipment = _api( + "POST", + "/api/v1/business/shipments", + token, + { + "business_id": business["id"], + "ship_date": ship_date, + "items": [{"product_name": "e2e_报表发货", "quantity": "1", "sale_price": "1.00"}], + }, + ) + receipt = _api( + "POST", + "/api/v1/business/receipts", + token, + { + "business_id": business["id"], + "receipt_date": date.today().isoformat(), + "amount": "10000.00", + "shipment_id": shipment["id"], + }, + ) + _api("POST", f"/api/v1/calc/receipts/{receipt['id']}/calc", token) + print(f"✓ 2. API 预置 业务 {business['business_no']} 已核算") + return { + "month": MONTH, + "business_no": business["business_no"], + "personnel_name": person["personnel_name"], + "manager_name": manager["personnel_name"], + } + + +def step_personal_detail(page: Page, ctx: dict) -> None: + page.goto(f"{BASE_URL}/reports/personal-detail", wait_until="networkidle") + page.wait_for_timeout(800) + if current_path(page) != "/reports/personal-detail": + fail("导航", f"未进入个人绩效明细页,当前 {page.url}") + + # 月份筛选:Element Plus 月份选择器可直接键入 YYYY-MM + month_input = page.locator(".filters input").first + month_input.fill(ctx["month"]) + month_input.press("Enter") + page.locator(".filters button:has-text('查询')").first.click() + page.wait_for_timeout(900) + + row = page.locator(".el-table__row", has_text=ctx["business_no"]).first + if row.count() == 0: + fail("个人明细", f"未找到业务 {ctx['business_no']} 的明细行") + print("✓ 3. 个人明细 列表命中本次核算业务") + + with page.expect_download(timeout=20000) as download_info: + page.locator("button:has-text('导出 Excel')").first.click() + download = download_info.value + if not download.suggested_filename.endswith(".xlsx"): + fail("导出", f"下载文件名非 xlsx:{download.suggested_filename}") + print(f"✓ 4. 导出下载 {download.suggested_filename}") + + +def step_department_summary(page: Page, ctx: dict) -> None: + page.goto(f"{BASE_URL}/reports/department-summary", wait_until="networkidle") + page.wait_for_timeout(900) + page.locator(".filters button:has-text('查询')").first.click() + page.wait_for_timeout(900) + + row = page.locator(".el-table__row", has_text=ctx["manager_name"]).first + if row.count() == 0: + fail("部门汇总", f"未找到经理 {ctx['manager_name']} 的团队行") + if page.locator(".company-card").count() == 0: + fail("部门汇总", "全公司 5% 卡片未渲染") + print("✓ 5. 部门汇总 团队行与全公司卡片可见") + + +def step_business_stats(page: Page, ctx: dict) -> None: + page.goto(f"{BASE_URL}/reports/business-stats", wait_until="networkidle") + page.wait_for_timeout(800) + keyword = page.locator(".filters input").first + keyword.fill(ctx["business_no"]) + keyword.press("Enter") + page.locator(".filters button:has-text('查询')").first.click() + page.wait_for_timeout(800) + + row = page.locator(".el-table__row", has_text=ctx["business_no"]).first + if row.count() == 0: + fail("业务统计", f"未找到业务 {ctx['business_no']}") + print("✓ 6. 业务统计 关键字筛选命中") + + +def step_payment_stats(page: Page, ctx: dict) -> None: + page.goto(f"{BASE_URL}/reports/payment-stats", wait_until="networkidle") + page.wait_for_timeout(900) + page.locator(".filters button:has-text('查询')").first.click() + page.wait_for_timeout(900) + + rows = page.locator(".el-table__row") + if rows.count() != 8: + fail("回款统计", f"区间表应为 8 行,实际 {rows.count()}") + bucket = page.locator(".el-table__row", has_text="61-90 天").first + if bucket.count() == 0: + fail("回款统计", "未找到 61-90 天区间行") + bucket_text = bucket.inner_text() + if "10000.00" not in bucket_text: + fail("回款统计", f"61-90 天桶回款金额不符:{bucket_text[:120]}") + print("✓ 7. 回款统计 8 档区间与目标桶金额正确") + + +def step_print_view(page: Page, ctx: dict) -> None: + page.add_init_script("window.print = () => { window.__printed = true }") + page.goto( + f"{BASE_URL}/reports/print/personal-detail?settlement_month={ctx['month']}", + wait_until="networkidle", + ) + page.wait_for_timeout(1200) + if page.locator(".sheet-table").count() == 0: + fail("打印视图", "报表表格未渲染") + text = page.locator(".print-page").inner_text() + if "财务复核" not in text or "领导签字" not in text: + fail("打印视图", "签字栏缺失") + page.locator("button:has-text('打印')").first.click() + page.wait_for_timeout(400) + if page.evaluate("window.__printed") is not True: + fail("打印视图", "window.print 未被调用") + print("✓ 8. 打印视图 表格/签字栏/打印调用均正常") + + +def run(page: Page) -> None: + collect_console_errors(page, console_errors) + admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "") + if not admin_password: + fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)") + + step_login(page, admin_password) + ctx = step_setup_via_api(page) + step_personal_detail(page, ctx) + step_department_summary(page, ctx) + step_business_stats(page, ctx) + step_payment_stats(page, ctx) + step_print_view(page, ctx) + + if console_errors: + print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})") + + +def main() -> int: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1600, "height": 1000}) + page.set_default_timeout(15000) + try: + run(page) + except E2EFailure as exc: + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ {exc}") + print(" 已保存截图 e2e/failure.png") + return 1 + except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因 + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") + return 1 + finally: + browser.close() + print("\n全部通过。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/e2e/secondary_allocation_flow.py b/frontend/e2e/secondary_allocation_flow.py new file mode 100644 index 0000000..0276cdc --- /dev/null +++ b/frontend/e2e/secondary_allocation_flow.py @@ -0,0 +1,234 @@ +"""二次分配方案录入端到端验证。 + +覆盖的链路(只有真实浏览器才能验证的组合): +- 登录 → 业务管理 → 新增业务 → 在「二次分配方案」区添加两行参与人 + 并填比例 → 提交成功 +- 详情/编辑回显:再次打开编辑抽屉,二次分配两行仍在(全量替换语义) +- 合计提示:两行比例非满额时展示「余额…(归属待业务确认)」 +- 数据清理:删除本次创建的业务(t_ 前缀客户/业务,走界面删除) + +运行前置(缺一不可): + docker compose up -d # MySQL + Redis + cd backend && uv run uvicorn app.main:app --port 8000 + cd frontend && pnpm dev + cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/secondary_allocation_flow.py +""" + +import os +import time + +from playwright.sync_api import Page, sync_playwright + +from common import ( + BASE_URL, + E2EFailure, + collect_console_errors, + confirm_message_box, + current_path, + expect_toast, + fail, + login, + visible_dialog, +) + +ADMIN = "admin" +SUFFIX = f"{int(time.time())}" +console_errors: list[str] = [] + + +def step_navigate_to_business(page: Page, admin_password: str) -> None: + login(page, ADMIN, admin_password) + # 路由直达业务管理:比点菜单更稳(open_menu 展开分组的两段点击可能与 + # 弹窗关闭动画竞争,导致后续点击被残留 overlay 拦截) + page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle") + page.wait_for_timeout(600) + if current_path(page) != "/business/businesses": + fail("菜单导航", f"未进入业务管理页,当前 {page.url}") + print("✓ 1. 菜单导航 进入业务管理页") + + +def step_create_customer_via_ui(page: Page) -> str: + """经客户管理页建一个 t_ 客户(业务必选引用),返回客户名。""" + page.goto(f"{BASE_URL}/base/customers", wait_until="networkidle") + page.wait_for_timeout(600) + page.click("button:has-text('新增客户')") + dialog = visible_dialog(page) + customer_name = f"t_e2e客户_{SUFFIX}" + dialog.locator('input[placeholder="与负责人构成组合唯一键"]').fill(customer_name) + dialog.locator('input[placeholder="客户公司的负责人姓名"]').fill("张老板") + # 客户分类:个人户(下拉在表单内,需先点开) + dialog.locator(".el-select").first.click() + page.wait_for_timeout(300) + page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text="个人户").first.click() + page.wait_for_timeout(300) + dialog.locator("button:has-text('保存')").click() + expect_toast(page, "客户已创建", "新增客户") + page.wait_for_timeout(500) + # 关闭客户弹窗,避免残留 overlay 遮挡后续业务弹窗的菜单点击 + close_btn = dialog.locator(".el-dialog__header button").last + if close_btn.count(): + close_btn.click() + page.wait_for_timeout(400) + print(f"✓ 2. 准备客户 新建 t_ 客户 {customer_name}") + return customer_name + + +def step_create_personnel(page: Page) -> None: + """造 2 名 t_ 在职营销员(二次分配参与人下拉的选项来源)。""" + page.goto(f"{BASE_URL}/base/personnel", wait_until="networkidle") + page.wait_for_timeout(600) + for i in range(2): + page.click("button:has-text('新增人员')") + dialog = visible_dialog(page) + dialog.locator('input[placeholder="营销人员姓名"]').fill(f"t_e2e员_{SUFFIX}_{i}") + # 岗位(表单内第一个 select):选第一项(营销员种子岗位) + dialog.locator(".el-select").first.click() + page.wait_for_timeout(400) + page.locator(".el-select-dropdown:visible .el-select-dropdown__item").first.click() + page.wait_for_timeout(300) + # 月工资标准(选填,给个合法值) + dialog.locator('.el-form-item:has-text("工资标准") input').first.fill("3000.00") + dialog.locator("button:has-text('保存')").click() + expect_toast(page, "已创建", f"新增人员{i+1}") + page.wait_for_timeout(600) + print("✓ 2b. 准备人员 新建 2 名 t_ 在职营销员") + + +def step_create_business_with_allocation(page: Page, customer_name: str) -> None: + # 客户弹窗已关(上一段已关闭);直接路由直达业务管理,避免菜单点击 + page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle") + page.wait_for_timeout(600) + page.click("button:has-text('新增业务')") + dialog = visible_dialog(page) + + # 主表:客户 + 一行明细 + dialog.locator(".el-select").first.click() + page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=customer_name).first.click() + page.wait_for_timeout(300) + + # 明细行(至少 1 行):产品名 + 数量 + 单价 + # 明细表列序:产品(0)/规格(1)/单位(2)/类别(3)/数量(4)/单价(5)/成本(6)/指导价(7) + first_row = dialog.locator(".items-table tbody tr").first + first_row.locator('input[placeholder="产品名称,可手填"]').fill(f"t_e2e产品_{SUFFIX}") + first_row.locator("td").nth(4).locator("input").fill("10") + first_row.locator("td").nth(5).locator("input").fill("100.00") + + # 二次分配方案:添加两行参与人(按钮先滚动进可视区,避免被 overlay 遮挡) + for _ in range(2): + add_btn = page.locator("button:has-text('添加参与人')") + add_btn.scroll_into_view_if_needed() + add_btn.click() + page.wait_for_timeout(300) + + # 弹窗里分配表有专属类 alloc-table(与产品明细的 items-table 区分), + # 避免空分配表时 nth(1) 错位到产品明细 + alloc_rows = page.locator(".el-dialog:visible .alloc-table tbody tr") + for i in range(2): + row = alloc_rows.nth(i) + row.locator(".el-select").first.click() + page.wait_for_timeout(400) + options = page.locator(".el-select-dropdown:visible .el-select-dropdown__item") + if options.count() < 2: + fail("参与人下拉", f"可分配人员不足 2 人(需先在人员管理录入),当前 {options.count()}") + options.nth(i).click() + page.wait_for_timeout(300) + row.locator("td").nth(1).locator("input").fill("30.00" if i == 0 else "40.00") + page.wait_for_timeout(200) + + dialog.locator("button:has-text('保存')").click() + expect_toast(page, "已创建", "新增业务(含二次分配)") + page.wait_for_timeout(800) + print("✓ 3. 录入方案 新增业务并录两行参与人(30% + 40%)") + + +def step_verify_allocation_echo(page: Page) -> None: + """编辑抽屉回显:二次分配两行仍在;合计提示展示余额。""" + page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle") + page.wait_for_timeout(600) + page.click("button:has-text('查询')") + page.wait_for_timeout(500) + first_row = page.locator(".el-table__row").first + if first_row.count() == 0: + fail("业务列表", "列表为空,未能找到刚创建的业务") + first_row.locator("button:has-text('编辑')").click() + dialog = visible_dialog(page) + page.wait_for_timeout(600) + + # 二次分配表应有 2 行;合计提示:30+40=70,余额 30 + alloc_table = page.locator(".el-dialog:visible .alloc-table") + alloc_count = alloc_table.locator("tbody tr").count() + if alloc_count != 2: + fail("方案回显", f"编辑抽屉二次分配应为 2 行,实际 {alloc_count}") + + total_bar = page.locator(".el-dialog:visible .alloc-total") + body_text = total_bar.inner_text() if total_bar.count() else "" + if "余额" not in body_text: + fail("合计提示", f"两行非满额应展示余额提示,当前:{body_text or '(无提示)'}") + print(f"✓ 4. 编辑回显 二次分配 2 行保留,合计提示:{body_text.strip()}") + + dialog.locator("button:has-text('取消')").click() + page.wait_for_timeout(400) + + +def step_cleanup(page: Page) -> None: + """删除本次创建的业务与其客户(t_ 前缀,走界面删除)。""" + page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle") + page.wait_for_timeout(600) + page.click("button:has-text('查询')") + page.wait_for_timeout(500) + target = page.locator(".el-table__row", has_text=f"t_e2e客户_{SUFFIX}").first + if target.count() == 0: + print(" (清理跳过:未找到本次业务)") + return + target.locator("button:has-text('删除')").click() + # 删除确认框按钮文案为「删除」(useConfirmAction confirmButtonText) + confirm_message_box(page, "删除") + page.wait_for_timeout(600) + print("✓ 5. 数据清理 已删除本次创建的 t_ 业务") + + +def run(page: Page) -> None: + collect_console_errors(page, console_errors) + admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "") + if not admin_password: + fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)") + + step_navigate_to_business(page, admin_password) + customer_name = step_create_customer_via_ui(page) + try: + step_create_personnel(page) + step_create_business_with_allocation(page, customer_name) + step_verify_allocation_echo(page) + finally: + step_cleanup(page) + + if console_errors: + print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})") + + +def main() -> int: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + # 弹窗较宽(业务表单含明细/二次分配两表),窄视口会把右侧操作按钮 + # 挤出可视区、被 overlay 遮挡;给到 1600 宽避免遮挡导致点击超时 + page = browser.new_page(viewport={"width": 1600, "height": 1000}) + page.set_default_timeout(12000) + try: + run(page) + except E2EFailure as exc: + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ {exc}") + print(" 已保存截图 e2e/failure.png") + return 1 + except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因 + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") + return 1 + finally: + browser.close() + print("\n全部通过。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/e2e/settings_flow.py b/frontend/e2e/settings_flow.py new file mode 100644 index 0000000..a269305 --- /dev/null +++ b/frontend/e2e/settings_flow.py @@ -0,0 +1,127 @@ +"""公司信息设置端到端验证。 + +覆盖的链路(只有真实浏览器才能验证的组合): +- 登录 → 系统管理分组出现「公司信息」菜单 → 进入设置页 +- 修改公司名称并保存 → 全局提示「已保存」 +- 退出登录 → 登录页品牌位显示**新**公司名(public 端点即时生效) +- 还原公司名(finally:防污染 login_flow/system_management 的品牌名断言) + +运行前置(缺一不可): + docker compose up -d # MySQL + Redis + cd backend && uv run uvicorn app.main:app --port 8000 + cd frontend && pnpm dev + cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/settings_flow.py +""" + +import os + +from playwright.sync_api import Page, sync_playwright + +from common import ( + BASE_URL, + E2EFailure, + collect_console_errors, + current_path, + expect_toast, + fail, + login, + open_menu, +) + +ADMIN = "admin" +console_errors: list[str] = [] + + +def step_change_company_name(page: Page, admin_password: str) -> str: + """进设置页改公司名(加随机后缀避免撞库),返回新名供后续断言。""" + login(page, ADMIN, admin_password) + open_menu(page, "公司信息", "系统管理") + if current_path(page) != "/system/settings": + fail("菜单导航", f"未进入公司信息页,当前 {page.url}") + + # 随机名:单行特性下库可复用,避免与既有公司名撞车;断言不依赖「默认」前提 + new_name = f"衍水e2e{os.getpid()}{os.urandom(2).hex()}" + page.fill('input[placeholder="展示在登录页与导出页眉"]', new_name) + page.click("button:has-text('保存')") + expect_toast(page, "已保存", "修改公司名称") + print(f"✓ 1. 设置页导航 进入公司信息并保存新名称「{new_name}」") + return new_name + + +def step_logout(page: Page) -> None: + page.click("text=退出登录") + page.wait_for_url("**/login**", timeout=10000) + print("✓ 2. 退出登录 已回到登录页") + + +def step_login_page_reflects(page: Page, new_name: str) -> None: + """登录页品牌位应显示新公司名(public 端点即时生效)。 + + 等 brand 元素文本变为新名(而非固定 sleep):public 请求是异步的, + 固定等待在慢机上会偶发拿到旧值。 + """ + page.goto(f"{BASE_URL}/login", wait_until="networkidle") + try: + page.locator(".card-brand__name").filter(has_text=new_name).wait_for( + state="visible", timeout=8000 + ) + except Exception: + brand = page.locator(".card-brand__name").inner_text().strip() + fail("登录页品牌", f"品牌位未显示新名称「{new_name}」,当前:{brand}") + foot = page.locator(".login-foot").inner_text() + if new_name not in foot: + fail("登录页页脚", f"页脚未含新名称「{new_name}」,当前:{foot}") + print(f"✓ 3. 品牌即时生效 登录页品牌位与页脚已显示「{new_name}」") + + +def step_restore(page: Page, admin_password: str) -> None: + """还原默认公司名,防污染后续 e2e 的品牌名断言。""" + login(page, ADMIN, admin_password) + open_menu(page, "公司信息", "系统管理") + page.fill('input[placeholder="展示在登录页与导出页眉"]', "衍水集团") + page.click("button:has-text('保存')") + expect_toast(page, "已保存", "还原公司名称") + print("✓ 4. 数据清理 公司名已还原为「衍水集团」") + + +def run(page: Page) -> None: + collect_console_errors(page, console_errors) + admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "") + if not admin_password: + fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)") + + new_name = step_change_company_name(page, admin_password) + try: + step_logout(page) + step_login_page_reflects(page, new_name) + finally: + step_restore(page, admin_password) + + if console_errors: + print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})") + + +def main() -> int: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page() + page.set_default_timeout(10000) + try: + run(page) + except E2EFailure as exc: + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ {exc}") + print(" 已保存截图 e2e/failure.png") + return 1 + except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因 + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") + return 1 + finally: + browser.close() + print("\n全部通过。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/e2e/system_management.py b/frontend/e2e/system_management.py new file mode 100644 index 0000000..cb4ace2 --- /dev/null +++ b/frontend/e2e/system_management.py @@ -0,0 +1,348 @@ +"""系统管理端到端验证:用户 / 角色 / 日志 / 权限。 + +覆盖的链路(只有真实浏览器才能验证的组合): + +- 侧边菜单导航与「系统管理」分组 +- 用户增删改查:新建(含重名负例)、编辑、重置密码、启用/禁用、删除 +- 角色保护:内置 admin 行的禁用/删除按钮禁用;角色 CRUD 全流程 +- 操作日志:写操作后可按条件查到记录,展开详情懒加载出脱敏后的请求参数 +- 登录日志:能查到当前会话的成功登录 +- 权限:普通用户看不到「系统管理」菜单,直连管理页面被守卫弹回首页 + +**运行前置**(缺一不可):: + + docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis + cd backend && uv run uvicorn app.main:app --port 8000 # 后端 + cd frontend && pnpm dev # 前端 + +管理员密码经环境变量传入(不写死在源码里):: + + cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py + +脚本自建的测试数据(`e2e_` 前缀)在流程内清理;中途失败会残留在开发库, +可用后端集成测试的前缀清理或手工删除。公共工具见 `e2e/common.py`。 +""" + +import os +import time + +from playwright.sync_api import Page, sync_playwright + +from common import ( + click_query, + confirm_message_box, + fill_filter, + open_menu, + select_filter_option, + table_rows, + visible_dialog, + BASE_URL, + E2EFailure, + collect_console_errors, + current_path, + expect_toast, + fail, + login, + toast_text, + wait_toast, +) + +ADMIN = "admin" +E2E_PASSWORD = "E2ePwd@2026" +RESET_PASSWORD = "E2eReset@2026" + +SUFFIX = str(int(time.time())) +NEW_USERNAME = f"e2e_user_{SUFFIX}" +VIEWER_USERNAME = f"e2e_viewer_{SUFFIX}" +NEW_ROLE_CODE = f"e2e_role_{SUFFIX}" + +# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取 +console_errors: list[str] = [] + + +# ==================== 页面操作辅助 ==================== + + +def create_user_via_ui( + page: Page, username: str, real_name: str, expect_success: bool = True +) -> None: + """经界面新增用户(角色固定选择「普通用户」)。""" + page.click("button:has-text('新增用户')") + page.wait_for_timeout(500) + dialog = visible_dialog(page) + dialog.locator('input[placeholder="登录用户名,创建后不可修改"]').fill(username) + dialog.locator('input[placeholder="8–72 字节(中文一个字符约 3 字节)"]').fill(E2E_PASSWORD) + dialog.locator('input[placeholder="真实姓名"]').fill(real_name) + + # 角色多选:展开下拉后选择「普通用户」 + dialog.locator(".el-select").click() + page.wait_for_timeout(300) + page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text="普通用户").first.click() + page.wait_for_timeout(200) + dialog.locator(".el-dialog__header").click() # 收起下拉 + page.wait_for_timeout(200) + + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + if expect_success: + expect_toast(page, "用户已创建", "新增用户") + page.wait_for_timeout(600) + + +def find_user_row(page: Page, username: str): + """在用户列表中按用户名筛选并返回目标行。""" + fill_filter(page, "用户名", username) + click_query(page) + return table_rows(page).filter(has_text=username).first + + +# ==================== 流程步骤 ==================== + + +def step_user_create(page: Page, admin_password: str) -> None: + """登录、菜单导航、新增用户与重名负例。""" + login(page, ADMIN, admin_password) + open_menu(page, "用户管理", "系统管理") + if current_path(page) != "/system/users": + fail("菜单导航", f"未进入用户管理页,当前 {page.url}") + print("✓ 1. 菜单导航 经侧边菜单进入用户管理") + + create_user_via_ui(page, NEW_USERNAME, "E2E测试") + row = find_user_row(page, NEW_USERNAME) + if row.count() == 0: + fail("新增用户", f"列表中未找到新建的 {NEW_USERNAME}") + print(f"✓ 2. 新增用户 创建 {NEW_USERNAME} 并可在列表查到") + + create_user_via_ui(page, NEW_USERNAME, "E2E重复", expect_success=False) + # 用 wait_toast 而非直读:上一条「用户已创建」提示尚在停留窗口内时, + # 直读会命中它造成偶发误判 + if "已存在" not in wait_toast(page, "已存在"): + fail("重名负例", f"重复用户名未提示已存在,当前提示:{toast_text(page) or '(无)'}") + visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click() + page.wait_for_timeout(500) + print("✓ 3. 重名负例 重复用户名被拒并给出中文提示") + + +def step_user_edit_and_status(page: Page) -> None: + """编辑用户、重置密码、启用/禁用。""" + row = find_user_row(page, NEW_USERNAME) + row.locator("button:has-text('编辑')").click() + page.wait_for_timeout(500) + dialog = visible_dialog(page) + dialog.locator('input[placeholder="真实姓名"]').fill("E2E已改名") + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "已保存", "编辑用户") + row = find_user_row(page, NEW_USERNAME) + if "E2E已改名" not in row.inner_text(): + fail("编辑用户", "列表未显示修改后的姓名") + print("✓ 4. 编辑用户 姓名修改生效") + + row = find_user_row(page, NEW_USERNAME) + row.locator("button:has-text('重置密码')").click() + page.wait_for_timeout(500) + dialog = visible_dialog(page) + dialog.locator('input[placeholder="8–72 字节(中文一个字符约 3 字节)"]').fill(RESET_PASSWORD) + dialog.locator(".el-dialog__footer button:has-text('确认重置')").click() + expect_toast(page, "密码已重置", "重置密码") + page.wait_for_timeout(600) + print("✓ 5. 重置密码 提示该用户需重新登录") + + row = find_user_row(page, NEW_USERNAME) + row.locator("button:has-text('禁用')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "禁用") + expect_toast(page, "已禁用", "禁用用户") + + row = find_user_row(page, NEW_USERNAME) + if "禁用" not in row.inner_text(): + fail("禁用用户", "列表中状态未更新为禁用") + row.locator("button:has-text('启用')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "启用") + expect_toast(page, "已启用", "启用用户") + print("✓ 6. 启用/禁用 状态切换与后端联动正常") + + +def step_role_management(page: Page) -> None: + """角色保护与角色 CRUD。""" + open_menu(page, "角色管理", "系统管理") + if current_path(page) != "/system/roles": + fail("菜单导航", f"未进入角色管理页,当前 {page.url}") + + admin_row = table_rows(page).filter(has_text="系统管理员").first + if not admin_row.locator("button:has-text('禁用')").is_disabled(): + fail("角色保护", "内置 admin 角色的禁用按钮未禁用") + if not admin_row.locator("button:has-text('删除')").is_disabled(): + fail("角色保护", "内置 admin 角色的删除按钮未禁用") + print("✓ 7. 角色保护 内置 admin 行的禁用/删除按钮均禁用") + + page.click("button:has-text('新增角色')") + page.wait_for_timeout(500) + dialog = visible_dialog(page) + dialog.locator('input[placeholder="如 sales_manager,创建后不可修改"]').fill(NEW_ROLE_CODE) + dialog.locator('input[placeholder="如 销售主管"]').fill("E2E测试角色") + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "角色已创建", "新增角色") + + fill_filter(page, "关键词", NEW_ROLE_CODE) + click_query(page) + role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first + if role_row.count() == 0: + fail("新增角色", f"列表中未找到 {NEW_ROLE_CODE}") + + role_row.locator("button:has-text('编辑')").click() + page.wait_for_timeout(500) + dialog = visible_dialog(page) + dialog.locator('input[placeholder="如 销售主管"]').fill("E2E角色改名") + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "已保存", "编辑角色") + + role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first + role_row.locator("button:has-text('禁用')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "禁用") + expect_toast(page, "已禁用", "禁用角色") + print("✓ 8. 角色管理 新增/编辑/禁用全流程正常") + + +def step_logs_query(page: Page) -> None: + """操作日志(含详情懒加载与脱敏断言)与登录日志。""" + open_menu(page, "日志查询", "系统管理") + if current_path(page) != "/system/logs": + fail("菜单导航", f"未进入日志查询页,当前 {page.url}") + + fill_filter(page, "操作人", ADMIN) + select_filter_option(page, "操作", "新增") + click_query(page) + + log_row = table_rows(page).filter(has_text=NEW_USERNAME).first + if log_row.count() == 0: + fail("操作日志", f"未查到「新增用户 {NEW_USERNAME}」的审计记录") + log_row.locator(".el-table__expand-icon").click() + + # 详情按 id 懒加载:等「加载中…」被替换为内容(含脱敏标记) + detail = "" + deadline = time.monotonic() + 8 + while time.monotonic() < deadline: + detail = page.locator(".log-detail__data:visible").first.text_content() or "" + if "***" in detail: + break + page.wait_for_timeout(200) + if "***" not in detail: + fail("日志脱敏", f"展开详情未加载出已脱敏参数,当前内容:{detail[:80]}") + if E2E_PASSWORD in detail: + fail("日志脱敏", "请求参数中出现明文密码 —— 脱敏未生效") + print("✓ 9. 操作日志 写操作留痕、详情懒加载且密码已脱敏") + + page.locator(".el-tabs__item:has-text('登录日志')").click() + page.wait_for_timeout(800) + fill_filter(page, "用户名", ADMIN) + click_query(page) + if table_rows(page).filter(has_text="成功").count() == 0: + fail("登录日志", "未查到当前会话的成功登录记录") + print("✓ 10. 登录日志 当前会话登录记录可查") + + +def step_permission_check(page: Page, admin_password: str) -> None: + """普通用户菜单隐藏与路由守卫。""" + open_menu(page, "用户管理", "系统管理") + create_user_via_ui(page, VIEWER_USERNAME, "E2E访客") + page.click("text=退出登录") + page.wait_for_url("**/login**", timeout=10_000) + + login(page, VIEWER_USERNAME, E2E_PASSWORD) + if page.locator(".app-menu").get_by_text("系统管理", exact=True).count() != 0: + fail("菜单权限", "普通用户看到了「系统管理」菜单") + print("✓ 11. 菜单权限 普通用户看不到「系统管理」分组") + + page.goto(f"{BASE_URL}/system/users", wait_until="networkidle") + page.wait_for_timeout(800) + if current_path(page) != "/dashboard": + fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}") + if "需要管理员权限" not in toast_text(page): + fail("路由守卫", "直连管理页未给出权限提示") + print("✓ 12. 路由守卫 直连 /system/users 被弹回首页") + + page.click("text=退出登录") + page.wait_for_url("**/login**", timeout=10_000) + login(page, ADMIN, admin_password) + + +def step_cleanup(page: Page) -> None: + """删除测试用户与角色(用 expect_toast 校验,失败不假绿)。""" + open_menu(page, "用户管理", "系统管理") + for username in (NEW_USERNAME, VIEWER_USERNAME): + row = find_user_row(page, username) + if row.count() == 0: + continue + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "删除") + expect_toast(page, "已删除", "数据清理") + + open_menu(page, "角色管理", "系统管理") + fill_filter(page, "关键词", NEW_ROLE_CODE) + click_query(page) + role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first + if role_row.count() > 0: + role_row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "删除") + expect_toast(page, "已删除", "数据清理") + print("✓ 13. 数据清理 e2e 测试用户与角色已删除") + + +# ==================== 入口 ==================== + + +def run(page: Page) -> None: + collect_console_errors(page, console_errors) + + admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "") + if not admin_password: + fail( + "环境变量", + "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)," + "示例:E2E_ADMIN_PASSWORD=xxx python e2e/system_management.py", + ) + + step_user_create(page, admin_password) + step_user_edit_and_status(page) + step_role_management(page) + step_logs_query(page) + step_permission_check(page, admin_password) + step_cleanup(page) + + if console_errors: + print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})") + + +def main() -> int: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page() + page.set_default_timeout(10_000) + try: + run(page) + except E2EFailure as exc: + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ {exc}") + if console_errors: + print(f" 控制台错误:{console_errors[-5:]}") + print(" 已保存截图 e2e/failure.png") + return 1 + except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因 + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") + if console_errors: + print(f" 控制台错误:{console_errors[-5:]}") + print(" 已保存截图 e2e/failure.png") + return 1 + finally: + browser.close() + + print("\n全部通过。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/src/api/approvals.ts b/frontend/src/api/approvals.ts new file mode 100644 index 0000000..4a157be --- /dev/null +++ b/frontend/src/api/approvals.ts @@ -0,0 +1,91 @@ +import { request } from './request' +import type { PageData } from './types' + +/** 审批单状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回 */ +export const APPROVAL_STATUS = { + PENDING_FINANCE: 1, + PENDING_LEADER: 2, + APPROVED: 3, + REJECTED: 4, + WITHDRAWN: 5, +} as const + +export function approvalStatusLabel(status: number): string { + return ( + { + 1: '待财务复核', + 2: '待领导审核', + 3: '已通过', + 4: '已驳回', + 5: '已撤回', + }[status] ?? String(status) + ) +} + +export function approvalStatusTagType(status: number): 'primary' | 'success' | 'danger' | 'info' { + return ( + ({ 1: 'primary', 2: 'warning', 3: 'success', 4: 'danger', 5: 'info' } as const)[status] ?? + 'info' + ) as 'primary' | 'success' | 'danger' | 'info' +} + +/** 节点动作:1通过 2驳回 3自动通过(系统) 4撤回 */ +export function stepActionLabel(action: number): string { + return ({ 1: '通过', 2: '驳回', 3: '自动通过', 4: '撤回' } as const)[action] ?? String(action) +} + +/** 节点名:0 仅用于撤回留痕 */ +export function stepNodeLabel(node: number): string { + return ({ 0: '撤回', 1: '财务复核', 2: '领导审核' } as const)[node] ?? `节点${node}` +} + +export interface ApprovalStep { + id: number + node: number + action: number + opinion: string | null + operator_id: number | null + operator_name: string + create_time: string +} + +export interface ApprovalOrder { + id: number + order_no: string + /** 1二次分配方案 2月度结算(预留) */ + order_type: number + ref_id: number + refer_no: string + status: number + submitter_id: number | null + submitter_name: string + create_time: string +} + +export interface ApprovalOrderDetail extends ApprovalOrder { + steps: ApprovalStep[] +} + +export interface ApprovalQueryParams { + pageNum?: number + pageSize?: number + /** 审批单号/引用单号,模糊 */ + keyword?: string + status?: number + order_type?: number +} + +export function listApprovals(params: ApprovalQueryParams): Promise> { + return request({ url: '/v1/system/approvals', method: 'get', params }) +} + +export function getApproval(orderId: number): Promise { + return request({ url: `/v1/system/approvals/${orderId}`, method: 'get' }) +} + +export function actApproval( + orderId: number, + data: { action: 1 | 2; opinion?: string | null }, +): Promise { + return request({ url: `/v1/system/approvals/${orderId}/action`, method: 'post', data }) +} diff --git a/frontend/src/api/businessRoles.ts b/frontend/src/api/businessRoles.ts new file mode 100644 index 0000000..eaadf70 --- /dev/null +++ b/frontend/src/api/businessRoles.ts @@ -0,0 +1,90 @@ +import { request } from './request' +import type { PageData } from './types' + +/** 公式模板下拉项(含声明参数,供岗位表单展示) */ +export interface TemplateOption { + code: string + name: string + description: string + params: { code: string; name: string }[] +} + +/** 岗位角色列表项(对应后端 `BusinessRoleOut`) */ +export interface BusinessRoleItem { + id: number + role_code: string + role_name: string + calc_template: string + description: string | null + sort_order: number + status: number + /** 1内置(禁删禁停) 0自定义 */ + is_builtin: number +} + +export interface BusinessRoleQueryParams { + pageNum?: number + pageSize?: number + keyword?: string + status?: number +} + +export interface BusinessRoleCreateParams { + role_code: string + role_name: string + calc_template: string + description?: string + sort_order?: number +} + +/** 编辑:不含 role_code(创建后不可修改,编码是参数维度与模板绑定的锚点) */ +export interface BusinessRoleUpdateParams { + role_name: string + calc_template: string + description?: string + sort_order?: number +} + +/** 分页查询岗位角色(含停用) */ +export function listBusinessRoles( + params: BusinessRoleQueryParams, +): Promise> { + return request({ url: '/v1/system/business-roles', method: 'get', params }) +} + +/** 新增岗位 */ +export function createBusinessRole( + data: BusinessRoleCreateParams, +): Promise { + return request({ url: '/v1/system/business-roles', method: 'post', data }) +} + +/** 编辑岗位 */ +export function updateBusinessRole( + roleId: number, + data: BusinessRoleUpdateParams, +): Promise { + return request({ url: `/v1/system/business-roles/${roleId}`, method: 'put', data }) +} + +/** 删除岗位(内置岗位后端拒绝,前端同步禁用按钮) */ +export function deleteBusinessRole(roleId: number): Promise { + return request({ url: `/v1/system/business-roles/${roleId}`, method: 'delete' }) +} + +/** 启用 / 禁用(内置岗位后端拒绝停用) */ +export function setBusinessRoleStatus( + roleId: number, + status: number, +): Promise { + return request({ + url: `/v1/system/business-roles/${roleId}/status`, + method: 'put', + data: { status }, + }) +} + +/** 公式模板下拉(含声明参数说明) */ +export function listTemplateOptions(): Promise { + return request({ url: '/v1/system/business-roles/meta/templates', method: 'get' }) +} diff --git a/frontend/src/api/businesses.ts b/frontend/src/api/businesses.ts new file mode 100644 index 0000000..89d10df --- /dev/null +++ b/frontend/src/api/businesses.ts @@ -0,0 +1,309 @@ +import { request } from './request' +import type { PageData } from './types' + +/** + * 业务产品明细行(对应后端 `BusinessItemOut`)。 + * + * 数量与金额一律字符串直出 —— 金额红线:不经 float、不 toFixed, + * Decimal 运算收敛在后端 service 层。 + */ +export interface BusinessItemRow { + id: number + product_id: number | null + product_name: string + spec_model: string | null + unit: string | null + /** municipal_water 市政水利 / rail_transit 轨道交通 —— 行级折算锚点 */ + product_category: string + /** 数字字符串,>0 */ + quantity: string + sale_price: string | null + /** 行销售额 = 数量 × 销售单价,后端计算固化 */ + sale_amount: string + cost_amount: string | null + guided_price: string | null + remark: string | null +} + +/** 业务列表项(对应后端 `BusinessOut`;四个汇总金额均为字符串直出) */ +export interface BusinessItem { + id: number + business_no: string + customer_id: number + customer_name: string | null + /** ISO 串(YYYY-MM-DD) */ + business_date: string | null + /** 1执行中 2已完成 3已终止 */ + status: number + owner_personnel_id: number | null + owner_personnel_name: string | null + assistant_personnel_id: number | null + assistant_personnel_name: string | null + /** 特殊业务标记:1是 0否 */ + is_special: number + special_note: string | null + total_sale_amount: string | null + total_cost_amount: string | null + total_ship_amount: string | null + total_received_amount: string | null + total_freight_amount: string | null + remark: string | null +} + +/** 二次分配参与人(2026-10-15 补充需求;比例不强制合计 100%) */ +export interface SecondaryAllocationRow { + personnel_id: number + personnel_name: string + /** 百分比数字字符串(两位小数) */ + ratio: string +} + +/** 业务详情:附产品明细行与二次分配方案(编辑弹窗一次回显) */ +export interface BusinessDetail extends BusinessItem { + items: BusinessItemRow[] + secondary_allocations: SecondaryAllocationRow[] + /** 方案合计/余额提示(余额归属待澄清 #2,仅展示) */ + allocation_hint: string | null +} + +export interface BusinessQueryParams { + pageNum?: number + pageSize?: number + /** 业务编号/公司名称,模糊匹配 */ + keyword?: string + status?: number + customer_id?: number + owner_personnel_id?: number + /** 业务日期起(含),ISO 串 */ + date_from?: string + /** 业务日期止(含),ISO 串 */ + date_to?: string +} + +/** 明细行入参(product_id 空 = 自由录入;金额/数量为数字字符串) */ +export interface BusinessItemParams { + product_id?: number | null + product_name: string + spec_model?: string | null + unit?: string | null + product_category: string + quantity: string + sale_price?: string | null + cost_amount?: string | null + guided_price?: string | null + remark?: string | null +} + +/** + * 创建业务:主表信息 + 明细行(≥1)。 + * + * is_special=1 时 special_note 必填(后端校验,前端同步拦截)。 + */ +export interface BusinessCreateParams { + customer_id: number + business_date?: string | null + owner_personnel_id?: number | null + assistant_personnel_id?: number | null + is_special?: number + special_note?: string | null + items: BusinessItemParams[] + /** 二次分配方案(只出现在合同刚建立时;空=不设置) */ + secondary_allocations?: { personnel_id: number; ratio: string }[] + remark?: string | null +} + +/** + * 编辑业务:明细全量替换并重算汇总。 + * + * 不含归属字段 —— 归属调整统一走 `PUT /{id}/ownership`(唯一写归属 + * 留痕的入口),编辑表单即使提交归属也会被后端忽略。 + */ +export interface BusinessUpdateParams { + customer_id: number + business_date?: string | null + is_special?: number + special_note?: string | null + items: BusinessItemParams[] + /** 二次分配方案(全量替换;空数组=清空) */ + secondary_allocations?: { personnel_id: number; ratio: string }[] + remark?: string | null +} + +/** 状态流转入参:执行中 → 2已完成 / 3已终止;终止时 reason 必填 */ +export interface BusinessStatusParams { + status: number + reason?: string | null +} + +/** 归属变更入参(新负责/新协助;前后值写入留痕,不回溯历史) */ +export interface BusinessOwnershipParams { + new_owner_personnel_id?: number | null + new_assistant_personnel_id?: number | null + reason?: string | null +} + +/** 业务归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */ +export interface OwnershipLogItem { + id: number + business_id: number + old_owner_personnel_id: number | null + new_owner_personnel_id: number | null + old_assistant_personnel_id: number | null + new_assistant_personnel_id: number | null + old_owner_name: string | null + new_owner_name: string | null + change_reason: string | null + operator_id: number | null + operator_name: string + create_time: string +} + +/** 业务状态流转记录(对应后端 `BusinessStatusLogOut`;创建首条 old=new=1) */ +export interface StatusLogItem { + id: number + business_id: number + old_status: number + new_status: number + change_reason: string | null + operator_id: number | null + operator_name: string + create_time: string +} + +/** 毛利润视图行(对应后端 `GrossProfitRowOut`);成本缺失行派生列为 null */ +export interface GrossProfitRowItem { + item_id: number + product_category: string + product_name: string + spec_model: string | null + quantity: string + sale_amount: string + cost_amount: string | null + profit_amount: string | null + profit_rate: string | null + threshold: string + converted_profit: string | null + missing_reason: string | null +} + +/** 按产品类别的毛利润汇总(成本缺失行不计入聚合) */ +export interface GrossProfitCategory { + product_category: string + sale_amount: string + cost_amount: string + profit_amount: string + /** 分数字符串原样(如 "997/1000"),不做数值化展示 */ + profit_rate: string + threshold: string + converted_profit: string +} + +/** 毛利润数据视图(对应后端 `GrossProfitOut`) */ +export interface GrossProfitView { + business_id: number + /** 参数取值时点(默认今日),ISO 串 */ + as_of: string + rows: GrossProfitRowItem[] + categories: GrossProfitCategory[] + missing_count: number + note: string +} + +/** 业务状态下拉项(与后端值域一致;执行中=1 已完成=2 已终止=3) */ +export const BUSINESS_STATUS_OPTIONS = [ + { label: '执行中', value: 1 }, + { label: '已完成', value: 2 }, + { label: '已终止', value: 3 }, +] as const + +/** 状态编码 → 展示名 */ +export function businessStatusLabel(status: number): string { + return BUSINESS_STATUS_OPTIONS.find((o) => o.value === status)?.label ?? `状态${status}` +} + +/** 状态编码 → 标签类型(三色:执行中 primary / 已完成 success / 已终止 danger) */ +export function businessStatusTagType(status: number): 'primary' | 'success' | 'danger' | 'info' { + if (status === 2) return 'success' + if (status === 3) return 'danger' + return 'primary' +} + +/** 分页查询业务(编号/公司名称模糊,可按状态/客户/归属/日期范围筛选) */ +export function listBusinesses(params: BusinessQueryParams): Promise> { + return request({ url: '/v1/business/businesses', method: 'get', params }) +} + +/** 创建业务(编号自动生成,写首条状态留痕) */ +export function createBusiness(data: BusinessCreateParams): Promise { + return request({ url: '/v1/business/businesses', method: 'post', data }) +} + +/** 业务详情(附产品明细行) */ +export function getBusinessDetail(businessId: number): Promise { + return request({ url: `/v1/business/businesses/${businessId}`, method: 'get' }) +} + +/** 编辑业务(明细全量替换;归属不在此端点调整) */ +export function updateBusiness( + businessId: number, + data: BusinessUpdateParams, +): Promise { + return request({ url: `/v1/business/businesses/${businessId}`, method: 'put', data }) +} + +/** 删除业务(有发货或回款记录时后端拒绝,应改为已终止) */ +export function deleteBusiness(businessId: number): Promise { + return request({ url: `/v1/business/businesses/${businessId}`, method: 'delete' }) +} + +/** 状态流转(终态不可再变更;终止必填原因) */ +export function changeBusinessStatus( + businessId: number, + data: BusinessStatusParams, +): Promise { + return request({ url: `/v1/business/businesses/${businessId}/status`, method: 'put', data }) +} + +/** 调整负责/协助营销人员(同事务写归属变更留痕) */ +export function changeBusinessOwnership( + businessId: number, + data: BusinessOwnershipParams, +): Promise { + return request({ url: `/v1/business/businesses/${businessId}/ownership`, method: 'put', data }) +} + +/** 某业务的归属变更留痕(分页,按变更时间倒序) */ +export function listOwnershipLogs( + businessId: number, + params: { pageNum?: number; pageSize?: number }, +): Promise> { + return request({ + url: `/v1/business/businesses/${businessId}/ownership-logs`, + method: 'get', + params, + }) +} + +/** 某业务的状态流转时间线(分页,按时间正序,从创建起展示) */ +export function listStatusLogs( + businessId: number, + params: { pageNum?: number; pageSize?: number }, +): Promise> { + return request({ + url: `/v1/business/businesses/${businessId}/status-logs`, + method: 'get', + params, + }) +} + +/** 毛利润数据视图(as_of 缺省=今日;阈值按该时点参数快照) */ +export function getGrossProfit( + businessId: number, + asOf?: string, +): Promise { + return request({ + url: `/v1/business/businesses/${businessId}/gross-profit`, + method: 'get', + params: asOf ? { as_of: asOf } : undefined, + }) +} diff --git a/frontend/src/api/calc.ts b/frontend/src/api/calc.ts new file mode 100644 index 0000000..61c7061 --- /dev/null +++ b/frontend/src/api/calc.ts @@ -0,0 +1,70 @@ +import { request } from './request' +import type { PageData } from './types' + +/** 绩效台账行(金额/比例一律 str) */ +export interface LedgerRow { + id: number + receipt_id: number | null + calc_version: number + is_active: number + calc_type: number + business_id: number + business_no: string + personnel_id: number + personnel_name: string + role_code: string + customer_type: string + product_category: string + ship_date: string | null + receipt_date: string + payment_days: number | null + rate_text: string + converted_profit: string | null + performance_amount: string | null + barter_applied: number + weight_r: number | null + payment_weight: string | null + payable_amount: string | null + settlement_month: string + is_probation: number + missing_cost: number + snapshot_json: Record | null + calc_reason: string | null +} + +export interface LedgerQueryParams { + pageNum?: number + pageSize?: number + settlement_month?: string + personnel_id?: number + business_id?: number + is_active?: number + calc_type?: number +} + +export function listLedger(params: LedgerQueryParams): Promise> { + return request({ url: '/v1/calc/ledger', method: 'get', params }) +} + +export function getLedger(id: number): Promise { + return request({ url: `/v1/calc/ledger/${id}`, method: 'get' }) +} + +/** 核算回款:返回台账行 + repeated(业务级一次性计提标记) */ +export function calcReceipt(receiptId: number): Promise<{ ledger: LedgerRow; repeated: boolean }> { + return request({ url: `/v1/calc/receipts/${receiptId}/calc`, method: 'post' }) +} + +export function recalcReceipt(receiptId: number, reason: string): Promise { + return request({ + url: `/v1/calc/receipts/${receiptId}/recalc`, + method: 'post', + data: { reason }, + }) +} + +export function batchCalc(receiptIds: number[]): Promise< + { receipt_id: number; ok: boolean; repeated?: boolean; error?: string }[] +> { + return request({ url: '/v1/calc/batch', method: 'post', data: { receipt_ids: receiptIds } }) +} diff --git a/frontend/src/api/customers.ts b/frontend/src/api/customers.ts new file mode 100644 index 0000000..812721c --- /dev/null +++ b/frontend/src/api/customers.ts @@ -0,0 +1,127 @@ +import { request } from './request' +import type { PageData } from './types' + +/** + * 客户列表项(对应后端 `CustomerOut`)。 + * + * owner_personnel_name 由联表带出:归属人员不可考(被物理删除)时为 null, + * owner_personnel_id 仍保留原值供追溯。 + */ +export interface CustomerItem { + id: number + company_name: string + /** 客户公司的负责人姓名(非系统内营销人员) */ + owner_name: string + contact_person: string | null + contact_phone: string | null + address: string | null + industry: string | null + /** personal 个人户 / manager_personal 经理个人户 / enterprise 企业户(计提规则锚点) */ + customer_type: string + owner_personnel_id: number | null + owner_personnel_name: string | null + remark: string | null +} + +export interface CustomerQueryParams { + pageNum?: number + pageSize?: number + /** 公司名称或客户负责人,模糊匹配 */ + keyword?: string + customer_type?: string + owner_personnel_id?: number +} + +/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */ +export interface CustomerCreateParams { + company_name: string + owner_name: string + contact_person?: string | null + contact_phone?: string | null + address?: string | null + industry?: string | null + customer_type: string + owner_personnel_id?: number | null + remark?: string | null +} + +/** + * 编辑客户:不含归属字段 —— 归属调整统一走 `PUT /{id}/ownership` + * (唯一写归属留痕的入口),编辑表单即使提交归属也会被后端忽略。 + */ +export type CustomerUpdateParams = Omit + +/** 归属变更入参(new_owner_personnel_id 空 = 清空归属) */ +export interface CustomerOwnershipParams { + new_owner_personnel_id?: number | null + reason?: string | null +} + +/** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */ +export interface OwnershipLogItem { + id: number + customer_id: number + old_owner_personnel_id: number | null + new_owner_personnel_id: number | null + old_owner_name: string | null + new_owner_name: string | null + change_reason: string | null + operator_id: number | null + operator_name: string + create_time: string +} + +/** 客户分类下拉项(与后端 `core/constants.py` 的值域一致) */ +export const CUSTOMER_TYPE_OPTIONS = [ + { label: '个人户', value: 'personal' }, + { label: '经理个人户', value: 'manager_personal' }, + { label: '企业户', value: 'enterprise' }, +] as const + +/** 分类编码 → 展示名(表格列展示用,未知编码原样回显) */ +export function customerTypeLabel(code: string): string { + return CUSTOMER_TYPE_OPTIONS.find((o) => o.value === code)?.label ?? code +} + +/** 分页查询客户(keyword 对公司名称/客户负责人模糊匹配,附归属人员姓名) */ +export function listCustomers(params: CustomerQueryParams): Promise> { + return request({ url: '/v1/base/customers', method: 'get', params }) +} + +/** 新增客户(公司名称+负责人组合唯一,冲突由后端拦截) */ +export function createCustomer(data: CustomerCreateParams): Promise { + return request({ url: '/v1/base/customers', method: 'post', data }) +} + +/** 编辑客户档案(不含归属;全量提交) */ +export function updateCustomer( + customerId: number, + data: CustomerUpdateParams, +): Promise { + return request({ url: `/v1/base/customers/${customerId}`, method: 'put', data }) +} + +/** 删除客户(物理删除) */ +export function deleteCustomer(customerId: number): Promise { + return request({ url: `/v1/base/customers/${customerId}`, method: 'delete' }) +} + +/** 归属变更(唯一写归属留痕的入口;新旧相同后端报「归属未变化」) */ +export function changeOwnership( + customerId: number, + data: CustomerOwnershipParams, +): Promise { + return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data }) +} + +/** 查某客户的归属变更留痕(分页,按变更时间倒序) */ +export function listOwnershipLogs( + customerId: number, + params: { pageNum?: number; pageSize?: number }, +): Promise> { + return request({ + url: `/v1/base/customers/${customerId}/ownership-logs`, + method: 'get', + params, + }) +} diff --git a/frontend/src/api/logs.ts b/frontend/src/api/logs.ts new file mode 100644 index 0000000..dbb9284 --- /dev/null +++ b/frontend/src/api/logs.ts @@ -0,0 +1,78 @@ +import { request } from './request' +import type { PageData } from './types' + +/** 操作日志列表项(对应后端 `OperationLogOut`;不含 request_data 大字段)。 */ +export interface OperationLogItem { + id: number + user_id: number | null + username: string + module: string + action: string + summary: string + method: string | null + path: string | null + ip: string | null + user_agent: string | null + /** 1成功 0失败 */ + success: number + error_message: string | null + duration_ms: number | null + create_time: string | null +} + +/** 操作日志详情(对应后端 `OperationLogDetailOut`;request_data 已脱敏) */ +export interface OperationLogDetail extends OperationLogItem { + request_data: string | null +} + +export interface OperationLogQueryParams { + pageNum?: number + pageSize?: number + username?: string + module?: string + action?: string + success?: number + /** 格式 YYYY-MM-DD HH:mm:ss(el-date-picker 需配 value-format) */ + start_time?: string + end_time?: string +} + +/** 登录日志项(对应后端 `LoginLogOut`)。 */ +export interface LoginLogItem { + id: number + user_id: number | null + username: string + ip: string | null + user_agent: string | null + /** 1成功 0失败 */ + success: number + fail_reason: string | null + create_time: string | null +} + +export interface LoginLogQueryParams { + pageNum?: number + pageSize?: number + username?: string + ip?: string + success?: number + start_time?: string + end_time?: string +} + +/** 分页查询操作日志(列表不含 request_data,详情走 getOperationLog) */ +export function listOperationLogs( + params: OperationLogQueryParams, +): Promise> { + return request({ url: '/v1/system/operation-logs', method: 'get', params }) +} + +/** 操作日志详情(含已脱敏的请求参数,展开行时按需加载) */ +export function getOperationLog(logId: number): Promise { + return request({ url: `/v1/system/operation-logs/${logId}`, method: 'get' }) +} + +/** 分页查询登录日志 */ +export function listLoginLogs(params: LoginLogQueryParams): Promise> { + return request({ url: '/v1/system/login-logs', method: 'get', params }) +} diff --git a/frontend/src/api/params.ts b/frontend/src/api/params.ts new file mode 100644 index 0000000..d8a50b0 --- /dev/null +++ b/frontend/src/api/params.ts @@ -0,0 +1,208 @@ +import service, { request } from './request' +import type { PageData } from './types' + +/** 参数目录项(含各维度截至查询时点的生效值) */ +export interface ParamDimensionValue { + dimension_value: string + value_text: string + effective_date: string | null +} + +export interface ParamCatalogItem { + param_code: string + param_name: string + param_group: string + value_type: string + unit: string | null + dimension_type: string + options: string[] + default_value: string | null + value_min: string | null + value_max: string | null + description: string | null + values: ParamDimensionValue[] +} + +/** 参数目录(asOf 缺省为今日) */ +export function getCatalog(asOf?: string): Promise { + return request({ url: '/v1/system/params', method: 'get', params: asOf ? { as_of: asOf } : {} }) +} + +/** 变更条目:参数 + 维度 + 新值 */ +export interface ParamChangeItem { + param_code: string + dimension_value: string + value: string +} + +export interface ParamChangePayload { + /** 生效方式:1立即生效 2指定日期生效 */ + change_type: number + effective_date?: string + remark?: string + items: ParamChangeItem[] +} + +export interface ParamChangeBatch { + id: number + version_no: string + effective_date: string + change_type: number + remark: string | null + operator_name: string + item_count: number + create_time: string +} + +/** 提交变更批次 */ +export function submitChanges(data: ParamChangePayload): Promise { + return request({ url: '/v1/system/params/changes', method: 'post', data }) +} + +export interface ParamBatchItem { + param_code: string + param_name: string + dimension_value: string + old_value_text: string | null + value_text: string + effective_date: string +} + +export interface ParamBatchDetail { + id: number + version_no: string + effective_date: string + change_type: number + remark: string | null + operator_name: string + item_count: number + create_time: string + items: ParamBatchItem[] +} + +/** 批次明细(旧值 → 新值) */ +export function getBatchDetail(batchId: number): Promise { + return request({ url: `/v1/system/params/changes/${batchId}`, method: 'get' }) +} + +export interface ParamBatch { + id: number + version_no: string + effective_date: string + change_type: number + remark: string | null + operator_name: string + item_count: number + create_time: string +} + +/** 变更记录分页 */ +export function listBatches(params: { + pageNum?: number + pageSize?: number + start_date?: string + end_date?: string +}): Promise> { + return request({ url: '/v1/system/params/changes', method: 'get', params }) +} + +/** 撤回未生效批次 */ +export function withdrawBatch(batchId: number): Promise { + return request({ url: `/v1/system/params/changes/${batchId}`, method: 'delete' }) +} + +export interface ParamDiff { + param_code: string + param_name: string + param_group: string + dimension_value: string + old_value: string | null + new_value: string | null + changed: boolean +} + +/** 两日期快照对比 */ +export function compareParams(fromDate: string, toDate: string): Promise { + return request({ + url: '/v1/system/params/compare', + method: 'get', + params: { from_date: fromDate, to_date: toDate }, + }) +} + +export interface ParamHistory { + version_no: string + dimension_value: string + old_value_text: string | null + value_text: string + effective_date: string + operator_name: string + create_time: string +} + +/** 单参数版本历史 */ +export function getParamHistory( + paramCode: string, + params: { pageNum?: number; pageSize?: number; dimension_value?: string }, +): Promise> { + return request({ url: `/v1/system/params/${paramCode}/history`, method: 'get', params }) +} + +/** + * 导出变更记录(文件流)。 + * + * 走 axios 实例(带令牌)而非 request 封装:响应是 xlsx 文件流, + * 不是统一信封,拦截器已对 responseType=blob 直通。 + */ +export async function exportChanges(params: { + start_date?: string + end_date?: string +}): Promise { + const response = await service.get('/v1/system/params/export', { + params, + responseType: 'blob', + }) + return response.data as Blob +} + +/** 计算预览入参(金额为字符串,Decimal 承载) */ +export interface ParamPreviewPayload { + business_role?: string + customer_type?: string + product_category?: string + ship_date: string + receipt_date: string + revenue: string + profit: string + received_amount: string + barter: boolean + probation: boolean +} + +export interface ParamUsed { + param_code: string + value_text: string + effective_date: string | null +} + +export interface ParamPreviewResult { + payment_days: number + rate: string + rate_text: string + profit_rate: string + converted_profit: string + performance_amount: string + barter_applied: boolean + performance_after_barter: string | null + start_payment_ok: boolean + payment_weight_r: number + payment_weight: string + payable_amount: string + probation_note: string | null + params_used: ParamUsed[] +} + +/** 计算预览 */ +export function previewCalc(data: ParamPreviewPayload): Promise { + return request({ url: '/v1/system/params/preview', method: 'post', data }) +} diff --git a/frontend/src/api/personnel.ts b/frontend/src/api/personnel.ts new file mode 100644 index 0000000..00b821d --- /dev/null +++ b/frontend/src/api/personnel.ts @@ -0,0 +1,150 @@ +import { request } from './request' +import type { PageData } from './types' + +/** 辐射区域条目(对应后端 `PersonnelRegionOut`) */ +export interface PersonnelRegionItem { + id: number + region_name: string + remark: string | null +} + +/** 拓展计划条目(对应后端 `PersonnelPlanOut`) */ +export interface PersonnelPlanItem { + id: number + plan_period: string + plan_content: string + target_desc: string | null +} + +/** 人员列表项(对应后端 `PersonnelOut`) */ +export interface PersonnelItem { + id: number + personnel_name: string + business_role_id: number + /** 岗位名称由联表带出 */ + role_name: string | null + manager_id: number | null + manager_name: string | null + phone: string | null + /** 入职日期,ISO 串(YYYY-MM-DD) */ + hire_date: string | null + /** 1试用期 0正式 */ + is_probation: number + /** 月工资标准(元),字符串直出 —— 金额红线:不经 float */ + salary_standard: string | null + /** 1在职 0离职 */ + status: number + remark: string | null +} + +/** 人员详情:附辐射区域与拓展计划,供编辑抽屉一次回显 */ +export interface PersonnelDetail extends PersonnelItem { + regions: PersonnelRegionItem[] + plans: PersonnelPlanItem[] +} + +export interface PersonnelQueryParams { + pageNum?: number + pageSize?: number + /** 姓名,模糊匹配 */ + keyword?: string + /** 1在职 0离职 */ + status?: number + /** 岗位角色ID */ + business_role_id?: number +} + +/** + * 新增/编辑人员(全量提交)。 + * + * 不含状态 —— 在职/离职是独立操作(PUT /{id}/status),避免编辑表单 + * 误触离职。可空字段传 `null` 表示清空,与后端 `PersonnelCreateIn` 对齐。 + */ +export interface PersonnelSaveParams { + personnel_name: string + business_role_id: number + /** 直属经理ID,空=无;后端校验不能是自己且须在职 */ + manager_id?: number | null + phone?: string | null + /** ISO 串(YYYY-MM-DD) */ + hire_date?: string | null + /** 1试用期 0正式 */ + is_probation?: number + /** 月工资标准(元),字符串形式的数字,最多两位小数 */ + salary_standard?: string | null + remark?: string | null +} + +/** 辐射区域条目入参(全量替换用) */ +export interface RegionItemParams { + region_name: string + remark?: string | null +} + +/** 拓展计划条目入参(全量替换用) */ +export interface PlanItemParams { + plan_period: string + plan_content: string + target_desc?: string | null +} + +/** 分页查询人员(含离职),姓名模糊匹配 */ +export function listPersonnel(params: PersonnelQueryParams): Promise> { + return request({ url: '/v1/base/personnel', method: 'get', params }) +} + +/** 新增人员(默认在职;岗位须存在且启用) */ +export function createPersonnel(data: PersonnelSaveParams): Promise { + return request({ url: '/v1/base/personnel', method: 'post', data }) +} + +/** 人员详情(附辐射区域与拓展计划) */ +export function getPersonnelDetail(personnelId: number): Promise { + return request({ url: `/v1/base/personnel/${personnelId}`, method: 'get' }) +} + +/** 编辑人员(全量提交,不含状态) */ +export function updatePersonnel( + personnelId: number, + data: PersonnelSaveParams, +): Promise { + return request({ url: `/v1/base/personnel/${personnelId}`, method: 'put', data }) +} + +/** 删除人员(被团队成员或客户归属引用时后端拒绝,应改为离职) */ +export function deletePersonnel(personnelId: number): Promise { + return request({ url: `/v1/base/personnel/${personnelId}`, method: 'delete' }) +} + +/** 设为在职 / 离职(离职人员不可再被业务与客户归属引用) */ +export function setPersonnelStatus(personnelId: number, status: number): Promise { + return request({ + url: `/v1/base/personnel/${personnelId}/status`, + method: 'put', + data: { status }, + }) +} + +/** 全量替换辐射区域(空列表即清空该人员的全部区域) */ +export function replacePersonnelRegions( + personnelId: number, + items: RegionItemParams[], +): Promise { + return request({ + url: `/v1/base/personnel/${personnelId}/regions`, + method: 'put', + data: { items }, + }) +} + +/** 全量替换拓展计划(空列表即清空该人员的全部计划) */ +export function replacePersonnelPlans( + personnelId: number, + items: PlanItemParams[], +): Promise { + return request({ + url: `/v1/base/personnel/${personnelId}/plans`, + method: 'put', + data: { items }, + }) +} diff --git a/frontend/src/api/products.ts b/frontend/src/api/products.ts new file mode 100644 index 0000000..8d14290 --- /dev/null +++ b/frontend/src/api/products.ts @@ -0,0 +1,91 @@ +import { request } from './request' +import type { PageData } from './types' + +/** + * 产品列表项(对应后端 `ProductOut`)。 + * + * 数量/单价为字符串直出 —— 金额红线:前端禁用 float 解析与 toFixed, + * 展示原串、提交原串,Decimal 转换收敛在后端 service 层。 + */ +export interface ProductItem { + id: number + product_name: string + /** 规格型号,可空串(与名称构成组合唯一键) */ + spec_model: string + unit: string | null + stock_quantity: string | null + sale_price: string | null + /** municipal_water 市政水利 / rail_transit 轨道交通 —— 折算阈值的行级锚点 */ + product_category: string + /** 1启用 0停用 */ + status: number + remark: string | null + create_time: string + update_time: string +} + +export interface ProductQueryParams { + pageNum?: number + pageSize?: number + /** 名称或规格,模糊匹配 */ + keyword?: string + product_category?: string + status?: number +} + +/** 新增/编辑产品(全量提交;不含状态,启停走独立端点) */ +export interface ProductSaveParams { + product_name: string + /** 空串与 None 同义(后端归一为空串以保组合唯一键生效) */ + spec_model?: string | null + unit?: string | null + /** 非负数字字符串 */ + stock_quantity?: string | null + /** 非负数字字符串 */ + sale_price?: string | null + product_category: string + remark?: string | null +} + +/** 产品类别下拉项(与后端 `core/constants.py` 的值域一致) */ +export const PRODUCT_CATEGORY_OPTIONS = [ + { label: '市政水利', value: 'municipal_water' }, + { label: '轨道交通', value: 'rail_transit' }, +] as const + +/** 类别编码 → 展示名(表格列展示用,未知编码原样回显) */ +export function productCategoryLabel(code: string): string { + return PRODUCT_CATEGORY_OPTIONS.find((o) => o.value === code)?.label ?? code +} + +/** 分页查询产品(含停用) */ +export function listProducts(params: ProductQueryParams): Promise> { + return request({ url: '/v1/base/products', method: 'get', params }) +} + +/** 新增产品(默认启用;类别须在合法值域内) */ +export function createProduct(data: ProductSaveParams): Promise { + return request({ url: '/v1/base/products', method: 'post', data }) +} + +/** 编辑产品(全量提交,不含状态;名称+规格组合唯一,冲突由后端拦截) */ +export function updateProduct( + productId: number, + data: ProductSaveParams, +): Promise { + return request({ url: `/v1/base/products/${productId}`, method: 'put', data }) +} + +/** 删除产品(物理删除,仅限误录数据;常规下架用停用) */ +export function deleteProduct(productId: number): Promise { + return request({ url: `/v1/base/products/${productId}`, method: 'delete' }) +} + +/** 启用 / 停用产品 */ +export function setProductStatus(productId: number, status: number): Promise { + return request({ + url: `/v1/base/products/${productId}/status`, + method: 'put', + data: { status }, + }) +} diff --git a/frontend/src/api/ratings.ts b/frontend/src/api/ratings.ts new file mode 100644 index 0000000..434a2d0 --- /dev/null +++ b/frontend/src/api/ratings.ts @@ -0,0 +1,60 @@ +import { request } from './request' + +/** + * 评级等级配置项(对应后端 `RatingGradeOut`)。 + * + * min_score/max_score 按分值红线以**字符串**出参(后端 str 直出), + * 前端参与数值比较前必须显式 parseFloat 转数值,禁止直接做字符串比大小。 + */ +export interface RatingGradeItem { + id: number + grade_name: string + min_score: string + max_score: string + /** 判定顺序,升序;判定取首个 min_score ≤ 总分的等级 */ + sort_order: number + remark: string | null + create_time: string + update_time: string +} + +/** + * 新增 / 编辑等级入参(编辑为全量替换语义)。 + * + * 分值以字符串承载(如 "90.0"):number 无法精确表示全部十进制小数, + * 字符串是分值红线的入口闸门,Decimal 转换统一收敛在后端 service 层。 + */ +export interface RatingGradeParams { + grade_name: string + min_score: string + max_score: string + sort_order?: number + remark?: string +} + +/** + * 全量等级列表(按 sort_order 升序返回,不分页)。 + * + * 等级映射是评级计算器的判定表,数量个位数,分页无意义。 + */ +export function listRatingGrades(): Promise { + return request({ url: '/v1/business/rating-grades', method: 'get' }) +} + +/** 新增评级等级(区间合法与不重叠由后端校验兜底,前端校验仅是展示约束) */ +export function createRatingGrade(data: RatingGradeParams): Promise { + return request({ url: '/v1/business/rating-grades', method: 'post', data }) +} + +/** 编辑评级等级(全量替换,字段与新增一致) */ +export function updateRatingGrade( + gradeId: number, + data: RatingGradeParams, +): Promise { + return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'put', data }) +} + +/** 删除评级等级(物理删除,等级表无下游引用,允许清空后由列表空态提示) */ +export function deleteRatingGrade(gradeId: number): Promise { + return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'delete' }) +} diff --git a/frontend/src/api/receipts.ts b/frontend/src/api/receipts.ts new file mode 100644 index 0000000..3dbb437 --- /dev/null +++ b/frontend/src/api/receipts.ts @@ -0,0 +1,104 @@ +import { request } from './request' +import type { PageData } from './types' + +/** + * 回款列表项(对应后端 `ReceiptOut`;字段契约纯蛇形直出)。 + * + * amount 字符串直出(金额红线);payment_days 为派生值:未指定发货 + * 批次时为 null,指定后 = 发货日到回款日的天数(发货日计第 1 天)。 + */ +export interface ReceiptItem { + id: number + receipt_batch_no: string + business_id: number + business_no: string + shipment_id: number | null + shipment_no: string | null + /** 回款到账日,ISO 串(YYYY-MM-DD);M3 核算按此日匹配参数版本 */ + receipt_date: string + /** 回款金额(元),字符串直出 */ + amount: string + /** 以物抵顶标记:1是 0否(绩效按比例折算) */ + is_barter: number + /** 核算状态:0未核算 1已核算(已核算禁改禁删) */ + settlement_status: number + payment_days: number | null + remark: string | null +} + +export interface ReceiptQueryParams { + pageNum?: number + pageSize?: number + business_id?: number + /** 回款到账日起(含),ISO 串 */ + date_from?: string + /** 回款到账日止(含),ISO 串 */ + date_to?: string + is_barter?: number + settlement_status?: number +} + +/** + * 新增回款:业务必选,发货批次可暂不指定(手动指定)。 + * + * shipment_id 须与本回款同属一个业务(后端强校验,数据完整性红线)。 + */ +export interface ReceiptCreateParams { + business_id: number + shipment_id?: number | null + receipt_date: string + /** 数字字符串,非负,最多两位小数 */ + amount: string + is_barter?: number + remark?: string | null +} + +/** + * 编辑回款:全量替换语义;不含 business_id(挂靠业务创建时定死)。 + * + * shipment_id 传 null = 清除指定(回到「暂未指定」)。 + */ +export interface ReceiptUpdateParams { + shipment_id?: number | null + receipt_date: string + amount: string + is_barter?: number + remark?: string | null +} + +/** 以物抵顶筛选项 */ +export const BARTER_OPTIONS = [ + { label: '以物抵顶', value: 1 }, + { label: '货币回款', value: 0 }, +] as const + +/** 核算状态筛选项(与列表标签文案一致) */ +export const SETTLEMENT_OPTIONS = [ + { label: '已核算', value: 1 }, + { label: '未核算', value: 0 }, +] as const + +/** 分页查询回款(附业务号/发货单号与回款天数派生值) */ +export function listReceipts(params: ReceiptQueryParams): Promise> { + return request({ url: '/v1/business/receipts', method: 'get', params }) +} + +/** 新增回款(批次号自动生成 HK 前缀;业务须执行中) */ +export function createReceipt(data: ReceiptCreateParams): Promise { + return request({ url: '/v1/business/receipts', method: 'post', data }) +} + +/** 回款详情(含回款天数派生值) */ +export function getReceiptDetail(receiptId: number): Promise { + return request({ url: `/v1/business/receipts/${receiptId}`, method: 'get' }) +} + +/** 编辑回款(全量替换;已核算回款后端拒绝) */ +export function updateReceipt(receiptId: number, data: ReceiptUpdateParams): Promise { + return request({ url: `/v1/business/receipts/${receiptId}`, method: 'put', data }) +} + +/** 删除回款(已核算/业务非执行中时后端拒绝;业务回款合计自动回退) */ +export function deleteReceipt(receiptId: number): Promise { + return request({ url: `/v1/business/receipts/${receiptId}`, method: 'delete' }) +} diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts new file mode 100644 index 0000000..fae54b9 --- /dev/null +++ b/frontend/src/api/reports.ts @@ -0,0 +1,228 @@ +import service, { request } from './request' +import type { PageData } from './types' + +/** 报表分页:标准分页四字段 + 全筛选合计(summary 非当前页合计) */ +export interface ReportSummary { + row_count: number + performance_total?: string | null + payable_total?: string | null + converted_profit_total?: string | null + ship_total?: string | null + received_total?: string | null + sale_total?: string | null +} + +export interface ReportPage extends PageData { + summary: ReportSummary +} + +/** 个人绩效明细行(金额/比例一律字符串,前端不做数值运算) */ +export interface PersonalDetailRow { + ledger_id: number + business_id: number + business_no: string + personnel_id: number + personnel_name: string + role_code: string + role_name: string + customer_type: string + product_category: string + ship_date: string | null + receipt_date: string + payment_days: number | null + rate_text: string + weight_r: number | null + payment_weight: string | null + performance_amount: string | null + payable_amount: string | null + converted_profit: string | null + is_probation: number + missing_cost: number + calc_version: number + settlement_id: number | null +} + +export interface DepartmentMemberRow { + personnel_id: number + personnel_name: string + role_code: string + is_probation: number + performance_sum: string | null + payable_sum: string | null + adjust_extra: string + adjust_deduct: string + adjust_net: string + net_total: string | null + ledger_ids: number[] +} + +export interface DepartmentTeamRow { + manager_id: number + manager_name: string + manager_status: number + member_count: number + team_base: string + team_share_amount: string + company_share_amount: string + company_base_ratio: string + adjust_extra: string + adjust_deduct: string + adjust_net: string + net_total: string + frozen_amount: string | null + aggregate_ledger_id: number | null + members: DepartmentMemberRow[] +} + +export interface DepartmentSummary { + settlement_month: string + params: { + team_text: string + company_text: string + manager_personal_included: number + params_as_of: string + } | null + teams: DepartmentTeamRow[] + unassigned: DepartmentMemberRow[] + company: { + team_base_total: string + company_base: string + company_share_pct: string + company_share_amount: string + } | null + totals: Record +} + +export interface BusinessStatsRow { + business_id: number + business_no: string + business_date: string | null + status: number + customer_name: string | null + owner_personnel_name: string | null + total_sale_amount: string | null + total_ship_amount: string | null + total_received_amount: string | null + performance_amount: string | null + payable_amount: string | null +} + +export interface BucketRow { + code: string + label: string + min_days: number | null + max_days: number | null + receipt_amount: string | null + receipt_count: number + performance_amount: string | null + payable_amount: string | null + ledger_count: number +} + +export interface GroupRow { + code: string + label: string + receipt_amount: string | null + performance_amount: string | null + payable_amount: string | null + ledger_count: number +} + +export interface PaymentStats { + settlement_month: string | null + buckets: BucketRow[] + by_customer_type: GroupRow[] + by_product_category: GroupRow[] + totals: Record +} + +export interface PaymentDetailRow { + ledger_id: number + receipt_id: number | null + receipt_batch_no: string | null + amount: string | null + business_id: number + business_no: string + personnel_id: number + personnel_name: string + customer_type: string + product_category: string + ship_date: string | null + receipt_date: string + payment_days: number | null + performance_amount: string | null + payable_amount: string | null +} + +/** 报表查询通用筛选(各页按需取用) */ +export interface ReportFilterParams { + pageNum?: number + pageSize?: number + settlement_month?: string + personnel_id?: number + customer_type?: string + product_category?: string + role_code?: string + manager_id?: number + keyword?: string + customer_id?: number + owner_personnel_id?: number + status?: number + date_from?: string + date_to?: string + bucket?: string +} + +export function listPersonalDetail( + params: ReportFilterParams, +): Promise> { + return request({ url: '/v1/reports/personal-detail', method: 'get', params }) +} + +export function getDepartmentSummary(params: ReportFilterParams): Promise { + return request({ url: '/v1/reports/department-summary', method: 'get', params }) +} + +export function listBusinessStats( + params: ReportFilterParams, +): Promise> { + return request({ url: '/v1/reports/business-stats', method: 'get', params }) +} + +export function getPaymentStats(params: ReportFilterParams): Promise { + return request({ url: '/v1/reports/payment-stats', method: 'get', params }) +} + +export function listPaymentDetail( + params: ReportFilterParams, +): Promise> { + return request({ url: '/v1/reports/payment-detail', method: 'get', params }) +} + +export function getPaymentDayBuckets(): Promise< + { code: string; label: string; min_days: number | null; max_days: number | null }[] +> { + return request({ url: '/v1/reports/payment-day-buckets', method: 'get' }) +} + +/** + * 导出报表(文件流)。走 axios 实例(带令牌)而非 request 封装:响应是 + * xlsx 文件流,不是统一信封,拦截器已对 responseType=blob 直通。 + * 服务端文件名是 ASCII(HTTP 头 latin-1 限制),中文名由调用方 link.download 指定。 + */ +export async function exportReport( + code: 'personal-detail' | 'department-summary' | 'business-stats' | 'payment-stats', + params: ReportFilterParams, +): Promise { + const response = await service.get(`/v1/reports/${code}/export`, { + params, + responseType: 'blob', + }) + return response.data as Blob +} + +/** 产品类别展示名:报表需额外覆盖「混合业务」(台账行可为 mixed) */ +export function reportCategoryLabel(code: string): string { + if (code === 'mixed') return '混合业务' + return { municipal_water: '市政水利', rail_transit: '轨道交通' }[code] ?? code +} diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 7f6ff39..d9f5e50 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -51,6 +51,10 @@ function handleUnauthorized(message: string): void { service.interceptors.response.use( (response: AxiosResponse) => { + // 文件流(导出下载)不是统一信封,直通给调用方处理 + if (response.config.responseType === 'blob') { + return response + } const body = response.data if (body.code !== 200) { // 防御性分支:后端非 2xx 走 reject 拦截器,这里是 2xx 但业务码异常的情况 @@ -59,9 +63,22 @@ service.interceptors.response.use( } return response }, - (error: AxiosError) => { + async (error: AxiosError) => { const status = error.response?.status - const message = error.response?.data?.message ?? '网络异常,请稍后重试' + const raw = error.response?.data + let message = (raw as ApiResult | undefined)?.message ?? '网络异常,请稍后重试' + // blob 请求失败时 data 是 Blob(如导出接口返回的 JSON 信封错误): + // 先解析出真实文案再统一提示 —— 先弹兜底再异步补弹会造成双提示, + // 401 时跳转提示也会与实际原因错位 + if (raw instanceof Blob) { + message = '导出失败,请稍后重试' + try { + const body = JSON.parse(await raw.text()) as { message?: string } + if (body.message) message = body.message + } catch { + /* 非 JSON 响应,保持兜底提示 */ + } + } if (status === 401) { // 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403, diff --git a/frontend/src/api/roles.ts b/frontend/src/api/roles.ts new file mode 100644 index 0000000..b459a27 --- /dev/null +++ b/frontend/src/api/roles.ts @@ -0,0 +1,54 @@ +import { request } from './request' +import type { PageData, RoleOption } from './types' + +/** 角色管理列表项(对应后端 `RoleManageOut`,比下拉多绑定用户数)。 */ +export interface RoleItem extends RoleOption { + /** 绑定用户数:>0 的角色不可删除,前端据此禁用按钮 */ + user_count: number +} + +export interface RoleQueryParams { + pageNum?: number + pageSize?: number + keyword?: string + status?: number +} + +export interface RoleCreateParams { + role_code: string + role_name: string + description?: string + sort_order?: number +} + +/** 编辑:不含编码(编码是授权判定锚点,创建后不可改) */ +export interface RoleUpdateParams { + role_name: string + description?: string + sort_order?: number +} + +/** 分页查询角色(含停用角色) */ +export function listRoles(params: RoleQueryParams): Promise> { + return request({ url: '/v1/system/roles', method: 'get', params }) +} + +/** 新增角色 */ +export function createRole(data: RoleCreateParams): Promise { + return request({ url: '/v1/system/roles', method: 'post', data }) +} + +/** 编辑角色 */ +export function updateRole(roleId: number, data: RoleUpdateParams): Promise { + return request({ url: `/v1/system/roles/${roleId}`, method: 'put', data }) +} + +/** 启用 / 禁用角色(内置 admin 不可停用) */ +export function setRoleStatus(roleId: number, status: number): Promise { + return request({ url: `/v1/system/roles/${roleId}/status`, method: 'put', data: { status } }) +} + +/** 删除角色(内置 admin 与仍有绑定用户的角色受后端保护) */ +export function deleteRole(roleId: number): Promise { + return request({ url: `/v1/system/roles/${roleId}`, method: 'delete' }) +} diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts new file mode 100644 index 0000000..64e259b --- /dev/null +++ b/frontend/src/api/settings.ts @@ -0,0 +1,46 @@ +import service, { request } from './request' + +/** 公司信息(管理端) */ +export interface CompanySettings { + company_name: string + /** 未上传时为 null */ + logo_url: string | null +} + +/** 匿名端点出参(登录页消费;与后端 SettingPublicOut 白名单一致) */ +export interface PublicSettings { + company_name: string + logo_url: string | null +} + +export function getSettings(): Promise { + return request({ url: '/v1/system/settings', method: 'get' }) +} + +export function updateSettings(companyName: string): Promise { + return request({ + url: '/v1/system/settings', + method: 'put', + data: { company_name: companyName }, + }) +} + +/** logo 上传走独立的 FormData(multipart,非 JSON) */ +export function uploadLogo(file: File): Promise { + const form = new FormData() + form.append('file', file) + return service.post('/v1/system/settings/logo', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) +} + +/** 登录页消费:无令牌也可调用(后端白名单端点)。 + * 请求头 no-cache:要求中间层/浏览器重校验而非直接用缓存副本 —— 公司名 + * 保存后应立即可见,不能因 300s 短缓存拿到旧值。 */ +export function getPublicSettings(): Promise { + return request({ + url: '/v1/system/settings/public', + method: 'get', + headers: { 'Cache-Control': 'no-cache' }, + }) +} diff --git a/frontend/src/api/settlements.ts b/frontend/src/api/settlements.ts new file mode 100644 index 0000000..6c72a84 --- /dev/null +++ b/frontend/src/api/settlements.ts @@ -0,0 +1,105 @@ +import { request } from './request' +import type { PageData } from './types' + +/** 月结状态:1草稿 2待审批 3已审定(=已锁定)4已驳回 */ +export const SETTLEMENT_STATUS = { + DRAFT: 1, + PENDING: 2, + APPROVED: 3, + REJECTED: 4, +} as const + +export function settlementStatusLabel(status: number): string { + return ( + { 1: '草稿', 2: '待审批', 3: '已审定(锁定)', 4: '已驳回' }[status] ?? String(status) + ) +} + +export function settlementStatusTagType( + status: number, +): 'primary' | 'success' | 'danger' | 'warning' | 'info' { + return ( + ({ 1: 'info', 2: 'primary', 3: 'success', 4: 'danger', 5: 'info' } as const)[status] ?? 'info' + ) +} + +export interface SettlementItem { + id: number + personnel_id: number + 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 + probation_note: string | null +} + +export interface Settlement { + id: number + settlement_month: string + status: number + total_performance: string | null + total_payable: string | null + person_count: number + approval_order_no: string | null + submitter_name: string + locked_at: string | null + locked_by: string | null + create_time: string +} + +export interface SettlementDetail extends Settlement { + items: SettlementItem[] +} + +export interface SettlementQueryParams { + pageNum?: number + pageSize?: number + status?: number +} + +export function listSettlements(params: SettlementQueryParams): Promise> { + return request({ url: '/v1/settlements', method: 'get', params }) +} + +export function getSettlement(id: number): Promise { + return request({ url: `/v1/settlements/${id}`, method: 'get' }) +} + +export function generateSettlement(month: string): Promise { + return request({ url: '/v1/settlements/generate', method: 'post', data: { month } }) +} + +export function submitSettlement(id: number): Promise { + return request({ url: `/v1/settlements/${id}/submit`, method: 'post' }) +} + +/** 调整单(已锁定月份的补发/扣减) */ +export interface AdjustmentRow { + id: number + order_no: string + settlement_month: string + personnel_name: string + direction: number + amount: string + reason: string + status: number +} + +export function createAdjustment(data: { + settlement_month: string + personnel_id: number + direction: number + amount: string + reason: string +}): Promise> { + return request({ url: '/v1/settlements/adjustments', method: 'post', data }) +} + +export function listAdjustments(): Promise { + return request({ url: '/v1/settlements/adjustments', method: 'get' }) +} diff --git a/frontend/src/api/shipments.ts b/frontend/src/api/shipments.ts new file mode 100644 index 0000000..7feb2eb --- /dev/null +++ b/frontend/src/api/shipments.ts @@ -0,0 +1,117 @@ +import { request } from './request' +import type { PageData } from './types' + +/** + * 发货产品明细行(对应后端 `ShipmentItemOut`)。 + * + * 数量/单价/行金额一律字符串直出 —— 金额红线:不经 float、不 toFixed; + * 行金额 = 数量 × 单价由后端计算固化,不信任前端传值。 + */ +export interface ShipmentItemRow { + id: number + product_id: number | null + product_name: string + spec_model: string | null + unit: string | null + /** 数字字符串,>0 */ + quantity: string + /** 发货单价,可空 = 待人工补价(补价前行金额记 0) */ + sale_price: string | null + /** 行金额,后端按数量×单价计算固化 */ + amount: string +} + +/** 发货单列表项(对应后端 `ShipmentOut`;business_no 为联表附带列) */ +export interface ShipmentItem { + id: number + shipment_no: string + business_id: number + business_no: string | null + /** ISO 串(YYYY-MM-DD);发货日是回款天数计算基准日(发货日计第 1 天) */ + ship_date: string + /** 运费(元),仅记录展示,不计入毛利润口径 */ + freight_amount: string + /** 发货总金额,后端按明细固化 */ + total_amount: string + remark: string | null +} + +/** 发货单详情:附产品明细行(编辑弹窗一次回显) */ +export interface ShipmentDetail extends ShipmentItem { + items: ShipmentItemRow[] +} + +export interface ShipmentQueryParams { + pageNum?: number + pageSize?: number + /** 业务筛选,空 = 全部 */ + business_id?: number + /** 发货日期起(含),ISO 串 */ + ship_date_from?: string + /** 发货日期止(含),ISO 串 */ + ship_date_to?: string +} + +/** 发货明细行入参(product_id 空 = 自由录入;单价可空=待人工补价) */ +export interface ShipmentItemParams { + product_id?: number | null + product_name: string + spec_model?: string | null + unit?: string | null + quantity: string + sale_price?: string | null +} + +/** + * 创建发货单:业务维度 + 明细行(≥1)。 + * + * 业务须处于执行中(后端校验);ship_date 为回款天数计算基准日。 + */ +export interface ShipmentCreateParams { + business_id: number + ship_date: string + freight_amount?: string | null + items: ShipmentItemParams[] + remark?: string | null +} + +/** + * 编辑发货单:明细全量替换。 + * + * 不含 business_id —— 发货单挂靠的业务创建时定死,改挂会让已指定 + * 本发货单的回款语义断裂。 + */ +export interface ShipmentUpdateParams { + ship_date: string + freight_amount?: string | null + items: ShipmentItemParams[] + remark?: string | null +} + +/** 分页查询发货单(附业务编号,可按业务/发货日期范围筛选) */ +export function listShipments(params: ShipmentQueryParams): Promise> { + return request({ url: '/v1/business/shipments', method: 'get', params }) +} + +/** 创建发货单(明细 ≥1;业务须执行中) */ +export function createShipment(data: ShipmentCreateParams): Promise { + return request({ url: '/v1/business/shipments', method: 'post', data }) +} + +/** 发货单详情(附产品明细行) */ +export function getShipmentDetail(shipmentId: number): Promise { + return request({ url: `/v1/business/shipments/${shipmentId}`, method: 'get' }) +} + +/** 编辑发货单(明细全量替换;终态业务的发货单后端拒绝) */ +export function updateShipment( + shipmentId: number, + data: ShipmentUpdateParams, +): Promise { + return request({ url: `/v1/business/shipments/${shipmentId}`, method: 'put', data }) +} + +/** 删除发货单(连带明细并回写业务汇总;被回款批次指定时后端拒绝) */ +export function deleteShipment(shipmentId: number): Promise { + return request({ url: `/v1/business/shipments/${shipmentId}`, method: 'delete' }) +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..561f225 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,25 @@ +/** 请求/响应公共类型。 */ + +/** + * 分页响应 —— 与后端 `PageResult` 的序列化字段一致。 + * + * 分页字段是驼峰(list/total/pageNum/pageSize,规范约定),而业务筛选 + * 参数是蛇形(username、real_name…,与后端 Query 模型一致), + * 这是既有契约,不要"顺手统一"。 + */ +export interface PageData { + list: T[] + total: number + pageNum: number + pageSize: number +} + +/** 已启用角色的下拉选项 */ +export interface RoleOption { + id: number + role_code: string + role_name: string + description: string | null + sort_order: number + status: number +} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..7594508 --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,86 @@ +import { request } from './request' +import type { PageData, RoleOption } from './types' + +/** 用户列表项(对应后端 `UserOut`)。 */ +export interface UserItem { + id: number + username: string + real_name: string | null + phone: string | null + email: string | null + /** 1启用 0禁用 */ + status: number + /** 角色编码列表 */ + roles: string[] + last_login_time: string | null + remark: string | null + create_time: string | null +} + +/** 列表查询:分页参数为驼峰,业务筛选为蛇形(与后端契约一致,勿"统一") */ +export interface UserQueryParams { + pageNum?: number + pageSize?: number + username?: string + real_name?: string + status?: number + role_code?: string +} + +export interface UserCreateParams { + username: string + password: string + real_name?: string + phone?: string + email?: string + roles: string[] + remark?: string +} + +/** 编辑:不含用户名与密码(后者走专门的重置接口) */ +export interface UserUpdateParams { + real_name?: string + phone?: string + email?: string + roles?: string[] + remark?: string +} + +/** 分页查询用户 */ +export function listUsers(params: UserQueryParams): Promise> { + return request({ url: '/v1/system/users', method: 'get', params }) +} + +/** 新增用户 */ +export function createUser(data: UserCreateParams): Promise { + return request({ url: '/v1/system/users', method: 'post', data }) +} + +/** 编辑用户 */ +export function updateUser(userId: number, data: UserUpdateParams): Promise { + return request({ url: `/v1/system/users/${userId}`, method: 'put', data }) +} + +/** 删除用户(物理删除,建议优先用禁用) */ +export function deleteUser(userId: number): Promise { + return request({ url: `/v1/system/users/${userId}`, method: 'delete' }) +} + +/** 重置密码(该用户已签发的令牌立即失效,需重新登录) */ +export function resetPassword(userId: number, newPassword: string): Promise { + return request({ + url: `/v1/system/users/${userId}/password`, + method: 'put', + data: { new_password: newPassword }, + }) +} + +/** 启用 / 禁用 */ +export function setUserStatus(userId: number, status: number): Promise { + return request({ url: `/v1/system/users/${userId}/status`, method: 'put', data: { status } }) +} + +/** 角色下拉选项(仅启用角色) */ +export function listRoleOptions(): Promise { + return request({ url: '/v1/system/users/meta/roles', method: 'get' }) +} diff --git a/frontend/src/assets/styles/index.css b/frontend/src/assets/styles/index.css index d3510fa..f9a1265 100644 --- a/frontend/src/assets/styles/index.css +++ b/frontend/src/assets/styles/index.css @@ -1,4 +1,55 @@ -/* 全局样式:重置 + 基础版式 */ +/* 全局样式:重置 + 品牌 token + 基础版式 */ + +:root { + /* + * 品牌色(2026-10-12 视觉方向:企业级扁平 · 轻质感 · 靛蓝点缀)。 + * 靛蓝只作重点点缀(主按钮/链接/焦点/当前菜单),大面积用中性灰白。 + * 设计预览与规范:docs/design/视觉方向预览.html + */ + --ys-indigo: #2e4ba8; + --ys-indigo-deep: #223a85; + --ys-indigo-wash: #edf1fa; + --ys-paper: #ffffff; + --ys-mist: #f4f5f8; + --ys-line: rgba(28, 35, 51, 0.09); + --ys-ink: #1c2333; + --ys-ink-2: #5b6577; + --ys-ink-3: #9aa3b2; + + /* + * 旧 token 名保留映射(BasicLayout / 登录页存量引用,值迁移到新体系: + * 深水青 → 冷墨系、金色 → 靛蓝系),存量页面迁移完成后移除。 + */ + --ys-ink-900: #16203a; + --ys-ink-800: #1c2333; + --ys-ink-700: #242e4a; + --ys-ink-600: #3a4a6e; + --ys-gold-bright: #6d81c2; + --ys-gold-deep: #2e4ba8; + + --ys-text: #1c2333; + --ys-muted: #5b6577; + + /* + * 品牌字:思源黑体(实施时自托管 4 字子集,内网可用)。 + * 栈内只用开源/免费商用字体 —— 微软雅黑、宋体等需商业授权的系统字体不得入栈。 + */ + --ys-font-brand: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC', + sans-serif; + + /* + * Element Plus 主色 = 靛蓝(白字对比约 7.5:1)。 + * 色阶为按 Element Plus 公式「主色与白色混合」预先算好的静态值 —— + * 不用 color-mix(),避免依赖较新的浏览器特性。 + */ + --el-color-primary: #2e4ba8; + --el-color-primary-light-3: #6d81c2; + --el-color-primary-light-5: #97a5d4; + --el-color-primary-light-7: #c0c9e5; + --el-color-primary-light-8: #d5dbee; + --el-color-primary-light-9: #eaedf6; + --el-color-primary-dark-2: #253c86; +} * { box-sizing: border-box; @@ -13,10 +64,55 @@ body, } body { - font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', - 'Microsoft YaHei', Arial, sans-serif; + /* 与品牌栈同源:开源字体优先;Helvetica/Arial 仅为西文回退(系统自带,不涉字体文件分发) */ + font-family: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC', + 'Helvetica Neue', Arial, sans-serif; font-size: 14px; - color: #303133; - background-color: #f5f7fa; + color: var(--ys-ink); + background-color: var(--ys-mist); -webkit-font-smoothing: antialiased; } + +/* 系统管理列表页共用的版式(原在三个页面 scoped 中各自重复) */ +.filter-form { + margin-bottom: 4px; +} + +.toolbar { + margin-bottom: 12px; +} + +/* + * 确认框(ElMessageBox)统一视觉 + * + * Element Plus 默认把 warning 图标渲染在正文左侧、与正文并排成两列: + * 多行正文时图标悬在文字块中间,左侧留出一列空白,标题行反而是空的; + * 实心黄圆也与本站「中性灰白 + 靛蓝点缀」体系冲突。 + * 这里去掉调用处的 type,把图标改画在标题行左侧:18px 描边样式, + * 深琥珀 #b7791f + 浅琥珀底 #fbf3e4(警示语义,饱和度压到与靛蓝同级); + * 正文与标题文字左对齐,图标单独悬挂在左侧。图标为 CSS 伪元素,天然不进无障碍树, + * 属于装饰元素,语义由标题文字承载。 + * MessageBox 挂载在 body 下,scoped 样式够不到,因此放在全局文件。 + */ +.ys-confirm-box .el-message-box__title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--ys-ink); +} + +.ys-confirm-box .el-message-box__title::before { + content: ''; + flex: none; + width: 18px; + height: 18px; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 18 18' fill='none'%3E%3Ccircle cx='9' cy='9' r='8.25' fill='%23FBF3E4'/%3E%3Ccircle cx='9' cy='9' r='7.4' stroke='%23B7791F' stroke-width='1.3'/%3E%3Cpath d='M9 5.3V9.8' stroke='%23B7791F' stroke-width='1.5' stroke-linecap='round'/%3E%3Ccircle cx='9' cy='12.4' r='0.95' fill='%23B7791F'/%3E%3C/svg%3E") + center / 18px 18px no-repeat; +} + +.ys-confirm-box .el-message-box__message { + /* 与标题文字对齐,让图标单独悬挂在左侧:18px 图标 + 8px gap */ + padding-left: 26px; + color: var(--ys-ink-2); +} diff --git a/frontend/src/assets/styles/table-actions.css b/frontend/src/assets/styles/table-actions.css new file mode 100644 index 0000000..1bf058e --- /dev/null +++ b/frontend/src/assets/styles/table-actions.css @@ -0,0 +1,57 @@ +/* + * 表格操作列统一样式。 + * + * 用法:用
包住该行的操作按钮;破坏性动作 + * (删除/禁用等)前插入 分组。 + * 列宽须容纳整行动作的自然宽度,否则 Element Plus 会把末尾按钮折到第二行。 + */ +.table-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + white-space: nowrap; +} + +/* 间距统一交给 gap,抵消相邻按钮默认的 12px 外边距 */ +.table-actions .el-button + .el-button { + margin-left: 0; +} + +/* + * 带 title 提示或 tooltip 的按钮常包在 span 里。行内盒的基线留白会让包装 + * 按钮比直接子按钮低约 1.7px,改为 flex 后与整行严格垂直对齐。 + */ +.table-actions > span:not(.table-actions__divider) { + display: flex; + align-items: center; +} + +/* 悬停加深并加下划线:Element 默认悬停变浅,浅靛蓝在表格底色上对比度不足 */ +.table-actions .el-button.is-link:hover { + text-decoration: underline; + text-underline-offset: 3px; +} + +.table-actions .el-button.is-link.el-button--primary:hover { + color: var(--el-color-primary-dark-2); +} + +.table-actions .el-button.is-link.el-button--success:hover { + color: var(--el-color-success-dark-2); +} + +.table-actions .el-button.is-link.el-button--warning:hover { + color: var(--el-color-warning-dark-2); +} + +.table-actions .el-button.is-link.el-button--danger:hover { + color: var(--el-color-danger-dark-2); +} + +.table-actions__divider { + flex: none; + width: 1px; + height: 12px; + background-color: var(--ys-line); +} diff --git a/frontend/src/components/layout/AppMenu.vue b/frontend/src/components/layout/AppMenu.vue new file mode 100644 index 0000000..c850200 --- /dev/null +++ b/frontend/src/components/layout/AppMenu.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/frontend/src/components/layout/BasicLayout.vue b/frontend/src/components/layout/BasicLayout.vue new file mode 100644 index 0000000..be56057 --- /dev/null +++ b/frontend/src/components/layout/BasicLayout.vue @@ -0,0 +1,236 @@ + + + + + + + diff --git a/frontend/src/components/table/AppPagination.vue b/frontend/src/components/table/AppPagination.vue new file mode 100644 index 0000000..f39d135 --- /dev/null +++ b/frontend/src/components/table/AppPagination.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/frontend/src/components/table/StatusTag.vue b/frontend/src/components/table/StatusTag.vue new file mode 100644 index 0000000..d7994c3 --- /dev/null +++ b/frontend/src/components/table/StatusTag.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/composables/useAppMenu.ts b/frontend/src/composables/useAppMenu.ts new file mode 100644 index 0000000..2bc464b --- /dev/null +++ b/frontend/src/composables/useAppMenu.ts @@ -0,0 +1,71 @@ +import { computed } from 'vue' +import { useRouter } from 'vue-router' + +import { useUserStore } from '@/store/user' +import { ROLE_ADMIN } from '@/utils/constants' + +/** 菜单节点:从路由表派生,避免菜单标题与页面标题两处维护漂移。 */ +export interface MenuNode { + path: string + title: string + order: number + group?: string +} + +export interface MenuGroup { + name: string + items: MenuNode[] +} + +/** + * 侧边菜单数据。 + * + * 规则: + * - 取路由表中带 `meta.title` 且未被 `meta.menu === false` 排除的记录 + * - `meta.requiresAdmin` 的项仅管理员可见(与路由守卫双保险,隐藏入口在前) + * - `meta.group` 相同的项聚合为一个子菜单;无 group 的为顶层菜单项 + * - 按 `meta.order` 排序 + */ +export function useAppMenu() { + const router = useRouter() + const userStore = useUserStore() + + const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN)) + const userRoles = computed(() => userStore.roles) + + const menuNodes = computed(() => + router + .getRoutes() + .filter((record) => Boolean(record.meta.title) && record.meta.menu !== false) + .filter((record) => !record.meta.requiresAdmin || isAdmin.value) + // 职能页面(审批等):任一列入角色命中即可见(admin 须显式列入集合) + .filter( + (record) => + !record.meta.allowedRoles || record.meta.allowedRoles.some((role) => userRoles.value.includes(role)), + ) + .map((record) => ({ + path: record.path, + title: record.meta.title ?? '', + order: record.meta.order ?? 99, + group: record.meta.group, + })) + .sort((a, b) => a.order - b.order), + ) + + /** 无分组的顶层菜单项 */ + const topLevelMenus = computed(() => menuNodes.value.filter((node) => !node.group)) + + /** 分组菜单(按 group 名聚合,组内保持 order 顺序) */ + const groupedMenus = computed(() => { + const groups = new Map() + for (const node of menuNodes.value) { + if (!node.group) continue + const items = groups.get(node.group) ?? [] + items.push(node) + groups.set(node.group, items) + } + return [...groups.entries()].map(([name, items]) => ({ name, items })) + }) + + return { topLevelMenus, groupedMenus } +} diff --git a/frontend/src/composables/useConfirmAction.ts b/frontend/src/composables/useConfirmAction.ts new file mode 100644 index 0000000..9eb8988 --- /dev/null +++ b/frontend/src/composables/useConfirmAction.ts @@ -0,0 +1,56 @@ +import { ElMessage, ElMessageBox } from 'element-plus' + +import { handledError } from '@/utils/error' + +/** + * 「确认框 → 执行 → 成功提示 → 收尾刷新」动作流。 + * + * 用户/角色两页的启用禁用、删除共四处结构逐行相同,收敛到这里: + * 取消确认静默返回 false;接口失败由请求拦截器统一提示,此处仅兜底 + * 阻止 Promise 继续传播(handledError 显式标注,不写空 catch)。 + */ +export function useConfirmAction() { + /** + * 弹出确认框并执行;返回是否真正执行成功(用户取消或执行失败均为 false)。 + */ + async function confirmAndRun(options: { + /** 确认框正文 */ + confirmText: string + /** 确认框标题 */ + title?: string + /** 确认按钮文案 */ + confirmButtonText?: string + /** 执行成功后的全局提示 */ + successText: string + /** 真正的业务动作 */ + run: () => Promise + /** 成功后的收尾(如刷新列表) */ + after?: () => Promise | void + }): Promise { + try { + await ElMessageBox.confirm(options.confirmText, options.title ?? '操作确认', { + // 不传 type:Element Plus 的 warning 图标会渲染在正文左侧独立成列, + // 标题行反被留空。图标改由全局样式画在标题行内(.ys-confirm-box)。 + customClass: 'ys-confirm-box', + confirmButtonText: options.confirmButtonText ?? '确定', + cancelButtonText: '取消', + }) + } catch { + return false // 用户取消,静默返回 + } + + try { + await options.run() + await options.after?.() + // 收尾(列表刷新)成功后才提示:先提示后收尾会让用户看到「已删除」 + // 而列表仍留旧行(after 抛错被吞),成功/失败提示同屏矛盾 + ElMessage.success(options.successText) + return true + } catch (error) { + handledError(error) // 失败提示由请求拦截器统一完成 + return false + } + } + + return { confirmAndRun } +} diff --git a/frontend/src/composables/useDropdownOptions.ts b/frontend/src/composables/useDropdownOptions.ts new file mode 100644 index 0000000..d5cbf45 --- /dev/null +++ b/frontend/src/composables/useDropdownOptions.ts @@ -0,0 +1,24 @@ +import { ref, type Ref } from 'vue' + +import { handledError } from '@/utils/error' + +/** + * 下拉选项的加载状态:取数失败不阻断列表使用(下拉为空 + 拦截器提示)。 + * + * 业务/客户/人员等参照下拉在多个页面重复同一「try 取数 → map 成选项 → + * 失败吞掉」骨架,映射函数由调用方给出(各页字段不同),骨架收口单点。 + */ +export function useDropdownOptions(fetch: () => Promise): { + options: Ref + load: () => Promise +} { + const options = ref([]) as Ref + async function load(): Promise { + try { + options.value = await fetch() + } catch (error) { + handledError(error) // 下拉为空不阻断列表使用 + } + } + return { options, load } +} diff --git a/frontend/src/composables/useEditingRow.ts b/frontend/src/composables/useEditingRow.ts new file mode 100644 index 0000000..d69a456 --- /dev/null +++ b/frontend/src/composables/useEditingRow.ts @@ -0,0 +1,16 @@ +import { computed, type Ref } from 'vue' + +/** + * 编辑中的行:editingId 非空时从当前页记录里找出行(回显兜底用)。 + * + * 回款/发货/人员三页此前各持一份逐字相同的 computed(行对象上要展示 + * 「编辑中」的派生信息,如业务编号、停用岗位回显),收口后口径一处维护。 + */ +export function useEditingRow( + records: Ref, + editingId: Ref, +) { + return computed(() => + editingId.value === null ? null : (records.value.find((r) => r.id === editingId.value) ?? null), + ) +} diff --git a/frontend/src/composables/usePagedList.ts b/frontend/src/composables/usePagedList.ts new file mode 100644 index 0000000..936701d --- /dev/null +++ b/frontend/src/composables/usePagedList.ts @@ -0,0 +1,86 @@ +import { ref, type Ref } from 'vue' + +import type { PageData } from '@/api/types' +import { handledError } from '@/utils/error' + +/** + * 分页列表状态管理。 + * + * 四个列表页(用户/角色/操作日志/登录日志)共用同一套分页交互,要点: + * + * - 「删除当前页最后一条后页码回退」是高频踩坑点:不回退就会停在空页 + * - **请求序号防竞态**:快速翻页/改条件时并发请求乱序返回,只有最新 + * 请求的响应允许写入状态,否则旧数据会覆盖新数据、与分页器错位 + * - 请求失败不外抛(提示由拦截器统一完成),loading 仅由最新请求复位 + */ +export function usePagedList( + fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise>, + getFilters: () => F, +) { + const records = ref([]) as Ref + const total = ref(0) + const pageNum = ref(1) + const pageSize = ref(10) + const loading = ref(false) + + /** 自增请求序号:仅最新请求的响应可写入状态 */ + let requestSeq = 0 + + /** 按当前页码与筛选条件加载 */ + async function load(): Promise { + const seq = ++requestSeq + loading.value = true + try { + const data = await fetcher({ + ...getFilters(), + pageNum: pageNum.value, + pageSize: pageSize.value, + }) + if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应 + records.value = data.list + total.value = data.total + } catch (error) { + handledError(error) // 失败提示由请求拦截器统一完成 + } finally { + if (seq === requestSeq) loading.value = false + } + } + + /** 条件查询:回到第一页再加载(停留旧页码会查到空页) */ + function search(): void { + pageNum.value = 1 + void load() + } + + function onPageChange(page: number): void { + pageNum.value = page + void load() + } + + function onSizeChange(size: number): void { + pageSize.value = size + pageNum.value = 1 + void load() + } + + /** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */ + async function reloadAfterDelete(removedCount = 1): Promise { + if (records.value.length - removedCount <= 0 && pageNum.value > 1) { + pageNum.value -= 1 + } + await load() + } + + return { + records, + total, + pageNum, + pageSize, + loading, + load, + search, + onPageChange, + onSizeChange, + reloadAfterDelete, + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 48fda14..2908410 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -8,5 +8,6 @@ import pinia from './store' import 'element-plus/dist/index.css' import './assets/styles/index.css' +import './assets/styles/table-actions.css' createApp(App).use(pinia).use(router).use(ElementPlus, { locale: zhCn }).mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 856d6dc..ee8ea7c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,20 +1,222 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' +import { ElMessage } from 'element-plus' import { useUserStore } from '@/store/user' +import { ROLE_ADMIN } from '@/utils/constants' + +declare module 'vue-router' { + interface RouteMeta { + /** 免登录页面(登录页) */ + public?: boolean + /** 页面标题:浏览器标题 / 面包屑 / 菜单共用,新页面必须写 */ + title?: string + /** 仅管理员可访问 —— 菜单隐藏 + 守卫兜底(直接输 URL 会被弹回首页) */ + requiresAdmin?: boolean + /** 任一角色可访问(与 requiresAdmin 互斥使用;admin 须显式列入)—— + * 菜单按角色过滤 + 守卫兜底,用于审批等职能页面 */ + allowedRoles?: string[] + /** 是否出现在侧边菜单(默认 true);false 用于排除登录页等 */ + menu?: boolean + /** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */ + group?: string + /** 菜单排序,越小越靠前 */ + order?: number + } +} const routes: RouteRecordRaw[] = [ - { path: '/', redirect: '/dashboard' }, { path: '/login', name: 'login', component: () => import('@/views/login/index.vue'), - meta: { public: true, title: '登录' }, + meta: { public: true, title: '登录', menu: false }, }, { - path: '/dashboard', - name: 'dashboard', - component: () => import('@/views/dashboard/index.vue'), - meta: { title: '首页' }, + // 应用主框架:菜单、顶栏、面包屑由布局提供,业务页面作为子路由渲染 + path: '/', + component: () => import('@/components/layout/BasicLayout.vue'), + redirect: '/dashboard', + children: [ + { + path: '/dashboard', + name: 'dashboard', + component: () => import('@/views/dashboard/index.vue'), + meta: { title: '首页', order: 1 }, + }, + { + path: '/system/users', + name: 'system-users', + component: () => import('@/views/system/users/index.vue'), + meta: { title: '用户管理', group: '系统管理', requiresAdmin: true, order: 2 }, + }, + { + path: '/system/roles', + name: 'system-roles', + component: () => import('@/views/system/roles/index.vue'), + meta: { title: '角色管理', group: '系统管理', requiresAdmin: true, order: 3 }, + }, + { + path: '/system/business-roles', + name: 'system-business-roles', + component: () => import('@/views/system/business-roles/index.vue'), + meta: { title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }, + }, + { + // 计提参数:查看放开到管理员/财务/领导(#17 答复「查看可、修改仅 + // 管理员」);页面内写按钮按 admin 角色显隐 + path: '/system/params', + name: 'system-params', + component: () => import('@/views/system/params/index.vue'), + meta: { + title: '计提参数', + group: '系统管理', + allowedRoles: [ROLE_ADMIN, 'finance', 'leader'], + order: 6, + }, + }, + { + path: '/system/settings', + name: 'system-settings', + component: () => import('@/views/system/settings/index.vue'), + meta: { title: '公司信息', group: '系统管理', requiresAdmin: true, order: 7 }, + }, + { + // 审批管理:管理员/财务/领导可见(营销后勤待权限清单答复后放开) + path: '/system/approvals', + name: 'system-approvals', + component: () => import('@/views/system/approvals/index.vue'), + meta: { + title: '审批管理', + group: '系统管理', + allowedRoles: [ROLE_ADMIN, 'finance', 'leader'], + order: 8, + }, + }, + { + path: '/system/logs', + name: 'system-logs', + component: () => import('@/views/system/logs/index.vue'), + meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 9 }, + }, + { + // 基础数据属业务操作:登录即可访问,不设 requiresAdmin(对照后端 api/v1/base) + path: '/base/personnel', + name: 'base-personnel', + component: () => import('@/views/base/personnel/index.vue'), + meta: { title: '人员管理', group: '基础数据', order: 10 }, + }, + { + path: '/base/products', + name: 'base-products', + component: () => import('@/views/base/products/index.vue'), + meta: { title: '产品管理', group: '基础数据', order: 11 }, + }, + { + path: '/base/customers', + name: 'base-customers', + component: () => import('@/views/base/customers/index.vue'), + meta: { title: '客户管理', group: '基础数据', order: 12 }, + }, + { + // 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin + path: '/business/businesses', + name: 'business-businesses', + component: () => import('@/views/business/businesses/index.vue'), + meta: { title: '业务管理', group: '业务数据', order: 13 }, + }, + { + path: '/business/shipments', + name: 'business-shipments', + component: () => import('@/views/business/shipments/index.vue'), + meta: { title: '发货管理', group: '业务数据', order: 14 }, + }, + { + path: '/business/receipts', + name: 'business-receipts', + component: () => import('@/views/business/receipts/index.vue'), + meta: { title: '回款管理', group: '业务数据', order: 15 }, + }, + { + // 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额, + // 登录用户即可用,不设 requiresAdmin(区别于系统管理组) + path: '/business/rating', + name: 'business-rating', + component: () => import('@/views/business/rating/index.vue'), + meta: { title: '客户评级计算器', group: '业务数据', order: 16 }, + }, + { + // 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤 + // (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin) + path: '/calc/ledger', + name: 'calc-ledger', + component: () => import('@/views/calc/ledger/index.vue'), + meta: { title: '绩效台账', group: '绩效工资', order: 17 }, + }, + { + path: '/settlements', + name: 'settlements', + component: () => import('@/views/settlements/index.vue'), + meta: { title: '月度结算', group: '绩效工资', order: 18 }, + }, + // 报表统计组(M4 ⑤):FR-030/031 限领导/财务/管理员;admin 须显式 + // 列入 allowedRoles(项目既定规则,admin 不天然放行) + { + path: '/reports/personal-detail', + name: 'reports-personal-detail', + component: () => import('@/views/reports/personal-detail/index.vue'), + meta: { + title: '个人绩效明细', + group: '报表统计', + order: 19, + allowedRoles: [ROLE_ADMIN, 'finance', 'leader'], + }, + }, + { + path: '/reports/department-summary', + name: 'reports-department-summary', + component: () => import('@/views/reports/department-summary/index.vue'), + meta: { + title: '部门绩效汇总', + group: '报表统计', + order: 20, + allowedRoles: [ROLE_ADMIN, 'finance', 'leader'], + }, + }, + { + path: '/reports/business-stats', + name: 'reports-business-stats', + component: () => import('@/views/reports/business-stats/index.vue'), + meta: { + title: '业务统计', + group: '报表统计', + order: 21, + allowedRoles: [ROLE_ADMIN, 'finance', 'leader'], + }, + }, + { + path: '/reports/payment-stats', + name: 'reports-payment-stats', + component: () => import('@/views/reports/payment-stats/index.vue'), + meta: { + title: '回款统计', + group: '报表统计', + order: 22, + allowedRoles: [ROLE_ADMIN, 'finance', 'leader'], + }, + }, + ], + }, + { + // 打印视图独立于 BasicLayout(不挂侧栏/顶栏,浏览器打印只出报表本身); + // menu:false 不进菜单,但 allowedRoles 守卫照常生效 + path: '/reports/print/:code', + name: 'report-print', + component: () => import('@/views/reports/print/index.vue'), + meta: { + title: '报表打印', + menu: false, + allowedRoles: [ROLE_ADMIN, 'finance', 'leader'], + }, }, // 未匹配路由回首页;若未登录会被守卫拦到登录页 { path: '/:pathMatch(.*)*', redirect: '/dashboard' }, @@ -28,11 +230,13 @@ const router = createRouter({ /** * 全局前置守卫。 * - * 三层判断: + * 四层判断: * 1. `meta.public` 标记的页面(登录页)直接放行 * 2. 无令牌 → 跳登录页,并把原目标记在 query 里供登录后回跳 * 3. 有令牌但内存中无用户信息(说明是刷新页面)→ 调 /me 恢复会话; * 失败则视为令牌已失效,清状态并跳登录页 + * 4. `meta.requiresAdmin` 的页面要求管理员角色 —— 此时用户信息已就绪, + * 这是"隐藏菜单"之外的第二道防线(防手输 URL) */ router.beforeEach(async (to) => { const userStore = useUserStore() @@ -54,6 +258,17 @@ router.beforeEach(async (to) => { } } + if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) { + ElMessage.error('需要管理员权限') + return { path: '/dashboard' } + } + + // 职能页面(审批等):任一角色命中即可进;菜单隐藏之外的第二道防线 + if (to.meta.allowedRoles && !to.meta.allowedRoles.some((role) => userStore.roles.includes(role))) { + ElMessage.error('无权访问该页面') + return { path: '/dashboard' } + } + return true }) diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts new file mode 100644 index 0000000..89b641a --- /dev/null +++ b/frontend/src/utils/constants.ts @@ -0,0 +1,35 @@ +/** 跨页面共享常量。 */ + +/** 系统管理员:可见「系统管理」菜单,全部管理接口可用(与后端 core/constants.py 一致) */ +export const ROLE_ADMIN = 'admin' + +/** + * 操作日志的模块 / 操作词表 —— 与后端 `core/constants.py` 的 + * `MODULE_*` / `ACTION_*` 保持一致;新增模块时两处同步(后端是权威来源)。 + */ +export const MODULE_OPTIONS = [ + '用户管理', + '角色管理', + '岗位角色', + '计提参数', + '人员管理', + '产品管理', + '客户管理', + '业务管理', + '发货管理', + '回款管理', + '客户评级', +] as const +export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const + +/** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */ +export const STATUS_OPTIONS = [ + { label: '启用', value: 1 }, + { label: '禁用', value: 0 }, +] as const + +/** 成功/失败筛选选项 */ +export const RESULT_OPTIONS = [ + { label: '成功', value: 1 }, + { label: '失败', value: 0 }, +] as const diff --git a/frontend/src/utils/error.ts b/frontend/src/utils/error.ts new file mode 100644 index 0000000..037cddf --- /dev/null +++ b/frontend/src/utils/error.ts @@ -0,0 +1,13 @@ +/** 异常处理工具。 */ + +/** + * 显式声明「异常已按约定处理」。 + * + * 本项目请求失败的提示统一由 axios 拦截器完成,页面 catch 块只需阻止 + * Promise 继续传播;但**空 catch 会掩盖非预期异常**(如本地 TypeError), + * 也违反「禁空 catch」规范,故以本函数显式标注意图并留痕。 + * 用 debug 级别:既不污染 e2e 收集的 error 级控制台信息,也便于排查时开启。 + */ +export function handledError(error: unknown): void { + console.debug('[handled]', error) +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..d05363e --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,12 @@ +/** 显示格式化工具。 */ + +/** + * 时间展示:把后端的时间串规范为 `YYYY-MM-DD HH:mm:ss`。 + * + * Pydantic 序列化 datetime 为 ISO 8601(含 `T`),直接展示不友好; + * 值缺失(空串/null/undefined)时返回占位符而非空白。 + */ +export function formatDateTime(value?: string | null, placeholder = '—'): string { + if (!value) return placeholder + return value.replace('T', ' ').slice(0, 19) +} diff --git a/frontend/src/utils/validate.ts b/frontend/src/utils/validate.ts new file mode 100644 index 0000000..9c0da74 --- /dev/null +++ b/frontend/src/utils/validate.ts @@ -0,0 +1,53 @@ +/** 表单校验工具。 */ + +/** + * 密码校验:按 **UTF-8 字节数** 而非字符数。 + * + * 后端 bcrypt 对超过 72 字节的密码直接拒绝;中文一个字符占 3 字节, + * 24 个汉字即到顶 —— 按字符数校验(如 maxlength=20)根本拦不住, + * 会出现"前端放行、后端 400"的错位体验。与后端 `PASSWORD_*_BYTES` 对齐。 + */ +export const PASSWORD_MIN_BYTES = 8 +export const PASSWORD_MAX_BYTES = 72 + +const BYTE_HINT = '(中文一个字符约 3 字节)' + +/** + * 金额形态正则(与后端 `schemas/business._check_amount` 同口径单点维护)。 + * + * 「DECIMAL 列 ↔ 前端放行位数」规则此前在业务/发货/回款/人员四页各写一份 + * (businesses 与 shipments 逐字重复,receipts/personnel 又各自内联两位小数 + * 正则),后端精度调整时漏改任何一页,就会出现「A 页面拦得住、B 页面放行 + * 后端 400」或「带精度历史行永远无法编辑」(fba8cfc 的教训)。 + */ +/** 非负数字、最多两位小数(对齐 DECIMAL(14,2):金额/数量/成本/运费/工资) */ +export const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/ +/** 非负数字、最多四位小数(对齐 DECIMAL(14,4):单价/指导价) */ +export const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/ +/** 全零形态(数量「0 或 0.00」须拦:数量 >0 是明细行的硬约束) */ +export const ZERO_PATTERN = /^0+(\.0+)?$/ + +/** 计算 UTF-8 字节数 */ +export function passwordByteLength(value: string): number { + return new TextEncoder().encode(value).length +} + +/** Element Plus 表单校验器(用于 el-form 的 rules.validator) */ +export function passwordRule( + _rule: unknown, + value: string, + callback: (error?: Error) => void, +): void { + if (!value) { + callback(new Error('请输入密码')) + return + } + const length = passwordByteLength(value) + if (length < PASSWORD_MIN_BYTES) { + callback(new Error(`密码不足 ${PASSWORD_MIN_BYTES} 字节${BYTE_HINT}`)) + } else if (length > PASSWORD_MAX_BYTES) { + callback(new Error(`密码超过 ${PASSWORD_MAX_BYTES} 字节上限${BYTE_HINT}`)) + } else { + callback() + } +} diff --git a/frontend/src/views/base/customers/index.vue b/frontend/src/views/base/customers/index.vue new file mode 100644 index 0000000..a04c75c --- /dev/null +++ b/frontend/src/views/base/customers/index.vue @@ -0,0 +1,567 @@ + + + + + diff --git a/frontend/src/views/base/personnel/index.vue b/frontend/src/views/base/personnel/index.vue new file mode 100644 index 0000000..8858df8 --- /dev/null +++ b/frontend/src/views/base/personnel/index.vue @@ -0,0 +1,664 @@ + + + + + diff --git a/frontend/src/views/base/products/index.vue b/frontend/src/views/base/products/index.vue new file mode 100644 index 0000000..1bf8b22 --- /dev/null +++ b/frontend/src/views/base/products/index.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/frontend/src/views/business/businesses/index.vue b/frontend/src/views/business/businesses/index.vue new file mode 100644 index 0000000..ad2eeab --- /dev/null +++ b/frontend/src/views/business/businesses/index.vue @@ -0,0 +1,1478 @@ + + + + + diff --git a/frontend/src/views/business/rating/index.vue b/frontend/src/views/business/rating/index.vue new file mode 100644 index 0000000..1ac318d --- /dev/null +++ b/frontend/src/views/business/rating/index.vue @@ -0,0 +1,485 @@ + + + + + diff --git a/frontend/src/views/business/receipts/index.vue b/frontend/src/views/business/receipts/index.vue new file mode 100644 index 0000000..8fb0e5d --- /dev/null +++ b/frontend/src/views/business/receipts/index.vue @@ -0,0 +1,505 @@ + + + + + diff --git a/frontend/src/views/business/shipments/index.vue b/frontend/src/views/business/shipments/index.vue new file mode 100644 index 0000000..be42f77 --- /dev/null +++ b/frontend/src/views/business/shipments/index.vue @@ -0,0 +1,492 @@ + + + + + diff --git a/frontend/src/views/calc/ledger/index.vue b/frontend/src/views/calc/ledger/index.vue new file mode 100644 index 0000000..4668737 --- /dev/null +++ b/frontend/src/views/calc/ledger/index.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/frontend/src/views/dashboard/index.vue b/frontend/src/views/dashboard/index.vue index 101396d..07815ca 100644 --- a/frontend/src/views/dashboard/index.vue +++ b/frontend/src/views/dashboard/index.vue @@ -1,98 +1,350 @@ diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue index 1da5400..3b279ab 100644 --- a/frontend/src/views/login/index.vue +++ b/frontend/src/views/login/index.vue @@ -23,6 +23,32 @@ const form = reactive({ const captchaId = ref('') const captchaImage = ref('') +/** 品牌名:默认衍水集团,公司信息设置页改了以后登录页同步生效(public 端点) */ +const brandName = ref('衍水集团') +const brandLogo = ref(null) + +async function loadBrand(): Promise { + 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' }], @@ -72,58 +98,38 @@ async function handleSubmit(): Promise { } } -onMounted(refreshCaptcha) +onMounted(() => { + refreshCaptcha() + loadBrand() +}) diff --git a/frontend/src/views/reports/department-summary/index.vue b/frontend/src/views/reports/department-summary/index.vue new file mode 100644 index 0000000..9ecfa38 --- /dev/null +++ b/frontend/src/views/reports/department-summary/index.vue @@ -0,0 +1,296 @@ + + + + + diff --git a/frontend/src/views/reports/payment-stats/index.vue b/frontend/src/views/reports/payment-stats/index.vue new file mode 100644 index 0000000..772c814 --- /dev/null +++ b/frontend/src/views/reports/payment-stats/index.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/frontend/src/views/reports/personal-detail/index.vue b/frontend/src/views/reports/personal-detail/index.vue new file mode 100644 index 0000000..d4f085c --- /dev/null +++ b/frontend/src/views/reports/personal-detail/index.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/frontend/src/views/reports/print/index.vue b/frontend/src/views/reports/print/index.vue new file mode 100644 index 0000000..ea37e56 --- /dev/null +++ b/frontend/src/views/reports/print/index.vue @@ -0,0 +1,411 @@ + + + + + diff --git a/frontend/src/views/settlements/index.vue b/frontend/src/views/settlements/index.vue new file mode 100644 index 0000000..9bfacf2 --- /dev/null +++ b/frontend/src/views/settlements/index.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/frontend/src/views/system/approvals/index.vue b/frontend/src/views/system/approvals/index.vue new file mode 100644 index 0000000..28d226d --- /dev/null +++ b/frontend/src/views/system/approvals/index.vue @@ -0,0 +1,424 @@ + + + + + diff --git a/frontend/src/views/system/business-roles/index.vue b/frontend/src/views/system/business-roles/index.vue new file mode 100644 index 0000000..d833628 --- /dev/null +++ b/frontend/src/views/system/business-roles/index.vue @@ -0,0 +1,377 @@ + + + + + diff --git a/frontend/src/views/system/logs/index.vue b/frontend/src/views/system/logs/index.vue new file mode 100644 index 0000000..fad721f --- /dev/null +++ b/frontend/src/views/system/logs/index.vue @@ -0,0 +1,403 @@ + + + + + diff --git a/frontend/src/views/system/params/index.vue b/frontend/src/views/system/params/index.vue new file mode 100644 index 0000000..7721e45 --- /dev/null +++ b/frontend/src/views/system/params/index.vue @@ -0,0 +1,1023 @@ + + + + + diff --git a/frontend/src/views/system/roles/index.vue b/frontend/src/views/system/roles/index.vue new file mode 100644 index 0000000..1ec65d5 --- /dev/null +++ b/frontend/src/views/system/roles/index.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/frontend/src/views/system/settings/index.vue b/frontend/src/views/system/settings/index.vue new file mode 100644 index 0000000..0d95bea --- /dev/null +++ b/frontend/src/views/system/settings/index.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/frontend/src/views/system/users/index.vue b/frontend/src/views/system/users/index.vue new file mode 100644 index 0000000..bbdbb94 --- /dev/null +++ b/frontend/src/views/system/users/index.vue @@ -0,0 +1,470 @@ + + + + + diff --git a/scripts/backup_full.sh b/scripts/backup_full.sh new file mode 100644 index 0000000..f64a4c1 --- /dev/null +++ b/scripts/backup_full.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# 衍水工资系统 —— MySQL 全量备份(⑨ 备份恢复与运维检查) +# +# 思路:在跑中的容器内执行 mysqldump,免暴露 3306 端口给宿主机工具, +# 也避免宿主机装 MySQL 客户端。凭据从 docker/.env 读取(不入脚本、不入版本库)。 +# +# 用法:在项目根目录执行 bash scripts/backup_full.sh +# 产物:db/backups/payroll_YYYYMMDD_HHMMSS.sql.gz,保留最近 30 天。 + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ -f docker/.env ]]; then + # shellcheck disable=SC1091 + source docker/.env +else + echo "ERROR: docker/.env 不存在(请先创建 docker/.env 并填入 MySQL 凭据)" >&2 + exit 1 +fi + +: "${MYSQL_DATABASE:?docker/.env 缺 MYSQL_DATABASE}" +: "${MYSQL_ROOT_PASSWORD:?docker/.env 缺 MYSQL_ROOT_PASSWORD}" + +BACKUP_DIR="$ROOT/db/backups" +mkdir -p "$BACKUP_DIR" +STAMP="$(date +%Y%m%d_%H%M%S)" +OUT="$BACKUP_DIR/payroll_${STAMP}.sql.gz" + +# 容器名固定(compose 显式 container_name),与 docker/docker-compose.dev.yml 保持一致 +CONTAINER="ys-mysql-dev" +if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "ERROR: 容器 $CONTAINER 未运行(先 docker compose up -d)" >&2 + exit 1 +fi + +echo "开始备份 $MYSQL_DATABASE → $(basename "$OUT")" +docker exec "$CONTAINER" sh -c \ + "mysqldump -uroot -p\"\$MYSQL_ROOT_PASSWORD\" --single-transaction --routines --triggers \"$MYSQL_DATABASE\"" \ + | gzip > "$OUT" + +if [[ ! -s "$OUT" ]]; then + echo "ERROR: 备份文件为空(数据库名或凭据可能不对)" >&2 + rm -f "$OUT" + exit 1 +fi + +SIZE="$(du -h "$OUT" | cut -f1)" +echo "备份完成:$(basename "$OUT") ($SIZE)" + +# 记录 binlog 位点:增量备份(backup_incremental.sh)与恢复重放的对齐锚点—— +# 从「此位点之后」的 binlog 开始重放即为完整增量链 +docker exec "$CONTAINER" sh -c \ + 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e "SHOW MASTER STATUS"' > "${OUT}.pos" 2>/dev/null +if [[ -s "${OUT}.pos" ]]; then + echo "binlog 位点:$(awk '{print $1, "pos="$2}' "${OUT}.pos") → $(basename "$OUT").pos" +fi + +# 保留 30 天 +find "$BACKUP_DIR" -maxdepth 1 -name 'payroll_*.sql.gz' -mtime +30 -print -delete +find "$BACKUP_DIR" -maxdepth 1 -name 'payroll_*.pos' -mtime +30 -print -delete +echo "已清理 30 天前的旧备份" + +echo "" +echo "最新备份:" +ls -lh "$BACKUP_DIR"/*.sql.gz 2>/dev/null | tail -3 diff --git a/scripts/backup_incremental.sh b/scripts/backup_incremental.sh new file mode 100644 index 0000000..795eaf3 --- /dev/null +++ b/scripts/backup_incremental.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# 衍水工资系统 —— MySQL 增量备份(binlog 归档) +# +# 口径(澄清 #19 业务答复 2026-10-19):每日全量 + 有更新时增量补充备份。 +# 增量 = 归档 MySQL binlog:自上次归档之后产生的新 binlog 文件逐个拷出压缩; +# 没有新变更时不产出文件(「有更新时」才补充)。 +# +# 恢复顺序:最新全量(backup_full.sh 产物)→ 依次重放其后的 binlog 增量包 +# (用 mysqlbinlog 重放,见 docs/备份恢复手册.md 增量章节)。 +# +# 实现说明:mysql:8.0 官方镜像不含 mysqlbinlog 客户端,故不走 +# --read-from-remote-server,直接从数据目录 tar 拷出原始 binlog 文件; +# 只归档「已写完」的文件(SHOW BINARY LOGS 列表最后一个为当前写入中, +# 留到下次收取,配合 30 天 binlog 过期窗口余量充足)。 +# +# 用法:在项目根目录执行 bash scripts/backup_incremental.sh +# 产物:db/backups/binlog_NNNNNN.gz(NNNNNN 为 binlog 序号),保留 30 天。 + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ -f docker/.env ]]; then + # shellcheck disable=SC1091 + source docker/.env +else + echo "ERROR: docker/.env 不存在(请先创建 docker/.env 并填入 MySQL 凭据)" >&2 + exit 1 +fi +: "${MYSQL_DATABASE:?docker/.env 缺 MYSQL_DATABASE}" + +BACKUP_DIR="$ROOT/db/backups" +STATE_FILE="$BACKUP_DIR/.incremental_state" +mkdir -p "$BACKUP_DIR" + +# 容器名固定(compose 显式 container_name),与 docker/docker-compose.dev.yml 保持一致 +CONTAINER="ys-mysql-dev" +if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "ERROR: 容器 $CONTAINER 未运行(先 docker compose up -d)" >&2 + exit 1 +fi + +# 列出 binlog:文件名 序号 大小(mysql -N 无表头,直接出数据行) +mapfile -t BINLOGS < <( + docker exec "$CONTAINER" sh -c \ + 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e "SHOW BINARY LOGS"' 2>/dev/null \ + | awk '{print $1}' +) +if [[ ${#BINLOGS[@]} -eq 0 ]]; then + echo "ERROR: SHOW BINARY LOGS 无输出(binlog 未开启或凭据不对)" >&2 + exit 1 +fi +if [[ ${#BINLOGS[@]} -eq 1 ]]; then + echo "仅有写入中的 binlog(${BINLOGS[0]}),无已写完文件可归档" + exit 0 +fi +COMPLETED=("${BINLOGS[@]:0:${#BINLOGS[@]}-1}") # 去掉写入中的最后一个 + +LAST="$(cat "$STATE_FILE" 2>/dev/null || echo "")" +NEW=() +for f in "${COMPLETED[@]}"; do + # 与状态文件比较序号:无状态(首次)全收;有状态只收更大的 + if [[ -z "$LAST" ]] || (( ${f##*.} > ${LAST##*.} )); then + NEW+=("$f") + fi +done + +if [[ ${#NEW[@]} -eq 0 ]]; then + echo "无新增变更(上次归档至 ${LAST},无需补充增量)" + exit 0 +fi + +STAMP="$(date +%Y%m%d_%H%M%S)" +echo "归档 ${#NEW[@]} 个 binlog(${NEW[0]} ~ ${NEW[-1]})" +for f in "${NEW[@]}"; do + OUT="$BACKUP_DIR/${f}.gz" + docker exec "$CONTAINER" sh -c "tar cf - -C /var/lib/mysql '$f'" | gzip > "$OUT" + if [[ ! -s "$OUT" ]]; then + echo "ERROR: $OUT 为空" >&2 + rm -f "$OUT" + exit 1 + fi + echo " $(basename "$OUT") ($(du -h "$OUT" | cut -f1))" +done +echo "$STAMP ${NEW[-1]}" > "$STATE_FILE" + +# 保留 30 天(与全量一致;binlog 服务端默认 30 天过期,先于服务端消失无妨, +# 全量 + 其后增量链完整即满足恢复目标) +find "$BACKUP_DIR" -maxdepth 1 -name 'binlog.*.gz' -mtime +30 -print -delete +echo "增量归档完成,最新至 ${NEW[-1]}" diff --git a/scripts/health_check.sh b/scripts/health_check.sh new file mode 100644 index 0000000..029a305 --- /dev/null +++ b/scripts/health_check.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# 运维健康检查 —— 容器/磁盘/备份新鲜度/登录异常(⑨ 输出一份可读报告) +# +# 用法:在项目根目录执行 bash scripts/health_check.sh +# 每项 PASS/WARN/FAIL,最后给出结论。WARN/FAIL 时退出码非零提示运维介入。 + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ -f docker/.env ]]; then + # shellcheck disable=SC1091 + source docker/.env +fi + +ISSUES=0 +report() { printf "%-10s %s\n" "$1" "$2"; } +warn() { report "WARN" "$1"; ISSUES=$((ISSUES+1)); } +fail() { report "FAIL" "$1"; ISSUES=$((ISSUES+1)); } +pass() { report "PASS" "$1"; } + +echo "======== 衍水工资系统 健康检查 $(date '+%Y-%m-%d %H:%M:%S') ========" + +# 1. 容器健康 +for C in ys-mysql-dev ys-redis-dev; do + if ! docker inspect "$C" >/dev/null 2>&1; then + fail "容器 $C 不存在" + continue + fi + STATUS="$(docker inspect -f '{{.State.Health.Status}}' "$C" 2>/dev/null || echo none)" + RUNNING="$(docker inspect -f '{{.State.Running}}' "$C" 2>/dev/null)" + if [[ "$RUNNING" == "true" && "$STATUS" == "healthy" ]]; then + pass "容器 $C 运行中且健康" + elif [[ "$RUNNING" == "true" ]]; then + warn "容器 $C 运行中但健康检查=$STATUS" + else + fail "容器 $C 未运行" + fi +done + +# 2. 磁盘水位 +DISK_PCT="$(df -h "$ROOT" | awk 'NR==2 {gsub(/%/,"",$5); print $5}')" +if [[ -z "$DISK_PCT" ]]; then + warn "磁盘信息读取失败" +elif (( DISK_PCT < 80 )); then + pass "磁盘使用率 ${DISK_PCT}%(阈值 80%)" +elif (( DISK_PCT < 90 )); then + warn "磁盘使用率 ${DISK_PCT}%(建议清理)" +else + fail "磁盘使用率 ${DISK_PCT}%(高危,立即清理)" +fi + +# 3. 备份新鲜度 +BACKUP_DIR="$ROOT/db/backups" +LATEST="$(ls -t "$BACKUP_DIR"/payroll_*.sql.gz 2>/dev/null | head -1)" +if [[ -z "$LATEST" ]]; then + warn "未找到任何备份(应每日备份)" +else + AGE_H=$(( ($(date +%s) - $(stat -c %Y "$LATEST")) / 3600 )) + if (( AGE_H < 24 )); then + pass "最新备份 $(basename "$LATEST"),${AGE_H} 小时前" + elif (( AGE_H < 48 )); then + warn "最新备份已 ${AGE_H} 小时前(超过 24 小时)" + else + fail "最新备份已 ${AGE_H} 小时前(备份任务可能已停)" + fi +fi + +# 4. Redis 存活(密码经容器内环境变量展开,不出现在宿主进程命令行; +# 写法同 backup_full.sh 的 MYSQL_ROOT_PASSWORD) +if [[ -n "${REDIS_PASSWORD:-}" ]]; then + if docker exec ys-redis-dev sh -c 'redis-cli --no-auth-warning -a "$REDIS_PASSWORD" ping' 2>/dev/null | grep -q PONG; then + pass "Redis 响应 PONG" + else + warn "Redis ping 无响应(检查 REDIS_PASSWORD 或容器状态)" + fi +else + warn "docker/.env 缺 REDIS_PASSWORD,跳过 Redis 探活" +fi + +# 5. 登录日志异常(24h 失败次数) +if [[ -n "${MYSQL_ROOT_PASSWORD:-}" ]] && docker inspect ys-mysql-dev >/dev/null 2>&1; then + FAILS=$(docker exec ys-mysql-dev sh -c \ + "mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" ${MYSQL_DATABASE:-payroll} -N -B -e \" + SELECT COUNT(*) FROM login_logs + WHERE success = 0 AND create_time > NOW() - INTERVAL 24 HOUR;\" 2>/dev/null" || echo "?") + if [[ "$FAILS" == "?" ]]; then + warn "登录日志查询失败" + elif (( FAILS < 10 )); then + pass "24h 登录失败 ${FAILS} 次(阈值 10)" + else + warn "24h 登录失败 ${FAILS} 次(可能有暴力破解,建议核查)" + fi +else + warn "跳过登录日志检查(无凭据或容器未运行)" +fi + +echo "==============================================================" +if (( ISSUES == 0 )); then + echo "结论:全部通过,系统健康。" + exit 0 +else + echo "结论:发现 $ISSUES 项需关注(见上)。" + exit 1 +fi diff --git a/scripts/restore_drill.sh b/scripts/restore_drill.sh new file mode 100644 index 0000000..51da158 --- /dev/null +++ b/scripts/restore_drill.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# 恢复演练 —— 把最近一份备份恢复到「临时库」并核对行数(⑨:备份不演练 = 没有备份) +# +# 演练绝不直接覆盖生产库;临时库名 payroll_drill_<时间戳>,核对完由 +# 调用方决定是否删除(本脚本默认不 DROP,安全优先)。 +# +# 用法:在项目根目录执行 bash scripts/restore_drill.sh +# 产物:临时库 + 终端行数对比报告;--drop-exec 参数在核对通过后删除临时库。 + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ -f docker/.env ]]; then + # shellcheck disable=SC1091 + source docker/.env +else + echo "ERROR: docker/.env 不存在" >&2 + exit 1 +fi + +: "${MYSQL_DATABASE:?docker/.env 缺 MYSQL_DATABASE}" +: "${MYSQL_ROOT_PASSWORD:?docker/.env 缺 MYSQL_ROOT_PASSWORD}" + +CONTAINER="ys-mysql-dev" +if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "ERROR: 容器 $CONTAINER 未运行" >&2 + exit 1 +fi + +BACKUP_DIR="$ROOT/db/backups" +LATEST="$(ls -t "$BACKUP_DIR"/payroll_*.sql.gz 2>/dev/null | head -1)" +if [[ -z "$LATEST" ]]; then + echo "ERROR: 未找到备份文件(先 bash scripts/backup_full.sh)" >&2 + exit 1 +fi + +DRILL_DB="payroll_drill_$(date +%Y%m%d_%H%M%S)" + +# 0. --drop-exec:演练前先清掉**全部**历史遗留 drill 库(含上一轮「保留供 +# 核对」的那份——否则每演练一轮净增一份整库拷贝,磁盘被无人认领的 +# drill 库吃满),演练完成后本轮临时库也随之删除 +if [[ "${1:-}" == "--drop-exec" ]]; then + OLD_DBS=$(docker exec "$CONTAINER" sh -c \ + "mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SHOW DATABASES LIKE 'payroll_drill_%';\"" 2>/dev/null || true) + for OLD in $OLD_DBS; do + docker exec "$CONTAINER" sh -c \ + "mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -f drop \"$OLD\"" + echo "已清理历史演练库 $OLD" + done +fi + +echo "演练恢复:$(basename "$LATEST") → 临时库 $DRILL_DB" + +# 1. 创建临时库(mysqladmin create 无反引号转义问题) +docker exec "$CONTAINER" sh -c \ + "mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" create \"$DRILL_DB\"" + +# 2. 解压导入 +gunzip -c "$LATEST" | docker exec -i "$CONTAINER" sh -c \ + "mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \"$DRILL_DB\"" + +# 3. 行数核对:**全部业务表**(新增表必须同步补入本清单——备份漏表时 +# 演练要能发现;alembic_version 不列,行数恒 1 无核对价值) +TABLES=( + adjustment_orders approval_orders approval_steps business_items business_roles + business_secondary_allocations business_status_logs businesses customer_rating_grades + customers login_logs monthly_settlements operation_logs ownership_change_logs + param_change_batches param_definitions param_values performance_ledgers permissions + personnel personnel_plans personnel_regions products receipts role_permissions roles + settlement_items shipment_items shipments system_settings user_roles users +) + +echo "" +echo "======== 行数对比(源库 vs 演练库)========" +printf "%-24s %12s %12s %6s\n" "表名" "源库" "演练库" "结果" +ALL_OK=1 +for T in "${TABLES[@]}"; do + SRC=$(docker exec "$CONTAINER" sh -c \ + "mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SELECT COUNT(*) FROM $MYSQL_DATABASE.$T;\" 2>/dev/null" || echo "?") + DST=$(docker exec "$CONTAINER" sh -c \ + "mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SELECT COUNT(*) FROM $DRILL_DB.$T;\" 2>/dev/null" || echo "?") + if [[ "$SRC" == "?" || "$DST" == "?" ]]; then + # 查询失败(表缺失/权限/客户端故障)不可与「数据一致」混淆:? + # 与 ? 相等会误报可恢复——备份真的漏表时演练必须红 + MARK="✗" + ALL_OK=0 + elif [[ "$SRC" == "$DST" ]]; then + MARK="✓" + else + MARK="✗" + ALL_OK=0 + fi + printf "%-24s %12s %12s %6s\n" "$T" "$SRC" "$DST" "$MARK" +done +echo "==========================================" + +if [[ "$ALL_OK" == "1" ]]; then + echo "" + echo "结果:全部表行数一致,备份可恢复。" + if [[ "${1:-}" == "--drop-exec" ]]; then + docker exec "$CONTAINER" sh -c \ + "mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -f drop \"$DRILL_DB\"" + echo "本轮临时库 $DRILL_DB 已删除(历史遗留亦在演练前清理)。" + else + echo "临时库 $DRILL_DB 保留(核对用);确认无误后重跑本脚本加 --drop-exec," + echo "会先清掉包括它在内的全部演练库再演练,本轮完成后同样即删。" + fi + exit 0 +else + echo "" + echo "结果:存在不一致,请检查备份文件与数据库结构。" >&2 + echo "临时库 $DRILL_DB 保留供排查。" >&2 + exit 1 +fi