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

898 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 报销申请批量导入(Excel 下拉模板)Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在报销申请页提供「下载模板 / 批量导入」:模板内置费用项与申请人 Excel 下拉、填写说明与示例行;导入按分组号合成多明细草稿,支持部分成功。
**Architecture:** 后端用 openpyxl 动态生成含隐藏选项表 + DataValidation 的 xlsx;导入服务解析行、按分组号分桶、校验单头一致后复用 `create_with_details` 写 `draft`。纯函数(路径文案、申请人文案、分组、示例识别)单独可测;API 薄封装。前端工具栏 + 导入结果弹窗,blob/FormData 走已有 `bxRequest`(`/api/reimburses` 已在加密排除列表)。
**Tech Stack:** FastAPI、SQLAlchemy、openpyxl、pytest、Vue 3、Element Plus、`bxRequest`
**Spec:** `docs/superpowers/specs/2026-08-18-reimburse-apply-batch-import-design.md`
---
## 文件结构
| 操作 | 文件 | 职责 |
|------|------|------|
| Create | `backend/app/utils/excel_list_validation.py` | 隐藏选项表 + list DataValidation 辅助 |
| Create | `backend/app/services/bx/reimburse_import.py` | 文案/分组纯函数、建模板、导入写库 |
| Create | `backend/tests/test_bx_reimburse_import.py` | 纯函数与模板/解析单测 |
| Modify | `backend/app/api/bx/reimburse.py` | `GET import-template`、`POST import` |
| Modify | `web-admin/src/api/bx/index.ts` | `downloadImportTemplate`、`importExcel` |
| Modify | `web-admin/src/views/bx/reimburse/apply/index.vue` | 工具栏按钮 + 导入弹窗 |
**不变:** 发票关联、自动提交、bx-mobile、CRM 导入、加密排除列表(已含 `/api/reimburses`)。
**常量约定(实现时写在 `reimburse_import.py` 顶部):**
```python
IMPORT_SHEET = "报销导入"
GUIDE_SHEET = "填写说明"
EXPENSE_OPT_SHEET = "_选项_费用项目"
APPLICANT_OPT_SHEET = "_选项_申请人"
EXAMPLE_REMARK_PREFIX = "【示例】"
MAX_IMPORT_ROWS = 500
PATH_SEP = " / "
HEADERS = [
"分组号", "申请人", "报销日期", "报销标题",
"费用项目", "金额(元)", "报销事由", "发生时间",
]
```
---
### Task 1: 纯函数 — 费用路径 / 申请人文案 / 示例行 / 分组
**Files:**
- Create: `backend/app/services/bx/reimburse_import.py`(先只放纯函数)
- Create: `backend/tests/test_bx_reimburse_import.py`
- [ ] **Step 1: 写失败单测**
```python
# backend/tests/test_bx_reimburse_import.py
from app.services.bx.reimburse_import import (
build_applicant_label,
build_expense_path_labels,
is_example_row,
partition_import_groups,
validate_group_headers,
)
def test_build_expense_path_labels_unique_and_enabled_only():
# flat nodes: id, parent_id, name, status, item_code
nodes = [
{"id": "1", "parent_id": "0", "item_name": "差旅费", "status": 1, "item_code": "CL"},
{"id": "2", "parent_id": "1", "item_name": "市内交通", "status": 1, "item_code": "JT"},
{"id": "3", "parent_id": "1", "item_name": "住宿费", "status": 0, "item_code": "ZS"},
{"id": "4", "parent_id": "0", "item_name": "办公费", "status": 1, "item_code": "BG"},
]
labels = build_expense_path_labels(nodes)
assert labels["2"] == "差旅费 / 市内交通"
assert labels["1"] == "差旅费"
assert labels["4"] == "办公费"
assert "3" not in labels # 停用
def test_build_expense_path_labels_disambiguate_collision():
nodes = [
{"id": "a", "parent_id": "0", "item_name": "同名", "status": 1, "item_code": "A1"},
{"id": "b", "parent_id": "0", "item_name": "同名", "status": 1, "item_code": "B1"},
]
labels = build_expense_path_labels(nodes)
assert labels["a"] != labels["b"]
assert "编码" in labels["a"] or "编码" in labels["b"] or labels["a"].endswith(")")
def test_build_applicant_label():
assert build_applicant_label("张三", "z001", "uid-abcdef") == "张三(z001)"
assert build_applicant_label("李四", "", "abcdefghij") == "李四(efghij)"
def test_is_example_row():
assert is_example_row({"报销事由": "【示例】地铁"}) is True
assert is_example_row({"报销事由": "正常事由"}) is False
def test_partition_empty_group_is_solo():
rows = [
{"_row": 2, "分组号": "G1", "申请人": "A", "报销日期": "2026-08-18", "报销标题": "", "费用项目": "x", "金额(元)": 1, "报销事由": "r1", "发生时间": ""},
{"_row": 3, "分组号": "G1", "申请人": "A", "报销日期": "2026-08-18", "报销标题": "", "费用项目": "y", "金额(元)": 2, "报销事由": "r2", "发生时间": ""},
{"_row": 4, "分组号": "", "申请人": "B", "报销日期": "2026-08-18", "报销标题": "", "费用项目": "z", "金额(元)": 3, "报销事由": "r3", "发生时间": ""},
]
groups = partition_import_groups(rows)
assert len(groups) == 2 # G1 一组 + 空分组号一行一组
g1 = next(g for g in groups if g["group_no"] == "G1")
assert len(g1["rows"]) == 2
solo = next(g for g in groups if g["group_no"] == "")
assert len(solo["rows"]) == 1
def test_validate_group_headers_mismatch():
rows = [
{"_row": 2, "申请人": "A", "报销日期": "2026-08-18", "报销标题": ""},
{"_row": 3, "申请人": "B", "报销日期": "2026-08-18", "报销标题": ""},
]
err = validate_group_headers(rows)
assert err and "申请人" in err
def test_validate_group_headers_title_partial_empty():
rows = [
{"_row": 2, "申请人": "A", "报销日期": "2026-08-18", "报销标题": "有标题"},
{"_row": 3, "申请人": "A", "报销日期": "2026-08-18", "报销标题": ""},
]
err = validate_group_headers(rows)
assert err and "标题" in err
```
- [ ] **Step 2: 跑测确认失败**
```bash
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_import.py -v
```
Expected: FAIL(模块/函数不存在)
- [ ] **Step 3: 实现纯函数**
在 `backend/app/services/bx/reimburse_import.py` 实现:
```python
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
EXAMPLE_REMARK_PREFIX = "【示例】"
PATH_SEP = " / "
MAX_IMPORT_ROWS = 500
HEADERS = [
"分组号", "申请人", "报销日期", "报销标题",
"费用项目", "金额(元)", "报销事由", "发生时间",
]
def build_applicant_label(nick_name: str, user_no: str, user_id: str) -> str:
name = (nick_name or user_no or user_id or "").strip() or user_id
suffix = (user_no or "").strip() or str(user_id)[-6:]
return f"{name}({suffix})"
def build_expense_path_labels(nodes: List[Dict[str, Any]]) -> Dict[str, str]:
"""返回 {id: 展示文案},仅 status==1;冲突时追加 (编码:xxx)。"""
by_id = {str(n["id"]): n for n in nodes}
enabled = [n for n in nodes if int(n.get("status") or 0) == 1]
def path_of(nid: str) -> str:
parts: List[str] = []
cur = by_id.get(nid)
guard = 0
while cur and guard < 20:
parts.append(str(cur.get("item_name") or "").strip())
pid = str(cur.get("parent_id") or "0")
if not pid or pid == "0":
break
cur = by_id.get(pid)
guard += 1
return PATH_SEP.join(reversed([p for p in parts if p]))
raw: Dict[str, str] = {str(n["id"]): path_of(str(n["id"])) for n in enabled}
# 冲突消歧
path_to_ids: Dict[str, List[str]] = {}
for iid, p in raw.items():
path_to_ids.setdefault(p, []).append(iid)
out: Dict[str, str] = {}
for p, ids in path_to_ids.items():
if len(ids) == 1:
out[ids[0]] = p
continue
for iid in ids:
code = (by_id[iid].get("item_code") or "").strip() or iid[-6:]
out[iid] = f"{p}(编码:{code})"
return out
def is_example_row(row: Dict[str, Any]) -> bool:
remark = str(row.get("报销事由") or "").strip()
return remark.startswith(EXAMPLE_REMARK_PREFIX)
def partition_import_groups(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""空分组号:每行独立一组(group_no=\"\");非空:同号合并。保持首次出现顺序。"""
ordered: List[Dict[str, Any]] = []
index: Dict[str, int] = {}
for row in rows:
g = str(row.get("分组号") or "").strip()
if not g:
ordered.append({"group_no": "", "rows": [row]})
continue
if g not in index:
index[g] = len(ordered)
ordered.append({"group_no": g, "rows": [row]})
else:
ordered[index[g]]["rows"].append(row)
return ordered
def validate_group_headers(rows: List[Dict[str, Any]]) -> Optional[str]:
if not rows:
return "分组无有效行"
apps = {str(r.get("申请人") or "").strip() for r in rows}
dates = {str(r.get("报销日期") or "").strip() for r in rows}
titles = [str(r.get("报销标题") or "").strip() for r in rows]
if len(apps) != 1 or not next(iter(apps)):
return "同组申请人不一致或为空"
if len(dates) != 1 or not next(iter(dates)):
return "同组报销日期不一致或为空"
empty_n = sum(1 for t in titles if not t)
if 0 < empty_n < len(titles):
return "同组报销标题不能部分为空"
if empty_n == 0 and len(set(titles)) != 1:
return "同组报销标题不一致"
return None
```
- [ ] **Step 4: 跑测通过**
```bash
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_import.py -v
```
Expected: PASS
- [ ] **Step 5: Commit(若用户要求提交时再执行)**
```bash
git add backend/app/services/bx/reimburse_import.py backend/tests/test_bx_reimburse_import.py
git commit -m "$(cat <<'EOF'
feat(bx): add reimburse import pure helpers for labels and grouping
EOF
)"
```
---
### Task 2: Excel list validation 工具
**Files:**
- Create: `backend/app/utils/excel_list_validation.py`
- Modify: `backend/tests/test_bx_reimburse_import.py`(追加模板下拉存在性测)
- [ ] **Step 1: 实现工具**
```python
# backend/app/utils/excel_list_validation.py
from __future__ import annotations
from typing import Iterable, Sequence
from openpyxl.utils import get_column_letter
from openpyxl.workbook.workbook import Workbook
from openpyxl.worksheet.datavalidation import DataValidation
from openpyxl.worksheet.worksheet import Worksheet
def write_option_sheet(
wb: Workbook,
sheet_title: str,
labels: Sequence[str],
ids: Sequence[str] | None = None,
*,
hidden: bool = True,
) -> str:
"""写入选项表:A=label,可选 B=id。返回 sheet_title。"""
if sheet_title in wb.sheetnames:
ws = wb[sheet_title]
wb.remove(ws)
ws = wb.create_sheet(sheet_title)
ws.append(["label", "id"] if ids is not None else ["label"])
for i, lab in enumerate(labels):
if ids is not None:
ws.append([lab, ids[i] if i < len(ids) else ""])
else:
ws.append([lab])
if hidden:
ws.sheet_state = "hidden"
return sheet_title
def add_list_validation(
ws: Worksheet,
*,
option_sheet: str,
option_rows: int,
target_col: int,
start_row: int,
end_row: int,
allow_blank: bool = True,
) -> None:
"""option_rows 为选项数据行数(不含表头)。option 在 A2:A{1+option_rows}。"""
if option_rows <= 0:
return
last = 1 + option_rows
formula = f"='{option_sheet}'!$A$2:$A${last}"
col = get_column_letter(target_col)
dv = DataValidation(
type="list",
formula1=formula,
allow_blank=allow_blank,
showErrorMessage=True,
showInputMessage=True,
)
dv.error = "请从下拉列表选择"
dv.errorTitle = "无效值"
dv.add(f"{col}{start_row}:{col}{end_row}")
ws.add_data_validation(dv)
```
- [ ] **Step 2: 最小冒烟测(内存 workbook)**
在 `test_bx_reimburse_import.py` 追加:
```python
from openpyxl import Workbook
from app.utils.excel_list_validation import add_list_validation, write_option_sheet
def test_write_option_sheet_and_validation():
wb = Workbook()
ws = wb.active
ws.title = "报销导入"
ws.append(["申请人"])
write_option_sheet(wb, "_选项_申请人", ["张三(z001)"], ["u1"], hidden=True)
add_list_validation(
ws,
option_sheet="_选项_申请人",
option_rows=1,
target_col=1,
start_row=2,
end_row=100,
)
assert wb["_选项_申请人"].sheet_state == "hidden"
assert len(ws.data_validations.dataValidation) == 1
```
- [ ] **Step 3: 跑测**
```bash
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_import.py::test_write_option_sheet_and_validation -v
```
Expected: PASS
---
### Task 3: 生成导入模板(含说明 + 示例 + 下拉)
**Files:**
- Modify: `backend/app/services/bx/reimburse_import.py`
- Modify: `backend/tests/test_bx_reimburse_import.py`
- [ ] **Step 1: 实现 `build_import_template_bytes`**
签名与要点:
```python
def build_import_template_bytes(
*,
expense_nodes: List[Dict[str, Any]],
applicants: List[Dict[str, Any]], # id, nick_name, user_no
current_applicant_label: str,
today: date,
) -> bytes:
```
行为:
1. `Workbook()`;先建 `填写说明`,写入 spec §2.5 九条说明(每条一行)。
2. 建 `报销导入`,`append(HEADERS)`。
3. `expense_labels = build_expense_path_labels(expense_nodes)`;按 path 排序写入 `_选项_费用项目`(A=label B=id)。
4. 申请人 labels:`build_applicant_label(...)`,写入 `_选项_申请人`。
5. 示例 3 行:事由以 `【示例】` 开头;申请人=`current_applicant_label`;日期=`today.isoformat()`;费用取选项前 1~2 个 label(无选项则费用列留空并在说明中提示先配置费用项);分组号 G1/G1/空;浅灰底 `PatternFill(start_color="F1F5F9", ...)`。
6. 对「申请人」列(B)、「费用项目」列(E)从行 2 到行 501 加 `add_list_validation`。
7. `wb.save(BytesIO)` 返回 bytes。
费用节点加载(供 API 调用,可同文件):
```python
def load_expense_nodes_flat(db: Session, tenant_no: str) -> List[Dict[str, Any]]:
rows = (
db.query(BxExpenseItem)
.filter(BxExpenseItem.del_flag == 0, BxExpenseItem.tenant_no == tenant_no)
.order_by(BxExpenseItem.sort_order.asc(), BxExpenseItem.create_time.desc())
.all()
)
return [
{
"id": r.id,
"parent_id": r.parent_id or "0",
"item_name": r.item_name,
"status": r.status,
"item_code": r.item_code,
}
for r in rows
]
```
申请人列表:查询 `SysUser`(同 `/users/options` 过滤);若 `not ReimburseService.can_change_applicant(...)` 则仅当前 user。
- [ ] **Step 2: 单测模板结构(不连库)**
```python
from datetime import date
from io import BytesIO
from openpyxl import load_workbook
from app.services.bx.reimburse_import import build_import_template_bytes
def test_build_import_template_has_guide_examples_and_dv():
raw = build_import_template_bytes(
expense_nodes=[
{"id": "1", "parent_id": "0", "item_name": "差旅费", "status": 1, "item_code": "CL"},
{"id": "2", "parent_id": "1", "item_name": "市内交通", "status": 1, "item_code": "JT"},
],
applicants=[{"id": "u1", "nick_name": "张三", "user_no": "z001"}],
current_applicant_label="张三(z001)",
today=date(2026, 8, 18),
)
wb = load_workbook(BytesIO(raw))
assert "填写说明" in wb.sheetnames
assert "报销导入" in wb.sheetnames
assert wb["_选项_费用项目"].sheet_state == "hidden"
ws = wb["报销导入"]
assert [c.value for c in ws[1]][:8] == [
"分组号", "申请人", "报销日期", "报销标题",
"费用项目", "金额(元)", "报销事由", "发生时间",
]
assert str(ws["G2"].value).startswith("【示例】")
assert len(ws.data_validations.dataValidation) >= 1
```
- [ ] **Step 3: 跑测**
```bash
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_import.py -v
```
Expected: PASS
---
### Task 4: 导入解析与按组写库
**Files:**
- Modify: `backend/app/services/bx/reimburse_import.py`
- Modify: `backend/tests/test_bx_reimburse_import.py`
- [ ] **Step 1: 实现解析辅助**
```python
def cell_str(v: Any) -> str:
if v is None:
return ""
if isinstance(v, datetime):
return v.date().isoformat()
if isinstance(v, date):
return v.isoformat()
return str(v).strip()
def parse_amount(v: Any) -> Tuple[Optional[Decimal], Optional[str]]:
if v is None or cell_str(v) == "":
return None, "金额不能为空"
try:
amt = Decimal(str(v).strip())
except Exception:
return None, "金额格式无效"
if amt <= 0:
return None, "金额必须大于 0"
return amt, None
```
- [ ] **Step 2: 实现 `import_reimburse_workbook`**
签名:
```python
def import_reimburse_workbook(
db: Session,
*,
file_bytes: bytes,
user: BxUserContext,
) -> Dict[str, Any]:
```
逻辑:
1. `load_workbook(BytesIO(file_bytes), data_only=True)`;无 `报销导入` → `raise ValueError("缺少工作表「报销导入」")`。
2. 读第 1 行表头,必须包含 `HEADERS` 全部列名(允许顺序与生成模板一致;缺列 raise)。
3. 从第 2 行起读到空行结束;转 dict;跳过全空行;`is_example_row` 跳过。
4. 有效行数 > `MAX_IMPORT_ROWS` → `raise ValueError(...)`。
5. `tenant_no = ReimburseService._tenant_no(db, user)`。
6. 构建匹配表:`label→expense_id`(`build_expense_path_labels` + 反查)、`label→user_id`(按权限加载申请人后 `build_applicant_label`)。
7. `partition_import_groups`。
8. 对每个 group:
- `validate_group_headers` 失败 → errors,continue。
- 解析每行费用/金额/事由/发生时间;任一行失败 → 整组 errors。
- 申请人 label → id;`resolve_applicant`;日期用 `parse_reimburse_date`(注意:组内已保证非空字符串;Excel datetime 先 `cell_str`)。
- 发生时间:`parse_optional_date`(先把 cell 规范成 date/str)。
- `details_data` 与 API `_build_details_data` 同形:查 `ExpenseItemService.get_by_id`,写 `expense_id/name/amount/remark/occur_date`。
- `data = {apply_code, status:"draft", reimburse_date, remark:""}`;若标题非空则 `title=...`;挂 `wf_id` 同 `create_reimburse`。
- `create_with_details(...)`;**立即 `db.commit()`**(部分成功);`created` 追加 `{groupNo, applyId, applyCode}`。
- 单组异常:`db.rollback()`,记入 errors,继续下一组。
9. 返回:
```python
{
"successGroups": ...,
"successRows": ...,
"failGroups": ...,
"failRows": ...,
"created": [...],
"errors": [{"groupNo": ..., "rows": [...], "message": ...}],
}
```
空分组号在结果里 `groupNo` 用 `""`。
- [ ] **Step 3: 纯解析测(不写库)— 可选抽出 `parse_sheet_rows`**
为便于测,先抽:
```python
def iter_data_rows(ws) -> List[Dict[str, Any]]:
...
```
单测:内存 workbook 含示例行 + 数据行,断言示例被跳过、分组正确。
- [ ] **Step 4: 跑测**
```bash
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_import.py -v
```
Expected: PASS(写库可用 mock/`MagicMock` 测分组失败不调用 create,或跳过集成测;至少保证解析/跳过示例/表头校验有测)
---
### Task 5: API 路由
**Files:**
- Modify: `backend/app/api/bx/reimburse.py`
- [ ] **Step 1: 增加依赖导入**
```python
import io
from urllib.parse import quote
from fastapi import File, UploadFile
from fastapi.responses import StreamingResponse
from app.services.bx.reimburse_import import (
build_applicant_label,
build_import_template_bytes,
import_reimburse_workbook,
load_expense_nodes_flat,
)
from app.models.models import SysUser
from app.services.bx.reimburse import ReimburseService
```
(若 `ReimburseService` 已导入则勿重复。)
- [ ] **Step 2: 实现 `GET /import-template`**
注意:放在 `/{reimburse_id}` 类路径**之前**,避免被当成 id。建议紧接在 `generate-title` 附近。
```python
@router.get("/import-template")
def download_reimburse_import_template(
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
nodes = load_expense_nodes_flat(db, tenant_no)
q = db.query(SysUser).filter(
SysUser.del_flag == 0, SysUser.status == "1", SysUser.tenant_no == tenant_no
)
if not ReimburseService.can_change_applicant(db, user):
q = q.filter(SysUser.id == user.id)
users = q.order_by(SysUser.create_time.desc()).all()
applicants = [
{"id": u.id, "nick_name": u.nick_name or "", "user_no": u.user_no or ""}
for u in users
]
# 当前用户展示名
me = next((a for a in applicants if a["id"] == user.id), None)
if me:
cur_label = build_applicant_label(me["nick_name"], me["user_no"], me["id"])
else:
cur_label = build_applicant_label(user.name or "", "", user.id)
applicants = [{"id": user.id, "nick_name": user.name or "", "user_no": ""}] + applicants
from datetime import date as date_cls
raw = build_import_template_bytes(
expense_nodes=nodes,
applicants=applicants,
current_applicant_label=cur_label,
today=date_cls.today(),
)
filename = quote("报销申请导入模板.xlsx")
return StreamingResponse(
io.BytesIO(raw),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}; filename*=UTF-8''{filename}"
},
)
```
- [ ] **Step 3: 实现 `POST /import`**
```python
@router.post("/import")
def import_reimburses(
file: UploadFile = File(...),
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
content = file.file.read()
if not content:
raise HTTPException(status_code=400, detail="文件为空")
try:
result = import_reimburse_workbook(db, file_bytes=content, user=user)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return ApiResponse(data=result)
```
- [ ] **Step 4: 手工冒烟(本地服务已启动时)**
```bash
# 登录后带 token
curl -o /tmp/bx-tpl.xlsx -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8000/api/reimburses/import-template
file /tmp/bx-tpl.xlsx
```
Expected: Excel 文件;用 Excel/WPS 打开可见下拉与说明。
---
### Task 6: 前端 API
**Files:**
- Modify: `web-admin/src/api/bx/index.ts`
- [ ] **Step 1: 在 `reimburseApi` 增加方法**
```typescript
downloadImportTemplate() {
return bxRequest.get('/reimburses/import-template', { responseType: 'blob' })
},
importExcel(file: File) {
const formData = new FormData()
formData.append('file', file)
return bxRequest.post('/reimburses/import', formData)
},
```
(`bx.ts` 已对 FormData 去掉 Content-Type,勿强制 `multipart/form-data` 字符串以免丢 boundary。)
---
### Task 7: 前端工具栏 + 导入弹窗
**Files:**
- Modify: `web-admin/src/views/bx/reimburse/apply/index.vue`
- [ ] **Step 1: 工具栏按钮**
在「新增报销申请」按钮后插入(`btn-ghost`,带图标):
```vue
<el-button class="btn-ghost" :loading="templateLoading" @click="downloadImportTemplate">
<el-icon><Download /></el-icon>下载模板
</el-button>
<el-button class="btn-ghost" @click="openImportDialog">
<el-icon><Upload /></el-icon>批量导入
</el-button>
```
注意:页面已有「提交审批」用 `Upload` 图标;批量导入可改用 `UploadFilled` 或 `DocumentAdd`,从 `@element-plus/icons-vue` 显式 import,避免两按钮同图标。推荐:`Download` + `FolderAdd`。
- [ ] **Step 2: 导入弹窗模板**
```vue
<el-dialog
v-model="importDialogVisible"
title="批量导入报销申请"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<p class="import-hint">
请先下载最新模板填写。灰色【示例】行导入时自动跳过。导入结果为「待提交」草稿,发票请事后在系统中关联。
</p>
<el-upload
drag
:auto-upload="false"
:limit="1"
accept=".xlsx,.xls"
:file-list="importFileList"
:on-change="onImportFileChange"
:on-remove="onImportFileRemove"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
</el-upload>
<div v-if="importResult" class="import-result">
<p>
成功 {{ importResult.successGroups }} 组({{ importResult.successRows }} 行),
失败 {{ importResult.failGroups }} 组({{ importResult.failRows }} 行)
</p>
<el-table v-if="importResult.errors?.length" :data="importResult.errors" size="small" max-height="240">
<el-table-column prop="groupNo" label="分组号" min-width="80" />
<el-table-column label="行号" min-width="100">
<template #default="{ row }">{{ (row.rows || []).join(', ') }}</template>
</el-table-column>
<el-table-column prop="message" label="原因" min-width="200" show-overflow-tooltip />
</el-table>
</div>
<template #footer>
<el-button class="btn-ghost" @click="importDialogVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="importLoading"
:disabled="!importFileList.length"
@click="submitImport"
>
<el-icon><Check /></el-icon>开始导入
</el-button>
</template>
</el-dialog>
```
- [ ] **Step 3: script 逻辑**
```typescript
const templateLoading = ref(false)
const importDialogVisible = ref(false)
const importLoading = ref(false)
const importFileList = ref<UploadFile[]>([])
const importResult = ref<any>(null)
async function downloadImportTemplate() {
templateLoading.value = true
try {
const res: any = await reimburseApi.downloadImportTemplate()
const blob =
res instanceof Blob
? res
: new Blob([res as BlobPart], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '报销申请导入模板.xlsx'
link.click()
URL.revokeObjectURL(url)
} catch {
ElMessage.error('下载模板失败')
} finally {
templateLoading.value = false
}
}
function openImportDialog() {
importFileList.value = []
importResult.value = null
importDialogVisible.value = true
}
function onImportFileChange(file: UploadFile, files: UploadFile[]) {
importFileList.value = files.slice(-1)
}
function onImportFileRemove() {
importFileList.value = []
}
async function submitImport() {
const raw = importFileList.value[0]?.raw
if (!raw) {
ElMessage.warning('请选择要导入的文件')
return
}
importLoading.value = true
importResult.value = null
try {
const res: any = await reimburseApi.importExcel(raw)
const data = pickBxData(res) || res?.data || res
importResult.value = data
const ok = Number(data?.successGroups || 0)
const fail = Number(data?.failGroups || 0)
if (ok > 0 && fail === 0) {
ElMessage.success(`成功导入 ${ok} 组`)
} else if (ok > 0) {
ElMessage.warning(`部分成功:成功 ${ok} 组,失败 ${fail} 组`)
} else {
ElMessage.error('导入失败,请查看错误清单')
}
if (ok > 0) await refreshList()
} finally {
importLoading.value = false
}
}
```
补全 `UploadFile`、`UploadFilled`、`Download`、`FolderAdd`、`Close`、`Check` 的 import;`refreshList` 用页面已有 `refresh`/`search`。
- [ ] **Step 4: 手工验收(对照 spec §5.2)**
1. 打开 `/#/bx/reimburse/apply`,下载模板 → 有说明、示例、下拉。
2. 不改示例直接导入 → 0 成功(或仅跳过),无脏数据。
3. 复制示例改事由去掉【示例】、金额合理 → 成功草稿。
4. G1 两行同头不同费用 → 一单两明细。
5. 同组申请人故意不一致 → 该组失败,其它组仍成功。
---
### Task 8: 收尾
- [ ] **Step 1: 后端全量相关测**
```bash
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_import.py -v
```
- [ ] **Step 2: 更新图谱(改代码后)**
```bash
./scripts/update-graph.sh backend
# 若改了 web-admin:
./scripts/update-graph.sh frontend
```
或按仓库规则:`graphify update .`(在对应目录)。
- [ ] **Step 3: 将 spec 状态改为「已实现」**(实现全部验收后)
修改 `docs/superpowers/specs/2026-08-18-reimburse-apply-batch-import-design.md` 顶部状态行。
---
## Spec 覆盖自检
| Spec 项 | Task |
|---------|------|
| D1 后端模板+导入 | 2–5 |
| D2 分组号行模型 | 1, 4 |
| D3 单头一致 | 1 `validate_group_headers`, 4 |
| D4 空分组号 | 1 `partition_import_groups` |
| D5 仅草稿 | 4 `status: draft` |
| D6 标题可空 | 1, 4 |
| D7 启用费用项含父级 | 1 `build_expense_path_labels` |
| D8 部分成功 | 4 按组 commit |
| D9 无发票 | 4 不传 invoice |
| D10 说明+示例 | 3 |
| API GET/POST | 5 |
| 前端按钮+弹窗 | 6–7 |
| 申请人权限 | 3 模板选项 + 4 `resolve_applicant` |
**占位符扫描:** 无 TBD;Vue 需与页面现有 `refresh` 函数名对齐(实现时搜 `refresh`/`handleSearch`)。
**类型一致:** `groupNo` / `successGroups` / `errors[].rows` 前后端统一驼峰(Pydantic/`ApiResponse` 若默认 camelCase,service 返回可用 dict 驼峰键,与现有 bx 出参习惯一致)。