Release/test 合并上生产 #2
@@ -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<ReturnType<typeof listOceanBookingLinkCandidates>>) => 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<HTMLElement>('.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<HTMLElement>('.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<HTMLButtonElement>('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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey || !linkContainerNo.trim()" @confirm="saveLink">
|
||||
<el-alert title="关联后保留当前柜次号、装柜计划和明细,仅补充订舱快照及港口取得的真实集装箱号。" type="info" :closable="false" />
|
||||
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无可关联的已放舱订舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号" required><el-input v-model="linkContainerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" /></el-form-item></el-form>
|
||||
<p class="dialog-hint">仓库、渠道、柜型、容量和限重必须与当前模拟柜一致;不一致的候选不可选择。</p>
|
||||
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号" required><el-input v-model="linkContainerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" /></el-form-item></el-form>
|
||||
<p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p>
|
||||
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<void> }).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')
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user