Files
shanxia2.0/docs/superpowers/specs/2026-08-18-reimburse-apply-batch-import-design.md
T
2026-08-19 09:14:37 +08:00

13 KiB
Raw Blame History

报销申请批量导入(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。

可解析时业务成功响应(示意):

{
  "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/)。