Files
zkxw/web-admin/src/views/cell-storage/protocol/SamplePaymentPanel.vue
T
zlwuandCursor 618d8fe21c feat(web-admin): wire fq last-period and dj deadline autofill in SamplePaymentPanel
Auto-recalculate the final installment due amount when earlier periods
change (with manual override), and sync lump-sum deadline to due date.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:53:11 +08:00

1286 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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="payModeTagType(String(pay.payMode || ''))" size="small">
{{ payModeLabel(String(pay.payMode || '')) || '—' }}
</el-tag>
</div>
<div
class="form-grid"
:class="isMultiSchedulePayMode(String(pay.payMode || '')) ? 'form-grid--col6' : '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
v-for="opt in payModeOptionsFor(pay)"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</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) => onLumpSumDeadlineChange(pIdx, v || '')"
/>
</el-form-item>
</template>
<template v-else>
<el-form-item :label="pay.payMode === 'fgd' ? '期数' : '分期期数'">
<el-input
:model-value="pay.installmentCount"
type="number"
:min="1"
style="width: 100%"
@update:model-value="(v: string) => onFqFieldInput(pIdx, 'installmentCount', v)"
@change="() => onFqFieldCommit(pIdx)"
/>
</el-form-item>
<el-form-item label="每期间隔(月)">
<el-input
:model-value="pay.installmentIntervalMonths"
type="number"
:min="0.1"
step="any"
style="width: 100%"
@update:model-value="(v: string) => onFqFieldInput(pIdx, 'installmentIntervalMonths', v)"
@change="() => onFqFieldCommit(pIdx)"
/>
</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%"
@update:model-value="(v: string) => onFqFieldInput(pIdx, 'installmentAdvanceDays', v)"
@change="() => onFqFieldCommit(pIdx)"
/>
</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 }} 期</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 type="primary" size="small" @click="addTransfer(pIdx, sIdx)">
<el-icon><Plus /></el-icon>
添加转账
</el-button>
</div>
<div class="form-grid form-grid--col4 schedule-due-edit">
<el-form-item label="应缴费金额(元)">
<AmountInput
:model-value="sch.dueAmount"
:min="0"
:disabled="isScheduleDueLocked(sch)"
style="width: 100%"
@update:model-value="(v: number | null) => patchSchedule(pIdx, sIdx, { dueAmount: v })"
/>
</el-form-item>
<el-form-item label="应缴费时间">
<el-date-picker
:model-value="sch.dueDate"
type="date"
value-format="YYYYMMDD"
format="YYYY-MM-DD"
placeholder="选择应缴费时间"
clearable
:disabled="isScheduleDueLocked(sch)"
style="width: 100%"
@update:model-value="(v: string) => patchSchedule(pIdx, sIdx, { dueDate: v || '' })"
/>
</el-form-item>
</div>
<el-table
:data="sch.transfers || []"
class="list-table transfer-table"
stripe
empty-text="暂无转账记录,请点击上方按钮添加"
>
<el-table-column type="index" label="#" min-width="50" align="center" />
<el-table-column label="转账方式" min-width="140" align="center">
<template #default="{ row }">
<el-select
v-model="row.transferType"
placeholder="请选择"
style="width: 100%"
:disabled="isTransferLocked(row)"
@change="emitChange"
>
<el-option
v-for="opt in transferTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="转账金额(元)" min-width="140" align="center">
<template #default="{ row }">
<AmountInput
v-model="row.transferAmount"
:min="0"
:disabled="isTransferLocked(row)"
style="width: 100%"
@change="emitChange"
/>
</template>
</el-table-column>
<el-table-column label="转账时间" min-width="190" align="center">
<template #default="{ row }">
<el-date-picker
v-model="row.transferTime"
type="datetime"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
placeholder="选择时间"
:disabled="isTransferLocked(row)"
style="width: 100%"
@change="emitChange"
/>
</template>
</el-table-column>
<el-table-column label="转账凭证" min-width="280" align="left">
<template #default="{ row, $index }">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
v-if="!isTransferLocked(row)"
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
@change="(f: any) => handleTransferAttachmentChange(pIdx, sIdx, $index, f)"
>
<div class="upload-card">
<el-icon :size="22" color="#047857"><Plus /></el-icon>
<span class="upload-card-text">上传凭证</span>
</div>
</el-upload>
<div
v-for="(file, fIdx) in ensureTransferAttachments(row)"
:key="file.id || fIdx"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div class="attachment-preview">
<template v-if="file.fileUrl && isImageUrl(file.fileUrl)">
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else>
<div class="attachment-preview__icon">
<el-icon :size="22"><Document /></el-icon>
</div>
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{
file.fileName || '附件'
}}</span>
<el-icon
v-if="!isTransferLocked(row)"
class="action-btn"
title="删除"
@click="removeTransferAttachment(pIdx, sIdx, $index, fIdx)"
>
<Delete />
</el-icon>
</div>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="备注" min-width="160" align="center">
<template #default="{ row }">
<el-input
v-if="!isTransferLocked(row)"
v-model="row.remark"
maxlength="512"
clearable
placeholder="选填"
@change="emitChange"
/>
<span v-else>{{ row.remark || '—' }}</span>
</template>
</el-table-column>
<el-table-column
label="财务备注"
min-width="140"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.financeRemark || '—' }}</template>
</el-table-column>
<el-table-column label="状态" min-width="110" align="center">
<template #default="{ row }">
<el-tag :type="payStatusTagType(String(row.payStatus || 'dfj'))" size="small">
{{ payStatusLabel(String(row.payStatus || 'dfj')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="300" align="center" fixed="right">
<template #default="{ row, $index }">
<el-button
v-if="!isTransferLocked(row)"
link
type="primary"
size="small"
:loading="savingKey === transferRowKey(pIdx, sIdx, $index, row)"
@click="saveTransferRow(pIdx, sIdx, $index)"
>
<el-icon><Select /></el-icon>
保存
</el-button>
<el-button
v-if="row.id && row.payStatus === 'dfj' && protocolId"
link
type="primary"
size="small"
:loading="submittingId === row.id"
@click="submitTransfer(row)"
>
<el-icon><Check /></el-icon>
提交财务确认
</el-button>
<el-button
v-if="row.id && row.payStatus === 'jfqrz' && protocolId"
link
type="warning"
size="small"
:loading="submittingId === row.id"
@click="withdrawTransfer(row)"
>
<el-icon><RefreshLeft /></el-icon>
撤回
</el-button>
<el-button
v-if="!isTransferLocked(row)"
link
type="danger"
size="small"
@click="removeTransfer(pIdx, sIdx, $index)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
<span
v-if="isTransferLocked(row) && row.payStatus !== 'jfqrz'"
class="muted-dash"
>—</span
>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Plus, Check, Delete, Document, Select, RefreshLeft } 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,
calcFqInstallmentDueDate,
normalizeInstallmentAmounts
} from '@/utils/protocol-payment-installment'
import {
applyConsolidateRemainingToPayment,
applyExpandRemainingToInstallment,
applyLumpSumDeadlineToLastDueDate,
availableSamplePayModes,
calcLastPeriodDueAmount,
parseInstallmentRatios,
parsePreferentialPrice,
resolvePerSamplePaymentDefaults,
samplePaymentHasConfirmedTransfer,
schedulePaidArrears,
shouldAutoFillLastPeriodDue,
shouldReplaceContractWithPreferentialPrice
} from '@/utils/protocol-sample-payment'
import { isMultiSchedulePayMode } from '@/utils/protocol-payment-presets'
import { payModeLabel, payModeTagType } from '@/utils/status-tag-registry'
import {
fetchCreatePaymentTransfer,
fetchUpdatePaymentTransfer,
fetchSubmitPaymentTransferFinance,
fetchWithdrawPaymentTransfer
} from '@/api/protocol-sample-payment'
import { fetchUploadAttachment } from '@/api/business'
import { ElMessageBox } from 'element-plus'
type TransferAttachment = {
id?: string
fileName?: string
fileUrl?: string
_uploading?: boolean
}
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function fullFileUrl(url: string) {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
return minioBaseUrl ? `${minioBaseUrl}/${url.replace(/^\//, '')}` : url
}
function isImageUrl(url: string) {
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(url)
}
/** 保证转账行有 attachments 数组(编辑回填或历史数据) */
function ensureTransferAttachments(tr: Record<string, any>): TransferAttachment[] {
if (Array.isArray(tr.attachments)) return tr.attachments
const urls = String(tr.transferAttachments || '')
.split(',')
.map((s: string) => s.trim())
.filter(Boolean)
tr.attachments = urls.map((fileUrl: string, i: number) => ({
id: `att_${i}`,
fileName: fileUrl.includes('/') ? fileUrl.split('/').pop() || '附件' : '附件',
fileUrl
}))
return tr.attachments
}
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('')
const savingKey = ref('')
/** 按样本 key:用户手改过最后一期应缴金额则 true,前几期再变时清除 */
const lastPeriodDueOverrideByKey = ref<Record<string, boolean>>({})
async function ensureTransferTypes() {
await loadTransferTypeDict()
transferTypeOptions.value = getTransferTypeOptions()
}
ensureTransferTypes()
function fmtMoney(v: unknown): string {
if (v == null || v === '') return '—'
return '¥' + formatMoneyDisplay(v)
}
function samplePayKey(pay: Record<string, any>, pIdx: number): string {
return String(pay.sampleCode || pay.protocolSampleId || pIdx)
}
function activeScheduleIndices(schedules: Record<string, any>[]): number[] {
const idx: number[] = []
schedules.forEach((s, i) => {
if (String(s.scheduleStatus || 'open') !== 'void' && !s._deleted) idx.push(i)
})
return idx
}
/** 仅财务已确认(yjf)锁定;付款确认中仍可改,直至财务确认 */
function isTransferLocked(tr: Record<string, any>) {
return tr.payStatus === 'yjf'
}
/** 期次存在任一已财务确认转账时,应缴金额/时间只读 */
function isScheduleDueLocked(sch: Record<string, any>) {
return (sch.transfers || []).some((t: Record<string, any>) => t.payStatus === 'yjf')
}
/** 仅改单期字段,不重建整表批次;手填金额同步到 installmentRatios */
function patchSchedule(pIdx: number, sIdx: number, patch: Record<string, any>) {
const list = [...(props.modelValue || [])]
const pay = { ...list[pIdx] }
const schedules = [...(pay.schedules || [])]
schedules[sIdx] = { ...schedules[sIdx], ...patch }
const mode = String(pay.payMode || '')
const key = samplePayKey(pay, pIdx)
const activeIdx = activeScheduleIndices(schedules)
if ('dueAmount' in patch && mode === 'fq' && activeIdx.length >= 2) {
const lastIdx = activeIdx[activeIdx.length - 1]
if (sIdx === lastIdx) {
lastPeriodDueOverrideByKey.value = {
...lastPeriodDueOverrideByKey.value,
[key]: true
}
} else if (activeIdx.slice(0, -1).includes(sIdx)) {
lastPeriodDueOverrideByKey.value = {
...lastPeriodDueOverrideByKey.value,
[key]: false
}
const lastSch = schedules[lastIdx]
const canFill = shouldAutoFillLastPeriodDue({
lastManualOverride: false,
lastLocked: isScheduleDueLocked(lastSch)
})
if (canFill) {
const priors = activeIdx.slice(0, -1).map((i) => schedules[i]?.dueAmount)
const lastAmt = calcLastPeriodDueAmount(pay.contractAmount, priors)
if (lastAmt != null) {
schedules[lastIdx] = { ...schedules[lastIdx], dueAmount: lastAmt }
}
}
}
}
pay.schedules = schedules
if ('dueAmount' in patch) {
pay.installmentRatios = schedules.map((s: Record<string, any>) => {
const n = Number(s.dueAmount)
return Number.isFinite(n) && n > 0 ? n : 0
})
}
list[pIdx] = pay
emit('update:modelValue', list)
}
function onLumpSumDeadlineChange(pIdx: number, raw: string) {
const list = [...(props.modelValue || [])]
const pay = { ...list[pIdx] }
const deadline = raw || ''
pay.lumpSumDeadline = deadline
if (String(pay.payMode || '') === 'dj') {
pay.schedules = applyLumpSumDeadlineToLastDueDate(
[...(pay.schedules || [])],
deadline,
isScheduleDueLocked
)
}
list[pIdx] = pay
emit('update:modelValue', list)
}
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 defaults = resolvePerSamplePaymentDefaults(sample, 'dj')
const amount = defaults.contractAmount
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 paymentHasLockedTransfer(pay: Record<string, any>) {
return (pay.schedules || []).some((s: Record<string, any>) =>
(s.transfers || []).some((t: Record<string, any>) => t.payStatus === 'yjf')
)
}
/** 优惠价变更时同步合同;打开编辑不覆盖库内金额(仅合同为空时补齐) */
function applyPreferentialPriceToPayment(
pay: Record<string, any>,
sample: Record<string, any>,
force = false
): Record<string, any> {
if (paymentHasLockedTransfer(pay)) return pay
const price = parsePreferentialPrice(sample)
if (price == null) return pay
if (!force && !shouldReplaceContractWithPreferentialPrice(pay.contractAmount, price)) {
return pay
}
if (Math.abs(Number(pay.contractAmount || 0) - price) <= 0.015) return pay
const mode = String(pay.payMode || 'dj')
const next: Record<string, any> = { ...pay, contractAmount: price }
// 分期:不自动改写各期金额(由用户保证合计=新合同);趸交随合同重建应缴
if (mode === 'fq' || mode === 'fgd') {
return next
}
return rebuildSchedules(next)
}
function scheduleByNo(pay: Record<string, any>, scheduleNo: number) {
return (pay.schedules || []).find(
(s: any) => Number(s.scheduleNo || 0) === Number(scheduleNo)
)
}
function transfersForSchedule(pay: Record<string, any>, scheduleNo: number) {
// 重建批次时保留确认中/已确认笔,以及待缴费草稿
return [...(scheduleByNo(pay, scheduleNo)?.transfers || [])]
}
function rebuildSchedules(pay: Record<string, any>): Record<string, any> {
const mode = pay.payMode || 'dj'
if (mode === 'dj') {
const prev = scheduleByNo(pay, 1)
return {
...pay,
schedules: [
{
id: prev?.id,
scheduleNo: 1,
dueAmount: pay.contractAmount,
dueDate: pay.lumpSumDeadline || '',
scheduleStatus: prev?.scheduleStatus || 'open',
transfers: transfersForSchedule(pay, 1)
}
]
}
}
// fq / fgd / djfq:按期数生成批次;金额无模板时留空由用户手填
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)
const rawRatios = parseInstallmentRatios(pay.installmentRatios)
const hasManualAmounts = rawRatios.some((v) => Number(v) > 0)
let amounts: Array<number | null> = []
if (count > 0) {
if (hasManualAmounts) {
amounts = normalizeInstallmentAmounts(rawRatios, pay.contractAmount, count)
} else {
amounts = Array.from({ length: count }, () => null)
}
}
if (!count || !interval || !startDate) {
return { ...pay, installmentRatios: hasManualAmounts ? amounts : [], schedules: [] }
}
return {
...pay,
installmentRatios: hasManualAmounts ? amounts : [],
schedules: Array.from({ length: count }, (_, i) => {
const scheduleNo = i + 1
const prev = scheduleByNo(pay, scheduleNo)
const prevAmt = prev?.dueAmount
const keepPrev =
!hasManualAmounts && prevAmt != null && Number(prevAmt) > 0
? Number(prevAmt)
: null
return {
id: prev?.id,
scheduleNo,
dueAmount: hasManualAmounts ? amounts[i] : keepPrev,
dueDate:
prev?.dueDate ||
calcFqInstallmentDueDate(startDate, i, interval, advanceDays) ||
'',
scheduleStatus: prev?.scheduleStatus || 'open',
transfers: transfersForSchedule(pay, scheduleNo)
}
})
}
}
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)
}
const PAY_MODE_OPTION_META: Array<{ value: string; label: string }> = [
{ value: 'dj', label: '趸交' },
{ value: 'fq', label: '分期' },
{ value: 'fgd', label: '分改趸' }
]
function payModeOptionsFor(pay: Record<string, any>) {
const allowed = new Set(availableSamplePayModes(pay))
return PAY_MODE_OPTION_META.filter((o) => allowed.has(o.value as any))
}
function activeScheduleCount(pay: Record<string, any>): number {
return (pay.schedules || []).filter(
(s: any) => String(s.scheduleStatus || 'open') !== 'void' && !s._deleted
).length
}
async function onPayModeChange(pIdx: number, mode: string) {
const list = props.modelValue || []
const prev = list[pIdx]
const prevMode = String(prev?.payMode || '')
if (!prev || mode === prevMode) return
const allowed = availableSamplePayModes(prev)
if (!allowed.includes(mode as any)) {
ElMessage.warning('当前缴费模式下不可切换为该选项')
return
}
const hasPaid = samplePaymentHasConfirmedTransfer(prev)
const schCount = activeScheduleCount(prev)
// 分期 → 趸交/分改趸:只要已有批次就并期(不依赖是否已有 yjf;无已缴时会并成 1 期)
// 此前无 yjf 时误走 rebuild,且期数仍按 6,界面几乎无变化
if (prevMode === 'fq' && (mode === 'dj' || mode === 'fgd') && schCount > 0) {
const label = mode === 'dj' ? '趸交' : '分改趸'
try {
await ElMessageBox.confirm(
hasPaid
? `改为${label}将保留已缴期,并把后续未缴合并至当前未清期。是否继续?`
: `改为${label}将把各期未缴合并为当前未清期。是否继续?`,
`确认改为${label}`,
{ type: 'warning', confirmButtonText: '继续', cancelButtonText: '取消' }
)
} catch {
return
}
try {
const nextList = [...(props.modelValue || [])]
nextList[pIdx] = applyConsolidateRemainingToPayment({ ...nextList[pIdx] }, mode)
emit('update:modelValue', nextList)
} catch (e: any) {
ElMessage.error(e?.message || `无法改为${label}`)
}
return
}
// 有已缴:趸交/分改趸 → 分期(保留已缴,剩余按期数拆分)
if (hasPaid && (prevMode === 'dj' || prevMode === 'fgd') && mode === 'fq') {
try {
await ElMessageBox.confirm(
'改为分期将保留已缴期,剩余金额按分期期数自动生成后续应缴批次。是否继续?',
'确认改为分期',
{ type: 'warning', confirmButtonText: '继续', cancelButtonText: '取消' }
)
} catch {
return
}
try {
const nextList = [...(props.modelValue || [])]
const cur = { ...nextList[pIdx] }
const count = Number(cur.installmentCount) || 6
const interval = Number(cur.installmentIntervalMonths) || 1
let startDate = String(cur.installmentStartDate || '').trim()
if (!startDate) {
startDate = String(cur.lumpSumDeadline || '').trim()
}
if (!startDate) {
const d = new Date()
startDate = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`
}
nextList[pIdx] = applyExpandRemainingToInstallment(cur, {
installmentCount: count,
installmentIntervalMonths: interval,
installmentStartDate: startDate,
installmentAdvanceDays:
cur.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS
})
emit('update:modelValue', nextList)
} catch (e: any) {
ElMessage.error(e?.message || '无法改为分期')
}
return
}
// 无已缴 / 无批次:自由切换并重建批次
const sample = (props.samples || []).find(
(s) => String(s.sampleCode || '') === String(prev?.sampleCode || '')
)
const presetMode = mode === 'fq' || mode === 'fgd' ? 'fq' : 'dj'
const defaults = resolvePerSamplePaymentDefaults(sample, presetMode)
const patch: Record<string, any> = {
payMode: mode,
contractAmount: defaults.contractAmount ?? prev?.contractAmount ?? null
}
if (mode === 'fq' || mode === 'fgd') {
// 建单选分改趸默认 3 期;勿用 defaults.installmentCount(||6) 盖掉
patch.installmentCount = mode === 'fgd' ? 3 : defaults.installmentCount || 6
patch.installmentIntervalMonths = 1
patch.installmentAdvanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS
patch.installmentRatios = []
} else {
patch.installmentCount = null
patch.installmentRatios = []
}
patchPay(pIdx, patch, true)
}
function onContractAmountChange(pIdx: number, v: number | null) {
patchPay(pIdx, { contractAmount: v }, true)
}
/** 输入过程中同步受控值(不重建批次,避免中途键入被回滚) */
function onFqFieldInput(pIdx: number, key: string, raw: string) {
const num = raw === '' || raw == null ? null : Number(raw)
patchPay(pIdx, { [key]: Number.isFinite(num as number) ? num : null }, false)
}
/** 失焦/回车后再按最新参数重建应缴批次 */
function onFqFieldCommit(pIdx: number) {
patchPay(pIdx, {}, 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',
remark: '',
financeRemark: '',
transferAttachments: '',
attachments: [] as TransferAttachment[]
})
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)
}
function getTransfer(pIdx: number, sIdx: number, tIdx: number) {
return props.modelValue?.[pIdx]?.schedules?.[sIdx]?.transfers?.[tIdx]
}
function transferRowKey(pIdx: number, sIdx: number, tIdx: number, row: Record<string, any>) {
return String(row.id || `${pIdx}-${sIdx}-${tIdx}`)
}
function attachmentsToUrls(tr: Record<string, any>) {
ensureTransferAttachments(tr)
return (tr.attachments || [])
.filter((f: TransferAttachment) => f?.fileUrl && !f._uploading)
.map((f: TransferAttachment) => String(f.fileUrl))
.join(',')
}
function validateTransferRow(tr: Record<string, any>) {
if (!(tr.transferType || '').trim()) {
ElMessage.warning('请选择转账方式')
return false
}
const amt = Number(tr.transferAmount)
if (!Number.isFinite(amt) || amt <= 0) {
ElMessage.warning('请填写转账金额')
return false
}
if ((tr.attachments || []).some((f: TransferAttachment) => f._uploading)) {
ElMessage.warning('凭证正在上传,请稍后再保存')
return false
}
return true
}
function applySavedTransfer(tr: Record<string, any>, data: Record<string, any>) {
if (!data) return
tr.id = data.id || tr.id
tr.payStatus = data.payStatus || tr.payStatus || 'dfj'
tr.scheduleId = data.scheduleId || tr.scheduleId
tr.samplePaymentId = data.samplePaymentId || tr.samplePaymentId
if (Array.isArray(data.attachments) && data.attachments.length) {
tr.attachments = data.attachments.map((a: Record<string, any>) => ({ ...a }))
}
if (data.transferAttachments) tr.transferAttachments = data.transferAttachments
emitChange()
}
async function saveTransferRow(pIdx: number, sIdx: number, tIdx: number) {
const tr = getTransfer(pIdx, sIdx, tIdx)
if (!tr || isTransferLocked(tr)) return
if (!props.protocolId) {
ElMessage.warning('请先保存协议后再保存转账记录')
return
}
const sch = props.modelValue?.[pIdx]?.schedules?.[sIdx]
if (!sch?.id) {
ElMessage.warning('请先保存协议后再保存转账记录')
return
}
if (!validateTransferRow(tr)) return
const key = transferRowKey(pIdx, sIdx, tIdx, tr)
savingKey.value = key
try {
const payload = {
transferType: tr.transferType,
transferAmount: tr.transferAmount,
transferTime: tr.transferTime || undefined,
transferAttachments: attachmentsToUrls(tr) || undefined,
remark: tr.remark
}
if (tr.id) {
const data: any = await fetchUpdatePaymentTransfer(String(tr.id), payload)
applySavedTransfer(tr, data || {})
ElMessage.success('转账记录已保存')
} else {
const data: any = await fetchCreatePaymentTransfer({
scheduleId: String(sch.id),
...payload
})
applySavedTransfer(tr, data || {})
}
} finally {
savingKey.value = ''
}
}
async function handleTransferAttachmentChange(
pIdx: number,
sIdx: number,
tIdx: number,
uploadFile: { raw?: File }
) {
const file = uploadFile.raw
if (!file) return
if (file.size > 20 * 1024 * 1024) {
ElMessage.warning('单文件不超过 20MB')
return
}
const tr = getTransfer(pIdx, sIdx, tIdx)
if (!tr) return
ensureTransferAttachments(tr)
const tempId = `temp_${Date.now()}_${Math.random().toString(16).slice(2)}`
tr.attachments.push({
id: tempId,
fileName: file.name,
fileUrl: '',
_uploading: true
})
emitChange()
const idx = tr.attachments.length - 1
try {
const fd = new FormData()
fd.append('folder', 'protocol-transfer')
fd.append('file', file)
const res: any = await fetchUploadAttachment(fd)
const data = res?.data || res
tr.attachments[idx] = {
id: data?.id || tempId,
fileName: data?.fileName || file.name,
fileUrl: data?.fileUrl || data?.url || '',
_uploading: false
}
emitChange()
} catch {
ElMessage.error('文件上传失败')
tr.attachments.splice(idx, 1)
emitChange()
}
}
function removeTransferAttachment(pIdx: number, sIdx: number, tIdx: number, fIdx: number) {
const tr = getTransfer(pIdx, sIdx, tIdx)
if (!tr?.attachments) return
tr.attachments.splice(fIdx, 1)
emitChange()
}
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 = ''
}
}
async function withdrawTransfer(tr: Record<string, any>) {
if (!tr.id) return
await ElMessageBox.confirm(
'确认撤回该笔转账的财务确认申请?撤回后可修改并重新提交。',
'撤回',
{ type: 'warning' }
)
submittingId.value = String(tr.id)
try {
await fetchWithdrawPaymentTransfer(String(tr.id))
tr.payStatus = 'dfj'
emitChange()
ElMessage.success('已撤回')
} finally {
submittingId.value = ''
}
}
/** 上一轮各样本优惠价,用于判断是否强制同步合同金额 */
const lastPreferentialByCode = ref<Record<string, number | null>>({})
/** 样本增减 / 优惠价变更时对齐缴费方案(按 sampleCode) */
watch(
() =>
(props.samples || [])
.filter((s) => String(s.sampleStatus || '') !== 'yzf' && String(s.sampleCode || '').trim())
.map((s) => ({
code: String(s.sampleCode),
price: parsePreferentialPrice(s),
id: s.id || '',
sample: s
})),
(rows) => {
const existing = [...(props.modelValue || [])]
const byCode = new Map(existing.map((p) => [String(p.sampleCode || ''), p]))
const next: Array<Record<string, any>> = []
const nextPriceMap: Record<string, number | null> = {}
for (const row of rows) {
const { code, price, sample } = row
nextPriceMap[code] = price
const prev = byCode.get(code)
if (prev) {
const hadPrev = Object.prototype.hasOwnProperty.call(
lastPreferentialByCode.value,
code
)
const prevPrice = lastPreferentialByCode.value[code]
// 仅在「已跟踪过该样本且优惠价发生变化」时强制覆盖;首次对齐只替换空值/价目表默认
const priceChanged = hadPrev && price != null && prevPrice !== price
const aligned = applyPreferentialPriceToPayment(
{
...prev,
protocolSampleId: sample.id || prev.protocolSampleId || ''
},
sample,
priceChanged
)
next.push(aligned)
} else {
next.push(emptyDjPayment(sample))
}
}
lastPreferentialByCode.value = nextPriceMap
const same =
next.length === existing.length &&
next.every((p, i) => {
const e = existing[i]
if (!e) return false
return (
p.sampleCode === e.sampleCode &&
p.protocolSampleId === e.protocolSampleId &&
Number(p.contractAmount) === Number(e.contractAmount) &&
String(p.payMode || '') === String(e.payMode || '') &&
JSON.stringify(p.schedules || []) === JSON.stringify(e.schedules || [])
)
})
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: 10px;
font-size: 13px;
}
.schedule-due-edit {
margin-bottom: 10px;
}
.schedule-arrears {
color: var(--mint-text-muted, #64748b);
}
.transfer-table {
width: 100%;
:deep(.el-date-editor) {
width: 100%;
}
}
.muted-dash {
color: var(--mint-text-muted, #94a3b8);
}
.attachment-upload {
width: 100%;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
}
.upload-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 88px;
height: 72px;
cursor: pointer;
border: 1px dashed #a7f3d0;
border-radius: 8px;
background: #ecfdf5;
}
.upload-card-text {
margin-top: 4px;
font-size: 11px;
color: #047857;
}
.attachment-item {
width: 88px;
overflow: hidden;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
&.is-uploading {
opacity: 0.6;
}
}
.attachment-preview {
display: flex;
align-items: center;
justify-content: center;
width: 88px;
height: 52px;
background: #f8fafc;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.attachment-preview__icon {
color: #64748b;
}
.attachment-footer {
display: flex;
gap: 4px;
align-items: center;
padding: 4px 6px;
}
.attachment-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.action-btn {
flex-shrink: 0;
cursor: pointer;
color: #94a3b8;
&:hover {
color: #dc2626;
}
}
.form-grid {
display: grid;
gap: 8px 12px;
}
.form-grid--col4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.form-grid--col6 {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
@media (max-width: 1400px) {
.form-grid--col6 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.form-grid--col4,
.form-grid--col6 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>