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

feat(mobile-oa-h5): align transfer payStatus labels with 付款 wording

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 4 недель назад
Родитель
Сommit
cc0b217c89

+ 228 - 0
mobile-oa-h5/src/market/components/PresignTransferCard.vue

@@ -0,0 +1,228 @@
+<template>
+  <article class="presign-transfer-card">
+    <header class="presign-transfer-card__header">
+      <div>
+        <strong>意向金转账 {{ index + 1 }}</strong>
+      </div>
+      <van-tag class="market-tag" :type="statusTone" :title="statusLabel">{{ statusLabel }}</van-tag>
+    </header>
+
+    <div class="presign-form-field">
+      <label>转账方式</label>
+      <PresignSelectField
+        :model-value="modelValue.transferType"
+        :options="transferTypes"
+        :disabled="!editable"
+        title="选择转账方式"
+        @update:model-value="updateField('transferType', $event)"
+      />
+    </div>
+
+    <div class="presign-form-field">
+      <label>转账金额(元)</label>
+      <OaMoneyField
+        :model-value="modelValue.transferAmount"
+        :disabled="!editable"
+        :show-upper="false"
+        @update:model-value="updateField('transferAmount', $event)"
+      />
+    </div>
+
+    <div class="presign-form-field">
+      <label>转账时间</label>
+      <OaDatePicker
+        :model-value="modelValue.transferTime"
+        type="datetime"
+        value-format="YYYY-MM-DD HH:mm:ss"
+        :disabled="!editable"
+        @update:model-value="updateField('transferTime', $event)"
+      />
+    </div>
+
+    <div class="presign-form-field is-wide">
+      <label>转账凭证</label>
+      <PresignAttachmentUploader
+        :model-value="modelValue.attachments"
+        :disabled="!editable"
+        :uploading="uploading"
+        @upload="$emit('upload', { transfer: modelValue, file: $event })"
+        @remove="removeAttachment"
+      />
+    </div>
+
+    <div class="presign-form-field is-wide">
+      <label>备注</label>
+      <van-field
+        :model-value="modelValue.remark"
+        type="textarea"
+        rows="2"
+        maxlength="512"
+        show-word-limit
+        :disabled="!editable"
+        placeholder="选填"
+        @update:model-value="updateField('remark', $event)"
+      />
+    </div>
+
+    <div v-if="modelValue.financeRemark" class="presign-transfer-card__finance">
+      <span>财务备注</span>
+      <strong>{{ modelValue.financeRemark }}</strong>
+    </div>
+
+    <footer v-if="showActions" class="presign-transfer-card__actions">
+      <van-button
+        v-if="canDelete"
+        size="small"
+        type="danger"
+        plain
+        icon="delete-o"
+        :disabled="busy"
+        @click="$emit('remove', modelValue)"
+      >删除</van-button>
+      <van-button
+        v-if="can('submit')"
+        size="small"
+        type="primary"
+        icon="passed"
+        :loading="busyAction === 'submit-transfer'"
+        :disabled="busy"
+        @click="$emit('submit-transfer', modelValue)"
+      >提交财务确认</van-button>
+      <van-button
+        v-if="can('withdraw')"
+        size="small"
+        type="warning"
+        plain
+        icon="revoke"
+        :loading="busyAction === 'withdraw-transfer'"
+        :disabled="busy"
+        @click="$emit('withdraw-transfer', modelValue)"
+      >撤回</van-button>
+    </footer>
+  </article>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import OaDatePicker from '../../oa/components/OaDatePicker.vue'
+import OaMoneyField from '../../oa/components/OaMoneyField.vue'
+import PresignAttachmentUploader from './PresignAttachmentUploader.vue'
+import PresignSelectField from './PresignSelectField.vue'
+
+const props = defineProps({
+  modelValue: { type: Object, required: true },
+  index: { type: Number, required: true },
+  transferTypes: { type: Array, default: () => [] },
+  disabled: { type: Boolean, default: false },
+  uploading: { type: Boolean, default: false },
+  busyAction: { type: String, default: '' }
+})
+
+const emit = defineEmits([
+  'update:modelValue',
+  'change',
+  'remove',
+  'upload',
+  'submit-transfer',
+  'withdraw-transfer'
+])
+
+const actionSet = computed(() => new Set(
+  (props.modelValue.allowedActions || []).map((action) =>
+    typeof action === 'string' ? action : action?.code
+  )
+))
+const newRow = computed(() => !String(props.modelValue.id || '').trim())
+const editable = computed(() => !props.disabled && (newRow.value || actionSet.value.has('edit')))
+const canDelete = computed(() => !props.disabled && (newRow.value || actionSet.value.has('delete')))
+const busy = computed(() => Boolean(props.busyAction) || props.uploading)
+const showActions = computed(() => canDelete.value || can('submit') || can('withdraw'))
+
+const statusLabel = computed(() => ({
+  dfj: '已付款',
+  jfqrz: '付款确认中',
+  yjf: '付款完成',
+  tfqrz: '退费确认中',
+  ytf: '已退费'
+}[String(props.modelValue.payStatus || 'dfj')] || '已付款'))
+
+const statusTone = computed(() => ({
+  dfj: 'default',
+  jfqrz: 'warning',
+  yjf: 'success',
+  tfqrz: 'warning',
+  ytf: 'default'
+}[String(props.modelValue.payStatus || 'dfj')] || 'default'))
+
+function can(action) {
+  return !props.disabled && actionSet.value.has(action)
+}
+
+function updateField(key, value) {
+  if (!editable.value) return
+  const next = { ...props.modelValue, [key]: value }
+  emit('update:modelValue', next)
+  emit('change', next)
+}
+
+function removeAttachment(index) {
+  if (!editable.value) return
+  const attachments = (props.modelValue.attachments || []).filter((_, itemIndex) => itemIndex !== index)
+  updateField('attachments', attachments)
+}
+</script>
+
+<style scoped>
+.presign-transfer-card {
+  padding: 14px;
+  border: 1px solid #dce9e4;
+  border-radius: 14px;
+  background: #fff;
+}
+
+.presign-transfer-card__header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 14px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #edf3f0;
+}
+
+.presign-transfer-card__header > div {
+  display: grid;
+  min-width: 0;
+  gap: 3px;
+}
+
+.presign-transfer-card__finance {
+  display: grid;
+  gap: 4px;
+  margin-top: 12px;
+  padding: 10px 12px;
+  border-radius: 10px;
+  background: #f4f8f6;
+}
+
+.presign-transfer-card__finance span {
+  color: #6b7f77;
+  font-size: 12px;
+}
+
+.presign-transfer-card__actions {
+  display: flex;
+  min-height: 44px;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+  margin-top: 14px;
+  padding-top: 12px;
+  border-top: 1px solid #edf3f0;
+}
+
+.presign-transfer-card__actions .van-button {
+  min-height: 36px;
+  border-radius: 9px;
+}
+</style>

