Add applyLumpSumDeadlineToLastDueDate for dj payment schedules, skipping locked or void rows. Co-authored-by: Cursor <cursoragent@cursor.com>
462 lines
15 KiB
TypeScript
462 lines
15 KiB
TypeScript
/** 按样本缴费 · 工具函数(payment_model_version=2)
|
||
* 合同金额唯一来源:样本优惠价 price;不再使用硬编码价目表。
|
||
*/
|
||
|
||
import {
|
||
calcFqInstallmentDueDate,
|
||
defaultInstallmentAmounts,
|
||
DEFAULT_INSTALLMENT_ADVANCE_DAYS
|
||
} from '@/utils/protocol-payment-installment'
|
||
|
||
export type SamplePaymentLike = {
|
||
contractAmount?: number | null
|
||
sampleCode?: string
|
||
payMode?: string
|
||
schedules?: ScheduleLike[]
|
||
}
|
||
|
||
export type SamplePriceLike = {
|
||
price?: number | string | null
|
||
}
|
||
|
||
export type TransferLike = {
|
||
payStatus?: string
|
||
transferAmount?: number | null
|
||
}
|
||
|
||
export type ScheduleLike = {
|
||
dueAmount?: number | null
|
||
dueDate?: string | null
|
||
scheduleStatus?: string
|
||
transfers?: TransferLike[]
|
||
}
|
||
|
||
export function sumSampleContractAmounts(payments: SamplePaymentLike[]): number {
|
||
return payments.reduce((s, p) => s + (Number(p.contractAmount) || 0), 0)
|
||
}
|
||
|
||
/** 样本优惠价格(元);未填返回 null */
|
||
export function parsePreferentialPrice(
|
||
sample: SamplePriceLike | null | undefined
|
||
): number | null {
|
||
if (sample == null) return null
|
||
const raw = sample.price
|
||
if (raw == null || raw === '') return null
|
||
const n = Number(raw)
|
||
if (!Number.isFinite(n) || n < 0) return null
|
||
return Math.round(n * 100) / 100
|
||
}
|
||
|
||
export function protocolTotalMismatchHint(
|
||
total: number | null | undefined,
|
||
sampleSum: number
|
||
): string | null {
|
||
if (total == null || Number.isNaN(Number(total))) return null
|
||
if (Math.abs(Number(total) - sampleSum) > 0.015) {
|
||
return `协议总金额 ${Number(total).toFixed(2)} 与样本合同合计 ${sampleSum.toFixed(2)} 不一致,请核对`
|
||
}
|
||
return null
|
||
}
|
||
|
||
function activeSchedules(pay: SamplePaymentLike): ScheduleLike[] {
|
||
return (pay.schedules || []).filter((s) => String(s.scheduleStatus || 'open') !== 'void')
|
||
}
|
||
|
||
/** 分期/分改趸:每期应缴金额与应缴时间必填 */
|
||
export function sampleScheduleRequiredFieldsHint(pay: SamplePaymentLike): string | null {
|
||
const mode = String(pay.payMode || '')
|
||
if (mode !== 'fq' && mode !== 'fgd' && mode !== 'djfq') return null
|
||
const schedules = activeSchedules(pay)
|
||
if (!schedules.length) return null
|
||
const code = String(pay.sampleCode || '').trim()
|
||
const prefix = code ? `样本 ${code} ` : ''
|
||
for (let i = 0; i < schedules.length; i++) {
|
||
const s = schedules[i]
|
||
const amt = Number(s.dueAmount)
|
||
if (!Number.isFinite(amt) || amt <= 0) {
|
||
return `${prefix}第 ${i + 1} 期应缴金额必须填写`
|
||
}
|
||
if (!String(s.dueDate || '').trim()) {
|
||
return `${prefix}第 ${i + 1} 期应缴时间必须填写`
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
/** 单样本:未作废期应缴合计须等于合同金额;不一致返回提示文案 */
|
||
export function sampleScheduleDuesMismatchHint(pay: SamplePaymentLike): string | null {
|
||
const contract = Number(pay.contractAmount)
|
||
if (!Number.isFinite(contract)) return null
|
||
const schedules = activeSchedules(pay)
|
||
if (!schedules.length) return null
|
||
const sum = schedules.reduce((acc, x) => acc + (Number(x.dueAmount) || 0), 0)
|
||
if (Math.abs(contract - sum) > 0.015) {
|
||
const code = String(pay.sampleCode || '').trim()
|
||
const prefix = code ? `样本 ${code} ` : ''
|
||
return `${prefix}合同金额 ${contract.toFixed(2)} 与各期应缴合计 ${sum.toFixed(2)} 不一致,请调整后再保存`
|
||
}
|
||
return null
|
||
}
|
||
|
||
function sumTransferAmountByStatuses(
|
||
transfers: TransferLike[] | undefined,
|
||
statuses: Set<string>
|
||
): number {
|
||
return (transfers || [])
|
||
.filter((t) => statuses.has(String(t.payStatus || '')))
|
||
.reduce((s, t) => s + (Number(t.transferAmount) || 0), 0)
|
||
}
|
||
|
||
/** 财务已确认口径(仅 yjf):用于分改趸并期等与后端 plan_fq_to_fgd_merge 对齐 */
|
||
export function scheduleFinanceConfirmedPaidArrears(
|
||
dueAmount: number,
|
||
transfers: TransferLike[]
|
||
): { paid: number; arrears: number } {
|
||
const paid =
|
||
Math.round(sumTransferAmountByStatuses(transfers, new Set(['yjf'])) * 100) / 100
|
||
return { paid, arrears: Math.round((dueAmount - paid) * 100) / 100 }
|
||
}
|
||
|
||
/**
|
||
* 期次已缴/欠缴(展示):已缴 = 仅 yjf;欠缴 = max(应缴 − 已缴, 0)
|
||
* 与后端 summarize_period / chase_period_money 一致
|
||
*/
|
||
export function schedulePaidArrears(
|
||
dueAmount: number,
|
||
transfers: TransferLike[]
|
||
): { paid: number; arrears: number } {
|
||
const { paid, arrears } = scheduleFinanceConfirmedPaidArrears(dueAmount, transfers)
|
||
return { paid, arrears: arrears > 0 ? arrears : 0 }
|
||
}
|
||
|
||
/** 单样本合同金额 = 优惠价格;未填优惠价则 null */
|
||
export function resolveSampleContractAmount(
|
||
sample: SamplePriceLike | null | undefined
|
||
): number | null {
|
||
return parsePreferentialPrice(sample)
|
||
}
|
||
|
||
/** 新建/切换缴费模式:合同取优惠价;分期不预填各期金额 */
|
||
export function resolvePerSamplePaymentDefaults(
|
||
sample: SamplePriceLike | null | undefined,
|
||
payMode: 'dj' | 'fq'
|
||
): {
|
||
contractAmount: number | null
|
||
installmentCount?: number
|
||
installmentAmounts?: number[]
|
||
} {
|
||
const contractAmount = resolveSampleContractAmount(sample)
|
||
if (payMode === 'fq') {
|
||
return {
|
||
contractAmount,
|
||
installmentCount: 6,
|
||
installmentAmounts: []
|
||
}
|
||
}
|
||
return { contractAmount }
|
||
}
|
||
|
||
/**
|
||
* 是否用优惠价覆盖合同:仅合同为空时(打开编辑不覆盖已有库内金额)。
|
||
* 用户改优惠价时由调用方 force=true 强制同步。
|
||
*/
|
||
export function shouldReplaceContractWithPreferentialPrice(
|
||
contractAmount: number | null | undefined,
|
||
preferentialPrice: number | null
|
||
): boolean {
|
||
if (preferentialPrice == null) return false
|
||
if (contractAmount == null || !Number.isFinite(Number(contractAmount))) return true
|
||
return false
|
||
}
|
||
|
||
export function parseInstallmentRatios(raw: unknown): number[] {
|
||
if (Array.isArray(raw)) return raw.map((v) => Number(v) || 0)
|
||
if (typeof raw === 'string' && raw.trim()) {
|
||
try {
|
||
const parsed = JSON.parse(raw)
|
||
if (Array.isArray(parsed)) return parsed.map((v) => Number(v) || 0)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
return []
|
||
}
|
||
|
||
export type FgdScheduleLike = ScheduleLike & {
|
||
id?: string
|
||
scheduleNo?: number
|
||
_deleted?: boolean
|
||
}
|
||
|
||
export type FgdMergePlan = {
|
||
targetScheduleId: string
|
||
targetScheduleNo: number
|
||
newDueAmount: number
|
||
softDeleteScheduleIds: string[]
|
||
softDeleteScheduleNos: number[]
|
||
newInstallmentCount: number
|
||
}
|
||
|
||
function roundMoney(n: number): number {
|
||
return Math.round(n * 100) / 100
|
||
}
|
||
|
||
/** fq → fgd:保留已缴期,剩余欠缴并入第一期未清期;软删其后各期(与后端 plan_fq_to_fgd_merge 对齐) */
|
||
export function planFqToFgdMerge(schedules: FgdScheduleLike[]): FgdMergePlan {
|
||
const schList = (schedules || [])
|
||
.filter((s) => String(s.scheduleStatus || 'open') !== 'void' && !s._deleted)
|
||
.slice()
|
||
.sort((a, b) => Number(a.scheduleNo || 0) - Number(b.scheduleNo || 0))
|
||
|
||
const arrearsOf = (s: FgdScheduleLike): number => {
|
||
const { arrears } = scheduleFinanceConfirmedPaidArrears(
|
||
Number(s.dueAmount) || 0,
|
||
s.transfers || []
|
||
)
|
||
return arrears > 0 ? arrears : 0
|
||
}
|
||
|
||
let target: FgdScheduleLike | undefined
|
||
for (const s of schList) {
|
||
if (arrearsOf(s) > 0) {
|
||
target = s
|
||
break
|
||
}
|
||
}
|
||
if (!target) {
|
||
target = schList.find((s) => String(s.scheduleStatus || 'open') !== 'cleared')
|
||
}
|
||
if (!target) {
|
||
throw new Error('没有可合并的未清期次')
|
||
}
|
||
|
||
const targetNo = Number(target.scheduleNo || 0)
|
||
const later = schList.filter((s) => Number(s.scheduleNo || 0) > targetNo)
|
||
for (const s of later) {
|
||
if ((s.transfers || []).some((t) => t.payStatus === 'yjf')) {
|
||
throw new Error('后续期已有已确认转账,无法合并为分改趸')
|
||
}
|
||
}
|
||
|
||
const remain = roundMoney(schList.reduce((sum, s) => sum + arrearsOf(s), 0))
|
||
const { paid: targetConfirmed } = scheduleFinanceConfirmedPaidArrears(
|
||
Number(target.dueAmount) || 0,
|
||
target.transfers || []
|
||
)
|
||
const newDueAmount = roundMoney(targetConfirmed + remain)
|
||
const softDeleteScheduleIds = later
|
||
.map((s) => String(s.id || '').trim())
|
||
.filter(Boolean)
|
||
const softDeleteScheduleNos = later.map((s) => Number(s.scheduleNo || 0))
|
||
const softNoSet = new Set(softDeleteScheduleNos)
|
||
const kept = schList.filter((s) => !softNoSet.has(Number(s.scheduleNo || 0)))
|
||
|
||
return {
|
||
targetScheduleId: String(target.id || ''),
|
||
targetScheduleNo: targetNo,
|
||
newDueAmount,
|
||
softDeleteScheduleIds,
|
||
softDeleteScheduleNos,
|
||
newInstallmentCount: kept.length
|
||
}
|
||
}
|
||
|
||
export type SamplePayMode = 'dj' | 'fq' | 'fgd'
|
||
|
||
export function samplePaymentHasConfirmedTransfer(pay: {
|
||
schedules?: FgdScheduleLike[]
|
||
}): boolean {
|
||
return (pay.schedules || []).some((s) =>
|
||
(s.transfers || []).some((t) => t.payStatus === 'yjf')
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 下拉可选缴费模式(趸交 ↔ 分改趸 互斥,不可直转):
|
||
* - 分期:可改趸交 / 分改趸
|
||
* - 趸交:仅可改分期
|
||
* - 分改趸:仅可改分期
|
||
*/
|
||
export function availableSamplePayModes(pay: {
|
||
payMode?: string
|
||
schedules?: FgdScheduleLike[]
|
||
}): SamplePayMode[] {
|
||
const mode = String(pay.payMode || 'dj') as SamplePayMode
|
||
if (mode === 'fq') return ['fq', 'dj', 'fgd']
|
||
if (mode === 'fgd') return ['fgd', 'fq']
|
||
return ['dj', 'fq']
|
||
}
|
||
|
||
/** 将剩余欠缴并入当前未清期,软删后续未缴期(fq→dj / fq→fgd) */
|
||
export function applyConsolidateRemainingToPayment<T extends Record<string, any>>(
|
||
pay: T,
|
||
targetMode: 'dj' | 'fgd'
|
||
): T {
|
||
const plan = planFqToFgdMerge((pay.schedules || []) as FgdScheduleLike[])
|
||
const softNoSet = new Set(plan.softDeleteScheduleNos)
|
||
const schedules = ((pay.schedules || []) as FgdScheduleLike[])
|
||
.filter(
|
||
(s) =>
|
||
String(s.scheduleStatus || 'open') !== 'void' &&
|
||
!s._deleted &&
|
||
!softNoSet.has(Number(s.scheduleNo || 0))
|
||
)
|
||
.map((s) => {
|
||
if (Number(s.scheduleNo || 0) !== plan.targetScheduleNo) return { ...s }
|
||
return { ...s, dueAmount: plan.newDueAmount }
|
||
})
|
||
.sort((a, b) => Number(a.scheduleNo || 0) - Number(b.scheduleNo || 0))
|
||
|
||
return {
|
||
...pay,
|
||
payMode: targetMode,
|
||
installmentCount: plan.newInstallmentCount,
|
||
schedules,
|
||
installmentRatios: schedules.map((s) => {
|
||
const n = Number(s.dueAmount)
|
||
return Number.isFinite(n) && n > 0 ? n : 0
|
||
})
|
||
}
|
||
}
|
||
|
||
/** 将并期结果写回样本缴费方案(前端即时预览;保存时后端仍会按库内数据再执行一遍) */
|
||
export function applyFqToFgdMergeToPayment<T extends Record<string, any>>(pay: T): T {
|
||
return applyConsolidateRemainingToPayment(pay, 'fgd')
|
||
}
|
||
|
||
export type ExpandInstallmentOpts = {
|
||
installmentCount?: number | null
|
||
installmentIntervalMonths?: number | null
|
||
installmentStartDate?: string
|
||
installmentAdvanceDays?: number | null
|
||
}
|
||
|
||
/** dj/fgd → fq:保留已确认实缴期,剩余按分期参数拆成新期 */
|
||
export function applyExpandRemainingToInstallment<T extends Record<string, any>>(
|
||
pay: T,
|
||
opts: ExpandInstallmentOpts
|
||
): T {
|
||
const schList = ((pay.schedules || []) as FgdScheduleLike[])
|
||
.filter((s) => String(s.scheduleStatus || 'open') !== 'void' && !s._deleted)
|
||
.slice()
|
||
.sort((a, b) => Number(a.scheduleNo || 0) - Number(b.scheduleNo || 0))
|
||
|
||
for (const s of schList) {
|
||
if ((s.transfers || []).some((t) => t.payStatus === 'jfqrz')) {
|
||
throw new Error('存在付款确认中的转账,请先完成财务确认')
|
||
}
|
||
}
|
||
|
||
let totalPaid = 0
|
||
const kept: FgdScheduleLike[] = []
|
||
for (const s of schList) {
|
||
const { paid, arrears } = scheduleFinanceConfirmedPaidArrears(
|
||
Number(s.dueAmount) || 0,
|
||
s.transfers || []
|
||
)
|
||
totalPaid = roundMoney(totalPaid + paid)
|
||
if (paid > 0) {
|
||
kept.push({
|
||
...s,
|
||
dueAmount: arrears > 0 ? paid : s.dueAmount,
|
||
scheduleStatus: arrears > 0 ? 'cleared' : s.scheduleStatus || 'cleared',
|
||
transfers: [...(s.transfers || [])]
|
||
})
|
||
}
|
||
}
|
||
|
||
const contract = Number(pay.contractAmount)
|
||
if (!Number.isFinite(contract)) {
|
||
throw new Error('请先填写样本合同金额')
|
||
}
|
||
const remaining = roundMoney(contract - totalPaid)
|
||
if (remaining <= 0) {
|
||
throw new Error('已缴清,无法改为分期')
|
||
}
|
||
|
||
const count = Math.max(1, Number(opts.installmentCount) || 6)
|
||
const newCount = Math.max(1, count - kept.length)
|
||
const interval = Number(opts.installmentIntervalMonths) || 1
|
||
const startDate = String(opts.installmentStartDate || '').trim()
|
||
if (!startDate) {
|
||
throw new Error('请先填写分期开始时间')
|
||
}
|
||
const advanceDays = Number(
|
||
opts.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS
|
||
)
|
||
const amounts = defaultInstallmentAmounts(remaining, newCount)
|
||
const maxNo = kept.reduce((m, s) => Math.max(m, Number(s.scheduleNo) || 0), 0)
|
||
const created: FgdScheduleLike[] = amounts.map((amt, i) => ({
|
||
scheduleNo: maxNo + 1 + i,
|
||
dueAmount: amt,
|
||
dueDate: calcFqInstallmentDueDate(startDate, i, interval, advanceDays),
|
||
scheduleStatus: 'open',
|
||
transfers: []
|
||
}))
|
||
|
||
const schedules = [...kept, ...created]
|
||
return {
|
||
...pay,
|
||
payMode: 'fq',
|
||
installmentCount: schedules.length,
|
||
installmentIntervalMonths: interval,
|
||
installmentStartDate: startDate,
|
||
installmentAdvanceDays: advanceDays,
|
||
schedules,
|
||
installmentRatios: schedules.map((s) => {
|
||
const n = Number(s.dueAmount)
|
||
return Number.isFinite(n) && n > 0 ? n : 0
|
||
})
|
||
}
|
||
}
|
||
|
||
/** 分期:最后一期应缴 = 合同 − 前 N−1 期;无法计算返回 null;负值钳为 0 */
|
||
export function calcLastPeriodDueAmount(
|
||
contractAmount: number | null | undefined,
|
||
priorAmounts: Array<number | string | null | undefined>
|
||
): number | null {
|
||
// Number(null) === 0;须先排除空值,否则会误算尾期
|
||
if (contractAmount == null) return null
|
||
const contract = Number(contractAmount)
|
||
if (!Number.isFinite(contract)) return null
|
||
if (!priorAmounts.length) return null
|
||
let anyFilled = false
|
||
let sumCents = 0
|
||
for (const raw of priorAmounts) {
|
||
if (raw === null || raw === undefined || raw === '') continue
|
||
const n = Number(raw)
|
||
if (!Number.isFinite(n)) continue
|
||
anyFilled = true
|
||
sumCents += Math.round(n * 100)
|
||
}
|
||
if (!anyFilled) return null
|
||
const last = Math.round(contract * 100 - sumCents) / 100
|
||
return last < 0 ? 0 : last
|
||
}
|
||
|
||
export function shouldAutoFillLastPeriodDue(opts: {
|
||
lastManualOverride: boolean
|
||
lastLocked: boolean
|
||
}): boolean {
|
||
return !opts.lastManualOverride && !opts.lastLocked
|
||
}
|
||
|
||
/** 趸交:将截止时间写入未作废批次中的最后一期 dueDate;锁定则跳过 */
|
||
export function applyLumpSumDeadlineToLastDueDate<T extends ScheduleLike>(
|
||
schedules: T[],
|
||
deadline: string | null | undefined,
|
||
isLocked: (sch: T) => boolean
|
||
): T[] {
|
||
const due = String(deadline || '').trim()
|
||
const activeIdx: number[] = []
|
||
schedules.forEach((s, i) => {
|
||
if (String(s.scheduleStatus || 'open') !== 'void') activeIdx.push(i)
|
||
})
|
||
if (!activeIdx.length) return schedules.map((s) => ({ ...s }))
|
||
const lastIdx = activeIdx[activeIdx.length - 1]
|
||
if (isLocked(schedules[lastIdx])) {
|
||
return schedules.map((s) => ({ ...s }))
|
||
}
|
||
return schedules.map((s, i) => (i === lastIdx ? { ...s, dueDate: due } : { ...s }))
|
||
}
|