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>
261 lines
8.2 KiB
TypeScript
261 lines
8.2 KiB
TypeScript
/**
|
||
* 采集协议单 · 缴费模式切换(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
|
||
}
|