优化
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
|
||||
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
import OceanBookingLinkDialog from './OceanBookingLinkDialog.vue'
|
||||
|
||||
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ linkContainerOceanBooking: vi.fn() }))
|
||||
|
||||
const container = {
|
||||
id: '9001', cabinetNo: 'CAB-1', bookingNo: 'CB-1', containerNo: null, planId: '7001', planStatus: 'EDITING',
|
||||
sourceType: 'SIMULATION', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班',
|
||||
oceanBookingId: 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: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null,
|
||||
loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null,
|
||||
actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 7,
|
||||
} satisfies ContainerBookingResource
|
||||
|
||||
const candidate = {
|
||||
oceanBookingId: '101', bookingNo: 'OB-101', requirementId: '202', containerType: '40HC', capacityCbm: 76,
|
||||
maxWeightKg: 28000, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1,
|
||||
customerName: '测试客户', warehouseName: '义乌仓', channelName: '美森正班',
|
||||
}
|
||||
|
||||
describe('OceanBookingLinkDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listOceanBookingLinkCandidates).mockReset().mockResolvedValue({ records: [candidate], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(linkContainerOceanBooking).mockReset().mockResolvedValue({ ...container, oceanBookingId: '101', oceanBookingRequirementId: '202', version: 8 } as never)
|
||||
})
|
||||
|
||||
it('loads matching released-booking requirements when opened', async () => {
|
||||
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelCode: 'ZBYHK', containerType: '40HC', page: 1, size: 200 })
|
||||
expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 40HC × 1')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[' mscu1234567 ', 'MSCU1234567'],
|
||||
['', null],
|
||||
])('normalizes and submits the optional real container number %#', async (input, expected) => {
|
||||
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as { candidateKey: string; containerNo: string; save: () => Promise<void> }
|
||||
vm.candidateKey = '101:202'
|
||||
vm.containerNo = input
|
||||
await vm.save()
|
||||
expect(linkContainerOceanBooking).toHaveBeenCalledExactlyOnceWith('9001', {
|
||||
oceanBookingId: '101', requirementId: '202', containerNo: expected, expectedVersion: 7,
|
||||
})
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false])
|
||||
expect(wrapper.emitted('linked')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; container: ContainerBookingResource | null }>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
linked: [container: ContainerBookingResource]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const candidateKey = ref('')
|
||||
const containerNo = ref('')
|
||||
const candidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string }>>([])
|
||||
let requestSequence = 0
|
||||
|
||||
function optional(value: string | null | undefined) { return value?.trim() || null }
|
||||
function normalizeContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() }
|
||||
function close() { emit('update:modelValue', false) }
|
||||
|
||||
async function loadCandidates() {
|
||||
const target = props.container
|
||||
candidateKey.value = ''
|
||||
containerNo.value = ''
|
||||
candidates.value = []
|
||||
error.value = null
|
||||
if (!target) return
|
||||
const warehouseId = optional(target.warehouseId)
|
||||
const channelCode = optional(target.channelCode)
|
||||
const containerType = optional(target.containerType)
|
||||
if (!warehouseId || !channelCode || !containerType) {
|
||||
error.value = { message: '当前模拟柜缺少仓库、渠道或柜型,无法查询可关联订舱' }
|
||||
return
|
||||
}
|
||||
const sequence = ++requestSequence
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listOceanBookingLinkCandidates({ warehouseId, channelCode, containerType, page: 1, size: 200 })
|
||||
if (sequence !== requestSequence || !props.modelValue || props.container?.id !== target.id) return
|
||||
candidates.value = result.records.map((candidate) => ({
|
||||
key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
|
||||
oceanBookingId: candidate.oceanBookingId,
|
||||
requirementId: candidate.requirementId,
|
||||
label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName} · ${candidate.warehouseName} · ${candidate.channelName}`,
|
||||
}))
|
||||
} catch (cause) {
|
||||
if (sequence === requestSequence) error.value = getApiErrorDetails(cause, '已放舱订舱候选加载失败')
|
||||
} finally {
|
||||
if (sequence === requestSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const target = props.container
|
||||
const candidate = candidates.value.find((item) => item.key === candidateKey.value)
|
||||
const normalized = normalizeContainerNo(containerNo.value)
|
||||
containerNo.value = normalized
|
||||
if (!target || !candidate) {
|
||||
error.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const linked = await linkContainerOceanBooking(target.id, {
|
||||
oceanBookingId: candidate.oceanBookingId,
|
||||
requirementId: candidate.requirementId,
|
||||
containerNo: normalized || null,
|
||||
expectedVersion: target.version,
|
||||
})
|
||||
close()
|
||||
ElMessage.success(normalized ? '订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变' : '订舱已关联,真实集装箱号待司机提柜时录入')
|
||||
emit('linked', linked)
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '关联真实订舱柜失败')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) void loadCandidates()
|
||||
else {
|
||||
requestSequence += 1
|
||||
loading.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
defineExpose({ candidates, candidateKey, containerNo, save })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog :model-value="modelValue" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
|
||||
<el-alert title="关联后保留当前柜次号、装柜计划和明细,并补充订舱快照;真实集装箱号可待司机提柜时录入。" type="info" :closable="false" />
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="柜次号"><el-input :model-value="container?.cabinetNo || ''" disabled /></el-form-item>
|
||||
<el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item>
|
||||
<el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item>
|
||||
</el-form>
|
||||
<p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p>
|
||||
<LoadErrorState v-if="error" compact title="关联失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user