898 lines
30 KiB
Markdown
898 lines
30 KiB
Markdown
# 报销申请批量导入(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 出参习惯一致)。
|