This commit is contained in:
2026-08-18 16:09:43 +08:00
parent f9ae2d94cb
commit 21249d1dde
52 changed files with 980 additions and 698 deletions
@@ -56,10 +56,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', { realContainerBookingId: '101', expectedVersion: 3 })
await linkContainerOceanBooking('7', { oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', 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', { realContainerBookingId: '101', expectedVersion: 3 })
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/containers/7/ocean-booking-link', { oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 3 })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/ocean-booking-link', { data: { expectedVersion: 4, reason: '空柜改配' } })
})
@@ -246,17 +246,17 @@ describe('ContainersView', () => {
expect(source).not.toContain('v-model="form.maxWeightKg"')
})
it('allows first linking after preloading but only offers unlinking for wait-loading containers', () => {
it('links a simulation to a released requirement and records the port container number', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
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('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())')
expect(source).toContain("listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })")
expect(source).toContain('booking.containerRequirements')
expect(source).toContain('booking.containers.filter((container) => container.requirementId === requirement.requirementId).length')
expect(source).toContain('请输入港口取得的真实集装箱号')
expect(source).toContain('oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo')
expect(source).toContain(':disabled="candidate.disabled"')
expect(source).not.toContain('realContainerBookingId')
expect(source).not.toContain('MERGED')
expect(source).not.toContain('解除订舱关联')
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert, Unlink } from '@lucide/vue'
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -20,7 +20,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
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'
import { cancelContainerException, completeContainerClearance, createContainerSimulation, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, linkContainerOceanBooking, listContainers, markContainerException, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types'
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
@@ -89,10 +89,9 @@ 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; realContainerBookingId: string; label: string }>>([])
const linkContainerNo = ref('')
const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; disabled: boolean }>>([])
const selectedTruckingSupplierId = ref('')
const unlinkOpen = ref(false)
const unlinkReason = ref('')
let listSequence = 0
let detailSequence = 0
@@ -353,71 +352,60 @@ async function saveSimulation() {
} catch (error) { simulationError.value = getApiErrorDetails(error, '模拟柜创建失败') }
finally { simulationSaving.value = false }
}
async function loadBookingStatus(status: 'CONFIRMED' | 'RELEASED') {
const first = await listOceanBookings({ page: 1, size: 200, status })
async function loadReleasedBookings() {
const first = await listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })
if (first.pages <= 1) return first.records
const rest = await Promise.all(Array.from({ length: first.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200, status })))
const rest = await Promise.all(Array.from({ length: first.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200, status: 'RELEASED' })))
return [first, ...rest].flatMap((result) => result.records)
}
async function openLink(source: object) {
const row = source as ContainerBookingResource
linkTarget.value = row
linkCandidateKey.value = ''
linkContainerNo.value = ''
linkCandidates.value = []
linkError.value = null
linkOpen.value = true
linkLoading.value = true
try {
const bookings = (await Promise.all([loadBookingStatus('CONFIRMED'), loadBookingStatus('RELEASED')])).flat()
.filter((booking) => booking.warehouseId === row.warehouseId && booking.channelCode === row.channelCode)
const bookings = await loadReleasedBookings()
const details = await Promise.all(bookings.map((booking) => getOceanBooking(booking.id)))
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, '真实订舱柜候选加载失败') }
linkCandidates.value = details.flatMap((booking) => booking.containerRequirements
.map((requirement) => ({ ...requirement, remainingQuantity: Math.max(0, Number(requirement.quantity) - booking.containers.filter((container) => container.requirementId === requirement.requirementId).length) }))
.filter((requirement) => Boolean(requirement.requirementId) && Number(requirement.remainingQuantity) > 0)
.map((requirement) => {
const specificationMatches = booking.warehouseId === row.warehouseId
&& booking.channelCode === row.channelCode
&& requirement.containerType === row.containerType
&& Number(requirement.capacityCbm) === Number(row.capacityCbm)
&& Number(requirement.maxWeightKg) === Number(row.maxWeightKg)
return {
key: `${booking.id}:${requirement.requirementId}`,
oceanBookingId: booking.id,
requirementId: String(requirement.requirementId),
disabled: !specificationMatches,
label: `${booking.bookingNo || '订舱号待补'} · ${requirement.containerType} × ${requirement.remainingQuantity} · ${booking.customerName || '未关联客户'} · ${booking.warehouseName || '仓库待补'} · ${booking.channelName || '渠道待补'}${specificationMatches ? '' : '(与当前柜规格不一致)'}`,
}
}))
} catch (error) { linkError.value = getApiErrorDetails(error, '已放舱订舱候选加载失败') }
finally { linkLoading.value = false }
}
async function saveLink() {
const target = linkTarget.value
const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value)
if (!target || !candidate) { linkError.value = { message: '请选择要关联的真实订舱柜' }; return }
const containerNo = linkContainerNo.value.trim()
if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return }
if (!containerNo) { linkError.value = { message: '请输入港口取得的真实集装箱号' }; return }
linkSaving.value = true
linkError.value = null
try {
await linkContainerOceanBooking(target.id, { realContainerBookingId: candidate.realContainerBookingId, expectedVersion: target.version })
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo, expectedVersion: target.version })
linkOpen.value = false
ElMessage.success('已关联真实订舱柜,柜次号与装柜明细保持不变')
ElMessage.success('订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变')
await loadRows()
} catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
finally { linkSaving.value = false }
}
function openUnlink(source: object) {
linkTarget.value = source as ContainerBookingResource
unlinkReason.value = ''
linkError.value = null
unlinkOpen.value = true
}
async function saveUnlink() {
const target = linkTarget.value
if (!target || !unlinkReason.value.trim()) { linkError.value = { message: '请输入解除关联原因' }; return }
linkSaving.value = true
linkError.value = null
try {
await unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())
unlinkOpen.value = false
ElMessage.success('真实订舱关联已解除,柜次号保持不变')
await loadRows()
} catch (error) { linkError.value = getApiErrorDetails(error, '解除真实订舱关联失败') }
finally { linkSaving.value = false }
}
async function loadRows() {
const sequence = ++listSequence
loading.value = true; loadError.value = null
@@ -449,7 +437,6 @@ async function openDetail(source: object) {
function handleMoreAction(command: string, source: object) {
const row = source as ContainerBookingResource
if (command === 'link') { void openLink(row); return }
if (command === 'unlink') { openUnlink(row); return }
if (command === 'overview') { void openDetail(row); return }
detailTarget.value = row
void openDetail(row).then(() => {
@@ -603,7 +590,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.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 #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.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :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>
@@ -642,19 +629,13 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="linkOpen" title="关联真实订舱柜" width="min(620px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey" @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" /></el-select></el-form-item></el-form>
<p class="dialog-hint">已预排货但尚未进入 PDA、取证或单证阶段的模拟柜仍可首次关联;最终状态门禁以后端为准。</p>
<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>
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="unlinkOpen" title="解除真实订舱关联" width="min(520px, calc(100vw - 28px))" confirm-text="确认解除" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!unlinkReason.trim()" @confirm="saveUnlink">
<el-alert title="只有尚未排货的空柜可以解除;已有排货明细、PDA 或单证事实时服务端会拒绝。柜次号不会被删除。" type="warning" :closable="false" />
<el-form label-position="top"><el-form-item label="解除原因" required><el-input v-model="unlinkReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form>
<LoadErrorState v-if="linkError" compact title="解除关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError && !transportOnly)" confirm-text="确定" cancel-text="关闭" @confirm="save">
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
<el-alert v-if="transportOnly" title="拣货中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
@@ -71,12 +71,17 @@ describe('GlobalSmartLoadingView', () => {
expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 })
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId)
expect(wrapper.text()).toContain('候选入库单与货物')
expect(wrapper.text()).toContain('候选装柜明细')
expect(wrapper.text()).toContain('美森正班海卡包税')
expect(wrapper.text()).toContain('IN-2')
expect(wrapper.text()).toContain('另一企业')
expect(wrapper.text()).toContain('MSCU-1 装柜方案')
expect(wrapper.text()).not.toContain('LP-99')
expect(wrapper.text()).toContain('IN-1')
expect(wrapper.text()).toContain('示例企业')
expect(wrapper.text()).not.toContain('服装')
expect(wrapper.text()).not.toContain('鞋类')
expect(wrapper.text()).not.toContain('SKU-1')
expect(wrapper.text()).not.toContain('IN-1')
expect(wrapper.text()).not.toContain('IN-2')
expect(wrapper.text()).not.toContain('ORD-1')
expect(wrapper.text()).not.toContain('ORD-2')
})
@@ -136,7 +141,7 @@ describe('GlobalSmartLoadingView', () => {
expect(option.text()).toContain('模拟柜次')
expect(option.text()).toContain('柜次号:待生成')
expect(option.text()).toContain('集装箱号:待录入')
expect(option.text()).toContain('开始装柜前录入集装箱号')
expect(option.text()).not.toContain('开始装柜前录入集装箱号')
expect(option.text()).toContain('渠道:美森正班海卡包税')
expect(option.text()).toContain('仓库:义乌国内仓')
expect(option.text()).toContain('柜型:40HC')
@@ -183,6 +188,11 @@ describe('GlobalSmartLoadingView', () => {
expect(source).not.toContain('customer.tenantCode')
})
it('defaults candidate assignment to all available boxes', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('assignForm.packageCount = candidate.availablePackageCount')
})
it('rounds capacity percentages and hides column settings in embedded tables', async () => {
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
...plan,
@@ -254,8 +264,9 @@ describe('GlobalSmartLoadingView', () => {
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(pushed.id, pushed.warehouseId)
expect(wrapper.findAll('.container-option')).toHaveLength(1)
expect(wrapper.text()).toContain('CB-2')
expect(wrapper.text()).toContain('已下发 PDA,仍可维护未完成的装柜明细')
expect(wrapper.text()).toContain('刷新 PDA 明细与版本')
expect(wrapper.text()).not.toContain('已下发 PDA,仍可维护未完成的装柜明细')
expect(wrapper.text()).not.toContain('刷新 PDA 明细与版本')
expect(wrapper.text()).toContain('示例企业')
})
it('hides completed containers from the smart loading workbench', async () => {
@@ -291,7 +302,7 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('IN-MATCH')
expect(wrapper.text()).toContain('匹配企业')
expect(wrapper.text()).not.toContain('IN-OTHER')
expect(wrapper.text()).not.toContain('其他货物')
})
@@ -363,7 +374,7 @@ describe('GlobalSmartLoadingView', () => {
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(1, plan.id, plan.items[0].id, 2, 1)
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(2, plan.id, secondItem.id, 3, 1)
expect(wrapper.text()).toContain('当前柜次暂无已装货物')
expect(wrapper.text()).toContain('当前柜次暂无装柜明细')
})
it('uses item capabilities to protect picked quantities and removal', async () => {
@@ -445,7 +456,7 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain(':label="channel.name"')
expect(source).not.toContain('channel.containerCount')
expect(source).toContain('class="compact-summary" aria-label="当前渠道装柜统计"')
expect(source).toContain('<dt>候选货物</dt>')
expect(source).toContain('<dt>候选明细</dt>')
expect(source).toContain('<dt>柜次</dt>')
expect(source).toContain('<dt>已装</dt>')
expect(source).toContain('<dt>总重量</dt>')
@@ -455,7 +466,7 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('class="capacity-grid"')
expect(source).toContain('智能推荐')
expect(source).toContain('推送预装')
expect(source).toContain('推送预装仅生成预装清单,不会直接开放 PDA')
expect(source).toContain('推送预装的流程说明已由操作确认框承载')
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && selectedContainer?.containerNo")
})
})
@@ -118,20 +118,17 @@ function preferredContainerId() {
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '入仓码', minWidth: 170, slotName: 'order' },
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 150 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
{ key: 'unitVolumeCbm', label: '单箱体积', width: 105, slotName: 'unitVolume' },
{ key: 'unitWeightKg', label: '单箱重量', width: 105, slotName: 'unitWeight' },
{ key: 'unitVolumeCbm', label: '单箱体积', width: 105, slotName: 'unitVolume' },
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '入仓码', minWidth: 170, slotName: 'order' },
{ key: 'goods', label: '货物', minWidth: 160, slotName: 'goods' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
{ key: 'volumeCbm', label: '体积', width: 105, slotName: 'volume' },
{ key: 'grossWeightKg', label: '毛重', width: 110, slotName: 'weight' },
{ key: 'volumeCbm', label: '体积', width: 105, slotName: 'volume' },
]
function formatNumber(value: number, digits = 3) {
@@ -274,7 +271,7 @@ async function createPlan() {
function openAssign(source: object) {
const candidate = candidateRow(source)
selectedCandidate.value = candidate
assignForm.packageCount = Math.min(candidate.availablePackageCount, 1)
assignForm.packageCount = candidate.availablePackageCount
assignOpen.value = true
}
async function assign() {
@@ -292,8 +289,8 @@ async function assign() {
}))
assignOpen.value = false
await loadSelectedData()
ElMessage.success('货物已加入当前柜次')
}, '分配货物失败', { preserveAssign: true })
ElMessage.success('明细已加入当前柜次')
}, '分配明细失败', { preserveAssign: true })
}
async function recommend() {
if (!plan.value) return
@@ -312,7 +309,7 @@ async function adjust(source: object) {
return
}
const max = adjustmentMax(item)
if (count > max) { ElMessage.warning(`该货物最多可分配 ${max} 箱`); return }
if (count > max) { ElMessage.warning(`该明细最多可分配 ${max} 箱`); return }
await mutate(async () => {
acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version))
await loadSelectedData()
@@ -322,7 +319,7 @@ async function adjust(source: object) {
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value || !item.canRemove) return
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.productNameZh || item.inboundCode || '该货物'}”吗?`, '移除装柜明细', {
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.customerName || '当前客户'}”的 ${item.packageCount} 箱明细吗?`, '移除装柜明细', {
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
@@ -402,7 +399,7 @@ onMounted(() => { void load(); void loadCustomers() })
<el-tab-pane v-for="channel in channelTabs" :key="channel.code" :label="channel.name" :name="channel.code" />
</el-tabs>
<dl class="compact-summary" aria-label="当前渠道装柜统计">
<div><dt>候选货物</dt><dd><strong>{{ filteredCandidates.length }}</strong> 条</dd></div>
<div><dt>候选明细</dt><dd><strong>{{ filteredCandidates.length }}</strong> 条</dd></div>
<div><dt>柜次</dt><dd><strong>{{ filteredContainers.length }}</strong> 个</dd></div>
<div><dt>已装</dt><dd><strong>{{ totalPackages }}</strong> 箱</dd></div>
<div><dt>总重量</dt><dd><strong>{{ formatNumber(totalWeight, 1) }}</strong> kg</dd></div>
@@ -426,7 +423,7 @@ onMounted(() => { void load(); void loadCustomers() })
<template v-else>
<section class="dispatch-grid" aria-label="智能装柜调度台">
<section class="dispatch-pane candidate-pane">
<header class="pane-header"><div><strong>候选入库单与货物</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
<DataTable
:data="filteredCandidates"
:columns="candidateColumns"
@@ -434,7 +431,7 @@ onMounted(() => { void load(); void loadCustomers() })
stripe
preference-key="global-smart-loading-candidates"
column-settings-mode="hide"
:empty-text="selectedContainer ? '当前仓库暂无可装货物' : '暂无可用柜次'"
:empty-text="selectedContainer ? '当前仓库暂无可装明细' : '暂无可用柜次'"
>
<template #actions="{ row }">
<el-button
@@ -446,14 +443,13 @@ onMounted(() => { void load(); void loadCustomers() })
@click="openAssign(row)"
><Plus :size="15" />加入</el-button>
</template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template>
<template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0), 1) }} kg</template>
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template>
</DataTable>
</section>
<aside class="dispatch-pane container-pane" aria-label="货柜列表">
<header class="pane-header"><div><strong>当前渠道柜次</strong><small>显示待装柜、预装编辑和尚未最终完成的 PDA 柜次</small></div></header>
<header class="pane-header"><div><strong>当前渠道柜次</strong></div></header>
<div v-loading="loading" class="container-list">
<el-empty v-if="!filteredContainers.length" description="暂无符合条件的货柜" :image-size="72" />
<button
@@ -474,14 +470,14 @@ onMounted(() => { void load(); void loadCustomers() })
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
<span>渠道:{{ item.channelName || item.channelCode || '渠道待补充' }}</span>
<span>仓库:{{ item.warehouseName || item.warehouseId || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
<span v-if="!hasRealContainerNo(item)" class="container-option__hint">可先排货,开始装柜前录入集装箱号</span>
<!-- 空柜的操作时机由状态和操作按钮表达,不在每张柜次卡片重复提示。 -->
</button>
</div>
</aside>
<section class="dispatch-pane plan-pane">
<header class="pane-header plan-header">
<div><strong>{{ hasRealContainerNo(selectedContainer) ? displayActualContainerNo(selectedContainer) : displayCabinetNo(selectedContainer) }} 装柜方案</strong><small>{{ selectedContainer ? `柜次号:${displayCabinetNo(selectedContainer)} · 集装箱号:${displayActualContainerNo(selectedContainer)}` : '尚未选择柜次' }}</small></div>
<div><strong>{{ hasRealContainerNo(selectedContainer) ? displayActualContainerNo(selectedContainer) : displayCabinetNo(selectedContainer) }} 装柜方案</strong></div>
<div class="plan-actions">
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
@@ -504,17 +500,12 @@ onMounted(() => { void load(); void loadCustomers() })
<el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建装柜方案</el-button>
</el-empty>
<template v-else-if="plan">
<!--
已下发 PDA 的可编辑说明不再常驻展示,避免挤占装柜明细空间。
操作权限仍由 plan.canEdit、canAdjust 和 canRemove 控制。
-->
<el-alert
v-if="plan.status === 'LOADING' && plan.canEdit"
class="preload-readiness-alert"
type="warning"
:closable="false"
show-icon
title="已下发 PDA,仍可维护未完成的装柜明细"
description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。"
/>
<el-alert
v-else-if="!plan.canEdit"
v-if="!plan.canEdit"
class="preload-readiness-alert"
:type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'"
:closable="false"
@@ -522,25 +513,16 @@ onMounted(() => { void load(); void loadCustomers() })
:title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'"
:description="plan.blockedReason || '请刷新计划获取最新作业状态。'"
/>
<el-alert
class="preload-readiness-alert"
type="info"
:closable="false"
show-icon
title="推送预装仅生成预装清单,不会直接开放 PDA"
description="后续依次完成:补集装箱号、关联并放舱真实订舱、确认预装;全部满足后才生成 PDA 任务。"
/>
<!-- 推送预装的流程说明已由操作确认框承载,不在方案区重复展示。 -->
<div class="capacity-grid">
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
</div>
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无已装货物">
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
</DataTable>
</template>
</section>
@@ -548,8 +530,8 @@ onMounted(() => { void load(); void loadCustomers() })
<AppDialog v-model="assignOpen" title="加入当前柜次" :confirm-loading="mutating" confirm-text="确认加入" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p>
<p>{{ selectedCandidate.inboundCode || '入仓码暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p>
<p><strong>{{ selectedCandidate.customerName || '客户待补充' }}</strong></p>
<p>可用 {{ selectedCandidate.availablePackageCount }} 箱 · 单箱 {{ formatNumber(Number(selectedCandidate.unitWeightKg || 0), 1) }} kg · {{ formatNumber(Number(selectedCandidate.unitVolumeCbm || 0)) }} m³</p>
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form>
</div>
</AppDialog>
+3 -1
View File
@@ -61,7 +61,9 @@ export interface ContainerSimulationCreateRequest {
}
export interface ContainerOceanBookingLinkRequest {
realContainerBookingId: string
oceanBookingId: string
requirementId: string
containerNo: string
expectedVersion: number
}