完善柜级费用分摊与财务来源展示

This commit is contained in:
2026-09-02 17:07:03 +08:00
parent 602674573c
commit 490fa4e2e5
9 changed files with 195 additions and 22 deletions
@@ -120,6 +120,8 @@ const feeCustomers = computed(() => {
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName })) return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
}) })
const feeBusinessReferences = computed(() => (loadingDetail.value?.items || []).map((item) => ({ const feeBusinessReferences = computed(() => (loadingDetail.value?.items || []).map((item) => ({
accountId: item.accountId,
customerName: item.customerName,
orderId: item.orderId, orderId: item.orderId,
orderNo: item.orderNo, orderNo: item.orderNo,
inboundId: item.inboundId, inboundId: item.inboundId,
+1 -1
View File
@@ -15,7 +15,7 @@
## 费用付款与凭证快速入口 ## 费用付款与凭证快速入口
- 入库费用的应付/直接已付表单在 `../unified-orders/components/InboundFeeDetailManager.vue`,柜次费用在 `../loading-lists/components/ContainerFeesPanel.vue`;对应 API 分别为 `../unified-orders/api/inboundFeeDetails.ts`、`../loading-lists/api/containerFees.ts`。 - 入库费用的应付/直接已付表单在 `../unified-orders/components/InboundFeeDetailManager.vue`,柜次费用在 `../loading-lists/components/ContainerFeesPanel.vue`;柜级公共费用在同一面板按真实客户与订单分摊,入仓码按事实选填;对应 API 分别为 `../unified-orders/api/inboundFeeDetails.ts`、`../loading-lists/api/containerFees.ts`。
- `DIRECT_PAID` 必须提交已付时间、付款方式,以及付款流水号或至少一个付款凭证;凭证由费用 API 调用 `/platform/finance/v1/vouchers` 上传为 `FINANCE_VOUCHER`,并绑定当前客户。 - `DIRECT_PAID` 必须提交已付时间、付款方式,以及付款流水号或至少一个付款凭证;凭证由费用 API 调用 `/platform/finance/v1/vouchers` 上传为 `FINANCE_VOUCHER`,并绑定当前客户。
- 后端事实入口为 Firstleg `FeePaymentSnapshotValidator`、入库/柜次费用服务,文件元数据归 File;Finance 只消费已发布的费用来源快照,不在前端或 Finance 中重建付款事实。 - 后端事实入口为 Firstleg `FeePaymentSnapshotValidator`、入库/柜次费用服务,文件元数据归 File;Finance 只消费已发布的费用来源快照,不在前端或 Finance 中重建付款事实。
@@ -246,9 +246,10 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(page).toContain('<el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button>') expect(page).toContain('<el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button>')
expect(page).toContain("@click=\"openMutation(row, 'SUBMIT')\"") expect(page).toContain("@click=\"openMutation(row, 'SUBMIT')\"")
expect(page).toContain("@click=\"openMutation(row, 'ADJUST')\"") expect(page).toContain("@click=\"openMutation(row, 'ADJUST')\"")
expect(page).toContain('!isFirstlegSource(row)') expect(page).toContain('!isUpstreamSource(row)')
expect(page).toContain('!isFirstlegSource(detail)') expect(page).toContain('!isUpstreamSource(detail)')
expect(page).toContain("['INBOUND_FEE', 'CONTAINER_FEE'].includes(type)") expect(page).toContain("['INBOUND_FEE', 'CONTAINER_FEE'].includes(type)")
expect(page).toContain("=== 'LASTLEG'")
expect(page).not.toMatch(/<template #actions[\s\S]*?<el-button[^>]*\blink\b/) expect(page).not.toMatch(/<template #actions[\s\S]*?<el-button[^>]*\blink\b/)
expect(page).toContain('white-space: nowrap') expect(page).toContain('white-space: nowrap')
}) })
@@ -126,6 +126,9 @@ function closeCustomerSelectOnScroll() {
const active = document.activeElement const active = document.activeElement
if (active instanceof HTMLElement) active.blur() if (active instanceof HTMLElement) active.blur()
} }
function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG'
}
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' }, { key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' }, { key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
@@ -144,6 +147,9 @@ function sourceTypeDisplay(value: unknown) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_COST: '整柜成本', CONTAINER_FEE: '柜次费用', INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_COST: '整柜成本', CONTAINER_FEE: '柜次费用',
MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用',
OVERSEAS_INBOUND_FEE: '海外入仓费用', OVERSEAS_PROCUREMENT_FEE: '海外采购费用',
OVERSEAS_TRUCK_FEE: '本土卡车费用', OVERSEAS_LOOSEBOARD_FEE: '本土散板费用',
OVERSEAS_WAREHOUSE_FEE: '海外仓服务费',
} }
return labels[code] || (code ? '其他费用来源' : '来源类型暂不可用') return labels[code] || (code ? '其他费用来源' : '来源类型暂不可用')
} }
@@ -251,8 +257,10 @@ async function submitReview() {
} }
async function openMutation(row: Record<string, unknown>, mode: 'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID') { async function openMutation(row: Record<string, unknown>, mode: 'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID') {
const target = row as unknown as ArapRow; selected.value = target const target = row as unknown as ArapRow; selected.value = target
if ((mode === 'EDIT' || mode === 'ADJUST') && isFirstlegSource(target)) { if ((mode === 'EDIT' || mode === 'ADJUST' || mode === 'VOID') && isUpstreamSource(target)) {
ElMessage.info('请到订单入库费用或柜次费用明细中调整,审核通过后会自动同步到财务') ElMessage.info(String(target.sourceSystem || '').toUpperCase() === 'LASTLEG'
? '请到对应海外业务费用中调整或作废,审核后会通过跨区事件同步到财务'
: '请到订单入库费用或柜次费用明细中调整或作废,审核后会自动同步到财务')
return return
} }
const loaded = await loadDetail(target) const loaded = await loadDetail(target)
@@ -364,7 +372,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<section v-if="!customersError && !loadError" class="surface-panel table-panel"> <section v-if="!customersError && !loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()"> <DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><el-button v-if="canCreateStatement" type="primary" :disabled="!selectedRows.length" @click="continueSelectedGeneration"><FilePlus2 :size="16" />生成所选对账单</el-button></div></template> <template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><el-button v-if="canCreateStatement" type="primary" :disabled="!selectedRows.length" @click="continueSelectedGeneration"><FilePlus2 :size="16" />生成所选对账单</el-button></div></template>
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canEditSource && !isFirstlegSource(row) && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain @click="openMutation(row, 'EDIT')"><Pencil :size="15" />编辑</el-button><el-button v-if="canEditSource && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain type="primary" @click="openMutation(row, 'SUBMIT')"><Send :size="15" />提交审核</el-button><el-button v-if="canReviewSource && row.reviewStatus === 'PENDING' && row.status === 'AVAILABLE'" size="small" plain type="success" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canEditSource && !isFirstlegSource(row) && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain @click="openMutation(row, 'ADJUST')"><Pencil :size="15" />调整</el-button><el-button v-if="canEditSource && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain type="danger" @click="openMutation(row, 'VOID')"><Ban :size="15" />作废</el-button></template><span v-else class="muted-action">手工成本</span></div></template> <template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canEditSource && !isUpstreamSource(row) && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain @click="openMutation(row, 'EDIT')"><Pencil :size="15" />编辑</el-button><el-button v-if="canEditSource && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain type="primary" @click="openMutation(row, 'SUBMIT')"><Send :size="15" />提交审核</el-button><el-button v-if="canReviewSource && row.reviewStatus === 'PENDING' && row.status === 'AVAILABLE'" size="small" plain type="success" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canEditSource && !isUpstreamSource(row) && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain @click="openMutation(row, 'ADJUST')"><Pencil :size="15" />调整</el-button><el-button v-if="canEditSource && !isUpstreamSource(row) && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain type="danger" @click="openMutation(row, 'VOID')"><Ban :size="15" />作废</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template> <template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template> <template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template> <template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
@@ -375,7 +383,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<AdminPage v-else as="div" class="finance-arap-detail" spacing="normal"> <AdminPage v-else as="div" class="finance-arap-detail" spacing="normal">
<section class="surface-panel finance-arap-detail__surface"> <section class="surface-panel finance-arap-detail__surface">
<DetailPageLayout class="finance-arap-detail__layout" aria-label="费用来源详情"> <DetailPageLayout class="finance-arap-detail__layout" aria-label="费用来源详情">
<template #header><div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div class="detail-title"><h2>费用来源详情</h2><p>{{ selected?.documentNo || '费用来源' }} · {{ selected ? customerName(selected.customerId, selected.customerName) : '客户名称暂不可用' }}</p></div><div class="detail-actions"><el-button v-if="canEditSource && detail && !isFirstlegSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button></div></div></template> <template #header><div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div class="detail-title"><h2>费用来源详情</h2><p>{{ selected?.documentNo || '费用来源' }} · {{ selected ? customerName(selected.customerId, selected.customerName) : '客户名称暂不可用' }}</p></div><div class="detail-actions"><el-button v-if="canEditSource && detail && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
<template v-if="detail" #summary><section class="arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><span>费用金额</span><strong>{{ money(detail.amount) }}</strong><small>CNY</small></div><div class="arap-metric"><span>收付方向</span><strong>{{ financeDirectionLabel(detail.direction) }}</strong></div><div class="arap-metric"><span>来源状态</span><strong>{{ sourceStatusDisplay(detail.sourceStatus) }}</strong></div><div class="arap-metric"><span>审核状态</span><strong>{{ statusLabel(detail.reviewStatus) }}</strong></div></section></template> <template v-if="detail" #summary><section class="arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><span>费用金额</span><strong>{{ money(detail.amount) }}</strong><small>CNY</small></div><div class="arap-metric"><span>收付方向</span><strong>{{ financeDirectionLabel(detail.direction) }}</strong></div><div class="arap-metric"><span>来源状态</span><strong>{{ sourceStatusDisplay(detail.sourceStatus) }}</strong></div><div class="arap-metric"><span>审核状态</span><strong>{{ statusLabel(detail.reviewStatus) }}</strong></div></section></template>
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState> <LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information" title="单据信息" :bordered="false"><el-descriptions :column="2" class="arap-detail"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement" title="审核与结算" :bordered="false"><el-descriptions :column="1" class="arap-detail"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log" title="操作记录" :bordered="false"><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="formatDateTime(log.occurredAt)">{{ financeOperationActionLabel(log.action) }} · {{ displayOperator(log.operatorName) }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></FormSection></template> <template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information" title="单据信息" :bordered="false"><el-descriptions :column="2" class="arap-detail"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement" title="审核与结算" :bordered="false"><el-descriptions :column="1" class="arap-detail"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log" title="操作记录" :bordered="false"><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="formatDateTime(log.occurredAt)">{{ financeOperationActionLabel(log.action) }} · {{ displayOperator(log.operatorName) }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></FormSection></template>
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http' import { http } from '@/shared/api/http'
import { adjustContainerFee, approveContainerFee, createContainerFee, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee } from './containerFees' import { adjustContainerFee, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup } from './containerFees'
import { listSuppliers } from '@/modules/suppliers' import { listSuppliers } from '@/modules/suppliers'
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() })) vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
@@ -21,6 +21,24 @@ describe('container fee API', () => {
expect(created).toMatchObject({ id: '3', loadingPlanId: '9', containerBookingId: '99', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paymentVoucherFileIds: ['9001'] }) expect(created).toMatchObject({ id: '3', loadingPlanId: '9', containerBookingId: '99', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paymentVoucherFileIds: ['9001'] })
}) })
it('writes and reviews one atomic allocation group without an accountId shortcut', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: [] })
const fee = { feeItemId: '7', direction: 'RECEIVABLE' as const, supplierId: null,
settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null,
paymentVoucherFileIds: null, businessDate: '2026-09-02', amount: 300,
currency: 'CNY' as const, remark: null }
const allocations = [
{ customerId: '11', orderId: '501', orderNo: 'FL-501', inboundId: null, inboundCode: null, amount: 120 },
{ customerId: '12', orderId: '502', orderNo: 'FL-502', inboundId: '602', inboundCode: 'IB-602', amount: 180 },
]
await createContainerFeeAllocationGroup('9', { fee, allocations })
await approveContainerFeeAllocationGroup('9', '71')
await voidContainerFeeAllocationGroup('9', '71', 1)
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/allocation-groups', { fee, allocations })
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/allocation-groups/71/approve')
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/allocation-groups/71/void', { expectedSourceVersion: 1 })
})
it('uploads a payment voucher in the current customer scope and rejects a mismatched echo', async () => { it('uploads a payment voucher in the current customer scope and rejects a mismatched echo', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValueOnce({ code: '200', data: { fileId: 9001, tenantId: 11, fileName: '付款凭证.png' } }) const post = vi.spyOn(http, 'post').mockResolvedValueOnce({ code: '200', data: { fileId: 9001, tenantId: 11, fileName: '付款凭证.png' } })
await expect(uploadContainerFeePaymentVoucher('11', new File(['x'], '付款凭证.png'))).resolves.toEqual({ fileId: '9001', fileName: '付款凭证.png' }) await expect(uploadContainerFeePaymentVoucher('11', new File(['x'], '付款凭证.png'))).resolves.toEqual({ fileId: '9001', fileName: '付款凭证.png' })
+60 -1
View File
@@ -9,6 +9,13 @@ export type ContainerFeePaymentMethod = 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'O
export interface ContainerFeeResource { export interface ContainerFeeResource {
id: string id: string
loadingPlanId: string loadingPlanId: string
customerId?: string
allocationGroupId?: string | null
allocationOrderId?: string | null
allocationOrderNo?: string | null
allocationInboundId?: string | null
allocationInboundCode?: string | null
allocationGroupAmount?: number | null
containerBookingId: string | null containerBookingId: string | null
containerNo: string | null containerNo: string | null
feeItemId: string feeItemId: string
@@ -55,6 +62,27 @@ export interface ContainerFeeAdjustRequest {
amount: number amount: number
} }
export interface ContainerFeeAllocation {
customerId: string
orderId: string
orderNo: string
inboundId: string | null
inboundCode: string | null
amount: number
}
export interface ContainerFeeAllocationGroupRequest {
fee: ContainerFeeSaveRequest
allocations: ContainerFeeAllocation[]
}
export interface ContainerFeeAllocationGroupAdjustRequest {
expectedSourceVersion: number
businessDate: string
totalAmount: number
allocations: Array<{ feeId: string; amount: number }>
}
export interface ApplicableFeeItem { export interface ApplicableFeeItem {
id: string id: string
name: string name: string
@@ -105,7 +133,14 @@ const params = (accountId: string) => ({ accountId })
function normalizeFee(raw: Record<string, unknown>): ContainerFeeResource { function normalizeFee(raw: Record<string, unknown>): ContainerFeeResource {
return { return {
id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), containerBookingId: raw.containerBookingId == null ? null : id(raw.containerBookingId), id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), customerId: id(raw.tenantId),
allocationGroupId: raw.allocationGroupId == null ? null : id(raw.allocationGroupId),
allocationOrderId: raw.allocationOrderId == null ? null : id(raw.allocationOrderId),
allocationOrderNo: raw.allocationOrderNo == null ? null : String(raw.allocationOrderNo),
allocationInboundId: raw.allocationInboundId == null ? null : id(raw.allocationInboundId),
allocationInboundCode: raw.allocationInboundCode == null ? null : String(raw.allocationInboundCode),
allocationGroupAmount: raw.allocationGroupAmount == null ? null : numberValue(raw.allocationGroupAmount),
containerBookingId: raw.containerBookingId == null ? null : id(raw.containerBookingId),
containerNo: raw.containerNo == null ? null : String(raw.containerNo), feeItemId: id(raw.feeItemId), containerNo: raw.containerNo == null ? null : String(raw.containerNo), feeItemId: id(raw.feeItemId),
feeItemName: String(raw.feeItemNameSnapshot || ''), direction: raw.direction as ContainerFeeDirection, feeItemName: String(raw.feeItemNameSnapshot || ''), direction: raw.direction as ContainerFeeDirection,
supplierId: raw.supplierId == null ? null : id(raw.supplierId), supplierName: raw.supplierName == null ? null : String(raw.supplierName), supplierId: raw.supplierId == null ? null : id(raw.supplierId), supplierName: raw.supplierName == null ? null : String(raw.supplierName),
@@ -132,6 +167,30 @@ export async function createContainerFee(planId: string, accountId: string, payl
return normalizeFee(unwrapApiResponse(response)) return normalizeFee(unwrapApiResponse(response))
} }
export async function createContainerFeeAllocationGroup(planId: string, payload: ContainerFeeAllocationGroupRequest) {
const response = await http.post<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>(`${base(planId)}/allocation-groups`, payload)
return unwrapApiResponse(response).map(normalizeFee)
}
export async function approveContainerFeeAllocationGroup(planId: string, groupId: string) {
const response = await http.post<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>(`${base(planId)}/allocation-groups/${encodeURIComponent(groupId)}/approve`)
return unwrapApiResponse(response).map(normalizeFee)
}
export async function rejectContainerFeeAllocationGroup(planId: string, groupId: string, reason: string) {
const response = await http.post<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>(`${base(planId)}/allocation-groups/${encodeURIComponent(groupId)}/reject`, { reason })
return unwrapApiResponse(response).map(normalizeFee)
}
export async function adjustContainerFeeAllocationGroup(planId: string, groupId: string, payload: ContainerFeeAllocationGroupAdjustRequest) {
const response = await http.post<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>(`${base(planId)}/allocation-groups/${encodeURIComponent(groupId)}/adjust`, payload)
return unwrapApiResponse(response).map(normalizeFee)
}
export async function voidContainerFeeAllocationGroup(planId: string, groupId: string, expectedSourceVersion: number) {
await http.post(`${base(planId)}/allocation-groups/${encodeURIComponent(groupId)}/void`, { expectedSourceVersion })
}
export async function approveContainerFee(planId: string, accountId: string, feeId: string) { export async function approveContainerFee(planId: string, accountId: string, feeId: string) {
await http.post(`${base(planId)}/${encodeURIComponent(feeId)}/approve`, undefined, { params: params(accountId) }) await http.post(`${base(planId)}/${encodeURIComponent(feeId)}/approve`, undefined, { params: params(accountId) })
} }
@@ -27,7 +27,7 @@ async function mountPanel(permissions: string[], customers = [{ accountId: '11',
const pinia = createPinia(); setActivePinia(pinia) const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin() useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', containerBookingId, customers, businessReferences: [{ orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001' }] }, global: { plugins: [pinia, ElementPlus] } }) const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', containerBookingId, customers, businessReferences: [{ accountId: '11', customerName: '客户A', orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001' }] }, global: { plugins: [pinia, ElementPlus] } })
await flushPromises(); return wrapper await flushPromises(); return wrapper
} }
@@ -10,13 +10,13 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue' import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance' import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance'
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees' import { adjustContainerFee, adjustContainerFeeAllocationGroup, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, rejectContainerFeeAllocationGroup, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeAllocation, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
const props = defineProps<{ const props = defineProps<{
planId: string planId: string
containerBookingId: string containerBookingId: string
customers: Array<{ accountId: string; customerName: string | null }> customers: Array<{ accountId: string; customerName: string | null }>
businessReferences: Array<{ orderId: string; orderNo: string; inboundId: string; inboundCode: string | null }> businessReferences: Array<{ accountId: string; customerName: string | null; orderId: string; orderNo: string; inboundId: string | null; inboundCode: string | null }>
}>() }>()
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const accountId = ref('') const accountId = ref('')
@@ -37,6 +37,8 @@ const editorError = ref<ApiErrorDetails | null>(null)
const rejectOpen = ref(false) const rejectOpen = ref(false)
const saving = ref(false) const saving = ref(false)
const target = ref<ContainerFeeResource | null>(null) const target = ref<ContainerFeeResource | null>(null)
const targetGroupRows = ref<ContainerFeeResource[]>([])
const allocationDraft = ref<ContainerFeeAllocation[]>([])
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const form = reactive<ContainerFeeSaveRequest>({ feeItemId: '', direction: 'RECEIVABLE', supplierId: null, settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', remark: null }) const form = reactive<ContainerFeeSaveRequest>({ feeItemId: '', direction: 'RECEIVABLE', supplierId: null, settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', remark: null })
const settlementModeModel = computed<string>({ const settlementModeModel = computed<string>({
@@ -61,6 +63,18 @@ const customerOptions = computed(() => {
return [...unique].map(([value, label]) => ({ value, label })) return [...unique].map(([value, label]) => ({ value, label }))
}) })
const selectedCustomer = computed(() => customerOptions.value.find((item) => item.value === accountId.value)) const selectedCustomer = computed(() => customerOptions.value.find((item) => item.value === accountId.value))
const allocationCandidates = computed(() => {
const unique = new Map<string, ContainerFeeAllocation>()
for (const item of props.businessReferences) {
if (!item.accountId || !item.orderId || !item.orderNo) continue
const key = `${item.accountId}|${item.orderId}|${item.inboundId || ''}|${item.inboundCode || ''}`
unique.set(key, { customerId: item.accountId, orderId: item.orderId, orderNo: item.orderNo,
inboundId: item.inboundId || null, inboundCode: item.inboundCode?.trim() || null, amount: 0 })
}
return [...unique.values()]
})
const allocationMode = computed(() => allocationCandidates.value.length > 1 || Boolean(target.value?.allocationGroupId))
const allocationSum = computed(() => allocationDraft.value.reduce((sum, item) => sum + Number(item.amount || 0), 0))
const orderNumbers = computed(() => new Map(props.businessReferences.map((item) => [item.orderId, item.orderNo]))) const orderNumbers = computed(() => new Map(props.businessReferences.map((item) => [item.orderId, item.orderNo])))
const inboundCodes = computed(() => new Map(props.businessReferences.map((item) => [item.inboundId, item.inboundCode]))) const inboundCodes = computed(() => new Map(props.businessReferences.map((item) => [item.inboundId, item.inboundCode])))
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE' const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE'
@@ -73,6 +87,7 @@ const columns: DataTableColumn[] = [
{ key: 'settlementMode', label: '结算方式', width: 120, slotName: 'settlementMode' }, { key: 'settlementMode', label: '结算方式', width: 120, slotName: 'settlementMode' },
{ key: 'paidSummary', label: '已付摘要', minWidth: 290, slotName: 'paidSummary' }, { key: 'paidSummary', label: '已付摘要', minWidth: 290, slotName: 'paidSummary' },
{ key: 'supplier', label: '供应商/客户', minWidth: 170, slotName: 'supplier' }, { key: 'supplier', label: '供应商/客户', minWidth: 170, slotName: 'supplier' },
{ key: 'businessReference', label: '费用归属', minWidth: 220, slotName: 'businessReference' },
{ key: 'amount', label: '金额(CNY)', width: 120, align: 'right', slotName: 'amount' }, { key: 'amount', label: '金额(CNY)', width: 120, align: 'right', slotName: 'amount' },
{ key: 'businessDate', prop: 'businessDate', label: '业务日期', width: 120 }, { key: 'businessDate', prop: 'businessDate', label: '业务日期', width: 120 },
{ key: 'reviewStatus', label: '审核状态', width: 110, slotName: 'reviewStatus' }, { key: 'reviewStatus', label: '审核状态', width: 110, slotName: 'reviewStatus' },
@@ -90,6 +105,8 @@ const rules: FormRules = { feeItemId: [{ required: true, message: '请选择费
function resetForm(item?: ContainerFeeResource) { function resetForm(item?: ContainerFeeResource) {
target.value = item || null target.value = item || null
targetGroupRows.value = []
allocationDraft.value = item?.allocationGroupId ? [] : allocationCandidates.value.map((value) => ({ ...value }))
Object.assign(form, item ? { feeItemId: item.feeItemId, direction: item.direction, supplierId: item.supplierId, settlementMode: item.settlementMode ?? null, paidAt: item.paidAt ?? null, paymentMethod: item.paymentMethod ?? null, paymentReferenceNo: item.paymentReferenceNo ?? null, paymentVoucherFileIds: item.paymentVoucherFileIds ?? null, businessDate: item.businessDate, amount: item.amount, currency: 'CNY', remark: item.remark } Object.assign(form, item ? { feeItemId: item.feeItemId, direction: item.direction, supplierId: item.supplierId, settlementMode: item.settlementMode ?? null, paidAt: item.paidAt ?? null, paymentMethod: item.paymentMethod ?? null, paymentReferenceNo: item.paymentReferenceNo ?? null, paymentVoucherFileIds: item.paymentVoucherFileIds ?? null, businessDate: item.businessDate, amount: item.amount, currency: 'CNY', remark: item.remark }
: { feeItemId: '', direction: 'RECEIVABLE', supplierId: null, settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', remark: null }) : { feeItemId: '', direction: 'RECEIVABLE', supplierId: null, settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', remark: null })
voucherFiles.value = (item?.paymentVoucherFileIds || []).map((fileId) => ({ fileId, fileName: `凭证 ${fileId}` })) voucherFiles.value = (item?.paymentVoucherFileIds || []).map((fileId) => ({ fileId, fileName: `凭证 ${fileId}` }))
@@ -102,6 +119,7 @@ function clearPaidFields() {
} }
function settlementLabel(item: ContainerFeeResource) { return item.direction !== 'PAYABLE' ? '—' : item.settlementMode === 'DIRECT_PAID' ? '已付成本' : item.settlementMode === 'ON_ACCOUNT' ? '账期应付' : '未设置' } function settlementLabel(item: ContainerFeeResource) { return item.direction !== 'PAYABLE' ? '—' : item.settlementMode === 'DIRECT_PAID' ? '已付成本' : item.settlementMode === 'ON_ACCOUNT' ? '账期应付' : '未设置' }
function paymentMethodLabel(value: string | null | undefined) { return ({ ONLINE: '线上支付', BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[value || ''] || '—' } function paymentMethodLabel(value: string | null | undefined) { return ({ ONLINE: '线上支付', BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[value || ''] || '—' }
function customerLabel(customerId: string) { return customerOptions.value.find((item) => item.value === customerId)?.label || '客户信息缺失' }
function paidSummary(item: ContainerFeeResource) { function paidSummary(item: ContainerFeeResource) {
if (item.settlementMode !== 'DIRECT_PAID') return '—' if (item.settlementMode !== 'DIRECT_PAID') return '—'
const evidence = item.paymentReferenceNo || (item.paymentVoucherFileIds?.length ? `${item.paymentVoucherFileIds.length} 个凭证` : '凭证缺失') const evidence = item.paymentReferenceNo || (item.paymentVoucherFileIds?.length ? `${item.paymentVoucherFileIds.length} 个凭证` : '凭证缺失')
@@ -203,7 +221,23 @@ async function loadSettledFeeIds(customerId: string, feeRows: ContainerFeeResour
} }
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void loadFeeItems() } function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void loadFeeItems() }
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return; resetForm(item); editorError.value = null; editorOpen.value = true } async function openAdjust(item: ContainerFeeResource) {
if (!canEdit.value || !accountId.value) return
resetForm(item); editorError.value = null
if (item.allocationGroupId) {
try {
const all = await Promise.all(customerOptions.value.map((customer) => listContainerFees(props.planId, customer.value)))
targetGroupRows.value = all.flat().filter((row, index, rows) => row.allocationGroupId === item.allocationGroupId
&& rows.findIndex((candidate) => candidate.id === row.id) === index)
allocationDraft.value = targetGroupRows.value.map((row) => ({ customerId: '', orderId: row.allocationOrderId || '',
orderNo: row.allocationOrderNo || '', inboundId: row.allocationInboundId ?? null,
inboundCode: row.allocationInboundCode ?? null, amount: row.amount }))
if (!targetGroupRows.value.length) throw new Error('未读取到完整分摊组')
form.amount = item.allocationGroupAmount ?? targetGroupRows.value.reduce((sum, row) => sum + row.amount, 0)
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '完整分摊组加载失败').message); return }
}
editorOpen.value = true
}
async function save() { async function save() {
if (!canEdit.value || !accountId.value || (!target.value && !canViewFeeItems.value)) return if (!canEdit.value || !accountId.value || (!target.value && !canViewFeeItems.value)) return
if (!target.value && form.direction === 'PAYABLE' && !canViewSuppliers.value) { ElMessage.warning('当前账号缺少供应商查看权限,无法保存应付费用'); return } if (!target.value && form.direction === 'PAYABLE' && !canViewSuppliers.value) { ElMessage.warning('当前账号缺少供应商查看权限,无法保存应付费用'); return }
@@ -212,11 +246,23 @@ async function save() {
if (target.value) { if (target.value) {
saving.value = true saving.value = true
try { try {
await adjustContainerFee(props.planId, accountId.value, target.value.id, { if (target.value.allocationGroupId) {
expectedSourceVersion: target.value.sourceVersion, if (Math.abs(allocationSum.value - form.amount) > 0.001 || allocationDraft.value.some((row) => row.amount <= 0)) {
businessDate: form.businessDate, ElMessage.warning('每个业务归属的分摊金额必须大于 0,且合计等于费用总额'); return
amount: form.amount, }
}) await adjustContainerFeeAllocationGroup(props.planId, target.value.allocationGroupId, {
expectedSourceVersion: target.value.sourceVersion, businessDate: form.businessDate,
totalAmount: form.amount, allocations: targetGroupRows.value.map((row, index) => ({
feeId: row.id, amount: allocationDraft.value[index]?.amount || 0,
})),
})
} else {
await adjustContainerFee(props.planId, accountId.value, target.value.id, {
expectedSourceVersion: target.value.sourceVersion,
businessDate: form.businessDate,
amount: form.amount,
})
}
editorOpen.value = false; ElMessage.success('费用调整已提交审核'); await load() editorOpen.value = false; ElMessage.success('费用调整已提交审核'); await load()
} catch (cause) { } catch (cause) {
const details = getApiErrorDetails(cause, '费用调整失败') const details = getApiErrorDetails(cause, '费用调整失败')
@@ -242,24 +288,46 @@ async function save() {
paymentVoucherFileIds: directPaid && voucherFiles.value.length ? voucherFiles.value.map((item) => item.fileId) : null } paymentVoucherFileIds: directPaid && voucherFiles.value.length ? voucherFiles.value.map((item) => item.fileId) : null }
saving.value = true saving.value = true
try { try {
await createContainerFee(props.planId, accountId.value, payload) if (allocationMode.value) {
const allocations = allocationDraft.value.filter((row) => row.amount > 0)
if (!allocations.length || Math.abs(allocationSum.value - form.amount) > 0.001) {
ElMessage.warning('请填写分摊金额,分摊合计必须等于费用总额'); return
}
if (payload.settlementMode === 'DIRECT_PAID' && payload.paymentVoucherFileIds?.length
&& new Set(allocations.map((row) => row.customerId)).size > 1) {
ElMessage.warning('跨客户直接已付费用不能共用客户凭证,请改用付款流水号或分别录入'); return
}
await createContainerFeeAllocationGroup(props.planId, { fee: payload, allocations })
} else {
await createContainerFee(props.planId, accountId.value, payload)
}
editorOpen.value = false; ElMessage.success('费用已新增'); await load() editorOpen.value = false; ElMessage.success('费用已新增'); await load()
} catch (cause) { editorError.value = getApiErrorDetails(cause, '费用保存失败') } } catch (cause) { editorError.value = getApiErrorDetails(cause, '费用保存失败') }
finally { saving.value = false } finally { saving.value = false }
} }
async function approve(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; await approveContainerFee(props.planId, accountId.value, item.id).then(() => ElMessage.success('费用已审核通过')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '审核失败').message)) } async function approve(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; await (item.allocationGroupId
? approveContainerFeeAllocationGroup(props.planId, item.allocationGroupId)
: approveContainerFee(props.planId, accountId.value, item.id)).then(() => ElMessage.success(item.allocationGroupId ? '分摊组已整体审核通过' : '费用已审核通过')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '审核失败').message)) }
function openReject(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; target.value = item; rejection.value = ''; rejectOpen.value = true } function openReject(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; target.value = item; rejection.value = ''; rejectOpen.value = true }
async function reject() { async function reject() {
if (!canReview.value || !accountId.value || !target.value) return if (!canReview.value || !accountId.value || !target.value) return
if (!rejection.value.trim()) return ElMessage.warning('请输入驳回原因') if (!rejection.value.trim()) return ElMessage.warning('请输入驳回原因')
saving.value = true saving.value = true
try { await rejectContainerFee(props.planId, accountId.value, target.value.id, rejection.value.trim()); rejectOpen.value = false; ElMessage.success('费用已驳回'); await load() } try {
if (target.value.allocationGroupId) await rejectContainerFeeAllocationGroup(props.planId, target.value.allocationGroupId, rejection.value.trim())
else await rejectContainerFee(props.planId, accountId.value, target.value.id, rejection.value.trim())
rejectOpen.value = false; ElMessage.success(target.value.allocationGroupId ? '分摊组已整体驳回' : '费用已驳回'); await load()
}
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '驳回失败').message) } finally { saving.value = false } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '驳回失败').message) } finally { saving.value = false }
} }
async function voidFee(item: ContainerFeeResource) { async function voidFee(item: ContainerFeeResource) {
if (!canReview.value || !accountId.value) return if (!canReview.value || !accountId.value) return
await ElMessageBox.confirm(`确认作废“${item.feeItemName}”费用?作废后将同步财务来源状态。`, '作废费用', { type: 'warning', confirmButtonText: '确认作废' }) await ElMessageBox.confirm(`确认作废“${item.feeItemName}”费用?作废后将同步财务来源状态。`, '作废费用', { type: 'warning', confirmButtonText: '确认作废' })
await voidContainerFee(props.planId, accountId.value, item.id, item.sourceVersion).then(() => ElMessage.success('费用已作废')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '作废失败').message)) await (item.allocationGroupId
? voidContainerFeeAllocationGroup(props.planId, item.allocationGroupId, item.sourceVersion)
: voidContainerFee(props.planId, accountId.value, item.id, item.sourceVersion))
.then(() => ElMessage.success(item.allocationGroupId ? '分摊组已整体作废' : '费用已作废')).then(load)
.catch((cause) => ElMessage.error(getApiErrorDetails(cause, '作废失败').message))
} }
function money(value: number) { return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } function money(value: number) { return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
function initializeCustomer() { accountId.value = customerOptions.value.length === 1 ? customerOptions.value[0].value : '' } function initializeCustomer() { accountId.value = customerOptions.value.length === 1 ? customerOptions.value[0].value : '' }
@@ -304,6 +372,7 @@ onMounted(() => void load())
<template #settlementMode="{ row }">{{ settlementLabel(row as ContainerFeeResource) }}</template> <template #settlementMode="{ row }">{{ settlementLabel(row as ContainerFeeResource) }}</template>
<template #paidSummary="{ row }">{{ paidSummary(row as ContainerFeeResource) }}</template> <template #paidSummary="{ row }">{{ paidSummary(row as ContainerFeeResource) }}</template>
<template #supplier="{ row }">{{ row.direction === 'PAYABLE' ? row.supplierName || '服务端未返回' : selectedCustomer?.label || '客户信息缺失' }}</template> <template #supplier="{ row }">{{ row.direction === 'PAYABLE' ? row.supplierName || '服务端未返回' : selectedCustomer?.label || '客户信息缺失' }}</template>
<template #businessReference="{ row }"><div class="business-reference"><span>{{ row.allocationOrderNo || '订单号待补充' }}</span><small>{{ row.allocationInboundCode || '无入仓码' }}<template v-if="row.allocationGroupId"> · 分摊组</template></small></div></template>
<template #amount="{ row }">{{ money(row.amount) }}</template><template #reviewStatus="{ row }"><StatusBadge :status="row.reviewStatus === 'PENDING' ? '待审核' : row.reviewStatus === 'APPROVED' ? '已通过' : '已驳回'" /></template><template #sourceStatus="{ row }"><StatusBadge :status="row.sourceStatus === 'ACTIVE' ? '有效' : '已作废'" /></template> <template #amount="{ row }">{{ money(row.amount) }}</template><template #reviewStatus="{ row }"><StatusBadge :status="row.reviewStatus === 'PENDING' ? '待审核' : row.reviewStatus === 'APPROVED' ? '已通过' : '已驳回'" /></template><template #sourceStatus="{ row }"><StatusBadge :status="row.sourceStatus === 'ACTIVE' ? '有效' : '已作废'" /></template>
</DataTable> </DataTable>
@@ -321,6 +390,7 @@ onMounted(() => void load())
<el-descriptions-item label="已付摘要">{{ paidSummary(target) }}</el-descriptions-item> <el-descriptions-item label="已付摘要">{{ paidSummary(target) }}</el-descriptions-item>
<el-descriptions-item label="币种">{{ target.currency }}</el-descriptions-item> <el-descriptions-item label="币种">{{ target.currency }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ target.remark || '—' }}</el-descriptions-item> <el-descriptions-item label="备注">{{ target.remark || '—' }}</el-descriptions-item>
<el-descriptions-item label="费用归属">{{ target.allocationOrderNo || '—' }} / {{ target.allocationInboundCode || '无入仓码' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</div> </div>
<template v-else> <template v-else>
@@ -343,6 +413,16 @@ onMounted(() => void load())
</template> </template>
<el-form-item label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item> <el-form-item label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></el-form-item> <el-form-item label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item v-if="allocationMode" label="业务归属分摊" class="allocation-editor" required>
<el-alert type="info" :closable="false" show-icon title="客户必须明确;入仓码没有时按订单归属,分摊合计必须等于费用总额。" />
<div class="allocation-editor__rows">
<div v-for="(row, index) in allocationDraft" :key="`${row.customerId}-${row.orderId}-${row.inboundId || 'none'}`" class="allocation-editor__row">
<span><strong>{{ customerLabel(row.customerId || targetGroupRows[index]?.customerId || '') }}</strong><small>{{ row.orderNo }} · {{ row.inboundCode || '无入仓码' }}</small></span>
<el-input-number v-model="row.amount" :min="0" :precision="2" controls-position="right" />
</div>
</div>
<p class="allocation-editor__sum" :class="{ 'is-error': Math.abs(allocationSum - form.amount) > 0.001 }">已分摊 ¥{{ money(allocationSum) }} / 总额 ¥{{ money(form.amount) }}</p>
</el-form-item>
<template v-if="!target"> <template v-if="!target">
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item> <el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</template> </template>
@@ -365,5 +445,8 @@ onMounted(() => void load())
.row-actions { display: flex; flex-wrap: wrap; }.allocation-section { padding-top: 16px; border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: 10px 18px; padding: 10px 14px; background: #f8fafc; font-size: 13px; }.cost-summary span { color: var(--cc-muted); }.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; } .row-actions { display: flex; flex-wrap: wrap; }.allocation-section { padding-top: 16px; border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: 10px 18px; padding: 10px 14px; background: #f8fafc; font-size: 13px; }.cost-summary span { color: var(--cc-muted); }.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.voucher-field { grid-column: 1 / -1; }.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border: 1px solid var(--cc-border); border-radius: 6px; color: var(--el-color-primary); cursor: pointer; }.voucher-picker.is-disabled { cursor: not-allowed; opacity: .6; }.voucher-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }.voucher-hint { color: var(--cc-muted); font-size: 12px; }.voucher-list { display: grid; width: 100%; gap: 6px; margin-top: 8px; }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 9px; border-radius: 5px; background: var(--el-fill-color-light); }.voucher-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.voucher-list button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; } .voucher-field { grid-column: 1 / -1; }.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border: 1px solid var(--cc-border); border-radius: 6px; color: var(--el-color-primary); cursor: pointer; }.voucher-picker.is-disabled { cursor: not-allowed; opacity: .6; }.voucher-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }.voucher-hint { color: var(--cc-muted); font-size: 12px; }.voucher-list { display: grid; width: 100%; gap: 6px; margin-top: 8px; }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 9px; border-radius: 5px; background: var(--el-fill-color-light); }.voucher-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.voucher-list button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; }
.adjust-snapshot p { margin: 0 0 12px; color: var(--cc-muted); font-size: 13px; line-height: 1.5; } .adjust-snapshot p { margin: 0 0 12px; color: var(--cc-muted); font-size: 13px; line-height: 1.5; }
.business-reference { display: grid; gap: 2px; }.business-reference small { color: var(--cc-muted); }
.allocation-editor { grid-column: 1 / -1; }.allocation-editor :deep(.el-form-item__content) { display: block; }
.allocation-editor__rows { display: grid; gap: 8px; margin-top: 10px; }.allocation-editor__row { display: grid; grid-template-columns: minmax(0, 1fr) 160px; align-items: center; gap: 12px; padding: 8px 10px; border: 1px solid var(--cc-border); border-radius: 6px; }.allocation-editor__row > span { display: grid; min-width: 0; }.allocation-editor__row small { overflow: hidden; color: var(--cc-muted); text-overflow: ellipsis; white-space: nowrap; }.allocation-editor__sum { margin: 8px 0 0; text-align: right; color: var(--el-color-success); }.allocation-editor__sum.is-error { color: var(--el-color-danger); }
@media (max-width: 640px) { .fees-header { align-items: stretch; flex-direction: column; }.fees-header .el-button { width: 100%; }.fee-form { grid-template-columns: 1fr; }.fee-form :deep(.el-form-item:last-child) { grid-column: auto; }.customer-context { align-items: stretch; flex-direction: column; } } @media (max-width: 640px) { .fees-header { align-items: stretch; flex-direction: column; }.fees-header .el-button { width: 100%; }.fee-form { grid-template-columns: 1fr; }.fee-form :deep(.el-form-item:last-child) { grid-column: auto; }.customer-context { align-items: stretch; flex-direction: column; } }
</style> </style>
@@ -120,6 +120,8 @@ const feeCustomers = computed(() => {
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName })) return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
}) })
const feeBusinessReferences = computed(() => (props.detail?.items || []).map((item) => ({ const feeBusinessReferences = computed(() => (props.detail?.items || []).map((item) => ({
accountId: item.accountId,
customerName: item.customerName,
orderId: item.orderId, orderId: item.orderId,
orderNo: item.orderNo, orderNo: item.orderNo,
inboundId: item.inboundId, inboundId: item.inboundId,