feat(mobile-oa-h5): port protocol sample payment helpers

Share web-aligned installment autofill and mode-switch helpers for
mobile protocol forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-13 15:28:39 +08:00
1 parent be22c37196
commit 66e1c8334c
3 files changed
+544

No files matched your search

@@ -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
}
@@ -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 || [])]
}
})
}
}
@@ -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, /不一致/)
})
})