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

30 KiB
Raw Blame History

报销申请批量导入(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 顶部):

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: 写失败单测

# 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: 跑测确认失败
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 实现:

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: 跑测通过
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_import.py -v

Expected: PASS

  • Step 5: Commit(若用户要求提交时再执行)
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: 实现工具

# 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 追加:

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: 跑测
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

签名与要点:

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 调用,可同文件):

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: 单测模板结构(不连库)
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: 跑测
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: 实现解析辅助

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

签名:

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. 返回:
{
    "successGroups": ...,
    "successRows": ...,
    "failGroups": ...,
    "failRows": ...,
    "created": [...],
    "errors": [{"groupNo": ..., "rows": [...], "message": ...}],
}

空分组号在结果里 groupNo 用 ""。

  • Step 3: 纯解析测(不写库)— 可选抽出 parse_sheet_rows

为便于测,先抽:

def iter_data_rows(ws) -> List[Dict[str, Any]]:
    ...

单测:内存 workbook 含示例行 + 数据行,断言示例被跳过、分组正确。

  • Step 4: 跑测
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: 增加依赖导入

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 附近。

@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
@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: 手工冒烟(本地服务已启动时)
# 登录后带 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 增加方法

  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,带图标):

          <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: 导入弹窗模板
    <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 逻辑
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: 后端全量相关测
cd backend && source venv/bin/activate && pytest tests/test_bx_reimburse_import.py -v
  • Step 2: 更新图谱(改代码后)
./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 出参习惯一致)。