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(['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 = { 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() 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 } }