import type { RowActionItem } from '@/shared/components/RowActions.vue' import type { OverseasFee } from './api/overseasFees' export const feePermissions = { list: 'platform:overseas-fee:list', edit: 'platform:finance:fee-source:edit', review: 'platform:finance:fee-source:review', } as const export const businessLabels: Record = { LASTLEG_ORDER: '尾程订单', WAREHOUSE_TRANSFER: '仓储转运', RETURN_ORDER: '退货', RELABEL_ORDER: '换标', US_TRUCK: '本土卡车', LOCAL_LOOSEBOARD: '本土散板', OVERSEAS_PROCUREMENT: '海外采购', DISCARD_ORDER: '废弃', OVERSEAS_INBOUND: '入库', WAREHOUSE_SERVICE: '库内服务', DELIVERY_ORDER: '派送', OVERSEAS_CONTAINER: '海外柜次', } export const reviewLabels: Record = { DRAFT: '草稿', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' } /** 已终结来源优先显示终态,避免保留的审核快照误导用户继续审核。 */ export function feeReviewLabel(fee: Pick) { if (fee.sourceStatus === 'VOIDED') return '已作废' if (fee.sourceStatus === 'CANCELLED') return '已取消' if (fee.sourceStatus === 'SUPERSEDED') return '已调整' return reviewLabels[fee.reviewStatus] || fee.reviewStatus } /** 失败发生于冻结、实扣或释放的不同阶段,不能一律称为扣费失败。 */ export function feeFundingErrorLabel(fee: Pick) { if (fee.walletStatus === 'FREEZE_FAILED') return '冻结失败' if (fee.walletStatus === 'RELEASE_FAILED') return '释放失败' return '扣费异常' } /** 业务分类取服务端真实单据类型,派送费项目不改变换标单归属。 */ export function feeBusinessLabel(fee: Pick) { const type = fee.displayBusinessType || fee.businessType return businessLabels[type] || type } export const walletLabels: Record = { FREEZE_PENDING: '锁定中', FROZEN: '已锁定', FREEZE_FAILED: '锁定失败', SETTLE_PENDING: '扣费中', SETTLED: '已扣费', SETTLE_FAILED: '扣费失败', RELEASE_PENDING: '释放中', RELEASED: '已释放', RELEASE_FAILED: '释放失败', } export const processingLabels: Record = { COMPLETED: '已完成', FAILED: '处理失败' } /** 余额实扣即完成;账单推送按接收回执判断,自动重试期间保留真实失败。 */ export function feeProcessingStatus(fee: Pick) { // 业务关闭不代表冻结资金已释放;失败必须保留可见,方便恢复处理。 if (fee.walletStatus === 'RELEASE_FAILED' || (['FREEZE_PENDING', 'RELEASE_PENDING'].includes(fee.walletStatus || '') && (fee.deliveryStatus === 'DEAD' || !!fee.deliveryErrorMessage?.trim()))) return 'FAILED' if (['FREEZE_PENDING', 'RELEASE_PENDING'].includes(fee.walletStatus || '')) return 'PROCESSING' if (['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus) || fee.reviewStatus === 'REJECTED') return 'CLOSED' if (fee.walletStatus === 'SETTLED') return 'COMPLETED' if (fee.deliveryStatus === 'DEAD' || (['PENDING', 'PROCESSING'].includes(fee.deliveryStatus || '') && fee.deliveryErrorMessage?.trim()) || fee.walletStatus?.endsWith('_FAILED')) return 'FAILED' if (fee.deliveryStatus === 'PENDING' || fee.deliveryStatus === 'PROCESSING' || fee.walletStatus?.endsWith('_PENDING')) return 'PROCESSING' if (fee.publicationStatus === 'PUBLISHED') return 'COMPLETED' return 'WAITING' } /** 应付完成仅代表财务已接收,不能用完成文案暗示供应商已收款。 */ export const feeProcessingLabel = (fee: OverseasFee) => feeProcessingStatus(fee) === 'COMPLETED' && fee.direction === 'PAYABLE' ? '推送成功' : processingLabels[feeProcessingStatus(fee)] || '—' /** 只提醒当前有效版本的同项目,已驳回记录仍用于解释重新录费原因。 */ export function sameProjectFees(rows: OverseasFee[], code: string, direction: string, excludeId?: string) { return rows.filter(row => row.feeItemCode === code && row.direction === direction && row.sourceStatus === 'ACTIVE' && row.sourceFeeId !== excludeId) } /** 重复收费提示同时交代原币金额和审核结果,不把不同币种直接相加。 */ export const sameProjectFeeSummary = (fee: OverseasFee) => `${feeMoney(fee.originalAmount, fee.originalCurrency)} · ${reviewLabels[fee.reviewStatus] || fee.reviewStatus}` export const retryResultLabels: Record = { SUCCESS: '重试成功', FAILED: '重试后仍失败', SUBMITTED: '已提交重试' } export const actionLabels: Record = { REVIEW: '审核', APPROVE: '审核通过', REJECT: '驳回', RETRY_FREEZE: '重试锁定', RETRY_SETTLE: '重试扣费', RETRY_RELEASE: '重试释放', SUBMIT: '提交审核', VOID: '作废', ADJUST: '调整', RETRY_PUBLISH: '重试推送', RETRY_FUNDING_DELIVERY: '重试资金指令', ADJUST_RENT: '调整仓租', } export function feeStatus(fee: Pick & { collectionMode?: string | null; walletStatus?: string | null }) { if (fee.sourceStatus === 'VOIDED') return '已作废' if (fee.sourceStatus === 'SUPERSEDED') return '已调整' if (fee.sourceStatus === 'CANCELLED') return '已取消' if (fee.collectionMode === 'WALLET' && fee.walletStatus === 'SETTLED') return '已扣费' if (fee.deliveryKind === 'FUNDING' && fee.deliveryStatus === 'DEAD') return '资金指令发送失败' if (fee.deliveryKind === 'FEE' && fee.deliveryStatus) { if (fee.deliveryStatus === 'DEAD') return '推送失败' if (fee.deliveryStatus !== 'PUBLISHED') return fee.collectionMode === 'WALLET' ? '已扣费,待推送' : '推送中' } if (fee.collectionMode === 'BILL') return fee.publicationStatus === 'PUBLISHED' ? '已推送结算' : '待推送结算' return fee.walletStatus ? walletLabels[fee.walletStatus] || fee.walletStatus : fee.collectionMode === 'WALLET' ? '待锁定' : '—' } export function feeActions(fee: OverseasFee, can: (permission: string) => boolean, busy = false): RowActionItem[] { const closed = fee.reviewStatus === 'REJECTED' || ['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus) const canReview = can(feePermissions.review) const visibleActions = fee.allowedActions.filter(action => action in actionLabels && !['APPROVE', 'REJECT', 'CANCEL'].includes(action)) .filter(action => !closed || ['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'].includes(action)) .filter(action => can(['VOID'].includes(action) ? feePermissions.review : feePermissions.edit)) .map(action => ({ key: action, label: actionLabels[action]!, priority: 20, tone: ['VOID'].includes(action) ? 'danger' as const : 'primary' as const, disabled: busy })) // 审核通过与驳回共用一个入口;取消不在业务单据费用行提供。 if (!closed && canReview && fee.allowedActions.some(action => ['APPROVE', 'REJECT'].includes(action))) { visibleActions.push({ key: 'REVIEW', label: actionLabels.REVIEW!, priority: 90, tone: 'primary', disabled: busy }) } return [{ key: 'DETAIL', label: '详情', priority: 100, disabled: busy }, ...visibleActions] } export function feeMoney(amount: number | string | null | undefined, currency = '') { if (amount == null) return '—' return `${currency} ${Number(amount).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`.trim() } export function feeTime(value?: string | null) { if (!value) return '—' // 只有服务端明确标记时区的时间才转换,兼容旧的无时区数据。 if (/(?:Z|[+-]\d{2}:\d{2})$/i.test(value)) { const date = new Date(value) if (!Number.isNaN(date.getTime())) { const pad = (part: number) => String(part).padStart(2, '0') return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` } } return value.replace('T', ' ').slice(0, 19) }