|
|
@@ -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
|
|
|
+<template>
|
|
|
+ <el-dialog
|
|
|
+ :model-value="modelValue"
|
|
|
+ title="上传银行回执单"
|
|
|
+ width="720px"
|
|
|
+ class="dialog-form-layout oa-bank-receipt-dialog"
|
|
|
+ align-center
|
|
|
+ append-to-body
|
|
|
+ destroy-on-close
|
|
|
+ modal-class="protocol-dialog-mask"
|
|
|
+ @update:model-value="emit('update:modelValue', $event)"
|
|
|
+ >
|
|
|
+ <!-- meta 摘要 + 附件卡片区 + footer 取消/确定 -->
|
|
|
+ </el-dialog>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+ import { Check, Close, Delete, Document, Download, Plus, Upload } from '@element-plus/icons-vue'
|
|
|
+ import { fetchOaBankReceipts, fetchReplaceOaBankReceipts } from '@/api/oaApproval'
|
|
|
+ import { fetchUploadAttachment } from '@/api/business'
|
|
|
+ import { useAttachmentDropZone } from '@/composables/useAttachmentDropZone'
|
|
|
+ import { downloadAttachmentFile } from '@/utils/download-attachment'
|
|
|
+ // ... open load, upload, save, scoped SCSS per ui-conventions
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+完整交互与 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
|
|
|
+<el-button
|
|
|
+ v-if="canUploadBankReceipt"
|
|
|
+ class="btn-op-item"
|
|
|
+ @click="openBankReceipt(row)"
|
|
|
+>
|
|
|
+ <el-icon><Upload /></el-icon>
|
|
|
+ 上传回执
|
|
|
+</el-button>
|
|
|
+```
|
|
|
+
|
|
|
+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
|
|
|
+<OaBankReceiptDialog
|
|
|
+ v-model="bankReceiptVisible"
|
|
|
+ :instance-id="bankReceiptInstanceId"
|
|
|
+ :meta="bankReceiptMeta"
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **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`
|