This commit is contained in:
zlwu committed 2026-06-04 12:57:59 +08:00
1 parent afd517ce10
commit eec61cb5dd
82 files changed
+16585 -9026

No files matched your search

@@ -0,0 +1,216 @@
/**
* 采集协议单 · 缴费模式切换(dj ↔ fq)
* 规则见 docs/superpowers/specs/2026-06-04-protocol-pay-mode-switch-design.md
*/
import {
calcFqInstallmentDueDate,
calcInstallmentDueAmounts
} from '@/utils/protocol-payment-installment'
export type PayMode = 'dj' | 'fq'
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
}
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
}
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
}
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' && toMode === 'fq') {
if (rows.some((d) => isPayDetailLocked(d))) {
return {
action: 'blocked',
reason: '趸交已有缴费记录,无法改为分期。'
}
}
const count = ctx.installmentCount || 6
return {
action: 'confirm',
summary: `将清除当前趸交计划,按 ${count} 期分期重新生成缴费明细。是否继续?`
}
}
if (fromMode === 'fq' && 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 条趸交明细。是否继续?`
}
}
return { action: 'noop' }
}
export function applyPayModeSwitch(
fromMode: PayMode,
toMode: PayMode,
details: DetailLike[],
ctx: PayModeSwitchContext
): DetailLike[] {
const rows = activeDetails(details)
if (fromMode === 'dj' && toMode === 'fq') {
const count = Number(ctx.installmentCount) || 6
const interval = Number(ctx.installmentIntervalMonths) || 6
const startDate = ctx.installmentStartDate || ''
const amounts = calcInstallmentDueAmounts(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) : '',
expanded: i === 0
})
)
}
return result
}
if (fromMode === 'fq' && 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]
}
return rows
}
/** 趸交模式下是否还可添加待缴明细 */
export function canAddPaymentDetail(payMode: string, details: DetailLike[]): boolean {
if (payMode === 'fq') return true
const dfjCount = details.filter(
(d) => !isPayDetailLocked(d) && String(d.payStatus || 'dfj') === 'dfj'
).length
return dfjCount < 1
}