+ 332 - 0
mobile-oa-h5/src/market/module-contracts.test.js

@@ -0,0 +1,332 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import { getMarketModule } from './module-registry.js'
+
+test('payment chase uses the singular read-only resource and opens payment detail', () => {
+  const module = getMarketModule('payment-chase')
+  assert.equal(module.resource, 'payment-chase')
+  assert.deepEqual(module.actions.specialist, ['view-payment'])
+  assert.deepEqual(module.detailTarget({ samplePaymentId: 'pay 1', scheduleId: 'sch-1' }), {
+    module: 'payment',
+    id: 'pay 1',
+    query: { scheduleId: 'sch-1' }
+  })
+})
+
+test('module adapters map list filters to the actual backend query names', () => {
+  assert.deepEqual(getMarketModule('presign').listParams({ status: 'draft' }), {
+    orderStatus: 'draft'
+  })
+  assert.deepEqual(getMarketModule('protocol').listParams({ status: 'cell_storage' }), {
+    source: 'cell_storage'
+  })
+  assert.deepEqual(getMarketModule('sample').listParams({ status: 'research_donation' }), {
+    source: 'research_donation'
+  })
+  assert.deepEqual(getMarketModule('freight').listParams({ status: 'air' }), {
+    transportMode: 'air',
+    includeItems: true
+  })
+})
+
+test('protocol list tabs match Web dual-source tabs without an all-sources option', () => {
+  assert.deepEqual(getMarketModule('protocol').statusOptions, [
+    { text: '细胞存储', value: 'cell_storage' },
+    { text: '科研捐赠', value: 'research_donation' }
+  ])
+  assert.deepEqual(getMarketModule('protocol').listParams({}), {
+    source: 'cell_storage'
+  })
+  assert.deepEqual(getMarketModule('protocol').listParams({ status: '' }), {
+    source: 'cell_storage'
+  })
+})
+
+test('sample list tabs match Web dual-source tabs without an all-sources option', () => {
+  assert.deepEqual(getMarketModule('sample').statusOptions, [
+    { text: '细胞存储', value: 'cell_storage' },
+    { text: '科研捐赠', value: 'research_donation' }
+  ])
+  assert.deepEqual(getMarketModule('sample').listParams({}), {
+    source: 'cell_storage'
+  })
+  assert.deepEqual(getMarketModule('sample').listParams({ status: '' }), {
+    source: 'cell_storage'
+  })
+  assert.deepEqual(getMarketModule('sample').listParams({ status: 'research_donation' }), {
+    source: 'research_donation'
+  })
+})
+
+test('presign filters use the backend order status codes', () => {
+  assert.deepEqual(getMarketModule('presign').statusOptions, [
+    { text: '全部状态', value: '' },
+    { text: '草稿', value: 'draft' },
+    { text: '进行中', value: 'active' },
+    { text: '已转正式', value: 'converted' },
+    { text: '已作废', value: 'void' }
+  ])
+})
+
+test('protocol summary status resolves real codes without exposing unknown codes', () => {
+  const protocol = getMarketModule('protocol')
+  assert.equal(protocol.statusLabel({ source: 'cell_storage', basic: { protocolStatus: 'sxz' } }), '生效中')
+  assert.equal(protocol.statusLabel({ source: 'research_donation', basic: { donationStatus: 'yzf' } }), '已作废')
+  assert.equal(protocol.statusLabel({ source: 'cell_storage', basic: { protocolStatus: 'raw-status' } }), '未知')
+  assert.equal(protocol.statusLabel({ source: 'cell_storage', basic: { protocolStatus: '' } }), '—')
+})
+
+test('protocol and sample adapters preserve nested detail sections', () => {
+  const protocol = getMarketModule('protocol').detailSections({
+    source: 'cell_storage',
+    protocol: { protocolNo: 'P-1' },
+    customer: { motherName: '张三' },
+    collection: { regionName: '华东' },
+    samples: [{ sampleCode: 'S-1' }],
+    payment: { payMode: 'yearly' },
+    attachments: { protocolAttachments: 'a.pdf' }
+  })
+  assert.deepEqual(protocol.map((section) => section.key), [
+    'protocol', 'collection', 'customer', 'samples', 'payment', 'attachments'
+  ])
+
+  const sample = getMarketModule('sample').detailSections({
+    sections: [{ key: 'market-handles', title: '市场办理', storageCert: { statusLabel: '已完成' } }]
+  })
+  assert.equal(sample[0].storageCert.statusLabel, '已完成')
+})
+
+test('attachment-bearing detail adapters expose typed values instead of path text', () => {
+  const protocol = getMarketModule('protocol').detailSections({
+    source: 'cell_storage',
+    basic: { protocolNo: 'P-1' },
+    attachments: {
+      protocolAttachments: 'protocol.pdf',
+      consentFormUrls: 'consent.jpg'
+    }
+  })
+  const protocolFiles = protocol.find((section) => section.key === 'attachments').fields
+  assert.ok(protocolFiles.every((field) => field.type === 'attachments'))
+  assert.equal(protocolFiles[0].value, 'protocol.pdf')
+
+  const payment = getMarketModule('payment').detailSections({
+    schedules: [{ transfers: [{ transferAttachments: 'payment-proof.png' }] }]
+  })
+  const paymentFiles = payment[1].cards[0].cards[0].fields.find(
+    (field) => field.label === '转账凭证'
+  )
+  assert.deepEqual(paymentFiles, {
+    label: '转账凭证',
+    type: 'attachments',
+    value: 'payment-proof.png'
+  })
+
+  const sample = getMarketModule('sample').detailSections({
+    sections: [{
+      key: 'market-handles',
+      storageCert: { attachments: 'certificate.pdf' },
+      insurance: { attachments: 'insurance.jpg' }
+    }]
+  })
+  assert.equal(sample[0].cards[0].fields.find((field) => field.label === '附件').type, 'attachments')
+
+  const presign = getMarketModule('presign').detailSections({
+    transfers: [{ transferAttachments: 'deposit-proof.png' }]
+  })
+  assert.equal(presign[1].cards[0].fields.find((field) => field.label === '转账附件').type, 'attachments')
+})
+
+test('presign detail prefers attachment metadata so original names remain visible', () => {
+  const attachments = [
+    { fileUrl: '/minio/uuid.pdf', fileName: '意向金凭证.pdf' }
+  ]
+  const section = getMarketModule('presign').detailSections({
+    transfers: [{ id: 't1', transferAttachments: '/minio/uuid.pdf', attachments }]
+  }).find((item) => item.key === 'transfers')
+
+  assert.deepEqual(
+    section.cards[0].fields.find((field) => field.type === 'attachments').value,
+    attachments
+  )
+})
+
+test('presign detail keeps the Web order fields and transfer cards', () => {
+  const sections = getMarketModule('presign').detailSections({
+    orderDate: '20261011',
+    protocolNo: 'P-1',
+    motherName: '张三',
+    expectedDeliveryDate: '20261231',
+    contractAmount: '28800',
+    confirmedDepositAmount: '5000',
+    orderStatus: 'active',
+    transfers: [{
+      id: 't-1', transferType: 'wx', transferAmount: '5000',
+      transferTime: '2026-10-11 10:00:00', payStatus: 'yjf'
+    }]
+  })
+
+  assert.deepEqual(sections.map((section) => section.key), ['base', 'transfers'])
+  assert.ok(sections[0].fields.some((field) => field.label === '母亲姓名' && field.value === '张三'))
+  assert.equal(sections[1].raw.transfers[0].statusLabel, '付款完成')
+})
+
+test('payment and sample adapters initialize only editable fields from real detail shapes', () => {
+  const payment = getMarketModule('payment')
+  const paymentDetail = {
+    id: 'payment-1',
+    schedules: [{ id: 'schedule-1', transfers: [{
+      id: 'transfer-1', transferAmount: '20', transferType: 'bank',
+      payStatus: 'dfj', statusLabel: '待提交', statusTone: 'warning'
+    }] }]
+  }
+  assert.deepEqual(payment.initialForm(paymentDetail, { transferId: 'transfer-1' }), {
+    transfer: { transferAmount: '20', transferType: 'bank' }
+  })
+
+  const sample = getMarketModule('sample')
+  const sampleDetail = { sections: [{ key: 'market-handles', storageCert: {
+    handleTime: '2026-10-11', handlerUserId: 'u1', certCode: 'CERT-1', statusLabel: '已完成'
+  } }] }
+  assert.deepEqual(sample.initialForm(sampleDetail, 'storage-cert'), {
+    handle: { handleTime: '2026-10-11', handlerUserId: 'u1' }
+  })
+})
+
+test('sample form options are scoped by the exact source-qualified sample id and action', () => {
+  const sample = getMarketModule('sample')
+  assert.deepEqual(sample.formOptionsParams('cell_storage:C-001', 'insurance'), {
+    sampleId: 'cell_storage:C-001',
+    action: 'insurance'
+  })
+})
+
+test('protocol personnel fields use isolated hydrated option collections', () => {
+  const fields = getMarketModule('protocol').formSections.flatMap((section) => section.fields)
+  assert.equal(fields.find((field) => field.key === 'collectorUserId').optionKey, 'collectorStaff')
+  assert.equal(fields.find((field) => field.key === 'salesUserId').optionKey, 'salesStaff')
+  assert.equal(fields.find((field) => field.key === 'regionManagerUserId').optionKey, 'managerStaff')
+})
+
+test('payment detail recognizes the converted cell-storage protocol status', () => {
+  const detail = getMarketModule('payment').detailSections({ protocolStatus: 'zckc' })
+  assert.equal(detail[0].fields.find((field) => field.label === '协议状态').value, '转细胞存储')
+})
+
+test('payment detail presents real backend progress, schedule statuses, and combined pay mode', () => {
+  const sections = getMarketModule('payment').detailSections({
+    payMode: 'djfq',
+    paymentProgress: '部分缴费',
+    schedules: [
+      { id: 'open', scheduleStatus: 'open' },
+      { id: 'cleared', scheduleStatus: 'cleared' },
+      { id: 'void', scheduleStatus: 'void' }
+    ]
+  })
+  const summary = sections.find((section) => section.key === 'summary')
+  const schedules = sections.find((section) => section.key === 'schedules').cards
+
+  assert.equal(summary.fields.find((field) => field.label === '缴费方式').value, '趸交+分期')
+  assert.equal(summary.fields.find((field) => field.label === '缴费进度').value, '部分缴费')
+  assert.equal(schedules.find((card) => card.key === 'open').statusText, '待缴费')
+  assert.equal(schedules.find((card) => card.key === 'cleared').statusText, '已缴清')
+  assert.equal(schedules.find((card) => card.key === 'void').statusText, '已作废')
+})
+
+test('protocol detail recognizes the combined lump-sum and installment pay mode', () => {
+  const sections = getMarketModule('protocol').detailSections({
+    source: 'cell_storage',
+    payment: { payMode: 'djfq' }
+  })
+  const payment = sections.find((section) => section.key === 'payment')
+  assert.equal(payment.fields.find((field) => field.label === '缴费模式').value, '趸交+分期')
+})
+
+test('protocol source is selectable only while creating and exposes all attachment fields', () => {
+  const fields = getMarketModule('protocol').formSections.flatMap((section) => section.fields)
+  assert.equal(fields.find((field) => field.key === 'source').createOnly, true)
+  assert.equal(fields.find((field) => field.key === 'source').type, 'tabs')
+  assert.ok(fields.some((field) => field.key === 'protocolTotalAmount'))
+  assert.ok(fields.some((field) => field.key === 'healthSurveyFormUrls'))
+  assert.ok(fields.some((field) => field.key === 'infectiousDiseaseReportUrls'))
+})
+
+test('freight unlink options come from complete detail items instead of a global first page', () => {
+  const freight = getMarketModule('freight')
+  const detailItems = [
+    { sampleSource: 'cell_storage', sampleId: 'old-1', freightId: 'f1' },
+    { sampleSource: 'research_donation', sampleId: 'old-2', freightId: 'f1' }
+  ]
+  const firstPage = [{ sampleSource: 'cell_storage', sampleId: 'new-1', freightId: '' }]
+  assert.deepEqual(freight.sampleOptions('unlink', detailItems, firstPage), detailItems)
+  assert.deepEqual(freight.sampleOptions('link', detailItems, firstPage), firstPage)
+  assert.equal(freight.usesPagedSampleOptions('unlink'), false)
+  assert.equal(freight.usesPagedSampleOptions('link'), true)
+})
+
+test('detail adapters hide unresolved references and dictionary codes but preserve business numbers', () => {
+  const protocol = getMarketModule('protocol').detailSections({
+    source: 'cell_storage',
+    basic: { protocolNo: 'P-RAW-1', protocolStatus: 'raw-status', collectorUserId: 'user-raw', collectorUserName: 'user-raw' },
+    collection: { regionId: 'region-raw', hospitalId: 'hospital-raw', salesUserId: 'sales-raw', salesUserName: 'sales-raw' },
+    customer: { newbornSex: 'raw-sex', deliveryMode: 'raw-delivery' },
+    samples: [{ sampleCode: 'S-RAW-1', sampleTypeId: 'type-raw', unitId: 'unit-raw', sampleStatus: 'ycj' }],
+    payment: { payMode: 'raw-pay-mode' }
+  })
+  const values = protocol.flatMap((section) => [
+    ...(section.fields || []).map((field) => field.value),
+    ...(section.cards || []).flatMap((card) => card.fields.map((field) => field.value))
+  ])
+  assert.ok(values.includes('P-RAW-1'))
+  assert.ok(values.includes('S-RAW-1'))
+  assert.ok(values.includes('未知人员'))
+  assert.ok(values.includes('未知'))
+  for (const raw of ['raw-status', 'user-raw', 'region-raw', 'hospital-raw', 'sales-raw', 'raw-sex', 'raw-delivery', 'type-raw', 'unit-raw', 'raw-pay-mode']) {
+    assert.ok(!values.includes(raw), `must not display ${raw}`)
+  }
+  assert.equal(protocol.find((section) => section.key === 'samples').cards[0].statusText, '已采集')
+
+  const sample = getMarketModule('sample').detailSections({
+    sections: [{ key: 'market-handles', storageCert: { handlerUserId: 'handler-raw', handlerUserName: 'handler-raw', statusLabel: 'raw-handle-status' } }]
+  })
+  assert.equal(sample[0].cards[0].fields.find((field) => field.label === '办理人员').value, '未知人员')
+  assert.equal(sample[0].cards[0].statusText, '未知')
+
+  const freight = getMarketModule('freight').detailSections({
+    freightNo: 'F-RAW-1', transportMode: 'raw-transport', createBy: 'creator-raw', creatorName: 'creator-raw',
+    items: [{ sampleSource: 'raw-source', sampleCode: 'S-RAW-2' }]
+  })
+  assert.equal(freight[0].fields.find((field) => field.label === '批次号').value, 'F-RAW-1')
+  assert.equal(freight[0].fields.find((field) => field.label === '运输方式').value, '未知')
+  assert.equal(freight[0].fields.find((field) => field.label === '创建人').value, '未知人员')
+  assert.equal(freight[1].cards[0].fields.find((field) => field.label === '样本编码').value, 'S-RAW-2')
+  assert.equal(freight[1].cards[0].fields.find((field) => field.label === '来源').value, '未知')
+
+  const presign = getMarketModule('presign').detailSections({
+    protocolNo: 'P-RAW-2', regionId: 'region-raw', regionName: 'region-raw',
+    hospitalId: 'hospital-raw', hospitalName: 'hospital-raw',
+    collectorUserId: 'collector-raw', collectorUserName: 'collector-raw',
+    payMode: 'raw-pay-mode', orderStatus: 'raw-order-status',
+    transfers: [{ transferType: 'raw-transfer', payStatus: 'raw-pay-status' }]
+  })
+  assert.equal(presign[0].fields.find((field) => field.label === '协议编号').value, 'P-RAW-2')
+  assert.equal(presign[0].fields.find((field) => field.label === '采集地区').value, '未知')
+  assert.equal(presign[0].fields.find((field) => field.label === '采集专员').value, '未知人员')
+  assert.equal(presign[0].fields.find((field) => field.label === '缴费方式').value, '未知')
+  assert.equal(presign[0].fields.find((field) => field.label === '订单状态').value, '未知')
+  assert.equal(presign[1].cards[0].fields.find((field) => field.label === '转账方式').value, '未知')
+  assert.equal(presign[1].cards[0].statusText, '未知')
+
+  const payment = getMarketModule('payment').detailSections({
+    protocolNo: 'P-RAW-3', payMode: 'raw-pay-mode', protocolStatus: 'raw-status',
+    progressStatus: 'raw-progress', schedules: [{ scheduleStatus: 'raw-schedule', transfers: [{ transferType: 'raw-transfer', payStatus: 'raw-pay-status' }] }]
+  })
+  const paymentValues = payment.flatMap((section) => [
+    ...(section.fields || []).map((field) => field.value),
+    ...(section.cards || []).flatMap((card) => [card.statusText, ...(card.cards || []).flatMap((nested) => [nested.statusText, ...nested.fields.map((field) => field.value)])])
+  ])
+  assert.ok(paymentValues.includes('P-RAW-3'))
+  for (const raw of ['raw-pay-mode', 'raw-status', 'raw-progress', 'raw-schedule', 'raw-transfer', 'raw-pay-status']) {
+    assert.ok(!paymentValues.includes(raw), `must not display ${raw}`)
+  }
+})

