Переглянути джерело

feat(web-admin): per-sample payment UI and refund registration on protocol form

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 місяць тому
батько
коміт
4e71a13165

+ 119 - 0
web-admin/src/api/protocol-sample-payment.ts

@@ -0,0 +1,119 @@
+import request from '@/utils/http'
+
+type PageList<T> = {
+  list: T[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages?: number
+}
+
+export function fetchSamplePayments(protocolId: string) {
+  return request.get<Array<Record<string, unknown>>>({
+    url: `/web/admin/collection-protocols/${protocolId}/sample-payments`
+  })
+}
+
+export function fetchPutSamplePayments(
+  protocolId: string,
+  data: { samplePayments: Array<Record<string, unknown>> }
+) {
+  return request.put({
+    url: `/web/admin/collection-protocols/${protocolId}/sample-payments`,
+    data,
+    showSuccessMessage: true
+  })
+}
+
+export function fetchCreatePaymentTransfer(data: Record<string, unknown>) {
+  return request.post({
+    url: '/web/admin/payment-transfers',
+    data,
+    showSuccessMessage: true
+  })
+}
+
+export function fetchUpdatePaymentTransfer(transferId: string, data: Record<string, unknown>) {
+  return request.put({
+    url: `/web/admin/payment-transfers/${transferId}`,
+    data,
+    showSuccessMessage: false
+  })
+}
+
+export function fetchSubmitPaymentTransferFinance(transferId: string) {
+  return request.post({
+    url: `/web/admin/payment-transfers/${transferId}/submit-receipt-finance`,
+    showSuccessMessage: true
+  })
+}
+
+export function fetchPaymentTransferReceiptConfirmList(params?: {
+  page?: number
+  pageSize?: number
+  keyword?: string
+}) {
+  return request.get<PageList<Record<string, unknown>>>({
+    url: '/web/admin/payment-transfers/receipt-confirm',
+    params
+  })
+}
+
+export function fetchPaymentTransferReceiptConfirmedList(params?: {
+  page?: number
+  pageSize?: number
+  keyword?: string
+}) {
+  return request.get<PageList<Record<string, unknown>>>({
+    url: '/web/admin/payment-transfers/receipt-confirmed',
+    params
+  })
+}
+
+export function fetchConfirmPaymentTransfer(
+  transferId: string,
+  data?: { financeConfirmAttachments?: string; financeRemark?: string }
+) {
+  return request.post({
+    url: `/web/admin/payment-transfers/${transferId}/receipt-confirm`,
+    data: data || {},
+    showSuccessMessage: true
+  })
+}
+
+export function fetchRejectPaymentTransfer(transferId: string, data?: { financeRemark?: string }) {
+  return request.post({
+    url: `/web/admin/payment-transfers/${transferId}/receipt-reject`,
+    data: data || {},
+    showSuccessMessage: true
+  })
+}
+
+export function fetchProtocolRefundRecords(protocolId: string) {
+  return request.get<Array<Record<string, unknown>>>({
+    url: `/web/admin/collection-protocols/${protocolId}/refund-records`
+  })
+}
+
+export function fetchCreateProtocolRefundRecord(protocolId: string, data: Record<string, unknown>) {
+  return request.post({
+    url: `/web/admin/collection-protocols/${protocolId}/refund-records`,
+    data,
+    showSuccessMessage: true
+  })
+}
+
+export function fetchUpdateProtocolRefundRecord(recordId: string, data: Record<string, unknown>) {
+  return request.put({
+    url: `/web/admin/protocol-refund-records/${recordId}`,
+    data,
+    showSuccessMessage: true
+  })
+}
+
+export function fetchDeleteProtocolRefundRecord(recordId: string) {
+  return request.del({
+    url: `/web/admin/protocol-refund-records/${recordId}`,
+    showSuccessMessage: true
+  })
+}

+ 73 - 0
web-admin/src/utils/protocol-sample-payment.ts

@@ -0,0 +1,73 @@
+/** 按样本缴费 · 工具函数(payment_model_version=2) */
+
+export type SamplePaymentLike = {
+  contractAmount?: number | null
+  sampleCode?: string
+  payMode?: string
+  schedules?: ScheduleLike[]
+}
+
+export type TransferLike = {
+  payStatus?: string
+  transferAmount?: number | null
+}
+
+export type ScheduleLike = {
+  dueAmount?: number | null
+  transfers?: TransferLike[]
+}
+
+export function sumSampleContractAmounts(payments: SamplePaymentLike[]): number {
+  return payments.reduce((s, p) => s + (Number(p.contractAmount) || 0), 0)
+}
+
+export function protocolTotalMismatchHint(
+  total: number | null | undefined,
+  sampleSum: number
+): string | null {
+  if (total == null || Number.isNaN(Number(total))) return null
+  if (Math.abs(Number(total) - sampleSum) > 0.015) {
+    return `协议总金额 ${Number(total).toFixed(2)} 与样本合同合计 ${sampleSum.toFixed(2)} 不一致,请核对`
+  }
+  return null
+}
+
+export function schedulePaidArrears(
+  dueAmount: number,
+  transfers: TransferLike[]
+): { paid: number; arrears: number } {
+  const paid = (transfers || [])
+    .filter((t) => t.payStatus === 'yjf')
+    .reduce((s, t) => s + (Number(t.transferAmount) || 0), 0)
+  return { paid, arrears: Math.round((dueAmount - paid) * 100) / 100 }
+}
+
+/** 单样本默认定价:沿用原「1 个样本」价目 */
+export function resolvePerSamplePreset(payMode: 'dj' | 'fq'): {
+  contractAmount: number
+  installmentCount?: number
+  installmentAmounts?: number[]
+} | null {
+  if (payMode === 'dj') return { contractAmount: 22880 }
+  if (payMode === 'fq') {
+    return {
+      contractAmount: 25880,
+      installmentCount: 6,
+      installmentAmounts: [6480, 3880, 3880, 3880, 3880, 3880]
+    }
+  }
+  return null
+}
+
+export function parseInstallmentRatios(raw: unknown): number[] {
+  if (Array.isArray(raw)) return raw.map((v) => Number(v) || 0)
+  if (typeof raw === 'string' && raw.trim()) {
+    try {
+      const parsed = JSON.parse(raw)
+      if (Array.isArray(parsed)) return parsed.map((v) => Number(v) || 0)
+    } catch {
+      /* ignore */
+    }
+  }
+  return []
+}

+ 179 - 19
web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue

@@ -683,6 +683,43 @@
               <div class="section-title-left"><h3>缴费信息</h3></div>
             </div>
 
+            <!-- v2: 按样本缴费 -->
+            <template v-if="paymentModelVersion >= 2">
+              <el-alert
+                v-if="presignDepositHint"
+                type="info"
+                :closable="false"
+                show-icon
+                class="presign-deposit-hint"
+                :title="presignDepositHintText"
+              />
+              <div class="payment-main-card" style="margin-bottom: 16px">
+                <div class="section-subtitle">协议总金额</div>
+                <div class="form-grid form-grid--col4">
+                  <el-form-item label="协议总金额(元)">
+                    <AmountInput
+                      v-model="formData.protocolTotalAmount"
+                      :min="0"
+                      placeholder="手填协议总金额"
+                      style="width: 100%"
+                    />
+                  </el-form-item>
+                  <el-form-item label="样本合同合计(元)">
+                    <el-input :model-value="fmtMoney(sampleContractSum)" disabled />
+                  </el-form-item>
+                </div>
+                <p v-if="protocolTotalHint" class="muted-hint warning-hint">{{ protocolTotalHint }}</p>
+              </div>
+              <SamplePaymentPanel
+                v-model="formData.samplePayments"
+                :samples="formData.samples"
+                :protocol-id="editingId || undefined"
+              />
+              <ProtocolRefundRecordBlock :protocol-id="editingId || undefined" />
+            </template>
+
+            <!-- v1: 旧整单缴费主表(历史协议) -->
+            <template v-else>
             <!-- 缴费主表 -->
             <div class="payment-main-card">
               <div class="section-subtitle">缴费主表</div>
@@ -998,6 +1035,7 @@
                 :image-size="48"
               />
             </div>
+            </template>
           </div>
         </div>
       </el-form>
@@ -1142,6 +1180,13 @@
     payStatusLabel,
     payStatusTagType
   } from '@/utils/protocol-payment-labels'
