From 00a315b61c698be24bc4176875ae44833bef158b Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 16 Oct 2026 16:47:18 +0800 Subject: [PATCH] docs(plan): OA done-center bank receipt upload implementation Break the approved design into TDD tasks for service, API, SQL, and web-admin UI. Co-authored-by: Cursor --- .../2026-10-16-oa-done-bank-receipt-upload.md | 904 ++++++++++++++++++ 1 file changed, 904 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-16-oa-done-bank-receipt-upload.md diff --git a/docs/superpowers/plans/2026-10-16-oa-done-bank-receipt-upload.md b/docs/superpowers/plans/2026-10-16-oa-done-bank-receipt-upload.md new file mode 100644 index 00000000..1d9f59d4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-oa-done-bank-receipt-upload.md @@ -0,0 +1,904 @@ +# OA 已办中心 · 上传银行回执单 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:** 在 web-admin【已办中心】为有 `oa:done:bank-receipt` 权限的用户提供银行回执单弹窗(回显历史、可追加/下载/删除并保存)。 + +**Architecture:** 附件落 `biz_attachment`(`business_type=oa_bank_receipt`,`business_id=instance_id`),不改 `oa_instance`。文件先走通用上传,再经 `GET/PUT .../instances/{id}/bank-receipts` 做关联覆盖。前后端均用同一权限字符;前端 `useMenuPerm` 控按钮,后端 `user_has_menu_perm` 强制鉴权。 + +**Tech Stack:** FastAPI + SQLAlchemy · Vue 3 + Element Plus · MinIO 通用上传 · `sys_menu` 按钮权限 + +**Spec:** `docs/superpowers/specs/2026-10-16-oa-done-bank-receipt-upload-design.md` +**Demo:** `docs/superpowers/specs/2026-10-16-oa-done-bank-receipt-upload-demo.html` + +--- + +## File map + +| 文件 | 职责 | +|------|------| +| Create: `backend/app/services/oa_bank_receipt.py` | 列表 / 整体覆盖 `biz_attachment` | +| Create: `backend/tests/test_oa_bank_receipt.py` | 服务层单测 | +| Modify: `backend/app/api/oa_approval.py` | GET/PUT 路由 + 权限常量 | +| Create: `docs/sql/migrations/2026-10-16-oa-done-bank-receipt-perm.sql` | 按钮菜单种子 | +| Modify: `web-admin/src/api/oaApproval.ts` | 前端 API | +| Create: `web-admin/src/components/oa-approval/OaBankReceiptDialog.vue` | 弹窗 UI | +| Modify: `web-admin/src/views/oa-approval/done/index.vue` | 操作列按钮 | + +--- + +### Task 1: 后端服务层(TDD) + +**Files:** +- Create: `backend/app/services/oa_bank_receipt.py` +- Test: `backend/tests/test_oa_bank_receipt.py` + +- [ ] **Step 1: 写失败测试** + +```python +"""OA 银行回执单:biz_attachment 挂载 instance。""" +from __future__ import annotations + +import unittest +from datetime import datetime + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.models.base import Base +from app.models.models import BizAttachment, OaApp, OaAppVersion, OaInstance +from app.services import oa_bank_receipt as svc + + +class TestOaBankReceipt(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all( + self.engine, + tables=[ + OaApp.__table__, + OaAppVersion.__table__, + OaInstance.__table__, + BizAttachment.__table__, + ], + ) + Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False) + self.db = Session() + t0 = datetime(2026, 10, 16, 10, 0, 0) + self.db.add_all( + [ + OaApp( + id="app1", + app_name="付款申请", + initiator_scope="all", + admin_scope="all_oa_admin", + status="1", + tenant_no="zkxw", + create_dept_id="", + create_time=t0, + update_time=t0, + del_flag=0, + ), + OaAppVersion( + id="v1", + app_id="app1", + version_no=1, + status="published", + is_latest_published="1", + form_schema_json="{}", + flow_tree_json="{}", + advanced_settings_json="{}", + tenant_no="zkxw", + create_dept_id="", + create_time=t0, + update_time=t0, + del_flag=0, + ), + OaInstance( + id="inst-1", + app_id="app1", + app_version_id="v1", + title="付款-1", + initiator_user_id="u1", + status="completed", + current_round=1, + tenant_no="zkxw", + create_dept_id="", + create_time=t0, + update_time=t0, + del_flag=0, + ), + OaInstance( + id="inst-other-tenant", + app_id="app1", + app_version_id="v1", + title="付款-x", + initiator_user_id="u1", + status="completed", + current_round=1, + tenant_no="other", + create_dept_id="", + create_time=t0, + update_time=t0, + del_flag=0, + ), + ] + ) + self.db.commit() + + def tearDown(self): + self.db.close() + self.engine.dispose() + + def test_list_empty(self): + items = svc.list_bank_receipts(self.db, instance_id="inst-1", tenant_no="zkxw") + self.assertEqual(items, []) + + def test_replace_then_list(self): + out = svc.replace_bank_receipts( + self.db, + instance_id="inst-1", + tenant_no="zkxw", + items=[ + { + "fileUrl": "oa-bank-receipt/a.pdf", + "fileName": "回单A.pdf", + "fileType": "application/pdf", + "fileSize": 100, + }, + { + "fileUrl": "oa-bank-receipt/b.jpg", + "fileName": "回单B.jpg", + "fileType": "image/jpeg", + "fileSize": 200, + }, + ], + user_id="u1", + dept_id="d1", + ) + self.db.commit() + self.assertEqual(len(out), 2) + listed = svc.list_bank_receipts(self.db, instance_id="inst-1", tenant_no="zkxw") + self.assertEqual([x["fileUrl"] for x in listed], [ + "oa-bank-receipt/a.pdf", + "oa-bank-receipt/b.jpg", + ]) + self.assertEqual(listed[0]["fileName"], "回单A.pdf") + + def test_replace_removes_missing(self): + svc.replace_bank_receipts( + self.db, + instance_id="inst-1", + tenant_no="zkxw", + items=[ + {"fileUrl": "oa-bank-receipt/a.pdf", "fileName": "A.pdf"}, + {"fileUrl": "oa-bank-receipt/b.pdf", "fileName": "B.pdf"}, + ], + user_id="u1", + dept_id="", + ) + self.db.commit() + svc.replace_bank_receipts( + self.db, + instance_id="inst-1", + tenant_no="zkxw", + items=[{"fileUrl": "oa-bank-receipt/b.pdf", "fileName": "B.pdf"}], + user_id="u1", + dept_id="", + ) + self.db.commit() + listed = svc.list_bank_receipts(self.db, instance_id="inst-1", tenant_no="zkxw") + self.assertEqual([x["fileUrl"] for x in listed], ["oa-bank-receipt/b.pdf"]) + gone = ( + self.db.query(BizAttachment) + .filter(BizAttachment.file_url == "oa-bank-receipt/a.pdf") + .first() + ) + self.assertEqual(gone.del_flag, 1) + + def test_replace_empty_clears_all(self): + svc.replace_bank_receipts( + self.db, + instance_id="inst-1", + tenant_no="zkxw", + items=[{"fileUrl": "oa-bank-receipt/a.pdf", "fileName": "A.pdf"}], + user_id="u1", + dept_id="", + ) + self.db.commit() + svc.replace_bank_receipts( + self.db, + instance_id="inst-1", + tenant_no="zkxw", + items=[], + user_id="u1", + dept_id="", + ) + self.db.commit() + self.assertEqual( + svc.list_bank_receipts(self.db, instance_id="inst-1", tenant_no="zkxw"), + [], + ) + + def test_bind_orphan_upload_row(self): + """通用上传已写入无 business_type 的行时,PUT 应绑定而非再插一行。""" + t0 = datetime(2026, 10, 16, 11, 0, 0) + orphan = BizAttachment( + id="att-orphan", + business_type=None, + business_id=None, + original_name="orphan.pdf", + file_ext="pdf", + file_url="oa-bank-receipt/orphan.pdf", + file_size=10, + file_type="application/pdf", + tenant_no="zkxw", + create_dept_id="", + create_time=t0, + update_time=t0, + del_flag=0, + ) + self.db.add(orphan) + self.db.commit() + svc.replace_bank_receipts( + self.db, + instance_id="inst-1", + tenant_no="zkxw", + items=[{"fileUrl": "oa-bank-receipt/orphan.pdf", "fileName": "orphan.pdf"}], + user_id="u1", + dept_id="", + ) + self.db.commit() + rows = ( + self.db.query(BizAttachment) + .filter(BizAttachment.file_url == "oa-bank-receipt/orphan.pdf", BizAttachment.del_flag == 0) + .all() + ) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].business_type, svc.BUSINESS_TYPE) + self.assertEqual(rows[0].business_id, "inst-1") + + def test_missing_instance_raises(self): + with self.assertRaises(svc.OaBankReceiptError): + svc.list_bank_receipts(self.db, instance_id="nope", tenant_no="zkxw") + + def test_cross_tenant_raises(self): + with self.assertRaises(svc.OaBankReceiptError): + svc.list_bank_receipts( + self.db, instance_id="inst-other-tenant", tenant_no="zkxw" + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_oa_bank_receipt.py -v +``` + +Expected: FAIL(`ModuleNotFoundError` 或 import 失败) + +- [ ] **Step 3: 实现服务** + +```python +"""OA 实例银行回执单(biz_attachment 关联)。""" +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from sqlalchemy.orm import Session + +from app.models.models import BizAttachment, OaInstance + +BUSINESS_TYPE = "oa_bank_receipt" + + +class OaBankReceiptError(Exception): + pass + + +def _require_instance(db: Session, instance_id: str, tenant_no: str) -> OaInstance: + iid = (instance_id or "").strip() + if not iid: + raise OaBankReceiptError("实例ID无效") + row = ( + db.query(OaInstance) + .filter( + OaInstance.id == iid, + OaInstance.tenant_no == tenant_no, + OaInstance.del_flag == 0, + ) + .first() + ) + if not row: + raise OaBankReceiptError("审批实例不存在") + return row + + +def _row_to_item(row: BizAttachment) -> Dict[str, Any]: + return { + "id": row.id, + "fileName": row.original_name or "", + "fileUrl": row.file_url or "", + "fileType": row.file_type or "", + "fileSize": row.file_size, + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + } + + +def list_bank_receipts(db: Session, *, instance_id: str, tenant_no: str) -> List[Dict[str, Any]]: + _require_instance(db, instance_id, tenant_no) + rows = ( + db.query(BizAttachment) + .filter( + BizAttachment.tenant_no == tenant_no, + BizAttachment.business_type == BUSINESS_TYPE, + BizAttachment.business_id == instance_id, + BizAttachment.del_flag == 0, + ) + .order_by(BizAttachment.create_time.asc(), BizAttachment.id.asc()) + .all() + ) + return [_row_to_item(r) for r in rows] + + +def replace_bank_receipts( + db: Session, + *, + instance_id: str, + tenant_no: str, + items: List[Dict[str, Any]], + user_id: str, + dept_id: str = "", +) -> List[Dict[str, Any]]: + _require_instance(db, instance_id, tenant_no) + now = datetime.now() + desired: List[Dict[str, Any]] = [] + seen_urls = set() + for raw in items or []: + url = str((raw or {}).get("fileUrl") or (raw or {}).get("file_url") or "").strip() + if not url or url in seen_urls: + continue + seen_urls.add(url) + desired.append( + { + "fileUrl": url, + "fileName": str((raw or {}).get("fileName") or (raw or {}).get("file_name") or "").strip() + or url.rsplit("/", 1)[-1], + "fileType": str((raw or {}).get("fileType") or (raw or {}).get("file_type") or "").strip(), + "fileSize": (raw or {}).get("fileSize") + if (raw or {}).get("fileSize") is not None + else (raw or {}).get("file_size"), + } + ) + + existing = ( + db.query(BizAttachment) + .filter( + BizAttachment.tenant_no == tenant_no, + BizAttachment.business_type == BUSINESS_TYPE, + BizAttachment.business_id == instance_id, + BizAttachment.del_flag == 0, + ) + .all() + ) + existing_by_url = {r.file_url: r for r in existing if r.file_url} + desired_urls = {d["fileUrl"] for d in desired} + + for row in existing: + if row.file_url not in desired_urls: + row.del_flag = 1 + row.update_by = user_id or None + row.update_time = now + + for d in desired: + url = d["fileUrl"] + row: Optional[BizAttachment] = existing_by_url.get(url) + if row is None: + row = ( + db.query(BizAttachment) + .filter( + BizAttachment.tenant_no == tenant_no, + BizAttachment.file_url == url, + BizAttachment.del_flag == 0, + ) + .order_by(BizAttachment.create_time.desc()) + .first() + ) + if row is None: + ext = "" + if "." in d["fileName"]: + ext = d["fileName"].rsplit(".", 1)[-1][:20] + size = d["fileSize"] + try: + size_int = int(size) if size is not None else None + except (TypeError, ValueError): + size_int = None + row = BizAttachment( + id=uuid.uuid4().hex, + business_type=BUSINESS_TYPE, + business_id=instance_id, + original_name=d["fileName"][:200], + file_ext=ext or None, + file_url=url, + file_size=size_int, + file_type=(d["fileType"] or None), + tenant_no=tenant_no, + create_by=user_id or None, + create_dept_id=dept_id or "", + create_time=now, + update_by=user_id or None, + update_time=now, + del_flag=0, + ) + db.add(row) + else: + row.business_type = BUSINESS_TYPE + row.business_id = instance_id + row.del_flag = 0 + if d["fileName"]: + row.original_name = d["fileName"][:200] + if d["fileType"]: + row.file_type = d["fileType"] + if d["fileSize"] is not None: + try: + row.file_size = int(d["fileSize"]) + except (TypeError, ValueError): + pass + row.update_by = user_id or None + row.update_time = now + + db.flush() + return list_bank_receipts(db, instance_id=instance_id, tenant_no=tenant_no) +``` + +- [ ] **Step 4: 跑测试确认通过** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_oa_bank_receipt.py -v +``` + +Expected: 全部 PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/oa_bank_receipt.py backend/tests/test_oa_bank_receipt.py +git commit -m "$(cat <<'EOF' +feat(oa): add bank receipt attachment service for instances + +Store multiple bank receipts on biz_attachment keyed by instance id. +EOF +)" +``` + +--- + +### Task 2: API 路由 + 权限校验 + +**Files:** +- Modify: `backend/app/api/oa_approval.py` + +- [ ] **Step 1: 增加常量、Body、两个路由** + +在文件顶部常量旁增加: + +```python +OA_DONE_BANK_RECEIPT_PERM = "oa:done:bank-receipt" +``` + +在合适位置(建议靠近 `form-attachments` / comments 路由)增加: + +```python +class OaBankReceiptItemBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + file_url: str = Field(..., alias="fileUrl") + file_name: Optional[str] = Field(None, alias="fileName") + file_type: Optional[str] = Field(None, alias="fileType") + file_size: Optional[int] = Field(None, alias="fileSize") + + +class OaBankReceiptReplaceBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + items: List[OaBankReceiptItemBody] = Field(default_factory=list) + + +@router.get("/instances/{instance_id}/bank-receipts") +async def list_oa_bank_receipts( + instance_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.core.menu_guard import user_has_menu_perm + from app.services import oa_bank_receipt as br + + if not user_has_menu_perm(db, user, OA_DONE_BANK_RECEIPT_PERM): + return error_response(403, "无权上传银行回执单") + try: + tenant_no = _get_tenant_no(db, user) + items = br.list_bank_receipts(db, instance_id=instance_id, tenant_no=tenant_no) + return success_response({"items": items}) + except br.OaBankReceiptError as e: + return error_response(400, str(e)) + + +@router.put("/instances/{instance_id}/bank-receipts") +async def replace_oa_bank_receipts( + instance_id: str, + body: OaBankReceiptReplaceBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.core.menu_guard import user_has_menu_perm + from app.services import oa_bank_receipt as br + + if not user_has_menu_perm(db, user, OA_DONE_BANK_RECEIPT_PERM): + return error_response(403, "无权上传银行回执单") + try: + tenant_no = _get_tenant_no(db, user) + items = br.replace_bank_receipts( + db, + instance_id=instance_id, + tenant_no=tenant_no, + items=[it.model_dump(by_alias=True) for it in (body.items or [])], + user_id=user.get("user_id") or "", + dept_id=user.get("dept_id") or "", + ) + db.commit() + return success_response({"items": items}) + except br.OaBankReceiptError as e: + db.rollback() + return error_response(400, str(e)) + except Exception: + db.rollback() + raise +``` + +注意:`Optional`、`List`、`BaseModel`、`Field`、`ConfigDict` 已在文件头部导入;若缺失则补全。 + +- [ ] **Step 2: 冒烟(无权限用户应 403;有权限/超管可调)** + +若本地有运行中的 API,用超管 token 调: + +```bash +# 示例:将 TOKEN / INSTANCE_ID 换成实际值 +curl -s -H "Authorization: Bearer $TOKEN" \ + "http://127.0.0.1:8000/web/admin/oa-approval/instances/$INSTANCE_ID/bank-receipts" +``` + +Expected: `code=0` 且 `data.items` 为数组(可为空) + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/api/oa_approval.py +git commit -m "$(cat <<'EOF' +feat(oa): expose bank-receipts GET/PUT with menu permission + +Gate instance bank receipt APIs behind oa:done:bank-receipt. +EOF +)" +``` + +--- + +### Task 3: 菜单权限种子 SQL + +**Files:** +- Create: `docs/sql/migrations/2026-10-16-oa-done-bank-receipt-perm.sql` + +- [ ] **Step 1: 编写 SQL** + +```sql +-- OA 已办中心 · 上传银行回执(按钮 F) +-- perms: oa:done:bank-receipt +-- 执行前确认父菜单:perms='oa:done' 或菜单名「已办中心」 +-- 执行后请在「角色管理」勾选该按钮权限,并重新登录/刷新菜单权限码 + +-- 查找父菜单(手工确认): +-- SELECT id, parent_id, menu_name, perms, menu_type +-- FROM sys_menu +-- WHERE del_flag = 0 AND (perms = 'oa:done' OR menu_name LIKE '%已办中心%'); + +INSERT INTO sys_menu ( + id, parent_id, menu_name, menu_type, path, component, perms, + icon, order_num, visible, status, is_frame, is_cache, + tenant_no, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag +) +SELECT + REPLACE(UUID(), '-', ''), + m.id, + '上传银行回执', + 'F', + '', + '', + 'oa:done:bank-receipt', + '', + 90, + '0', + '0', + 0, + 0, + 'zkxw', + '已办中心操作列:上传/管理银行回执单', + 'system', + '', + NOW(), + 'system', + NOW(), + 0 +FROM sys_menu m +WHERE m.del_flag = 0 + AND m.perms = 'oa:done' + AND NOT EXISTS ( + SELECT 1 FROM sys_menu x + WHERE x.del_flag = 0 AND x.perms = 'oa:done:bank-receipt' + ) +LIMIT 1; +``` + +若库中「已办中心」无 `perms='oa:done'`,把 `WHERE` 改成按 `menu_name` / 实际 id;保持 `perms='oa:done:bank-receipt'` 不变。列名若与库不一致,对照 `docs/sql/zkxw.sql` 中已有 `menu_type='F'` 行改写。 + +- [ ] **Step 2: Commit** + +```bash +git add docs/sql/migrations/2026-10-16-oa-done-bank-receipt-perm.sql +git commit -m "$(cat <<'EOF' +docs(sql): seed oa:done:bank-receipt button permission + +EOF +)" +``` + +--- + +### Task 4: 前端 API + +**Files:** +- Modify: `web-admin/src/api/oaApproval.ts` + +- [ ] **Step 1: 在文件末尾(或 comments API 附近)追加** + +```typescript +export type OaBankReceiptItem = { + id?: string + fileName: string + fileUrl: string + fileType?: string + fileSize?: number | null + createTime?: string +} + +export function fetchOaBankReceipts(instanceId: string) { + return request.get<{ items: OaBankReceiptItem[] }>({ + url: `${BASE}/instances/${instanceId}/bank-receipts` + }) +} + +export function fetchReplaceOaBankReceipts( + instanceId: string, + items: Array<{ + fileUrl: string + fileName?: string + fileType?: string + fileSize?: number | null + }> +) { + return request.put<{ items: OaBankReceiptItem[] }>({ + url: `${BASE}/instances/${instanceId}/bank-receipts`, + data: { items } + }) +} +``` + +确认该文件已有 `request` 与 `BASE`(现有模块顶部已定义)。 + +- [ ] **Step 2: Commit** + +```bash +git add web-admin/src/api/oaApproval.ts +git commit -m "$(cat <<'EOF' +feat(web-admin): add OA bank receipt API clients + +EOF +)" +``` + +--- + +### Task 5: 弹窗组件 `OaBankReceiptDialog.vue` + +**Files:** +- Create: `web-admin/src/components/oa-approval/OaBankReceiptDialog.vue` + +对齐:`ProtocolFormDialog` 协议附件卡片 + Demo HTML;`label-position` 不强制整表,弹窗用摘要 + 附件区。 + +- [ ] **Step 1: 创建组件** + +实现要求(代码须完整写入文件,勿留 TODO): + +1. Props:`modelValue: boolean`;`instanceId: string`;`meta?: { appName?: string; initiatorUserName?: string; applyTime?: string }` +2. Emits:`update:modelValue`;`saved` +3. 打开时(`watch` visible + instanceId)调用 `fetchOaBankReceipts`,映射为本地 `attachments: { id, fileName, fileUrl, fileType, _uploading? }[]` +4. 上传:`fetchUploadAttachment`,`folder=oa-bank-receipt`;卡片化 DOM(上传卡 140×96 在首);`useAttachmentDropZone`;单文件 ≤ 20MB +5. 下载:`downloadAttachmentFile` + `VITE_MINIO_BASE_URL` 拼全路径(与协议附件一致) +6. 删除:本地 `splice`;确定时 `fetchReplaceOaBankReceipts` 提交当前非上传中项的 `fileUrl/fileName/fileType` +7. Footer:取消(Close)/ 确定(Check);确定前若存在 `_uploading` 则 `ElMessage.warning('附件上传中,请稍候')` +8. `dialog-form-layout`、`append-to-body`、`modal-class="protocol-dialog-mask"`、`align-center`、`destroy-on-close` +9. 宽度约 `720px`;遮罩无 blur + +骨架参考(实现时补全样式与上传逻辑,类名须含 `.attachment-upload` / `.upload-card` / `.attachment-item` 等规范类): + +```vue + + + +``` + +完整交互与 SCSS 以 OpenSpec「附件上传卡片化布局」为准;可对照 `ProtocolFormDialog.vue` 协议附件段落复制后改 folder / API。 + +- [ ] **Step 2: Commit** + +```bash +git add web-admin/src/components/oa-approval/OaBankReceiptDialog.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): add OA bank receipt upload dialog + +EOF +)" +``` + +--- + +### Task 6: 已办中心接入 + +**Files:** +- Modify: `web-admin/src/views/oa-approval/done/index.vue` + +- [ ] **Step 1: 操作列加按钮 + 弹窗** + +1. `import { useMenuPerm } from '@/hooks/core/useMenuPerm'` +2. `const OA_DONE_BANK_RECEIPT = 'oa:done:bank-receipt'` +3. `const { hasMenuPerm } = useMenuPerm()` +4. `const canUploadBankReceipt = computed(() => hasMenuPerm(OA_DONE_BANK_RECEIPT))` +5. 操作列 `min-width` 从 `320` 调到 `400`(或足以容纳三按钮) +6. 在打印按钮后: + +```vue + + + 上传回执 + +``` + +7. 引入 `Upload` 图标与 `OaBankReceiptDialog` +8. 状态: + +```ts +const bankReceiptVisible = ref(false) +const bankReceiptInstanceId = ref('') +const bankReceiptMeta = ref<{ + appName?: string + initiatorUserName?: string + applyTime?: string +}>({}) + +function openBankReceipt(row: any) { + const id = String(row?.instanceId || row?.id || '').trim() + if (!id) return + bankReceiptInstanceId.value = id + bankReceiptMeta.value = { + appName: row.appName || row.title || '', + initiatorUserName: row.initiatorUserName || '', + applyTime: row.applyTime || '' + } + bankReceiptVisible.value = true +} +``` + +9. 模板末尾(`page-container` 内): + +```vue + +``` + +- [ ] **Step 2: 本地手工验收** + +1. 执行 Task 3 SQL,给测试角色勾选「上传银行回执」,重新登录。 +2. 打开 `/#/system-manage/oa-approval/done`:有权限见按钮,无权限不见。 +3. 上传 2 张 → 确定 → 再开弹窗仍在;删 1 张 → 确定 → 再开只剩 1 张。 +4. 无权限账号直接调 API 应得 403。 + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/views/oa-approval/done/index.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): add bank receipt upload on OA done center + +Show permission-gated upload action and wire the receipt dialog. +EOF +)" +``` + +--- + +### Task 7: 图谱增量(若本会话改了 backend 服务/API) + +- [ ] **Step 1: 更新 backend 图谱** + +```bash +./scripts/update-graph.sh backend +``` + +- [ ] **Step 2: 若有变更则提交** + +```bash +git add backend/graphify-out/ +git commit -m "$(cat <<'EOF' +chore(graphify): refresh backend graph after OA bank receipt + +EOF +)" +``` + +若脚本无输出变更可跳过 commit。 + +--- + +## Spec coverage check + +| Spec 要求 | Task | +|-----------|------| +| `biz_attachment` + `oa_bank_receipt` | 1 | +| GET/PUT API + 403 | 2 | +| 权限字符 + SQL 种子 | 3 | +| 前端 API | 4 | +| 弹窗回显/上传/删/下载 | 5 | +| 已办操作列按钮 + hasMenuPerm | 6 | +| 不改 `oa_instance` / 不做小程序 | 全任务未包含 = 符合非目标 | + +## Placeholder scan + +无 TBD/TODO;服务与测试代码完整;Vue 组件要求对照规范落地(Task 5 明确对齐 Demo 与 ProtocolFormDialog,避免复制整份数千行)。 + +## Type consistency + +- 权限字符全程:`oa:done:bank-receipt` +- `business_type`:`oa_bank_receipt` +- folder:`oa-bank-receipt` +- 响应字段:`fileName` / `fileUrl` / `fileType` / `fileSize` / `createTime`