|
|
@@ -0,0 +1,369 @@
|
|
|
+/** 按样本缴费 · 工具函数(对齐 web-admin protocol-sample-payment.ts) */
|
|
|
+
|
|
|
+import {
|
|
|
+ calcFqInstallmentDueDate,
|
|
|
+ defaultInstallmentAmounts,
|
|
|
+ DEFAULT_INSTALLMENT_ADVANCE_DAYS
|
|
|
+} from './protocol-payment-installment.js'
|
|
|
+
|
|
|
+export { calcFqInstallmentDueDate, DEFAULT_INSTALLMENT_ADVANCE_DAYS }
|
|
|
+
|
|
|
+export function sumSampleContractAmounts(payments) {
|
|
|
+ return (payments || []).reduce((s, p) => s + (Number(p.contractAmount) || 0), 0)
|
|
|
+}
|
|
|
+
|
|
|
+export function parsePreferentialPrice(sample) {
|
|
|
+ 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, sampleSum) {
|
|
|
+ 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) {
|
|
|
+ return (pay.schedules || []).filter((s) => String(s.scheduleStatus || 'open') !== 'void')
|
|
|
+}
|
|
|
+
|
|
|
+export function sampleScheduleRequiredFieldsHint(pay) {
|
|
|
+ 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) {
|
|
|
+ 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, statuses) {
|
|
|
+ return (transfers || [])
|
|
|
+ .filter((t) => statuses.has(String(t.payStatus || '')))
|
|
|
+ .reduce((s, t) => s + (Number(t.transferAmount) || 0), 0)
|
|
|
+}
|
|
|
+
|
|
|
+export function scheduleFinanceConfirmedPaidArrears(dueAmount, transfers) {
|
|
|
+ const paid =
|
|
|
+ Math.round(sumTransferAmountByStatuses(transfers, new Set(['yjf'])) * 100) / 100
|
|
|
+ return { paid, arrears: Math.round((dueAmount - paid) * 100) / 100 }
|
|
|
+}
|
|
|
+
|
|
|
+export function schedulePaidArrears(dueAmount, transfers) {
|
|
|
+ const { paid, arrears } = scheduleFinanceConfirmedPaidArrears(dueAmount, transfers)
|
|
|
+ return { paid, arrears: arrears > 0 ? arrears : 0 }
|
|
|
+}
|
|
|
+
|
|
|
+export function resolveSampleContractAmount(sample) {
|
|
|
+ return parsePreferentialPrice(sample)
|
|
|
+}
|
|
|
+
|
|
|
+export function resolvePerSamplePaymentDefaults(sample, payMode) {
|
|
|
+ const contractAmount = resolveSampleContractAmount(sample)
|
|
|
+ if (payMode === 'fq') {
|
|
|
+ return { contractAmount, installmentCount: 6, installmentAmounts: [] }
|
|
|
+ }
|
|
|
+ return { contractAmount }
|
|
|
+}
|
|
|
+
|
|
|
+export function shouldReplaceContractWithPreferentialPrice(contractAmount, preferentialPrice) {
|
|
|
+ if (preferentialPrice == null) return false
|
|
|
+ if (contractAmount == null || !Number.isFinite(Number(contractAmount))) return true
|
|
|
+ return false
|
|
|
+}
|
|
|
+
|
|
|
+export function parseInstallmentRatios(raw) {
|
|
|
+ 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 []
|
|
|
+}
|
|
|
+
|
|
|
+function roundMoney(n) {
|
|
|
+ return Math.round(n * 100) / 100
|
|
|
+}
|
|
|
+
|
|
|
+export function planFqToFgdMerge(schedules) {
|
|
|
+ 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) => {
|
|
|
+ const { arrears } = scheduleFinanceConfirmedPaidArrears(
|
|
|
+ Number(s.dueAmount) || 0,
|
|
|
+ s.transfers || []
|
|
|
+ )
|
|
|
+ return arrears > 0 ? arrears : 0
|
|
|
+ }
|
|
|
+
|
|
|
+ let target
|
|
|
+ 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 function samplePaymentHasConfirmedTransfer(pay) {
|
|
|
+ return (pay.schedules || []).some((s) =>
|
|
|
+ (s.transfers || []).some((t) => t.payStatus === 'yjf')
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+export function availableSamplePayModes(pay) {
|
|
|
+ const mode = String(pay.payMode || 'dj')
|
|
|
+ if (mode === 'fq') return ['fq', 'dj', 'fgd']
|
|
|
+ if (mode === 'fgd') return ['fgd', 'fq']
|
|
|
+ return ['dj', 'fq']
|
|
|
+}
|
|
|
+
|
|
|
+export function applyConsolidateRemainingToPayment(pay, targetMode) {
|
|
|
+ const plan = planFqToFgdMerge(pay.schedules || [])
|
|
|
+ const softNoSet = new Set(plan.softDeleteScheduleNos)
|
|
|
+ const schedules = (pay.schedules || [])
|
|
|
+ .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(pay) {
|
|
|
+ return applyConsolidateRemainingToPayment(pay, 'fgd')
|
|
|
+}
|
|
|
+
|
|
|
+export function applyExpandRemainingToInstallment(pay, opts) {
|
|
|
+ const schList = (pay.schedules || [])
|
|
|
+ .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 = []
|
|
|
+ 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 = 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
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export function calcLastPeriodDueAmount(contractAmount, priorAmounts) {
|
|
|
+ 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) {
|
|
|
+ return !opts.lastManualOverride && !opts.lastLocked
|
|
|
+}
|
|
|
+
|
|
|
+export function applyLumpSumDeadlineToLastDueDate(schedules, deadline, isLocked) {
|
|
|
+ const due = String(deadline || '').trim()
|
|
|
+ const activeIdx = []
|
|
|
+ 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 }))
|
|
|
+}
|
|
|
+
|
|
|
+/** 按缴费参数重建应缴批次(对齐 SamplePaymentPanel.rebuildSchedules 精简版) */
|
|
|
+export function rebuildSampleSchedules(pay) {
|
|
|
+ const mode = pay.payMode || 'dj'
|
|
|
+ if (mode === 'dj') {
|
|
|
+ const prev = (pay.schedules || []).find((s) => Number(s.scheduleNo || 0) === 1)
|
|
|
+ return {
|
|
|
+ ...pay,
|
|
|
+ schedules: [
|
|
|
+ {
|
|
|
+ id: prev?.id,
|
|
|
+ scheduleNo: 1,
|
|
|
+ dueAmount: pay.contractAmount,
|
|
|
+ dueDate: pay.lumpSumDeadline || '',
|
|
|
+ scheduleStatus: prev?.scheduleStatus || 'open',
|
|
|
+ transfers: [...(prev?.transfers || [])]
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const count = Number(pay.installmentCount) || 0
|
|
|
+ const interval = Number(pay.installmentIntervalMonths) || 0
|
|
|
+ const startDate = pay.installmentStartDate || ''
|
|
|
+ const advanceDays = Number(pay.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS)
|
|
|
+ if (!count || !interval || !startDate) {
|
|
|
+ return { ...pay, schedules: [] }
|
|
|
+ }
|
|
|
+ const prevByNo = new Map(
|
|
|
+ (pay.schedules || []).map((s) => [Number(s.scheduleNo || 0), s])
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ ...pay,
|
|
|
+ schedules: Array.from({ length: count }, (_, i) => {
|
|
|
+ const scheduleNo = i + 1
|
|
|
+ const prev = prevByNo.get(scheduleNo)
|
|
|
+ const prevAmt = prev?.dueAmount
|
|
|
+ return {
|
|
|
+ id: prev?.id,
|
|
|
+ scheduleNo,
|
|
|
+ dueAmount: prevAmt != null && Number(prevAmt) > 0 ? Number(prevAmt) : null,
|
|
|
+ dueDate: prev?.dueDate || calcFqInstallmentDueDate(startDate, i, interval, advanceDays) || '',
|
|
|
+ scheduleStatus: prev?.scheduleStatus || 'open',
|
|
|
+ transfers: [...(prev?.transfers || [])]
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|