From 4e71a13165be741d88fe8b428ede77d242a33719 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Sat, 3 Oct 2026 11:13:32 +0800 Subject: [PATCH] feat(web-admin): per-sample payment UI and refund registration on protocol form Co-authored-by: Cursor --- web-admin/src/api/protocol-sample-payment.ts | 119 ++++ .../src/utils/protocol-sample-payment.ts | 73 +++ .../protocol/ProtocolFormDialog.vue | 198 +++++- .../protocol/ProtocolRefundRecordBlock.vue | 340 +++++++++++ .../protocol/SamplePaymentPanel.vue | 569 ++++++++++++++++++ 5 files changed, 1280 insertions(+), 19 deletions(-) create mode 100644 web-admin/src/api/protocol-sample-payment.ts create mode 100644 web-admin/src/utils/protocol-sample-payment.ts create mode 100644 web-admin/src/views/cell-storage/protocol/ProtocolRefundRecordBlock.vue create mode 100644 web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue diff --git a/web-admin/src/api/protocol-sample-payment.ts b/web-admin/src/api/protocol-sample-payment.ts new file mode 100644 index 00000000..e9270d9b --- /dev/null +++ b/web-admin/src/api/protocol-sample-payment.ts @@ -0,0 +1,119 @@ +import request from '@/utils/http' + +type PageList = { + list: T[] + total: number + page: number + pageSize: number + totalPages?: number +} + +export function fetchSamplePayments(protocolId: string) { + return request.get>>({ + url: `/web/admin/collection-protocols/${protocolId}/sample-payments` + }) +} + +export function fetchPutSamplePayments( + protocolId: string, + data: { samplePayments: Array> } +) { + return request.put({ + url: `/web/admin/collection-protocols/${protocolId}/sample-payments`, + data, + showSuccessMessage: true + }) +} + +export function fetchCreatePaymentTransfer(data: Record) { + return request.post({ + url: '/web/admin/payment-transfers', + data, + showSuccessMessage: true + }) +} + +export function fetchUpdatePaymentTransfer(transferId: string, data: Record) { + 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>>({ + url: '/web/admin/payment-transfers/receipt-confirm', + params + }) +} + +export function fetchPaymentTransferReceiptConfirmedList(params?: { + page?: number + pageSize?: number + keyword?: string +}) { + return request.get>>({ + 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>>({ + url: `/web/admin/collection-protocols/${protocolId}/refund-records` + }) +} + +export function fetchCreateProtocolRefundRecord(protocolId: string, data: Record) { + return request.post({ + url: `/web/admin/collection-protocols/${protocolId}/refund-records`, + data, + showSuccessMessage: true + }) +} + +export function fetchUpdateProtocolRefundRecord(recordId: string, data: Record) { + 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 + }) +} diff --git a/web-admin/src/utils/protocol-sample-payment.ts b/web-admin/src/utils/protocol-sample-payment.ts new file mode 100644 index 00000000..af8c0752 --- /dev/null +++ b/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 [] +} diff --git a/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue b/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue index 377acd02..b31660b2 100644 --- a/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue +++ b/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue @@ -683,6 +683,43 @@

缴费信息

+ + + + + @@ -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([]) @@ -1449,7 +1494,33 @@ installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS as number | null, installmentRatios: [] as number[] as number[], // Payment Details - paymentDetails: [] as Array> + paymentDetails: [] as Array>, + // v2 per-sample payment + paymentModelVersion: 2, + protocolTotalAmount: null as number | null, + sourcePresignOrderId: '', + samplePayments: [] as Array> + }) + + const paymentModelVersion = computed(() => Number(formData.paymentModelVersion || 2)) + const presignDepositHint = ref | 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) || null + const samplePays = bundle.samplePayments as Array> | null + if (samplePays && samplePays.length) { + formData.samplePayments = samplePays.map((p) => ({ + ...p, + installmentRatios: parseInstallmentRatios(p.installmentRatios) + })) } const pc = bundle.partyCustomer as Record | 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 ═══════════════════════════════════════════ */ diff --git a/web-admin/src/views/cell-storage/protocol/ProtocolRefundRecordBlock.vue b/web-admin/src/views/cell-storage/protocol/ProtocolRefundRecordBlock.vue new file mode 100644 index 00000000..0b6526af --- /dev/null +++ b/web-admin/src/views/cell-storage/protocol/ProtocolRefundRecordBlock.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue b/web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue new file mode 100644 index 00000000..e131cbcb --- /dev/null +++ b/web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue @@ -0,0 +1,569 @@ + + + + +