Files
zkxw/web-admin/src/utils/protocol-fq-params-change.ts
T
zlwuandCursor a6f56f3453 feat(web-admin): rename transfer pay statuses to 付款 wording
Use 已付款/付款确认中/付款完成 for payStatus; keep period money
labels 应缴费/已缴费/欠缴费 unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 18:10:13 +08:00

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
}