/** 按样本缴费 · 工具函数(payment_model_version=2) * 合同金额唯一来源:样本优惠价 price;不再使用硬编码价目表。 */ import { calcFqInstallmentDueDate, defaultInstallmentAmounts, DEFAULT_INSTALLMENT_ADVANCE_DAYS } from '@/utils/protocol-payment-installment' export type SamplePaymentLike = { contractAmount?: number | null sampleCode?: string payMode?: string schedules?: ScheduleLike[] } export type SamplePriceLike = { price?: number | string | null } export type TransferLike = { payStatus?: string transferAmount?: number | null } export type ScheduleLike = { dueAmount?: number | null dueDate?: string | null scheduleStatus?: string transfers?: TransferLike[] } export function sumSampleContractAmounts(payments: SamplePaymentLike[]): number { return payments.reduce((s, p) => s + (Number(p.contractAmount) || 0), 0) } /** 样本优惠价格(元);未填返回 null */ export function parsePreferentialPrice( sample: SamplePriceLike | null | undefined ): number | null { if (sample == null) return null const raw = sample.price if (raw == null || raw === '') return null const n = Number(raw) if (!Number.isFinite(n) || n < 0) return null return Math.round(n * 100) / 100 } 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 } function activeSchedules(pay: SamplePaymentLike): ScheduleLike[] { return (pay.schedules || []).filter((s) => String(s.scheduleStatus || 'open') !== 'void') } /** 分期/分改趸:每期应缴金额与应缴时间必填 */ export function sampleScheduleRequiredFieldsHint(pay: SamplePaymentLike): string | null { const mode = String(pay.payMode || '') if (mode !== 'fq' && mode !== 'fgd' && mode !== 'djfq') return null const schedules = activeSchedules(pay) if (!schedules.length) return null const code = String(pay.sampleCode || '').trim() const prefix = code ? `样本 ${code} ` : '' for (let i = 0; i < schedules.length; i++) { const s = schedules[i] const amt = Number(s.dueAmount) if (!Number.isFinite(amt) || amt <= 0) { return `${prefix}第 ${i + 1} 期应缴金额必须填写` } if (!String(s.dueDate || '').trim()) { return `${prefix}第 ${i + 1} 期应缴时间必须填写` } } return null } /** 单样本:未作废期应缴合计须等于合同金额;不一致返回提示文案 */ export function sampleScheduleDuesMismatchHint(pay: SamplePaymentLike): string | null { const contract = Number(pay.contractAmount) if (!Number.isFinite(contract)) return null const schedules = activeSchedules(pay) if (!schedules.length) return null const sum = schedules.reduce((acc, x) => acc + (Number(x.dueAmount) || 0), 0) if (Math.abs(contract - sum) > 0.015) { const code = String(pay.sampleCode || '').trim() const prefix = code ? `样本 ${code} ` : '' return `${prefix}合同金额 ${contract.toFixed(2)} 与各期应缴合计 ${sum.toFixed(2)} 不一致,请调整后再保存` } return null } function sumTransferAmountByStatuses( transfers: TransferLike[] | undefined, statuses: Set ): number { return (transfers || []) .filter((t) => statuses.has(String(t.payStatus || ''))) .reduce((s, t) => s + (Number(t.transferAmount) || 0), 0) } /** 财务已确认口径(仅 yjf):用于分改趸并期等与后端 plan_fq_to_fgd_merge 对齐 */ export function scheduleFinanceConfirmedPaidArrears( dueAmount: number, transfers: TransferLike[] ): { paid: number; arrears: number } { const paid = Math.round(sumTransferAmountByStatuses(transfers, new Set(['yjf'])) * 100) / 100 return { paid, arrears: Math.round((dueAmount - paid) * 100) / 100 } } /** * 期次已缴/欠缴(展示):已缴 = 仅 yjf;欠缴 = max(应缴 − 已缴, 0) * 与后端 summarize_period / chase_period_money 一致 */ export function schedulePaidArrears( dueAmount: number, transfers: TransferLike[] ): { paid: number; arrears: number } { const { paid, arrears } = scheduleFinanceConfirmedPaidArrears(dueAmount, transfers) return { paid, arrears: arrears > 0 ? arrears : 0 } } /** 单样本合同金额 = 优惠价格;未填优惠价则 null */ export function resolveSampleContractAmount( sample: SamplePriceLike | null | undefined ): number | null { return parsePreferentialPrice(sample) } /** 新建/切换缴费模式:合同取优惠价;分期不预填各期金额 */ export function resolvePerSamplePaymentDefaults( sample: SamplePriceLike | null | undefined, payMode: 'dj' | 'fq' ): { contractAmount: number | null installmentCount?: number installmentAmounts?: number[] } { const contractAmount = resolveSampleContractAmount(sample) if (payMode === 'fq') { return { contractAmount, installmentCount: 6, installmentAmounts: [] } } return { contractAmount } } /** * 是否用优惠价覆盖合同:仅合同为空时(打开编辑不覆盖已有库内金额)。 * 用户改优惠价时由调用方 force=true 强制同步。 */ export function shouldReplaceContractWithPreferentialPrice( contractAmount: number | null | undefined, preferentialPrice: number | null ): boolean { if (preferentialPrice == null) return false if (contractAmount == null || !Number.isFinite(Number(contractAmount))) return true return false } 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 [] } export type FgdScheduleLike = ScheduleLike & { id?: string scheduleNo?: number _deleted?: boolean } export type FgdMergePlan = { targetScheduleId: string targetScheduleNo: number newDueAmount: number softDeleteScheduleIds: string[] softDeleteScheduleNos: number[] newInstallmentCount: number } function roundMoney(n: number): number { return Math.round(n * 100) / 100 } /** fq → fgd:保留已缴期,剩余欠缴并入第一期未清期;软删其后各期(与后端 plan_fq_to_fgd_merge 对齐) */ export function planFqToFgdMerge(schedules: FgdScheduleLike[]): FgdMergePlan { const schList = (schedules || []) .filter((s) => String(s.scheduleStatus || 'open') !== 'void' && !s._deleted) .slice() .sort((a, b) => Number(a.scheduleNo || 0) - Number(b.scheduleNo || 0)) const arrearsOf = (s: FgdScheduleLike): number => { const { arrears } = scheduleFinanceConfirmedPaidArrears( Number(s.dueAmount) || 0, s.transfers || [] ) return arrears > 0 ? arrears : 0 } let target: FgdScheduleLike | undefined for (const s of schList) { if (arrearsOf(s) > 0) { target = s break } } if (!target) { target = schList.find((s) => String(s.scheduleStatus || 'open') !== 'cleared') } if (!target) { throw new Error('没有可合并的未清期次') } const targetNo = Number(target.scheduleNo || 0) const later = schList.filter((s) => Number(s.scheduleNo || 0) > targetNo) for (const s of later) { if ((s.transfers || []).some((t) => t.payStatus === 'yjf')) { throw new Error('后续期已有已确认转账,无法合并为分改趸') } } const remain = roundMoney(schList.reduce((sum, s) => sum + arrearsOf(s), 0)) const { paid: targetConfirmed } = scheduleFinanceConfirmedPaidArrears( Number(target.dueAmount) || 0, target.transfers || [] ) const newDueAmount = roundMoney(targetConfirmed + remain) const softDeleteScheduleIds = later .map((s) => String(s.id || '').trim()) .filter(Boolean) const softDeleteScheduleNos = later.map((s) => Number(s.scheduleNo || 0)) const softNoSet = new Set(softDeleteScheduleNos) const kept = schList.filter((s) => !softNoSet.has(Number(s.scheduleNo || 0))) return { targetScheduleId: String(target.id || ''), targetScheduleNo: targetNo, newDueAmount, softDeleteScheduleIds, softDeleteScheduleNos, newInstallmentCount: kept.length } } export type SamplePayMode = 'dj' | 'fq' | 'fgd' export function samplePaymentHasConfirmedTransfer(pay: { schedules?: FgdScheduleLike[] }): boolean { return (pay.schedules || []).some((s) => (s.transfers || []).some((t) => t.payStatus === 'yjf') ) } /** * 下拉可选缴费模式(趸交 ↔ 分改趸 互斥,不可直转): * - 分期:可改趸交 / 分改趸 * - 趸交:仅可改分期 * - 分改趸:仅可改分期 */ export function availableSamplePayModes(pay: { payMode?: string schedules?: FgdScheduleLike[] }): SamplePayMode[] { const mode = String(pay.payMode || 'dj') as SamplePayMode if (mode === 'fq') return ['fq', 'dj', 'fgd'] if (mode === 'fgd') return ['fgd', 'fq'] return ['dj', 'fq'] } /** 将剩余欠缴并入当前未清期,软删后续未缴期(fq→dj / fq→fgd) */ export function applyConsolidateRemainingToPayment>( pay: T, targetMode: 'dj' | 'fgd' ): T { const plan = planFqToFgdMerge((pay.schedules || []) as FgdScheduleLike[]) const softNoSet = new Set(plan.softDeleteScheduleNos) const schedules = ((pay.schedules || []) as FgdScheduleLike[]) .filter( (s) => String(s.scheduleStatus || 'open') !== 'void' && !s._deleted && !softNoSet.has(Number(s.scheduleNo || 0)) ) .map((s) => { if (Number(s.scheduleNo || 0) !== plan.targetScheduleNo) return { ...s } return { ...s, dueAmount: plan.newDueAmount } }) .sort((a, b) => Number(a.scheduleNo || 0) - Number(b.scheduleNo || 0)) return { ...pay, payMode: targetMode, installmentCount: plan.newInstallmentCount, schedules, installmentRatios: schedules.map((s) => { const n = Number(s.dueAmount) return Number.isFinite(n) && n > 0 ? n : 0 }) } } /** 将并期结果写回样本缴费方案(前端即时预览;保存时后端仍会按库内数据再执行一遍) */ export function applyFqToFgdMergeToPayment>(pay: T): T { return applyConsolidateRemainingToPayment(pay, 'fgd') } export type ExpandInstallmentOpts = { installmentCount?: number | null installmentIntervalMonths?: number | null installmentStartDate?: string installmentAdvanceDays?: number | null } /** dj/fgd → fq:保留已确认实缴期,剩余按分期参数拆成新期 */ export function applyExpandRemainingToInstallment>( pay: T, opts: ExpandInstallmentOpts ): T { const schList = ((pay.schedules || []) as FgdScheduleLike[]) .filter((s) => String(s.scheduleStatus || 'open') !== 'void' && !s._deleted) .slice() .sort((a, b) => Number(a.scheduleNo || 0) - Number(b.scheduleNo || 0)) for (const s of schList) { if ((s.transfers || []).some((t) => t.payStatus === 'jfqrz')) { throw new Error('存在付款确认中的转账,请先完成财务确认') } } let totalPaid = 0 const kept: FgdScheduleLike[] = [] for (const s of schList) { const { paid, arrears } = scheduleFinanceConfirmedPaidArrears( Number(s.dueAmount) || 0, s.transfers || [] ) totalPaid = roundMoney(totalPaid + paid) if (paid > 0) { kept.push({ ...s, dueAmount: arrears > 0 ? paid : s.dueAmount, scheduleStatus: arrears > 0 ? 'cleared' : s.scheduleStatus || 'cleared', transfers: [...(s.transfers || [])] }) } } const contract = Number(pay.contractAmount) if (!Number.isFinite(contract)) { throw new Error('请先填写样本合同金额') } const remaining = roundMoney(contract - totalPaid) if (remaining <= 0) { throw new Error('已缴清,无法改为分期') } const count = Math.max(1, Number(opts.installmentCount) || 6) const newCount = Math.max(1, count - kept.length) const interval = Number(opts.installmentIntervalMonths) || 1 const startDate = String(opts.installmentStartDate || '').trim() if (!startDate) { throw new Error('请先填写分期开始时间') } const advanceDays = Number( opts.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS ) const amounts = defaultInstallmentAmounts(remaining, newCount) const maxNo = kept.reduce((m, s) => Math.max(m, Number(s.scheduleNo) || 0), 0) const created: FgdScheduleLike[] = amounts.map((amt, i) => ({ scheduleNo: maxNo + 1 + i, dueAmount: amt, dueDate: calcFqInstallmentDueDate(startDate, i, interval, advanceDays), scheduleStatus: 'open', transfers: [] })) const schedules = [...kept, ...created] return { ...pay, payMode: 'fq', installmentCount: schedules.length, installmentIntervalMonths: interval, installmentStartDate: startDate, installmentAdvanceDays: advanceDays, schedules, installmentRatios: schedules.map((s) => { const n = Number(s.dueAmount) return Number.isFinite(n) && n > 0 ? n : 0 }) } } /** 分期:最后一期应缴 = 合同 − 前 N−1 期;无法计算返回 null;负值钳为 0 */ export function calcLastPeriodDueAmount( contractAmount: number | null | undefined, priorAmounts: Array ): number | null { // Number(null) === 0;须先排除空值,否则会误算尾期 if (contractAmount == null) return null const contract = Number(contractAmount) if (!Number.isFinite(contract)) return null if (!priorAmounts.length) return null let anyFilled = false let sumCents = 0 for (const raw of priorAmounts) { if (raw === null || raw === undefined || raw === '') continue const n = Number(raw) if (!Number.isFinite(n)) continue anyFilled = true sumCents += Math.round(n * 100) } if (!anyFilled) return null const last = Math.round(contract * 100 - sumCents) / 100 return last < 0 ? 0 : last } export function shouldAutoFillLastPeriodDue(opts: { lastManualOverride: boolean lastLocked: boolean }): boolean { return !opts.lastManualOverride && !opts.lastLocked } /** 趸交:将截止时间写入未作废批次中的最后一期 dueDate;锁定则跳过 */ export function applyLumpSumDeadlineToLastDueDate( schedules: T[], deadline: string | null | undefined, isLocked: (sch: T) => boolean ): T[] { const due = String(deadline || '').trim() const activeIdx: number[] = [] schedules.forEach((s, i) => { if (String(s.scheduleStatus || 'open') !== 'void') activeIdx.push(i) }) if (!activeIdx.length) return schedules.map((s) => ({ ...s })) const lastIdx = activeIdx[activeIdx.length - 1] if (isLocked(schedules[lastIdx])) { return schedules.map((s) => ({ ...s })) } return schedules.map((s, i) => (i === lastIdx ? { ...s, dueDate: due } : { ...s })) }