// web-admin/src/utils/protocol-sample-payment.test.ts import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { parsePreferentialPrice, resolveSampleContractAmount, resolvePerSamplePaymentDefaults, shouldReplaceContractWithPreferentialPrice, sampleScheduleRequiredFieldsHint, sampleScheduleDuesMismatchHint, sumSampleContractAmounts, schedulePaidArrears, planFqToFgdMerge, applyFqToFgdMergeToPayment, applyConsolidateRemainingToPayment, applyExpandRemainingToInstallment, availableSamplePayModes, samplePaymentHasConfirmedTransfer, isSamplePaymentTransferLocked, samplePaymentHasLockedTransfer, calcLastPeriodDueAmount, shouldAutoFillLastPeriodDue, applyLumpSumDeadlineToLastDueDate } from './protocol-sample-payment' describe('parsePreferentialPrice', () => { it('reads sample.price as preferential amount', () => { assert.equal(parsePreferentialPrice({ price: 20000 }), 20000) assert.equal(parsePreferentialPrice({ price: '22000' }), 22000) }) it('null/empty → null', () => { assert.equal(parsePreferentialPrice({ price: null }), null) assert.equal(parsePreferentialPrice({ price: '' }), null) assert.equal(parsePreferentialPrice({}), null) assert.equal(parsePreferentialPrice(null), null) }) }) describe('resolveSampleContractAmount', () => { it('uses preferential price when set', () => { assert.equal(resolveSampleContractAmount({ price: 20000 }), 20000) assert.equal(resolveSampleContractAmount({ price: 22000 }), 22000) }) it('no fallback to hardcoded price table', () => { assert.equal(resolveSampleContractAmount({}), null) assert.equal(resolveSampleContractAmount({ price: null }), null) }) }) describe('resolvePerSamplePaymentDefaults', () => { it('dj defaults contract to preferential price', () => { const d = resolvePerSamplePaymentDefaults({ price: 20000 }, 'dj') assert.equal(d.contractAmount, 20000) assert.equal(d.installmentCount, undefined) }) it('fq has structural count but empty installment amounts', () => { const d = resolvePerSamplePaymentDefaults({ price: 20000 }, 'fq') assert.equal(d.contractAmount, 20000) assert.equal(d.installmentCount, 6) assert.deepEqual(d.installmentAmounts, []) }) }) describe('shouldReplaceContractWithPreferentialPrice', () => { it('only replaces null/empty contract', () => { assert.equal(shouldReplaceContractWithPreferentialPrice(null, 20000), true) assert.equal(shouldReplaceContractWithPreferentialPrice(22880, 20000), false) assert.equal(shouldReplaceContractWithPreferentialPrice(18000, 20000), false) }) }) describe('sampleScheduleRequiredFieldsHint', () => { it('requires amount and dueDate for each fq period', () => { const hint = sampleScheduleRequiredFieldsHint({ sampleCode: 'A1', payMode: 'fq', contractAmount: 20000, schedules: [ { dueAmount: null, dueDate: '20261001', scheduleStatus: 'open' }, { dueAmount: 10000, dueDate: '', scheduleStatus: 'open' } ] }) assert.ok(hint) assert.match(hint!, /应缴金额/) }) it('ok when all periods filled', () => { assert.equal( sampleScheduleRequiredFieldsHint({ payMode: 'fq', contractAmount: 20000, schedules: [ { dueAmount: 10000, dueDate: '20261001' }, { dueAmount: 10000, dueDate: '20261101' } ] }), null ) }) }) describe('sampleScheduleDuesMismatchHint', () => { it('sum must equal contract', () => { const hint = sampleScheduleDuesMismatchHint({ payMode: 'fq', contractAmount: 20000, schedules: [ { dueAmount: 10000, dueDate: '20261001' }, { dueAmount: 5000, dueDate: '20261101' } ] }) assert.ok(hint) }) }) describe('sumSampleContractAmounts', () => { it('two samples 20000+22000 → 42000', () => { const payments = [ { contractAmount: resolveSampleContractAmount({ price: 20000 }) }, { contractAmount: resolveSampleContractAmount({ price: 22000 }) } ] assert.equal(sumSampleContractAmounts(payments), 42000) }) }) describe('schedulePaidArrears', () => { it('display paid includes only yjf', () => { const { paid, arrears } = schedulePaidArrears(25880, [ { payStatus: 'dfj', transferAmount: 480 }, { payStatus: 'dfj', transferAmount: 6000 }, { payStatus: 'jfqrz', transferAmount: 100 }, { payStatus: 'yjf', transferAmount: 200 }, { payStatus: 'ytf', transferAmount: 50 } ]) assert.equal(paid, 200) assert.equal(arrears, 25680) }) }) describe('planFqToFgdMerge', () => { it('keeps paid periods and merges remaining into first unpaid', () => { const schedules = [ { id: 's1', scheduleNo: 1, dueAmount: 5000, scheduleStatus: 'cleared', transfers: [{ payStatus: 'yjf', transferAmount: 5000 }] }, { id: 's2', scheduleNo: 2, dueAmount: 5000, scheduleStatus: 'cleared', transfers: [{ payStatus: 'yjf', transferAmount: 5000 }] }, { id: 's3', scheduleNo: 3, dueAmount: 5000, scheduleStatus: 'open', transfers: [] }, { id: 's4', scheduleNo: 4, dueAmount: 5000, scheduleStatus: 'open', transfers: [] }, { id: 's5', scheduleNo: 5, dueAmount: 5000, scheduleStatus: 'open', transfers: [] }, { id: 's6', scheduleNo: 6, dueAmount: 4800, scheduleStatus: 'open', transfers: [] } ] const plan = planFqToFgdMerge(schedules) assert.equal(plan.targetScheduleId, 's3') assert.equal(plan.targetScheduleNo, 3) assert.equal(plan.newDueAmount, 19800) assert.deepEqual(plan.softDeleteScheduleIds, ['s4', 's5', 's6']) assert.equal(plan.newInstallmentCount, 3) }) it('rejects when a later period already has confirmed transfer', () => { assert.throws( () => planFqToFgdMerge([ { id: 's1', scheduleNo: 1, dueAmount: 1000, transfers: [] }, { id: 's2', scheduleNo: 2, dueAmount: 1000, transfers: [{ payStatus: 'yjf', transferAmount: 1000 }] } ]), /后续期/ ) }) }) describe('applyFqToFgdMergeToPayment', () => { it('updates payMode/count and drops later unpaid schedules', () => { const next = applyFqToFgdMergeToPayment({ payMode: 'fq', installmentCount: 6, contractAmount: 29800, schedules: [ { id: 's1', scheduleNo: 1, dueAmount: 5000, scheduleStatus: 'cleared', transfers: [{ payStatus: 'yjf', transferAmount: 5000 }] }, { id: 's2', scheduleNo: 2, dueAmount: 5000, scheduleStatus: 'cleared', transfers: [{ payStatus: 'yjf', transferAmount: 5000 }] }, { id: 's3', scheduleNo: 3, dueAmount: 5000, scheduleStatus: 'open', transfers: [] }, { id: 's4', scheduleNo: 4, dueAmount: 5000, scheduleStatus: 'open', transfers: [] }, { id: 's5', scheduleNo: 5, dueAmount: 5000, scheduleStatus: 'open', transfers: [] }, { id: 's6', scheduleNo: 6, dueAmount: 4800, scheduleStatus: 'open', transfers: [] } ] }) assert.equal(next.payMode, 'fgd') assert.equal(next.installmentCount, 3) assert.equal(next.schedules.length, 3) assert.equal(next.schedules[2].dueAmount, 19800) assert.deepEqual( next.schedules.map((s: { scheduleNo: number }) => s.scheduleNo), [1, 2, 3] ) }) }) describe('availableSamplePayModes', () => { it('fq → dj/fgd allowed', () => { assert.deepEqual(availableSamplePayModes({ payMode: 'fq' }), ['fq', 'dj', 'fgd']) }) it('dj cannot switch to fgd', () => { assert.deepEqual(availableSamplePayModes({ payMode: 'dj' }), ['dj', 'fq']) }) it('fgd cannot switch to dj', () => { assert.deepEqual(availableSamplePayModes({ payMode: 'fgd' }), ['fgd', 'fq']) }) }) describe('samplePaymentHasLockedTransfer', () => { it('locks jfqrz / yjf / tfqrz / ytf like backend', () => { for (const payStatus of ['jfqrz', 'yjf', 'tfqrz', 'ytf']) { assert.equal(isSamplePaymentTransferLocked(payStatus), true) assert.equal( samplePaymentHasLockedTransfer({ schedules: [{ transfers: [{ payStatus }] }] }), true ) } }) it('dfj / empty are not locked', () => { assert.equal(isSamplePaymentTransferLocked('dfj'), false) assert.equal(isSamplePaymentTransferLocked(''), false) assert.equal( samplePaymentHasLockedTransfer({ schedules: [{ transfers: [{ payStatus: 'dfj' }] }] }), false ) }) }) describe('applyConsolidateRemainingToPayment', () => { it('fq→dj keeps paid and consolidates remaining', () => { const next = applyConsolidateRemainingToPayment( { payMode: 'fq', installmentCount: 6, contractAmount: 29800, schedules: [ { id: 's1', scheduleNo: 1, dueAmount: 5000, transfers: [{ payStatus: 'yjf', transferAmount: 5000 }] }, { id: 's2', scheduleNo: 2, dueAmount: 5000, transfers: [{ payStatus: 'yjf', transferAmount: 5000 }] }, { id: 's3', scheduleNo: 3, dueAmount: 5000, transfers: [] }, { id: 's4', scheduleNo: 4, dueAmount: 5000, transfers: [] }, { id: 's5', scheduleNo: 5, dueAmount: 5000, transfers: [] }, { id: 's6', scheduleNo: 6, dueAmount: 4800, transfers: [] } ] }, 'dj' ) assert.equal(next.payMode, 'dj') assert.equal(next.installmentCount, 3) assert.equal(next.schedules.length, 3) assert.equal(next.schedules[2].dueAmount, 19800) }) }) describe('applyExpandRemainingToInstallment', () => { it('dj partial paid → keep paid period and split remaining', () => { const next = applyExpandRemainingToInstallment( { payMode: 'dj', contractAmount: 12000, schedules: [ { id: 's1', scheduleNo: 1, dueAmount: 12000, transfers: [{ payStatus: 'yjf', transferAmount: 4000 }] } ] }, { installmentCount: 4, installmentIntervalMonths: 1, installmentStartDate: '20261001', installmentAdvanceDays: 0 } ) assert.equal(next.payMode, 'fq') assert.equal(next.schedules[0].dueAmount, 4000) assert.equal(next.schedules.length, 4) // 1 kept + 3 new (4-1) const newSum = next.schedules.slice(1).reduce((s, x) => s + Number(x.dueAmount), 0) assert.equal(newSum, 8000) assert.equal(next.schedules[1].dueDate, '20261001') }) it('rejects when already cleared', () => { assert.throws( () => applyExpandRemainingToInstallment( { payMode: 'dj', contractAmount: 1000, schedules: [ { scheduleNo: 1, dueAmount: 1000, transfers: [{ payStatus: 'yjf', transferAmount: 1000 }] } ] }, { installmentCount: 6, installmentIntervalMonths: 1, installmentStartDate: '20261001' } ), /已缴清/ ) }) }) describe('samplePaymentHasConfirmedTransfer', () => { it('detects yjf', () => { assert.equal( samplePaymentHasConfirmedTransfer({ schedules: [{ transfers: [{ payStatus: 'dfj' }] }] }), false ) assert.equal( samplePaymentHasConfirmedTransfer({ schedules: [{ transfers: [{ payStatus: 'yjf', transferAmount: 1 }] }] }), true ) }) }) describe('calcLastPeriodDueAmount', () => { it('last = contract − sum(priors), cents remainder on last', () => { assert.equal(calcLastPeriodDueAmount(100, [33.33, 33.33]), 33.34) assert.equal(calcLastPeriodDueAmount(20000, [10000, 5000]), 5000) }) it('returns null when contract invalid', () => { assert.equal(calcLastPeriodDueAmount(null, [1000]), null) assert.equal(calcLastPeriodDueAmount(NaN, [1000]), null) }) it('returns null when all priors empty', () => { assert.equal(calcLastPeriodDueAmount(20000, [null, '', undefined]), null) }) it('treats empty slot as 0 when at least one prior filled', () => { assert.equal(calcLastPeriodDueAmount(20000, [10000, null, '']), 10000) }) it('clamps negative result to 0', () => { assert.equal(calcLastPeriodDueAmount(1000, [800, 800]), 0) }) }) describe('shouldAutoFillLastPeriodDue', () => { it('blocks when override or locked', () => { assert.equal( shouldAutoFillLastPeriodDue({ lastManualOverride: true, lastLocked: false }), false ) assert.equal( shouldAutoFillLastPeriodDue({ lastManualOverride: false, lastLocked: true }), false ) }) it('allows when neither override nor locked', () => { assert.equal( shouldAutoFillLastPeriodDue({ lastManualOverride: false, lastLocked: false }), true ) }) }) describe('applyLumpSumDeadlineToLastDueDate', () => { it('writes deadline to last unlocked schedule', () => { const next = applyLumpSumDeadlineToLastDueDate( [ { scheduleNo: 1, dueDate: '', scheduleStatus: 'open' }, { scheduleNo: 2, dueDate: '20260101', scheduleStatus: 'open' } ], '20261231', (s) => false ) assert.equal(next[0].dueDate, '') assert.equal(next[1].dueDate, '20261231') }) it('clears dueDate when deadline empty', () => { const next = applyLumpSumDeadlineToLastDueDate( [{ scheduleNo: 1, dueDate: '20261231', scheduleStatus: 'open' }], '', () => false ) assert.equal(next[0].dueDate, '') }) it('skips when last schedule locked', () => { const next = applyLumpSumDeadlineToLastDueDate( [{ scheduleNo: 1, dueDate: '20260101', scheduleStatus: 'open' }], '20261231', () => true ) assert.equal(next[0].dueDate, '20260101') }) it('ignores void schedules when picking last', () => { const next = applyLumpSumDeadlineToLastDueDate( [ { scheduleNo: 1, dueDate: '', scheduleStatus: 'open' }, { scheduleNo: 2, dueDate: 'old', scheduleStatus: 'void' } ], '20261231', () => false ) assert.equal(next[0].dueDate, '20261231') assert.equal(next[1].dueDate, 'old') }) })