+  import SamplePaymentPanel from './SamplePaymentPanel.vue'
+  import ProtocolRefundRecordBlock from './ProtocolRefundRecordBlock.vue'
+  import {
+    parseInstallmentRatios,
+    protocolTotalMismatchHint,
+    sumSampleContractAmounts
+  } from '@/utils/protocol-sample-payment'
 
   const transferTypeOptions = ref<Api.Dict.DictDataOption[]>([])
 
@@ -1449,7 +1494,33 @@
     installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS as number | null,
     installmentRatios: [] as number[] as number[],
     // Payment Details
-    paymentDetails: [] as Array<Record<string, any>>
+    paymentDetails: [] as Array<Record<string, any>>,
+    // v2 per-sample payment
+    paymentModelVersion: 2,
+    protocolTotalAmount: null as number | null,
+    sourcePresignOrderId: '',
+    samplePayments: [] as Array<Record<string, any>>
+  })
+
+  const paymentModelVersion = computed(() => Number(formData.paymentModelVersion || 2))
+  const presignDepositHint = ref<Record<string, any> | null>(null)
+  const sampleContractSum = computed(() => sumSampleContractAmounts(formData.samplePayments))
+  const protocolTotalHint = computed(() =>
+    protocolTotalMismatchHint(formData.protocolTotalAmount, sampleContractSum.value)
+  )
+  const presignDepositHintText = computed(() => {
+    const h = presignDepositHint.value
+    if (!h) return ''
+    const amt = Number(h.confirmedDepositAmount || 0)
+    const cnt = Number(h.confirmedTransferCount || 0)
+    return `来源预签约已确认意向金合计 ¥${formatMoneyDisplay(amt)}(${cnt} 笔),仅提示不自动写入样本缴费`
+  })
+
+  watch(sampleContractSum, (sum) => {
+    if (paymentModelVersion.value < 2) return
+    if (formData.protocolTotalAmount == null && sum > 0) {
+      formData.protocolTotalAmount = Math.round(sum * 100) / 100
+    }
   })
 
   const formRules = {
@@ -1506,8 +1577,13 @@
       installmentStartDate: '',
       installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
       installmentRatios: [] as number[],
-      paymentDetails: []
+      paymentDetails: [],
+      paymentModelVersion: 2,
+      protocolTotalAmount: null,
+      sourcePresignOrderId: '',
+      samplePayments: []
     })
+    presignDepositHint.value = null
     customerId.value = null
     customerOptions.value = []
     customerSnapshot.value = null
@@ -1966,6 +2042,18 @@
       formData.protocolStatus = String(prot.protocolStatus || formData.protocolStatus || 'dsx')
       formData.registerDate = String(prot.registerDate || formData.registerDate || '')
       formData.remark = String(prot.remark ?? formData.remark ?? '')
