Files
zkxw/web-admin/src/utils/protocol-sample-payment.ts
T
zlwuandCursor 6ec4ec6730 feat(web-admin): sync lump-sum deadline to last due date
Add applyLumpSumDeadlineToLastDueDate for dj payment schedules,
skipping locked or void rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:50:41 +08:00

462 lines
15 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.
/** 按样本缴费 · 工具函数(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 }))
}