|
|
@@ -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
|
|
|
+<ProtocolSamplePaymentPanel
|
|
|
+ v-if="context.module === 'protocol' && formData.source === 'cell_storage'"
|
|
|
+ v-model="formData.samplePayments"
|
|
|
+ :samples="formData.samples || []"
|
|
|
+ :disabled="formBusy"
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **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` 内成功创建用例为模板复制)。
|