feat: connect plan-level preload confirmation

This commit is contained in:
2026-08-15 17:04:00 +08:00
parent fc80d09faa
commit 835d1d0216
26 changed files with 398 additions and 267 deletions
@@ -199,7 +199,7 @@ describe('ContainersView', () => {
it('keeps the prototype field sequence ahead of contract-only supplemental fields', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const labels = ['集装箱号', '柜号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
const labels = ['集装箱号', '柜次号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
let previous = -1
for (const label of labels) {
const current = source.indexOf(`label="${label}"`, previous + 1)
@@ -235,7 +235,7 @@ describe('ContainersView', () => {
expect(source).toContain('新增模拟柜')
expect(source).toContain('createContainerSimulation({ ...simulationForm })')
expect(source).toContain("channelCode: '', warehouseId: '', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000")
expect(source).toContain('服务端会生成稳定的内部柜次')
expect(source).toContain('服务端会生成稳定的柜次号')
expect(source).not.toContain('openCreate')
})
@@ -244,7 +244,7 @@ describe('ContainersView', () => {
expect(source).toContain("!row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)")
expect(source).toContain("row.oceanBookingId && row.planStatus === 'WAIT_LOADING'")
expect(source).toContain('已预排货但尚未进入 PDA、取证或单证阶段的模拟柜仍可首次关联')
expect(source).toContain('内部柜次与装柜明细保持不变')
expect(source).toContain('柜次号与装柜明细保持不变')
expect(source).toContain('只有尚未排货的空柜可以解除')
expect(source).toContain('linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, expectedVersion: target.version })')
expect(source).toContain('unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())')
@@ -164,14 +164,14 @@ const destinationWarehouseValue = computed(() => form.destinationWarehouseCode |
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 220, placeholder: '全部客户', options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、柜号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、集装箱号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
{ key: 'containerType', type: 'select', label: '柜型', width: 140, options: ['20GP', '40GP', '40HQ', '45HQ'].map((value) => ({ label: value, value })), props: { 'aria-label': '柜型' } },
{ key: 'status', type: 'select', label: '排柜状态', width: 150, options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } },
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' },
{ key: 'cabinetNo', label: '柜次', minWidth: 180, slotName: 'cabinet' },
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
@@ -387,7 +387,7 @@ async function saveLink() {
try {
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, expectedVersion: target.version })
linkOpen.value = false
ElMessage.success('已关联真实订舱柜,内部柜次与装柜明细保持不变')
ElMessage.success('已关联真实订舱柜,柜次号与装柜明细保持不变')
await loadRows()
} catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
finally { linkSaving.value = false }
@@ -406,7 +406,7 @@ async function saveUnlink() {
try {
await unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())
unlinkOpen.value = false
ElMessage.success('真实订舱关联已解除,内部柜次保持不变')
ElMessage.success('真实订舱关联已解除,柜次号保持不变')
await loadRows()
} catch (error) { linkError.value = getApiErrorDetails(error, '解除真实订舱关联失败') }
finally { linkSaving.value = false }
@@ -597,8 +597,8 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<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 #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>内部柜次</small></template>
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo || '真实柜号待录入' }}</small></span><span v-else>未关联</span></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>
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
@@ -615,7 +615,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<AppDrawer v-model="detailOpen" title="货柜详情" size="min(760px, 94vw)" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
<LoadErrorState v-if="detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="内部柜次">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="真实订舱">{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</el-descriptions-item><el-descriptions-item label="真实集装箱号">{{ detail.containerNo || '待录入' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" /></template>
<template v-else-if="detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="真实订舱">{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '待录入' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" /></template>
</AppDrawer>
<AppDialog v-model="clearanceDialogOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :confirm-loading="clearanceActionLoading" :confirm-disabled="!clearanceFile" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><el-form v-else label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ clearanceDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的目的港清关资料。' }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="clearanceError" compact title="操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
@@ -625,7 +625,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<AppDialog v-model="exceptionDialogOpen" :title="exceptionDialogTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="exceptionError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
<el-alert title="模拟柜用于先排货,服务端会生成稳定的内部柜次;此处不填写真实柜号和船期。" type="info" :closable="false" />
<el-alert title="模拟柜用于先排货,服务端会生成稳定的柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select v-model="simulationForm.channelCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="channel in simulationChannels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode" /></el-select></el-form-item>
@@ -637,14 +637,14 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
</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>
<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>
<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-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>
@@ -656,7 +656,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<el-alert v-if="businessOptionsError && !transportOnly" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
<el-form v-if="!warehouseError || transportOnly" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
<el-form-item label="集装箱号"><el-input v-model="form.containerNo" maxlength="64" placeholder="请输入集装箱号,开始装柜前必填" /></el-form-item>
<el-form-item label="柜号" prop="cabinetNo"><el-input v-model="form.cabinetNo" maxlength="64" placeholder="请输入柜号" :disabled="transportOnly" /></el-form-item>
<el-form-item label="柜次号" prop="cabinetNo"><el-input v-model="form.cabinetNo" maxlength="64" placeholder="请输入柜次号" :disabled="transportOnly" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库" :disabled="transportOnly"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" :disabled="transportOnly" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" :disabled="transportOnly" /></el-form-item>
@@ -115,12 +115,14 @@ describe('GlobalSmartLoadingView', () => {
const option = wrapper.find('.container-option')
expect(option.text()).toContain('模拟柜次')
expect(option.text()).toContain('模拟柜次:真实柜号待录入')
expect(option.text()).toContain('真实柜号:待录入')
expect(option.text()).toContain('柜次号:待生成')
expect(option.text()).toContain('集装箱号:待录入')
expect(option.text()).toContain('开始装柜前录入集装箱号')
expect(option.text()).toContain('渠道:美森正班海卡包税')
expect(option.text()).toContain('仓库:义乌国内仓')
expect(option.text()).toContain('柜型:40HQ')
expect(option.text()).not.toContain('2088475410134773762')
expect(wrapper.text()).not.toContain('真实柜号')
})
it('groups containers by channel and reloads candidates when the channel changes', async () => {
@@ -368,5 +370,7 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('class="capacity-grid"')
expect(source).toContain('智能推荐')
expect(source).toContain('推送预装')
expect(source).toContain('推送预装仅生成预装清单,不会直接开放 PDA')
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && selectedContainer?.containerNo")
})
})
@@ -138,21 +138,18 @@ function adjustmentMax(source: object) {
const remaining = candidates.value.find((candidate) => candidate.inboundDetailId === item.inboundDetailId)?.availablePackageCount || 0
return Math.max(1, item.packageCount + remaining)
}
function displayContainerNo(container: ContainerBookingResource | null | undefined) {
if (!container) return '柜号待生成'
const actual = String(container.containerNo || '').trim()
const internalReference = /^(?:MAN|SPLIT)-/i.test(actual)
if (actual && !internalReference) return actual
function displayCabinetNo(container: ContainerBookingResource | null | undefined) {
if (!container) return '待生成'
const cabinetReference = String(container.cabinetNo || '').trim()
return /^\d{15,}$/.test(cabinetReference) ? '模拟柜次' : cabinetReference || '柜号待生成'
return /^\d{15,}$/.test(cabinetReference) ? '待生成' : cabinetReference || '待生成'
}
function hasRealContainerNo(container: ContainerBookingResource | null | undefined) {
if (!container) return false
const value = String(container.containerNo || '').trim()
return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value)
}
function hasOpaqueCabinetReference(container: ContainerBookingResource | null | undefined) {
return Boolean(container && /^\d{15,}$/.test(String(container.cabinetNo || '').trim()))
function displayActualContainerNo(container: ContainerBookingResource | null | undefined) {
return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待录入'
}
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
@@ -346,13 +343,13 @@ async function removeAll() {
}
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后当前方案进入装柜流程,不能继续调整。确认推送预装清单?', '推送预装清单', {
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、关联并放舱真实订舱、确认预装。确认继续?', '推送预装清单', {
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version))
ElMessage.success('已推送预装清单')
ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认')
}, '推送预装清单失败')
}
@@ -456,24 +453,23 @@ onMounted(() => { void load(); void loadCustomers() })
@click="selectContainer(item.id)"
>
<span class="container-option__top">
<strong>{{ hasRealContainerNo(item) ? displayContainerNo(item) : '模拟柜次' }}</strong>
<strong>{{ hasRealContainerNo(item) ? displayActualContainerNo(item) : '模拟柜次' }}</strong>
<el-tag size="small" :type="hasRealContainerNo(item) ? 'success' : 'info'">
{{ hasRealContainerNo(item) ? '已录入柜号' : '空柜' }}
{{ hasRealContainerNo(item) ? '已录入集装箱号' : '空柜' }}
</el-tag>
</span>
<span class="container-option__identity">
{{ hasRealContainerNo(item) ? `柜号:${displayContainerNo(item)}` : (hasOpaqueCabinetReference(item) ? '模拟柜次:真实柜号待录入' : `柜次参考:${displayContainerNo(item)}`) }}
</span>
<span class="container-option__identity">柜次号:{{ displayCabinetNo(item) }}</span>
<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>
<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>{{ displayContainerNo(selectedContainer) }} 装柜方案</strong><small>{{ plan ? '当前柜号的装柜明细' : '尚未创建方案' }}</small></div>
<div><strong>{{ hasRealContainerNo(selectedContainer) ? displayActualContainerNo(selectedContainer) : displayCabinetNo(selectedContainer) }} 装柜方案</strong><small>{{ selectedContainer ? `柜次号:${displayCabinetNo(selectedContainer)} · 集装箱号:${displayActualContainerNo(selectedContainer)}` : '尚未选择柜次' }}</small></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 && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
@@ -496,6 +492,14 @@ 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">
<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>
@@ -561,6 +565,7 @@ onMounted(() => { void load(); void loadCustomers() })
.plan-actions,.row-actions { flex-wrap: wrap; }
.plan-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.capacity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; border-bottom: 1px solid var(--cc-border); }
.preload-readiness-alert { border-radius: 0; border-right: 0; border-left: 0; }
.readonly-status-card { display: grid; align-content: start; justify-items: start; gap: 10px; padding: 28px 24px; }
.readonly-status-card strong { font-size: 16px; }
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
@@ -60,7 +60,7 @@ describe('SmartLoadingWorkbenchView', () => {
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
expect(listSmartLoadingCandidates).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('该柜号已进入拣货流程')
expect(wrapper.text()).toContain('该柜次已进入拣货流程')
expect(wrapper.text()).toContain('查看预装清单')
expect(wrapper.text()).not.toContain('智能推荐')
expect(wrapper.text()).not.toContain('ORD-1')
@@ -92,9 +92,13 @@ describe('SmartLoadingWorkbenchView', () => {
expect(source).toContain('size="small" type="danger" plain')
expect(source).toContain("canPush && plan?.status === 'EDITING'")
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && container?.containerNo")
expect(source).toContain('推送后生成预装清单,不会直接开放 PDA')
expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)')
expect(source).not.toContain("label: '目的仓'")
expect(source).not.toContain('plan.planNo')
expect(source.match(/<DataTable[^>]*\bstripe\b/g)).toHaveLength(2)
expect(source).toContain('<span>柜次号</span>')
expect(source).toContain('<span>集装箱号</span>')
expect(source).not.toContain('当前柜号')
})
})
@@ -82,11 +82,14 @@ function explainCandidate(source: object) {
const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount
return `按 110% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱`
}
function displayCabinetNo(value: ContainerBookingResource | null | undefined) {
if (!value) return '待生成'
const cabinetNo = String(value.cabinetNo || '').trim()
return /^\d{15,}$/.test(cabinetNo) ? '待生成' : cabinetNo || '待生成'
}
function displayContainerNo(value: ContainerBookingResource | null | undefined) {
if (!value) return '柜号待生成'
const actual = String(value.containerNo || '').trim()
const internalReference = /^(?:MAN|SPLIT)-/i.test(actual)
return actual && !internalReference ? actual : String(value.cabinetNo || '').trim() || '柜号待生成'
const containerNo = String(value?.containerNo || '').trim()
return containerNo && !/^(?:MAN|SPLIT)-/i.test(containerNo) ? containerNo : '待录入'
}
function syncDrafts() { for (const item of plan.value?.items || []) quantityDrafts[item.id] = item.packageCount }
function acceptPlan(next: SmartLoadingPlan) { plan.value = next; noDraft.value = false; syncDrafts() }
@@ -168,11 +171,11 @@ async function remove(source: object) {
}
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后计划进入装柜流程,不能继续调整排柜明细。确认推送预装清单?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、关联并放舱真实订舱、确认预装。确认继续?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('已推送预装清单') }
try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认') }
catch (error) { await handleMutationError(error, '推送失败') }
finally { mutating.value = false }
}
@@ -199,9 +202,15 @@ onMounted(load)
<AdminPage>
<section class="business-page workbench">
<section class="surface-panel workbench-command-bar" aria-label="排柜作业摘要">
<div class="workbench-identity">
<span>当前柜号</span>
<strong>{{ loading ? '正在加载' : displayContainerNo(container) }}</strong>
<div class="workbench-identities">
<div class="workbench-identity">
<span>柜次号</span>
<strong>{{ loading ? '正在加载' : displayCabinetNo(container) }}</strong>
</div>
<div class="workbench-identity">
<span>集装箱号</span>
<strong>{{ loading ? '正在加载' : displayContainerNo(container) }}</strong>
</div>
</div>
<div class="header-actions">
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
@@ -220,7 +229,7 @@ onMounted(load)
<el-button v-if="canViewLoadingList" type="primary" @click="openOperationalList"><ListChecks :size="16" />查看装柜清单</el-button>
</section>
<section v-else-if="container?.planStatus === 'LOADING'" class="readonly-status-card" aria-label="已进入拣货流程">
<strong>该柜号已进入拣货流程</strong>
<strong>该柜次已进入拣货流程</strong>
<p>智能装柜仅处理待装柜和预装编辑中的空柜,请在预装清单继续查看。</p>
<el-button v-if="canViewLoadingList" type="primary" @click="openOperationalList"><ListChecks :size="16" />查看预装清单</el-button>
</section>
@@ -255,6 +264,7 @@ onMounted(load)
.workbench { display: grid; gap: 16px; min-width: 0; }
.workbench-command-bar,.header-actions,.row-actions,.meter-title { display: flex; align-items: center; }
.workbench-command-bar { justify-content: space-between; gap: 16px; padding: 14px 16px; }
.workbench-identities { display: flex; min-width: 0; align-items: center; gap: 32px; }
.workbench-identity { display: grid; min-width: 0; gap: 3px; }
.workbench-identity span { color: var(--cc-muted); font-size: 11px; }
.workbench-identity strong { overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
@@ -274,5 +284,5 @@ onMounted(load)
.workbench :deep(.el-input-number) { width: 130px; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
</style>