Procházet zdrojové kódy

refactor(mobile-oa-h5): extract ScheduleTransferEditor for shared transfer UI

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu před 1 týdnem
rodič
revize
c825c170e7

+ 31 - 499
mobile-oa-h5/src/market/components/ProtocolSamplePaymentPanel.vue

@@ -231,140 +231,15 @@
                     <b>¥{{ schedulePaid(sch).arrears }}</b>
                   </div>
                 </div>
-                <article
-                  v-for="(tr, tIdx) in sch.transfers || []"
-                  :key="tr.id || `${pIdx}-${sIdx}-${tIdx}`"
-                  class="protocol-sample-pay__transfer"
-                >
-                  <header class="protocol-sample-pay__transfer-title">
-                    <strong>转账 {{ tIdx + 1 }}</strong>
-                    <van-tag class="market-tag" :type="payStatusTone(tr.payStatus)" round>
-                      {{ payStatusLabel(tr.payStatus) }}
-                    </van-tag>
-                  </header>
-                  <div class="market-form-fields">
-                    <div class="market-form-field">
-                      <label>转账方式</label>
-                      <div
-                        class="market-form-field__control"
-                        @click="!disabled && canEditTransfer(sch, tr) && openTransferTypeSheet(pIdx, sIdx, tIdx)"
-                      >
-                        <van-field
-                          :model-value="transferTypeLabel(tr.transferType)"
-                          is-link
-                          readonly
-                          placeholder="请选择"
-                          :disabled="disabled || !canEditTransfer(sch, tr)"
-                          :border="false"
-                        />
-                      </div>
-                    </div>
-                    <div class="market-form-field">
-                      <label>转账金额</label>
-                      <van-field
-                        :model-value="tr.transferAmount == null ? '' : String(tr.transferAmount)"
-                        type="number"
-                        placeholder="请输入"
-                        :disabled="disabled || !canEditTransfer(sch, tr)"
-                        :border="false"
-                        @update:model-value="(v) => patchTransfer(pIdx, sIdx, tIdx, { transferAmount: parseAmount(v) })"
-                      />
-                    </div>
-                    <div class="market-form-field">
-                      <label>转账时间</label>
-                      <div class="market-form-field__control">
-                        <OaDatePicker
-                          :model-value="tr.transferTime || ''"
-                          type="datetime"
-                          value-format="YYYY-MM-DD HH:mm:ss"
-                          :disabled="disabled || !canEditTransfer(sch, tr)"
-                          @update:model-value="(v) => patchTransfer(pIdx, sIdx, tIdx, { transferTime: v || '' })"
-                        />
-                      </div>
-                    </div>
-                    <div class="market-form-field">
-                      <label>转账凭证</label>
-                      <PresignAttachmentUploader
-                        :model-value="ensureTransferAttachments(tr)"
-                        :disabled="disabled || !canEditTransfer(sch, tr)"
-                        :uploading="isTransferUploading(pIdx, sIdx, tIdx)"
-                        @upload="(file) => uploadTransferFile(pIdx, sIdx, tIdx, file)"
-                        @remove="(index) => removeTransferFile(pIdx, sIdx, tIdx, index)"
-                      />
-                    </div>
-                    <div class="market-form-field">
-                      <label>备注</label>
-                      <van-field
-                        :model-value="tr.remark || ''"
-                        type="textarea"
-                        rows="2"
-                        maxlength="512"
-                        show-word-limit
-                        placeholder="选填"
-                        :disabled="disabled || !canEditTransfer(sch, tr)"
-                        :border="false"
-                        @update:model-value="(v) => patchTransfer(pIdx, sIdx, tIdx, { remark: v })"
-                      />
-                    </div>
-                    <div v-if="tr.financeRemark" class="market-form-field">
-                      <label>财务备注</label>
-                      <van-field
-                        :model-value="tr.financeRemark"
-                        readonly
-                        :border="false"
-                      />
-                    </div>
-                  </div>
-                  <div v-if="!disabled && hasTransferAction(sch, tr)" class="protocol-sample-pay__transfer-actions">
-                    <van-button
-                      v-if="canEditTransfer(sch, tr)"
-                      size="mini"
-                      type="primary"
-                      plain
-                      :loading="busyKey === transferBusyKey(pIdx, sIdx, tIdx, tr)"
-                      @click="saveTransfer(pIdx, sIdx, tIdx)"
-                    >
-                      保存
-                    </van-button>
-                    <van-button
-                      v-if="canSubmitTransfer(tr)"
-                      size="mini"
-                      type="primary"
-                      :loading="busyKey === `submit:${tr.id}`"
-                      @click="submitFinance(tr, pIdx, sIdx, tIdx)"
-                    >
-                      提交财务
-                    </van-button>
-                    <van-button
-                      v-if="canWithdrawTransfer(tr)"
-                      size="mini"
-                      type="warning"
-                      :loading="busyKey === `withdraw:${tr.id}`"
-                      @click="withdrawFinance(tr, pIdx, sIdx, tIdx)"
-                    >
-                      撤回
-                    </van-button>
-                    <van-button
-                      v-if="canDeleteTransfer(sch, tr)"
-                      size="mini"
-                      type="danger"
-                      plain
-                      @click="removeTransfer(pIdx, sIdx, tIdx)"
-                    >
-                      删除
-                    </van-button>
-                  </div>
-                </article>
-                <van-button
-                  v-if="!disabled && canAddTransfer(sch)"
-                  class="protocol-sample-pay__add-transfer"
-                  size="mini"
-                  type="primary"
-                  block
-                  @click="addTransfer(pIdx, sIdx)"
-                >
-                  添加转账
-                </van-button>
+                <ScheduleTransferEditor
+                  :schedule="sch"
+                  :transfer-types="transferTypes"
+                  :market-role="marketRole"
+                  :context-id="protocolId"
+                  :disabled="disabled"
+                  @update:schedule="(next) => replaceSchedule(pIdx, sIdx, next)"
+                  @changed="onTransferChanged"
+                />
               </div>
             </div>
           </article>
@@ -379,13 +254,6 @@
       close-on-click-action
       @select="onPayModeSelect"
     />
-    <van-action-sheet
-      v-model:show="transferTypeSheetVisible"
-      :actions="transferTypeSheetActions"
-      cancel-text="取消"
-      close-on-click-action
-      @select="onTransferTypeSelect"
-    />
     <van-popup v-model:show="datePickerVisible" position="bottom" round>
       <van-date-picker
         v-model="datePickerValue"
@@ -401,10 +269,8 @@
 
 <script setup>
 import { computed, ref, watch } from 'vue'
