diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index fce3cb8c..03c487d7 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -15,7 +15,7 @@ vi.mock('@/shared/api/http', () => ({ unwrapApiResponse: (response: { data: T }) => response.data, })) -const container = { id: 9007199254740993, planId: 9007199254740994, warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', version: '2', capacityCbm: '68', maxWeightKg: '28000' } +const container = { id: 9007199254740993, planId: 9007199254740994, warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', version: '2', capacityCbm: '76', maxWeightKg: '28000' } const plan = { id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'EDITING', items: [{ id: 9007199254740995, loadingPlanId: 9007199254740993, accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, destinationWarehouseId: 5, packageCount: '6', version: '1' }] } describe('platform container loading API', () => { @@ -33,14 +33,14 @@ describe('platform container loading API', () => { it('normalizes paged container ids and numeric metadata', async () => { vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: '1', size: '20', total: '1', pages: '1' } }) const result = await listContainers({ page: 1, size: 20 }) - expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), warehouseId: String(container.warehouseId), channelCode: 'NY_EXX', channelName: '纽约EXX海铁', version: 2, capacityCbm: 68 }) + expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), warehouseId: String(container.warehouseId), channelCode: 'NY_EXX', channelName: '纽约EXX海铁', version: 2, capacityCbm: 76 }) expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } }) }) it('uses full-edit and transport whitelist request shapes', async () => { vi.mocked(http.put).mockResolvedValue({ code: '200', data: container }) vi.mocked(http.patch).mockResolvedValue({ code: '200', data: container }) - const data = { cabinetNo: 'CB-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: null, maxWeightKg: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null } + const data = { cabinetNo: 'CB-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null } await updateContainer('7', 2, data) await updateContainerTransport('7', 3, { containerNo: 'MSCU1', voyageNo: 'V8' }) expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data }) @@ -50,10 +50,10 @@ describe('platform container loading API', () => { it('uses the frozen simulation create and ocean booking link contracts', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: 101, oceanBookingRequirementId: 202 } }) vi.mocked(http.delete).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: null, oceanBookingRequirementId: null } }) - await createContainerSimulation({ channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000 }) + await createContainerSimulation({ channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }) await linkContainerOceanBooking('7', { oceanBookingId: '101', requirementId: '202', expectedVersion: 3 }) await unlinkContainerOceanBooking('7', 4, '空柜改配') - expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/containers', { channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000 }) + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/containers', { channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }) expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/containers/7/ocean-booking-link', { oceanBookingId: '101', requirementId: '202', expectedVersion: 3 }) expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/ocean-booking-link', { data: { expectedVersion: 4, reason: '空柜改配' } }) }) diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index e0d10569..2378f46e 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -38,8 +38,8 @@ describe('ContainersView', () => { vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 }) vi.mocked(listSuppliers).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 }) vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 }) - vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 }) - vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }) + vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 }) + vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }) vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] }) }) @@ -70,10 +70,10 @@ describe('ContainersView', () => { it('uses stable view labels when cabinetNo is missing', async () => { const base = { - id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0, + id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', planStatus: 'WAIT_LOADING' as const, version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', - destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, + destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, } @@ -99,10 +99,10 @@ describe('ContainersView', () => { it('routes each plan status to its owning workflow without reopening pushed drafts', async () => { const base = { - id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', version: 0, + id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', - destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, + destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, } @@ -234,9 +234,14 @@ describe('ContainersView', () => { expect(source).not.toContain('新增货柜') expect(source).toContain('新增模拟柜') expect(source).toContain('createContainerSimulation({ ...simulationForm })') - expect(source).toContain("channelCode: '', warehouseId: '', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000") + expect(source).toContain("channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000") + expect(source).toContain('CONTAINER_SPECIFICATIONS') + expect(source).toContain('selectSimulationContainerType') expect(source).toContain('服务端会生成稳定的柜次号') expect(source).not.toContain('openCreate') + expect(source).not.toContain("['20GP','40GP','40HQ','45HQ']") + expect(source).not.toContain('v-model="form.capacityCbm"') + expect(source).not.toContain('v-model="form.maxWeightKg"') }) it('allows first linking after preloading but only offers unlinking for wait-loading containers', () => { diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 11f7cf4d..d738e47d 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -18,6 +18,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' +import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue' import { cancelContainerException, completeContainerClearance, createContainerSimulation, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, linkContainerOceanBooking, listContainers, markContainerException, unlinkContainerOceanBooking, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading' @@ -82,7 +83,7 @@ const simulationError = ref(null) const simulationChannels = ref([]) const simulationChannelsLoading = ref(false) const simulationFormRef = ref() -const simulationForm = reactive({ channelCode: '', warehouseId: '', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000 }) +const simulationForm = reactive({ channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }) const linkOpen = ref(false) const linkLoading = ref(false) const linkSaving = ref(false) @@ -99,10 +100,10 @@ let detailSequence = 0 function emptyForm(): ContainerForm { return { id: '', version: 0, containerNo: null, cabinetNo: '', warehouseId: '', warehouseName: null, - billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HQ', shippingCompanyId: null, + billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HC', shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, - capacityCbm: null, maxWeightKg: null, terminalTruckingCompany: null, loadingAt: null, + capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, } @@ -133,8 +134,6 @@ const simulationRules: FormRules = { warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }], channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }], containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }], - capacityCbm: [{ required: true, message: '请输入容量', trigger: 'change' }], - maxWeightKg: [{ required: true, message: '请输入限重', trigger: 'change' }], } interface PortOption { value: string; code: string | null; name: string | null } @@ -165,7 +164,7 @@ const destinationWarehouseValue = computed(() => form.destinationWarehouseCode | const queryFields = computed(() => [ { key: 'customerId', type: 'select', label: '客户', width: 220, placeholder: '全部客户', options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, { key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、集装箱号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } }, - { key: 'containerType', type: 'select', label: '柜型', width: 140, options: ['20GP', '40GP', '40HQ', '45HQ'].map((value) => ({ label: value, value })), props: { 'aria-label': '柜型' } }, + { key: 'containerType', type: 'select', label: '柜型', width: 140, options: CONTAINER_SPECIFICATIONS.map((item) => ({ label: item.type, value: item.type })), props: { 'aria-label': '柜型' } }, { key: 'status', type: 'select', label: '排柜状态', width: 150, options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } }, { key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } }, ]) @@ -200,6 +199,8 @@ function containerViewLabel(row: ContainerBookingResource) { } function replaceForm(source: ContainerBookingResource | null) { Object.assign(form, emptyForm(), source || {}) + const specification = containerSpecification(form.containerType) + if (specification) applyContainerSpecification(form, specification.type) selectedTruckingSupplierId.value = truckingCompanies.value.find((supplier) => supplier.name === form.terminalTruckingCompany)?.id || '' } function payload(): ContainerSaveRequest { @@ -315,11 +316,14 @@ async function loadAllOceanBookings() { return [firstPage, ...remaining].flatMap((pageResult) => pageResult.records) } function openSimulation() { - Object.assign(simulationForm, { channelCode: '', warehouseId: '', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000 }) + Object.assign(simulationForm, { channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }) simulationChannels.value = [] simulationError.value = null simulationOpen.value = true } +function selectSimulationContainerType(containerType: string) { + applyContainerSpecification(simulationForm, containerType) +} async function selectSimulationWarehouse(value: unknown) { simulationForm.warehouseId = String(value || '') simulationForm.channelCode = '' @@ -629,9 +633,8 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin - - - + + @@ -661,13 +664,13 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin - + - - + + diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index ae7d0241..8cf3294d 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -41,10 +41,10 @@ vi.mock('../api/platformContainerLoading', () => ({ const container = { id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', - warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1, + warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'EDITING' as const, version: 1, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, - destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 28000, + destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, } @@ -103,7 +103,7 @@ describe('GlobalSmartLoadingView', () => { containerNo: null, channelName: '美森正班海卡包税', warehouseName: '义乌国内仓', - containerType: '40HQ', + containerType: '40HC', }], page: 1, size: 100, @@ -120,7 +120,7 @@ describe('GlobalSmartLoadingView', () => { expect(option.text()).toContain('开始装柜前录入集装箱号') expect(option.text()).toContain('渠道:美森正班海卡包税') expect(option.text()).toContain('仓库:义乌国内仓') - expect(option.text()).toContain('柜型:40HQ') + expect(option.text()).toContain('柜型:40HC') expect(option.text()).not.toContain('2088475410134773762') expect(wrapper.text()).not.toContain('真实柜号') }) @@ -165,7 +165,6 @@ describe('GlobalSmartLoadingView', () => { }) it('rounds capacity percentages and hides column settings in embedded tables', async () => { - vi.mocked(listContainers).mockResolvedValue({ records: [{ ...container, capacityCbm: 100, maxWeightKg: 100000 }], page: 1, size: 100, total: 1, pages: 1 }) vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, items: [{ ...plan.items[0], volumeCbm: 0.187, grossWeightKg: 13.6 }], @@ -173,8 +172,8 @@ describe('GlobalSmartLoadingView', () => { const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() - expect(wrapper.text()).toContain('0.17%') - expect(wrapper.text()).toContain('0.01%') + expect(wrapper.text()).toContain('0.22%') + expect(wrapper.text()).toContain('0.04%') expect(wrapper.text()).not.toContain('0.169999999') expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0) }) diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts index 21f3891c..5485d1b1 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts @@ -18,10 +18,10 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ const container = { id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', - warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1, + warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'EDITING' as const, version: 1, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, - destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 28000, + destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, } diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts index 71921b49..06872a45 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts @@ -33,10 +33,28 @@ describe('FinanceCustomerAccountsView delivery', () => { it('keeps the account detail as a full page with traceable ledger controls', () => { expect(page).toContain('v-else-if="!detailOpen"') expect(page).toContain('返回客户账户') + expect(page).toContain('') + expect(page).toContain('') + expect(page).not.toContain('class="detail-toolbar"') expect(page).not.toContain(' { + for (const marker of ['class="term-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker) + expect(page).toContain('grid-template-columns:repeat(4,minmax(0,1fr))') + expect(page).toContain('grid-template-columns:minmax(180px,1fr) auto') + expect(page).toContain('class="surface-panel finance-account-detail__surface"') + expect(page).toContain('min-height:88px') + expect(page).toContain('class="detail-table-scroll"') + }) + it('shows only derived CNY summaries and no wallet actions', () => { for (const marker of ['未收', '待出账', '本期已收', '应付', 'CNY']) expect(page).toContain(marker) for (const forbidden of ['手动充值', '钱包', '外币', '发票', '利润']) expect(page).not.toContain(forbidden) diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.vue b/src/modules/finance/pages/FinanceCustomerAccountsView.vue index 550dc18a..aefa38b3 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.vue +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.vue @@ -9,6 +9,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import AdminPage from '@/shared/components/AdminPage.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' +import DetailPageLayout from '@/shared/components/DetailPageLayout.vue' import FormSection from '@/shared/components/FormSection.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' @@ -221,18 +222,25 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome diff --git a/src/modules/finance/pages/FinanceCustomerArapView.test.ts b/src/modules/finance/pages/FinanceCustomerArapView.test.ts index 7acd50a5..9d799497 100644 --- a/src/modules/finance/pages/FinanceCustomerArapView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerArapView.test.ts @@ -137,7 +137,8 @@ describe('FinanceCustomerArapView G2 operations', () => { await vm.openDetail(row); await flushPromises() expect(getFinanceFeeSource).toHaveBeenCalledWith('42') expect(document.body.textContent).toContain('IB-91') - expect(document.body.textContent).toContain('100.00 CNY') + expect(document.body.textContent).toContain('100.00') + expect(document.body.textContent).toContain('CNY') await vm.openReview(row); vm.reviewForm.action = 'APPROVE'; vm.reviewForm.reason = '核对无误'; await vm.submitReview() expect(reviewFinanceFeeSource).toHaveBeenCalledWith('42', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 }) @@ -167,6 +168,21 @@ describe('FinanceCustomerArapView G2 operations', () => { expect(wrapper.router.currentRoute.value.name).toBe('finance-customer-arap-detail') }) + it('uses the desktop detail layout for summary, source information, and operation history', async () => { + const wrapper = await mountView(['platform:finance:customer-arap:list'], '/finance/customer-arap/42') + expect(wrapper.find('.finance-arap-detail__layout').exists()).toBe(true) + expect(wrapper.find('.finance-arap-detail__surface').exists()).toBe(true) + expect(wrapper.find('.detail-page-layout__header').exists()).toBe(true) + expect(wrapper.find('.detail-page-layout__summary').exists()).toBe(true) + expect(wrapper.find('.arap-metrics').exists()).toBe(true) + expect(wrapper.findAll('.arap-metric')).toHaveLength(4) + expect(wrapper.find('.arap-information').text()).toContain('单据信息') + expect(wrapper.find('.arap-settlement').text()).toContain('审核与结算') + expect(wrapper.find('.operation-log').text()).toContain('操作记录') + expect(wrapper.find('.arap-information').text()).toContain('业务日期') + expect(wrapper.find('.arap-information').text()).not.toContain('T00:00:00') + }) + it('resolves blank detail customer names and keeps source labels business-facing', async () => { vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, customerName: '', sourceType: 'NEW_SOURCE' }) const wrapper = await mountView(['platform:finance:customer-arap:list'], '/finance/customer-arap/42') diff --git a/src/modules/finance/pages/FinanceCustomerArapView.vue b/src/modules/finance/pages/FinanceCustomerArapView.vue index e62a8021..918e1e12 100644 --- a/src/modules/finance/pages/FinanceCustomerArapView.vue +++ b/src/modules/finance/pages/FinanceCustomerArapView.vue @@ -10,6 +10,8 @@ import AdminPage from '@/shared/components/AdminPage.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import AppDialog from '@/shared/components/AppDialog.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' +import DetailPageLayout from '@/shared/components/DetailPageLayout.vue' +import FormSection from '@/shared/components/FormSection.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' @@ -260,12 +262,14 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou -
返回客户应收应付

费用来源详情

{{ selected?.documentNo || '费用来源' }} · {{ selected ? customerName(selected.customerId, selected.customerName) : '客户名称暂不可用' }}

审核参与结算
- - +
+ + + + + + +
@@ -283,18 +287,38 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts b/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts index 6515b5c7..c05cba7b 100644 --- a/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts @@ -79,9 +79,26 @@ describe('FinanceCustomerStatementsView', () => { expect(document.body.textContent).toContain('优惠调整') expect(document.body.textContent).toContain('创建人A') expect(document.body.textContent).toContain('2026-08-20 11:00') + expect(document.querySelector('[aria-label="客户对账单详情"]')).toBeTruthy() + expect(document.querySelector('[aria-label="所选对账单摘要"]')?.textContent).toContain('200.00 CNY') + expect(document.querySelector('.statement-detail-head')?.textContent).toContain('REC-501') + expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy() + expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy() expect(document.querySelector('.el-drawer')).toBeNull() }) + it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => { + const wrapper = await mountPage() + const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise; activeDetailTab: 'lines' | 'payments' } + await vm.openDetail(statement) + vm.activeDetailTab = 'payments' + await flushPromises() + + expect(document.querySelector('[aria-label="客户对账单支付历史"]')?.textContent).toContain('PAY-701') + expect(document.querySelectorAll('.statement-detail-panel .detail-page-layout')).toHaveLength(1) + expect(document.querySelectorAll('.statement-detail-panel .surface-panel')).toHaveLength(0) + }) + it('resolves customer names from the active customer list and never exposes internal customer or operator ids', async () => { const internalCustomerId = '2088123456789' const internalOperatorId = '1000001' diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.vue b/src/modules/finance/pages/FinanceCustomerStatementsView.vue index 95f1f53a..2e965af1 100644 --- a/src/modules/finance/pages/FinanceCustomerStatementsView.vue +++ b/src/modules/finance/pages/FinanceCustomerStatementsView.vue @@ -9,6 +9,8 @@ import AdminPage from '@/shared/components/AdminPage.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import AppDialog from '@/shared/components/AppDialog.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' +import DetailPageLayout from '@/shared/components/DetailPageLayout.vue' +import FormSection from '@/shared/components/FormSection.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' @@ -114,6 +116,30 @@ const columns: DataTableColumn[] = [ { key: 'updatedBy', label: '更新人', width: 130, slotName: 'updatedBy' }, { key: 'updatedAt', label: '更新时间', minWidth: 170, slotName: 'updatedAt' }, ] +const detailLineColumns: DataTableColumn[] = [ + { key: 'inboundCode', label: '入仓号', minWidth: 150, slotName: 'inboundCode' }, + { key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' }, + { key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' }, + { key: 'feeItemName', label: '费用项目', minWidth: 160 }, + { key: 'quantity', label: '计费量', width: 110, slotName: 'quantity' }, + { key: 'unitPrice', label: '单价(CNY)', width: 120, slotName: 'unitPrice' }, + { key: 'sourceAmount', label: '来源金额(CNY)', width: 150 }, + { key: 'amount', label: '账单金额(CNY)', width: 150 }, + { key: 'adjustmentReason', label: '调整说明', minWidth: 190 }, + { key: 'billNo', label: '对账单号', minWidth: 190, slotName: 'billNo' }, + { key: 'periodStart', label: '结算起始日', width: 130, slotName: 'periodStart' }, + { key: 'periodEnd', label: '结算结束日', width: 130, slotName: 'periodEnd' }, +] +const paymentColumns: DataTableColumn[] = [ + { key: 'customerName', label: '客户', minWidth: 200, slotName: 'customerName' }, + { key: 'transactionDate', label: '支付日期', width: 130, slotName: 'transactionDate' }, + { key: 'amount', label: '支付金额(CNY)', width: 150 }, + { key: 'method', label: '支付方式', width: 130, slotName: 'method' }, + { key: 'referenceNo', label: '支付流水号', minWidth: 180 }, + { key: 'createdBy', label: '登记人', minWidth: 140, slotName: 'createdBy' }, + { key: 'occurredAt', label: '登记时间', width: 170, slotName: 'occurredAt' }, + { key: 'remark', label: '备注', minWidth: 200 }, +] const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName.trim()]))) @@ -419,47 +445,67 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
-
-
已选择 {{ selectedId ? 1 : 0 }} 项{{ detail.billNo }} · {{ customerDisplayName(detail.customerId, detail.customerName) }}
- 清空 -
- - - - - - - - - - - - - - - - - - - - - + + + + + + + + + +
+ + + + + + + + + + +
+ +
+ +
+ + +
+ + + + + + + +
+
+ +
+
+
@@ -476,10 +522,25 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load() diff --git a/src/modules/finance/pages/FinancePresentation.test.ts b/src/modules/finance/pages/FinancePresentation.test.ts index 3f8b7507..06008a38 100644 --- a/src/modules/finance/pages/FinancePresentation.test.ts +++ b/src/modules/finance/pages/FinancePresentation.test.ts @@ -21,7 +21,7 @@ describe('finance presentation governance', () => { expect(financeRoutes).toContain("finance/settlement-receivables/:settlementId") expect(financeRoutes).toContain("finance/settlement-payables/:settlementId") expect(financeRoutes).toContain('showPageHeading: false') - expect(settlement).toContain('class="settlement-detail-page"') + expect(settlement).toContain('class="settlement-detail-page surface-panel"') expect(settlement).toContain('返回列表') expect(settlement).not.toContain('AppDrawer') expect(settlement).toContain('登记后剩余余额') @@ -29,6 +29,23 @@ describe('finance presentation governance', () => { expect(settlement).toContain('remainingAfter(paymentForm.amount)') }) + it('uses one continuous desktop detail surface with balanced summary and scroll-safe tables', () => { + expect(settlement).toContain('') + for (const title of ['基本信息', '费用明细', '修订记录']) expect(settlement).toContain(`title="${title}"`) + expect(settlement).toContain(':title="kind === \'receivable\' ? \'收款记录\' : \'付款记录\'"') + expect(settlement.match(/class="detail-table-scroll"/g)).toHaveLength(3) + expect(settlement).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))') + expect(settlement).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }') + expect(settlement).not.toContain('class="detail-section surface-panel"') + expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2) + expect(settlement.match(/class="settlement-operation-form"/g)).toHaveLength(2) + expect(settlement).toContain('grid-template-columns: repeat(5, minmax(0, 1fr))') + expect(settlement).toContain('grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 16px') + }) + it('uses optional all-customer filters without a customer-selection blocker', () => { for (const source of [settlement, statements, arap]) { expect(source).toContain("placeholder: '全部客户'") diff --git a/src/modules/inbounds/review/MeasurementReviewPanel.test.ts b/src/modules/inbounds/review/MeasurementReviewPanel.test.ts index 77aa12ae..366d2145 100644 --- a/src/modules/inbounds/review/MeasurementReviewPanel.test.ts +++ b/src/modules/inbounds/review/MeasurementReviewPanel.test.ts @@ -62,9 +62,12 @@ describe('MeasurementReviewPanel', () => { expect(wrapper.text()).toContain('柏悦客户 · 海运') expect(wrapper.text()).toContain('合计货物重量') expect(wrapper.text()).toContain('全部称重图片') + expect(wrapper.text()).toContain('托盘数量') + expect(wrapper.text()).toContain('1 托') expect(wrapper.text()).toContain('称重图片') expect(wrapper.text()).toContain('货物重量') expect(wrapper.text()).toContain('110 kg') + expect(wrapper.text()).toContain('容量 1.2 m³') expect(wrapper.text()).not.toContain('申报毛重') expect(wrapper.text()).not.toContain('托盘皮重') expect(wrapper.text()).not.toContain('OCR 原文') @@ -110,6 +113,7 @@ describe('MeasurementReviewPanel', () => { '货物重量120 kg', ]) expect(wrapper.find('.weight-overview').text()).toContain('330 kg') + expect(wrapper.find('.weight-overview').text()).toContain('3 托') expect(downloadWeightThumbnail).toHaveBeenCalledTimes(3) }) diff --git a/src/modules/inbounds/review/MeasurementReviewPanel.vue b/src/modules/inbounds/review/MeasurementReviewPanel.vue index 0c63d109..67b77530 100644 --- a/src/modules/inbounds/review/MeasurementReviewPanel.vue +++ b/src/modules/inbounds/review/MeasurementReviewPanel.vue @@ -57,7 +57,6 @@ const canRelease = computed(() => access.can('firstleg:inbound:exception-release const customerId = computed(() => props.customerId || props.tenantId || '') const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING') const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED') -const confirmedWeights = computed(() => detail.value?.weightResults.filter((item) => item.confirmedAt && item.netWeightKg !== null).length ?? 0) const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交')) function formatNumber(value: number | null | undefined, unit = '') { @@ -262,7 +261,7 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie diff --git a/src/shared/domain/containerSpecifications.ts b/src/shared/domain/containerSpecifications.ts new file mode 100644 index 00000000..1a4925ba --- /dev/null +++ b/src/shared/domain/containerSpecifications.ts @@ -0,0 +1,23 @@ +export const CONTAINER_SPECIFICATIONS = [ + { type: '20GP', capacityCbm: 33, maxWeightKg: 28000 }, + { type: '40GP', capacityCbm: 67, maxWeightKg: 28000 }, + { type: '40HC', capacityCbm: 76, maxWeightKg: 28000 }, + { type: '40NR', capacityCbm: 67, maxWeightKg: 28000 }, + { type: '45HC', capacityCbm: 86, maxWeightKg: 28000 }, +] as const + +export type ContainerSpecification = (typeof CONTAINER_SPECIFICATIONS)[number] + +export function containerSpecification(containerType: string | null | undefined): ContainerSpecification | null { + const normalized = String(containerType || '').trim().toUpperCase() + return CONTAINER_SPECIFICATIONS.find((item) => item.type === normalized) || null +} + +export function applyContainerSpecification(target: T, containerType: string) { + const specification = containerSpecification(containerType) + if (!specification) return false + target.containerType = specification.type + target.capacityCbm = specification.capacityCbm + target.maxWeightKg = specification.maxWeightKg + return true +}