优化
This commit is contained in:
@@ -15,7 +15,7 @@ vi.mock('@/shared/api/http', () => ({
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
const container = { id: 9007199254740993, planId: 9007199254740994, warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', version: '2', capacityCbm: '76', maxWeightKg: '28000' }
|
||||
const container = { id: 9007199254740993, planId: 9007199254740994, warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, 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', () => {
|
||||
@@ -51,10 +51,10 @@ describe('platform container loading API', () => {
|
||||
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: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
|
||||
await linkContainerOceanBooking('7', { oceanBookingId: '101', requirementId: '202', expectedVersion: 3 })
|
||||
await linkContainerOceanBooking('7', { realContainerBookingId: '101', expectedVersion: 3 })
|
||||
await unlinkContainerOceanBooking('7', 4, '空柜改配')
|
||||
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.post).toHaveBeenNthCalledWith(2, '/platform/v1/containers/7/ocean-booking-link', { realContainerBookingId: '101', expectedVersion: 3 })
|
||||
expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/ocean-booking-link', { data: { expectedVersion: 4, reason: '空柜改配' } })
|
||||
})
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
|
||||
...raw,
|
||||
id: id(raw.id),
|
||||
planId: optionalId(raw.planId),
|
||||
mergedIntoContainerBookingId: optionalId(raw.mergedIntoContainerBookingId),
|
||||
warehouseId: id(raw.warehouseId),
|
||||
oceanBookingId: optionalId(raw.oceanBookingId),
|
||||
oceanBookingRequirementId: optionalId(raw.oceanBookingRequirementId),
|
||||
|
||||
@@ -9,17 +9,17 @@ import ContainersView from './ContainersView.vue'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { listPlatformPartners } from '@/modules/master-data'
|
||||
import { listSuppliers } from '@/modules/suppliers'
|
||||
import { listOceanBookings } from '@/modules/ocean-bookings'
|
||||
import { getOceanBooking, listOceanBookings } from '@/modules/ocean-bookings'
|
||||
import { getContainer, getContainerOverview, listContainers } from '../api/platformContainerLoading'
|
||||
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
|
||||
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
|
||||
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookings: vi.fn() }))
|
||||
vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOceanBookings: vi.fn(), listWarehouseLogisticsChannels: vi.fn() }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
|
||||
const accessCan = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainerSimulation: vi.fn(), linkContainerOceanBooking: vi.fn(), unlinkContainerOceanBooking: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({ history: createMemoryHistory(), routes: [
|
||||
@@ -38,6 +38,7 @@ 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(getOceanBooking).mockResolvedValue({} as never)
|
||||
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: [] })
|
||||
@@ -246,12 +247,14 @@ describe('ContainersView', () => {
|
||||
|
||||
it('allows first linking after preloading but only offers unlinking for wait-loading containers', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain("!row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)")
|
||||
expect(source).toContain("row.oceanBookingId && row.planStatus === 'WAIT_LOADING'")
|
||||
expect(source).toContain("row.sourceType === 'SIMULATION' && row.recordStatus === 'ACTIVE' && !row.oceanBookingId")
|
||||
expect(source).toContain("row.sourceType === 'SIMULATION' && row.recordStatus === 'ACTIVE' && row.oceanBookingId")
|
||||
expect(source).toContain('已预排货但尚未进入 PDA、取证或单证阶段的模拟柜仍可首次关联')
|
||||
expect(source).toContain('柜次号与装柜明细保持不变')
|
||||
expect(source).toContain('只有尚未排货的空柜可以解除')
|
||||
expect(source).toContain('linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, expectedVersion: target.version })')
|
||||
expect(source).toContain('booking.containers')
|
||||
expect(source).toContain('realContainerBookingId: container.containerBookingId')
|
||||
expect(source).toContain('linkContainerOceanBooking(target.id, { realContainerBookingId: candidate.realContainerBookingId, expectedVersion: target.version })')
|
||||
expect(source).toContain('unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingDetail, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
|
||||
import { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
|
||||
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -90,7 +90,7 @@ const linkSaving = ref(false)
|
||||
const linkError = ref<ApiErrorDetails | null>(null)
|
||||
const linkTarget = ref<ContainerBookingResource | null>(null)
|
||||
const linkCandidateKey = ref('')
|
||||
const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; detail: OceanBookingDetail }>>([])
|
||||
const linkCandidates = ref<Array<{ key: string; realContainerBookingId: string; label: string }>>([])
|
||||
const selectedTruckingSupplierId = ref('')
|
||||
const unlinkOpen = ref(false)
|
||||
const unlinkReason = ref('')
|
||||
@@ -365,20 +365,17 @@ async function openLink(source: object) {
|
||||
const bookings = (await Promise.all([loadBookingStatus('CONFIRMED'), loadBookingStatus('RELEASED')])).flat()
|
||||
.filter((booking) => booking.warehouseId === row.warehouseId && booking.channelCode === row.channelCode)
|
||||
const details = await Promise.all(bookings.map((booking) => getOceanBooking(booking.id)))
|
||||
linkCandidates.value = details.flatMap((booking) => {
|
||||
const allocated = new Set(booking.containerAllocations.filter((item) => item.containerBookingId).map((item) => item.requirementId))
|
||||
return booking.containerRequirements.filter((requirement) => requirement.id
|
||||
&& !allocated.has(requirement.id)
|
||||
&& requirement.containerType === row.containerType
|
||||
&& Number(requirement.capacityCbm) === Number(row.capacityCbm)
|
||||
&& Number(requirement.maxWeightKg) === Number(row.maxWeightKg)).map((requirement) => ({
|
||||
key: `${booking.id}:${requirement.id}`,
|
||||
oceanBookingId: booking.id,
|
||||
requirementId: requirement.id!,
|
||||
detail: booking,
|
||||
label: `${booking.bookingNo || '订舱号待补'} · ${requirement.containerType} · ${booking.customerName || '未关联客户'}`,
|
||||
}))
|
||||
})
|
||||
linkCandidates.value = details.flatMap((booking) => booking.containers
|
||||
.filter((container) => container.warehouseId === row.warehouseId
|
||||
&& container.channelCode === row.channelCode
|
||||
&& container.containerType === row.containerType
|
||||
&& Number(container.capacityCbm) === Number(row.capacityCbm)
|
||||
&& Number(container.maxWeightKg) === Number(row.maxWeightKg))
|
||||
.map((container) => ({
|
||||
key: container.containerBookingId,
|
||||
realContainerBookingId: container.containerBookingId,
|
||||
label: `${booking.bookingNo || '订舱号待补'} · 集装箱号 ${container.containerNo} · 柜次号 ${container.cabinetNo} · ${container.containerType}`,
|
||||
})))
|
||||
} catch (error) { linkError.value = getApiErrorDetails(error, '真实订舱柜候选加载失败') }
|
||||
finally { linkLoading.value = false }
|
||||
}
|
||||
@@ -389,7 +386,7 @@ async function saveLink() {
|
||||
linkSaving.value = true
|
||||
linkError.value = null
|
||||
try {
|
||||
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, expectedVersion: target.version })
|
||||
await linkContainerOceanBooking(target.id, { realContainerBookingId: candidate.realContainerBookingId, expectedVersion: target.version })
|
||||
linkOpen.value = false
|
||||
ElMessage.success('已关联真实订舱柜,柜次号与装柜明细保持不变')
|
||||
await loadRows()
|
||||
@@ -600,7 +597,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联真实订舱柜</el-dropdown-item><el-dropdown-item v-if="canEdit && row.oceanBookingId && row.planStatus === 'WAIT_LOADING'" command="unlink"><Unlink :size="15" />解除订舱关联</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && row.recordStatus === 'ACTIVE' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联真实订舱柜</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && row.recordStatus === 'ACTIVE' && row.oceanBookingId && row.planStatus === 'WAIT_LOADING'" command="unlink"><Unlink :size="15" />解除订舱关联</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
|
||||
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号待录入' }}</small></span><span v-else>未关联</span></template>
|
||||
<template #containerNo="{ row }">{{ row.containerNo || '待录入' }}</template>
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface ContainerBookingResource {
|
||||
bookingNo: string
|
||||
containerNo: string | null
|
||||
cabinetNo: string
|
||||
sourceType?: 'SIMULATION' | 'OCEAN_BOOKING'
|
||||
recordStatus?: 'ACTIVE' | 'MERGED'
|
||||
mergedIntoContainerBookingId?: string | null
|
||||
warehouseId: string
|
||||
warehouseName: string | null
|
||||
channelCode: string | null
|
||||
@@ -52,8 +55,7 @@ export interface ContainerSimulationCreateRequest {
|
||||
}
|
||||
|
||||
export interface ContainerOceanBookingLinkRequest {
|
||||
oceanBookingId: string
|
||||
requirementId: string
|
||||
realContainerBookingId: string
|
||||
expectedVersion: number
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user