Files
zkxw/web-admin/src/utils/protocol-payment-installment.ts
T
2026-06-11 15:42:58 +08:00

304 lines
10 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.
/** 分期缴费:期数对齐、应缴日/金额计算(与 ProtocolFormDialog 逻辑一致) */
const DAYS_PER_MONTH = 30
export const DEFAULT_INSTALLMENT_ADVANCE_DAYS = 30
export const DEFAULT_INSTALLMENT_RATIOS_6 = [50, 10, 10, 10, 10, 10]
export function defaultInstallmentRatios(count: number): number[] {
if (count <= 0) return []
if (count === 6) return [...DEFAULT_INSTALLMENT_RATIOS_6]
const base = Math.floor(100 / count)
const arr = Array(count - 1).fill(base)
arr.push(100 - base * (count - 1))
return arr
}
export function resizeInstallmentRatios(oldRatios: number[], newCount: number): number[] {
if (newCount <= 0) return []
if (newCount > oldRatios.length) {
return [...oldRatios, ...Array(newCount - oldRatios.length).fill(0)]
}
if (newCount < oldRatios.length) {
return oldRatios.slice(0, newCount)
}
return [...oldRatios]
}
/** 末位 = 100 - sum(前 n-1);返回新数组 */
export function finalizeInstallmentRatios(ratios: number[]): number[] {
if (!ratios.length) return []
if (ratios.length === 1) return [100]
const head = ratios.slice(0, -1).map((v) => Number(v || 0))
const last = Math.round((100 - head.reduce((s, v) => s + v, 0)) * 100) / 100
return [...head, last]
}
export function isInstallmentRatiosValid(ratios: number[]): boolean {
if (!ratios.length) return false
const head = ratios.slice(0, -1)
const headSum = head.reduce((s, v) => s + Number(v || 0), 0)
if (headSum > 100 + 0.001) return false
const total = ratios.reduce((s, v) => s + Number(v || 0), 0)
return Math.abs(total - 100) < 0.01
}
export function addMonthsYmd(dateStr: string, months: number): string {
const compact = String(dateStr || '').replace(/-/g, '')
if (compact.length !== 8) return ''
const y = parseInt(compact.slice(0, 4), 10)
const m = parseInt(compact.slice(4, 6), 10) - 1
const d = parseInt(compact.slice(6, 8), 10)
const date = new Date(y, m + months, d)
const yy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
return `${yy}${mm}${dd}`
}
/** 日期加 N 天,返回 YYYYMMDD(入参支持 YYYYMMDD 或 YYYY-MM-DD) */
export function addDaysYmd(dateStr: string, days: number): string {
const compact = String(dateStr || '').replace(/-/g, '')
if (compact.length !== 8) return ''
const y = parseInt(compact.slice(0, 4), 10)
const m = parseInt(compact.slice(4, 6), 10) - 1
const d = parseInt(compact.slice(6, 8), 10)
const date = new Date(y, m, d)
date.setDate(date.getDate() + days)
const yy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
return `${yy}${mm}${dd}`
}
/** 整数部分按日历加月,小数部分按 30 天/月换算为天数 */
export function addIntervalMonthsYmd(dateStr: string, totalMonths: number): string {
const compact = String(dateStr || '').replace(/-/g, '')
if (compact.length !== 8 || !Number.isFinite(totalMonths) || totalMonths === 0) {
return compact.length === 8 ? compact : ''
}
const wholeMonths = Math.trunc(totalMonths)
const fracMonths = totalMonths - wholeMonths
let result = compact
if (wholeMonths !== 0) {
result = addMonthsYmd(result, wholeMonths)
}
if (fracMonths !== 0) {
const days = Math.round(fracMonths * DAYS_PER_MONTH)
if (days !== 0) {
result = addDaysYmd(result, days)
}
}
return result
}
/** 分期应缴费时间:第 1 期=开始时间,第 2 期起在间隔递增基础上提前 advanceDays 天 */
export function calcFqInstallmentDueDate(
startDate: string,
index: number,
intervalMonths: number,
advanceDays: number = DEFAULT_INSTALLMENT_ADVANCE_DAYS
): string {
const base = addIntervalMonthsYmd(startDate, index * intervalMonths)
if (index >= 1) {
return addDaysYmd(base, -Number(advanceDays || 0))
}
return base
}
export function calcInstallmentDueAmounts(
contractAmount: number | null | undefined,
count: number
): number[] {
if (contractAmount == null || !count || count < 1) return []
const totalCents = Math.round(Number(contractAmount) * 100)
const baseCents = Math.floor(totalCents / count)
const amounts: number[] = []
let sumCents = 0
for (let i = 0; i < count; i++) {
if (i === count - 1) {
amounts.push((totalCents - sumCents) / 100)
} else {
amounts.push(baseCents / 100)
sumCents += baseCents
}
}
return amounts
}
export function calcInstallmentDueAmountsByRatios(
contractAmount: number | null | undefined,
ratios: number[]
): number[] {
if (contractAmount == null || !ratios.length) return []
const totalCents = Math.round(Number(contractAmount) * 100)
const amounts: number[] = []
let sumCents = 0
for (let i = 0; i < ratios.length; i++) {
if (i === ratios.length - 1) {
amounts.push((totalCents - sumCents) / 100)
} else {
const cents = Math.floor((totalCents * Number(ratios[i] || 0)) / 100)
amounts.push(cents / 100)
sumCents += cents
}
}
return amounts
}
export function calcUnpaidAmountsByRatios(
remaining: number,
ratios: number[],
unpaidIndices: number[]
): number[] {
if (remaining <= 0 || !unpaidIndices.length) return []
const subset = unpaidIndices.map((i) => Number(ratios[i] || 0))
const ratioSum = subset.reduce((s, v) => s + v, 0)
if (ratioSum <= 0) return []
const totalCents = Math.round(Number(remaining) * 100)
const amounts: number[] = []
let sumCents = 0
for (let j = 0; j < unpaidIndices.length; j++) {
if (j === unpaidIndices.length - 1) {
amounts.push((totalCents - sumCents) / 100)
} else {
const cents = Math.floor((totalCents * subset[j]) / ratioSum)
amounts.push(cents / 100)
sumCents += cents
}
}
return amounts
}
type PaymentLike = Record<string, unknown> | null | undefined
type DetailLike = Record<string, any>
function resolveFqAmounts(payment: PaymentLike, count: number, details?: DetailLike[]): number[] {
const contract = payment?.contractAmount != null ? Number(payment.contractAmount) : null
const ratios = Array.isArray(payment?.installmentRatios)
? (payment!.installmentRatios as number[])
: []
if (ratios.length === count && contract != null) {
const paidRows = (details || []).filter((d) => String(d.payStatus) === 'yjf')
if (paidRows.length > 0) {
const paidSum = paidRows.reduce((s, d) => s + (Number(d.dueAmount) || 0), 0)
const remaining = Math.round((contract - paidSum) * 100) / 100
const unpaidIndices = Array.from({ length: count }, (_, i) => i).filter(
(i) => !paidRows.some((d) => Number(d.installmentNo) === i + 1)
)
const unpaidAmounts = calcUnpaidAmountsByRatios(remaining, ratios, unpaidIndices)
const amounts = Array(count).fill(0)
unpaidIndices.forEach((idx, j) => {
amounts[idx] = unpaidAmounts[j] ?? 0
})
paidRows.forEach((d) => {
const no = Number(d.installmentNo)
if (no >= 1 && no <= count) amounts[no - 1] = Number(d.dueAmount) || 0
})
return amounts
}
return calcInstallmentDueAmountsByRatios(contract, ratios)
}
return calcInstallmentDueAmounts(contract, count)
}
/**
* 分期模式下按 installmentCount 补齐缺失期次(保留已有明细数据),仅用于编辑表单。
*/
export function alignFqPaymentDetailsForDisplay(
payment: PaymentLike,
details: DetailLike[]
): DetailLike[] {
if (!payment || payment.payMode !== 'fq') return details
const count = Number(payment.installmentCount)
if (!count || count < 1) return details
const rows = [...details]
const byNo = new Map<number, DetailLike>()
const unnumbered: DetailLike[] = []
for (const row of rows) {
const no = row.installmentNo
if (no != null && no !== '') {
byNo.set(Number(no), row)
} else {
unnumbered.push(row)
}
}
const needed = new Set(Array.from({ length: count }, (_, i) => i + 1))
const have = new Set(byNo.keys())
let missing = false
for (const n of needed) {
if (!have.has(n)) {
missing = true
break
}
}
if (!missing && rows.length >= count) {
return sortFqPaymentDetails(rows).slice(0, count)
}
const amounts = resolveFqAmounts(payment, count, rows)
const startDate = String(payment.installmentStartDate || '')
const interval = Number(payment.installmentIntervalMonths || 0)
const advanceDays = Number(payment.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS)
const result: DetailLike[] = []
for (let i = 0; i < count; i++) {
const no = i + 1
if (byNo.has(no)) {
result.push(byNo.get(no)!)
continue
}
if (i < unnumbered.length) {
const raw = { ...unnumbered[i], installmentNo: no }
if (!raw.installmentDueDate && startDate && interval) {
raw.installmentDueDate = calcFqInstallmentDueDate(startDate, i, interval, advanceDays)
}
result.push(raw)
continue
}
result.push({
installmentNo: no,
installmentDueDate:
startDate && interval ? calcFqInstallmentDueDate(startDate, i, interval, advanceDays) : '',
dueAmount: amounts[i] ?? null,
payStatus: 'dfj',
transferType: '',
transferAmount: null,
attachments: []
})
}
return result
}
export function sortFqPaymentDetails(details: DetailLike[]): DetailLike[] {
return [...details].sort((a, b) => {
const na = a.installmentNo != null && a.installmentNo !== '' ? Number(a.installmentNo) : 9999
const nb = b.installmentNo != null && b.installmentNo !== '' ? Number(b.installmentNo) : 9999
if (na !== nb) return na - nb
return Number(a.orderNum || 0) - Number(b.orderNum || 0)
})
}
/** 编辑表单:将缴费明细行数与分期期数对齐(不覆盖已有行) */
export function syncFqPaymentDetailRowCount(
payment: {
payMode: string
installmentCount: number | null
contractAmount: number | null
installmentStartDate: string
installmentIntervalMonths: number | null
installmentAdvanceDays?: number | null
installmentRatios?: number[]
},
paymentDetails: DetailLike[]
): void {
if (payment.payMode !== 'fq') return
const count = Number(payment.installmentCount)
if (!count || count < 1) return
const aligned = alignFqPaymentDetailsForDisplay(payment, paymentDetails)
paymentDetails.splice(0, paymentDetails.length, ...aligned)
}