/** 分期缴费:期数对齐、应缴日/金额计算(与 ProtocolFormDialog 逻辑一致) */ import { isInstallmentPayMode } from '@/utils/protocol-payment-presets' const DAYS_PER_MONTH = 30 export const DEFAULT_INSTALLMENT_ADVANCE_DAYS = 30 export const DEFAULT_INSTALLMENT_RATIOS_6 = [50, 10, 10, 10, 10, 10] export function defaultInstallmentRatios(count: number): number[] { if (count <= 0) return [] if (count === 6) return [...DEFAULT_INSTALLMENT_RATIOS_6] const base = Math.floor(100 / count) const arr = Array(count - 1).fill(base) arr.push(100 - base * (count - 1)) return arr } export function resizeInstallmentRatios(oldRatios: number[], newCount: number): number[] { if (newCount <= 0) return [] if (newCount > oldRatios.length) { return [...oldRatios, ...Array(newCount - oldRatios.length).fill(0)] } if (newCount < oldRatios.length) { return oldRatios.slice(0, newCount) } return [...oldRatios] } /** 末位 = 100 - sum(前 n-1);返回新数组 */ export function finalizeInstallmentRatios(ratios: number[]): number[] { if (!ratios.length) return [] if (ratios.length === 1) return [100] const head = ratios.slice(0, -1).map((v) => Number(v || 0)) const last = Math.round((100 - head.reduce((s, v) => s + v, 0)) * 100) / 100 return [...head, last] } export function isInstallmentRatiosValid(ratios: number[]): boolean { if (!ratios.length) return false const head = ratios.slice(0, -1) const headSum = head.reduce((s, v) => s + Number(v || 0), 0) if (headSum > 100 + 0.001) return false const total = ratios.reduce((s, v) => s + Number(v || 0), 0) return Math.abs(total - 100) < 0.01 } /** 历史数据:installmentRatios 存的是百分比(合计约 100) */ export function isLegacyInstallmentRatios( values: number[], contractAmount?: number | null ): boolean { if (!values.length) return false const nums = values.map((v) => Number(v || 0)) const sum = nums.reduce((s, v) => s + v, 0) const max = Math.max(...nums) if (contractAmount != null && Math.abs(sum - Number(contractAmount)) < 0.02) { return false } return max <= 100 && Math.abs(sum - 100) < 0.05 } /** 将存储值规范为各期金额(元);兼容旧比例数据 */ export function normalizeInstallmentAmounts( values: number[], contractAmount: number | null | undefined, count: number ): number[] { if (!values.length) { return defaultInstallmentAmounts(contractAmount, count) } if (isLegacyInstallmentRatios(values, contractAmount)) { const sized = values.length === count ? values : resizeInstallmentRatios(values, count) return calcInstallmentDueAmountsByRatios(contractAmount, finalizeInstallmentRatios(sized)) } const amounts = values.map((v) => Number(v || 0)) if (amounts.length === count) return amounts return resizeInstallmentAmounts(amounts, count, contractAmount) } export function defaultInstallmentAmounts( contractAmount: number | null | undefined, count: number ): number[] { return calcInstallmentDueAmounts(contractAmount, count) } export function resizeInstallmentAmounts( oldAmounts: number[], newCount: number, contractAmount: number | null | undefined ): number[] { if (newCount <= 0) return [] const head = oldAmounts.map((v) => Number(v || 0)) if (newCount > head.length) { const existingSum = sumInstallmentAmounts(head) const remaining = contractAmount != null ? Math.max(0, Math.round((Number(contractAmount) - existingSum) * 100) / 100) : 0 const extraCount = newCount - head.length const extra = remaining > 0 && contractAmount != null ? defaultInstallmentAmounts(remaining, extraCount) : Array(extraCount).fill(0) return [...head, ...extra] } if (newCount < head.length) { return head.slice(0, newCount) } return head } export function isInstallmentAmountsValid( amounts: number[], contractAmount: number | null | undefined ): boolean { if (!amounts.length || contractAmount == null) return false if (amounts.some((v) => Number(v) < 0)) return false const sum = amounts.reduce((s, v) => s + Number(v || 0), 0) return Math.abs(sum - Number(contractAmount)) < 0.01 } export function sumInstallmentAmounts(amounts: number[]): number { const total = amounts.reduce((s, v) => s + Number(v || 0), 0) return Math.round(total * 100) / 100 } /** 合同金额变更时,按原各期占比等比缩放金额 */ export function scaleInstallmentAmountsToContract( amounts: number[], contractAmount: number | null | undefined ): number[] { if (!amounts.length || contractAmount == null) return amounts const oldSum = sumInstallmentAmounts(amounts) if (oldSum <= 0) { return defaultInstallmentAmounts(contractAmount, amounts.length) } const target = Number(contractAmount) const scaled: number[] = [] let sumCents = 0 const totalCents = Math.round(target * 100) for (let i = 0; i < amounts.length; i++) { if (i === amounts.length - 1) { scaled.push((totalCents - sumCents) / 100) } else { const cents = Math.floor((totalCents * Number(amounts[i] || 0)) / oldSum) scaled.push(cents / 100) sumCents += cents } } return scaled } export function addMonthsYmd(dateStr: string, months: number): string { const compact = String(dateStr || '').replace(/-/g, '') if (compact.length !== 8) return '' const y = parseInt(compact.slice(0, 4), 10) const m = parseInt(compact.slice(4, 6), 10) - 1 const d = parseInt(compact.slice(6, 8), 10) const date = new Date(y, m + months, d) const yy = date.getFullYear() const mm = String(date.getMonth() + 1).padStart(2, '0') const dd = String(date.getDate()).padStart(2, '0') return `${yy}${mm}${dd}` } /** 日期加 N 天,返回 YYYYMMDD(入参支持 YYYYMMDD 或 YYYY-MM-DD) */ export function addDaysYmd(dateStr: string, days: number): string { const compact = String(dateStr || '').replace(/-/g, '') if (compact.length !== 8) return '' const y = parseInt(compact.slice(0, 4), 10) const m = parseInt(compact.slice(4, 6), 10) - 1 const d = parseInt(compact.slice(6, 8), 10) const date = new Date(y, m, d) date.setDate(date.getDate() + days) const yy = date.getFullYear() const mm = String(date.getMonth() + 1).padStart(2, '0') const dd = String(date.getDate()).padStart(2, '0') return `${yy}${mm}${dd}` } /** 整数部分按日历加月,小数部分按 30 天/月换算为天数 */ export function addIntervalMonthsYmd(dateStr: string, totalMonths: number): string { const compact = String(dateStr || '').replace(/-/g, '') if (compact.length !== 8 || !Number.isFinite(totalMonths) || totalMonths === 0) { return compact.length === 8 ? compact : '' } const wholeMonths = Math.trunc(totalMonths) const fracMonths = totalMonths - wholeMonths let result = compact if (wholeMonths !== 0) { result = addMonthsYmd(result, wholeMonths) } if (fracMonths !== 0) { const days = Math.round(fracMonths * DAYS_PER_MONTH) if (days !== 0) { result = addDaysYmd(result, days) } } return result } /** 分期应缴费时间:第 1 期=开始时间,第 2 期起在间隔递增基础上提前 advanceDays 天 */ export function calcFqInstallmentDueDate( startDate: string, index: number, intervalMonths: number, advanceDays: number = DEFAULT_INSTALLMENT_ADVANCE_DAYS ): string { const base = addIntervalMonthsYmd(startDate, index * intervalMonths) if (index >= 1) { return addDaysYmd(base, -Number(advanceDays || 0)) } return base } export function calcInstallmentDueAmounts( contractAmount: number | null | undefined, count: number ): number[] { if (contractAmount == null || !count || count < 1) return [] const totalCents = Math.round(Number(contractAmount) * 100) const baseCents = Math.floor(totalCents / count) const amounts: number[] = [] let sumCents = 0 for (let i = 0; i < count; i++) { if (i === count - 1) { amounts.push((totalCents - sumCents) / 100) } else { amounts.push(baseCents / 100) sumCents += baseCents } } return amounts } export function calcInstallmentDueAmountsByRatios( contractAmount: number | null | undefined, ratios: number[] ): number[] { if (contractAmount == null || !ratios.length) return [] const totalCents = Math.round(Number(contractAmount) * 100) const amounts: number[] = [] let sumCents = 0 for (let i = 0; i < ratios.length; i++) { if (i === ratios.length - 1) { amounts.push((totalCents - sumCents) / 100) } else { const cents = Math.floor((totalCents * Number(ratios[i] || 0)) / 100) amounts.push(cents / 100) sumCents += cents } } return amounts } export function calcUnpaidAmountsByRatios( remaining: number, ratios: number[], unpaidIndices: number[] ): number[] { if (remaining <= 0 || !unpaidIndices.length) return [] const subset = unpaidIndices.map((i) => Number(ratios[i] || 0)) const ratioSum = subset.reduce((s, v) => s + v, 0) if (ratioSum <= 0) return [] const totalCents = Math.round(Number(remaining) * 100) const amounts: number[] = [] let sumCents = 0 for (let j = 0; j < unpaidIndices.length; j++) { if (j === unpaidIndices.length - 1) { amounts.push((totalCents - sumCents) / 100) } else { const cents = Math.floor((totalCents * subset[j]) / ratioSum) amounts.push(cents / 100) sumCents += cents } } return amounts } type PaymentLike = Record | null | undefined type DetailLike = Record function resolveFqAmounts(payment: PaymentLike, count: number, details?: DetailLike[]): number[] { const contract = payment?.contractAmount != null ? Number(payment.contractAmount) : null const stored = Array.isArray(payment?.installmentRatios) ? (payment!.installmentRatios as number[]) : [] if (stored.length === count && contract != null) { const baseAmounts = isLegacyInstallmentRatios(stored, contract) ? calcInstallmentDueAmountsByRatios(contract, finalizeInstallmentRatios(stored)) : stored.map((v) => Number(v || 0)) const paidRows = (details || []).filter((d) => String(d.payStatus) === 'yjf') if (paidRows.length > 0) { const amounts = [...baseAmounts] paidRows.forEach((d) => { const no = Number(d.installmentNo) if (no >= 1 && no <= count) amounts[no - 1] = Number(d.dueAmount) || 0 }) return amounts } return baseAmounts } return calcInstallmentDueAmounts(contract, count) } /** * 分期模式下按 installmentCount 补齐缺失期次(保留已有明细数据),仅用于编辑表单。 */ export function alignFqPaymentDetailsForDisplay( payment: PaymentLike, details: DetailLike[] ): DetailLike[] { if (!payment || !isInstallmentPayMode(String(payment.payMode || ''))) return details const count = Number(payment.installmentCount) if (!count || count < 1) return details const rows = [...details] const byNo = new Map() const unnumbered: DetailLike[] = [] for (const row of rows) { const no = row.installmentNo if (no != null && no !== '') { byNo.set(Number(no), row) } else { unnumbered.push(row) } } const needed = new Set(Array.from({ length: count }, (_, i) => i + 1)) const have = new Set(byNo.keys()) let missing = false for (const n of needed) { if (!have.has(n)) { missing = true break } } if (!missing && rows.length >= count) { return sortFqPaymentDetails(rows).slice(0, count) } const amounts = resolveFqAmounts(payment, count, rows) const startDate = String(payment.installmentStartDate || '') const interval = Number(payment.installmentIntervalMonths || 0) const advanceDays = Number(payment.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS) const result: DetailLike[] = [] for (let i = 0; i < count; i++) { const no = i + 1 if (byNo.has(no)) { result.push(byNo.get(no)!) continue } if (i < unnumbered.length) { const raw = { ...unnumbered[i], installmentNo: no } if (!raw.installmentDueDate && startDate && interval) { raw.installmentDueDate = calcFqInstallmentDueDate(startDate, i, interval, advanceDays) } result.push(raw) continue } result.push({ installmentNo: no, installmentDueDate: startDate && interval ? calcFqInstallmentDueDate(startDate, i, interval, advanceDays) : '', dueAmount: amounts[i] ?? null, payStatus: 'dfj', transferType: '', transferAmount: null, attachments: [] }) } return result } export function sortFqPaymentDetails(details: DetailLike[]): DetailLike[] { return [...details].sort((a, b) => { const na = a.installmentNo != null && a.installmentNo !== '' ? Number(a.installmentNo) : 9999 const nb = b.installmentNo != null && b.installmentNo !== '' ? Number(b.installmentNo) : 9999 if (na !== nb) return na - nb return Number(a.orderNum || 0) - Number(b.orderNum || 0) }) } /** 编辑表单:将缴费明细行数与分期期数对齐(不覆盖已有行) */ export function syncFqPaymentDetailRowCount( payment: { payMode: string installmentCount: number | null contractAmount: number | null installmentStartDate: string installmentIntervalMonths: number | null installmentAdvanceDays?: number | null installmentRatios?: number[] }, paymentDetails: DetailLike[] ): void { if (!isInstallmentPayMode(payment.payMode)) return const count = Number(payment.installmentCount) if (!count || count < 1) return const aligned = alignFqPaymentDetailsForDisplay(payment, paymentDetails) paymentDetails.splice(0, paymentDetails.length, ...aligned) }