+      formData.paymentModelVersion = Number(prot.paymentModelVersion || 1)
+      formData.protocolTotalAmount =
+        prot.protocolTotalAmount != null ? Number(prot.protocolTotalAmount) : null
+      formData.sourcePresignOrderId = String(prot.sourcePresignOrderId || '')
+    }
+    presignDepositHint.value = (bundle.presignDepositHint as Record<string, any>) || null
+    const samplePays = bundle.samplePayments as Array<Record<string, any>> | null
+    if (samplePays && samplePays.length) {
+      formData.samplePayments = samplePays.map((p) => ({
+        ...p,
+        installmentRatios: parseInstallmentRatios(p.installmentRatios)
+      }))
     }
 
     const pc = bundle.partyCustomer as Record<string, any> | null
@@ -2154,16 +2242,33 @@
       return
     }
     const sampleCount = getActiveSampleCount(formData.samples)
-    if (!isPayModeValidForSampleCount(formData.payMode as PresetPayMode, sampleCount)) {
-      submitLoading.value = false
-      await ElMessageBox.alert(
-        formData.payMode === 'djfq'
-          ? '「趸交+分期」仅适用于 2 个样本,请切换缴费模式'
-          : '当前缴费模式与样本数量不匹配,请切换缴费模式',
-        '无法保存',
-        { type: 'warning' }
+    const isV2 = paymentModelVersion.value >= 2
+    if (!isV2) {
+      if (!isPayModeValidForSampleCount(formData.payMode as PresetPayMode, sampleCount)) {
+        submitLoading.value = false
+        await ElMessageBox.alert(
+          formData.payMode === 'djfq'
+            ? '「趸交+分期」仅适用于 2 个样本,请切换缴费模式'
+            : '当前缴费模式与样本数量不匹配,请切换缴费模式',
+          '无法保存',
+          { type: 'warning' }
+        )
+        return
+      }
+    } else {
+      const missingCode = formData.samples.some(
+        (s) => String(s.sampleStatus || '') !== 'yzf' && !String(s.sampleCode || '').trim()
       )
-      return
+      if (missingCode && formData.samplePayments.length) {
+        submitLoading.value = false
+        ElMessage.warning('请先为样本分配样本编码,再保存按样本缴费方案')
+        return
+      }
+      const mismatch = protocolTotalMismatchHint(
+        formData.protocolTotalAmount,
+        sumSampleContractAmounts(formData.samplePayments)
+      )
+      if (mismatch) ElMessage.warning(mismatch)
     }
     if (!isEdit.value) {
       for (const sampleRow of formData.samples) {
@@ -2176,14 +2281,16 @@
         }
       }
     }
-    if (formData.payMode === 'dj') {
+    if (!isV2 && formData.payMode === 'dj') {
       const dueHint = djDueAmountMismatchHint(formData.contractAmount, formData.paymentDetails)
       if (dueHint) {
         ElMessage.warning(dueHint)
       }
     }
     try {
-      syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
+      if (!isV2) {
+        syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
+      }
       const protocolAttachmentUrls = formData.attachments
         .map((a) => a.fileUrl)
         .filter(Boolean)
@@ -2267,8 +2374,47 @@
               ? String(s.sampleStatus).trim()
               : null,
           transport: transport?.transportType ? { ...transport } : undefined
-        })),
-        payment: {
+        }))
+      }
+
+      if (isV2) {
+        payload.paymentModelVersion = 2
+        payload.protocolTotalAmount = formData.protocolTotalAmount
+        payload.sourcePresignOrderId = formData.sourcePresignOrderId || undefined
+        payload.samplePayments = formData.samplePayments.map((p) => ({
+          id: p.id,
+          sampleCode: p.sampleCode,
+          protocolSampleId: p.protocolSampleId || undefined,
+          payMode: p.payMode,
+          contractAmount: p.contractAmount ?? undefined,
+          lumpSumDeadline: p.lumpSumDeadline || undefined,
+          installmentCount: toIntOrUndefined(p.installmentCount),
+          installmentIntervalMonths: toFloatOrUndefined(p.installmentIntervalMonths),
+          installmentStartDate: p.installmentStartDate || undefined,
+          installmentAdvanceDays: toIntOrUndefined(p.installmentAdvanceDays),
+          installmentRatios: Array.isArray(p.installmentRatios)
+            ? p.installmentRatios.map((v: any) => Number(v || 0))
+            : parseInstallmentRatios(p.installmentRatios),
+          remark: p.remark || undefined,
+          schedules: (p.schedules || []).map((sch: any) => ({
+            scheduleNo: sch.scheduleNo,
+            dueAmount: sch.dueAmount,
+            dueDate: sch.dueDate || undefined,
+            scheduleStatus: sch.scheduleStatus || 'open',
+            remark: sch.remark,
+            transfers: (sch.transfers || []).map((t: any) => ({
+              id: t.id,
+              transferType: t.transferType,
+              transferAmount: t.transferAmount,
+              transferTime: t.transferTime,
+              transferAttachments: t.transferAttachments,
+              payStatus: t.payStatus || 'dfj',
+              remark: t.remark
+            }))
+          }))
+        }))
+      } else {
+        payload.payment = {
           payMode: formData.payMode,
           contractAmount: formData.contractAmount ?? undefined,
           lumpSumDeadline: formData.lumpSumDeadline || undefined,
@@ -2277,10 +2423,10 @@
           installmentStartDate: formData.installmentStartDate || undefined,
           installmentAdvanceDays: toIntOrUndefined(formData.installmentAdvanceDays),
           installmentRatios: isInstallmentPayMode(formData.payMode)
-              ? (formData.installmentRatios || []).map((v) => Number(v || 0))
-              : undefined
-        },
-        paymentDetails: formData.paymentDetails.map(
+            ? (formData.installmentRatios || []).map((v) => Number(v || 0))
+            : undefined
+        }
+        payload.paymentDetails = formData.paymentDetails.map(
           ({ _expanded, _payModeLocked, _voided, ...d }) => ({
             id: d.id,
             transferType: d.transferType,
@@ -3894,6 +4040,20 @@
     }
   }
 
+  .presign-deposit-hint {
+    margin-bottom: 14px;
+  }
+
+  .muted-hint {
+    margin: 4px 0 0;
+    font-size: 12px;
+    color: #94a3b8;
+  }
+
+  .warning-hint {
+    color: #d97706;
+  }
+
   /* ═══════════════════════════════════════════
      FORM GRID
      ═══════════════════════════════════════════ */

+ 340 - 0
web-admin/src/views/cell-storage/protocol/ProtocolRefundRecordBlock.vue

@@ -0,0 +1,340 @@
+<template>
+  <div class="refund-record-block">
+    <div class="section-subtitle-row">
+      <div class="section-subtitle">退费登记</div>
+      <el-button type="primary" size="small" :disabled="!protocolId" @click="openCreate">
+        <el-icon><Plus /></el-icon>
+        新增登记
+      </el-button>
+    </div>
+    <p v-if="!protocolId" class="muted-hint">保存协议后可登记退费(不触发财务退费确认)</p>
+
+    <el-table v-if="records.length" :data="records" class="list-table" stripe>
+      <el-table-column prop="refundAmount" label="退费金额(元)" min-width="120">
+        <template #default="{ row }">{{ fmtMoney(row.refundAmount) }}</template>
+      </el-table-column>
+      <el-table-column prop="refundTime" label="退费时间" min-width="160" />
+      <el-table-column prop="refundRemark" label="备注" min-width="180" show-overflow-tooltip />
+      <el-table-column label="附件" min-width="100">
+        <template #default="{ row }">
+          {{ (row.refundAttachmentFiles || []).length || (row.refundAttachments ? 1 : 0) }} 个
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" min-width="140" fixed="right">
+        <template #default="{ row }">
+          <el-button link type="primary" size="small" @click="openEdit(row)">
+            <el-icon><Edit /></el-icon>
+            编辑
+          </el-button>
+          <el-button link type="danger" size="small" @click="onDelete(row)">
+            <el-icon><Delete /></el-icon>
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <el-empty v-else description="暂无退费登记" :image-size="40" />
+
+    <el-dialog
+      v-model="dialogVisible"
+      :title="editingId ? '编辑退费登记' : '新增退费登记'"
+      width="560px"
+      align-center
+      append-to-body
+      destroy-on-close
+      class="dialog-form-layout"
+      modal-class="order-module-dialog-mask"
+      @closed="resetDialog"
+    >
+      <el-form label-position="top" class="dialog-form-body">
+        <div class="form-grid-2">
+          <el-form-item label="退费金额(元)" required>
+            <AmountInput v-model="form.refundAmount" :min="0.01" style="width: 100%" />
+          </el-form-item>
+          <el-form-item label="退费时间">
+            <el-date-picker
+              v-model="form.refundTime"
+              type="datetime"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              clearable
+              style="width: 100%"
+            />
+          </el-form-item>
+          <el-form-item label="退费备注" class="form-col-span-2">
+            <el-input
+              v-model="form.refundRemark"
+              type="textarea"
+              :rows="2"
+              maxlength="500"
+              show-word-limit
+            />
+          </el-form-item>
+          <el-form-item label="附件" class="form-col-span-2">
+            <div class="attachment-upload">
+              <div class="attachment-list">
+                <el-upload
+                  :auto-upload="false"
+                  :show-file-list="false"
+                  :multiple="true"
+                  accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar"
+                  @change="onFileChange"
+                >
+                  <div class="upload-card upload-card--sm">
+                    <el-icon :size="22" color="#047857"><Plus /></el-icon>
+                    <span class="upload-card-text">上传附件</span>
+                  </div>
+                </el-upload>
+                <div
+                  v-for="(file, idx) in form.attachments"
+                  :key="file.id || idx"
+                  class="attachment-item"
+                >
+                  <div class="attachment-name" :title="file.fileName">{{ file.fileName }}</div>
+                  <el-icon
+                    class="action-btn action-btn--danger"
+                    @click="form.attachments.splice(idx, 1)"
+                  >
+                    <Delete />
+                  </el-icon>
+                </div>
+              </div>
+              <span class="upload-hint">支持图片与常见文档,单文件不超过 20MB</span>
+            </div>
+          </el-form-item>
+        </div>
+      </el-form>
+      <template #footer>
+        <el-button class="btn-ghost" @click="dialogVisible = false">
+          <el-icon><Close /></el-icon>
+          取消
+        </el-button>
+        <el-button class="btn-main" type="primary" :loading="saving" @click="onSave">
+          <el-icon><Check /></el-icon>
+          确定
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { onMounted, reactive, ref, watch } from 'vue'
+  import { Plus, Edit, Delete, Close, Check } from '@element-plus/icons-vue'
+  import AmountInput from '@/components/common/amount-input/index.vue'
+  import { formatMoneyDisplay } from '@/utils/money-format'
+  import { fetchUploadAttachment } from '@/api/business'
+  import {
+    fetchProtocolRefundRecords,
+    fetchCreateProtocolRefundRecord,
+    fetchUpdateProtocolRefundRecord,
+    fetchDeleteProtocolRefundRecord
+  } from '@/api/protocol-sample-payment'
+
+  const props = defineProps<{
+    protocolId?: string
+  }>()
+
+  const records = ref<Array<Record<string, any>>>([])
+  const dialogVisible = ref(false)
+  const editingId = ref('')
+  const saving = ref(false)
+  const form = reactive({
+    refundAmount: null as number | null,
+    refundTime: '',
+    refundRemark: '',
+    attachments: [] as Array<Record<string, any>>
+  })
+
+  function fmtMoney(v: unknown) {
+    if (v == null || v === '') return '—'
+    return formatMoneyDisplay(v)
+  }
+
+  async function loadRecords() {
+    if (!props.protocolId) {
+      records.value = []
+      return
+    }
+    const res = await fetchProtocolRefundRecords(props.protocolId)
+    records.value = Array.isArray(res) ? res : []
+  }
+
+  function openCreate() {
+    editingId.value = ''
+    resetDialog()
+    dialogVisible.value = true
+  }
+
+  function openEdit(row: Record<string, any>) {
+    editingId.value = String(row.id || '')
+    form.refundAmount = row.refundAmount ?? null
+    form.refundTime = String(row.refundTime || '')
+    form.refundRemark = String(row.refundRemark || '')
+    form.attachments = Array.isArray(row.refundAttachmentFiles)
+      ? [...row.refundAttachmentFiles]
+      : []
+    dialogVisible.value = true
+  }
+
+  function resetDialog() {
+    form.refundAmount = null
+    form.refundTime = ''
+    form.refundRemark = ''
+    form.attachments = []
+  }
+
+  async function onFileChange(uploadFile: any) {
+    const raw = uploadFile?.raw as File | undefined
+    if (!raw) return
+    if (raw.size > 20 * 1024 * 1024) {
+      ElMessage.warning('单文件不超过 20MB')
+      return
+    }
+    const fd = new FormData()
+    fd.append('file', raw)
+    fd.append('folder', 'protocol-refund-record')
+    const res: any = await fetchUploadAttachment(fd)
+    const data = res?.data || res
+    form.attachments.push({
+      id: data?.id,
+      fileName: data?.fileName || raw.name,
+      fileUrl: data?.fileUrl || data?.url,
+      fileSize: data?.fileSize,
+      fileType: data?.fileType
+    })
+  }
+
+  async function onSave() {
+    if (!props.protocolId) return
+    if (form.refundAmount == null || Number(form.refundAmount) <= 0) {
+      ElMessage.warning('请填写退费金额')
+      return
+    }
+    saving.value = true
+    try {
+      const urls = form.attachments
+        .map((a) => a.fileUrl)
+        .filter(Boolean)
+        .join(',')
+      const payload = {
+        refundAmount: form.refundAmount,
+        refundTime: form.refundTime || undefined,
+        refundRemark: form.refundRemark || undefined,
+        refundAttachments: urls || undefined
+      }
+      if (editingId.value) {
+        await fetchUpdateProtocolRefundRecord(editingId.value, payload)
+      } else {
+        await fetchCreateProtocolRefundRecord(props.protocolId, payload)
+      }
+      dialogVisible.value = false
+      await loadRecords()
+    } finally {
+      saving.value = false
+    }
+  }
+
+  async function onDelete(row: Record<string, any>) {
+    if (!row.id) return
+    await ElMessageBox.confirm('确认删除该退费登记?', '提示', { type: 'warning' })
+    await fetchDeleteProtocolRefundRecord(String(row.id))
+    await loadRecords()
+  }
+
+  watch(
+    () => props.protocolId,
+    () => {
+      loadRecords()
+    }
+  )
+
+  onMounted(loadRecords)
+</script>
+
+<style scoped lang="scss">
+  .section-subtitle-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin: 16px 0 8px;
+  }
+
+  .section-subtitle {
+    font-size: 13px;
+    font-weight: 600;
+    color: var(--mint-text-secondary, #475569);
+  }
+
+  .muted-hint {
+    margin: 0 0 8px;
+    font-size: 12px;
+    color: var(--mint-text-muted, #94a3b8);
+  }
+
+  .form-grid-2 {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 8px 12px;
+  }
+
+  .form-col-span-2 {
+    grid-column: 1 / -1;
+  }
+
+  .attachment-list {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    align-items: flex-start;
+  }
+
+  .upload-card--sm {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    width: 140px;
+    height: 96px;
+    cursor: pointer;
+    border: 1px dashed #a7f3d0;
+    border-radius: 8px;
+    background: #ecfdf5;
+  }
+
+  .upload-card-text {
+    margin-top: 4px;
+    font-size: 12px;
+    color: #047857;
+  }
+
+  .attachment-item {
+    display: flex;
+    gap: 6px;
+    align-items: center;
+    min-width: 140px;
+    max-width: 220px;
+    padding: 8px 10px;
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
+  }
+
+  .attachment-name {
+    flex: 1;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-size: 12px;
+  }
+
+  .action-btn--danger {
+    color: #ef4444;
+    cursor: pointer;
+  }
+
+  .upload-hint {
+    display: block;
+    margin-top: 6px;
+    font-size: 12px;
+    color: var(--mint-text-muted, #94a3b8);
+  }
+</style>

+ 569 - 0
web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue

@@ -0,0 +1,569 @@
+<template>
+  <div class="sample-payment-panel">
+    <el-empty
+      v-if="!syncedPayments.length"
+      description="请先在「样本信息」中添加样本,再配置按样本缴费"
+      :image-size="48"
+    />
+
+    <div
+      v-for="(pay, pIdx) in syncedPayments"
+      :key="pay.sampleCode || pIdx"
+      class="sample-pay-card"
+    >
+      <div class="sample-pay-card__head">
+        <div class="sample-pay-card__title">
+          {{ sampleTitle(pay) }}
+          <el-tag v-if="pay.sampleCode" size="small" type="info" class="ml-2">
+            {{ pay.sampleCode }}
+          </el-tag>
+        </div>
+        <el-tag :type="pay.payMode === 'fq' ? 'warning' : 'success'" size="small">
+          {{ pay.payMode === 'fq' ? '分期' : '趸交' }}
+        </el-tag>
+      </div>
+
+      <div class="form-grid form-grid--col4">
+        <el-form-item label="缴费模式">
+          <el-select
+            :model-value="pay.payMode"
+            style="width: 100%"
+            @update:model-value="(v: string) => onPayModeChange(pIdx, v)"
+          >
+            <el-option label="趸交" value="dj" />
+            <el-option label="分期" value="fq" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="合同金额(元)">
+          <AmountInput
+            :model-value="pay.contractAmount"
+            :min="0"
+            style="width: 100%"
+            @update:model-value="(v: number | null) => onContractAmountChange(pIdx, v)"
+          />
+        </el-form-item>
+        <template v-if="pay.payMode === 'dj'">
+          <el-form-item label="趸交截止时间">
+            <el-date-picker
+              :model-value="pay.lumpSumDeadline"
+              type="date"
+              value-format="YYYYMMDD"
+              format="YYYY-MM-DD"
+              placeholder="选择截止时间"
+              clearable
+              style="width: 100%"
+              @update:model-value="(v: string) => patchPay(pIdx, { lumpSumDeadline: v || '' })"
+            />
+          </el-form-item>
+        </template>
+        <template v-else>
+          <el-form-item label="分期期数">
+            <el-input
+              :model-value="pay.installmentCount"
+              type="number"
+              :min="1"
+              style="width: 100%"
+              @change="(v: string) => onFqFieldChange(pIdx, 'installmentCount', v)"
+            />
+          </el-form-item>
+          <el-form-item label="每期间隔(月)">
+            <el-input
+              :model-value="pay.installmentIntervalMonths"
+              type="number"
+              :min="0.1"
+              step="any"
+              style="width: 100%"
+              @change="(v: string) => onFqFieldChange(pIdx, 'installmentIntervalMonths', v)"
+            />
+          </el-form-item>
+          <el-form-item label="分期开始时间">
+            <el-date-picker
+              :model-value="pay.installmentStartDate"
+              type="date"
+              value-format="YYYYMMDD"
+              format="YYYY-MM-DD"
+              clearable
+              style="width: 100%"
+              @update:model-value="(v: string) => onFqStartDateChange(pIdx, v)"
+            />
+          </el-form-item>
+          <el-form-item label="应缴费提前(天)">
+            <el-input
+              :model-value="pay.installmentAdvanceDays"
+              type="number"
+              :min="0"
+              :max="365"
+              style="width: 100%"
+              @change="(v: string) => onFqFieldChange(pIdx, 'installmentAdvanceDays', v)"
+            />
+          </el-form-item>
+        </template>
+      </div>
+
+      <div class="section-subtitle">应缴批次</div>
+      <div v-if="!(pay.schedules || []).length" class="muted-hint"
+        >暂无批次,请完善缴费参数后自动生成</div
+      >
+      <div v-for="(sch, sIdx) in pay.schedules || []" :key="sch.id || sIdx" class="schedule-block">
+        <div class="schedule-block__head">
+          <span
+            >第 {{ sch.scheduleNo || sIdx + 1 }} 期 · 应缴
+            {{ fmtMoney(sch.dueAmount) }}
+            <template v-if="sch.dueDate"> · {{ formatDueDate(sch.dueDate) }}</template>
+          </span>
+          <span class="schedule-arrears">
+            已缴
+            {{
+              fmtMoney(schedulePaidArrears(Number(sch.dueAmount) || 0, sch.transfers || []).paid)
+            }}
+            · 欠缴
+            {{
+              fmtMoney(schedulePaidArrears(Number(sch.dueAmount) || 0, sch.transfers || []).arrears)
+            }}
+          </span>
+          <el-button link type="primary" size="small" @click="addTransfer(pIdx, sIdx)">
+            <el-icon><Plus /></el-icon>
+            添加转账
+          </el-button>
+        </div>
+
+        <div
+          v-for="(tr, tIdx) in sch.transfers || []"
+          :key="tr.id || `${sIdx}-${tIdx}`"
+          class="transfer-row form-grid form-grid--col4"
+        >
+          <el-form-item label="转账方式">
+            <el-select
+              v-model="tr.transferType"
+              placeholder="请选择"
+              style="width: 100%"
+              :disabled="isTransferLocked(tr)"
+              @change="emitChange"
+            >
+              <el-option
+                v-for="opt in transferTypeOptions"
+                :key="opt.value"
+                :label="opt.label"
+                :value="opt.value"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="转账金额(元)">
+            <AmountInput
+              v-model="tr.transferAmount"
+              :min="0"
+              :disabled="isTransferLocked(tr)"
+              style="width: 100%"
+              @change="emitChange"
+            />
+          </el-form-item>
+          <el-form-item label="转账时间">
+            <el-date-picker
+              v-model="tr.transferTime"
+              type="datetime"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              clearable
+              :disabled="isTransferLocked(tr)"
+              style="width: 100%"
+              @change="emitChange"
+            />
+          </el-form-item>
+          <el-form-item label="状态">
+            <el-tag :type="payStatusTagType(String(tr.payStatus || 'dfj'))" size="small">
+              {{ payStatusLabel(String(tr.payStatus || 'dfj')) }}
+            </el-tag>
+            <el-button
+              v-if="tr.id && tr.payStatus === 'dfj' && protocolId"
+              link
+              type="primary"
+              size="small"
+              style="margin-left: 8px"
+              :loading="submittingId === tr.id"
+              @click="submitTransfer(tr)"
+            >
+              提交财务确认
+            </el-button>
+            <el-button
+              v-if="!isTransferLocked(tr)"
+              link
+              type="danger"
+              size="small"
+              style="margin-left: 4px"
+              @click="removeTransfer(pIdx, sIdx, tIdx)"
+            >
+              删除
+            </el-button>
+          </el-form-item>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref, watch } from 'vue'
+  import { Plus } from '@element-plus/icons-vue'
+  import AmountInput from '@/components/common/amount-input/index.vue'
+  import { formatMoneyDisplay } from '@/utils/money-format'
+  import {
+    payStatusLabel,
+    payStatusTagType,
+    loadTransferTypeDict,
+    getTransferTypeOptions
+  } from '@/utils/protocol-payment-labels'
+  import {
+    DEFAULT_INSTALLMENT_ADVANCE_DAYS,
+    normalizeInstallmentAmounts
+  } from '@/utils/protocol-payment-installment'
+  import { generateInitialFqDetails } from '@/utils/protocol-fq-params-change'
+  import {
+    parseInstallmentRatios,
+    resolvePerSamplePreset,
+    schedulePaidArrears
+  } from '@/utils/protocol-sample-payment'
+  import { fetchSubmitPaymentTransferFinance } from '@/api/protocol-sample-payment'
+
+  const props = defineProps<{
+    modelValue: Array<Record<string, any>>
+    samples: Array<Record<string, any>>
+    protocolId?: string
+  }>()
+
+  const emit = defineEmits<{
+    'update:modelValue': [Array<Record<string, any>>]
+  }>()
+
+  const transferTypeOptions = ref<Api.Dict.DictDataOption[]>([])
+  const submittingId = ref('')
+
+  async function ensureTransferTypes() {
+    await loadTransferTypeDict()
+    transferTypeOptions.value = getTransferTypeOptions()
+  }
+  ensureTransferTypes()
+
+  function fmtMoney(v: unknown): string {
+    if (v == null || v === '') return '—'
+    return '¥' + formatMoneyDisplay(v)
+  }
+
+  function formatDueDate(v: string) {
+    if (!v || v.length !== 8) return v || ''
+    return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`
+  }
+
+  function isTransferLocked(tr: Record<string, any>) {
+    return tr.payStatus === 'jfqrz' || tr.payStatus === 'yjf'
+  }
+
+  function sampleTitle(pay: Record<string, any>) {
+    const sample = (props.samples || []).find(
+      (s) => String(s.sampleCode || '') === String(pay.sampleCode || '')
+    )
+    return String(sample?.sampleTypeName || sample?.sampleName || '样本') || '样本'
+  }
+
+  function emptyDjPayment(sample: Record<string, any>): Record<string, any> {
+    const preset = resolvePerSamplePreset('dj')
+    const amount = preset?.contractAmount ?? null
+    return {
+      sampleCode: String(sample.sampleCode || ''),
+      protocolSampleId: sample.id || '',
+      payMode: 'dj',
+      contractAmount: amount,
+      lumpSumDeadline: '',
+      installmentCount: null,
+      installmentIntervalMonths: null,
+      installmentStartDate: '',
+      installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
+      installmentRatios: [],
+      schedules: amount
+        ? [
+            {
+              scheduleNo: 1,
+              dueAmount: amount,
+              dueDate: '',
+              scheduleStatus: 'open',
+              transfers: []
+            }
+          ]
+        : []
+    }
+  }
+
+  function rebuildSchedules(pay: Record<string, any>): Record<string, any> {
+    const mode = pay.payMode || 'dj'
+    if (mode === 'dj') {
+      return {
+        ...pay,
+        schedules: [
+          {
+            scheduleNo: 1,
+            dueAmount: pay.contractAmount,
+            dueDate: pay.lumpSumDeadline || '',
+            scheduleStatus: 'open',
+            transfers: (pay.schedules?.[0]?.transfers || []).filter(
+              (t: any) => t.payStatus === 'dfj' || !t.payStatus
+            )
+          }
+        ]
+      }
+    }
+    const count = Number(pay.installmentCount) || 0
+    const interval = Number(pay.installmentIntervalMonths) || 0
+    const startDate = pay.installmentStartDate || ''
+    const advanceDays = Number(pay.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS)
+    let ratios = parseInstallmentRatios(pay.installmentRatios)
+    if (count > 0) {
+      ratios = normalizeInstallmentAmounts(ratios, pay.contractAmount, count)
+    }
+    if (!count || !interval || !startDate) {
+      return { ...pay, installmentRatios: ratios, schedules: [] }
+    }
+    const details = generateInitialFqDetails(
+      {
+        installmentCount: count,
+        installmentIntervalMonths: interval,
+        installmentStartDate: startDate,
+        installmentAdvanceDays: advanceDays,
+        installmentRatios: ratios
+      },
+      pay.contractAmount
+    )
+    return {
+      ...pay,
+      installmentRatios: ratios,
+      schedules: details.map((d, i) => ({
+        scheduleNo: d.installmentNo || i + 1,
+        dueAmount: d.dueAmount,
+        dueDate: d.installmentDueDate || '',
+        scheduleStatus: 'open',
+        transfers: []
+      }))
+    }
+  }
+
+  const syncedPayments = computed(() => props.modelValue || [])
+
+  function emitChange() {
+    emit('update:modelValue', [...(props.modelValue || [])])
+  }
+
+  function patchPay(pIdx: number, patch: Record<string, any>, rebuild = false) {
+    const list = [...(props.modelValue || [])]
+    let next = { ...list[pIdx], ...patch }
+    if (rebuild) next = rebuildSchedules(next)
+    list[pIdx] = next
+    emit('update:modelValue', list)
+  }
+
+  function onPayModeChange(pIdx: number, mode: string) {
+    const preset = resolvePerSamplePreset(mode === 'fq' ? 'fq' : 'dj')
+    const patch: Record<string, any> = {
+      payMode: mode,
+      contractAmount: preset?.contractAmount ?? null
+    }
+    if (mode === 'fq' && preset) {
+      patch.installmentCount = preset.installmentCount || 6
+      patch.installmentIntervalMonths = 1
+      patch.installmentAdvanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS
+      patch.installmentRatios = preset.installmentAmounts || []
+      if (!patch.installmentStartDate) {
+        /* keep existing start date if any */
+      }
+    }
+    patchPay(pIdx, patch, true)
+  }
+
+  function onContractAmountChange(pIdx: number, v: number | null) {
+    patchPay(pIdx, { contractAmount: v }, true)
+  }
+
+  function onFqFieldChange(pIdx: number, key: string, raw: string) {
+    const num = raw === '' || raw == null ? null : Number(raw)
+    patchPay(pIdx, { [key]: num }, true)
+  }
+
+  function onFqStartDateChange(pIdx: number, v: string) {
+    patchPay(pIdx, { installmentStartDate: v || '' }, true)
+  }
+
+  function addTransfer(pIdx: number, sIdx: number) {
+    const list = [...(props.modelValue || [])]
+    const pay = { ...list[pIdx] }
+    const schedules = [...(pay.schedules || [])]
+    const sch = { ...schedules[sIdx] }
+    const transfers = [...(sch.transfers || [])]
+    transfers.push({
+      transferType: '',
+      transferAmount: null,
+      transferTime: '',
+      payStatus: 'dfj',
+      transferAttachments: ''
+    })
+    sch.transfers = transfers
+    schedules[sIdx] = sch
+    pay.schedules = schedules
+    list[pIdx] = pay
+    emit('update:modelValue', list)
+  }
+
+  function removeTransfer(pIdx: number, sIdx: number, tIdx: number) {
+    const list = [...(props.modelValue || [])]
+    const pay = { ...list[pIdx] }
+    const schedules = [...(pay.schedules || [])]
+    const sch = { ...schedules[sIdx] }
+    const transfers = [...(sch.transfers || [])]
+    transfers.splice(tIdx, 1)
+    sch.transfers = transfers
+    schedules[sIdx] = sch
+    pay.schedules = schedules
+    list[pIdx] = pay
+    emit('update:modelValue', list)
+  }
+
+  async function submitTransfer(tr: Record<string, any>) {
+    if (!tr.id) {
+      ElMessage.warning('请先保存协议后再提交财务确认')
+      return
+    }
+    submittingId.value = String(tr.id)
+    try {
+      await fetchSubmitPaymentTransferFinance(String(tr.id))
+      tr.payStatus = 'jfqrz'
+      emitChange()
+    } finally {
+      submittingId.value = ''
+    }
+  }
+
+  /** 样本增减时对齐缴费方案(按 sampleCode) */
+  watch(
+    () =>
+      (props.samples || [])
+        .filter((s) => String(s.sampleStatus || '') !== 'yzf' && String(s.sampleCode || '').trim())
+        .map((s) => String(s.sampleCode)),
+    (codes) => {
+      const codeSet = new Set(codes)
+      const existing = [...(props.modelValue || [])]
+      const byCode = new Map(existing.map((p) => [String(p.sampleCode || ''), p]))
+      const next: Array<Record<string, any>> = []
+      for (const sample of props.samples || []) {
+        const code = String(sample.sampleCode || '').trim()
+        if (!code || String(sample.sampleStatus || '') === 'yzf') continue
+        const prev = byCode.get(code)
+        if (prev) {
+          next.push({
+            ...prev,
+            protocolSampleId: sample.id || prev.protocolSampleId || ''
+          })
+        } else {
+          next.push(emptyDjPayment(sample))
+        }
+      }
+      // 保留已有但样本暂无编码的方案(编辑中间态)
+      for (const p of existing) {
+        const code = String(p.sampleCode || '')
+        if (code && !codeSet.has(code) && p.id) {
+          /* 样本删除:不保留 */
+        }
+      }
+      const same =
+        next.length === existing.length &&
+        next.every(
+          (p, i) =>
+            p.sampleCode === existing[i]?.sampleCode &&
+            p.protocolSampleId === existing[i]?.protocolSampleId
+        )
+      if (!same) emit('update:modelValue', next)
+    },
+    { immediate: true, deep: true }
+  )
+</script>
+
+<style scoped lang="scss">
+  .sample-payment-panel {
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+  }
+
+  .sample-pay-card {
+    padding: 14px 16px;
+    border: 1px solid var(--el-border-color-lighter, #e2e8f0);
+    border-radius: 10px;
+    background: #fff;
+  }
+
+  .sample-pay-card__head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 12px;
+  }
+
+  .sample-pay-card__title {
+    display: flex;
+    align-items: center;
+    font-size: 14px;
+    font-weight: 600;
+    color: var(--mint-text, #0f172a);
+  }
+
+  .ml-2 {
+    margin-left: 8px;
+  }
+
+  .section-subtitle {
+    margin: 12px 0 8px;
+    font-size: 13px;
+    font-weight: 600;
+    color: var(--mint-text-secondary, #475569);
+  }
+
+  .muted-hint {
+    font-size: 12px;
+    color: var(--mint-text-muted, #94a3b8);
+  }
+
+  .schedule-block {
+    margin-bottom: 10px;
+    padding: 10px 12px;
+    border-radius: 8px;
+    background: var(--mint-surface-muted, #f8fafc);
+  }
+
+  .schedule-block__head {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px 12px;
+    align-items: center;
+    margin-bottom: 8px;
+    font-size: 13px;
+  }
+
+  .schedule-arrears {
+    color: var(--mint-text-muted, #64748b);
+  }
+
+  .transfer-row {
+    margin-top: 6px;
+    padding-top: 6px;
+    border-top: 1px dashed #e2e8f0;
+  }
+
+  .form-grid {
+    display: grid;
+    gap: 8px 12px;
+  }
+
+  .form-grid--col4 {
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+  }
+
+  @media (max-width: 1100px) {
+    .form-grid--col4 {
+      grid-template-columns: repeat(2, minmax(0, 1fr));
+    }
+  }
+</style>