From e8e278be8699c3157bdaed350c51300f823a901d Mon Sep 17 00:00:00 2001
From: zlwu <704541015@qq.com>
Date: Sun, 11 Oct 2026 10:45:55 +0800
Subject: [PATCH] docs: plan verified mobile presign parity implementation.
---
...2026-10-11-market-presign-mobile-parity.md | 637 ++++++++++++++++++
...-11-market-presign-mobile-parity-design.md | 2 +-
2 files changed, 638 insertions(+), 1 deletion(-)
create mode 100644 docs/superpowers/plans/2026-10-11-market-presign-mobile-parity.md
diff --git a/docs/superpowers/plans/2026-10-11-market-presign-mobile-parity.md b/docs/superpowers/plans/2026-10-11-market-presign-mobile-parity.md
new file mode 100644
index 00000000..17234ffe
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-11-market-presign-mobile-parity.md
@@ -0,0 +1,637 @@
+# Market Presign Mobile Parity 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 端字段和业务操作,并以 Vant 4、工作流移动端风格实现白色顶部栏、卡片列表、触底分页及移动表单。
+
+**Architecture:** 预签约订单使用专用列表和专用表单组件,复用现有市场路由上下文、鉴权、HTTP、错误处理与现场 scope;后端在既有 `/mini/market/{specialist|manager}` 固定路由下补齐删除订单和意向金转账操作。复杂表单状态拆到纯 JavaScript 模块中先做单元测试,Vue 组件只负责 Vant 展示与事件编排。
+
+**Tech Stack:** Python 3.9、FastAPI、SQLAlchemy、Pydantic v2、pytest、Vue 3、Vant 4、Vite、Node `node:test`
+
+---
+
+## 文件结构
+
+### 后端
+
+- Modify: `backend/app/schemas/market_mobile.py` — 收紧预签订单/转账 DTO,并提供移动转账请求体。
+- Modify: `backend/app/services/market_presign.py` — scope 内订单删除、转账增改删、提交、撤回。
+- Modify: `backend/app/services/market_support.py` — 预签/转账动作白名单与表单选项。
+- Modify: `backend/app/api/market_mobile.py` — 暴露移动端删除与转账路由。
+- Modify: `backend/tests/test_market_presign_api.py` — 服务层权限、状态机、附件与软删除测试。
+- Modify: `backend/tests/test_market_support_api.py` — 动作白名单和选项返回测试。
+
+### `mobile-oa-h5`
+
+- Modify: `mobile-oa-h5/src/api/market.js` — 预签删除、转账增改删、提交、撤回 API。
+- Modify: `mobile-oa-h5/src/market/action-policy.js` — `delete` 与预签转账动作白名单。
+- Modify: `mobile-oa-h5/src/market/api-contract.test.js` — URL 与 HTTP 方法契约。
+- Modify: `mobile-oa-h5/src/market/module-actions.test.js` — 前后端动作交集测试。
+- Create: `mobile-oa-h5/src/market/presign/presign-presenter.js` — 列表字段、状态、金额、日期格式化。
+- Create: `mobile-oa-h5/src/market/presign/presign-presenter.test.js` — 展示逻辑测试。
+- Create: `mobile-oa-h5/src/market/presign/presign-form-state.js` — 表单默认值、级联清理、转账锁定和 payload。
+- Create: `mobile-oa-h5/src/market/presign/presign-form-state.test.js` — 表单纯逻辑测试。
+- Create: `mobile-oa-h5/src/market/components/PresignOrderCard.vue` — Web 字段完整卡片及操作按钮。
+- Create: `mobile-oa-h5/src/market/components/PresignSelectField.vue` — Vant Field + Popup/Picker 单选。
+- Create: `mobile-oa-h5/src/market/components/PresignAttachmentUploader.vue` — 上传触发卡与附件卡片。
+- Create: `mobile-oa-h5/src/market/components/PresignTransferCard.vue` — 意向金转账卡片及状态操作。
+- Create: `mobile-oa-h5/src/views/market/presign/PresignOrderList.vue` — 白色顶部栏、搜索、触底分页和悬浮按钮。
+- Create: `mobile-oa-h5/src/views/market/presign/PresignOrderForm.vue` — Web 字段完整新增/编辑页。
+- Modify: `mobile-oa-h5/src/market/routes.js` — 仅预签约订单切到专用列表/表单。
+- Modify: `mobile-oa-h5/src/styles/market.css` — 预签专用移动样式,品牌主色 `#0a9f6d`。
+
+## Task 1: 后端预签操作状态机与 scope 校验
+
+**Files:**
+- Modify: `backend/app/schemas/market_mobile.py`
+- Modify: `backend/app/services/market_presign.py`
+- Modify: `backend/tests/test_market_presign_api.py`
+
+- [ ] **Step 1: 写订单删除和转账越权的失败测试**
+
+在 `backend/tests/test_market_presign_api.py` 增加:
+
+```python
+def test_specialist_can_only_delete_own_non_converted_presign():
+ from app.services.market_presign import delete_presign_order
+
+ db = _session()
+ db.add_all([
+ _order('own', sales_user_id='u1'),
+ _order('other', sales_user_id='u2'),
+ ])
+ db.commit()
+
+ delete_presign_order(db, order_id='own', ctx=_ctx('specialist'), user_info=_user())
+ assert db.query(BizPresignOrder).filter_by(id='own').one().del_flag == 1
+ with pytest.raises(HTTPException):
+ delete_presign_order(db, order_id='other', ctx=_ctx('specialist'), user_info=_user())
+
+
+def test_presign_transfer_rejects_other_scope_order():
+ from app.schemas.market_mobile import MarketPresignTransferBody
+ from app.services.market_presign import create_presign_transfer
+
+ db = _session()
+ db.add(_order('other', sales_user_id='u2'))
+ db.commit()
+
+ with pytest.raises(HTTPException):
+ create_presign_transfer(
+ db,
+ order_id='other',
+ body=MarketPresignTransferBody(transferAmount=100, transferType='wxzz'),
+ ctx=_ctx('specialist'),
+ user_info=_user(),
+ )
+```
+
+- [ ] **Step 2: 运行失败测试**
+
+Run:
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_market_presign_api.py -k 'delete_own or transfer_rejects' -q
+```
+
+Expected: FAIL,提示 `delete_presign_order` / `create_presign_transfer` 尚不存在。
+
+- [ ] **Step 3: 收紧 DTO 并实现订单删除和 scoped transfer 查询**
+
+在 `backend/app/schemas/market_mobile.py` 将预签 DTO 改为拒绝未知字段,并限制字段长度:
+
+```python
+class MarketPresignTransferBody(BaseModel):
+ model_config = ConfigDict(extra='forbid', populate_by_name=True)
+
+ transfer_amount: Optional[MarketPaymentAmount] = Field(None, alias='transferAmount')
+ transfer_time: Optional[str] = Field(None, alias='transferTime')
+ transfer_type: Optional[str] = Field(None, max_length=16, alias='transferType')
+ transfer_attachments: Optional[str] = Field(None, max_length=4000, alias='transferAttachments')
+ remark: Optional[str] = Field(None, max_length=512)
+```
+
+`MarketPresignOrderBody` 同时加入 Web 已开放的订单状态字段:
+
+```python
+order_status: Optional[Literal['draft', 'active', 'converted', 'void']] = Field(
+ None, alias='orderStatus'
+)
+```
+
+移动 service 的更新规则与 Web 对齐:仅 `converted` 禁止保存;`void` 允许再次编辑,但禁止转正式。
+
+在 `backend/app/services/market_presign.py` 增加:
+
+```python
+def delete_presign_order(db, *, order_id, ctx, user_info):
+ row = assert_presign_access(db, order_id, ctx, user_info=user_info)
+ if (row.order_status or '').strip() == 'converted':
+ raise ValueError('已转正式的预签约订单不可删除')
+ presign_crud.soft_delete_presign_order(
+ db, order_id=row.id, tenant_no=ctx.tenant_no, user_id=ctx.user_id
+ )
+
+
+def _scoped_transfer(db, *, transfer_id, ctx, user_info):
+ transfer = (
+ db.query(BizPresignTransfer)
+ .filter(
+ BizPresignTransfer.id == transfer_id,
+ BizPresignTransfer.tenant_no == ctx.tenant_no,
+ BizPresignTransfer.del_flag == 0,
+ )
+ .first()
+ )
+ if transfer is None:
+ raise ValueError('意向金转账不存在')
+ order = assert_presign_access(db, transfer.presign_order_id, ctx, user_info=user_info)
+ return transfer, order
+```
+
+- [ ] **Step 4: 实现转账增改删、提交和撤回**
+
+继续在 `market_presign.py` 增加 `create_presign_transfer`、`update_presign_transfer`、`delete_presign_transfer`、`submit_presign_transfer`、`withdraw_presign_transfer`;每个函数先调用 `assert_presign_access` 或 `_scoped_transfer`,只允许订单状态 `''/draft/active`。附件写入前调用:
+
+```python
+fields['transfer_attachments'] = validate_attachment_refs(
+ db,
+ fields.get('transfer_attachments'),
+ module='presign',
+ ctx=ctx,
+ creating=creating,
+ business_id=order.id,
+)
+```
+
+删除转账必须满足 `pay_status == 'dfj'`,以 `del_flag=1`、`update_by`、`update_time` 软删除;提交调用 `presign_crud.submit_receipt_finance`,撤回调用 `presign_crud.withdraw_receipt`。
+
+- [ ] **Step 5: 跑预签服务测试**
+
+Run:
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_market_presign_api.py -q
+```
+
+Expected: 全部 PASS。
+
+- [ ] **Step 6: 提交后端服务层**
+
+```bash
+git add backend/app/schemas/market_mobile.py backend/app/services/market_presign.py backend/tests/test_market_presign_api.py
+git commit -m "feat: enforce scoped presign deletion and transfer workflows on mobile."
+```
+
+## Task 2: 后端移动路由、动作白名单与选项
+
+**Files:**
+- Modify: `backend/app/api/market_mobile.py`
+- Modify: `backend/app/services/market_support.py`
+- Modify: `backend/tests/test_market_support_api.py`
+
+- [ ] **Step 1: 写动作白名单失败测试**
+
+```python
+def test_presign_actions_include_delete_and_nested_transfer_actions():
+ active = decorate_allowed_actions(
+ 'presign',
+ {
+ 'orderStatus': 'active',
+ 'transfers': [
+ {'id': 't1', 'payStatus': 'dfj'},
+ {'id': 't2', 'payStatus': 'jfqrz'},
+ {'id': 't3', 'payStatus': 'yjf'},
+ ],
+ },
+ scope_kind='specialist',
+ detail=True,
+ )
+ assert active['allowedActions'] == ['edit', 'convert', 'delete']
+ assert [row['allowedActions'] for row in active['transfers']] == [
+ ['edit', 'delete', 'submit'], ['withdraw'], []
+ ]
+```
+
+同一测试还需断言:`void -> ['edit', 'delete']`,`converted -> ['edit']`;已转正式的“编辑”只用于打开只读表单,不允许提交保存。
+
+- [ ] **Step 2: 运行并确认失败**
+
+Run:
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_market_support_api.py -k presign_actions -q
+```
+
+Expected: FAIL,当前 `presign` 没有 `delete`,转账行没有 `allowedActions`。
+
+- [ ] **Step 3: 补齐动作装饰与表单选项**
+
+在 `allowed_actions()` 中使用:
+
+```python
+if module == 'presign':
+ if state == 'converted':
+ return ['edit']
+ if state == 'void':
+ return ['edit', 'delete']
+ return ['edit', 'convert', 'delete']
+if module == 'presign-transfer':
+ if state == 'dfj':
+ return ['edit', 'delete', 'submit']
+ if state == 'jfqrz':
+ return ['withdraw']
+ return []
+```
+
+`decorate_allowed_actions('presign', ..., detail=True)` 必须逐笔装饰 `transfers`。`get_form_options(module='presign')` 增加:
+
+```python
+'payModes': [
+ {'label': '趸交', 'value': 'dj'},
+ {'label': '分期', 'value': 'fq'},
+],
+'orderStatuses': [
+ {'label': '草稿', 'value': 'draft'},
+ {'label': '进行中', 'value': 'active'},
+ {'label': '已转正式', 'value': 'converted'},
+ {'label': '已作废', 'value': 'void'},
+],
+'transferTypes': _dict_options(db, ctx.tenant_no, 'zzfs'),
+```
+
+地区人员选项不能继续只返回 `SysUser`;查询需同时带出 `BizCollectionRegionStaff.is_leader`,返回:
+
+```python
+{
+ 'label': user.nick_name,
+ 'value': user.id,
+ 'isLeader': bool(staff.is_leader),
+}
+```
+
+所选地区存在多个绑定时,负责人排在普通人员之前。上传校验需把预签模块单文件上限对齐 Web 的 20MiB:`save_market_attachment()` 调用 `_validate_upload(..., max_bytes=20 * 1024 * 1024)`;其他市场模块继续保持现有 10MiB,避免扩大无关范围。
+
+- [ ] **Step 4: 注册移动路由**
+
+在 `_register_presign_routes()` 中增加以下固定路径,均复用当前 `permission_guard`:
+
+```python
+DELETE {prefix}/{order_id}
+POST {prefix}/{order_id}/transfers
+PUT /mini/market/{scope_kind}/presign-transfers/{transfer_id}
+DELETE /mini/market/{scope_kind}/presign-transfers/{transfer_id}
+POST /mini/market/{scope_kind}/presign-transfers/{transfer_id}/submit
+POST /mini/market/{scope_kind}/presign-transfers/{transfer_id}/withdraw
+```
+
+每个路由只调用 Task 1 的 service 函数,并把 `PermissionError` 转为 403、`ValueError` 转为 400。修正该函数中重复的 `data = market_presign.create_presign_order(` 行。
+
+- [ ] **Step 5: 运行后端相关测试**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_market_presign_api.py tests/test_market_support_api.py tests/test_market_mobile_permissions.py -q
+```
+
+Expected: 全部 PASS。
+
+- [ ] **Step 6: 提交移动路由**
+
+```bash
+git add backend/app/api/market_mobile.py backend/app/services/market_support.py backend/tests/test_market_support_api.py
+git commit -m "feat: expose complete scoped presign actions to mobile clients."
+```
+
+## Task 3: 前端 API 与纯业务状态
+
+**Files:**
+- Modify: `mobile-oa-h5/src/api/market.js`
+- Modify: `mobile-oa-h5/src/market/action-policy.js`
+- Modify: `mobile-oa-h5/src/market/api-contract.test.js`
+- Modify: `mobile-oa-h5/src/market/module-actions.test.js`
+- Create: `mobile-oa-h5/src/market/presign/presign-presenter.js`
+- Create: `mobile-oa-h5/src/market/presign/presign-presenter.test.js`
+- Create: `mobile-oa-h5/src/market/presign/presign-form-state.js`
+- Create: `mobile-oa-h5/src/market/presign/presign-form-state.test.js`
+
+- [ ] **Step 1: 写 API 和动作契约失败测试**
+
+在 `api-contract.test.js` 断言:
+
+```javascript
+const presign = { marketRole: 'specialist', module: 'presign' }
+assert.equal(buildMarketActionPath(presign, 'delete', 'o 1'), '/mini/market/specialist/presign-orders/o%201')
+assert.equal(buildMarketActionPath(presign, 'create-transfer', 'o1'), '/mini/market/specialist/presign-orders/o1/transfers')
+assert.equal(buildMarketActionPath(presign, 'submit-transfer', 't1'), '/mini/market/specialist/presign-transfers/t1/submit')
+assert.equal(marketActionMethod('delete'), 'DELETE')
+assert.equal(marketActionMethod('edit-presign-transfer'), 'PUT')
+```
+
+在 `module-actions.test.js` 断言预签白名单包含 `delete/create-transfer/edit-presign-transfer/delete-transfer/submit-transfer/withdraw-transfer`,并拒绝后端未授权动作。
+
+- [ ] **Step 2: 运行失败测试**
+
+```bash
+cd mobile-oa-h5 && node --test src/market/api-contract.test.js src/market/module-actions.test.js
+```
+
+Expected: FAIL,动作和 URL 映射尚未实现。
+
+- [ ] **Step 3: 实现 API 映射和请求函数**
+
+`buildMarketActionPath()` 按 Task 2 的固定端点映射;`marketActionMethod()` 返回 DELETE/PUT/POST。导出语义化包装:
+
+```javascript
+export const deletePresignOrder = (context, id, options) =>
+ performMarketAction(context, 'delete', { id }, options)
+export const createPresignTransfer = (context, orderId, data, options) =>
+ performMarketAction(context, 'create-transfer', { id: orderId, data }, options)
+export const updatePresignTransfer = (context, transferId, data, options) =>
+ performMarketAction(context, 'edit-presign-transfer', { id: transferId, data }, options)
+```
+
+- [ ] **Step 4: 写展示与表单纯逻辑测试**
+
+覆盖:8 位日期转 `YYYY-MM-DD`、金额、Web 状态文案、跨页序号、地区变化清空医院、默认表单、转账状态锁定、payload 排除财务只读字段。
+
+```javascript
+assert.equal(formatPresignDate('20261011'), '2026-10-11')
+assert.equal(formatPresignMoney(28800), '¥28,800.00')
+assert.equal(presignCardIndex(2, 10, 3), '13')
+assert.deepEqual(changePresignRegion({ regionId: 'r1', hospitalId: 'h1' }, 'r2'), {
+ regionId: 'r2', hospitalId: ''
+})
+assert.equal(isPresignTransferLocked({ payStatus: 'jfqrz' }), true)
+```
+
+- [ ] **Step 5: 实现纯函数并运行测试**
+
+`presign-presenter.js` 只负责格式化;`presign-form-state.js` 导出 `createPresignForm`、`changePresignRegion`、`isPresignLocked`、`isPresignTransferLocked`、`buildPresignOrderPayload`、`buildPresignTransferPayload`。
+
+```bash
+cd mobile-oa-h5 && node --test src/market/presign/*.test.js src/market/api-contract.test.js src/market/module-actions.test.js
+```
+
+Expected: 全部 PASS。
+
+- [ ] **Step 6: 提交前端基础契约**
+
+```bash
+git add mobile-oa-h5/src/api/market.js mobile-oa-h5/src/market/action-policy.js mobile-oa-h5/src/market/*.test.js mobile-oa-h5/src/market/presign
+git commit -m "feat: add tested mobile presign contracts and form state."
+```
+
+## Task 4: 专用卡片列表与滚动分页
+
+**Files:**
+- Create: `mobile-oa-h5/src/market/components/PresignOrderCard.vue`
+- Create: `mobile-oa-h5/src/views/market/presign/PresignOrderList.vue`
+- Modify: `mobile-oa-h5/src/market/routes.js`
+- Modify: `mobile-oa-h5/src/styles/market.css`
+- Modify: `mobile-oa-h5/src/market/routes.test.js`
+
+- [ ] **Step 1: 写预签专用路由失败测试**
+
+导出 `marketListComponentKey(module)` / `marketFormComponentKey(module)` 并断言:
+
+```javascript
+assert.equal(marketListComponentKey('presign'), 'presign-list')
+assert.equal(marketListComponentKey('payment'), 'generic-list')
+assert.equal(marketFormComponentKey('presign'), 'presign-form')
+```
+
+- [ ] **Step 2: 运行失败测试**
+
+```bash
+cd mobile-oa-h5 && node --test src/market/routes.test.js
+```
+
+Expected: FAIL,路由选择函数不存在。
+
+- [ ] **Step 3: 创建 Web 字段完整卡片**
+
+`PresignOrderCard.vue` 必须显示:序号、业务日期、协议编号、母亲姓名、预产期、合同金额、到账金额、状态、创建时间;操作区使用:
+
+```vue
+编辑
+转正式
+删除
+```
+
+所有按钮最小高度 36px,整行操作区域触控高度不小于 44px。
+
+- [ ] **Step 4: 创建列表页**
+
+`PresignOrderList.vue` 使用:
+
+```vue
+
+
+
+
+
+
+```
+
+首次加载沿用 `request-state.js`;搜索重置页码,触底追加;删除和转正式用 `showConfirmDialog`,成功后重载第一页。
+
+- [ ] **Step 5: 路由仅替换预签组件**
+
+在 `routes.js` 中按 `module === 'presign'` 选择专用 list/form,其余模块保持现状;保留 specialist/manager 两套路由和 meta,不合并 scope。
+
+- [ ] **Step 6: 运行测试和构建**
+
+```bash
+cd mobile-oa-h5 && node --test src/market/routes.test.js src/market/request-state.test.js && npm run build
+```
+
+Expected: 测试全部 PASS,Vite build 成功。
+
+- [ ] **Step 7: 提交列表页**
+
+```bash
+git add mobile-oa-h5/src/market/components/PresignOrderCard.vue mobile-oa-h5/src/views/market/presign/PresignOrderList.vue mobile-oa-h5/src/market/routes.js mobile-oa-h5/src/market/routes.test.js mobile-oa-h5/src/styles/market.css
+git commit -m "feat: replace mobile presign list with scroll-loaded parity cards."
+```
+
+## Task 5: Vant 选择器、附件与转账卡片
+
+**Files:**
+- Create: `mobile-oa-h5/src/market/components/PresignSelectField.vue`
+- Create: `mobile-oa-h5/src/market/components/PresignAttachmentUploader.vue`
+- Create: `mobile-oa-h5/src/market/components/PresignTransferCard.vue`
+- Create: `mobile-oa-h5/src/market/presign/presign-components-contract.test.js`
+
+- [ ] **Step 1: 写组件契约失败测试**
+
+读取三个 SFC 源文件并断言它们分别包含 `van-popup/van-picker`、`van-uploader`、`allowedActions` 与 44px 触控约束;同时断言上传组件没有原生可见 `