/** * 分期参数变更(期数 / 间隔 / 开始时间 / 提前天数 / 比例),非缴费模式切换 * 规则见 docs/superpowers/specs/2026-06-04-protocol-pay-mode-switch-design.md §分期参数变更 */ import { calcFqInstallmentDueDate, DEFAULT_INSTALLMENT_ADVANCE_DAYS, defaultInstallmentAmounts, normalizeInstallmentAmounts, sortFqPaymentDetails } from '@/utils/protocol-payment-installment' import { calcRemainingAmount, createPaymentDetailRow, type DetailLike, type EvaluateResult } from '@/utils/protocol-pay-mode-switch' export interface FqParams { installmentCount: number | null installmentIntervalMonths: number | null installmentStartDate: string installmentAdvanceDays: number | null installmentRatios: number[] } const BLOCKING_STATUSES = new Set(['jfqrz', 'tfqrz']) function formatMoneySummary(v: number): string { return `¥${v.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}` } function countByStatus(details: DetailLike[], status: string): number { return details.filter((d) => String(d.payStatus || 'dfj') === status).length } function ratiosEqual(a: number[], b: number[]): boolean { if (a.length !== b.length) return false return a.every((v, i) => Math.abs(Number(v) - Number(b[i])) < 0.01) } export function fqParamsSnapshot(params: FqParams): FqParams { return { installmentCount: params.installmentCount, installmentIntervalMonths: params.installmentIntervalMonths, installmentStartDate: params.installmentStartDate || '', installmentAdvanceDays: params.installmentAdvanceDays, installmentRatios: [...(params.installmentRatios || [])] } } export function isSameFqParams(a: FqParams, b: FqParams): boolean { return ( Number(a.installmentCount) === Number(b.installmentCount) && Number(a.installmentIntervalMonths) === Number(b.installmentIntervalMonths) && String(a.installmentStartDate || '') === String(b.installmentStartDate || '') && Number(a.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS) === Number(b.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS) && ratiosEqual(a.installmentRatios || [], b.installmentRatios || []) ) } export function getPaidInstallmentRows(details: DetailLike[]): DetailLike[] { return [...details] .filter((d) => String(d.payStatus) === 'yjf') .sort((a, b) => Number(a.installmentNo || 0) - Number(b.installmentNo || 0)) } function maxPaidInstallmentNo(details: DetailLike[]): number { return getPaidInstallmentRows(details).reduce( (max, d) => Math.max(max, Number(d.installmentNo || 0)), 0 ) } export function evaluateFqParamsChange( _oldParams: FqParams, newParams: FqParams, details: DetailLike[], contractAmount: number | null ): EvaluateResult { const rows = details.filter((d) => !d._voided) const newCount = Number(newParams.installmentCount) const interval = Number(newParams.installmentIntervalMonths) const startDate = String(newParams.installmentStartDate || '') if (!newCount || newCount < 1) { return { action: 'blocked', reason: '请填写有效的分期期数。' } } if (!interval || interval <= 0) { return { action: 'blocked', reason: '请填写有效的每期间隔(月)。' } } if (!startDate) { return { action: 'blocked', reason: '请填写分期开始时间。' } } if (rows.some((d) => BLOCKING_STATUSES.has(String(d.payStatus || '')))) { return { action: 'blocked', reason: '存在付款确认中或退费处理中的明细,请先完成相关流程后再修改分期参数。' } } const paidRows = getPaidInstallmentRows(rows) const maxPaidNo = maxPaidInstallmentNo(rows) if (maxPaidNo > 0 && newCount < maxPaidNo) { return { action: 'blocked', reason: `已有第 ${maxPaidNo} 期缴费记录,分期期数不能少于 ${maxPaidNo}。` } } const dfjCount = countByStatus(rows, 'dfj') if (paidRows.length === 0) { if (rows.length === 0) { return { action: 'noop' } } return { action: 'confirm', summary: `将按新参数(${newCount} 期)重新生成缴费明细,原有 ${rows.length} 条待缴计划将被替换。是否继续?` } } const remaining = calcRemainingAmount(contractAmount, rows) const newUnpaidCount = newCount - paidRows.length if (newUnpaidCount < 0) { return { action: 'blocked', reason: `已有 ${paidRows.length} 期缴费记录,分期期数不能少于 ${paidRows.length}。` } } if (remaining <= 0 && dfjCount > 0) { return { action: 'blocked', reason: '已缴清,无需调整分期计划。' } } const countChanged = Number(_oldParams.installmentCount) !== newCount if (countChanged) { return { action: 'confirm', summary: `将保留 ${paidRows.length} 期已缴记录,作废 ${dfjCount} 期未缴计划,并重新生成 ${newUnpaidCount} 期未缴明细(剩余 ${formatMoneySummary(remaining)})。是否继续?` } } return { action: 'confirm', summary: `将保留已缴记录,并更新 ${dfjCount} 期未缴计划的应缴时间与金额(剩余 ${formatMoneySummary(remaining)})。是否继续?` } } function buildFqAmounts( contractAmount: number | null, installmentAmounts: number[], paidRows: DetailLike[], count: number ): number[] { const amounts = normalizeInstallmentAmounts(installmentAmounts, contractAmount, count) if (paidRows.length === 0) { return amounts } paidRows.forEach((d) => { const no = Number(d.installmentNo) if (no >= 1 && no <= count) amounts[no - 1] = Number(d.dueAmount) || 0 }) return amounts } /** 按新分期参数重建明细:保留 yjf,作废原 dfj,生成剩余期次 */ export function applyFqParamsChange( params: FqParams, details: DetailLike[], contractAmount: number | null ): DetailLike[] { const count = Number(params.installmentCount) || 0 const interval = Number(params.installmentIntervalMonths) || 0 const startDate = params.installmentStartDate || '' const advanceDays = Number(params.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS) const installmentAmounts = normalizeInstallmentAmounts( params.installmentRatios || [], contractAmount, count ) if (!count || !interval || !startDate) return details const paidRows = getPaidInstallmentRows(details).map((d) => ({ ...d, _payModeLocked: true })) const remainingCount = count - paidRows.length const amounts = buildFqAmounts(contractAmount, installmentAmounts, paidRows, count) const result: DetailLike[] = [...paidRows] for (let i = 0; i < remainingCount; i++) { const periodIndex = paidRows.length + i result.push( createPaymentDetailRow({ dueAmount: amounts[periodIndex] ?? null, installmentNo: periodIndex + 1, installmentDueDate: calcFqInstallmentDueDate(startDate, periodIndex, interval, advanceDays), expanded: false }) ) } return sortFqPaymentDetails(result) } /** 首次填充分期参数时生成明细(无需确认) */ export function generateInitialFqDetails( params: FqParams, contractAmount: number | null ): DetailLike[] { const count = Number(params.installmentCount) || 0 const interval = Number(params.installmentIntervalMonths) || 0 const startDate = params.installmentStartDate || '' const advanceDays = Number(params.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS) const installmentAmounts = normalizeInstallmentAmounts( params.installmentRatios || [], contractAmount, count ) if (!count || !interval || !startDate) return [] const amounts = installmentAmounts const result: DetailLike[] = [] for (let i = 0; i < count; i++) { result.push( createPaymentDetailRow({ dueAmount: amounts[i] ?? null, installmentNo: i + 1, installmentDueDate: calcFqInstallmentDueDate(startDate, i, interval, advanceDays), expanded: i === 0 }) ) } return result }