diff --git a/docs/T+对接数据需求清单_2026-10-15.xlsx b/docs/T+对接数据需求清单_2026-10-15.xlsx new file mode 100644 index 0000000..26881ec Binary files /dev/null and b/docs/T+对接数据需求清单_2026-10-15.xlsx differ diff --git a/docs/superpowers/plans/2026-10-12-剩余开发计划.md b/docs/superpowers/plans/2026-10-12-剩余开发计划.md index 56a7444..e5e2d9d 100644 --- a/docs/superpowers/plans/2026-10-12-剩余开发计划.md +++ b/docs/superpowers/plans/2026-10-12-剩余开发计划.md @@ -2,7 +2,7 @@ > **面向 AI 代理的工作者:** 首工作块(M1)实施时使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现本计划第二章;步骤使用复选框(`- [ ]`)语法跟踪进度。第一章路线图供全局导航。 -**目标:** 在需求裁决全部落定(2026-10-12)后,为剩余 5 大模块与 T+ 同步给出推进路线;并交付首个工作块「系统设置与参数体系(#28)」的可执行实施计划(任务级,含测试与验证)。 +**目标:** 在需求裁决全部落定(2026-10-12)后,为剩余 5 大模块与 T+ 同步给出推进路线;并交付首个工作块「系统设置与参数体系(#28)」的可执行实施计划(任务级,含测试与验证)。2026-10-15 功能清单核查补充(二次分配方案、工作台、复核审批、月结与调整单、备份恢复运维、公司信息配置)已并入路线图(1.1/1.3/1.4)并收录为第三章。 **架构:** 沿用现有六层(api → services → repositories → models,schemas 承载请求/响应,core 横切);新增「公式模板注册表(代码内有限集合)+ 参数按生效日期时点化存储 + 计算预览」作为核算模块的输入契约,不引入规则引擎。 @@ -40,11 +40,17 @@ ### 1.1 推荐顺序与依赖 ```text -① 系统设置与参数体系(#28) ← 地基:岗位角色 + 参数 + 公式模板 -② 基础数据管理 ← 人员引用 ① 岗位角色(外键上游) -③ 业务数据管理 ← 引用 ② 人员/产品/客户 +① 系统设置与参数体系(#28)✅ ← 地基:岗位角色 + 参数 + 公式模板 +│ 增量(2026-10-15):公司信息配置;审批开关走参数体系 +② 基础数据管理 ✅ ← 人员引用 ① 岗位角色(外键上游) +③ 业务数据管理 ✅ ← 引用 ② 人员/产品/客户 +│ 增量(2026-10-15):二次分配方案(合同刚建立时录入) ④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据 -⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;⑥ 写入 ②③ 目标表,受 IT 字段映射制约 +│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(语义待澄清) +⑦ 复核审批流(新) ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展 +⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约 +⑧ 工作台(新) ← 消费 ④⑤⑥⑦ 数据的聚合展示层 +⑨ 备份恢复与运维检查(新) ← 独立于业务块,可与任一批次并行,建议尽早 ``` ### 1.2 顺序论证(要点) @@ -59,32 +65,37 @@ | 块 | 范围界定 | 验收标准(一句话) | | --- | --- | --- | -| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览 | 管理员可新增岗位并绑定模板;任一参数变更产生含变更人/时间/项/旧值/新值/生效日期的记录;任意业务日期可解析当时快照;预览精确复现 360 天=6‰、361 天=4‰;301 基线不回归 | -| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更 | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 | -| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器 | 一笔业务可多产品多批次发货、多笔回款;回款天数按「发货日首日=1」自动算;折算走精确分数;评级只出等级不影响金额 | -| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕 | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成 | -| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致 | +| ① 系统设置与参数体系 | 岗位角色 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** | ① 系统设置与参数体系 | 独立可交付;预览工具可作业务对账入口先行验收 | -| **M2** | ② + ③ | 合并交付:一次表结构评审把 T+ 预留列与报表字段清单同时定稿 | -| **M3** | ④ 绩效工资核算 | 核心钱袋子;测试重点在精度与参数回溯 | -| **M4** | ⑤ 报表 + ⑥ T+ 同步(并行) | ⑥ 受 IT 制约,谁先就绪谁先交 | +| **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 | 经理个人户 8‰(不设参数);含运费口径;协助 25%(不设参数) | +| ① | 需规 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 | 26 天示例;含运费公式(2.2.4/2.3.3 整段) | -| ④ | 需规 2.3.1–2.3.3;对照表 #3/#4/#6/#7/#11/#15/#16/#17/#20–#24 | 协助 25%;经理 8‰;26 天示例;含运费公式 | -| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R) | — | +| ③ | 需规 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) | --- @@ -599,6 +610,118 @@ git commit -m "test(system): 参数体系模块用例与测试记录" | 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.4 对路线图的影响(本次已修订) + +1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容;第三章待 3.3 各项澄清后,按第二章同粒度补齐 M3 各增量块的任务级实施计划。 + ## 验证方式 1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款); diff --git a/docs/功能模块清单.xlsx b/docs/功能模块清单.xlsx deleted file mode 100644 index aa94e3d..0000000 Binary files a/docs/功能模块清单.xlsx and /dev/null 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-13.xlsx b/docs/模块页面功能清单_2026-10-13.xlsx index b4ccd01..0c76747 100644 Binary files a/docs/模块页面功能清单_2026-10-13.xlsx and b/docs/模块页面功能清单_2026-10-13.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/frontend/src/api/approvals.ts b/frontend/src/api/approvals.ts new file mode 100644 index 0000000..6a23de7 --- /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/businesses.ts b/frontend/src/api/businesses.ts index f9c10cf..89d10df 100644 --- a/frontend/src/api/businesses.ts +++ b/frontend/src/api/businesses.ts @@ -50,9 +50,20 @@ export interface BusinessItem { 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 { @@ -96,6 +107,8 @@ export interface BusinessCreateParams { is_special?: number special_note?: string | null items: BusinessItemParams[] + /** 二次分配方案(只出现在合同刚建立时;空=不设置) */ + secondary_allocations?: { personnel_id: number; ratio: string }[] remark?: string | null } @@ -111,6 +124,8 @@ export interface BusinessUpdateParams { is_special?: number special_note?: string | null items: BusinessItemParams[] + /** 二次分配方案(全量替换;空数组=清空) */ + secondary_allocations?: { personnel_id: number; ratio: string }[] remark?: string | null } diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts new file mode 100644 index 0000000..d2ecf2a --- /dev/null +++ b/frontend/src/api/settings.ts @@ -0,0 +1,40 @@ +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' }, + }) +} + +/** 登录页消费:无令牌也可调用(后端白名单端点) */ +export function getPublicSettings(): Promise { + return request({ url: '/v1/system/settings/public', method: 'get' }) +} diff --git a/frontend/src/composables/useAppMenu.ts b/frontend/src/composables/useAppMenu.ts index 857e743..2bc464b 100644 --- a/frontend/src/composables/useAppMenu.ts +++ b/frontend/src/composables/useAppMenu.ts @@ -31,12 +31,18 @@ export function useAppMenu() { 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 ?? '', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b3fb951..225ee4b 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -12,6 +12,9 @@ declare module 'vue-router' { title?: string /** 仅管理员可访问 —— 菜单隐藏 + 守卫兜底(直接输 URL 会被弹回首页) */ requiresAdmin?: boolean + /** 任一角色可访问(与 requiresAdmin 互斥使用;admin 须显式列入)—— + * 菜单按角色过滤 + 守卫兜底,用于审批等职能页面 */ + allowedRoles?: string[] /** 是否出现在侧边菜单(默认 true);false 用于排除登录页等 */ menu?: boolean /** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */ @@ -64,49 +67,67 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/system/params/index.vue'), meta: { title: '计提参数', group: '系统管理', requiresAdmin: true, 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: 7 }, + 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: 8 }, + meta: { title: '人员管理', group: '基础数据', order: 10 }, }, { path: '/base/products', name: 'base-products', component: () => import('@/views/base/products/index.vue'), - meta: { title: '产品管理', group: '基础数据', order: 9 }, + meta: { title: '产品管理', group: '基础数据', order: 11 }, }, { path: '/base/customers', name: 'base-customers', component: () => import('@/views/base/customers/index.vue'), - meta: { title: '客户管理', group: '基础数据', order: 10 }, + meta: { title: '客户管理', group: '基础数据', order: 12 }, }, { // 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin path: '/business/businesses', name: 'business-businesses', component: () => import('@/views/business/businesses/index.vue'), - meta: { title: '业务管理', group: '业务数据', order: 11 }, + meta: { title: '业务管理', group: '业务数据', order: 13 }, }, { path: '/business/shipments', name: 'business-shipments', component: () => import('@/views/business/shipments/index.vue'), - meta: { title: '发货管理', group: '业务数据', order: 12 }, + meta: { title: '发货管理', group: '业务数据', order: 14 }, }, { path: '/business/receipts', name: 'business-receipts', component: () => import('@/views/business/receipts/index.vue'), - meta: { title: '回款管理', group: '业务数据', order: 13 }, + meta: { title: '回款管理', group: '业务数据', order: 15 }, }, { // 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额, @@ -114,7 +135,7 @@ const routes: RouteRecordRaw[] = [ path: '/business/rating', name: 'business-rating', component: () => import('@/views/business/rating/index.vue'), - meta: { title: '客户评级计算器', group: '业务数据', order: 14 }, + meta: { title: '客户评级计算器', group: '业务数据', order: 16 }, }, ], }, @@ -163,6 +184,12 @@ router.beforeEach(async (to) => { 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/views/business/businesses/index.vue b/frontend/src/views/business/businesses/index.vue index 45cfa7c..2773ee5 100644 --- a/frontend/src/views/business/businesses/index.vue +++ b/frontend/src/views/business/businesses/index.vue @@ -131,6 +131,69 @@ const form = reactive({ remark: '', }) +// ==================== 二次分配方案(合同刚建立时可录入;不强制合计 100%) ==================== + +interface AllocationDraft { + personnel_id: number | undefined + /** 百分比数字字符串(两位小数),与后端 NUMERIC(5,2) 同口径 */ + ratio: string +} + +const allocationRows = ref([]) + +/** 合计(0-999.99 不做上限:需求原文不要求合计 100%);余额 = 100 - 合计 */ +const allocationTotal = computed(() => { + let total = 0 + for (const row of allocationRows.value) { + const value = Number(row.ratio) + if (Number.isFinite(value)) total += value + } + return total +}) + +const allocationHint = computed(() => { + if (allocationRows.value.length === 0) return '' + const total = Number(allocationTotal.value.toFixed(2)) + if (total === 100) return `二次分配合计 ${total.toFixed(2)}%` + const rest = Number((100 - total).toFixed(2)) + return `二次分配合计 ${total.toFixed(2)}%,余额 ${rest.toFixed(2)}%(归属待业务确认)` +}) + +function addAllocationRow(): void { + allocationRows.value.push({ personnel_id: undefined, ratio: '' }) +} + +function removeAllocationRow(index: number): void { + allocationRows.value.splice(index, 1) +} + +/** 分配行校验:人员非重、比例 0-100 两位小数;空行(未选人员且比例为 0)自动忽略 */ +function validateAllocationRows(rows: AllocationDraft[]): string | null { + const active = rows.filter((r) => r.personnel_id !== undefined || Number(r.ratio) > 0) + const seen = new Set() + for (let i = 0; i < active.length; i += 1) { + const row = active[i] + const no = `第 ${i + 1} 个` + if (row.personnel_id === undefined) return `${no}参与人未选择` + if (seen.has(row.personnel_id)) return `${no}参与人重复` + seen.add(row.personnel_id) + const value = row.ratio.trim() + if (!value) return `${no}分配比例未填写` + if (!AMOUNT_PATTERN.test(value) || ZERO_PATTERN.test(value)) { + return `${no}分配比例须为大于 0 的数字,最多两位小数` + } + if (Number(value) > 100) return `${no}分配比例不能超过 100%` + } + return null +} + +/** 提交行:空行剔除;人员/比例均按提交格式直出 */ +function buildAllocationParams(rows: AllocationDraft[]): { personnel_id: number; ratio: string }[] { + return rows + .filter((r) => r.personnel_id !== undefined) + .map((r) => ({ personnel_id: r.personnel_id as number, ratio: r.ratio.trim() })) +} + /** 默认落在首个类别上,避免空类别提交被后端拒 */ function emptyItem(): ItemDraft { return { @@ -172,6 +235,7 @@ function openCreate(): void { remark: '', }) itemRows.value = [emptyItem()] // 预置一空行,用户可直接填写 + allocationRows.value = [] dialogVisible.value = true formRef.value?.clearValidate() } @@ -203,6 +267,10 @@ async function openEdit(row: BusinessItem): Promise { cost_amount: it.cost_amount ?? '', guided_price: it.guided_price ?? '', })) + allocationRows.value = (detail.secondary_allocations ?? []).map((a) => ({ + personnel_id: a.personnel_id, + ratio: a.ratio, + })) formRef.value?.clearValidate() } @@ -266,6 +334,11 @@ async function handleSubmit(): Promise { ElMessage.warning(rowError) return } + const allocError = validateAllocationRows(allocationRows.value) + if (allocError) { + ElMessage.warning(allocError) + return + } submitting.value = true try { @@ -277,6 +350,7 @@ async function handleSubmit(): Promise { is_special: form.is_special, special_note: form.is_special === 1 ? form.special_note.trim() || null : null, items: buildItemParams(itemRows.value), + secondary_allocations: buildAllocationParams(allocationRows.value), remark: form.remark.trim() || null, }) ElMessage.success('已保存') @@ -289,6 +363,7 @@ async function handleSubmit(): Promise { is_special: form.is_special, special_note: form.is_special === 1 ? form.special_note.trim() || null : null, items: buildItemParams(itemRows.value), + secondary_allocations: buildAllocationParams(allocationRows.value), remark: form.remark.trim() || null, }) ElMessage.success('业务已创建') @@ -752,6 +827,58 @@ onMounted(() => { + +
+ + 二次分配方案 + (可选;比例不要求合计 100%,分摊计算待业务确认后另批) + + 添加参与人 +
+ + + + + + + + + + + + +
+ {{ allocationHint }} +
+
产品明细(至少 1 行,编辑为全量替换) 添加一行 @@ -1209,6 +1336,32 @@ onMounted(() => { font-size: 13px; } +.alloc-note { + margin-left: 6px; + font-weight: 400; + font-size: 11px; + color: var(--ys-ink-3); +} + +.alloc-total { + margin: -8px 0 16px; + padding: 6px 10px; + font-size: 12px; + color: var(--ys-ink-3); + background: #f4f5f8; + border-radius: 0 0 6px 6px; +} + +.alloc-total.is-full { + color: var(--ys-indigo); + background: rgba(46, 75, 168, 0.07); +} + +.alloc-empty { + font-size: 12px; + color: var(--ys-ink-3); +} + .items-table { margin-bottom: 16px; } diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue index 244dc6a..c23a04f 100644 --- a/frontend/src/views/login/index.vue +++ b/frontend/src/views/login/index.vue @@ -4,6 +4,7 @@ import { onMounted, reactive, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { getCaptcha } from '@/api/auth' +import { getPublicSettings } from '@/api/settings' import { useUserStore } from '@/store/user' const router = useRouter() @@ -23,6 +24,20 @@ const form = reactive({ const captchaId = ref('') const captchaImage = ref('') +/** 品牌名:默认衍水集团,公司信息设置页改了以后登录页同步生效(public 端点) */ +const brandName = ref('衍水集团') +const brandLogo = ref(null) + +async function loadBrand(): Promise { + try { + const data = await getPublicSettings() + if (data.company_name) brandName.value = data.company_name + brandLogo.value = data.logo_url + } catch { + // 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录 + } +} + const rules: FormRules = { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }], @@ -72,7 +87,10 @@ async function handleSubmit(): Promise { } } -onMounted(refreshCaptcha) +onMounted(() => { + refreshCaptcha() + loadBrand() +})