From 10ff84497ff97a6a69378e5a97c6b0694f01c42c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 20:18:59 +0800 Subject: [PATCH] fix(admin): stabilize released booking association --- .../pages/ContainersView.test.ts | 31 +++++++++++------ .../pages/ContainersView.vue | 9 +++-- .../api/platformOceanBookings.test.ts | 6 ++-- .../pages/OceanBookingsView.test.ts | 34 ++++++++++++++++++- .../pages/OceanBookingsView.vue | 2 +- src/modules/ocean-bookings/types.ts | 3 +- 6 files changed, 63 insertions(+), 22 deletions(-) diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 87f4423c..8871e7d4 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -11,6 +11,7 @@ import { listPlatformPartners } from '@/modules/master-data' import { listSuppliers } from '@/modules/suppliers' import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings' import { getContainer, getContainerOverview, listContainers, updateContainer } from '../api/platformContainerLoading' +import AppDialog from '@/shared/components/AppDialog.vue' vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() })) @@ -265,8 +266,14 @@ describe('ContainersView', () => { expect(source).not.toContain('v-model="form.maxWeightKg"') }) - it('loads one frozen candidate page per open and ignores repeated clicks while it is pending', async () => { + it('keeps OB-260825-0183 selectable for CAB-260825-0215 when only capacity snapshots differ and blocks duplicate waves', async () => { let resolveCandidates!: (value: Awaited>) => void + vi.mocked(listContainers).mockResolvedValue({ records: [{ + id: '215', planId: null, bookingNo: 'CB-260825-0215', cabinetNo: 'CAB-260825-0215', containerNo: null, + sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', + channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '45HC', planStatus: 'WAIT_LOADING', + capacityCbm: 86, maxWeightKg: 29000, version: 7, + }], page: 1, size: 20, total: 1, pages: 1 } as never) vi.mocked(listOceanBookingLinkCandidates).mockReturnValueOnce(new Promise((resolve) => { resolveCandidates = resolve })) const router = createTestRouter() await router.push('/'); await router.isReady() @@ -275,17 +282,20 @@ describe('ContainersView', () => { await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click') await flushPromises() const linkAction = Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')) - .find((item) => item.textContent?.includes('关联已放舱订舱'))! + .filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)! linkAction.click() linkAction.click() expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1) - expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelCode: 'ZBYHK', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, page: 1, size: 200 }) + expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelCode: 'ZBYHK', containerType: '45HC', page: 1, size: 200 }) expect(getOceanBooking).not.toHaveBeenCalled() - resolveCandidates({ records: [{ oceanBookingId: '101', bookingNo: 'OB-1', requirementId: '202', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 }) + expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')) + .toContain('暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求') + resolveCandidates({ records: [{ oceanBookingId: '183', bookingNo: 'OB-260825-0183', requirementId: '45183', containerType: '45HC', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 }) await flushPromises() - expect(document.body.textContent).toContain('OB-1 · 40HC × 1') + expect(document.body.textContent).toContain('OB-260825-0183 · 45HC × 1') + expect((wrapper.vm as unknown as { linkCandidates: Array<{ disabled: boolean }> }).linkCandidates[0]?.disabled).toBe(false) wrapper.unmount() }) @@ -303,10 +313,10 @@ describe('ContainersView', () => { await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click') await flushPromises() Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')) - .find((item) => item.textContent?.includes('关联已放舱订舱'))!.click() + .filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click() await flushPromises() - Array.from(document.body.querySelectorAll('button')) - .find((button) => button.textContent?.trim() === '取消')!.click() + wrapper.findAllComponents(AppDialog) + .find((dialog) => dialog.props('title') === '关联已放舱订舱')!.vm.$emit('update:modelValue', false) await flushPromises() await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click') @@ -319,8 +329,9 @@ describe('ContainersView', () => { await flushPromises() expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(2) - expect(document.body.textContent).toContain('OB-LATEST') - expect(document.body.textContent).not.toContain('OB-STALE') + const candidateLabels = (wrapper.vm as unknown as { linkCandidates: Array<{ label: string }> }).linkCandidates.map((candidate) => candidate.label) + expect(candidateLabels.some((label) => label.includes('OB-LATEST'))).toBe(true) + expect(candidateLabels.some((label) => label.includes('OB-STALE'))).toBe(false) wrapper.unmount() }) diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 212391de..f1b070f4 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -386,10 +386,10 @@ async function openLink(source: object) { const row = source as ContainerBookingResource const warehouseId = optional(row.warehouseId) const channelCode = optional(row.channelCode) - if (!warehouseId || !channelCode || row.capacityCbm == null || row.maxWeightKg == null) { + if (!warehouseId || !channelCode || !optional(row.containerType)) { linkTarget.value = row linkCandidates.value = [] - linkError.value = { message: '当前模拟柜缺少仓库、渠道或固定柜型规格,无法查询可关联订舱' } + linkError.value = { message: '当前模拟柜缺少仓库、渠道或柜型,无法查询可关联订舱' } linkOpen.value = true return } @@ -404,7 +404,6 @@ async function openLink(source: object) { try { const result = await listOceanBookingLinkCandidates({ warehouseId, channelCode, containerType: row.containerType, - capacityCbm: row.capacityCbm, maxWeightKg: row.maxWeightKg, page: 1, size: 200, }) if (sequence !== linkSequence || linkTarget.value?.id !== row.id) return @@ -657,8 +656,8 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin - -

仓库、渠道、柜型、容量和限重必须与当前模拟柜一致;不一致的候选不可选择。

+ +

仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。

diff --git a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts index ce575c44..05c697de 100644 --- a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts +++ b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts @@ -14,18 +14,18 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({ describe('ocean bookings api', () => { it('normalizes pagination and long ids', async () => { - vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, customerId: 456, shipperName: '发货企业', consigneeName: '收货企业', containerCount: '3' }], page: '2', size: '20', total: '21', pages: '2' } }) + vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, bookingNo: 'OB-260825-0183', customerId: 456, shipperName: '发货企业', consigneeName: '收货企业', containerCount: '1' }], page: '2', size: '20', total: '21', pages: '2' } }) const result = await listOceanBookings({ page: 1, size: 10 }) expect(result.page).toBe(2) expect(result.total).toBe(21) expect(result.records[0]?.id).toBe('123') expect(result.records[0]?.customerId).toBe('456') - expect(result.records[0]).toMatchObject({ shipperName: '发货企业', consigneeName: '收货企业', containerCount: 3 }) + expect(result.records[0]).toMatchObject({ bookingNo: 'OB-260825-0183', shipperName: '发货企业', consigneeName: '收货企业', containerCount: 1 }) }) it('uses the frozen single-query link candidate contract and normalizes long ids', async () => { vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: { records: [{ oceanBookingId: 9007199254740993, bookingNo: 'OB-1', requirementId: 9007199254740994, containerType: '40HC', capacityCbm: '76', maxWeightKg: '28000', requiredQuantity: '2', allocatedQuantity: '1', remainingQuantity: '1', customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班' }], page: '1', size: '200', total: '1', pages: '1' } }) - const query = { warehouseId: '8', channelCode: 'ZBYHK', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, page: 1, size: 200 } + const query = { warehouseId: '8', channelCode: 'ZBYHK', containerType: '40HC', page: 1, size: 200 } const result = await listOceanBookingLinkCandidates(query) diff --git a/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts index b0f5628f..a714952c 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts @@ -1,10 +1,24 @@ -import { describe, expect, it } from 'vitest' +import ElementPlus from 'element-plus' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' +import OceanBookingsView from './OceanBookingsView.vue' +import { listOceanBookings } from '../api/platformOceanBookings' + +const routerPush = vi.hoisted(() => vi.fn()) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) +vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [] }) })) +vi.mock('../api/platformOceanBookings', () => ({ listOceanBookings: vi.fn() })) const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8') describe('OceanBookingsView', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }) + }) it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")) expect(source).toContain("fixed: 'left'") @@ -41,6 +55,24 @@ describe('OceanBookingsView', () => { } }) + it('displays the server containerCount as the booking requirement total without detail N+1', async () => { + vi.mocked(listOceanBookings).mockResolvedValueOnce({ records: [{ + id: '183', bookingNo: 'OB-260825-0183', status: 'RELEASED', containerCount: 1, + customerName: '回归客户', vesselName: null, voyageNo: null, etd: null, eta: null, updatedAt: null, + }], page: 1, size: 20, total: 1, pages: 1 } as never) + const wrapper = mount(OceanBookingsView, { global: { plugins: [ElementPlus] } }) + + await (wrapper.vm as unknown as { load: () => Promise }).load() + await flushPromises() + + const tableText = wrapper.find('.el-table__body-wrapper').text() + expect(tableText).toContain('OB-260825-0183') + expect(tableText).toContain('1') + expect(source).toContain("label: '订舱柜量'") + expect(source).not.toContain('getOceanBooking') + wrapper.unmount() + }) + it('loads active customers by name and stays usable on narrow screens', () => { expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })") expect(source).toContain('label: customer.tenantName') diff --git a/src/modules/ocean-bookings/pages/OceanBookingsView.vue b/src/modules/ocean-bookings/pages/OceanBookingsView.vue index 29b218b7..bb0f6ef4 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingsView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingsView.vue @@ -41,7 +41,7 @@ const columns = [ { prop: 'billOfLadingNo', label: '提单号', minWidth: 155, formatter: (row: OceanBookingResource) => row.billOfLadingNo || '—' }, { prop: 'shipperName', label: '提单发货人', minWidth: 160, formatter: (row: OceanBookingResource) => row.shipperName || '—' }, { prop: 'consigneeName', label: '提单收货人', minWidth: 160, formatter: (row: OceanBookingResource) => row.consigneeName || '—' }, - { prop: 'containerCount', label: '柜量', width: 90, align: 'right' }, + { prop: 'containerCount', label: '订舱柜量', width: 100, align: 'right' }, { prop: 'carrierName', label: '船公司', minWidth: 130 }, { prop: 'bookingAgent', label: '订舱代理', minWidth: 140 }, { prop: 'vesselName', label: '船名 / 航次', minWidth: 175, slotName: 'vessel' }, diff --git a/src/modules/ocean-bookings/types.ts b/src/modules/ocean-bookings/types.ts index 5801024e..add33cb4 100644 --- a/src/modules/ocean-bookings/types.ts +++ b/src/modules/ocean-bookings/types.ts @@ -35,6 +35,7 @@ export interface OceanBookingDraftData { export interface OceanBookingResource { id: string; bookingNo: string | null; status: OceanBookingStatus; version: number customerId: string | null; customerName: string | null; billOfLadingNo: string | null + /** 订舱柜型需求 quantity 的总和,不是已关联真实柜数。 */ shipperName: string | null; consigneeName: string | null; containerCount: number channelCode: string | null; channelName: string | null; warehouseId: string | null; warehouseName: string | null shippingCompanyId: string | null; shippingCompanyName: string | null; bookingAgentId: string | null; bookingAgentName: string | null @@ -71,8 +72,6 @@ export interface OceanBookingLinkCandidateQuery { warehouseId: string channelCode: string containerType: string - capacityCbm: number - maxWeightKg: number page: number size: number }