1
This commit is contained in:
@@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance/api/finance'
|
||||
import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees'
|
||||
import ContainerFeesPanel from './ContainerFeesPanel.vue'
|
||||
|
||||
@@ -13,6 +14,9 @@ vi.mock('../api/containerFees', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/containerFees')>()
|
||||
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
|
||||
})
|
||||
vi.mock('@/modules/finance/api/finance', () => ({
|
||||
getFinanceSettlement: vi.fn(), listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(),
|
||||
}))
|
||||
|
||||
const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8')
|
||||
const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
|
||||
@@ -33,6 +37,9 @@ beforeEach(() => {
|
||||
vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([])
|
||||
vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset()
|
||||
vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never)
|
||||
vi.mocked(getFinanceSettlement).mockReset().mockResolvedValue({} as never)
|
||||
vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([])
|
||||
vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([])
|
||||
vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' })
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
@@ -140,6 +147,30 @@ describe('ContainerFeesPanel contract', () => {
|
||||
for (const label of ['新增费用', '通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false)
|
||||
})
|
||||
|
||||
it('hides adjustment and void actions for settled fees', async () => {
|
||||
vi.mocked(listContainerFees).mockResolvedValue([
|
||||
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '海运费', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
|
||||
])
|
||||
vi.mocked(listFinanceReceivableSettlements).mockResolvedValue([{ id: 'ar-1', status: 'PAID', paymentStatus: 'PAID' } as never])
|
||||
vi.mocked(getFinanceSettlement).mockResolvedValue({ lines: [{ sourceType: 'CONTAINER_FEE', sourceFeeId: '1' }] } as never)
|
||||
const wrapper = await mountPanel([
|
||||
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review',
|
||||
'platform:finance:settlement-ar:list',
|
||||
])
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === '调整')).toBe(false)
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
|
||||
expect(getFinanceSettlement).toHaveBeenCalledWith('ar-1')
|
||||
})
|
||||
|
||||
it('hides adjustment and void actions for directly paid payable fees', async () => {
|
||||
vi.mocked(listContainerFees).mockResolvedValue([
|
||||
{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '拖车费', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-1', paymentVoucherFileIds: ['9001'], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
|
||||
])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review'])
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === '调整')).toBe(false)
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps receivable usable when supplier permission is missing', async () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
|
||||
|
||||
@@ -9,12 +9,14 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance/api/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'
|
||||
|
||||
const props = defineProps<{ planId: string; containerBookingId: string; customers: Array<{ accountId: string; customerName: string | null }> }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const accountId = ref('')
|
||||
const fees = ref<ContainerFeeResource[]>([])
|
||||
const settledFeeIds = ref<Set<string>>(new Set())
|
||||
const feeItems = ref<ApplicableFeeItem[]>([])
|
||||
const suppliers = ref<ContainerFeeSupplierOption[]>([])
|
||||
const costs = ref<ContainerCostDetail[]>([])
|
||||
@@ -98,6 +100,9 @@ function paidSummary(item: ContainerFeeResource) {
|
||||
const evidence = item.paymentReferenceNo || (item.paymentVoucherFileIds?.length ? `${item.paymentVoucherFileIds.length} 个凭证` : '凭证缺失')
|
||||
return `${item.paidAt?.replace('T', ' ').slice(0, 16) || '时间缺失'} · ${paymentMethodLabel(item.paymentMethod)} · ${evidence}`
|
||||
}
|
||||
function isSettled(item: ContainerFeeResource) {
|
||||
return item.settlementMode === 'DIRECT_PAID' || settledFeeIds.value.has(item.id)
|
||||
}
|
||||
async function selectVoucher(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
@@ -153,6 +158,8 @@ async function load() {
|
||||
const feeRows = await listContainerFees(props.planId, requestedAccountId)
|
||||
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
||||
fees.value = feeRows
|
||||
await loadSettledFeeIds(requestedAccountId, feeRows, sequence)
|
||||
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
||||
costs.value = []
|
||||
const containerIds = [...new Set(feeRows.map((item) => item.containerBookingId).filter((value): value is string => Boolean(value)))]
|
||||
if (containerIds.some((value) => value !== props.containerBookingId)) throw new Error('柜次费用与装柜摘要的柜次标识不一致,已停止查询财务分摊')
|
||||
@@ -166,6 +173,28 @@ async function load() {
|
||||
finally { if (sequence === loadSequence) loading.value = false }
|
||||
}
|
||||
|
||||
async function loadSettledFeeIds(customerId: string, feeRows: ContainerFeeResource[], sequence: number) {
|
||||
const settled = new Set(feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id))
|
||||
settledFeeIds.value = settled
|
||||
const listRequests: Array<Promise<Awaited<ReturnType<typeof listFinanceReceivableSettlements>> | Awaited<ReturnType<typeof listFinancePayableSettlements>>>> = []
|
||||
if (access.can('platform:finance:settlement-ar:list')) listRequests.push(listFinanceReceivableSettlements(customerId))
|
||||
if (access.can('platform:finance:settlement-ap:list')) listRequests.push(listFinancePayableSettlements(customerId))
|
||||
if (!listRequests.length) return
|
||||
|
||||
const settlementResults = await Promise.allSettled(listRequests)
|
||||
const paidSettlements = settlementResults.flatMap((result) => result.status === 'fulfilled'
|
||||
? result.value.filter((item) => item.status === 'PAID' || item.paymentStatus === 'PAID')
|
||||
: [])
|
||||
const detailResults = await Promise.allSettled(paidSettlements.map((item) => getFinanceSettlement(item.id)))
|
||||
for (const result of detailResults) {
|
||||
if (result.status !== 'fulfilled') continue
|
||||
for (const line of result.value.lines) {
|
||||
if (line.sourceType === 'CONTAINER_FEE' && line.sourceFeeId) settled.add(line.sourceFeeId)
|
||||
}
|
||||
}
|
||||
if (sequence === loadSequence && customerId === accountId.value) settledFeeIds.value = settled
|
||||
}
|
||||
|
||||
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 save() {
|
||||
@@ -230,7 +259,7 @@ function initializeCustomer() { accountId.value = customerOptions.value.length =
|
||||
function clearCustomerContext() {
|
||||
loadSequence += 1
|
||||
supplierLoadSequence += 1
|
||||
fees.value = []; costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined
|
||||
fees.value = []; settledFeeIds.value = new Set(); costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined
|
||||
editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = ''
|
||||
resetForm()
|
||||
}
|
||||
@@ -263,7 +292,7 @@ onMounted(() => void load())
|
||||
<LoadErrorState v-else-if="error" compact title="柜次费用加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable v-else :data="fees" :columns="columns" :loading="loading" preference-key="container-fees" row-key="id" empty-text="当前客户暂无柜次费用">
|
||||
<template #toolbar-start><div class="fees-table-title"><strong>费用明细</strong><span>{{ fees.length }} 条</span></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
|
||||
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
|
||||
<template #settlementMode="{ row }">{{ settlementLabel(row as ContainerFeeResource) }}</template>
|
||||
<template #paidSummary="{ row }">{{ paidSummary(row as ContainerFeeResource) }}</template>
|
||||
|
||||
Reference in New Issue
Block a user