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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-03 11:13:32 +08:00
1 parent a328b9ff2d
commit 4e71a13165
5 files changed
+1280 -19

No files matched your search

@@ -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
})
}
@@ -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 []
}
@@ -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
═══════════════════════════════════════════ */
@@ -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>
@@ -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>