/** * 采集协议单 · 缴费模式切换(dj / fq / djfq) * 规则见 docs/superpowers/specs/2026-06-04-protocol-pay-mode-switch-design.md */ import { calcFqInstallmentDueDate, DEFAULT_INSTALLMENT_ADVANCE_DAYS, defaultInstallmentAmounts } from '@/utils/protocol-payment-installment' import { type PayMode, isInstallmentPayMode } from '@/utils/protocol-payment-presets' export type { PayMode } export type DetailLike = Record const LOCKED_STATUSES = new Set(['jfqrz', 'yjf', 'tfqrz', 'ytf']) const BLOCKING_STATUSES = new Set(['jfqrz', 'tfqrz', 'ytf']) export interface PayModeSwitchContext { contractAmount: number | null lumpSumDeadline: string installmentCount: number | null installmentIntervalMonths: number | null installmentStartDate: string installmentAdvanceDays: number | null installmentRatios: number[] } export type EvaluateResult = | { action: 'noop' } | { action: 'blocked'; reason: string } | { action: 'confirm'; summary: string } export function isPayDetailLocked(detail: DetailLike): boolean { if (detail._payModeLocked) return true return LOCKED_STATUSES.has(String(detail.payStatus || '')) } export function markPayDetailLocks(details: DetailLike[]): void { for (const d of details) { d._payModeLocked = isPayDetailLocked(d) } } export function sumPaidAmount(details: DetailLike[]): number { return details .filter((d) => String(d.payStatus) === 'yjf') .reduce((sum, d) => sum + (Number(d.dueAmount) || 0), 0) } export function calcRemainingAmount(contractAmount: number | null, details: DetailLike[]): number { if (contractAmount == null) return 0 const remaining = Number(contractAmount) - sumPaidAmount(details) return Math.round(remaining * 100) / 100 } /** 趸交:应缴合计与合同金额不一致时返回提醒文案(不阻断保存) */ export function djDueAmountMismatchHint( contractAmount: number | null | undefined, details: DetailLike[] ): string | null { if (contractAmount == null || Number(contractAmount) === 0) return null const totalDue = details.reduce((sum, d) => sum + (Number(d.dueAmount) || 0), 0) const contract = Number(contractAmount) if (Math.abs(totalDue - contract) > 0.015) { return `趸交各明细应缴金额之和应为 ${contract.toFixed(2)},当前为 ${totalDue.toFixed(2)},请核对` } return null } function formatMoneySummary(v: number): string { return `¥${v.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}` } function activeDetails(details: DetailLike[]): DetailLike[] { return details.filter((d) => !d._voided) } function hasBlockingStatus(details: DetailLike[]): boolean { return details.some((d) => BLOCKING_STATUSES.has(String(d.payStatus || ''))) } function countByStatus(details: DetailLike[], status: string): number { return details.filter((d) => String(d.payStatus || 'dfj') === status).length } function buildInstallmentDetails( ctx: PayModeSwitchContext, rows: DetailLike[] ): DetailLike[] { const count = Number(ctx.installmentCount) || 6 const interval = Number(ctx.installmentIntervalMonths) || 6 const startDate = ctx.installmentStartDate || '' const advanceDays = Number(ctx.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS) const amounts = ctx.installmentRatios?.length && sumPaidAmount(rows) === 0 ? ctx.installmentRatios.map((v) => Number(v || 0)) : defaultInstallmentAmounts(ctx.contractAmount, count) const result: DetailLike[] = [] for (let i = 0; i < count; i++) { result.push( createPaymentDetailRow({ dueAmount: amounts[i] ?? null, installmentNo: i + 1, installmentDueDate: startDate && interval ? calcFqInstallmentDueDate(startDate, i, interval, advanceDays) : '', expanded: i === 0 }) ) } return result } export function createPaymentDetailRow(options: { dueAmount: number | null installmentDueDate?: string installmentNo?: number | null payStatus?: string expanded?: boolean }): DetailLike { const now = new Date() const pad = (n: number) => String(n).padStart(2, '0') const defaultTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` return { transferType: '', dueAmount: options.dueAmount, transferAmount: null, transferTime: defaultTime, payStatus: options.payStatus ?? 'dfj', installmentNo: options.installmentNo ?? null, installmentDueDate: options.installmentDueDate ?? '', _expanded: options.expanded ?? false, _payModeLocked: false, attachments: [] } } export function evaluatePayModeSwitch( fromMode: PayMode, toMode: PayMode, details: DetailLike[], ctx: PayModeSwitchContext ): EvaluateResult { if (fromMode === toMode) return { action: 'noop' } const rows = activeDetails(details) if (hasBlockingStatus(rows)) { return { action: 'blocked', reason: '存在缴费确认中或退费处理中的明细,请先完成相关流程后再切换缴费模式。' } } if (fromMode === 'dj' && isInstallmentPayMode(toMode)) { if (rows.some((d) => isPayDetailLocked(d))) { return { action: 'blocked', reason: '趸交已有缴费记录,无法改为分期。' } } const count = ctx.installmentCount || 6 const label = toMode === 'djfq' ? '趸交+分期' : '分期' return { action: 'confirm', summary: `将清除当前趸交计划,按 ${count} 期${label}重新生成缴费明细。是否继续?` } } if (isInstallmentPayMode(fromMode) && toMode === 'dj') { const dfjCount = countByStatus(rows, 'dfj') const yjfCount = countByStatus(rows, 'yjf') if (yjfCount > 0 && dfjCount === 0) { return { action: 'blocked', reason: '分期已全部缴清,无需改为趸交。' } } const remaining = calcRemainingAmount(ctx.contractAmount, rows) if (yjfCount > 0 && remaining <= 0) { return { action: 'blocked', reason: '已缴清,无需改为趸交。' } } if (yjfCount > 0) { return { action: 'confirm', summary: `将作废 ${dfjCount} 期未缴计划,保留 ${yjfCount} 期已缴记录,并新增 1 条趸交明细(剩余 ${formatMoneySummary(remaining)})。是否继续?` } } return { action: 'confirm', summary: `将作废 ${dfjCount || rows.length} 期分期计划,并合并为 1 条趸交明细。是否继续?` } } if (isInstallmentPayMode(fromMode) && isInstallmentPayMode(toMode) && fromMode !== toMode) { if (rows.some((d) => isPayDetailLocked(d))) { return { action: 'blocked', reason: '分期已有缴费记录,无法切换分期类型。' } } const count = ctx.installmentCount || 6 const label = toMode === 'djfq' ? '趸交+分期' : '分期' return { action: 'confirm', summary: `将按 ${count} 期${label}重新生成缴费明细。是否继续?` } } return { action: 'noop' } } export function applyPayModeSwitch( fromMode: PayMode, toMode: PayMode, details: DetailLike[], ctx: PayModeSwitchContext ): DetailLike[] { const rows = activeDetails(details) if (fromMode === 'dj' && isInstallmentPayMode(toMode)) { return buildInstallmentDetails(ctx, rows) } if (isInstallmentPayMode(fromMode) && toMode === 'dj') { const yjfRows = rows .filter((d) => String(d.payStatus) === 'yjf') .map((d) => ({ ...d, _payModeLocked: true })) const remaining = calcRemainingAmount(ctx.contractAmount, yjfRows) const deadline = ctx.lumpSumDeadline || '' const dueAmount = yjfRows.length ? remaining : ctx.contractAmount const newDjRow = createPaymentDetailRow({ dueAmount, installmentNo: null, installmentDueDate: deadline, expanded: yjfRows.length === 0 }) return [...yjfRows, newDjRow] } if (isInstallmentPayMode(fromMode) && isInstallmentPayMode(toMode) && fromMode !== toMode) { return buildInstallmentDetails(ctx, rows) } return rows } /** 趸交与分期均允许多条缴费明细(合计须等于合同金额) */ export function canAddPaymentDetail(_payMode: string, _details: DetailLike[]): boolean { return true }