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:
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>
|
||||
Reference in new issue
Block a user