fix(admin): integrate workbench contracts and dropship review states
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { adjustContainerFee, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup } from './containerFees'
|
||||
import { adjustContainerFee, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup } from './containerFees'
|
||||
import { listSuppliers } from '@/modules/suppliers'
|
||||
|
||||
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
|
||||
@@ -70,20 +70,19 @@ describe('container fee API', () => {
|
||||
expect(listSuppliers).toHaveBeenCalledWith({ status: 'ACTIVE', category: undefined, page: 1, size: 200 })
|
||||
})
|
||||
|
||||
it('reads allocation rows from Finance and never calculates them', async () => {
|
||||
const get = vi.spyOn(http, 'get')
|
||||
.mockResolvedValueOnce({ code: '200', data: [{ id: 81, customerId: 11, containerId: 99 }, { id: 82, customerId: 11, containerId: 100 }] })
|
||||
.mockResolvedValueOnce({ code: '200', data: { cost: { id: 81, customerId: 11, containerId: 99, supplierName: '承运商A', feeItemName: '拖车费', payableAmount: 300 }, allocations: [{ id: 1, orderId: 8, volumeCbm: 2, totalVolumeCbm: 5, allocationRate: 0.4, allocatedAmount: 120 }] } })
|
||||
const result = await listContainerCostDetails('11', '99')
|
||||
expect(result[0]).toMatchObject({ supplierName: '承运商A', allocations: [{ orderId: '8', allocatedAmount: 120 }] })
|
||||
expect(get).toHaveBeenNthCalledWith(2, '/platform/finance/v1/cost-entries/81', { params: { customerId: '11' } })
|
||||
expect(get).toHaveBeenCalledTimes(2)
|
||||
it('maps settled fees and allocation rows from one container aggregate request', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: {
|
||||
customerId: 11, containerBookingId: 99, settledSourceFeeIds: [7],
|
||||
costs: [{ costId: 81, costNo: 'COST-81', costType: 'TRUCKING', costOrigin: 'CONTAINER_FEE', sourceFeeId: 7, sourceFeeVersion: 2, containerBookingId: 99, supplierName: '承运商A', feeItemName: '拖车费', amount: 300, currency: 'CNY', status: 'CONFIRMED', allocations: [{ allocationId: 1, orderId: 8, inboundId: 9, volumeCbm: 2, totalVolumeCbm: 5, allocationRate: 0.4, allocatedAmount: 120, roundingAdjustment: false }] }],
|
||||
} })
|
||||
const result = await getContainerSettlementDetails('11', '99')
|
||||
expect(result).toMatchObject({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: ['7'], costs: [{ id: '81', containerBookingId: '99', sourceFeeId: '7', amount: 300, allocations: [{ id: '1', orderId: '8', inboundId: '9', allocatedAmount: 120 }] }] })
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/container-settlement-details', { params: { customerId: '11', containerBookingId: '99' } })
|
||||
expect(get).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fails closed when Finance detail does not echo the selected customer and container', async () => {
|
||||
vi.spyOn(http, 'get')
|
||||
.mockResolvedValueOnce({ code: '200', data: [{ id: 81, customerId: 11, containerId: 99 }] })
|
||||
.mockResolvedValueOnce({ code: '200', data: { cost: { id: 81, customerId: 12, containerId: 99 }, allocations: [] } })
|
||||
await expect(listContainerCostDetails('11', '99')).rejects.toThrow('Finance 成本详情与柜次或客户上下文不一致')
|
||||
it('fails closed when the Finance aggregate does not echo the selected customer and container', async () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: 12, containerBookingId: 99, settledSourceFeeIds: [], costs: [] } })
|
||||
await expect(getContainerSettlementDetails('11', '99')).rejects.toThrow('Finance 聚合结果与柜次或客户上下文不一致')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -113,13 +113,27 @@ export interface ContainerCostAllocation {
|
||||
|
||||
export interface ContainerCostDetail {
|
||||
id: string
|
||||
containerId: string | null
|
||||
costNo: string
|
||||
costType: string
|
||||
costOrigin: string
|
||||
sourceFeeId: string | null
|
||||
sourceFeeVersion: number
|
||||
containerBookingId: string | null
|
||||
supplierName: string | null
|
||||
feeItemName: string
|
||||
amount: number
|
||||
currency: string
|
||||
status: string
|
||||
allocations: ContainerCostAllocation[]
|
||||
}
|
||||
|
||||
export interface ContainerSettlementDetails {
|
||||
customerId: string
|
||||
containerBookingId: string
|
||||
settledSourceFeeIds: string[]
|
||||
costs: ContainerCostDetail[]
|
||||
}
|
||||
|
||||
const id = (value: unknown) => value == null ? '' : String(value)
|
||||
const numberValue = (value: unknown) => Number.isFinite(Number(value)) ? Number(value) : 0
|
||||
const supplierCategories: SupplierCategory[] = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE']
|
||||
@@ -229,16 +243,23 @@ export async function uploadContainerFeePaymentVoucher(customerId: string, file:
|
||||
return { fileId, fileName: String(raw.fileName || raw.file_name || file.name) }
|
||||
}
|
||||
|
||||
export async function listContainerCostDetails(customerId: string, containerId: string): Promise<ContainerCostDetail[]> {
|
||||
const response = await http.get<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>('/platform/finance/v1/cost-entries', { params: { customerId } })
|
||||
const costs = unwrapApiResponse(response).filter((raw) => id(raw.customerId) === customerId && id(raw.containerId) === containerId)
|
||||
return Promise.all(costs.map(async (raw) => {
|
||||
const costId = id(raw.id)
|
||||
const detailResponse = await http.get<ApiEnvelope<{ cost: Record<string, unknown>; allocations: Record<string, unknown>[] }>, ApiEnvelope<{ cost: Record<string, unknown>; allocations: Record<string, unknown>[] }>>(`/platform/finance/v1/cost-entries/${encodeURIComponent(costId)}`, { params: { customerId } })
|
||||
const detail = unwrapApiResponse(detailResponse)
|
||||
if (id(detail.cost.id) !== costId || id(detail.cost.customerId) !== customerId || id(detail.cost.containerId) !== containerId) {
|
||||
throw new Error('Finance 成本详情与柜次或客户上下文不一致')
|
||||
export async function getContainerSettlementDetails(customerId: string, containerBookingId: string): Promise<ContainerSettlementDetails> {
|
||||
const response = await http.get<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>('/platform/finance/v1/container-settlement-details', { params: { customerId, containerBookingId } })
|
||||
const raw = unwrapApiResponse(response)
|
||||
if (id(raw.customerId) !== customerId || id(raw.containerBookingId) !== containerBookingId) throw new Error('Finance 聚合结果与柜次或客户上下文不一致')
|
||||
const costs = (Array.isArray(raw.costs) ? raw.costs : []).map((value) => {
|
||||
const cost = value as Record<string, unknown>
|
||||
if (id(cost.containerBookingId) !== containerBookingId) throw new Error('Finance 聚合成本与柜次上下文不一致')
|
||||
return {
|
||||
id: id(cost.costId), costNo: String(cost.costNo || ''), costType: String(cost.costType || ''), costOrigin: String(cost.costOrigin || ''),
|
||||
sourceFeeId: cost.sourceFeeId == null ? null : id(cost.sourceFeeId), sourceFeeVersion: numberValue(cost.sourceFeeVersion),
|
||||
containerBookingId: cost.containerBookingId == null ? null : id(cost.containerBookingId), supplierName: cost.supplierName == null ? null : String(cost.supplierName),
|
||||
feeItemName: String(cost.feeItemName || ''), amount: numberValue(cost.amount), currency: String(cost.currency || ''), status: String(cost.status || ''),
|
||||
allocations: (Array.isArray(cost.allocations) ? cost.allocations : []).map((value) => {
|
||||
const allocation = value as Record<string, unknown>
|
||||
return { id: id(allocation.allocationId), orderId: id(allocation.orderId), inboundId: allocation.inboundId == null ? null : id(allocation.inboundId), volumeCbm: numberValue(allocation.volumeCbm), totalVolumeCbm: numberValue(allocation.totalVolumeCbm), allocationRate: numberValue(allocation.allocationRate), allocatedAmount: numberValue(allocation.allocatedAmount), roundingAdjustment: Boolean(allocation.roundingAdjustment) }
|
||||
}),
|
||||
}
|
||||
return { id: costId, containerId: detail.cost.containerId == null ? null : id(detail.cost.containerId), supplierName: detail.cost.supplierName == null ? null : String(detail.cost.supplierName), feeItemName: String(detail.cost.feeItemName || ''), amount: numberValue(detail.cost.payableAmount), allocations: (detail.allocations || []).map((item) => ({ id: id(item.id), orderId: id(item.orderId), inboundId: item.inboundId == null ? null : id(item.inboundId), volumeCbm: numberValue(item.volumeCbm), totalVolumeCbm: numberValue(item.totalVolumeCbm), allocationRate: numberValue(item.allocationRate), allocatedAmount: numberValue(item.allocatedAmount), roundingAdjustment: Boolean(item.roundingAdjustment) })) }
|
||||
}))
|
||||
})
|
||||
return { customerId, containerBookingId, settledSourceFeeIds: (Array.isArray(raw.settledSourceFeeIds) ? raw.settledSourceFeeIds : []).map(id), costs }
|
||||
}
|
||||
|
||||
@@ -6,20 +6,17 @@ 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'
|
||||
import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees'
|
||||
import { adjustContainerFee, createContainerFee, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees'
|
||||
import ContainerFeesPanel from './ContainerFeesPanel.vue'
|
||||
|
||||
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() }
|
||||
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), getContainerSettlementDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
|
||||
})
|
||||
vi.mock('@/modules/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')
|
||||
const containerDetail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
|
||||
|
||||
class ResizeObserverStub { observe() {}; unobserve() {}; disconnect() {} }
|
||||
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
|
||||
@@ -35,11 +32,8 @@ beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage())
|
||||
vi.mocked(listContainerFees).mockReset().mockResolvedValue([]); vi.mocked(listApplicableFeeItems).mockReset().mockResolvedValue([])
|
||||
vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([])
|
||||
vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset()
|
||||
vi.mocked(getContainerSettlementDetails).mockReset().mockResolvedValue({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: [], costs: [] }); 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 = ''
|
||||
})
|
||||
@@ -52,9 +46,12 @@ describe('ContainerFeesPanel contract', () => {
|
||||
expect(panel).toContain('.fees-panel :deep(.data-table__toolbar) { min-height: 48px;')
|
||||
})
|
||||
|
||||
it('is exposed from loading-list detail and derives customer context from real items', () => {
|
||||
expect(detail).toContain(':container-booking-id="summary.containerBookingId"')
|
||||
expect(detail).toContain('item.accountId')
|
||||
it('is exposed from the domestic container fee tab and derives customer context from real items', () => {
|
||||
expect(detail).not.toContain('ContainerFeesPanel')
|
||||
expect(containerDetail).toContain('title="国内段费用(头程)"')
|
||||
expect(containerDetail).toContain(':container-booking-id="detail.id"')
|
||||
expect(containerDetail).toContain(':plan-id="overview.loadingPlanId"')
|
||||
expect(containerDetail).toContain('item.accountId')
|
||||
expect(panel).toContain('该柜次包含多个客户,请先选择费用归属客户')
|
||||
expect(panel).toContain('装柜明细缺少客户归属,无法安全维护费用')
|
||||
})
|
||||
@@ -84,7 +81,7 @@ describe('ContainerFeesPanel contract', () => {
|
||||
})
|
||||
|
||||
it('shows only server allocations and supplier snapshots', () => {
|
||||
expect(panel).toContain('listContainerCostDetails')
|
||||
expect(panel).toContain('getContainerSettlementDetails')
|
||||
expect(panel).toContain('仅展示 Finance 已持久化结果')
|
||||
expect(panel).toContain("cost.supplierName || '服务端未返回'")
|
||||
expect(panel).toContain('尚无与该柜次关联的财务分摊结果')
|
||||
@@ -108,21 +105,25 @@ describe('ContainerFeesPanel contract', () => {
|
||||
resolveFirst([{ id: 'old', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '旧客户费用', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }])
|
||||
await flushPromises()
|
||||
expect(state.fees).toEqual([])
|
||||
expect(listContainerCostDetails).not.toHaveBeenCalledWith('11', expect.anything())
|
||||
expect(getContainerSettlementDetails).not.toHaveBeenCalledWith('11', expect.anything())
|
||||
})
|
||||
|
||||
it('uses the summary container ID when fee rows are empty', async () => {
|
||||
it('does not request any Finance data when container fees are empty', async () => {
|
||||
vi.mocked(listContainerFees).mockResolvedValue([])
|
||||
await mountPanel(['firstleg:inbound:fee-detail:view'])
|
||||
expect(listContainerCostDetails).toHaveBeenCalledWith('11', '99')
|
||||
expect(getContainerSettlementDetails).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the summary container ID when fee rows agree', async () => {
|
||||
it('loads Finance data only on an explicit single request when fee rows agree', async () => {
|
||||
vi.mocked(listContainerFees).mockResolvedValue([
|
||||
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '费用A', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
|
||||
])
|
||||
await mountPanel(['firstleg:inbound:fee-detail:view'])
|
||||
expect(listContainerCostDetails).toHaveBeenCalledWith('11', '99')
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'])
|
||||
expect(getContainerSettlementDetails).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(getContainerSettlementDetails).toHaveBeenCalledWith('11', '99')
|
||||
expect(getContainerSettlementDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fails closed when fee rows disagree with the summary container', async () => {
|
||||
@@ -130,8 +131,10 @@ describe('ContainerFeesPanel contract', () => {
|
||||
{ id: '2', loadingPlanId: '9', containerBookingId: '100', containerNo: null, feeItemId: '2', feeItemName: '费用B', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', businessDate: '2026-08-12', amount: 20, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
|
||||
])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:fee-item:view'])
|
||||
expect(wrapper.text()).toContain('柜次费用加载失败')
|
||||
expect(listContainerCostDetails).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('财务状态加载失败')
|
||||
expect(getContainerSettlementDetails).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks write entry points without a selected customer or edit permission', async () => {
|
||||
@@ -155,15 +158,15 @@ describe('ContainerFeesPanel contract', () => {
|
||||
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)
|
||||
vi.mocked(getContainerSettlementDetails).mockResolvedValue({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: ['1'], costs: [] })
|
||||
const wrapper = await mountPanel([
|
||||
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review',
|
||||
'platform:finance:settlement-ar:list',
|
||||
])
|
||||
await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click')
|
||||
await flushPromises()
|
||||
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')
|
||||
expect(getContainerSettlementDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hides adjustment and void actions for directly paid payable fees', async () => {
|
||||
|
||||
@@ -9,15 +9,16 @@ 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'
|
||||
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'
|
||||
import { adjustContainerFee, adjustContainerFeeAllocationGroup, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listApplicableFeeItems, 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 = withDefaults(defineProps<{
|
||||
title?: string
|
||||
planId: string
|
||||
containerBookingId: string
|
||||
customers: Array<{ accountId: string; customerName: string | null }>
|
||||
businessReferences: Array<{ accountId: string; customerName: string | null; orderId: string; orderNo: string; inboundId: string | null; inboundCode: string | null }>
|
||||
}>()
|
||||
readonly?: boolean
|
||||
}>(), { title: '柜次费用', readonly: false })
|
||||
const access = useAccessPreviewStore()
|
||||
const accountId = ref('')
|
||||
const fees = ref<ContainerFeeResource[]>([])
|
||||
@@ -26,7 +27,10 @@ const feeItems = ref<ApplicableFeeItem[]>([])
|
||||
const suppliers = ref<ContainerFeeSupplierOption[]>([])
|
||||
const costs = ref<ContainerCostDetail[]>([])
|
||||
const loading = ref(false)
|
||||
const financeLoading = ref(false)
|
||||
const financeLoaded = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const financeError = ref<ApiErrorDetails | null>(null)
|
||||
const feeItemsError = ref<ApiErrorDetails | null>(null)
|
||||
const suppliersError = ref<ApiErrorDetails | null>(null)
|
||||
const feeItemsLoading = ref(false)
|
||||
@@ -53,8 +57,8 @@ let loadSequence = 0
|
||||
let supplierLoadSequence = 0
|
||||
|
||||
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
|
||||
const canEdit = computed(() => access.can('firstleg:inbound:fee-detail:edit'))
|
||||
const canReview = computed(() => access.can('firstleg:inbound:fee-detail:review'))
|
||||
const canEdit = computed(() => !props.readonly && access.can('firstleg:inbound:fee-detail:edit'))
|
||||
const canReview = computed(() => !props.readonly && access.can('firstleg:inbound:fee-detail:review'))
|
||||
const canViewFeeItems = computed(() => access.can('firstleg:fee-item:view'))
|
||||
const canViewSuppliers = computed(() => access.can('platform:supplier:list'))
|
||||
const customerOptions = computed(() => {
|
||||
@@ -183,41 +187,36 @@ 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
|
||||
settledFeeIds.value = new Set(feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id))
|
||||
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('柜次费用与装柜摘要的柜次标识不一致,已停止查询财务分摊')
|
||||
const currentContainerId = props.containerBookingId
|
||||
if (currentContainerId) {
|
||||
const result = await listContainerCostDetails(requestedAccountId, currentContainerId)
|
||||
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
||||
costs.value = result
|
||||
}
|
||||
financeLoaded.value = false
|
||||
financeError.value = null
|
||||
} catch (cause) { if (sequence === loadSequence && requestedAccountId === accountId.value) error.value = getApiErrorDetails(cause, '柜次费用加载失败') }
|
||||
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)
|
||||
}
|
||||
async function loadFinanceDetails() {
|
||||
if (!accountId.value || !canView.value || !fees.value.length || financeLoading.value) return
|
||||
const sequence = loadSequence
|
||||
const requestedAccountId = accountId.value
|
||||
const feeRows = [...fees.value]
|
||||
financeLoading.value = true; financeError.value = null
|
||||
try {
|
||||
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('柜次费用与装柜摘要的柜次标识不一致,已停止查询财务分摊')
|
||||
const aggregate = await getContainerSettlementDetails(requestedAccountId, props.containerBookingId)
|
||||
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
||||
settledFeeIds.value = new Set([
|
||||
...feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id),
|
||||
...aggregate.settledSourceFeeIds,
|
||||
])
|
||||
costs.value = aggregate.costs
|
||||
financeLoaded.value = true
|
||||
} catch (cause) {
|
||||
if (sequence === loadSequence && requestedAccountId === accountId.value) financeError.value = getApiErrorDetails(cause, '财务状态加载失败')
|
||||
} finally {
|
||||
if (sequence === loadSequence && requestedAccountId === accountId.value) financeLoading.value = false
|
||||
}
|
||||
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() }
|
||||
@@ -334,7 +333,7 @@ function initializeCustomer() { accountId.value = customerOptions.value.length =
|
||||
function clearCustomerContext() {
|
||||
loadSequence += 1
|
||||
supplierLoadSequence += 1
|
||||
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
|
||||
fees.value = []; settledFeeIds.value = new Set(); costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; financeError.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; financeLoading.value = false; financeLoaded.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined
|
||||
editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = ''
|
||||
resetForm()
|
||||
}
|
||||
@@ -353,7 +352,7 @@ onMounted(() => void load())
|
||||
<template>
|
||||
<section class="fees-panel" aria-label="柜次费用维护">
|
||||
<header class="fees-header">
|
||||
<div><h2>柜次费用</h2><p>费用与财务分摊均来自服务端真实记录</p></div>
|
||||
<div><h2>{{ title }}</h2><p>费用与财务分摊均来自服务端真实记录</p></div>
|
||||
<div class="fees-header__actions">
|
||||
<label v-if="customerOptions.length > 1" class="customer-context"><span>客户</span><el-select v-model="accountId" placeholder="请选择费用归属客户"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></label>
|
||||
<el-button v-if="canEdit && canViewFeeItems && accountId" type="primary" @click="openCreate"><Plus :size="16" />新增费用</el-button>
|
||||
@@ -367,7 +366,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' && !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 #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 && financeLoaded && 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 && financeLoaded && 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>
|
||||
@@ -376,7 +375,7 @@ onMounted(() => void load())
|
||||
<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>
|
||||
|
||||
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>供应商:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #orderNo="{ row }">{{ orderNumbers.get(row.orderId) || '订单编码待补充' }}</template><template #inboundCode="{ row }">{{ row.inboundId ? (inboundCodes.get(row.inboundId) || '入仓码待补充') : '—' }}</template><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
|
||||
<div v-if="accountId && fees.length" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div><el-button :loading="financeLoading" @click="loadFinanceDetails">{{ financeLoaded ? '刷新财务状态' : '加载财务状态' }}</el-button></header><LoadErrorState v-if="financeError" compact title="财务状态加载失败" :message="financeError.message" @retry="loadFinanceDetails"><template #details><ApiErrorMeta :details="financeError" /></template></LoadErrorState><el-empty v-else-if="!financeLoaded" description="点击加载财务状态后查看结算与成本分摊" :image-size="64" /><el-empty v-else-if="!financeLoading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>供应商:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #orderNo="{ row }">{{ orderNumbers.get(row.orderId) || '订单编码待补充' }}</template><template #inboundCode="{ row }">{{ row.inboundId ? (inboundCodes.get(row.inboundId) || '入仓码待补充') : '—' }}</template><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
|
||||
</template>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save">
|
||||
|
||||
@@ -6,7 +6,6 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import ContainerFeesPanel from './ContainerFeesPanel.vue'
|
||||
import { loadingListStatusLabel } from '../status'
|
||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||
import type { LoadingDetail, LoadingHistory, LoadingItem, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
|
||||
@@ -117,19 +116,6 @@ const pdaTaskStatusLabels: Record<string, string> = {
|
||||
const blockingReasonText = computed(() => (preloadSummary.value?.readiness.blockingReasons || [])
|
||||
.map((reason) => blockingReasonLabels[reason] || '存在未满足的确认条件')
|
||||
.join(';'))
|
||||
const feeCustomers = computed(() => {
|
||||
const customers = new Map<string, string | null>()
|
||||
for (const item of props.detail?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
|
||||
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
|
||||
})
|
||||
const feeBusinessReferences = computed(() => (props.detail?.items || []).map((item) => ({
|
||||
accountId: item.accountId,
|
||||
customerName: item.customerName,
|
||||
orderId: item.orderId,
|
||||
orderNo: item.orderNo,
|
||||
inboundId: item.inboundId,
|
||||
inboundCode: item.inboundCode,
|
||||
})))
|
||||
const title = computed(() => {
|
||||
if (!summary.value) return props.mode === 'preload' ? '预装清单详情' : '装柜清单详情'
|
||||
const number = props.mode === 'preload'
|
||||
@@ -228,8 +214,6 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" :business-references="feeBusinessReferences" />
|
||||
|
||||
<section class="history-section" aria-label="操作历史">
|
||||
<header><h2>操作历史</h2><span>{{ visibleHistories.length }} 条</span></header>
|
||||
<el-empty v-if="visibleHistories.length === 0" description="暂无操作历史" :image-size="72" />
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('LoadingListsView', () => {
|
||||
expect(preloadDetailSource).toContain('AppDialog')
|
||||
expect(confirmedDetailSource).not.toContain('AppDialog')
|
||||
expect(detailSource).not.toContain('AppDrawer')
|
||||
expect(detailSource).toContain('ContainerFeesPanel')
|
||||
expect(detailSource).not.toContain('ContainerFeesPanel')
|
||||
})
|
||||
|
||||
it('distinguishes the generated cabinet reference from the physical container number', () => {
|
||||
@@ -57,7 +57,7 @@ describe('LoadingListsView', () => {
|
||||
expect(source).not.toContain('internalContainerNo')
|
||||
})
|
||||
|
||||
it('opens both preload and confirmed details as independent route pages', () => {
|
||||
it('keeps preload routes while completed lists move into domestic container detail', () => {
|
||||
expect(source).toContain("name: 'preload-plan-detail'")
|
||||
expect(source).toContain("name: 'loading-list-detail'")
|
||||
expect(source).not.toContain('confirmed-inline-detail')
|
||||
@@ -66,8 +66,8 @@ describe('LoadingListsView', () => {
|
||||
expect(confirmedDetailSource).toContain('返回列表')
|
||||
expect(confirmedDetailSource).toContain('router.back()')
|
||||
expect(confirmedDetailSource).toContain('@media (min-width: 1920px) { .confirmed-loading-detail-page { width: 100%; max-width: none; gap: 12px; } }')
|
||||
expect(routesSource).toContain("path: 'loading-lists/:planId'")
|
||||
expect(routesSource).toContain("name: 'loading-list-detail'")
|
||||
expect(routesSource).not.toContain("path: 'loading-lists/:planId'")
|
||||
expect(routesSource).not.toContain("name: 'loading-list-detail'")
|
||||
})
|
||||
|
||||
it('keeps the real transport edit contract in a 620px centered modal on the detail page', () => {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
export * from './api/platformLoadingLists'
|
||||
export {
|
||||
listContainerFeeSupplierOptions,
|
||||
uploadContainerFeePaymentVoucher,
|
||||
type ContainerFeePaymentMethod,
|
||||
type ContainerFeeSettlementMode,
|
||||
type ContainerFeeSupplierOption,
|
||||
} from './api/containerFees'
|
||||
export { loadingListRoutes } from './routes'
|
||||
export { loadingListStatusLabel } from './status'
|
||||
export { default as ContainerFeesPanel } from './components/ContainerFeesPanel.vue'
|
||||
export { default as LoadingListDetailDrawer } from './components/LoadingListDetailDrawer.vue'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import { getPreloadPlan } from '../api/platformLoadingLists'
|
||||
import { getConfirmedLoadingList, getPreloadPlan } from '../api/platformLoadingLists'
|
||||
import PreloadPlanDetailView from './PreloadPlanDetailView.vue'
|
||||
|
||||
const routerReplace = vi.hoisted(() => vi.fn())
|
||||
@@ -15,6 +15,7 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
|
||||
vi.mock('@/core/workspace-tabs/workspaceTabs', () => ({ useWorkspaceTabsStore: () => ({ removeByIds: removeWorkspaceTabs }) }))
|
||||
vi.mock('../api/platformLoadingLists', () => ({
|
||||
confirmPreloadPlan: vi.fn(),
|
||||
getConfirmedLoadingList: vi.fn(),
|
||||
getPreloadPlan: vi.fn(),
|
||||
updateLoadingItemWarehouseRemark: vi.fn(),
|
||||
updatePreloadTransport: vi.fn(),
|
||||
@@ -52,6 +53,7 @@ describe('PreloadPlanDetailView lifecycle handoff', () => {
|
||||
|
||||
it('replaces the current tab after authoritative lifecycle resolution', async () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValue(lifecycle('LOADING_LIST', 'COMPLETED'))
|
||||
vi.mocked(getConfirmedLoadingList).mockResolvedValue({ summary: { containerBookingId: '2900000000000000001' }, items: [], histories: [] } as never)
|
||||
const message = vi.spyOn(ElMessage, 'success').mockImplementation(() => undefined as never)
|
||||
|
||||
const wrapper = await mountView()
|
||||
@@ -63,9 +65,9 @@ describe('PreloadPlanDetailView lifecycle handoff', () => {
|
||||
expect(getPreloadPlan).not.toHaveBeenCalled()
|
||||
expect(routerReplace).toHaveBeenCalledTimes(1)
|
||||
expect(routerReplace).toHaveBeenCalledWith({
|
||||
name: 'loading-list-detail',
|
||||
params: { planId: '1900000000000000001' },
|
||||
query: { warehouseId: '1900000000000000002' },
|
||||
name: 'domestic-container-detail',
|
||||
params: { containerId: '2900000000000000001' },
|
||||
query: { tab: 'loading-list' },
|
||||
})
|
||||
expect(removeWorkspaceTabs).toHaveBeenCalledWith(['preload-plan-detail'])
|
||||
expect(routerReplace.mock.invocationCallOrder[0]).toBeLessThan(removeWorkspaceTabs.mock.invocationCallOrder[0]!)
|
||||
|
||||
@@ -24,11 +24,11 @@ describe('PreloadPlanDetailView prototype contract', () => {
|
||||
expect(source).toContain('/loading-plans/${encodeURIComponent(planId.value)}/lifecycle')
|
||||
expect(source).toContain("lifecycle.detailResource === 'LOADING_LIST'")
|
||||
expect(source).toContain("lifecycle.planStatus !== 'COMPLETED'")
|
||||
expect(source).toContain("router.replace({ name: 'loading-list-detail'")
|
||||
expect(source).toContain("router.replace({ name: 'domestic-container-detail'")
|
||||
expect(source).toContain("workspace.removeByIds(['preload-plan-detail'])")
|
||||
expect(source).toContain('装柜已完成,已切换至装柜清单详情')
|
||||
expect(source).not.toContain("preloadError.code !== 'A0404'")
|
||||
expect(source).not.toContain('getConfirmedLoadingList')
|
||||
expect(source).toContain('getConfirmedLoadingList')
|
||||
expect(source).not.toContain("router.push({ name: 'loading-list-detail'")
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getApiErrorDetails, http, unwrapApiResponse, type ApiEnvelope, type Api
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
||||
import { confirmPreloadPlan, getPreloadPlan, updateLoadingItemWarehouseRemark, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
|
||||
import { confirmPreloadPlan, getConfirmedLoadingList, getPreloadPlan, updateLoadingItemWarehouseRemark, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -96,7 +96,8 @@ async function loadDetail() {
|
||||
error.value = { code: 'MODE_STATUS_MISMATCH', message: '装柜计划阶段异常,暂时无法切换详情,请稍后重试。' }
|
||||
return
|
||||
}
|
||||
await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query: { warehouseId: warehouseId.value } })
|
||||
const confirmed = await getConfirmedLoadingList(planId.value, warehouseId.value)
|
||||
await router.replace({ name: 'domestic-container-detail', params: { containerId: confirmed.summary.containerBookingId }, query: { tab: 'loading-list' } })
|
||||
workspace.removeByIds(['preload-plan-detail'])
|
||||
if (sequence === loadSequence) ElMessage.success('装柜已完成,已切换至装柜清单详情')
|
||||
return
|
||||
|
||||
@@ -2,12 +2,10 @@ import { describe, expect, it } from 'vitest'
|
||||
import { loadingListRoutes } from './routes'
|
||||
|
||||
describe('loading list routes', () => {
|
||||
it('declares preload list/detail and completed list entries with backend permissions', () => {
|
||||
it('keeps completed loading lists inside domestic container detail', () => {
|
||||
expect(loadingListRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
|
||||
['loading-plans', 'platform:firstleg:preload:list'],
|
||||
['loading-plans/:planId', 'platform:firstleg:preload:list'],
|
||||
['loading-lists', 'platform:firstleg:loading-list:list'],
|
||||
['loading-lists/:planId', 'platform:firstleg:loading-list:list'],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,24 +21,4 @@ export const loadingListRoutes = [
|
||||
permission: 'platform:firstleg:preload:list',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'loading-lists',
|
||||
name: 'loading-lists',
|
||||
component: () => import('./pages/ConfirmedLoadingListsView.vue'),
|
||||
meta: {
|
||||
title: '装柜清单',
|
||||
description: '查看全部仓库已完成且永久只读的装柜清单',
|
||||
permission: 'platform:firstleg:loading-list:list',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'loading-lists/:planId',
|
||||
name: 'loading-list-detail',
|
||||
component: () => import('./pages/ConfirmedLoadingListDetailView.vue'),
|
||||
meta: {
|
||||
title: '装柜清单详情',
|
||||
description: '查看装柜清单基本信息、货物明细、费用和操作历史',
|
||||
permission: 'platform:firstleg:loading-list:list',
|
||||
},
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
|
||||
Reference in New Issue
Block a user