114 lines
14 KiB
TypeScript
114 lines
14 KiB
TypeScript
import type { PlatformProcurement, ProcurementInternalFee, ProcurementFile } from './procurementTypes'
|
|
import type { ProcurementCommand, ProcurementCommandBody, ProcurementFeeSelection } from './api/procurementCommands'
|
|
import { cents, decimal } from './procurementPresentation'
|
|
export const financeCommands = new Set<ProcurementCommand>(['charges/confirm', 'fees/retry', 'fees/terminate', 'fees/release-estimate', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'fees/additional'])
|
|
export type ChargePurpose = 'INTERNATIONAL_FREIGHT' | 'DUTY' | 'DOMESTIC_FREIGHT' | 'OTHER'
|
|
export interface ChargeDraft { purpose: ChargePurpose; code: string; name: string; mode: 'NEW' | 'EXISTING'; feeId: string; determination: '' | 'POSITIVE' | 'ZERO' | 'NOT_APPLICABLE'; amount: string; currency: 'CNY' | 'USD'; businessDate: string; voucherFileId: string; reason: string }
|
|
export interface AdditionalDraft { name: string; amount: string; currency: 'CNY' | 'USD'; businessDate: string; voucherFileId: string }
|
|
export interface FinanceForm { additional: AdditionalDraft; fixed: ChargeDraft[]; otherDetermination: 'NOT_APPLICABLE' | 'ITEMIZED'; others: ChargeDraft[]; feeId: string; targetOriginalAmount: string; refundAmount: string }
|
|
const titles: Record<ChargePurpose, string> = { INTERNATIONAL_FREIGHT: '实际国际运费', DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用' }
|
|
export function chargeDraft(purpose: ChargePurpose, fee?: ProcurementInternalFee, code = ''): ChargeDraft { return { purpose, code: fee?.feeItemCode || code || purpose, name: fee?.feeItemName || titles[purpose], mode: fee ? 'EXISTING' : 'NEW', feeId: fee?.id || '', determination: '', amount: '', currency: 'CNY', businessDate: '', voucherFileId: '', reason: '' } }
|
|
export function financeForm(detail: PlatformProcurement): FinanceForm { const fees = detail.fees.filter(row => row.active); const others = fees.filter(row => row.purpose === 'OTHER').map(row => chargeDraft('OTHER', row)); return { additional: { name: '', amount: '', currency: 'CNY', businessDate: '', voucherFileId: '' }, fixed: (['INTERNATIONAL_FREIGHT', 'DUTY', 'DOMESTIC_FREIGHT'] as const).map(purpose => chargeDraft(purpose, fees.find(row => row.purpose === purpose))), otherDetermination: others.length ? 'ITEMIZED' : 'NOT_APPLICABLE', others, feeId: '', targetOriginalAmount: '', refundAmount: '' } }
|
|
export function feeRetryPermission(fee: ProcurementInternalFee) { return `platform:overseas-business:purchase:${fee.confirmationOrigin === 'FINANCIAL' ? 'finance-adjust' : fee.purpose === 'SAMPLE_PAYMENT' ? 'buy' : fee.purpose === 'INTERNATIONAL_ESTIMATE' ? 'estimate' : ['ADJUSTMENT', 'REFUND'].includes(fee.purpose) ? 'finance-adjust' : 'charge'}` }
|
|
export function linked(detail: PlatformProcurement, fee: ProcurementInternalFee) { return detail.customerView.fees.some(row => row.feeNo === fee.feeNo && row.sourceLinked) }
|
|
function feeAmount(fee: ProcurementInternalFee) { const amount = cents(fee.amount); if (amount === null || amount < 0n || fee.currency !== 'CNY') throw new Error('原费用人民币金额缺失,请先核对'); return amount }
|
|
export function feeChain(detail: PlatformProcurement, selected: ProcurementInternalFee) {
|
|
const root = selected.purpose === 'ADJUSTMENT' ? detail.fees.find(row => row.id === selected.parentFeeId) : selected
|
|
if (!root || ['REFUND', 'ADJUSTMENT', 'INTERNATIONAL_ESTIMATE'].includes(root.purpose) || !root.active || !linked(detail, root)) throw new Error('原实际费用及来源关联尚未完成')
|
|
const originals = new Set([root.id, ...detail.fees.filter(row => row.purpose === 'ADJUSTMENT' && row.parentFeeId === root.id).map(row => row.id)])
|
|
const chain = detail.fees.filter(row => originals.has(row.id) || (row.purpose === 'REFUND' && !!row.parentFeeId && originals.has(row.parentFeeId)))
|
|
if (chain.some(row => row.active && !linked(detail, row))) throw new Error('该费用链仍有未完成的调整或退款,请先完成或终止原请求')
|
|
return chain
|
|
}
|
|
export function refundAvailable(detail: PlatformProcurement, fee: ProcurementInternalFee) {
|
|
feeChain(detail, fee)
|
|
if (fee.walletStatus !== 'SETTLED' || !fee.walletLedgerId || fee.purpose === 'INTERNATIONAL_ESTIMATE') throw new Error('退款必须关联已成功实扣的实际费用')
|
|
return feeAmount(fee) - detail.fees.filter(row => row.purpose === 'REFUND' && row.parentFeeId === fee.id && row.walletStatus === 'REFUNDED').reduce((sum, row) => sum + feeAmount(row), 0n)
|
|
}
|
|
export function replacementCandidates(detail: PlatformProcurement) {
|
|
return detail.fees.filter(fee => !fee.active && ['TERMINATED', 'RELEASED'].includes(fee.walletStatus) && !fee.sourceFeeId && !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)
|
|
&& !detail.fees.some(other => other.purpose === fee.purpose && other.feeItemCode === fee.feeItemCode && (other.active || other.feeVersion > fee.feeVersion)))
|
|
}
|
|
function frozenTarget(fee: ProcurementInternalFee, amount: string) {
|
|
const original = cents(amount); const rate = /^(\d+)(?:\.(\d{1,12}))?$/.exec(String(fee.exchangeRate))
|
|
if (original === null || original < 0n || !rate) throw new Error('请填写正确原币总额,并核对原汇率快照')
|
|
const denominator = 10n ** BigInt((rate[2] || '').length); const numerator = BigInt(rate[1] + (rate[2] || ''))
|
|
if (numerator <= 0n) throw new Error('原汇率快照无效')
|
|
const target = (original * numerator * 2n + denominator) / (2n * denominator)
|
|
if (target > 99999999999999999999n) throw new Error('折算人民币金额超出支持范围')
|
|
return target
|
|
}
|
|
export function replacementPreview(detail: PlatformProcurement, fee: ProcurementInternalFee, amount: string) {
|
|
if (!replacementCandidates(detail).some(row => row.id === fee.id)) throw new Error('须选择最新、已终止或释放且未实扣的实际费用')
|
|
const target = frozenTarget(fee, amount)
|
|
if (target > 9999999999999999n || cents(amount)! > 9999999999999999n) throw new Error('折算人民币或原币金额超出支持范围')
|
|
if (fee.purpose === 'SAMPLE_PAYMENT') {
|
|
const budget = detail.customerView.budget; const limit = cents(budget.totalAmount)
|
|
if (limit === null) throw new Error('客户预算尚未明确')
|
|
if ((budget.currency === fee.originalCurrency && cents(amount)! > limit) || (budget.currency === 'CNY' && target > limit)) throw new Error('替代货款超过客户授权预算,请先完成预算核验')
|
|
}
|
|
return { target: decimal(target), net: '0.00', difference: decimal(target) }
|
|
}
|
|
export function targetPreview(detail: PlatformProcurement, fee: ProcurementInternalFee, amount: string) {
|
|
if (['ADJUSTMENT', 'REFUND', 'INTERNATIONAL_ESTIMATE'].includes(fee.purpose)) throw new Error('目标更正须选择原实际费用')
|
|
const chain = feeChain(detail, fee); const target = frozenTarget(fee, amount)
|
|
const net = chain.reduce((sum, row) => row.walletStatus === 'SETTLED' ? sum + feeAmount(row) : row.walletStatus === 'REFUNDED' ? sum - feeAmount(row) : sum, 0n)
|
|
if (target - net > 9999999999999999n) throw new Error('追加人民币金额超出支持范围')
|
|
return { target: decimal(target), net: decimal(net), difference: decimal(target - net) }
|
|
}
|
|
function amount(value: string, positive = false) { const parsed = cents(value.trim()); if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(value.trim()) || parsed === null || (positive ? parsed <= 0n : parsed < 0n)) throw new Error(positive ? '请填写正额金额,最多两位小数' : '请明确填写非负金额,最多两位小数'); return value.trim() }
|
|
function select(draft: ChargeDraft, detail: PlatformProcurement, files: ProcurementFile[]): ProcurementFeeSelection {
|
|
if (draft.mode === 'NEW' && detail.operations.some(row => row.action === 'CONFIRM_CHARGES')) throw new Error('首次核费后只可引用已有费用,新发生或替代费用请使用财务操作')
|
|
if (draft.mode === 'EXISTING') {
|
|
if (!detail.fees.some(row => row.id === draft.feeId && row.active && row.purpose === draft.purpose && (draft.purpose !== 'OTHER' || row.feeItemCode === draft.code))) throw new Error(`${draft.name}引用的费用不是本单该用途的有效版本`)
|
|
return { feeId: draft.feeId }
|
|
}
|
|
if (detail.fees.some(row => row.active && row.purpose === draft.purpose && (draft.purpose !== 'OTHER' || row.feeItemCode === draft.code))) throw new Error(`${draft.name}已有有效费用,请引用原版本或先按权限更正`)
|
|
if (!draft.determination) throw new Error(`${draft.name}尚未明确核定,未知不能视为零元`)
|
|
const value = amount(draft.amount, draft.determination === 'POSITIVE')
|
|
if (draft.determination !== 'POSITIVE' && cents(value) !== 0n) throw new Error('零元或不适用必须明确记为 0.00')
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(draft.businessDate) || !Number.isFinite(Date.parse(draft.businessDate))) throw new Error(`请选择${draft.name}的业务日期`)
|
|
if (!draft.reason.trim() || draft.reason.trim().length > 500) throw new Error(`请填写${draft.name}的核定说明,最多 500 字`)
|
|
if (draft.determination === 'POSITIVE' && !files.some(file => file.fileId === draft.voucherFileId && file.category === 'VOUCHER')) throw new Error(`${draft.name}须关联本单费用凭证`)
|
|
return { value: { determination: draft.determination, amount: value, currency: draft.currency, businessDate: draft.businessDate, ...(draft.voucherFileId ? { voucherFileId: draft.voucherFileId } : {}), reason: draft.reason.trim() } }
|
|
}
|
|
export function buildFinance(command: ProcurementCommand, form: FinanceForm, detail: PlatformProcurement, files: ProcurementFile[], base: ProcurementCommandBody, voucherFileId: string): ProcurementCommandBody {
|
|
if (command === 'fees/additional') {
|
|
if (!detail.purchaseFacts.purchased_at) throw new Error('独立新增费用须关联已发生购买的订单')
|
|
const row = form.additional; const value = amount(row.amount, true)
|
|
if (!row.name.trim() || row.name.trim().length > 128) throw new Error('请填写独立费用名称,最多 128 字')
|
|
const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(row.businessDate) || !Number.isFinite(Date.parse(row.businessDate)) || row.businessDate > today) throw new Error('请选择已发生费用的真实业务日期')
|
|
if (!files.some(file => file.fileId === row.voucherFileId && file.category === 'VOUCHER')) throw new Error('请关联本单新增费用凭证')
|
|
if ((base.reason?.length || 0) > 500) throw new Error('新增费用说明最多 500 字')
|
|
return { ...base, name: row.name.trim(), amount: value, currency: row.currency, businessDate: row.businessDate, voucherFileId: row.voucherFileId }
|
|
}
|
|
if (command === 'charges/confirm') {
|
|
if (!detail.purchaseFacts.domestic_received_at) throw new Error('须完成国内收货后确认费用')
|
|
const fixed = (['INTERNATIONAL_FREIGHT', 'DUTY', 'DOMESTIC_FREIGHT'] as const).map(purpose => { const rows = form.fixed.filter(row => row.purpose === purpose); if (rows.length !== 1) throw new Error('固定费用项目不完整'); return select(rows[0], detail, files) })
|
|
if ((form.otherDetermination === 'NOT_APPLICABLE') !== (form.others.length === 0)) throw new Error('其他费用适用时须逐项核定,不适用时不保留收费行')
|
|
const codes = new Set<string>()
|
|
const otherFees = form.others.map(row => { if (!/^[A-Z][A-Z0-9_]{0,31}$/.test(row.code) || codes.has(row.code) || !row.name.trim() || row.name.trim().length > 128) throw new Error('其他费用名称必填且项目不可重复'); codes.add(row.code); return { code: row.code, name: row.name.trim(), selection: select(row, detail, files) } })
|
|
const selected = new Set([...fixed, ...otherFees.map(row => row.selection)].map(row => row.feeId).filter(Boolean))
|
|
if (detail.fees.some(row => row.active && ['INTERNATIONAL_FREIGHT', 'DUTY', 'DOMESTIC_FREIGHT', 'OTHER'].includes(row.purpose) && !selected.has(row.id))) throw new Error('不能通过省略有效费用隐藏原收费')
|
|
return { ...base, internationalFreight: fixed[0], duty: fixed[1], domesticFreight: fixed[2], otherDetermination: form.otherDetermination, otherFees }
|
|
}
|
|
const fee = detail.fees.find(row => row.id === form.feeId); if (!fee) throw new Error('请选择本单费用')
|
|
const common = { ...base, feeId: fee.id }
|
|
if (command === 'fees/retry' || command === 'fees/terminate') {
|
|
if (!fee.active || !fee.walletStatus.endsWith('_FAILED')) throw new Error('仅能处理资金结果已明确失败的有效费用,未知结果不能新建重试')
|
|
return common
|
|
}
|
|
if (command === 'fees/release-estimate') {
|
|
if (!fee.active || fee.purpose !== 'INTERNATIONAL_ESTIMATE' || !['FROZEN', 'ZERO'].includes(fee.walletStatus)) throw new Error('仅能释放已冻结或已确认零元的有效预估')
|
|
return common
|
|
}
|
|
if (!files.some(file => file.fileId === voucherFileId && file.category === 'VOUCHER')) throw new Error('请关联本单财务更正凭证')
|
|
if ((base.reason?.length || 0) > 500) throw new Error('财务更正说明最多 500 字')
|
|
if (command === 'fees/replace-failed') { const targetOriginalAmount = amount(form.targetOriginalAmount); replacementPreview(detail, fee, targetOriginalAmount); return { ...common, targetOriginalAmount, voucherFileId } }
|
|
if (command === 'fees/correct-target') { const targetOriginalAmount = amount(form.targetOriginalAmount); targetPreview(detail, fee, targetOriginalAmount); return { ...common, targetOriginalAmount, voucherFileId } }
|
|
const refund = amount(form.refundAmount, true)
|
|
if (cents(refund)! > refundAvailable(detail, fee)) throw new Error('退款超过该原扣款的剩余可退金额')
|
|
return { ...common, amount: refund, voucherFileId }
|
|
}
|