锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
445 lines
14 KiB
TypeScript
445 lines
14 KiB
TypeScript
// 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')
|
||
})
|
||
})
|