|
|
@@ -0,0 +1,763 @@
|
|
|
+# 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:** 在 OA 审批下交付租户级「收款账户管理」CRUD,并注册 FormCreate `receiveAccount` 多选控件(存完整快照,详情/打印完整展示)。
|
|
|
+
|
|
|
+**Architecture:** 新建表 `oa_receive_account` + 挂在 `/web/admin/oa-approval` 的 CRUD/options API;管理页对齐 `roles/index.vue`;控件四层扩展对齐 `person`/`dept`/`money`(value 工具 → Vue 控件 → DragRule → setup 双侧注册);后端发布白名单与发起校验增加 `receiveAccount`。
|
|
|
+
|
|
|
+**Tech Stack:** FastAPI · SQLAlchemy · Pydantic v2 · pytest · Vue 3 · FormCreate / `@form-create/designer` · Element Plus · pnpm
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-07-30-oa-receive-account-design.md`
|
|
|
+**Demo:** `docs/mockups/oa-receive-account-demo.html`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File map
|
|
|
+
|
|
|
+| Path | Responsibility |
|
|
|
+|------|----------------|
|
|
|
+| Create: `docs/sql/oa_receive_account.sql` | 建表 + 菜单 INSERT(幂等) |
|
|
|
+| Modify: `docs/sql/zkxw.sql` | 同步表结构与菜单基线(可选段落或注明以 alter 脚本为准) |
|
|
|
+| Modify: `backend/app/models/models.py` | `OaReceiveAccount` 模型 |
|
|
|
+| Create: `backend/app/crud/oa_receive_account.py` | list/options/get/create/update/soft_delete |
|
|
|
+| Modify: `backend/app/api/oa_approval.py` | REST endpoints + Body schemas |
|
|
|
+| Create: `backend/app/services/oa_receive_account.py` | `validate_receive_account_form_values` |
|
|
|
+| Create: `backend/tests/test_oa_receive_account.py` | 校验单测 + 类型必填校验单测 |
|
|
|
+| Modify: `backend/app/services/oa_publish.py` | `FILLABLE_TYPES` 增加 `receiveAccount` |
|
|
|
+| Modify: `backend/app/services/oa_engine.py` | `start_instance` 挂入校验 |
|
|
|
+| Modify: `web-admin/src/api/oaApproval.ts` | 前端 API |
|
|
|
+| Create: `web-admin/src/utils/oa-receive-account-value.ts` | 快照类型 / normalize / format |
|
|
|
+| Create: `web-admin/src/utils/oa-receive-account-value.test.ts` | Node 单测 |
|
|
|
+| Create: `web-admin/src/utils/oa-receive-account-rule.ts` | DragRule |
|
|
|
+| Create: `web-admin/src/utils/oa-form-create-receive-account.ts` | setup |
|
|
|
+| Create: `web-admin/src/components/oa-approval/OaReceiveAccountSelect.vue` | 控件 |
|
|
|
+| Modify: `web-admin/src/main.ts` | setup 调用 |
|
|
|
+| Modify: `web-admin/src/oa-fc-host/main.ts` | setup 调用 |
|
|
|
+| Modify: `web-admin/src/utils/oa-approval-print.ts` | 打印分支 |
|
|
|
+| Create: `web-admin/src/views/oa-approval/receive-accounts/index.vue` | 管理页 |
|
|
|
+| Modify: `web-admin/src/router/modules/sqxp.ts` | 前端路由子项 |
|
|
|
+| Modify: `docs/superpowers/specs/2026-07-30-oa-receive-account-design.md` | 状态 → 已实现 |
|
|
|
+
|
|
|
+**菜单 ID:** `OA000000000000000000000000000108`(order_num=10)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: SQL 建表 + 菜单
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `docs/sql/oa_receive_account.sql`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写迁移 SQL**
|
|
|
+
|
|
|
+```sql
|
|
|
+-- docs/sql/oa_receive_account.sql
|
|
|
+CREATE TABLE IF NOT EXISTS `oa_receive_account` (
|
|
|
+ `id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
|
|
|
+ `account_type` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'alipay/personal/corporate',
|
|
|
+ `account_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '户名',
|
|
|
+ `account_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '账号',
|
|
|
+ `bank_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '银行',
|
|
|
+ `bank_province` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '省',
|
|
|
+ `bank_city` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '市',
|
|
|
+ `bank_branch` varchar(256) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '支行',
|
|
|
+ `tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
|
|
+ `order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
|
|
+ `remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
|
|
+ `create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
|
|
|
+ `create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
|
|
+ `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
|
+ `update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
|
|
|
+ `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
|
+ `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
|
|
+ PRIMARY KEY (`id`) USING BTREE,
|
|
|
+ KEY `idx_ora_tenant` (`tenant_no`,`del_flag`) USING BTREE,
|
|
|
+ KEY `idx_ora_type` (`tenant_no`,`account_type`,`del_flag`) USING BTREE
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OA收款账户';
|
|
|
+
|
|
|
+INSERT INTO `sys_menu` (
|
|
|
+ `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
|
|
|
+ `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
|
|
|
+ `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
|
|
+) SELECT
|
|
|
+ 'OA000000000000000000000000000108', '收款账户管理', 'OA000000000000000000000000000001',
|
|
|
+ 'C', 'receive-accounts', '/oa-approval/receive-accounts/index', 'oa:receive-accounts:list',
|
|
|
+ 1, '1', '1', 'zkxw', 10, 'OA收款账户主数据',
|
|
|
+ NULL, '', NOW(), NULL, NOW(), 0
|
|
|
+FROM DUAL
|
|
|
+WHERE NOT EXISTS (
|
|
|
+ SELECT 1 FROM `sys_menu` WHERE `id` = 'OA000000000000000000000000000108' AND `del_flag` = 0
|
|
|
+);
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在本地/测试库执行**
|
|
|
+
|
|
|
+```bash
|
|
|
+# 使用项目现有 DB 连接执行;示例:
|
|
|
+mysql -h... -u... -p... zkxw < docs/sql/oa_receive_account.sql
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 表存在;菜单可查到。
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add docs/sql/oa_receive_account.sql
|
|
|
+git commit -m "docs(sql): add oa_receive_account table and menu."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: SQLAlchemy 模型
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/models/models.py`(紧挨 `OaRole` / `OaRoleUser` 之后插入)
|
|
|
+
|
|
|
+- [ ] **Step 1: 增加模型**
|
|
|
+
|
|
|
+```python
|
|
|
+class OaReceiveAccount(Base):
|
|
|
+ """OA收款账户 oa_receive_account"""
|
|
|
+
|
|
|
+ __tablename__ = "oa_receive_account"
|
|
|
+
|
|
|
+ id = Column(String(64), primary_key=True, comment="主键ID")
|
|
|
+ account_type = Column(String(16), nullable=False, comment="alipay/personal/corporate")
|
|
|
+ account_name = Column(String(128), nullable=False, comment="户名")
|
|
|
+ account_no = Column(String(128), nullable=False, comment="账号")
|
|
|
+ bank_name = Column(String(128), nullable=True, comment="银行")
|
|
|
+ bank_province = Column(String(64), nullable=True, comment="省")
|
|
|
+ bank_city = Column(String(64), nullable=True, comment="市")
|
|
|
+ bank_branch = Column(String(256), nullable=True, comment="支行")
|
|
|
+ tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
|
|
+ order_num = Column(Integer, nullable=True, comment="排序号")
|
|
|
+ remark = Column(String(512), nullable=True, comment="备注")
|
|
|
+ create_by = Column(String(64), nullable=True, comment="创建人id")
|
|
|
+ create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
|
|
+ create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
|
|
+ update_by = Column(String(64), nullable=True, comment="更新人id")
|
|
|
+ update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
|
|
+ del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
|
|
+```
|
|
|
+
|
|
|
+确保文件顶部已有 `datetime`、`Column`、`String`、`Integer`、`DateTime`、`SmallInteger`、`Base` 导入(与现有 OA 模型一致)。
|
|
|
+
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/models/models.py
|
|
|
+git commit -m "feat(backend): add OaReceiveAccount model."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: CRUD + 类型规范化(TDD 校验逻辑)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `backend/app/crud/oa_receive_account.py`
|
|
|
+- Create: `backend/app/services/oa_receive_account.py`(先放 `normalize_account_payload` + 表单校验;也可同文件)
|
|
|
+- Create: `backend/tests/test_oa_receive_account.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败单测(payload 规范化 + form 校验)**
|
|
|
+
|
|
|
+```python
|
|
|
+# backend/tests/test_oa_receive_account.py
|
|
|
+import unittest
|
|
|
+
|
|
|
+from app.services.oa_receive_account import (
|
|
|
+ normalize_account_fields,
|
|
|
+ validate_receive_account_form_values,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+class TestNormalizeAccountFields(unittest.TestCase):
|
|
|
+ def test_alipay_clears_bank(self):
|
|
|
+ ok, err, data = normalize_account_fields(
|
|
|
+ {
|
|
|
+ "accountType": "alipay",
|
|
|
+ "accountName": "张三",
|
|
|
+ "accountNo": "13800138000",
|
|
|
+ "bankName": "工行",
|
|
|
+ "bankProvince": "安徽",
|
|
|
+ "bankCity": "合肥",
|
|
|
+ "bankBranch": "xx支行",
|
|
|
+ "remark": "r",
|
|
|
+ }
|
|
|
+ )
|
|
|
+ self.assertTrue(ok)
|
|
|
+ self.assertIsNone(err)
|
|
|
+ self.assertEqual(data["account_type"], "alipay")
|
|
|
+ self.assertEqual(data["account_name"], "张三")
|
|
|
+ self.assertEqual(data["bank_name"], "")
|
|
|
+ self.assertEqual(data["bank_province"], "")
|
|
|
+ self.assertEqual(data["bank_city"], "")
|
|
|
+ self.assertEqual(data["bank_branch"], "")
|
|
|
+
|
|
|
+ def test_personal_requires_bank(self):
|
|
|
+ ok, err, _ = normalize_account_fields(
|
|
|
+ {
|
|
|
+ "accountType": "personal",
|
|
|
+ "accountName": "李四",
|
|
|
+ "accountNo": "6222",
|
|
|
+ }
|
|
|
+ )
|
|
|
+ self.assertFalse(ok)
|
|
|
+ self.assertTrue(err)
|
|
|
+
|
|
|
+ def test_personal_ok(self):
|
|
|
+ ok, err, data = normalize_account_fields(
|
|
|
+ {
|
|
|
+ "accountType": "personal",
|
|
|
+ "accountName": "李四",
|
|
|
+ "accountNo": "6222",
|
|
|
+ "bankName": "工行",
|
|
|
+ "bankProvince": "安徽省",
|
|
|
+ "bankCity": "合肥市",
|
|
|
+ "bankBranch": "长江中路支行",
|
|
|
+ }
|
|
|
+ )
|
|
|
+ self.assertTrue(ok)
|
|
|
+ self.assertEqual(data["bank_name"], "工行")
|
|
|
+
|
|
|
+
|
|
|
+class TestValidateReceiveAccountFormValues(unittest.TestCase):
|
|
|
+ def test_ok_array(self):
|
|
|
+ schema = {
|
|
|
+ "rule": [
|
|
|
+ {
|
|
|
+ "type": "receiveAccount",
|
|
|
+ "field": "acc",
|
|
|
+ "title": "收款账户",
|
|
|
+ "$required": True,
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ data = {
|
|
|
+ "acc": [
|
|
|
+ {
|
|
|
+ "id": "1",
|
|
|
+ "accountType": "alipay",
|
|
|
+ "accountName": "张三",
|
|
|
+ "accountNo": "138",
|
|
|
+ "bankName": "",
|
|
|
+ "bankProvince": "",
|
|
|
+ "bankCity": "",
|
|
|
+ "bankBranch": "",
|
|
|
+ "remark": "",
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ self.assertEqual(validate_receive_account_form_values(schema, data), [])
|
|
|
+
|
|
|
+ def test_required_empty(self):
|
|
|
+ schema = {
|
|
|
+ "rule": [
|
|
|
+ {"type": "receiveAccount", "field": "acc", "title": "收款账户", "$required": True}
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ errs = validate_receive_account_form_values(schema, {"acc": []})
|
|
|
+ self.assertTrue(any("收款账户" in e for e in errs))
|
|
|
+
|
|
|
+ def test_invalid_item(self):
|
|
|
+ schema = {"rule": [{"type": "receiveAccount", "field": "acc"}]}
|
|
|
+ errs = validate_receive_account_form_values(schema, {"acc": [{"id": "1"}]})
|
|
|
+ self.assertTrue(len(errs) >= 1)
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ unittest.main()
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 跑测 — 期望 FAIL**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_receive_account.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL(模块不存在)
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 `backend/app/services/oa_receive_account.py`**
|
|
|
+
|
|
|
+实现要点:
|
|
|
+
|
|
|
+- `ACCOUNT_TYPES = frozenset({"alipay", "personal", "corporate"})`
|
|
|
+- `normalize_account_fields(body: dict) -> Tuple[bool, Optional[str], Dict]`:读 camelCase;`alipay` 清空银行四字段;`personal`/`corporate` 校验银行四字段非空;户名账号必填
|
|
|
+- `validate_receive_account_form_values(schema, data)`:遍历 `type==receiveAccount`;值必须是 list;每项须含 `id`、`accountType`、`accountName`、`accountNo`(字符串非空);`alipay` 不强制银行字段;其它类型快照内银行字段也应非空(与存值契约一致);`$required` 时 list 非空
|
|
|
+- `_walk_rules` / `_schema_rules` 复制 `oa_money_field.py` 模式
|
|
|
+
|
|
|
+- [ ] **Step 4: 实现 `backend/app/crud/oa_receive_account.py`**
|
|
|
+
|
|
|
+```python
|
|
|
+# 关键函数签名(实现体对齐 oa_approval._role_to_dict 风格)
|
|
|
+def row_to_dict(r: OaReceiveAccount) -> Dict[str, Any]: ...
|
|
|
+def list_accounts(db, *, tenant_no, keyword=None, account_type=None, page=1, page_size=10) -> Tuple[List[Dict], int]: ...
|
|
|
+def list_options(db, *, tenant_no) -> List[Dict]: ... # del_flag=0, create_time DESC
|
|
|
+def get_account(db, account_id, tenant_no) -> Optional[Dict]: ...
|
|
|
+def create_account(db, *, tenant_no, user_id, dept_id, fields: Dict) -> Dict: ...
|
|
|
+def update_account(db, account_id, tenant_no, user_id, fields: Dict) -> Optional[Dict]: ...
|
|
|
+def soft_delete_account(db, account_id, tenant_no, user_id) -> bool: ...
|
|
|
+def soft_delete_accounts(db, ids: List[str], tenant_no, user_id) -> int: ...
|
|
|
+```
|
|
|
+
|
|
|
+`row_to_dict` 字段(camelCase):`id, accountType, accountName, accountNo, bankName, bankProvince, bankCity, bankBranch, tenantNo, orderNum, remark, createTime, updateTime`。
|
|
|
+
|
|
|
+列表默认 `create_time DESC`;keyword 模糊匹配 `account_name` / `account_no` / `bank_name`。
|
|
|
+
|
|
|
+- [ ] **Step 5: 跑测 — 期望 PASS**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_receive_account.py -v
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/services/oa_receive_account.py backend/app/crud/oa_receive_account.py backend/tests/test_oa_receive_account.py
|
|
|
+git commit -m "feat(backend): add receive account CRUD helpers and validators."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: REST API + 发布白名单 + 发起校验
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/api/oa_approval.py`
|
|
|
+- Modify: `backend/app/services/oa_publish.py`
|
|
|
+- Modify: `backend/app/services/oa_engine.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: `oa_publish.FILLABLE_TYPES` 增加 `"receiveAccount"`**
|
|
|
+
|
|
|
+```python
|
|
|
+# FILLABLE_TYPES frozenset 内增加
|
|
|
+"receiveAccount",
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: `oa_engine.start_instance` 挂入校验**
|
|
|
+
|
|
|
+在现有 `validate_person_form_values` 附近:
|
|
|
+
|
|
|
+```python
|
|
|
+from app.services.oa_receive_account import validate_receive_account_form_values
|
|
|
+...
|
|
|
+recv_errs = validate_receive_account_form_values(schema, data)
|
|
|
+if recv_errs:
|
|
|
+ raise OaEngineError(recv_errs[0])
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 在 `oa_approval.py` 增加 Body 与路由**
|
|
|
+
|
|
|
+```python
|
|
|
+class OaReceiveAccountBody(BaseModel):
|
|
|
+ model_config = ConfigDict(populate_by_name=True)
|
|
|
+ account_type: str = Field(..., alias="accountType")
|
|
|
+ account_name: str = Field(..., alias="accountName")
|
|
|
+ account_no: str = Field(..., alias="accountNo")
|
|
|
+ bank_name: Optional[str] = Field(None, alias="bankName")
|
|
|
+ bank_province: Optional[str] = Field(None, alias="bankProvince")
|
|
|
+ bank_city: Optional[str] = Field(None, alias="bankCity")
|
|
|
+ bank_branch: Optional[str] = Field(None, alias="bankBranch")
|
|
|
+ order_num: Optional[int] = Field(None, alias="orderNum")
|
|
|
+ remark: Optional[str] = None
|
|
|
+
|
|
|
+
|
|
|
+class OaReceiveAccountBatchDeleteBody(BaseModel):
|
|
|
+ model_config = ConfigDict(populate_by_name=True)
|
|
|
+ ids: List[str] = Field(default_factory=list)
|
|
|
+```
|
|
|
+
|
|
|
+路由(均 `Depends(require_admin_or_super_admin)`):
|
|
|
+
|
|
|
+| 方法 | 路径 | 行为 |
|
|
|
+|------|------|------|
|
|
|
+| GET | `/receive-accounts` | `page,pageSize,keyword,accountType` → `{list,total}` |
|
|
|
+| GET | `/receive-accounts/options` | `{list}` |
|
|
|
+| GET | `/receive-accounts/{account_id}` | 详情 |
|
|
|
+| POST | `/receive-accounts` | normalize → create |
|
|
|
+| PUT | `/receive-accounts/{account_id}` | normalize → update |
|
|
|
+| DELETE | `/receive-accounts/{account_id}` | soft delete |
|
|
|
+| POST | `/receive-accounts/batch-delete` | body.ids 批量软删 |
|
|
|
+
|
|
|
+创建/更新失败返回 `error_response(message=err)`。
|
|
|
+
|
|
|
+- [ ] **Step 4: 手测或 curl(需登录 token)**
|
|
|
+
|
|
|
+```bash
|
|
|
+# 启动后端后:创建支付宝账户 → options 可见 → 软删后 options 不可见
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/api/oa_approval.py backend/app/services/oa_publish.py backend/app/services/oa_engine.py
|
|
|
+git commit -m "feat(backend): expose receive account APIs and form validation."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: 前端 value 工具(TDD)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `web-admin/src/utils/oa-receive-account-value.ts`
|
|
|
+- Create: `web-admin/src/utils/oa-receive-account-value.test.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败测试**
|
|
|
+
|
|
|
+```ts
|
|
|
+// web-admin/src/utils/oa-receive-account-value.test.ts
|
|
|
+import { describe, it } from 'node:test'
|
|
|
+import assert from 'node:assert/strict'
|
|
|
+import {
|
|
|
+ ACCOUNT_TYPE_LABEL,
|
|
|
+ formatReceiveAccountDisplay,
|
|
|
+ formatReceiveAccountLine,
|
|
|
+ isReceiveAccountSnapshot,
|
|
|
+ normalizeReceiveAccountModelValue,
|
|
|
+ type ReceiveAccountSnapshot
|
|
|
+} from './oa-receive-account-value'
|
|
|
+
|
|
|
+const alipay: ReceiveAccountSnapshot = {
|
|
|
+ id: '1',
|
|
|
+ accountType: 'alipay',
|
|
|
+ accountName: '张三',
|
|
|
+ accountNo: '13800138000',
|
|
|
+ bankName: '',
|
|
|
+ bankProvince: '',
|
|
|
+ bankCity: '',
|
|
|
+ bankBranch: '',
|
|
|
+ remark: '日常'
|
|
|
+}
|
|
|
+
|
|
|
+const personal: ReceiveAccountSnapshot = {
|
|
|
+ id: '2',
|
|
|
+ accountType: 'personal',
|
|
|
+ accountName: '李四',
|
|
|
+ accountNo: '6222',
|
|
|
+ bankName: '工行',
|
|
|
+ bankProvince: '安徽省',
|
|
|
+ bankCity: '合肥市',
|
|
|
+ bankBranch: '长江中路支行',
|
|
|
+ remark: ''
|
|
|
+}
|
|
|
+
|
|
|
+describe('oa-receive-account-value', () => {
|
|
|
+ it('type labels', () => {
|
|
|
+ assert.equal(ACCOUNT_TYPE_LABEL.alipay, '支付宝')
|
|
|
+ assert.equal(ACCOUNT_TYPE_LABEL.personal, '个人银行卡')
|
|
|
+ assert.equal(ACCOUNT_TYPE_LABEL.corporate, '对公银行账户')
|
|
|
+ })
|
|
|
+ it('isReceiveAccountSnapshot', () => {
|
|
|
+ assert.equal(isReceiveAccountSnapshot(alipay), true)
|
|
|
+ assert.equal(isReceiveAccountSnapshot({ id: '1' }), false)
|
|
|
+ })
|
|
|
+ it('normalize always array', () => {
|
|
|
+ assert.deepEqual(normalizeReceiveAccountModelValue(null), [])
|
|
|
+ assert.deepEqual(normalizeReceiveAccountModelValue(alipay), [alipay])
|
|
|
+ assert.deepEqual(normalizeReceiveAccountModelValue([alipay, personal]), [alipay, personal])
|
|
|
+ })
|
|
|
+ it('format line alipay omits bank', () => {
|
|
|
+ const line = formatReceiveAccountLine(alipay)
|
|
|
+ assert.ok(line.includes('支付宝'))
|
|
|
+ assert.ok(line.includes('张三'))
|
|
|
+ assert.ok(!line.includes('工行'))
|
|
|
+ assert.ok(line.includes('日常'))
|
|
|
+ })
|
|
|
+ it('format line personal full', () => {
|
|
|
+ const line = formatReceiveAccountLine(personal)
|
|
|
+ assert.ok(line.includes('个人银行卡'))
|
|
|
+ assert.ok(line.includes('安徽省 / 合肥市'))
|
|
|
+ assert.ok(line.includes('长江中路支行'))
|
|
|
+ })
|
|
|
+ it('format display multiline', () => {
|
|
|
+ const text = formatReceiveAccountDisplay([alipay, personal])
|
|
|
+ assert.ok(text.includes('\n'))
|
|
|
+ })
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 跑测 — FAIL**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd web-admin && pnpm exec tsx --test src/utils/oa-receive-account-value.test.ts
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 `oa-receive-account-value.ts`**
|
|
|
+
|
|
|
+```ts
|
|
|
+export type ReceiveAccountType = 'alipay' | 'personal' | 'corporate'
|
|
|
+
|
|
|
+export type ReceiveAccountSnapshot = {
|
|
|
+ id: string
|
|
|
+ accountType: ReceiveAccountType
|
|
|
+ accountName: string
|
|
|
+ accountNo: string
|
|
|
+ bankName: string
|
|
|
+ bankProvince: string
|
|
|
+ bankCity: string
|
|
|
+ bankBranch: string
|
|
|
+ remark: string
|
|
|
+}
|
|
|
+
|
|
|
+export const ACCOUNT_TYPE_LABEL: Record<ReceiveAccountType, string> = {
|
|
|
+ alipay: '支付宝',
|
|
|
+ personal: '个人银行卡',
|
|
|
+ corporate: '对公银行账户'
|
|
|
+}
|
|
|
+
|
|
|
+export function isReceiveAccountSnapshot(v: unknown): v is ReceiveAccountSnapshot { ... }
|
|
|
+export function normalizeReceiveAccountModelValue(raw: unknown): ReceiveAccountSnapshot[] { ... }
|
|
|
+export function formatReceiveAccountLine(s: ReceiveAccountSnapshot): string {
|
|
|
+ // 有值才 join ' · ';银行类型加 `${province} / ${city}`;支付宝跳过银行字段
|
|
|
+}
|
|
|
+export function formatReceiveAccountDisplay(raw: unknown): string {
|
|
|
+ // normalize 后 map line,join '\n'
|
|
|
+}
|
|
|
+export function optionLabelFromRow(row: {
|
|
|
+ accountType: string
|
|
|
+ accountName: string
|
|
|
+ accountNo: string
|
|
|
+}): string {
|
|
|
+ const t = ACCOUNT_TYPE_LABEL[row.accountType as ReceiveAccountType] || row.accountType
|
|
|
+ return `${t} · ${row.accountName} · ${row.accountNo}`
|
|
|
+}
|
|
|
+export function snapshotFromRow(row: any): ReceiveAccountSnapshot { ... }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 跑测 — PASS**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd web-admin && pnpm exec tsx --test src/utils/oa-receive-account-value.test.ts
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/utils/oa-receive-account-value.ts web-admin/src/utils/oa-receive-account-value.test.ts
|
|
|
+git commit -m "feat(web-admin): add receive account snapshot value helpers."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: API 模块 + FormCreate 注册链路
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/api/oaApproval.ts`
|
|
|
+- Create: `web-admin/src/utils/oa-receive-account-rule.ts`
|
|
|
+- Create: `web-admin/src/utils/oa-form-create-receive-account.ts`
|
|
|
+- Create: `web-admin/src/components/oa-approval/OaReceiveAccountSelect.vue`
|
|
|
+- Modify: `web-admin/src/main.ts`
|
|
|
+- Modify: `web-admin/src/oa-fc-host/main.ts`
|
|
|
+- Modify: `web-admin/src/utils/oa-approval-print.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: `oaApproval.ts` 追加**
|
|
|
+
|
|
|
+```ts
|
|
|
+export function fetchOaReceiveAccountList(params?: {
|
|
|
+ keyword?: string
|
|
|
+ accountType?: string
|
|
|
+ page?: number
|
|
|
+ pageSize?: number
|
|
|
+}) {
|
|
|
+ return request.get<{ list: any[]; total: number }>({
|
|
|
+ url: `${BASE}/receive-accounts`,
|
|
|
+ params
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+export function fetchOaReceiveAccountOptions() {
|
|
|
+ return request.get<{ list: any[] }>({ url: `${BASE}/receive-accounts/options` })
|
|
|
+}
|
|
|
+
|
|
|
+export function fetchOaReceiveAccountDetail(id: string) {
|
|
|
+ return request.get<any>({ url: `${BASE}/receive-accounts/${id}` })
|
|
|
+}
|
|
|
+
|
|
|
+export function fetchCreateOaReceiveAccount(data: Record<string, unknown>) {
|
|
|
+ return request.post({ url: `${BASE}/receive-accounts`, data, showSuccessMessage: true })
|
|
|
+}
|
|
|
+
|
|
|
+export function fetchUpdateOaReceiveAccount(id: string, data: Record<string, unknown>) {
|
|
|
+ return request.put({ url: `${BASE}/receive-accounts/${id}`, data, showSuccessMessage: true })
|
|
|
+}
|
|
|
+
|
|
|
+export function fetchDeleteOaReceiveAccount(id: string) {
|
|
|
+ return request.del({ url: `${BASE}/receive-accounts/${id}`, showSuccessMessage: true })
|
|
|
+}
|
|
|
+
|
|
|
+export function fetchBatchDeleteOaReceiveAccounts(ids: string[]) {
|
|
|
+ return request.post({
|
|
|
+ url: `${BASE}/receive-accounts/batch-delete`,
|
|
|
+ data: { ids },
|
|
|
+ showSuccessMessage: true
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: DragRule(固定多选,无 single switch)**
|
|
|
+
|
|
|
+对齐 `oa-person-select-rule.ts`,但:
|
|
|
+
|
|
|
+```ts
|
|
|
+export const receiveAccountDragRule = {
|
|
|
+ menu: 'main' as const,
|
|
|
+ icon: 'icon-input',
|
|
|
+ label: '收款账户',
|
|
|
+ name: 'receiveAccount',
|
|
|
+ input: true,
|
|
|
+ mask: true,
|
|
|
+ validate: ['array', 'object'],
|
|
|
+ event: ['change'],
|
|
|
+ rule() {
|
|
|
+ return {
|
|
|
+ type: 'receiveAccount',
|
|
|
+ field: uniqueId(), // suffix 'r'
|
|
|
+ title: '收款账户',
|
|
|
+ info: '',
|
|
|
+ $required: false,
|
|
|
+ props: { multiple: true }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ props() {
|
|
|
+ return [{ type: 'input', field: 'formCreateInfo', title: '提示信息' }]
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: `setupOaReceiveAccountSelect`**
|
|
|
+
|
|
|
+复制 `oa-form-create-person.ts` 模式,`component('receiveAccount', OaReceiveAccountSelect)` + `addDragRule`。
|
|
|
+
|
|
|
+- [ ] **Step 4: `OaReceiveAccountSelect.vue`**
|
|
|
+
|
|
|
+对齐 `OaPersonSelect.vue` 结构:
|
|
|
+
|
|
|
+- `defineOptions({ name: 'receiveAccount' })`
|
|
|
+- props: `modelValue`, `disabled`, `formCreateInject`(`multiple` 恒 true)
|
|
|
+- 可编辑:PC `el-select` multiple,options 来自 `fetchOaReceiveAccountOptions`;label 用 `optionLabelFromRow`;`@update` 时用 `snapshotFromRow` 组装快照数组
|
|
|
+- 窄屏:抽屉 + checkbox 列表(同 person)
|
|
|
+- 只读:`formatReceiveAccountDisplay`,用 `white-space: pre-line` 展示多行完整字段
|
|
|
+- 加载失败 `ElMessage.error`
|
|
|
+
|
|
|
+- [ ] **Step 5: `main.ts` / `oa-fc-host/main.ts` 调用 setup**
|
|
|
+
|
|
|
+与 `setupOaPersonSelect` 并列。
|
|
|
+
|
|
|
+- [ ] **Step 6: 打印**
|
|
|
+
|
|
|
+在 `oa-approval-print.ts` 的 `person`/`money` 分支旁:
|
|
|
+
|
|
|
+```ts
|
|
|
+if (type === 'receiveAccount') {
|
|
|
+ const text = formatReceiveAccountDisplay(value)
|
|
|
+ out.push({ label, text: text || '-' })
|
|
|
+ continue
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/api/oaApproval.ts \
|
|
|
+ web-admin/src/utils/oa-receive-account-rule.ts \
|
|
|
+ web-admin/src/utils/oa-form-create-receive-account.ts \
|
|
|
+ web-admin/src/components/oa-approval/OaReceiveAccountSelect.vue \
|
|
|
+ web-admin/src/main.ts \
|
|
|
+ web-admin/src/oa-fc-host/main.ts \
|
|
|
+ web-admin/src/utils/oa-approval-print.ts
|
|
|
+git commit -m "feat(web-admin): register receiveAccount FormCreate control."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 7: 管理页 + 路由
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `web-admin/src/views/oa-approval/receive-accounts/index.vue`
|
|
|
+- Modify: `web-admin/src/router/modules/sqxp.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: 路由子项**(插在 `roles` 与 `start` 之间或 OA children 末尾可见菜单区)
|
|
|
+
|
|
|
+```ts
|
|
|
+{
|
|
|
+ id: 'OA000000000000000000000000000108',
|
|
|
+ name: 'OaApprovalReceiveAccounts',
|
|
|
+ path: 'receive-accounts',
|
|
|
+ component: '/oa-approval/receive-accounts/index',
|
|
|
+ meta: {
|
|
|
+ title: '收款账户管理',
|
|
|
+ keepAlive: true,
|
|
|
+ menuCode: 'oa:receive-accounts:list'
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 实现管理页**
|
|
|
+
|
|
|
+以 `web-admin/src/views/oa-approval/roles/index.vue` 为骨架,对照 Demo `docs/mockups/oa-receive-account-demo.html`:
|
|
|
+
|
|
|
+- 标题:`收款账户管理`(`h2.title-bar-heading`)
|
|
|
+- 筛选:keyword、accountType(alipay/personal/corporate)
|
|
|
+- 工具栏:已选 N 项 → 新增(primary)→ 批量删除
|
|
|
+- 表格列:类型 Tag、户名、账号、银行、开户地/支行、备注、操作(查看/编辑/删除,fixed right,min-width≥260)
|
|
|
+- Tag:`alipay`→`success`,`personal`→`primary`,`corporate`→`warning`
|
|
|
+- 弹窗:`dialog-form-layout`、`label-position="top"`、`form-grid-2`、`append-to-body`、`modal-class="protocol-dialog-mask"`
|
|
|
+- 类型切换:`alipay` 隐藏银行字段并清空;银行字段必填校验动态 rules
|
|
|
+- 省/市:`el-cascader` 或两个 `el-select` 基于 `fetchChinaRegionTree()`(只取到市,存 **名称** 到 bankProvince/bankCity)
|
|
|
+- 查看弹窗:按类型完整字段(SystemViewDialog 或自建 view grid)
|
|
|
+- 按钮带 `el-icon`(Plus/Search/RefreshLeft/Close/Check/Delete/View/Edit)
|
|
|
+
|
|
|
+- [ ] **Step 3: 浏览器手测**
|
|
|
+
|
|
|
+1. 登录后 OA → 收款账户管理
|
|
|
+2. 三类各建一条
|
|
|
+3. 软删一条,options 不再出现
|
|
|
+4. 设计器拖入「收款账户」,发布含该字段的应用
|
|
|
+5. 发起审批多选账户,详情/打印完整展示;改主数据后历史快照不变
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/views/oa-approval/receive-accounts/index.vue web-admin/src/router/modules/sqxp.ts
|
|
|
+git commit -m "feat(web-admin): add OA receive account management page."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 8: 收尾
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `docs/superpowers/specs/2026-07-30-oa-receive-account-design.md` 状态改为「已实现」
|
|
|
+- Run: `./scripts/update-graph.sh backend` 与 `./scripts/update-graph.sh frontend`(若脚本存在)
|
|
|
+- Run: `cd backend && python scripts/check_field_consistency.py`(若支持本实体则跑;否则跳过并注明)
|
|
|
+
|
|
|
+- [ ] **Step 1: 更新 spec 状态**
|
|
|
+- [ ] **Step 2: 更新知识图谱**
|
|
|
+- [ ] **Step 3: Commit docs/graph**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add docs/superpowers/specs/2026-07-30-oa-receive-account-design.md backend/graphify-out web-admin/graphify-out
|
|
|
+git commit -m "docs: mark receive account design implemented and refresh graphs."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Spec coverage checklist
|
|
|
+
|
|
|
+| Spec 要求 | Task |
|
|
|
+|-----------|------|
|
|
|
+| 表 `oa_receive_account` + 公共字段 | 1–2 |
|
|
|
+| 三类字段校验 / 支付宝清空银行 | 3 |
|
|
|
+| CRUD + options API | 3–4 |
|
|
|
+| 菜单 + 路由 | 1, 7 |
|
|
|
+| 管理页 P01 | 7 |
|
|
|
+| FormCreate `receiveAccount` 四层扩展 | 5–6 |
|
|
|
+| 快照多选存值 | 5–6 |
|
|
|
+| 完整展示 + 打印 | 5–6 |
|
|
|
+| 发布白名单 + 发起校验 | 4 |
|
|
|
+| 软删无启用停用 | 3–4 |
|
|
|
+| 非目标:条件分支 / 小程序 | 不做 |
|
|
|
+
|
|
|
+## Placeholder / consistency self-review
|
|
|
+
|
|
|
+- 无 TBD;类型名统一 `receiveAccount` / `ReceiveAccountSnapshot` / `accountType`
|
|
|
+- API 路径统一 `/receive-accounts`
|
|
|
+- 菜单 ID 统一 `OA…108`
|