304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
/** 分期缴费:期数对齐、应缴日/金额计算(与 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)
|
||
}
|