+ 104 - 0
mobile-oa-h5/src/market/modules/payment.js

@@ -1,3 +1,79 @@
+const PAY_MODE = Object.freeze({ dj: '趸交', fq: '分期', fgd: '分改趸', djfq: '趸交+分期' })
+const PROTOCOL_STATUS = Object.freeze({
+  dsx: '待生效', dqr: '待确认', sxz: '生效中', ygq: '已过期',
+  yzf: '已作废', sdfq: '属地废弃', zckc: '转细胞存储', zky: '转科研'
+})
+const PAYMENT_PROGRESS = Object.freeze({
+  dfj: '待缴费', jfqrz: '付款确认中', bfyj: '部分已缴', yjq: '已缴清',
+  待缴费: '待缴费', 部分缴费: '部分缴费', 已缴清: '已缴清'
+})
+const SCHEDULE_STATUS = Object.freeze({
+  open: '待缴费', cleared: '已缴清', void: '已作废',
+  pending: '待缴费', partial: '部分到账', paid: '已缴清', closed: '已关闭'
+})
+const PAY_STATUS = Object.freeze({
+  dfj: '已付款', jfqrz: '付款确认中', yjf: '付款完成', tfqrz: '退费确认中', ytf: '已退费'
+})
+const TRANSFER_TYPE = Object.freeze({
+  wxzz: '微信转账', wx: '微信转账', wechat: '微信转账',
+  zzbzz: '支付宝转账', zfb: '支付宝转账', alipay: '支付宝转账',
+  yhkzz: '银行卡转账', bank: '银行卡转账',
+  xj: '现金', cash: '现金', lszz: '乐刷'
+})
+
+function label(map, value) {
+  const key = String(value || '').trim()
+  return map[key] || (key ? '未知' : '—')
+}
+
+function money(value) {
+  if (value == null || value === '') return '—'
+  const amount = Number(value)
+  if (!Number.isFinite(amount)) return String(value)
+  return `¥${amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
+}
+
+function displayDate(value) {
+  const text = String(value || '').trim()
+  return /^\d{8}$/.test(text)
+    ? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`
+    : text || '—'
+}
+
+function transferCard(transfer, index) {
+  const statusText = label(PAY_STATUS, transfer?.payStatus)
+  return {
+    key: String(transfer?.id || `transfer-${index}`),
+    title: `转账笔 ${index + 1}`,
+    statusText,
+    statusTone: statusText.includes('确认中') ? 'warning' : statusText === '付款完成' ? 'success' : 'info',
+    fields: [
+      { label: '转账方式', value: label(TRANSFER_TYPE, transfer?.transferType) },
+      { label: '转账金额', value: money(transfer?.transferAmount) },
+      { label: '转账时间', value: transfer?.transferTime || '—' },
+      { label: '转账凭证', type: 'attachments', value: transfer?.transferAttachments || '' },
+      { label: '备注', value: transfer?.remark || '—' }
+    ]
+  }
+}
+
+function scheduleCard(schedule, index) {
+  const statusText = label(SCHEDULE_STATUS, schedule?.scheduleStatus)
+  return {
+    key: String(schedule?.id || `schedule-${index}`),
+    title: `第 ${schedule?.scheduleNo || index + 1} 期`,
+    statusText,
+    statusTone: statusText === '已缴清' ? 'success' : statusText === '部分到账' ? 'primary' : 'warning',
+    fields: [
+      { label: '应缴金额', value: money(schedule?.dueAmount) },
+      { label: '应缴日', value: displayDate(schedule?.dueDate) },
+      { label: '已缴金额', value: money(schedule?.paidAmount) },
+      { label: '欠缴金额', value: money(schedule?.arrearsAmount) }
+    ],
+    cards: (schedule?.transfers || []).map(transferCard)
+  }
+}
+
 export default {
   key: 'payment',
   resource: 'payments',
@@ -7,6 +83,34 @@ export default {
   },
   listParams: ({ status } = {}) => ({ payMode: status || '' }),
   statusOptions: [{ text: '全部缴费模式', value: '' }],
+  detailSections(detail) {
+    const progress = label(PAYMENT_PROGRESS, detail?.progressStatus || detail?.paymentProgress)
+    const protocolStatus = label(PROTOCOL_STATUS, detail?.protocolStatus)
+    return [
+      {
+        key: 'summary',
+        title: '缴费概览',
+        fields: [
+          { label: '协议号', value: detail?.protocolNo || '—' },
+          { label: '客户', value: detail?.motherName || '—' },
+          { label: '联系电话', value: detail?.motherPhone || '—' },
+          { label: '样本编码', value: detail?.sampleCode || '—' },
+          { label: '缴费方式', value: label(PAY_MODE, detail?.payMode), tag: true, tagType: 'primary' },
+          { label: '合同额', value: money(detail?.contractAmount) },
+          { label: '已缴 / 待缴', value: `${money(detail?.paidAmount)} / ${money(detail?.dueRemainAmount ?? detail?.remainAmount)}` },
+          { label: '整体进度', value: `已缴清 ${Number(detail?.clearedScheduleCount || 0)}/${Number(detail?.activeScheduleCount || 0)} 期` },
+          { label: '协议状态', value: protocolStatus, tag: true },
+          { label: '缴费进度', value: progress, tag: true }
+        ]
+      },
+      {
+        key: 'schedules',
+        title: '应缴批次',
+        fields: [],
+        cards: (detail?.schedules || []).map(scheduleCard)
+      }
+    ]
+  },
   initialForm(detail, target = {}) {
     const transfer = (detail?.schedules || [])
       .flatMap((row) => row.transfers || [])

+ 128 - 5
mobile-oa-h5/src/market/modules/presign.js

@@ -1,4 +1,55 @@
 import { commonFields } from './shared.js'
+import {
+  formatPresignDate,
+  formatPresignDateTime,
+  formatPresignMoney,
+  presignStatusLabel,
+  presignStatusTone
+} from '../presign/presign-presenter.js'
+
+const paymentStatus = Object.freeze({
+  dfj: { label: '已付款', tone: 'default' },
+  jfqrz: { label: '付款确认中', tone: 'warning' },
+  yjf: { label: '付款完成', tone: 'success' },
+  tfqrz: { label: '退费确认中', tone: 'warning' },
+  ytf: { label: '已退费', tone: 'default' }
+})
+const transferTypeLabels = Object.freeze({
+  wxzz: '微信转账', wx: '微信转账', wechat: '微信转账',
+  zzbzz: '支付宝转账', zfb: '支付宝转账', alipay: '支付宝转账',
+  yhkzz: '银行卡转账', bank: '银行卡转账',
+  xj: '现金', cash: '现金', lszz: '乐刷'
+})
+
+function dictionaryLabel(map, value) {
+  const key = String(value || '').trim()
+  return map[key] || (key ? '未知' : '—')
+}
+
+function person(name, id) {
+  const displayName = String(name || '').trim()
+  const referenceId = String(id || '').trim()
+  if (displayName && displayName !== referenceId) return displayName
+  return referenceId ? '未知人员' : '—'
+}
+
+function referenceName(name, id) {
+  const displayName = String(name || '').trim()
+  const referenceId = String(id || '').trim()
+  if (displayName && displayName !== referenceId) return displayName
+  return referenceId ? '未知' : '—'
+}
+
+function presignStatus(value) {
+  const code = String(value || '').trim()
+  if (!code) return '—'
+  const result = presignStatusLabel(code)
+  return result === code ? '未知' : result
+}
+
+function displayValue(value) {
+  return value && value !== '-' ? value : '—'
+}
 
 export default {
   key: 'presign',
@@ -10,16 +61,88 @@ export default {
   listParams: ({ status } = {}) => ({ orderStatus: status || '' }),
   statusOptions: [
     { text: '全部状态', value: '' },
-    { text: '预签约', value: 'yqy' },
-    { text: '已转正式', value: 'yzs' }
+    { text: '草稿', value: 'draft' },
+    { text: '进行中', value: 'active' },
+    { text: '已转正式', value: 'converted' },
+    { text: '已作废', value: 'void' }
   ],
+  statusLabel(detail) {
+    return presignStatus(detail?.orderStatus)
+  },
+  statusTone(detail) {
+    return presignStatusTone(detail?.orderStatus)
+  },
+  detailSections(detail) {
+    const status = String(detail?.orderStatus || '')
+    const transfers = (detail?.transfers || []).map((transfer, index) => {
+      const paymentCode = String(transfer?.payStatus || 'dfj')
+      const payment = paymentStatus[paymentCode] || { label: '未知', tone: 'default' }
+      return {
+        ...transfer,
+        title: `意向金转账 ${index + 1}`,
+        statusLabel: payment.label,
+        statusTone: payment.tone,
+        transferAmount: formatPresignMoney(transfer?.transferAmount),
+        transferTime: formatPresignDateTime(transfer?.transferTime)
+      }
+    })
+    return [
+      {
+        key: 'base',
+        title: '预签信息',
+        fields: [
+          { label: '业务日期', value: displayValue(formatPresignDate(detail?.orderDate)) },
+          { label: '协议编号', value: detail?.protocolNo || '—' },
+          { label: '母亲姓名', value: detail?.motherName || '—' },
+          { label: '预产期', value: displayValue(formatPresignDate(detail?.expectedDeliveryDate)) },
+          { label: '采集地区', value: referenceName(detail?.regionName, detail?.regionId) },
+          { label: '采集医院', value: referenceName(detail?.hospitalName, detail?.hospitalId) },
+          { label: '采集专员', value: person(detail?.collectorUserName, detail?.collectorUserId) },
+          { label: '采集经理', value: person(detail?.regionManagerUserName, detail?.regionManagerUserId) },
+          { label: '采集时间', value: displayValue(formatPresignDateTime(detail?.sampleCollectTime)) },
+          { label: '缴费方式', value: dictionaryLabel({ fq: '分期', dj: '趸交' }, detail?.payMode) },
+          { label: '合同金额', value: displayValue(formatPresignMoney(detail?.contractAmount)) },
+          { label: '到账金额', value: displayValue(formatPresignMoney(detail?.confirmedDepositAmount)) },
+          { label: '订单状态', value: presignStatus(status), tag: true, tagType: presignStatusTone(status) },
+          { label: '备注', value: detail?.remark || '—' },
+          { label: '创建时间', value: displayValue(formatPresignDateTime(detail?.createTime)) }
+        ],
+        raw: {},
+        cards: []
+      },
+      {
+        key: 'transfers',
+        title: '意向金转账',
+        fields: [],
+        raw: { transfers },
+        cards: transfers.map((transfer, index) => ({
+          key: String(transfer?.id || `transfer-${index}`),
+          title: transfer.title,
+          statusText: transfer.statusLabel,
+          statusTone: transfer.statusTone,
+          fields: [
+            { label: '转账方式', value: dictionaryLabel(transferTypeLabels, transfer.transferType) },
+            { label: '转账金额', value: transfer.transferAmount || '—' },
+            { label: '转账时间', value: transfer.transferTime || '—' },
+            {
+              label: '转账附件',
+              type: 'attachments',
+              value: transfer.attachments?.length
+                ? transfer.attachments
+                : transfer.transferAttachments || ''
+            },
+            { label: '备注', value: transfer.remark || '—' }
+          ]
+        }))
+      }
+    ]
+  },
   formSections: [
     {
       key: 'base', title: '预签信息', fields: [
-        { key: 'orderDate', label: '预签日期', type: 'date' },
-        { key: 'protocolNo', label: '协议编号' },
+        { key: 'orderDate', label: '预签日期', type: 'date', valueFormat: 'YYYYMMDD' },
         { key: 'motherName', label: '客户姓名', required: true },
-        { key: 'expectedDeliveryDate', label: '预产期', type: 'date' },
+        { key: 'expectedDeliveryDate', label: '预产期', type: 'date', valueFormat: 'YYYYMMDD' },
         { key: 'regionId', label: '采集地区', type: 'select', optionKey: 'regions' },
         { key: 'hospitalId', label: '采集医院', type: 'select', optionKey: 'hospitals' },
         { key: 'sampleCollectTime', label: '采集时间', type: 'datetime' },