-import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
-import OaDatePicker from '../../oa/components/OaDatePicker.vue'
-import { performMarketAction, uploadMarketAttachment } from '../../api/market.js'
-import PresignAttachmentUploader from './PresignAttachmentUploader.vue'
+import { showConfirmDialog, showToast } from 'vant'
+import ScheduleTransferEditor from './ScheduleTransferEditor.vue'
 import {
   applyConsolidateRemainingToPayment,
   applyExpandRemainingToInstallment,
@@ -421,32 +287,11 @@ import {
   shouldAutoFillLastPeriodDue
 } from '../protocol-sample-payment.js'
 import {
-  applySavedTransfer,
-  assertCanSaveTransfer,
-  buildTransferSavePayload,
-  canSubmitFinance,
   canUseProtocolScheduleAction,
-  canUseProtocolTransferAction,
-  canWithdrawFinance,
-  emptyTransferRow,
-  ensureTransferAttachments,
-  PAY_STATUS_LABELS,
-  patchTransfersAt,
-  validateTransferRow
+  emptyTransferRow
 } from '../protocol-sample-transfer.js'
-import { marketErrorMessage } from '../market-error.js'
-import {
-  createPendingMarketAttachment,
-  dropPendingMarketAttachment,
-  removeMarketAttachmentAt,
-  resolvePendingMarketAttachment,
-  withPendingMarketAttachment
-} from '../attachment-upload-state.js'
 import {
-  applyTransferDeleteResult,
-  clearTransferDirty,
   hasDirtyTransfers,
-  isTransferDirty,
   markTransferDirty
 } from '../protocol-transfer-dirty.js'
 import { compareMoney, normalizeMoney } from '../money.js'
@@ -472,16 +317,11 @@ const lastPeriodDueOverrideByKey = ref({})
 const modeSheetVisible = ref(false)
 const modeSheetPayIdx = ref(-1)
 const modeSheetActions = ref([])
-const transferTypeSheetVisible = ref(false)
-const transferTypeSheetActions = ref([])
-const transferTypeTarget = ref(null)
 const datePickerVisible = ref(false)
 const datePickerValue = ref(['2026', '10', '13'])
 const dateTarget = ref(null)
 const minDate = new Date(2020, 0, 1)
 const maxDate = new Date(2040, 11, 31)
-const busyKey = ref('')
-const uploadKeys = ref(new Set())
 
 const syncedPayments = computed(() => props.modelValue || [])
 const expandedSchedules = ref(new Set())
@@ -592,66 +432,38 @@ function isScheduleLocked(sch) {
   return (sch.transfers || []).some((t) => t.payStatus === 'yjf')
 }
 
-function paymentContext() {
-  return { module: 'payment', marketRole: props.marketRole || 'specialist' }
-}
-
 function schedulePaid(sch) {
   return schedulePaidArrears(sch?.dueAmount, sch?.transfers || [])
 }
 
-function payStatusLabel(status) {
-  return PAY_STATUS_LABELS[String(status || 'dfj')] || '已付款'
-}
-
-function payStatusTone(status) {
-  const code = String(status || 'dfj')
-  if (code === 'yjf') return 'success'
-  if (code === 'jfqrz') return 'warning'
-  return 'primary'
-}
-
-function transferTypeLabel(value) {
-  const key = String(value || '').trim()
-  const hit = (props.transferTypes || []).find((opt) => String(opt.value) === key)
-  return hit?.label || key || '请选择'
-}
-
-function parseAmount(raw) {
-  if (raw === '' || raw == null) return null
-  return String(raw)
-}
-
-function transferBusyKey(pIdx, sIdx, tIdx, tr) {
-  return `save:${tr?.id || `${pIdx}-${sIdx}-${tIdx}`}`
-}
-
-function isTransferUploading(pIdx, sIdx, tIdx) {
-  return uploadKeys.value.has(`${pIdx}-${sIdx}-${tIdx}`)
-}
-
-function getTransferList(pIdx, sIdx) {
-  return [...(props.modelValue?.[pIdx]?.schedules?.[sIdx]?.transfers || [])]
+function replaceSchedule(pIdx, sIdx, nextSchedule) {
+  const list = [...(props.modelValue || [])]
+  const pay = { ...list[pIdx] }
+  const schedules = [...(pay.schedules || [])]
+  schedules[sIdx] = nextSchedule
+  pay.schedules = schedules
+  list[pIdx] = pay
+  emitList(list)
+  emit('transfer-dirty-change', hasDirtyTransfers(list))
 }
 
-function setTransferList(pIdx, sIdx, transfers) {
-  const next = patchTransfersAt(props.modelValue, pIdx, sIdx, transfers)
-  emitList(next)
-  emit('transfer-dirty-change', hasDirtyTransfers(next))
+function onTransferChanged() {
+  emit('transfer-dirty-change', hasDirtyTransfers(props.modelValue))
 }
 
-function patchTransfer(pIdx, sIdx, tIdx, patch) {
-  const transfers = getTransferList(pIdx, sIdx)
-  transfers[tIdx] = markTransferDirty({ ...transfers[tIdx], ...patch })
-  setTransferList(pIdx, sIdx, transfers)
+function canAddTransfer(schedule) {
+  if (!schedule) return false
+  // 对齐 Web SamplePaymentPanel:每期均可录入转账草稿
+  const actions = schedule.allowedActions
+  if (!Array.isArray(actions) || actions.length === 0) return true
+  return canUseProtocolScheduleAction(schedule, 'register-transfer')
 }
 
 function addTransfer(pIdx, sIdx) {
   const schedule = props.modelValue?.[pIdx]?.schedules?.[sIdx]
   if (!canAddTransfer(schedule)) return
-  const transfers = getTransferList(pIdx, sIdx)
-  transfers.push(markTransferDirty(emptyTransferRow()))
-  setTransferList(pIdx, sIdx, transfers)
+  const transfers = [...(schedule.transfers || []), markTransferDirty(emptyTransferRow())]
+  replaceSchedule(pIdx, sIdx, { ...schedule, transfers })
   const key = scheduleKey(pIdx, sIdx)
   if (!expandedSchedules.value.has(key)) {
     const next = new Set(expandedSchedules.value)
@@ -660,240 +472,6 @@ function addTransfer(pIdx, sIdx) {
   }
 }
 
-async function removeTransfer(pIdx, sIdx, tIdx) {
-  const schedule = props.modelValue?.[pIdx]?.schedules?.[sIdx]
-  const transfer = schedule?.transfers?.[tIdx]
-  if (!transfer || !canDeleteTransfer(schedule, transfer)) return
-  try {
-    await showConfirmDialog({ title: '删除转账', message: '确认删除该笔转账记录吗?' })
-  } catch {
-    return
-  }
-  if (!transfer.id) {
-    const transfers = getTransferList(pIdx, sIdx)
-    transfers.splice(tIdx, 1)
-    setTransferList(pIdx, sIdx, transfers)
-    return
-  }
-  busyKey.value = `delete:${transfer.id}`
-  try {
-    await performMarketAction(paymentContext(), 'delete-payment-transfer', {
-      id: transfer.id
-    })
-    const next = applyTransferDeleteResult(
-      { samplePayments: props.modelValue }, pIdx, sIdx, tIdx, true
-    ).samplePayments
-    emitList(next)
-    emit('transfer-dirty-change', hasDirtyTransfers(next))
-    showSuccessToast('转账记录已删除')
-  } catch (error) {
-    showToast(marketErrorMessage(error, '删除转账失败'))
-  } finally {
-    busyKey.value = ''
-  }
-}
-
-function canAddTransfer(schedule) {
-  if (!schedule) return false
-  // 对齐 Web SamplePaymentPanel:每期均可录入转账草稿
-  const actions = schedule.allowedActions
-  if (!Array.isArray(actions) || actions.length === 0) return true
-  return canUseProtocolScheduleAction(schedule, 'register-transfer')
-}
-
-function canEditTransfer(schedule, transfer) {
-  if (!transfer?.id) return canAddTransfer(schedule)
-  return canUseProtocolTransferAction(transfer, 'edit-transfer')
-}
-
-function canDeleteTransfer(schedule, transfer) {
-  if (!transfer?.id) return canAddTransfer(schedule)
-  return canUseProtocolTransferAction(transfer, 'delete-payment-transfer')
-}
-
-function canSubmitTransfer(transfer) {
-  return !isTransferDirty(transfer) &&
-    canUseProtocolTransferAction(transfer, 'submit') &&
-    canSubmitFinance(transfer, props.protocolId)
-}
-
-function canWithdrawTransfer(transfer) {
-  return canUseProtocolTransferAction(transfer, 'withdraw') &&
-    canWithdrawFinance(transfer, props.protocolId)
-}
-
-function hasTransferAction(schedule, transfer) {
-  return canEditTransfer(schedule, transfer) ||
-    canDeleteTransfer(schedule, transfer) ||
-    canSubmitTransfer(transfer) ||
-    canWithdrawTransfer(transfer)
-}
-
-function openTransferTypeSheet(pIdx, sIdx, tIdx) {
-  transferTypeTarget.value = { pIdx, sIdx, tIdx }
-  transferTypeSheetActions.value = (props.transferTypes || []).map((opt) => ({
-    name: opt.label || opt.value,
-    value: opt.value
-  }))
-  if (!transferTypeSheetActions.value.length) {
-    showToast('暂无转账方式选项')
-    return
-  }
-  transferTypeSheetVisible.value = true
-}
-
-function onTransferTypeSelect(action) {
-  const target = transferTypeTarget.value
-  if (!target) return
-  patchTransfer(target.pIdx, target.sIdx, target.tIdx, { transferType: action.value })
-}
-
-async function saveTransfer(pIdx, sIdx, tIdx) {
-  const sch = props.modelValue?.[pIdx]?.schedules?.[sIdx]
-  const tr = sch?.transfers?.[tIdx]
-  if (!tr || !canEditTransfer(sch, tr)) return
-  try {
-    assertCanSaveTransfer({ protocolId: props.protocolId, scheduleId: sch?.id })
-  } catch (error) {
-    showToast(error.message)
-    return
-  }
-  const invalid = validateTransferRow(tr)
-  if (invalid) {
-    showToast(invalid)
-    return
-  }
-  const key = transferBusyKey(pIdx, sIdx, tIdx, tr)
-  busyKey.value = key
-  try {
-    const payload = buildTransferSavePayload(tr)
-    let remote
-    if (tr.id) {
-      remote = await performMarketAction(paymentContext(), 'edit-transfer', {
-        id: tr.id,
-        data: payload
-      })
-    } else {
-      remote = await performMarketAction(paymentContext(), 'register-transfer', {
-        data: { scheduleId: String(sch.id), ...payload }
-      })
-    }
-    const data = remote?.data || remote || {}
-    const transfers = getTransferList(pIdx, sIdx)
-    transfers[tIdx] = clearTransferDirty(applySavedTransfer(transfers[tIdx], data))
-    setTransferList(pIdx, sIdx, transfers)
-    showSuccessToast(tr.id ? '转账记录已保存' : '转账记录已创建')
-  } catch (error) {
-    showToast(marketErrorMessage(error, '保存转账失败'))
-  } finally {
-    busyKey.value = ''
-  }
-}
-
-async function submitFinance(tr, pIdx, sIdx, tIdx) {
-  if (!canSubmitTransfer(tr)) {
-    showToast('请先保存该转账记录后再提交财务确认')
-    return
-  }
-  try {
-    await showConfirmDialog({
-      title: '提交财务确认',
-      message: '确认提交该笔转账给财务确认吗?'
-    })
-  } catch {
-    return
-  }
-  busyKey.value = `submit:${tr.id}`
-  try {
-    const remote = await performMarketAction(paymentContext(), 'submit', { id: tr.id })
-    const data = remote?.data || remote || {}
-    const transfers = getTransferList(pIdx, sIdx)
-    transfers[tIdx] = applySavedTransfer(transfers[tIdx], { ...data, payStatus: data.payStatus || 'jfqrz' })
-    setTransferList(pIdx, sIdx, transfers)
-    showSuccessToast('已提交财务确认')
-  } catch (error) {
-    showToast(marketErrorMessage(error, '提交失败'))
-  } finally {
-    busyKey.value = ''
-  }
-}
-
-async function withdrawFinance(tr, pIdx, sIdx, tIdx) {
-  if (!canWithdrawTransfer(tr)) return
-  try {
-    await showConfirmDialog({
-      title: '撤回',
-      message: '确认撤回该笔转账的财务确认申请?撤回后可修改并重新提交。'
-    })
-  } catch {
-    return
-  }
-  busyKey.value = `withdraw:${tr.id}`
-  try {
-    const remote = await performMarketAction(paymentContext(), 'withdraw', { id: tr.id })
-    const data = remote?.data || remote || {}
-    const transfers = getTransferList(pIdx, sIdx)
-    transfers[tIdx] = applySavedTransfer(transfers[tIdx], { ...data, payStatus: data.payStatus || 'dfj' })
-    setTransferList(pIdx, sIdx, transfers)
-    showSuccessToast('已撤回')
-  } catch (error) {
-    showToast(marketErrorMessage(error, '撤回失败'))
-  } finally {
-    busyKey.value = ''
-  }
-}
-
-async function uploadTransferFile(pIdx, sIdx, tIdx, file) {
-  if (!file) return
-  const key = `${pIdx}-${sIdx}-${tIdx}`
-  const pending = createPendingMarketAttachment(file)
-  const transfersBegin = getTransferList(pIdx, sIdx)
-  const begin = { ...transfersBegin[tIdx] }
-  ensureTransferAttachments(begin)
-  begin.attachments = withPendingMarketAttachment(begin.attachments, pending)
-  transfersBegin[tIdx] = markTransferDirty(begin)
-  setTransferList(pIdx, sIdx, transfersBegin)
-  uploadKeys.value = new Set([...uploadKeys.value, key])
-  try {
-    const uploaded = await uploadMarketAttachment(
-      { module: 'payment', marketRole: props.marketRole || 'specialist' },
-      file
-    )
-    const transfers = getTransferList(pIdx, sIdx)
-    const current = { ...transfers[tIdx] }
-    ensureTransferAttachments(current)
-    current.attachments = resolvePendingMarketAttachment(
-      current.attachments,
-      pending._pendingId,
-      uploaded
-    )
-    transfers[tIdx] = markTransferDirty(current)
-    setTransferList(pIdx, sIdx, transfers)
-    showSuccessToast('附件上传成功')
-  } catch (error) {
-    const transfers = getTransferList(pIdx, sIdx)
-    const current = { ...transfers[tIdx] }
-    ensureTransferAttachments(current)
-    current.attachments = dropPendingMarketAttachment(current.attachments, pending._pendingId)
-    transfers[tIdx] = markTransferDirty(current)
-    setTransferList(pIdx, sIdx, transfers)
-    showToast(marketErrorMessage(error, '附件上传失败'))
-  } finally {
-    const next = new Set(uploadKeys.value)
-    next.delete(key)
-    uploadKeys.value = next
-  }
-}
-
-function removeTransferFile(pIdx, sIdx, tIdx, index) {
-  const transfers = getTransferList(pIdx, sIdx)
-  const current = { ...transfers[tIdx] }
-  ensureTransferAttachments(current)
-  current.attachments = removeMarketAttachmentAt(current.attachments, index)
-  transfers[tIdx] = markTransferDirty(current)
-  setTransferList(pIdx, sIdx, transfers)
-}
-
 function activeScheduleIndices(schedules) {
   const idx = []
   ;(schedules || []).forEach((s, i) => {
@@ -1421,50 +999,4 @@ function onDateConfirm({ selectedValues }) {
 .protocol-sample-pay__stat.is-arrears b {
   color: #b45309;
 }
-.protocol-sample-pay__transfer {
-  margin: 8px 8px 10px;
-  padding: 10px;
-  border-radius: 12px;
-  border: 1px solid #e8f0ec;
-  background: linear-gradient(180deg, #f8fffc, #fff);
-}
-.protocol-sample-pay__transfer-title {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 8px;
-  margin-bottom: 6px;
-}
-.protocol-sample-pay__attach,
-.protocol-sample-pay__datetime {
-  padding: 8px 12px;
-}
-.protocol-sample-pay__attach label,
-.protocol-sample-pay__datetime label {
-  display: block;
-  margin-bottom: 6px;
-  color: #3d524a;
-  font-size: 12px;
-  font-weight: 650;
-}
-.protocol-sample-pay__transfer-actions {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
-  justify-content: flex-end;
-  padding: 8px 4px 0;
-}
-.protocol-sample-pay__transfer-actions :deep(.van-button),
-.protocol-sample-pay__add-transfer {
-  min-width: auto;
-  min-height: 28px;
-  height: 28px;
-  padding: 0 10px;
-  border-radius: 8px !important;
-  font-weight: 600;
-}
-.protocol-sample-pay__add-transfer {
-  margin: 0 8px;
-  width: calc(100% - 16px);
-}
 </style>

+ 527 - 0
mobile-oa-h5/src/market/components/ScheduleTransferEditor.vue

@@ -0,0 +1,527 @@
+<template>
+  <div class="protocol-sample-pay__transfers">
+    <article
+      v-for="(tr, tIdx) in transfers"
+      :key="tr.id || tIdx"
+      class="protocol-sample-pay__transfer"
+    >
+      <header class="protocol-sample-pay__transfer-title">
+        <strong>转账 {{ tIdx + 1 }}</strong>
+        <van-tag class="market-tag" :type="payStatusTone(tr.payStatus)" round>
+          {{ payStatusLabel(tr.payStatus) }}
+        </van-tag>
+      </header>
+      <div class="market-form-fields">
+        <div class="market-form-field">
+          <label>转账方式</label>
+          <div
+            class="market-form-field__control"
+            @click="!disabled && canEditTransfer(tr) && openTransferTypeSheet(tIdx)"
+          >
+            <van-field
+              :model-value="transferTypeLabel(tr.transferType)"
+              is-link
+              readonly
+              placeholder="请选择"
+              :disabled="disabled || !canEditTransfer(tr)"
+              :border="false"
+            />
+          </div>
+        </div>
+        <div class="market-form-field">
+          <label>转账金额</label>
+          <van-field
+            :model-value="tr.transferAmount == null ? '' : String(tr.transferAmount)"
+            type="number"
+            placeholder="请输入"
+            :disabled="disabled || !canEditTransfer(tr)"
+            :border="false"
+            @update:model-value="(v) => patchTransfer(tIdx, { transferAmount: parseAmount(v) })"
+          />
+        </div>
+        <div class="market-form-field">
+          <label>转账时间</label>
+          <div class="market-form-field__control">
+            <OaDatePicker
+              :model-value="tr.transferTime || ''"
+              type="datetime"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              :disabled="disabled || !canEditTransfer(tr)"
+              @update:model-value="(v) => patchTransfer(tIdx, { transferTime: v || '' })"
+            />
+          </div>
+        </div>
+        <div class="market-form-field">
+          <label>转账凭证</label>
+          <PresignAttachmentUploader
+            :model-value="ensureTransferAttachments(tr)"
+            :disabled="disabled || !canEditTransfer(tr)"
+            :uploading="isTransferUploading(tIdx)"
+            @upload="(file) => uploadTransferFile(tIdx, file)"
+            @remove="(index) => removeTransferFile(tIdx, index)"
+          />
+        </div>
+        <div class="market-form-field">
+          <label>备注</label>
+          <van-field
+            :model-value="tr.remark || ''"
+            type="textarea"
+            rows="2"
+            maxlength="512"
+            show-word-limit
+            placeholder="选填"
+            :disabled="disabled || !canEditTransfer(tr)"
+            :border="false"
+            @update:model-value="(v) => patchTransfer(tIdx, { remark: v })"
+          />
+        </div>
+        <div v-if="tr.financeRemark" class="market-form-field">
+          <label>财务备注</label>
+          <van-field
+            :model-value="tr.financeRemark"
+            readonly
+            :border="false"
+          />
+        </div>
+      </div>
+      <div v-if="!disabled && hasTransferAction(tr)" class="protocol-sample-pay__transfer-actions">
+        <van-button
+          v-if="canEditTransfer(tr)"
+          size="mini"
+          type="primary"
+          plain
+          :loading="busyKey === transferBusyKey(tIdx, tr)"
+          @click="saveTransfer(tIdx)"
+        >
+          保存
+        </van-button>
+        <van-button
+          v-if="canSubmitTransfer(tr)"
+          size="mini"
+          type="primary"
+          :loading="busyKey === `submit:${tr.id}`"
+          @click="submitFinance(tr, tIdx)"
+        >
+          提交财务
+        </van-button>
+        <van-button
+          v-if="canWithdrawTransfer(tr)"
+          size="mini"
+          type="warning"
+          :loading="busyKey === `withdraw:${tr.id}`"
+          @click="withdrawFinance(tr, tIdx)"
+        >
+          撤回
+        </van-button>
+        <van-button
+          v-if="canDeleteTransfer(tr)"
+          size="mini"
+          type="danger"
+          plain
+          @click="removeTransfer(tIdx)"
+        >
+          删除
+        </van-button>
+      </div>
+    </article>
+    <van-button
+      v-if="!disabled && canAddTransfer"
+      class="protocol-sample-pay__add-transfer"
+      size="mini"
+      type="primary"
+      block
+      @click="addTransfer"
+    >
+      添加转账
+    </van-button>
+
+    <van-action-sheet
+      v-model:show="transferTypeSheetVisible"
+      :actions="transferTypeSheetActions"
+      cancel-text="取消"
+      close-on-click-action
+      @select="onTransferTypeSelect"
+    />
+  </div>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
+import OaDatePicker from '../../oa/components/OaDatePicker.vue'
+import { performMarketAction, uploadMarketAttachment } from '../../api/market.js'
+import PresignAttachmentUploader from './PresignAttachmentUploader.vue'
+import {
+  applySavedTransfer,
+  assertCanSaveTransfer,
+  buildTransferSavePayload,
+  canSubmitFinance,
+  canUseProtocolScheduleAction,
+  canUseProtocolTransferAction,
+  canWithdrawFinance,
+  emptyTransferRow,
+  ensureTransferAttachments,
+  PAY_STATUS_LABELS,
+  validateTransferRow
+} from '../protocol-sample-transfer.js'
+import { marketErrorMessage } from '../market-error.js'
+import {
+  createPendingMarketAttachment,
+  dropPendingMarketAttachment,
+  removeMarketAttachmentAt,
+  resolvePendingMarketAttachment,
+  withPendingMarketAttachment
+} from '../attachment-upload-state.js'
+import {
+  applyTransferDeleteResult,
+  clearTransferDirty,
+  isTransferDirty,
+  markTransferDirty
+} from '../protocol-transfer-dirty.js'
+
+const props = defineProps({
+  schedule: { type: Object, required: true },
+  transferTypes: { type: Array, default: () => [] },
+  marketRole: { type: String, required: true },
+  contextId: { type: String, required: true },
+  disabled: { type: Boolean, default: false }
+})
+
+const emit = defineEmits(['update:schedule', 'changed'])
+
+const busyKey = ref('')
+const uploadKeys = ref(new Set())
+const transferTypeSheetVisible = ref(false)
+const transferTypeSheetActions = ref([])
+const transferTypeTarget = ref(-1)
+
+const transfers = computed(() => props.schedule?.transfers || [])
+
+const canAddTransfer = computed(() => {
+  const schedule = props.schedule
+  if (!schedule) return false
+  const actions = schedule.allowedActions
+  if (!Array.isArray(actions) || actions.length === 0) return true
+  return canUseProtocolScheduleAction(schedule, 'register-transfer')
+})
+
+function paymentContext() {
+  return { module: 'payment', marketRole: props.marketRole || 'specialist' }
+}
+
+function emitSchedule(nextSchedule, { changed = false } = {}) {
+  emit('update:schedule', nextSchedule)
+  if (changed) emit('changed')
+}
+
+function withTransfers(nextTransfers) {
+  return {
+    ...props.schedule,
+    transfers: nextTransfers
+  }
+}
+
+function payStatusLabel(status) {
+  return PAY_STATUS_LABELS[String(status || 'dfj')] || '已付款'
+}
+
+function payStatusTone(status) {
+  const code = String(status || 'dfj')
+  if (code === 'yjf') return 'success'
+  if (code === 'jfqrz') return 'warning'
+  return 'primary'
+}
+
+function transferTypeLabel(value) {
+  const key = String(value || '').trim()
+  const hit = (props.transferTypes || []).find((opt) => String(opt.value) === key)
+  return hit?.label || key || '请选择'
+}
+
+function parseAmount(raw) {
+  if (raw === '' || raw == null) return null
+  return String(raw)
+}
+
+function transferBusyKey(tIdx, tr) {
+  return `save:${tr?.id || tIdx}`
+}
+
+function isTransferUploading(tIdx) {
+  return uploadKeys.value.has(String(tIdx))
+}
+
+function canEditTransfer(transfer) {
+  if (!transfer?.id) return canAddTransfer.value
+  return canUseProtocolTransferAction(transfer, 'edit-transfer')
+}
+
+function canDeleteTransfer(transfer) {
+  if (!transfer?.id) return canAddTransfer.value
+  return canUseProtocolTransferAction(transfer, 'delete-payment-transfer')
+}
+
+function canSubmitTransfer(transfer) {
+  return !isTransferDirty(transfer) &&
+    canUseProtocolTransferAction(transfer, 'submit') &&
+    canSubmitFinance(transfer, props.contextId)
+}
+
+function canWithdrawTransfer(transfer) {
+  return canUseProtocolTransferAction(transfer, 'withdraw') &&
+    canWithdrawFinance(transfer, props.contextId)
+}
+
+function hasTransferAction(transfer) {
+  return canEditTransfer(transfer) ||
+    canDeleteTransfer(transfer) ||
+    canSubmitTransfer(transfer) ||
+    canWithdrawTransfer(transfer)
+}
+
+function patchTransfer(tIdx, patch) {
+  const next = [...transfers.value]
+  next[tIdx] = markTransferDirty({ ...next[tIdx], ...patch })
+  emitSchedule(withTransfers(next))
+}
+
+function addTransfer() {
+  if (!canAddTransfer.value) return
+  const next = [...transfers.value, markTransferDirty(emptyTransferRow())]
+  emitSchedule(withTransfers(next))
+}
+
+async function removeTransfer(tIdx) {
+  const transfer = transfers.value[tIdx]
+  if (!transfer || !canDeleteTransfer(transfer)) return
+  try {
+    await showConfirmDialog({ title: '删除转账', message: '确认删除该笔转账记录吗?' })
+  } catch {
+    return
+  }
+  if (!transfer.id) {
+    const next = [...transfers.value]
+    next.splice(tIdx, 1)
+    emitSchedule(withTransfers(next), { changed: true })
+    return
+  }
+  busyKey.value = `delete:${transfer.id}`
+  try {
+    await performMarketAction(paymentContext(), 'delete-payment-transfer', {
+      id: transfer.id
+    })
+    const patched = applyTransferDeleteResult(
+      { samplePayments: [{ schedules: [props.schedule] }] },
+      0,
+      0,
+      tIdx,
+      true
+    ).samplePayments[0].schedules[0]
+    emitSchedule(patched, { changed: true })
+    showSuccessToast('转账记录已删除')
+  } catch (error) {
+    showToast(marketErrorMessage(error, '删除转账失败'))
+  } finally {
+    busyKey.value = ''
+  }
+}
+
+function openTransferTypeSheet(tIdx) {
+  transferTypeTarget.value = tIdx
+  transferTypeSheetActions.value = (props.transferTypes || []).map((opt) => ({
+    name: opt.label || opt.value,
+    value: opt.value
+  }))
+  if (!transferTypeSheetActions.value.length) {
+    showToast('暂无转账方式选项')
+    return
+  }
+  transferTypeSheetVisible.value = true
+}
+
+function onTransferTypeSelect(action) {
+  if (transferTypeTarget.value < 0) return
+  patchTransfer(transferTypeTarget.value, { transferType: action.value })
+}
+
+async function saveTransfer(tIdx) {
+  const tr = transfers.value[tIdx]
+  if (!tr || !canEditTransfer(tr)) return
+  try {
+    assertCanSaveTransfer({ contextId: props.contextId, scheduleId: props.schedule?.id })
+  } catch (error) {
+    showToast(error.message)
+    return
+  }
+  const invalid = validateTransferRow(tr)
+  if (invalid) {
+    showToast(invalid)
+    return
+  }
+  busyKey.value = transferBusyKey(tIdx, tr)
+  try {
+    const payload = buildTransferSavePayload(tr)
+    let remote
+    if (tr.id) {
+      remote = await performMarketAction(paymentContext(), 'edit-transfer', {
+        id: tr.id,
+        data: payload
+      })
+    } else {
+      remote = await performMarketAction(paymentContext(), 'register-transfer', {
+        data: { scheduleId: String(props.schedule.id), ...payload }
+      })
+    }
+    const data = remote?.data || remote || {}
+    const next = [...transfers.value]
+    next[tIdx] = clearTransferDirty(applySavedTransfer(next[tIdx], data))
+    emitSchedule(withTransfers(next), { changed: true })
+    showSuccessToast(tr.id ? '转账记录已保存' : '转账记录已创建')
+  } catch (error) {
+    showToast(marketErrorMessage(error, '保存转账失败'))
+  } finally {
+    busyKey.value = ''
+  }
+}
+
+async function submitFinance(tr, tIdx) {
+  if (!canSubmitTransfer(tr)) {
+    showToast('请先保存该转账记录后再提交财务确认')
+    return
+  }
+  try {
+    await showConfirmDialog({
+      title: '提交财务确认',
+      message: '确认提交该笔转账给财务确认吗?'
+    })
+  } catch {
+    return
+  }
+  busyKey.value = `submit:${tr.id}`
+  try {
+    const remote = await performMarketAction(paymentContext(), 'submit', { id: tr.id })
+    const data = remote?.data || remote || {}
+    const next = [...transfers.value]
+    next[tIdx] = applySavedTransfer(next[tIdx], { ...data, payStatus: data.payStatus || 'jfqrz' })
+    emitSchedule(withTransfers(next), { changed: true })
+    showSuccessToast('已提交财务确认')
+  } catch (error) {
+    showToast(marketErrorMessage(error, '提交失败'))
+  } finally {
+    busyKey.value = ''
+  }
+}
+
+async function withdrawFinance(tr, tIdx) {
+  if (!canWithdrawTransfer(tr)) return
+  try {
+    await showConfirmDialog({
+      title: '撤回',
+      message: '确认撤回该笔转账的财务确认申请?撤回后可修改并重新提交。'
+    })
+  } catch {
+    return
+  }
+  busyKey.value = `withdraw:${tr.id}`
+  try {
+    const remote = await performMarketAction(paymentContext(), 'withdraw', { id: tr.id })
+    const data = remote?.data || remote || {}
+    const next = [...transfers.value]
+    next[tIdx] = applySavedTransfer(next[tIdx], { ...data, payStatus: data.payStatus || 'dfj' })
+    emitSchedule(withTransfers(next), { changed: true })
+    showSuccessToast('已撤回')
+  } catch (error) {
+    showToast(marketErrorMessage(error, '撤回失败'))
+  } finally {
+    busyKey.value = ''
+  }
+}
+
+async function uploadTransferFile(tIdx, file) {
+  if (!file) return
+  const key = String(tIdx)
+  const pending = createPendingMarketAttachment(file)
+  const beginList = [...transfers.value]
+  const begin = { ...beginList[tIdx] }
+  ensureTransferAttachments(begin)
+  begin.attachments = withPendingMarketAttachment(begin.attachments, pending)
+  beginList[tIdx] = markTransferDirty(begin)
+  emitSchedule(withTransfers(beginList))
+  uploadKeys.value = new Set([...uploadKeys.value, key])
+  try {
+    const uploaded = await uploadMarketAttachment(
+      { module: 'payment', marketRole: props.marketRole || 'specialist' },
+      file
+    )
+    const list = [...(props.schedule?.transfers || [])]
+    const current = { ...list[tIdx] }
+    ensureTransferAttachments(current)
+    current.attachments = resolvePendingMarketAttachment(
+      current.attachments,
+      pending._pendingId,
+      uploaded
+    )
+    list[tIdx] = markTransferDirty(current)
+    emitSchedule(withTransfers(list))
+    showSuccessToast('附件上传成功')
+  } catch (error) {
+    const list = [...(props.schedule?.transfers || [])]
+    const current = { ...list[tIdx] }
+    ensureTransferAttachments(current)
+    current.attachments = dropPendingMarketAttachment(current.attachments, pending._pendingId)
+    list[tIdx] = markTransferDirty(current)
+    emitSchedule(withTransfers(list))
+    showToast(marketErrorMessage(error, '附件上传失败'))
+  } finally {
+    const next = new Set(uploadKeys.value)
+    next.delete(key)
+    uploadKeys.value = next
+  }
+}
+
+function removeTransferFile(tIdx, index) {
+  const list = [...transfers.value]
+  const current = { ...list[tIdx] }
+  ensureTransferAttachments(current)
+  current.attachments = removeMarketAttachmentAt(current.attachments, index)
+  list[tIdx] = markTransferDirty(current)
+  emitSchedule(withTransfers(list))
+}
+</script>
+
+<style scoped>
+.protocol-sample-pay__transfer {
+  margin: 8px 8px 10px;
+  padding: 10px;
+  border-radius: 12px;
+  border: 1px solid #e8f0ec;
+  background: linear-gradient(180deg, #f8fffc, #fff);
+}
+.protocol-sample-pay__transfer-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 6px;
+}
+.protocol-sample-pay__transfer-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  justify-content: flex-end;
+  padding: 8px 4px 0;
+}
+.protocol-sample-pay__transfer-actions :deep(.van-button),
+.protocol-sample-pay__add-transfer {
+  min-width: auto;
+  min-height: 28px;
+  height: 28px;
+  padding: 0 10px;
+  border-radius: 8px !important;
+  font-weight: 600;
+}
+.protocol-sample-pay__add-transfer {
+  margin: 0 8px;
+  width: calc(100% - 16px);
+}
+</style>

+ 25 - 21
mobile-oa-h5/src/market/mobile-parity-ui.test.js

@@ -82,6 +82,7 @@ test('market list visual contract keeps search white, tags filled and actions ri
     './components/PresignOrderCard.vue',
     './components/PresignTransferCard.vue',
     './components/ProtocolSamplePaymentPanel.vue',
+    './components/ScheduleTransferEditor.vue',
     './components/ProtocolSampleTransportPanel.vue',
     './components/ProtocolV1PaymentPanel.vue',
     '../views/market/MarketModuleDetail.vue'
@@ -449,13 +450,15 @@ test('market forms share attachment cards and preserve their existing value shap
   assert.match(genericForm, /fieldsLocked/)
 
   const protocolPay = source('./components/ProtocolSamplePaymentPanel.vue')
+  const transferEditor = source('./components/ScheduleTransferEditor.vue')
   const protocolV1Pay = source('./components/ProtocolV1PaymentPanel.vue')
   const presignForm = source('../views/market/presign/PresignOrderForm.vue')
-  for (const content of [protocolPay, protocolV1Pay, presignForm, genericForm]) {
+  assert.match(protocolPay, /<ScheduleTransferEditor/)
+  for (const content of [transferEditor, protocolV1Pay, presignForm, genericForm]) {
     assert.match(content, /attachment-upload-state/)
   }
-  assert.match(protocolPay, /createPendingMarketAttachment/)
-  assert.match(protocolPay, /withPendingMarketAttachment/)
+  assert.match(transferEditor, /createPendingMarketAttachment/)
+  assert.match(transferEditor, /withPendingMarketAttachment/)
   assert.match(protocolV1Pay, /removeMarketAttachmentAt/)
   assert.match(presignForm, /createPendingMarketAttachment/)
 })
@@ -498,7 +501,7 @@ test('payment detail presents every web field and nested transfer field with Chi
   assert.deepEqual(sections.map((section) => section.title), ['缴费概览', '应缴批次'])
   assert.deepEqual(
     sections[0].fields.map((field) => field.label),
-    ['协议号', '客户', '联系电话', '样本编码', '缴费方式', '合同额', '已缴 / 待缴', '整体进度', '协议状态', '缴费进度']
+    ['客户', '联系电话', '样本编码', '缴费方式', '合同额', '已缴 / 待缴', '整体进度', '协议状态', '缴费进度']
   )
   assert.equal(sections[1].cards[0].variant, 'schedule')
   assert.deepEqual(
@@ -611,8 +614,6 @@ test('protocol, freight and presign details define explicit Chinese nested cards
     customer: { motherName: '张三' },
     samples: [{ id: 's1', sampleCode: 'S-1', sampleTypeName: '脐带血' }],
     attachments: { protocolAttachments: 'a.pdf' },
-    storageCert: { makeStatus: '0', certImageUrls: 'cert.jpg' },
-    fullmoonTodo: { handled: false, insuranceAttachments: 'ins.pdf', handleTime: '2026-10-01' },
     audits: []
   })
   const freight = getMarketModule('freight').detailSections({
@@ -634,13 +635,8 @@ test('protocol, freight and presign details define explicit Chinese nested cards
     }
   }
   assert.ok(protocol.find((section) => section.key === 'samples').cards.length)
-  const storageCert = protocol.find((section) => section.key === 'storageCert')
-  const insurance = protocol.find((section) => section.key === 'insurance')
-  assert.ok(storageCert)
-  assert.ok(insurance)
-  assert.ok(storageCert.fields.some((field) => field.type === 'attachments' && field.label === '储存证图片'))
-  assert.ok(insurance.fields.some((field) => field.type === 'attachments' && field.label === '保险附件'))
-  assert.ok(insurance.fields.some((field) => field.label === '办理时间'))
+  assert.equal(protocol.find((section) => section.key === 'storageCert'), undefined)
+  assert.equal(protocol.find((section) => section.key === 'insurance'), undefined)
   assert.ok(protocol.find((section) => section.key === 'audits'))
   assert.ok(protocol.find((section) => section.key === 'statusChange'))
   assert.ok(freight.find((section) => section.key === 'items').cards.length)
@@ -730,21 +726,22 @@ test('protocol create and edit forms expose every writable web business field',
   assert.match(samplePanel, /searchable/)
   assert.match(samplePanel, /market-form-field/)
   const payPanel = source('../market/components/ProtocolSamplePaymentPanel.vue')
+  const transferEditor = source('../market/components/ScheduleTransferEditor.vue')
   assert.match(payPanel, /applyPreferentialPriceToPayment/)
   assert.match(payPanel, /lastPreferentialByCode/)
+  assert.match(payPanel, /<ScheduleTransferEditor/)
   assert.match(payPanel, /添加转账/)
-  assert.match(payPanel, /提交财务确认/)
-  assert.match(payPanel, /撤回/)
+  assert.match(transferEditor, /提交财务确认/)
+  assert.match(transferEditor, /撤回/)
   assert.match(payPanel, /protocolId/)
   assert.match(payPanel, /canUseProtocolScheduleAction/)
-  assert.match(payPanel, /canUseProtocolTransferAction/)
+  assert.match(transferEditor, /canUseProtocolTransferAction/)
   assert.doesNotMatch(payPanel, /canManageTransfers/)
-  assert.match(payPanel, /performMarketAction|register-transfer/)
-  assert.match(payPanel, /module:\s*'payment'/)
+  assert.match(transferEditor, /performMarketAction|register-transfer/)
+  assert.match(transferEditor, /module:\s*'payment'/)
   assert.match(payPanel, /协议总金额/)
   assert.match(payPanel, /protocolTotalAmount/)
   assert.match(payPanel, /market-form-field/)
-  assert.match(payPanel, /添加转账/)
   assert.match(payPanel, /canAddTransfer/)
   assert.match(samplePanel, /loadExistingFreights/)
   assert.match(samplePanel, /includeItems/)
@@ -756,14 +753,13 @@ test('protocol create and edit forms expose every writable web business field',
   assert.match(form, /currentUserRegions|applyCollectionRegion/)
   assert.match(form, /onProtocolTotalAmountChange/)
   assert.doesNotMatch(form, /样本合同合计/)
-  assert.doesNotMatch(payPanel, /module:\s*'protocol'[\s\S]*uploadTransferFile/)
+  assert.doesNotMatch(transferEditor, /module:\s*'protocol'[\s\S]*uploadTransferFile/)
   assert.match(form, /:protocol-id=/)
   assert.doesNotMatch(form, /:can-manage-transfers="true"/)
   assert.match(form, /presignDepositHint/)
   assert.match(form, /depositTransfers/)
   const protocolModule = source('../market/modules/protocol.js')
   assert.match(protocolModule, /意向金(关联预签约)/)
-  assert.match(protocolModule, /储存证/)
   assert.match(protocolModule, /审核记录/)
   assert.match(protocolModule, /状态变更记录/)
   assert.match(protocolModule, /运输方式/)
@@ -792,3 +788,11 @@ test('protocol detail filters actions when from=payment', () => {
 
   assert.match(detail, /isPaymentEntryView|filterProtocolActionsForPaymentEntry/)
 })
+
+test('ScheduleTransferEditor exposes save submit withdraw and MarketAttachmentCards', () => {
+  const src = source('./components/ScheduleTransferEditor.vue')
+  assert.match(src, /MarketAttachmentCards|PresignAttachmentUploader/)
+  assert.match(src, /提交财务/)
+  assert.match(src, /撤回/)
+  assert.match(src, /添加转账/)
+})

+ 82 - 31
mobile-oa-h5/src/market/protocol-mobile-parity.test.js

@@ -20,7 +20,7 @@ function cardLabels(card) {
   ]
 }
 
-test('protocol detail exposes reception qc production storage and release information', () => {
+test('protocol detail exposes reception and storage release without qc production or cert sections', () => {
   const sections = getMarketModule('protocol').detailSections({
     source: 'cell_storage',
     basic: { protocolNo: 'P-1', protocolStatus: 'sxz' },
@@ -64,40 +64,66 @@ test('protocol detail exposes reception qc production storage and release inform
   const labels = [...sample.fields.map((field) => field.label), ...sample.cards.flatMap(cardLabels)]
   for (const expected of [
     '质检接收', '接收时间', '接收附件', '生产接收', '处理时间',
-    '门控质检', '工单号', '过程质检 1', '生产信息', '生产任务 1',
     '入库放行', '生产结论', '质检结论', '入库状态', '质检附件', '生产附件'
   ]) assert.ok(labels.includes(expected), `missing ${expected}`)
+  for (const hidden of ['门控质检', '过程质检 1', '生产信息', '生产任务 1']) {
+    assert.ok(!labels.includes(hidden), `must not show ${hidden}`)
+  }
+  assert.equal(sections.find((section) => section.key === 'storageCert'), undefined)
+  assert.equal(sections.find((section) => section.key === 'insurance'), undefined)
+})
+
+test('protocol detail shows refund registration attachments', () => {
+  const files = [{ fileUrl: '/minio/refund.pdf', fileName: '退费凭证.pdf' }]
+  const sections = getMarketModule('protocol').detailSections({
+    source: 'cell_storage',
+    basic: { protocolNo: 'P-1', protocolStatus: 'sxz' },
+    refundRecords: [{
+      id: 'rr-1',
+      refundAmount: 1200,
+      refundTime: '2026-10-16 09:00:00',
+      refundRemark: '客户申请',
+      refundAttachments: '/minio/refund.pdf',
+      refundAttachmentFiles: files
+    }]
+  })
+  const refund = sections.find((section) => section.key === 'refundRecords')
+  assert.equal(refund.title, '退费登记')
+  assert.equal(refund.cards.length, 1)
+  assert.equal(refund.cards[0].fields.find((field) => field.label === '退费金额').value, '1200')
+  assert.deepEqual(
+    refund.cards[0].fields.find((field) => field.type === 'attachments').value,
+    files
+  )
 })
 
-test('protocol lifecycle keeps qc targets results batches and production inputs outputs', () => {
+test('donation detail follows the web view and omits price and payment', () => {
   const sections = getMarketModule('protocol').detailSections({
     source: 'research_donation',
-    basic: { donationNo: 'D-1', donationStatus: 'sxz' },
+    basic: { donationNo: 'D-1', donationStatus: 'sxz', donationDate: '20261001', remark: '捐赠备注' },
+    customer: { motherName: '李四' },
     samples: [{
-      id: 'sample-rich', sampleCode: 'D-S-1',
-      qcInfo: {
-        workOrderNo: 'QC-1', cellProductCode: 'CP-1', cellProductName: '细胞产品',
-        productionWorkOrderNo: 'PR-1', triggerSourceLabel: '样本接收',
-        inspectionBatches: [{ id: 'b1', batchNo: 'B-1', batchName: '第一批', assigneeUserNames: '质检员', taskCount: 1, completedTaskCount: 1 }],
-        tasks: [{ taskNo: 'QT-1', results: [{ paramName: '活率', paramUnit: '%', paramValue: '98' }] }]
-      },
-      productionInfo: {
-        workOrderNo: 'PR-1',
-        tasks: [{
-          taskNo: 'PT-1',
-          inputs: [{ inputType: '原料', inputCode: 'IN-1', inputName: '脐带血' }],
-          outputs: [{ outputType: '产物', outputCode: 'OUT-1', outputName: '细胞制剂' }]
-        }]
-      }
-    }]
+      id: 's1', sampleCode: 'D-S-1', sampleTypeName: '脐带血',
+      price: 100, originalPrice: 200, storageYears: 20
+    }],
+    payment: { payMode: 'dj', contractAmount: 100 },
+    samplePayments: [{ sampleCode: 'D-S-1', payMode: 'dj', contractAmount: 100 }],
+    attachments: { protocolAttachments: 'a.pdf', healthSurveyFormUrls: 'h.pdf', consentFormUrls: 'c.pdf' }
   })
-  const sample = sections.find((section) => section.key === 'samples').cards[0]
-  const labels = [...sample.cards.flatMap(cardLabels)]
-  for (const expected of [
-    '质检对象编码', '质检对象名称', '生产工单号', '触发来源',
-    '检测批次 1', '批次编号', '负责人', '检测结果 1', '参数值',
-    '投入物 1', '投入物编码', '产出物 1', '产出物编码'
-  ]) assert.ok(labels.includes(expected), `missing ${expected}`)
+  assert.deepEqual(sections.map((section) => section.key), [
+    'protocol', 'collection', 'samples', 'customer', 'audits', 'attachments', 'statusChange'
+  ])
+  assert.equal(sections[0].title, '基本信息')
+  assert.deepEqual(sections[0].fields.map((field) => field.label), [
+    '状态', '母亲姓名', '捐赠日期', '创建时间', '备注'
+  ])
+  const sampleLabels = sections.find((section) => section.key === 'samples').cards[0].fields.map((field) => field.label)
+  for (const hidden of ['优惠价', '原价', '存储年限']) {
+    assert.ok(!sampleLabels.includes(hidden), `must not show ${hidden}`)
+  }
+  assert.equal(sections.find((section) => section.key === 'payment'), undefined)
+  const attachmentLabels = sections.find((section) => section.key === 'attachments').fields.map((field) => field.label)
+  assert.deepEqual(attachmentLabels, ['知情同意书', '传染病检测报告', '其他附件'])
 })
 
 test('donation detail shows audit history and convert-to-storage status history', () => {
@@ -112,7 +138,28 @@ test('donation detail shows audit history and convert-to-storage status history'
   const audit = sections.find((section) => section.key === 'audits')
   assert.equal(audit.cards.length, 1)
   const status = sections.find((section) => section.key === 'statusChange')
-  assert.deepEqual(status.fields.map((field) => field.label), ['转细胞存储时间', '转细胞存储描述'])
+  assert.equal(status.cards.length, 1)
+  assert.equal(status.cards[0].statusText, '转细胞存储')
+  assert.equal(
+    status.cards[0].fields.find((field) => field.label === '变更说明').value,
+    '已转细胞存储'
+  )
+})
+
+test('status change history renders one card per record including same type', () => {
+  const sections = getMarketModule('protocol').detailSections({
+    source: 'cell_storage',
+    basic: { protocolNo: 'P-1', protocolStatus: 'ygq' },
+    statusChanges: [
+      { id: 's1', changeType: 'expire', fromStatus: 'sxz', toStatus: 'ygq', changeTime: '2026-06-01 12:00:00', changeDesc: '第一次过期', operatorUserName: '李四' },
+      { id: 's2', changeType: 'expire', fromStatus: 'ygq', toStatus: 'ygq', changeTime: '2026-09-15 18:00:00', changeDesc: '第二次过期', operatorUserName: '王五' }
+    ]
+  })
+  const status = sections.find((section) => section.key === 'statusChange')
+  assert.equal(status.cards.length, 2)
+  assert.equal(status.cards[0].statusText, '过期')
+  assert.equal(status.cards[1].fields.find((field) => field.label === '变更说明').value, '第二次过期')
+  assert.equal(status.cards[0].fields.find((field) => field.label === '状态流转').value, '生效中 → 已过期')
 })
 
 test('v1 protocol detail renders readonly payment details with backend transfer labels', () => {
@@ -263,11 +310,14 @@ test('protocol transfer controls require backend nested capability and frontend
 
 test('protocol transfer UI calls DELETE for persisted rows without reloading form drafts', () => {
   const panel = source('./components/ProtocolSamplePaymentPanel.vue')
+  const editor = source('./components/ScheduleTransferEditor.vue')
   const form = source('../views/market/MarketModuleForm.vue')
   assert.doesNotMatch(form, /:can-manage-transfers="true"/)
-  assert.match(panel, /performMarketAction\(paymentContext\(\), 'delete-payment-transfer'/)
-  assert.match(panel, /title:\s*'提交财务确认'/)
+  assert.match(panel, /<ScheduleTransferEditor/)
+  assert.match(editor, /performMarketAction\(paymentContext\(\), 'delete-payment-transfer'/)
+  assert.match(editor, /title:\s*'提交财务确认'/)
   assert.doesNotMatch(panel, /emit\('refresh'\)/)
+  assert.doesNotMatch(editor, /emit\('refresh'\)/)
   assert.doesNotMatch(form, /@refresh="handleTransferRefresh"/)
   assert.match(form, /@transfer-dirty-change="handleTransferDirtyChange"/)
   assert.match(form, /protocolTransferSaveBlockMessage\(transferDirty\.value\)/)
@@ -294,13 +344,14 @@ test('protocol uploads use the Web 20MB rich attachment policy', () => {
 test('protocol list and nested payment actions use OA-aligned soft semantic buttons', () => {
   const styles = source('../styles/market.css')
   const panel = source('./components/ProtocolSamplePaymentPanel.vue')
+  const editor = source('./components/ScheduleTransferEditor.vue')
   assert.match(styles, /\.market-list-card__actions \.van-button\s*\{[^}]*min-height:\s*32px;/)
   assert.match(styles, /\.market-list-card__actions \.van-button\s*\{[\s\S]*?padding:\s*0 12px;/)
   assert.match(styles, /\.market-page \.van-button--mini\s*\{[\s\S]*?min-height:\s*28px;/)
   assert.match(styles, /\.market-page \.van-button--mini\s*\{[\s\S]*?border-radius:\s*8px;/)
   assert.match(styles, /\.market-page \.van-button--primary\s*\{[\s\S]*?background:\s*var\(--market-primary\);/)
   assert.doesNotMatch(styles, /linear-gradient\(135deg,\s*#12b981/)
-  assert.match(panel, /\.protocol-sample-pay__transfer-actions[\s\S]*?\.van-button[\s\S]*?min-height:\s*28px;/)
+  assert.match(editor, /\.protocol-sample-pay__transfer-actions[\s\S]*?\.van-button[\s\S]*?min-height:\s*28px;/)
   assert.match(panel, /protocol-sample-pay__timeline/)
   assert.match(panel, /protocol-sample-pay__sum/)
 })