diff --git a/docs/superpowers/plans/2026-10-13-mobile-protocol-payment-parity.md b/docs/superpowers/plans/2026-10-13-mobile-protocol-payment-parity.md new file mode 100644 index 00000000..a8a5f2c8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-mobile-protocol-payment-parity.md @@ -0,0 +1,644 @@ +# 小程序协议单 · 缴费对齐 web(P1)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 `SamplePaymentPanel`),详情按样本展示;审核等管理动作仍不开放。 + +**Architecture:** 后端 `MarketProtocolBody` 增加 `samplePayments`,create/update 复用 `save_sample_payments_for_protocol`,详情返回 bundle。前端移植 web 缴费纯函数到 `mobile-oa-h5`,新增移动端 `ProtocolSamplePaymentPanel`,在 `MarketModuleForm` 挂载;详情 `protocol.js` 按样本卡片展示。 + +**Tech Stack:** FastAPI + SQLAlchemy;Vue 3 + Vant;`node --test`;`pytest` + +**Spec:** `docs/superpowers/specs/2026-10-13-mobile-protocol-payment-parity-design.md` + +--- + +## File map + +| 文件 | 职责 | +|------|------| +| `backend/app/schemas/market_mobile.py` | `samplePayments` 白名单 schema | +| `backend/app/services/market_protocol.py` | 详情附带 bundle;create/update 落库 | +| `backend/tests/test_market_protocol_api.py` | API 单测 | +| `mobile-oa-h5/src/market/protocol-sample-payment.js` | 缴费纯函数(移植 web) | +| `mobile-oa-h5/src/market/protocol-sample-payment.test.js` | 单测 | +| `mobile-oa-h5/src/market/form-payload.js` | 保存时带上 `samplePayments` | +| `mobile-oa-h5/src/market/components/ProtocolSamplePaymentPanel.vue` | 移动端缴费面板 | +| `mobile-oa-h5/src/views/market/MarketModuleForm.vue` | 挂载面板 + 校验 | +| `mobile-oa-h5/src/market/modules/protocol.js` | 去掉只读缴费;详情卡片;表单不再用只读三字段顶替 | + +--- + +### Task 1: 后端 Schema 接受 samplePayments + +**Files:** +- Modify: `backend/app/schemas/market_mobile.py` +- Modify: `backend/tests/test_market_protocol_api.py` + +- [ ] **Step 1: 写失败测试(schema 可解析 samplePayments)** + +在 `test_market_protocol_api.py` 追加: + +```python +def test_market_protocol_body_accepts_sample_payments(): + from app.schemas.market_mobile import MarketProtocolBody + + body = MarketProtocolBody.model_validate( + { + 'source': 'cell_storage', + 'protocolNo': 'P-PAY-1', + 'customer': {'motherName': '李四'}, + 'samplePayments': [ + { + 'sampleCode': 'S-001', + 'payMode': 'dj', + 'contractAmount': 20000, + 'lumpSumDeadline': '20261231', + 'schedules': [ + { + 'scheduleNo': 1, + 'dueAmount': 20000, + 'dueDate': '20261231', + } + ], + } + ], + } + ) + assert body.sample_payments is not None + assert len(body.sample_payments) == 1 + assert body.sample_payments[0].sample_code == 'S-001' + assert body.sample_payments[0].pay_mode == 'dj' +``` + +- [ ] **Step 2: 跑测确认失败** + +```bash +cd backend && source venv/bin/activate && PYTHONPATH=. pytest tests/test_market_protocol_api.py::test_market_protocol_body_accepts_sample_payments -q +``` + +Expected: FAIL(`samplePayments` extra forbid 或字段不存在) + +- [ ] **Step 3: 实现 schema** + +在 `market_mobile.py` 的 `MarketProtocolAttachmentsBody` 后、`MarketProtocolBody` 前增加: + +```python +class MarketProtocolPaymentScheduleBody(BaseModel): + model_config = ConfigDict(extra='forbid', populate_by_name=True) + + id: Optional[str] = None + schedule_no: Optional[int] = Field(None, alias='scheduleNo') + due_amount: Optional[float] = Field(None, alias='dueAmount') + due_date: Optional[str] = Field(None, alias='dueDate') + schedule_status: Optional[str] = Field(None, alias='scheduleStatus') + transfers: Optional[List[dict]] = None + + +class MarketProtocolSamplePaymentBody(BaseModel): + model_config = ConfigDict(extra='forbid', populate_by_name=True) + + id: Optional[str] = None + sample_code: str = Field(..., min_length=1, alias='sampleCode') + protocol_sample_id: Optional[str] = Field(None, alias='protocolSampleId') + pay_mode: str = Field(..., alias='payMode') + contract_amount: Optional[float] = Field(None, alias='contractAmount') + lump_sum_deadline: Optional[str] = Field(None, alias='lumpSumDeadline') + installment_count: Optional[int] = Field(None, alias='installmentCount') + installment_interval_months: Optional[float] = Field( + None, alias='installmentIntervalMonths' + ) + installment_start_date: Optional[str] = Field( + None, alias='installmentStartDate' + ) + installment_advance_days: Optional[int] = Field( + None, alias='installmentAdvanceDays' + ) + installment_ratios: Optional[List[float]] = Field( + None, alias='installmentRatios' + ) + remark: Optional[str] = None + schedules: Optional[List[MarketProtocolPaymentScheduleBody]] = None +``` + +在 `MarketProtocolBody` 增加: + +```python + sample_payments: Optional[List[MarketProtocolSamplePaymentBody]] = Field( + None, alias='samplePayments' + ) +``` + +(若 `extra=forbid` 与 `transfers: List[dict]` 冲突,可将 `transfers` 改为 nested 白名单模型,字段仅 `id/transferType/transferAmount/transferTime/remark/transferAttachments/payStatus` 只读透传——保存服务端会按既有逻辑处理。) + +- [ ] **Step 4: 跑测通过** + +```bash +cd backend && source venv/bin/activate && PYTHONPATH=. pytest tests/test_market_protocol_api.py::test_market_protocol_body_accepts_sample_payments -q +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/schemas/market_mobile.py backend/tests/test_market_protocol_api.py +git commit -m "$(cat <<'EOF' +feat(backend): accept samplePayments on market protocol body + +Whitelist per-sample payment payload for mobile protocol create/update. +EOF +)" +``` + +--- + +### Task 2: 详情返回 samplePayments + create/update 落库 + +**Files:** +- Modify: `backend/app/services/market_protocol.py` +- Modify: `backend/tests/test_market_protocol_api.py` + +- [ ] **Step 1: 写失败测试(创建带缴费 → 详情可见)** + +```python +def test_protocol_create_persists_sample_payments(monkeypatch): + from app.services import market_protocol as mp + from app.services.market_protocol import create_protocol, get_protocol + from app.crud import protocol_sample_payment as psp + + db = _session() + _masters(db) + # 若 create 依赖附件校验,沿用现有测试的 monkeypatch/合法附件模式 + body = _body( + samplePayments=[ + { + 'sampleCode': 'S-001', + 'payMode': 'dj', + 'contractAmount': 20000, + 'lumpSumDeadline': '20261231', + 'schedules': [ + { + 'scheduleNo': 1, + 'dueAmount': 20000, + 'dueDate': '20261231', + 'scheduleStatus': 'open', + 'transfers': [], + } + ], + } + ] + ) + created = create_protocol( + db, body=body, ctx=_ctx('specialist'), user_info=_user('specialist') + ) + protocol_id = created['basic']['id'] + detail = get_protocol( + db, + source='cell_storage', + protocol_id=protocol_id, + ctx=_ctx('specialist'), + user_info=_user('specialist'), + ) + pays = detail.get('samplePayments') or [] + assert len(pays) == 1 + assert pays[0]['sampleCode'] == 'S-001' + assert pays[0]['payMode'] == 'dj' + assert float(pays[0]['contractAmount']) == 20000 + assert pays[0]['schedules'][0]['dueDate'] in ('20261231', '2026-12-31') +``` + +(实现时按仓库现有 `_user` / monkeypatch 附件辅助函数命名对齐;若 `get_protocol` 签名不同则以文件内现有调用为准。) + +- [ ] **Step 2: 跑测确认失败** + +```bash +cd backend && source venv/bin/activate && PYTHONPATH=. pytest tests/test_market_protocol_api.py::test_protocol_create_persists_sample_payments -q +``` + +Expected: FAIL(详情无 `samplePayments` 或未落库) + +- [ ] **Step 3: 实现 `_cell_detail` 附带 bundle** + +在 `market_protocol.py` 顶部增加 import: + +```python +from app.crud import protocol_sample_payment as psp_crud +from app.services import protocol_sample_payment as sp_pay_svc +``` + +(若项目用 `psp_svc.get_sample_payments_bundle`,与 `collection_protocol.py` 现有引用保持一致。) + +改 `_cell_detail`: + +```python +def _cell_detail(db: Session, row: BizCollectionProtocol, tenant_no: str) -> dict: + # ... existing party/collection/samples/payment ... + version = int(getattr(row, 'payment_model_version', None) or 1) + sample_payments = [] + if version >= 2: + sample_payments = sp_pay_svc.get_sample_payments_bundle( + db, collection_protocol_id=row.id, tenant_no=tenant_no + ) + detail = { + # ... existing keys ... + 'paymentModelVersion': version, + 'samplePayments': sample_payments, + } + _enrich_protocol_detail_labels(db, detail, tenant_no) + return detail +``` + +- [ ] **Step 4: create/update 落库** + +抽取辅助: + +```python +def _persist_sample_payments_if_any( + db: Session, + *, + row: BizCollectionProtocol, + body: MarketProtocolBody, + ctx: MarketFieldContext, +) -> None: + if body.source != SOURCE_CELL: + return + if body.sample_payments is None: + return + raw = [p.model_dump(by_alias=True, exclude_none=False) for p in body.sample_payments] + if getattr(row, 'payment_model_version', None) in (None, 0, 1): + row.payment_model_version = 2 + db.add(row) + psp_crud.save_sample_payments_for_protocol( + db, + collection_protocol_id=row.id, + tenant_no=ctx.tenant_no, + user_id=ctx.user_id, + sample_payments=raw, + ) +``` + +在 `create_protocol` 细胞分支:`save_samples` 之后、`db.commit()` 之前调用 `_persist_sample_payments_if_any(...)`。 +在 `update_protocol` 细胞分支:样本保存之后、`commit` 之前同样调用(仅当 `body.sample_payments is not None`)。 + +科研捐赠:若请求带 `samplePayments`,`raise ValueError('科研捐赠不支持样本缴费方案')`。 + +- [ ] **Step 5: 跑测通过** + +```bash +cd backend && source venv/bin/activate && PYTHONPATH=. pytest tests/test_market_protocol_api.py::test_protocol_create_persists_sample_payments tests/test_market_protocol_api.py::test_market_protocol_body_accepts_sample_payments -q +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/market_protocol.py backend/tests/test_market_protocol_api.py +git commit -m "$(cat <<'EOF' +feat(backend): persist and return samplePayments for market protocols + +Mobile protocol create/update saves v2 per-sample payments; detail +includes samplePayments for edit hydration. +EOF +)" +``` + +--- + +### Task 3: 移动端缴费纯函数 + 单测 + +**Files:** +- Create: `mobile-oa-h5/src/market/protocol-sample-payment.js` +- Create: `mobile-oa-h5/src/market/protocol-sample-payment.test.js` + +- [ ] **Step 1: 写失败测试** + +创建 `protocol-sample-payment.test.js`: + +```js +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + calcLastPeriodDueAmount, + shouldAutoFillLastPeriodDue, + applyLumpSumDeadlineToLastDueDate, + availableSamplePayModes, + applyConsolidateRemainingToPayment, + sampleScheduleRequiredFieldsHint, + sampleScheduleDuesMismatchHint +} from './protocol-sample-payment.js' + +describe('calcLastPeriodDueAmount', () => { + it('last = contract − priors', () => { + assert.equal(calcLastPeriodDueAmount(20000, [10000, 5000]), 5000) + }) +}) + +describe('shouldAutoFillLastPeriodDue', () => { + it('blocks override/locked', () => { + assert.equal(shouldAutoFillLastPeriodDue({ lastManualOverride: true, lastLocked: false }), false) + }) +}) + +describe('applyLumpSumDeadlineToLastDueDate', () => { + it('writes last dueDate', () => { + const next = applyLumpSumDeadlineToLastDueDate( + [{ scheduleNo: 1, dueDate: '', scheduleStatus: 'open' }], + '20261231', + () => false + ) + assert.equal(next[0].dueDate, '20261231') + }) +}) + +describe('availableSamplePayModes', () => { + it('fq can switch to dj/fgd', () => { + assert.deepEqual(availableSamplePayModes({ payMode: 'fq' }), ['fq', 'dj', 'fgd']) + }) +}) + +describe('hints', () => { + it('requires fq amounts', () => { + const hint = sampleScheduleRequiredFieldsHint({ + payMode: 'fq', + sampleCode: 'S1', + schedules: [{ dueAmount: null, dueDate: '20261001' }] + }) + assert.match(hint, /应缴金额/) + }) +}) +``` + +- [ ] **Step 2: 跑测失败** + +```bash +cd mobile-oa-h5 && node --test src/market/protocol-sample-payment.test.js +``` + +Expected: FAIL(模块不存在) + +- [ ] **Step 3: 移植实现** + +从 `web-admin/src/utils/protocol-sample-payment.ts`(及依赖的 `calcFqInstallmentDueDate` / `DEFAULT_INSTALLMENT_ADVANCE_DAYS`)拷贝为 **纯 ESM JS**(去掉 TypeScript 类型),至少导出: + +- `calcLastPeriodDueAmount` +- `shouldAutoFillLastPeriodDue` +- `applyLumpSumDeadlineToLastDueDate` +- `availableSamplePayModes` +- `applyConsolidateRemainingToPayment` +- `applyExpandRemainingToInstallment` +- `samplePaymentHasConfirmedTransfer` +- `sampleScheduleRequiredFieldsHint` +- `sampleScheduleDuesMismatchHint` +- `parsePreferentialPrice` +- `resolvePerSamplePaymentDefaults` +- `sumSampleContractAmounts` +- `protocolTotalMismatchHint` +- `schedulePaidArrears`(若面板展示已缴/欠缴需要) + +分期日期计算可内联最小实现(与 web `protocol-payment-installment.ts` 同公式): + +```js +export const DEFAULT_INSTALLMENT_ADVANCE_DAYS = 30 +export function calcFqInstallmentDueDate(startDate, index, intervalMonths, advanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS) { + // 与 web 一致:第 1 期=开始日;后续 = start + index*interval − advanceDays +} +``` + +- [ ] **Step 4: 跑测通过** + +```bash +cd mobile-oa-h5 && node --test src/market/protocol-sample-payment.test.js +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add mobile-oa-h5/src/market/protocol-sample-payment.js mobile-oa-h5/src/market/protocol-sample-payment.test.js +git commit -m "$(cat <<'EOF' +feat(mobile-oa-h5): port protocol sample payment helpers + +Share web-aligned installment autofill and mode-switch helpers for +mobile protocol forms. +EOF +)" +``` + +--- + +### Task 4: form payload 携带 samplePayments + 校验入口 + +**Files:** +- Modify: `mobile-oa-h5/src/market/form-payload.js` +- Modify: `mobile-oa-h5/src/market/form-payload.test.js`(若无则创建) +- Modify: `mobile-oa-h5/src/market/modules/protocol.js` + +- [ ] **Step 1: 测试 payload 保留 samplePayments** + +```js +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { pickConfiguredPayload, mergeProtocolSamplePayments } from './form-payload.js' + +describe('mergeProtocolSamplePayments', () => { + it('attaches samplePayments for cell_storage', () => { + const base = { source: 'cell_storage', samples: [] } + const pays = [{ sampleCode: 'S1', payMode: 'dj', contractAmount: 1, schedules: [] }] + const out = mergeProtocolSamplePayments(base, pays) + assert.equal(out.samplePayments.length, 1) + }) + it('omits for research_donation', () => { + const out = mergeProtocolSamplePayments({ source: 'research_donation' }, [{ sampleCode: 'S1' }]) + assert.equal(out.samplePayments, undefined) + }) +}) +``` + +- [ ] **Step 2: 实现** + +在 `form-payload.js`: + +```js +export function mergeProtocolSamplePayments(payload, samplePayments) { + const next = { ...payload } + if (String(next.source || '') !== 'cell_storage') { + delete next.samplePayments + return next + } + if (samplePayments == null) return next + next.samplePayments = sanitizeMarketPayload(samplePayments) + return next +} +``` + +在 `protocol.js`: + +- 删除(或注释替换)只读 payment `formSections` 块;改为不在 `formSections` 里声明支付字段(由面板托管)。 +- `detailSections` 细胞存储缴费区改为:若有 `detail.samplePayments`,按样本生成 cards(模式、合同、各期应缴金额/时间);否则回退旧 `payment` 字段。 + +- [ ] **Step 3: 跑测** + +```bash +cd mobile-oa-h5 && node --test src/market/form-payload.test.js src/market/protocol-sample-payment.test.js src/market/module-contracts.test.js +``` + +Expected: PASS(必要时更新 `module-contracts` 中协议 payment 只读断言) + +- [ ] **Step 4: Commit** + +```bash +git add mobile-oa-h5/src/market/form-payload.js mobile-oa-h5/src/market/form-payload.test.js mobile-oa-h5/src/market/modules/protocol.js +git commit -m "$(cat <<'EOF' +feat(mobile-oa-h5): wire samplePayments into protocol payload and detail + +Drop read-only payment stubs; detail shows per-sample payment cards. +EOF +)" +``` + +--- + +### Task 5: ProtocolSamplePaymentPanel 组件 + +**Files:** +- Create: `mobile-oa-h5/src/market/components/ProtocolSamplePaymentPanel.vue` + +- [ ] **Step 1: 创建组件骨架** + +Props: + +```js +defineProps({ + modelValue: { type: Array, default: () => [] }, // samplePayments + samples: { type: Array, default: () => [] } +}) +const emit = defineEmits(['update:modelValue']) +``` + +UI 要求(信息元素齐套,移动端布局): + +- `v-for` 每个 payment:标题=样本类型/编码;`van-cell-group` +- 缴费模式:`van-field` + `van-action-sheet` / `van-radio-group`(选项来自 `availableSamplePayModes`) +- 合同金额:`type="number"` +- 趸交:截止时间 `van-field` readonly + `van-calendar`/`van-date-picker`;变更时调用 `applyLumpSumDeadlineToLastDueDate` +- 分期/分改趸:期数、间隔、开始日、提前天数;变更后重建 schedules(逻辑对齐 web `rebuildSchedules`——可在纯函数文件增加 `rebuildSampleSchedules(pay)`) +- 各期:应缴金额、应缴时间;`yjf` 锁定;金额变更时走尾期自动算(同 web `patchSchedule`) +- 模式切换:`showConfirmDialog` 文案对齐 web,成功则 `applyConsolidateRemainingToPayment` / `applyExpandRemainingToInstallment` + +- [ ] **Step 2: 样本同步** + +`watch(samples)`:按 `sampleCode` 对齐 payments;新增样本用 `resolvePerSamplePaymentDefaults` 建趸交草稿;删除样本移除方案。 + +- [ ] **Step 3: 最小样式** + +使用现有 `market.css` / 卡片类名,与其它 market 表单一致(三墙绿主色)。 + +- [ ] **Step 4: Commit** + +```bash +git add mobile-oa-h5/src/market/components/ProtocolSamplePaymentPanel.vue mobile-oa-h5/src/market/protocol-sample-payment.js +git commit -m "$(cat <<'EOF' +feat(mobile-oa-h5): add ProtocolSamplePaymentPanel for mobile protocol forms + +Mobile card UI for per-sample pay modes, schedules, and autofill rules. +EOF +)" +``` + +--- + +### Task 6: MarketModuleForm 集成与保存校验 + +**Files:** +- Modify: `mobile-oa-h5/src/views/market/MarketModuleForm.vue` + +- [ ] **Step 1: 挂载面板** + +在模板 `MarketFormSection` 列表后(或样本 section 后): + +```vue + +``` + +- [ ] **Step 2: 加载/初始化** + +编辑加载详情后: + +```js +formData.value.samplePayments = detail.samplePayments || [] +``` + +新建:`samplePayments: []`(面板 watch 样本后补齐)。 + +- [ ] **Step 3: 保存** + +`submit` 中: + +```js +let fields = pickConfiguredPayload(contract.value.formSections, formData.value) +fields = mergeProtocolSamplePayments(fields, formData.value.samplePayments) +// 校验 +for (const pay of formData.value.samplePayments || []) { + const h1 = sampleScheduleRequiredFieldsHint(pay) + if (h1) { showToast(h1); return } + const h2 = sampleScheduleDuesMismatchHint(pay) + if (h2) { showToast(h2); return } +} +const totalHint = protocolTotalMismatchHint(formData.value.protocolTotalAmount, sumSampleContractAmounts(formData.value.samplePayments || [])) +if (totalHint) { + await showConfirmDialog({ title: '提示', message: totalHint }) +} +await saveMarketRecord(..., fields) +``` + +- [ ] **Step 4: 手工冒烟清单**(写入 PR/自测记录) + +1. 新建分期尾期自动算 +2. 趸交截止日同步 +3. fq→fgd 确认并期 +4. 保存后详情卡片可见 + +- [ ] **Step 5: 跑相关测 + commit** + +```bash +cd mobile-oa-h5 && node --test src/market/*.test.js +cd ../backend && source venv/bin/activate && PYTHONPATH=. pytest tests/test_market_protocol_api.py -q --tb=line +``` + +```bash +git add mobile-oa-h5/src/views/market/MarketModuleForm.vue +git commit -m "$(cat <<'EOF' +feat(mobile-oa-h5): integrate sample payment panel into protocol form + +Validate and persist samplePayments on cell_storage protocol save. +EOF +)" +``` + +--- + +## Spec coverage + +| Spec 要求 | Task | +|-----------|------| +| Schema `samplePayments` | 1 | +| create/update 落库 + 详情返回 | 2 | +| 纯函数/自动算/并期 | 3、5 | +| payload + 详情卡片 | 4 | +| 移动端面板 UI | 5 | +| 表单挂载/校验/保存 | 6 | +| 科研不写缴费 | 2、4 | +| 管理动作不开放 | 无改白名单(保持) | + +## Self-review + +- 无 TBD;函数名前后一致。 +- Vue 面板允许实现时按 Vant 现有表单控件微调,但信息元素不得删减。 +- `test_protocol_create_persists_sample_payments` 需对齐仓库现有 fixture/monkeypatch 命名(实现时以 `test_market_protocol_api.py` 内成功创建用例为模板复制)。