Просмотр исходного кода

feat(mobile-oa-h5): present payment periods as collapsible periodBlocks

Stop flattening six installment field groups into list cards; emit periodBlocks and footerFields instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 4 недель назад
Родитель
Сommit
4e586e905b
2 измененных файлов с 123 добавлено и 44 удалено
  1. 70 31
      mobile-oa-h5/src/market/list-presenter.js
  2. 53 13
      mobile-oa-h5/src/market/list-presenter.test.js

+ 70 - 31
mobile-oa-h5/src/market/list-presenter.js

@@ -24,7 +24,7 @@ const ACTION_LABELS = Object.freeze({
   submit: '提交财务确认',
   withdraw: '撤回转账',
   link: '关联样本',
-  unlink: '取消运费'
+  unlink: '取消样本关联'
 })
 
 const PAY_MODE_LABELS = Object.freeze({ dj: '趸交', fq: '分期', fgd: '分改趸', djfq: '趸交+分期' })
@@ -180,21 +180,48 @@ function chaseCard(item) {
   }
 }
 
+function hasPresentValue(value) {
+  if (value == null) return false
+  if (typeof value === 'number') return Number.isFinite(value)
+  return String(value).trim() !== ''
+}
+
+function isPaymentPeriodPresent(period) {
+  if (!period || typeof period !== 'object') return false
+  return (
+    hasPresentValue(period.scheduleNo) ||
+    hasPresentValue(period.dueAmount) ||
+    hasPresentValue(period.displayPaidAmount) ||
+    hasPresentValue(period.paidAmount) ||
+    hasPresentValue(period.arrearsAmount) ||
+    hasPresentValue(period.dueDate) ||
+    hasPresentValue(period.payDate)
+  )
+}
+
+function paymentPeriodBlock(period, index) {
+  const scheduleNo = Number(period.scheduleNo) || index + 1
+  const paidRaw = period.displayPaidAmount ?? period.paidAmount
+  return {
+    key: String(period.id || `period-${scheduleNo}`),
+    scheduleNo,
+    dueAmount: money(period.dueAmount),
+    paidAmount: money(paidRaw),
+    fields: [
+      field('应缴费时间', period.dueDate),
+      field('已缴费时间', period.payDate),
+      field('欠缴费金额', money(period.arrearsAmount), { money: true })
+    ]
+  }
+}
+
 function paymentCard(item) {
   const progress = label({}, item.paymentProgress || item.progress || (Number(item.paidAmount || 0) > 0 ? '部分已缴' : '待缴清'))
-  const periods = Array.from({ length: 6 }, (_, index) => item.periods?.[index] || {})
-  const periodFields = periods.flatMap((period, index) => {
-    const prefix = `第${index + 1}期·`
-    return [
-      field(`${prefix}应缴费金额(元)`, money(period.dueAmount), { money: true }),
-      field(`${prefix}应缴费时间`, period.dueDate),
-      field(`${prefix}已缴费金额`, money(period.displayPaidAmount ?? period.paidAmount), { money: true }),
-      field(`${prefix}已缴费时间`, period.payDate),
-      field(`${prefix}欠缴费金额`, money(period.arrearsAmount), { money: true })
-    ]
-  })
+  const periods = (Array.isArray(item.periods) ? item.periods : []).filter(isPaymentPeriodPresent)
   return {
-    title: text(item.motherName), subtitle: text(item.sampleCode), status: progress,
+    title: text(item.motherName),
+    subtitle: text(item.sampleCode),
+    status: progress,
     fields: [
       field('协议号', item.protocolNo, { wide: true }),
       field('姓名', item.motherName), field('手机号', item.motherPhone),
@@ -203,8 +230,10 @@ function paymentCard(item) {
       field('合同总额', money(item.contractAmount), { money: true }),
       field('缴费方式', label(PAY_MODE_LABELS, item.payMode), { tag: true }),
       field('优惠金额', money(item.discountAmount), { money: true }),
-      field('应收总额', money(item.receivableAmount), { money: true }),
-      ...periodFields,
+      field('应收总额', money(item.receivableAmount), { money: true })
+    ],
+    periodBlocks: periods.map(paymentPeriodBlock),
+    footerFields: [
       field('累计实缴', money(item.paidAmount), { money: true }),
       field('待缴金额', money(item.dueRemainAmount ?? item.remainAmount), { money: true }),
       field('备注', item.remark, { wide: true })
@@ -213,38 +242,48 @@ function paymentCard(item) {
   }
 }
 
+function freightSampleBlock(row, index) {
+  const sampleCode = text(row?.sampleCode, '')
+  const title = sampleCode
+    ? `样本 ${index + 1} · ${sampleCode}`
+    : `样本 ${index + 1}`
+  return {
+    key: String(row?.id || row?.sampleId || `${sampleCode || 'sample'}-${index}`),
+    title,
+    status: label(SOURCE_LABELS, row?.sampleSource),
+    fields: [
+      field('协议号', row?.protocolNo, { wide: true }),
+      field('母亲姓名', row?.motherName),
+      field('子女姓名', row?.newbornName),
+      field('样本编码', row?.sampleCode, { wide: true }),
+      field('样本类型', row?.sampleTypeName, { wide: true })
+    ]
+  }
+}
+
 function freightCard(role, item) {
   const rows = Array.isArray(item.items) ? item.items : []
   const first = rows[0] || {}
   const transportMode = label({}, item.transportModeLabel || item.transportMode)
   const source = label(SOURCE_LABELS, first.sampleSource || item.sourceLabel)
-  const itemFields = rows.flatMap((row, index) => {
-    const prefix = index === 0 ? '' : `样本${index + 1}·`
-    return [
-      field(`${prefix}来源`, label(SOURCE_LABELS, row.sampleSource), { tag: true }),
-      field(`${prefix}协议号`, row.protocolNo, { wide: true }),
-      field(`${prefix}母亲姓名`, row.motherName),
-      field(`${prefix}子女姓名`, row.newbornName),
-      field(`${prefix}样本编码`, row.sampleCode, { wide: true }),
-      field(`${prefix}样本类型`, row.sampleTypeName, { wide: true })
-    ]
-  })
+  const sampleCount = Number(item.visibleItemCount ?? rows.length) || 0
   return {
     title: text(item.freightNo),
     subtitle: `${transportMode} · ${money(item.freightAmount)}`,
     status: source,
     fields: [
-      field('批次号', item.freightNo, { wide: true }), field('运输方式', transportMode, { tag: true }),
+      field('运输方式', transportMode, { tag: true }),
       field('运费金额', money(item.freightAmount), { money: true }),
-      field('关联数', String(item.visibleItemCount ?? item.items?.length ?? 0)),
-      ...itemFields,
+      field('关联数', String(sampleCount)),
       field('创建人', personName(item.creatorName || item.createByName, item.createBy)),
       field('创建时间', item.createTime),
       field('更新时间', item.updateTime),
       field('备注', item.remark, { wide: true })
     ],
-    privacyNote: role === 'specialist' ? '仅展示当前权限范围内的关联样本' : '',
-    actions: allowed(item, ['edit', 'link', 'unlink'])
+    sampleBlocks: rows.map(freightSampleBlock),
+    sampleCount,
+    privacyNote: '',
+    actions: allowed(item, ['edit', 'delete', 'link', 'unlink'])
   }
 }
 

+ 53 - 13
mobile-oa-h5/src/market/list-presenter.test.js

@@ -137,11 +137,39 @@ test('payment card formats summary totals and progress', () => {
   assert.ok(card.fields.some((field) => field.label === '协议号' && field.value === 'P-1'))
   assert.ok(card.fields.some((field) => field.label === '签署日期' && field.value === '2026-10-01'))
   assert.ok(card.fields.some((field) => field.label === '存储项目' && field.value === '脐带血'))
-  assert.ok(card.fields.some((field) => field.label === '第1期·应缴费金额(元)' && field.value === '¥3,000.00'))
-  assert.ok(card.fields.some((field) => field.label === '待缴金额' && field.value === '¥7,000.00'))
+  assert.ok(!card.fields.some((field) => String(field.label).includes('第1期')))
+  assert.equal(card.periodBlocks.length, 1)
+  assert.equal(card.periodBlocks[0].scheduleNo, 1)
+  assert.equal(card.periodBlocks[0].dueAmount, '¥3,000.00')
+  assert.equal(card.periodBlocks[0].paidAmount, '¥3,000.00')
+  assert.ok(card.periodBlocks[0].fields.some((f) => f.label === '应缴费时间' && f.value === '2026-10-10'))
+  assert.ok(card.periodBlocks[0].fields.some((f) => f.label === '已缴费时间' && f.value === '2026-10-11'))
+  assert.ok(card.periodBlocks[0].fields.some((f) => f.label === '欠缴费金额' && f.value === '¥0.00'))
+  assert.ok(card.footerFields.some((field) => field.label === '待缴金额' && field.value === '¥7,000.00'))
+  assert.ok(card.footerFields.some((field) => field.label === '累计实缴' && field.value === '¥3,000.00'))
+  assert.ok(card.footerFields.some((field) => field.label === '备注' && field.value === '首期已缴'))
   assert.deepEqual(card.actions, ['view-protocol', 'view-payment-detail'])
 })
 
+test('payment card periodBlocks skip empty periods and do not pad to six', () => {
+  const card = buildMarketCard('payment', 'specialist', {
+    motherName: '李婷', sampleCode: 'S-2', payMode: 'fq',
+    periods: [
+      { scheduleNo: 1, dueAmount: 1000, dueDate: '2026-01-01', paidAmount: 1000, payDate: '2026-01-02', arrearsAmount: 0 },
+      {},
+      { scheduleNo: 3, dueAmount: 2000, dueDate: '2026-07-01', paidAmount: 0, arrearsAmount: 2000 },
+      null,
+      undefined
+    ]
+  })
+  assert.equal(card.periodBlocks.length, 2)
+  assert.equal(card.periodBlocks[0].key, 'period-1')
+  assert.equal(card.periodBlocks[1].scheduleNo, 3)
+  assert.equal(card.periodBlocks[1].dueAmount, '¥2,000.00')
+  assert.equal(card.periodBlocks[1].paidAmount, '¥0.00')
+  assert.ok(!card.fields.some((field) => /第\d+期/.test(field.label)))
+})
+
 test('payment chase keeps the Web action column while paid totals remain card data', () => {
   const card = buildMarketCard('payment-chase', 'specialist', {
     protocolNo: 'P-1', protocolId: 'protocol-1', motherName: '周雨桐',
@@ -153,7 +181,7 @@ test('payment chase keeps the Web action column while paid totals remain card da
   assert.ok(card.fields.some((field) => field.label === '催缴状态'))
 })
 
-test('freight card shows the scope-safe visible count and protocol number for both roles', () => {
+test('freight card shows the scope-safe visible count and collapsible sample blocks for both roles', () => {
   const item = {
     freightNo: 'YF-1', transportModeLabel: '冷链配送', freightAmount: '120.00',
     visibleItemCount: 2,
@@ -166,24 +194,32 @@ test('freight card shows the scope-safe visible count and protocol number for bo
   }
   const specialist = buildMarketCard('freight', 'specialist', item)
   const manager = buildMarketCard('freight', 'manager', item)
-  assert.equal(specialist.privacyNote, '仅展示当前权限范围内的关联样本')
+  assert.equal(specialist.privacyNote, '')
   assert.ok(specialist.fields.some((field) => field.label === '关联数' && field.value === '2'))
-  assert.ok(specialist.fields.some((field) => field.label === '协议号' && field.value === 'P-1'))
+  assert.equal(specialist.fields.some((field) => field.label === '批次号'), false)
+  assert.equal(specialist.sampleCount, 2)
+  assert.equal(specialist.sampleBlocks.length, 2)
+  assert.equal(specialist.sampleBlocks[0].fields.find((field) => field.label === '协议号').value, 'P-1')
+  assert.doesNotMatch(
+    specialist.fields.map((field) => field.label).join(','),
+    /协议号|样本编码|母亲姓名|批次号/
+  )
   assert.equal(manager.privacyNote, '')
   assert.ok(manager.fields.some((field) => field.label === '关联数' && field.value === '2'))
-  assert.ok(manager.fields.some((field) => field.label === '样本2·来源' && field.value === '科研捐赠'))
-  assert.ok(manager.fields.some((field) => field.label === '样本2·协议号' && field.value === 'P-2'))
-  assert.ok(manager.fields.some((field) => field.label === '样本2·母亲姓名' && field.value === '周雨桐'))
-  assert.ok(manager.fields.some((field) => field.label === '样本2·子女姓名' && field.value === '宝宝乙'))
-  assert.ok(manager.fields.some((field) => field.label === '样本2·样本编码' && field.value === 'S-2'))
-  assert.ok(manager.fields.some((field) => field.label === '样本2·样本类型' && field.value === '脐带'))
+  assert.equal(manager.sampleBlocks[1].title, '样本 2 · S-2')
+  assert.equal(manager.sampleBlocks[1].status, '科研捐赠')
+  assert.equal(manager.sampleBlocks[1].fields.find((field) => field.label === '协议号').value, 'P-2')
+  assert.equal(manager.sampleBlocks[1].fields.find((field) => field.label === '母亲姓名').value, '周雨桐')
+  assert.equal(manager.sampleBlocks[1].fields.find((field) => field.label === '子女姓名').value, '宝宝乙')
+  assert.equal(manager.sampleBlocks[1].fields.find((field) => field.label === '样本编码').value, 'S-2')
+  assert.equal(manager.sampleBlocks[1].fields.find((field) => field.label === '样本类型').value, '脐带')
   assert.ok(manager.fields.some((field) => field.label === '更新时间' && field.value === '2026-10-13 10:00:00'))
   assert.ok(manager.fields.some((field) => field.label === '备注' && field.value === '同批送达'))
 })
 
 test('action labels cover module-specific operations', () => {
   assert.equal(actionLabel('link'), '关联样本')
-  assert.equal(actionLabel('unlink'), '取消运费')
+  assert.equal(actionLabel('unlink'), '取消样本关联')
   assert.equal(actionLabel('internal-action-code'), '操作')
 })
 
@@ -208,7 +244,11 @@ test('list cards hide unresolved reference and dictionary codes while retaining
   assert.equal(freight.title, 'F-KEEP-1')
   assert.ok(freight.subtitle.startsWith('未知 ·'))
   assert.equal(freight.fields.find((field) => field.label === '创建人').value, '未知人员')
-  assert.equal(freight.fields.find((field) => field.label === '样本编码').value, 'S-KEEP-2')
+  assert.equal(
+    freight.sampleBlocks[0].fields.find((field) => field.label === '样本编码').value,
+    'S-KEEP-2'
+  )
+  assert.equal(freight.sampleBlocks[0].status, '未知')
 
   const payment = buildMarketCard('payment', 'manager', {
     protocolNo: 'P-KEEP-2', sampleCode: 'S-KEEP-2', payMode: 'raw-pay-mode'