Use 已付款/付款确认中/付款完成 for payStatus; keep period money labels 应缴费/已缴费/欠缴费 unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
244 lines
7.9 KiB
TypeScript
244 lines
7.9 KiB
TypeScript
/**
|
|
* 分期参数变更(期数 / 间隔 / 开始时间 / 提前天数 / 比例),非缴费模式切换
|
|
* 规则见 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
|
|
}
|