Files
zkxw/web-admin/src/utils/protocol-pay-mode-switch.ts
T
zlwuandCursor 57487fd0dc fix(protocol): save DJ pay details without blocking on due-sum mismatch
Persist payment before samples to avoid consuming sample codes on validation failure, soft-delete samples on create rollback, and surface DJ due-amount mismatch as a hint only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 17:49:36 +08:00

261 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 采集协议单 · 缴费模式切换(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<string, any>
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
}