This commit is contained in:
zlwu committed 2026-08-19 09:14:37 +08:00
1 parent c3bd2c00ae
commit a52fe77e34
39 files changed
+609647 -9238

No files matched your search

@@ -0,0 +1,297 @@
# 报销申请批量导入(Excel 下拉模板)— 设计文档
> **日期**: 2026-08-18
> **状态**: 已实现(待联调验收)
> **仓库**: shanxia2.0(`backend/` + `web-admin/`)
> **入口页**: `http://localhost:3006/#/bx/reimburse/apply`
> **界面模式**: P01 标准列表扩展(工具栏 + 导入弹窗);参考 Fresh Mint 工具栏按钮规则
> **实现路径**: 方案 A — 后端生成模板 + 后端导入
## 背景与问题
报销申请页已支持新增 / 编辑 / 删除。用户希望增加**批量导入**,并特别要求:
1. **费用项目**、**申请人**在 Excel 中为下拉框(非自由文本)。
2. 可**下载模板**,模板内**内置下拉数据**,并带**填写说明与示例行**,便于理解。
现状难点:
- 费用项目在页面是树形选择;Excel 只能做扁平下拉。
- 申请人选项接口 label 仅为昵称,易重名。
- 一单多明细;导入行模型需显式分组。
- 仓库内 CRM「下载模板」仅有表头,**无** DataValidation 下拉;本需求需新增能力。
## 已确认决策
| # | 决策 | 说明 |
|---|------|------|
| D1 | **实现路径 A** | 后端 `openpyxl` 动态生成模板(隐藏选项表 + DataValidation);后端解析导入;前端只做下载/上传与结果展示 |
| D2 | **行模型 B** | Excel **一行 = 一条明细**;相同「分组号」合成一张报销单 |
| D3 | **单头一致性** | 同组每一行都须填写申请人 / 报销日期 / 标题(标题可空),且**必须完全一致**;不一致则**整组失败** |
| D4 | **空分组号** | 允许为空;空则该行**单独成一张单** |
| D5 | **仅草稿** | 导入成功写入「待提交」草稿;不自动提交审批 |
| D6 | **标题可空** | 同组标题都空 → 按现有规则自动生成;若填写则同组必须相同 |
| D7 | **费用项范围** | 下拉含当前租户全部**启用**节点(含一级父节点),与页面 `check-strictly` 一致 |
| D8 | **部分成功** | 合法分组写入;失败组/行返回错误清单;不因单组失败回滚整文件 |
| D9 | **不做发票/附件** | v1 不导入关联发票与附件;用户可事后编辑草稿补票 |
| D10 | **模板含说明+示例** | 「填写说明」工作表 + 数据表内示例行(导入时跳过) |
---
## 一、目标与范围
### 目标
在报销申请列表提供「下载模板」「批量导入」,使用户能通过带下拉的 Excel 批量创建多张(含多明细)草稿报销单。
### 范围内
- 后端:导入模板生成、导入解析/分组/校验/写库、结果结构
- Web:`#/bx/reimburse/apply` 工具栏按钮 + 导入结果弹窗
- 费用项目全路径下拉、申请人唯一展示名下拉
- 与现有 `create_with_details` / `resolve_applicant` 对齐
### 范围外(v1)
- 发票、附件导入
- 导入后自动提交审批
- 按单号更新已有单据(仅新建)
- Excel 级联下拉(`INDIRECT`)
- 前端本地解析后循环调用单条创建 API
- `bx-mobile` / 小程序导入
---
## 二、Excel 模板形态
### 2.1 工作表
| 工作表 | 可见性 | 用途 |
|--------|--------|------|
| `报销导入` | 可见 | 用户填写;含表头、示例行、数据验证下拉 |
| `填写说明` | 可见 | 规则说明、列说明、常见错误 |
| `_选项_费用项目` | 隐藏 | A 列:下拉展示文案;B 列:id(内部) |
| `_选项_申请人` | 隐藏 | A 列:下拉展示文案;B 列:userId(内部) |
生成时按**当前登录用户**与租户实时查询选项,不使用静态 xlsx 文件。
### 2.2 `报销导入` 列定义
| 列名 | 必填 | 说明 |
|------|------|------|
| 分组号 | 否 | 相同非空值合成一张单;空 = 该行单独成单 |
| 申请人 | 是 | 下拉;展示 `昵称(工号)`(见 2.4) |
| 报销日期 | 是 | `YYYY-MM-DD`;同组必须一致 |
| 报销标题 | 否 | 同组都空则自动生成;填写则同组必须相同 |
| 费用项目 | 是 | 下拉;全路径,分隔符 ` / ` |
| 金额(元) | 是 | 数值且 > 0 |
| 报销事由 | 是 | 对应明细 `remark` |
| 发生时间 | 否 | `YYYY-MM-DD`;对应明细 `occur_date` |
### 2.3 下拉实现(openpyxl)
- 使用 `DataValidation(type="list")`,`formula1` 引用隐藏表列区域(如 `=_选项_费用项目!$A$2:$A$N`)。
- **禁止**把选项拼进公式字符串(255 字符限制)。
- `showErrorMessage=True`,提示「请从下拉列表选择」。
- 下拉仅为 UX;导入以服务端当前库精确匹配为准。
### 2.4 展示文案与匹配键
**费用项目**
- 展示:祖先全路径,分隔符为 ` / `(半角斜杠两侧各一空格),例如 `差旅费 / 市内交通`;一级节点仅本级名称。
- 仅 `status=启用` 的节点进入选项表。
- 若规范化后全路径冲突:在文案末尾追加 `(编码:xxx)` 或短 id 后缀保证唯一;导入匹配同一套规则。
- 匹配:导入单元格文本 trim 后与选项 A 列精确相等 → 得到 id;失败则该行错误。
**申请人**
- 展示:`{nickName}({userNo})`;`userNo` 为空时用稳定后缀(如用户 id 后 6 位)保证唯一。
- 有 `bx:reimburseApply:changeApplicant` 或 `super_admin`:选项 = 本租户全部有效用户。
- 无权限:选项仅当前用户;导入仍走 `ReimburseService.resolve_applicant`(无权限强制本人)。
- 匹配:精确匹配展示文案 → userId。
### 2.5 填写说明与示例(D10)
**`填写说明` 工作表**(要点,实现时可微调文案):
1. 请使用本系统「下载模板」获取最新文件;费用项/人员变更后请重新下载。
2. 推荐 Excel 或 WPS **桌面版**;在线表格可能丢失下拉。
3. **一行一条费用明细**;相同「分组号」合成一张报销单。
4. 分组号可空:空则该行单独成单。
5. 同一分组内:申请人、报销日期、报销标题必须每行都填且完全一致(标题可都留空以自动生成)。
6. 费用项目、申请人请用下拉选择,勿手改名称。
7. 灰色**示例行仅供参考,导入时自动跳过**,请从示例下方空行开始填写。
8. 导入结果为「待提交」草稿;发票请导入后在系统内关联。
9. 请勿修改或删除隐藏的 `_选项_*` 工作表。
**`报销导入` 示例行**(建议 2~3 行,浅灰底 +「示例」标识,导入跳过):
| 分组号 | 申请人 | 报销日期 | 报销标题 | 费用项目 | 金额 | 报销事由 | 发生时间 |
|--------|--------|----------|----------|----------|------|----------|----------|
| G1 | (当前用户展示名) | (今日) | | (选项表第一项路径) | 50.00 | 【示例】地铁通勤,请删除本行 | |
| G1 | (同上) | (同上) | | (选项表第二项或同第一项) | 300.00 | 【示例】住宿,请删除本行 | (昨日) |
| | (同上) | (同上) | | (任选项) | 20.00 | 【示例】空分组号单独成单 | |
示例行识别规则(按优先级):
1. 首列或隐藏标记列含 `__EXAMPLE__` / 约定标记;或
2. 「报销事由」以 `【示例】` 开头;或
3. 生成时写入的固定行号范围(模板内注释说明)。
推荐采用 **事由前缀 `【示例】` + 行底色**,解析简单、用户可见。
---
## 三、导入校验与写库
### 3.1 流程
```
上传 xlsx
→ 读「报销导入」表,校验表头
→ 跳过空行、示例行
→ 按分组号分桶(空分组号 → 每行独立桶)
→ 对每个分组:
校验单头一致(申请人/日期/标题)
解析申请人、费用项、金额、事由、发生时间
任一行失败 → 整组失败,不写库
全部通过 → create_with_details(draft)
→ 汇总 success / errors 返回
```
### 3.2 分组单头规则(D3 / D6)
对分组内所有数据行:
- `申请人`、`报销日期` 字符串 trim 后必须完全一致。
- `报销标题`:要么全部为空(自动生成),要么全部非空且文本完全一致;**不允许**组内「有的空有的不空」。
- 不一致 → 整组错误,例如:`同组申请人不一致` / `同组报销标题不能部分为空`。
### 3.3 字段校验
| 字段 | 规则 |
|------|------|
| 申请人 | 选项匹配成功;再 `resolve_applicant` |
| 报销日期 | 可解析为日期;建议规范为 `YYYY-MM-DD` |
| 费用项目 | 选项匹配且仍为启用 |
| 金额 | 数值且 > 0 |
| 报销事由 | 非空 trim |
| 发生时间 | 空或合法日期 |
### 3.4 写库
- 复用 `ReimburseService.create_with_details`(及 API 层 `_build_details_data` 同类逻辑)。
- 状态:待提交草稿(与页面「保存草稿」一致)。
- 不调用 submit;不写 `invoice_ids` / `invoice_links`。
- `create_by` = 当前操作人;`applicant_*` = 解析后的申请人。
- **部分成功(D8)**:成功组 commit(或按组 flush+commit 策略保证已成功组不因后续失败回滚);失败组仅进 `errors`。
- 建议上限:单次最多 **500** 行有效数据(可常量配置);超出返回 400 提示拆分。
### 3.5 错误与结果结构
整文件无法解析(非 xlsx、缺必要表头)→ HTTP 400。
可解析时业务成功响应(示意):
```json
{
"successGroups": 2,
"successRows": 3,
"failGroups": 1,
"failRows": 2,
"created": [
{ "groupNo": "G1", "applyId": "...", "applyCode": "..." }
],
"errors": [
{ "groupNo": "G2", "rows": [5, 6], "message": "同组申请人不一致" },
{ "groupNo": "", "rows": [8], "message": "费用项目「xxx」不存在或已停用" }
]
}
```
`rows` 为 Excel 行号(含表头时从 1 计,与用户所见一致;实现时在说明中写明)。
---
## 四、API 与前端
### 4.1 后端 API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/reimburses/import-template` | 下载模板(StreamingResponse xlsx) |
| POST | `/api/reimburses/import` | `multipart` 文件字段(如 `file`) |
- 认证:与报销申请其它接口相同(`get_bx_current_user`)。
- 菜单权限:与报销申请列表入口一致;不新开「导入」权限码(若产品后续要单独控权可再加)。
- 改申请人:不新增权限码,沿用 `bx:reimburseApply:changeApplicant`。
落点建议:
- 路由:`backend/app/api/bx/reimburse.py`
- 服务:`backend/app/services/bx/reimburse_import.py`(或同目录等价命名)
- Excel 工具:可抽 `backend/app/utils/excel_data_validation.py`(隐藏表 + list 验证),供后续其它导入复用
### 4.2 前端
页面:`web-admin/src/views/bx/reimburse/apply/index.vue`
工具栏(「新增报销申请」之后,**中性描边** `btn-ghost`):
1. **下载模板**
2. **批量导入**
导入弹窗:
- 选择 `.xlsx` / `.xls`
- 文案提示:请先下载最新模板;示例行自动跳过;结果为待提交草稿
- 上传后展示成功组数/行数、失败清单(分组号、行号、原因)
- 关闭后刷新列表
API 封装:`web-admin/src/api/bx`(或现有 `reimburseApi`)增加 `downloadImportTemplate`、`importExcel`。
**不做**:用 `ArtExcelImport` 前端解析后循环 `create`。
UI 约束:无遮罩模糊;按钮带图标;符合 Fresh Mint「仅首主按钮用主色」。
---
## 五、风险与验收
### 5.1 风险对策
| 风险 | 对策 |
|------|------|
| 下拉被绕过 | 服务端精确匹配当前库 |
| 昵称重名 | `昵称(工号)` 唯一展示 |
| 费用项同名 | 全路径;冲突加后缀 |
| 旧模板 | 说明要求重新下载;导入以当前主数据为准 |
| 无权限改申请人 | `resolve_applicant` 强制本人 |
| 示例入库 | `【示例】` 前缀跳过 |
| WPS/在线表丢失验证 | 说明推荐桌面版;服务端仍校验 |
### 5.2 验收标准
1. 可下载模板:含填写说明、灰色示例行、费用项与申请人下拉。
2. 示例行不入库。
3. 相同分组号且单头一致 → 一张多明细草稿。
4. 空分组号 → 每行一张单。
5. 同组单头不一致或主数据无法匹配 → 该组失败,其它组合法写入。
6. 无改申请人权限时,入库申请人仍为操作人本人。
7. 状态为待提交;不关联发票、不自动提交。
8. 工具栏按钮样式符合 Fresh Mint。
---
## 六、实现落点预览
| 层 | 路径 |
|----|------|
| API | `backend/app/api/bx/reimburse.py` |
| Service | `backend/app/services/bx/reimburse_import.py` |
| Excel 工具 | `backend/app/utils/`(DataValidation 辅助) |
| 前端页 | `web-admin/src/views/bx/reimburse/apply/index.vue` |
| 前端 API | `web-admin/src/api/bx`(reimburse 相关) |
下一步:经用户确认本 spec 后,使用 writing-plans 产出实现计划(`docs/superpowers/plans/`)。