2026-10-13-mobile-protocol-payment-parity.md 21 KB

小程序协议单 · 缴费对齐 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 追加:

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: 跑测确认失败
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 前增加:

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 增加:

    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: 跑测通过
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
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: 写失败测试(创建带缴费 → 详情可见)

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: 跑测确认失败
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:

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:

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 落库

抽取辅助:

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: 跑测通过
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
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:

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: 跑测失败
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 同公式):

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: 跑测通过
cd mobile-oa-h5 && node --test src/market/protocol-sample-payment.test.js

Expected: PASS

  • Step 5: Commit
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

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:

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: 跑测

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
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:

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
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 后):

<ProtocolSamplePaymentPanel
  v-if="context.module === 'protocol' && formData.source === 'cell_storage'"
  v-model="formData.samplePayments"
  :samples="formData.samples || []"
  :disabled="formBusy"
/>
  • Step 2: 加载/初始化

编辑加载详情后:

formData.value.samplePayments = detail.samplePayments || []

新建:samplePayments: [](面板 watch 样本后补齐)。

  • Step 3: 保存

submit 中:

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
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
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 内成功创建用例为模板复制)。