diff --git a/mobile-oa-h5/src/market/protocol-payment-installment.js b/mobile-oa-h5/src/market/protocol-payment-installment.js new file mode 100644 index 00000000..51a20453 --- /dev/null +++ b/mobile-oa-h5/src/market/protocol-payment-installment.js @@ -0,0 +1,74 @@ +/** 分期日期/均摊金额(与 web-admin protocol-payment-installment 对齐) */ + +const DAYS_PER_MONTH = 30 +export const DEFAULT_INSTALLMENT_ADVANCE_DAYS = 30 + +export function addMonthsYmd(dateStr, months) { + 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}` +} + +export function addDaysYmd(dateStr, days) { + 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}` +} + +export function addIntervalMonthsYmd(dateStr, totalMonths) { + 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 +} + +export function calcFqInstallmentDueDate( + startDate, + index, + intervalMonths, + advanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS +) { + const base = addIntervalMonthsYmd(startDate, index * intervalMonths) + if (index >= 1) return addDaysYmd(base, -Number(advanceDays || 0)) + return base +} + +export function defaultInstallmentAmounts(contractAmount, count) { + if (contractAmount == null || !count || count < 1) return [] + const totalCents = Math.round(Number(contractAmount) * 100) + const baseCents = Math.floor(totalCents / count) + const amounts = [] + 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 +} diff --git a/mobile-oa-h5/src/market/protocol-sample-payment.js b/mobile-oa-h5/src/market/protocol-sample-payment.js new file mode 100644 index 00000000..adee7a4e --- /dev/null +++ b/mobile-oa-h5/src/market/protocol-sample-payment.js @@ -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 || [])] + } + }) + } +} diff --git a/mobile-oa-h5/src/market/protocol-sample-payment.test.js b/mobile-oa-h5/src/market/protocol-sample-payment.test.js new file mode 100644 index 00000000..3ee9fb1c --- /dev/null +++ b/mobile-oa-h5/src/market/protocol-sample-payment.test.js @@ -0,0 +1,101 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + calcLastPeriodDueAmount, + shouldAutoFillLastPeriodDue, + applyLumpSumDeadlineToLastDueDate, + availableSamplePayModes, + applyConsolidateRemainingToPayment, + sampleScheduleRequiredFieldsHint, + sampleScheduleDuesMismatchHint +} from './protocol-sample-payment.js' + +describe('calcLastPeriodDueAmount', () => { + it('last = contract − priors', () => { + assert.equal(calcLastPeriodDueAmount(20000, [10000, 5000]), 5000) + assert.equal(calcLastPeriodDueAmount(100, [33.33, 33.33]), 33.34) + }) + it('returns null when contract invalid or priors empty', () => { + assert.equal(calcLastPeriodDueAmount(null, [1000]), null) + assert.equal(calcLastPeriodDueAmount(20000, [null, '']), null) + }) +}) + +describe('shouldAutoFillLastPeriodDue', () => { + it('blocks override/locked', () => { + assert.equal( + shouldAutoFillLastPeriodDue({ lastManualOverride: true, lastLocked: false }), + false + ) + assert.equal( + shouldAutoFillLastPeriodDue({ lastManualOverride: false, lastLocked: true }), + false + ) + }) + it('allows otherwise', () => { + assert.equal( + shouldAutoFillLastPeriodDue({ lastManualOverride: false, lastLocked: false }), + true + ) + }) +}) + +describe('applyLumpSumDeadlineToLastDueDate', () => { + it('writes last dueDate', () => { + const next = applyLumpSumDeadlineToLastDueDate( + [{ scheduleNo: 1, dueDate: '', scheduleStatus: 'open' }], + '20261231', + () => false + ) + assert.equal(next[0].dueDate, '20261231') + }) +}) + +describe('availableSamplePayModes', () => { + it('fq can switch to dj/fgd', () => { + assert.deepEqual(availableSamplePayModes({ payMode: 'fq' }), ['fq', 'dj', 'fgd']) + }) +}) + +describe('applyConsolidateRemainingToPayment', () => { + it('fq→fgd consolidates unpaid', () => { + const next = applyConsolidateRemainingToPayment( + { + payMode: 'fq', + installmentCount: 3, + contractAmount: 30000, + schedules: [ + { scheduleNo: 1, dueAmount: 10000, transfers: [] }, + { scheduleNo: 2, dueAmount: 10000, transfers: [] }, + { scheduleNo: 3, dueAmount: 10000, transfers: [] } + ] + }, + 'fgd' + ) + assert.equal(next.payMode, 'fgd') + assert.equal(next.schedules.length, 1) + assert.equal(next.schedules[0].dueAmount, 30000) + }) +}) + +describe('hints', () => { + it('requires fq amounts', () => { + const hint = sampleScheduleRequiredFieldsHint({ + payMode: 'fq', + sampleCode: 'S1', + schedules: [{ dueAmount: null, dueDate: '20261001' }] + }) + assert.match(hint, /应缴金额/) + }) + it('dues must match contract', () => { + const hint = sampleScheduleDuesMismatchHint({ + payMode: 'fq', + contractAmount: 20000, + schedules: [ + { dueAmount: 10000, dueDate: '20261001' }, + { dueAmount: 5000, dueDate: '20261101' } + ] + }) + assert.match(hint, /不一致/) + }) +})