feat: connect plan-level preload confirmation
This commit is contained in:
@@ -9,7 +9,7 @@ const pages = [
|
|||||||
['src/modules/port-schedules/pages/PortSchedulesView.vue', "prop: 'schedule.scheduleNo'"],
|
['src/modules/port-schedules/pages/PortSchedulesView.vue', "prop: 'schedule.scheduleNo'"],
|
||||||
['src/modules/trucking/pages/TruckAppointmentsView.vue', "key: 'appointmentNo'"],
|
['src/modules/trucking/pages/TruckAppointmentsView.vue', "key: 'appointmentNo'"],
|
||||||
['src/modules/customs/pages/CustomsCasesView.vue', "prop: 'customsNo'"],
|
['src/modules/customs/pages/CustomsCasesView.vue', "prop: 'customsNo'"],
|
||||||
['src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue', "prop: 'orderNo'"],
|
['src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue', "prop: 'inboundCode'"],
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function source(path: string) {
|
function source(path: string) {
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './api/platformContainerLoading'
|
export * from './api/platformContainerLoading'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ describe('ContainersView', () => {
|
|||||||
|
|
||||||
it('keeps the prototype field sequence ahead of contract-only supplemental fields', () => {
|
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 source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||||
const labels = ['集装箱号', '柜号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
|
const labels = ['集装箱号', '柜次号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
|
||||||
let previous = -1
|
let previous = -1
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
const current = source.indexOf(`label="${label}"`, previous + 1)
|
const current = source.indexOf(`label="${label}"`, previous + 1)
|
||||||
@@ -235,7 +235,7 @@ describe('ContainersView', () => {
|
|||||||
expect(source).toContain('新增模拟柜')
|
expect(source).toContain('新增模拟柜')
|
||||||
expect(source).toContain('createContainerSimulation({ ...simulationForm })')
|
expect(source).toContain('createContainerSimulation({ ...simulationForm })')
|
||||||
expect(source).toContain("channelCode: '', warehouseId: '', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000")
|
expect(source).toContain("channelCode: '', warehouseId: '', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000")
|
||||||
expect(source).toContain('服务端会生成稳定的内部柜次')
|
expect(source).toContain('服务端会生成稳定的柜次号')
|
||||||
expect(source).not.toContain('openCreate')
|
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 && ['WAIT_LOADING','EDITING'].includes(row.planStatus)")
|
||||||
expect(source).toContain("row.oceanBookingId && row.planStatus === 'WAIT_LOADING'")
|
expect(source).toContain("row.oceanBookingId && row.planStatus === 'WAIT_LOADING'")
|
||||||
expect(source).toContain('已预排货但尚未进入 PDA、取证或单证阶段的模拟柜仍可首次关联')
|
expect(source).toContain('已预排货但尚未进入 PDA、取证或单证阶段的模拟柜仍可首次关联')
|
||||||
expect(source).toContain('内部柜次与装柜明细保持不变')
|
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('linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, expectedVersion: target.version })')
|
||||||
expect(source).toContain('unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())')
|
expect(source).toContain('unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())')
|
||||||
|
|||||||
@@ -164,14 +164,14 @@ const destinationWarehouseValue = computed(() => form.destinationWarehouseCode |
|
|||||||
|
|
||||||
const queryFields = computed<QueryField[]>(() => [
|
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: '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: '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: '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': '预计开船日期范围' } },
|
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
|
||||||
])
|
])
|
||||||
const columns: DataTableColumn[] = [
|
const columns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' },
|
{ 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: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
|
||||||
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
|
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
|
||||||
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
|
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
|
||||||
@@ -387,7 +387,7 @@ async function saveLink() {
|
|||||||
try {
|
try {
|
||||||
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, expectedVersion: target.version })
|
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, expectedVersion: target.version })
|
||||||
linkOpen.value = false
|
linkOpen.value = false
|
||||||
ElMessage.success('已关联真实订舱柜,内部柜次与装柜明细保持不变')
|
ElMessage.success('已关联真实订舱柜,柜次号与装柜明细保持不变')
|
||||||
await loadRows()
|
await loadRows()
|
||||||
} catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
|
} catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
|
||||||
finally { linkSaving.value = false }
|
finally { linkSaving.value = false }
|
||||||
@@ -406,7 +406,7 @@ async function saveUnlink() {
|
|||||||
try {
|
try {
|
||||||
await unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())
|
await unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())
|
||||||
unlinkOpen.value = false
|
unlinkOpen.value = false
|
||||||
ElMessage.success('真实订舱关联已解除,内部柜次保持不变')
|
ElMessage.success('真实订舱关联已解除,柜次号保持不变')
|
||||||
await loadRows()
|
await loadRows()
|
||||||
} catch (error) { linkError.value = getApiErrorDetails(error, '解除真实订舱关联失败') }
|
} catch (error) { linkError.value = getApiErrorDetails(error, '解除真实订舱关联失败') }
|
||||||
finally { linkSaving.value = false }
|
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="暂无货柜">
|
<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 #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.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 #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 #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 #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 #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>
|
<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="关闭">
|
<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>
|
<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>
|
</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>
|
<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="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">
|
<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 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="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>
|
<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>
|
||||||
|
|
||||||
<AppDialog v-model="linkOpen" title="关联真实订舱柜" width="min(620px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey" @confirm="saveLink">
|
<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-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-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>
|
<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>
|
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
|
||||||
</AppDialog>
|
</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">
|
<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>
|
<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>
|
<LoadErrorState v-if="linkError" compact title="解除关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
|
||||||
</AppDialog>
|
</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-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 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="集装箱号"><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="装柜仓库" 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.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>
|
<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')
|
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('渠道:美森正班海卡包税')
|
||||||
expect(option.text()).toContain('仓库:义乌国内仓')
|
expect(option.text()).toContain('仓库:义乌国内仓')
|
||||||
expect(option.text()).toContain('柜型:40HQ')
|
expect(option.text()).toContain('柜型:40HQ')
|
||||||
expect(option.text()).not.toContain('2088475410134773762')
|
expect(option.text()).not.toContain('2088475410134773762')
|
||||||
|
expect(wrapper.text()).not.toContain('真实柜号')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('groups containers by channel and reloads candidates when the channel changes', async () => {
|
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('class="capacity-grid"')
|
||||||
expect(source).toContain('智能推荐')
|
expect(source).toContain('智能推荐')
|
||||||
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
|
const remaining = candidates.value.find((candidate) => candidate.inboundDetailId === item.inboundDetailId)?.availablePackageCount || 0
|
||||||
return Math.max(1, item.packageCount + remaining)
|
return Math.max(1, item.packageCount + remaining)
|
||||||
}
|
}
|
||||||
function displayContainerNo(container: ContainerBookingResource | null | undefined) {
|
function displayCabinetNo(container: ContainerBookingResource | null | undefined) {
|
||||||
if (!container) return '柜号待生成'
|
if (!container) return '待生成'
|
||||||
const actual = String(container.containerNo || '').trim()
|
|
||||||
const internalReference = /^(?:MAN|SPLIT)-/i.test(actual)
|
|
||||||
if (actual && !internalReference) return actual
|
|
||||||
const cabinetReference = String(container.cabinetNo || '').trim()
|
const cabinetReference = String(container.cabinetNo || '').trim()
|
||||||
return /^\d{15,}$/.test(cabinetReference) ? '模拟柜次' : cabinetReference || '柜号待生成'
|
return /^\d{15,}$/.test(cabinetReference) ? '待生成' : cabinetReference || '待生成'
|
||||||
}
|
}
|
||||||
function hasRealContainerNo(container: ContainerBookingResource | null | undefined) {
|
function hasRealContainerNo(container: ContainerBookingResource | null | undefined) {
|
||||||
if (!container) return false
|
if (!container) return false
|
||||||
const value = String(container.containerNo || '').trim()
|
const value = String(container.containerNo || '').trim()
|
||||||
return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value)
|
return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value)
|
||||||
}
|
}
|
||||||
function hasOpaqueCabinetReference(container: ContainerBookingResource | null | undefined) {
|
function displayActualContainerNo(container: ContainerBookingResource | null | undefined) {
|
||||||
return Boolean(container && /^\d{15,}$/.test(String(container.cabinetNo || '').trim()))
|
return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待录入'
|
||||||
}
|
}
|
||||||
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
|
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
|
||||||
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
|
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
|
||||||
@@ -346,13 +343,13 @@ async function removeAll() {
|
|||||||
}
|
}
|
||||||
async function pushPreload() {
|
async function pushPreload() {
|
||||||
if (!plan.value) return
|
if (!plan.value) return
|
||||||
const confirmed = await ElMessageBox.confirm('推送后当前方案进入装柜流程,不能继续调整。确认推送预装清单?', '推送预装清单', {
|
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、关联并放舱真实订舱、确认预装。确认继续?', '推送预装清单', {
|
||||||
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
|
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
|
||||||
}).catch(() => false)
|
}).catch(() => false)
|
||||||
if (!confirmed || !plan.value) return
|
if (!confirmed || !plan.value) return
|
||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version))
|
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)"
|
@click="selectContainer(item.id)"
|
||||||
>
|
>
|
||||||
<span class="container-option__top">
|
<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'">
|
<el-tag size="small" :type="hasRealContainerNo(item) ? 'success' : 'info'">
|
||||||
{{ hasRealContainerNo(item) ? '已录入柜号' : '空柜' }}
|
{{ hasRealContainerNo(item) ? '已录入集装箱号' : '空柜' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</span>
|
</span>
|
||||||
<span class="container-option__identity">
|
<span class="container-option__identity">柜次号:{{ displayCabinetNo(item) }}</span>
|
||||||
{{ hasRealContainerNo(item) ? `柜号:${displayContainerNo(item)}` : (hasOpaqueCabinetReference(item) ? '模拟柜次:真实柜号待录入' : `柜次参考:${displayContainerNo(item)}`) }}
|
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
|
||||||
</span>
|
|
||||||
<span>渠道:{{ item.channelName || item.channelCode || '渠道待补充' }}</span>
|
<span>渠道:{{ item.channelName || item.channelCode || '渠道待补充' }}</span>
|
||||||
<span>仓库:{{ item.warehouseName || item.warehouseId || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="dispatch-pane plan-pane">
|
<section class="dispatch-pane plan-pane">
|
||||||
<header class="pane-header plan-header">
|
<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">
|
<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-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>
|
<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-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建装柜方案</el-button>
|
||||||
</el-empty>
|
</el-empty>
|
||||||
<template v-else-if="plan">
|
<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 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(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><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,.row-actions { flex-wrap: wrap; }
|
||||||
.plan-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
.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); }
|
.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 { display: grid; align-content: start; justify-items: start; gap: 10px; padding: 28px 24px; }
|
||||||
.readonly-status-card strong { font-size: 16px; }
|
.readonly-status-card strong { font-size: 16px; }
|
||||||
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
|
.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(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
|
||||||
expect(listSmartLoadingCandidates).not.toHaveBeenCalled()
|
expect(listSmartLoadingCandidates).not.toHaveBeenCalled()
|
||||||
expect(wrapper.text()).toContain('该柜号已进入拣货流程')
|
expect(wrapper.text()).toContain('该柜次已进入拣货流程')
|
||||||
expect(wrapper.text()).toContain('查看预装清单')
|
expect(wrapper.text()).toContain('查看预装清单')
|
||||||
expect(wrapper.text()).not.toContain('智能推荐')
|
expect(wrapper.text()).not.toContain('智能推荐')
|
||||||
expect(wrapper.text()).not.toContain('ORD-1')
|
expect(wrapper.text()).not.toContain('ORD-1')
|
||||||
@@ -92,9 +92,13 @@ describe('SmartLoadingWorkbenchView', () => {
|
|||||||
expect(source).toContain('size="small" type="danger" plain')
|
expect(source).toContain('size="small" type="danger" plain')
|
||||||
expect(source).toContain("canPush && plan?.status === 'EDITING'")
|
expect(source).toContain("canPush && plan?.status === 'EDITING'")
|
||||||
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && container?.containerNo")
|
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('link type="primary" :disabled="!routeMatches(row)')
|
||||||
expect(source).not.toContain("label: '目的仓'")
|
expect(source).not.toContain("label: '目的仓'")
|
||||||
expect(source).not.toContain('plan.planNo')
|
expect(source).not.toContain('plan.planNo')
|
||||||
expect(source.match(/<DataTable[^>]*\bstripe\b/g)).toHaveLength(2)
|
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
|
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)} 箱`
|
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) {
|
function displayContainerNo(value: ContainerBookingResource | null | undefined) {
|
||||||
if (!value) return '柜号待生成'
|
const containerNo = String(value?.containerNo || '').trim()
|
||||||
const actual = String(value.containerNo || '').trim()
|
return containerNo && !/^(?:MAN|SPLIT)-/i.test(containerNo) ? containerNo : '待录入'
|
||||||
const internalReference = /^(?:MAN|SPLIT)-/i.test(actual)
|
|
||||||
return actual && !internalReference ? actual : String(value.cabinetNo || '').trim() || '柜号待生成'
|
|
||||||
}
|
}
|
||||||
function syncDrafts() { for (const item of plan.value?.items || []) quantityDrafts[item.id] = item.packageCount }
|
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() }
|
function acceptPlan(next: SmartLoadingPlan) { plan.value = next; noDraft.value = false; syncDrafts() }
|
||||||
@@ -168,11 +171,11 @@ async function remove(source: object) {
|
|||||||
}
|
}
|
||||||
async function pushPreload() {
|
async function pushPreload() {
|
||||||
if (!plan.value) return
|
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
|
if (!confirmed || !plan.value) return
|
||||||
mutating.value = true
|
mutating.value = true
|
||||||
mutationError.value = null
|
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, '推送失败') }
|
catch (error) { await handleMutationError(error, '推送失败') }
|
||||||
finally { mutating.value = false }
|
finally { mutating.value = false }
|
||||||
}
|
}
|
||||||
@@ -199,9 +202,15 @@ onMounted(load)
|
|||||||
<AdminPage>
|
<AdminPage>
|
||||||
<section class="business-page workbench">
|
<section class="business-page workbench">
|
||||||
<section class="surface-panel workbench-command-bar" aria-label="排柜作业摘要">
|
<section class="surface-panel workbench-command-bar" aria-label="排柜作业摘要">
|
||||||
<div class="workbench-identity">
|
<div class="workbench-identities">
|
||||||
<span>当前柜号</span>
|
<div class="workbench-identity">
|
||||||
<strong>{{ loading ? '正在加载' : displayContainerNo(container) }}</strong>
|
<span>柜次号</span>
|
||||||
|
<strong>{{ loading ? '正在加载' : displayCabinetNo(container) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="workbench-identity">
|
||||||
|
<span>集装箱号</span>
|
||||||
|
<strong>{{ loading ? '正在加载' : displayContainerNo(container) }}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
|
<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>
|
<el-button v-if="canViewLoadingList" type="primary" @click="openOperationalList"><ListChecks :size="16" />查看装柜清单</el-button>
|
||||||
</section>
|
</section>
|
||||||
<section v-else-if="container?.planStatus === 'LOADING'" class="readonly-status-card" aria-label="已进入拣货流程">
|
<section v-else-if="container?.planStatus === 'LOADING'" class="readonly-status-card" aria-label="已进入拣货流程">
|
||||||
<strong>该柜号已进入拣货流程</strong>
|
<strong>该柜次已进入拣货流程</strong>
|
||||||
<p>智能装柜仅处理待装柜和预装编辑中的空柜,请在预装清单继续查看。</p>
|
<p>智能装柜仅处理待装柜和预装编辑中的空柜,请在预装清单继续查看。</p>
|
||||||
<el-button v-if="canViewLoadingList" type="primary" @click="openOperationalList"><ListChecks :size="16" />查看预装清单</el-button>
|
<el-button v-if="canViewLoadingList" type="primary" @click="openOperationalList"><ListChecks :size="16" />查看预装清单</el-button>
|
||||||
</section>
|
</section>
|
||||||
@@ -255,6 +264,7 @@ onMounted(load)
|
|||||||
.workbench { display: grid; gap: 16px; min-width: 0; }
|
.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,.header-actions,.row-actions,.meter-title { display: flex; align-items: center; }
|
||||||
.workbench-command-bar { justify-content: space-between; gap: 16px; padding: 14px 16px; }
|
.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 { display: grid; min-width: 0; gap: 3px; }
|
||||||
.workbench-identity span { color: var(--cc-muted); font-size: 11px; }
|
.workbench-identity span { color: var(--cc-muted); font-size: 11px; }
|
||||||
.workbench-identity strong { overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
|
.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; }
|
.workbench :deep(.el-input-number) { width: 130px; }
|
||||||
.assign-dialog p { margin: 0 0 10px; }
|
.assign-dialog p { margin: 0 0 10px; }
|
||||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { http } from '@/shared/api/http'
|
import { http } from '@/shared/api/http'
|
||||||
import {
|
import {
|
||||||
getConfirmedLoadingList,
|
getConfirmedLoadingList,
|
||||||
|
confirmPreloadPlan,
|
||||||
uploadLoadingDeclaration,
|
uploadLoadingDeclaration,
|
||||||
completeLoadingDeclaration,
|
completeLoadingDeclaration,
|
||||||
getPreloadPlan,
|
getPreloadPlan,
|
||||||
@@ -15,7 +16,7 @@ const preloadSummary = {
|
|||||||
containerBookingId: 1900000000000000004n as unknown as string,
|
containerBookingId: 1900000000000000004n as unknown as string,
|
||||||
preloadNo: 'PL-001',
|
preloadNo: 'PL-001',
|
||||||
containerNo: 'MSCU1234567',
|
containerNo: 'MSCU1234567',
|
||||||
internalContainerNo: 'YG-001',
|
cabinetNo: 'YG-001',
|
||||||
customers: [{ accountId: 1900000000000000002n as unknown as string, customerName: '测试客户' }],
|
customers: [{ accountId: 1900000000000000002n as unknown as string, customerName: '测试客户' }],
|
||||||
warehouseId: 1900000000000000003n as unknown as string,
|
warehouseId: 1900000000000000003n as unknown as string,
|
||||||
warehouseName: '义乌仓',
|
warehouseName: '义乌仓',
|
||||||
@@ -29,6 +30,20 @@ const preloadSummary = {
|
|||||||
totalGrossWeightKg: 1200,
|
totalGrossWeightKg: 1200,
|
||||||
createdAt: '2026-08-10T10:00:00',
|
createdAt: '2026-08-10T10:00:00',
|
||||||
version: 3,
|
version: 3,
|
||||||
|
readiness: {
|
||||||
|
preloadPushed: true,
|
||||||
|
containerNoReady: true,
|
||||||
|
preloadLinkCount: 1,
|
||||||
|
confirmedPreloadCount: 0,
|
||||||
|
oceanBookingId: 1900000000000000005n as unknown as string,
|
||||||
|
oceanBookingStatus: 'RELEASED',
|
||||||
|
pdaTaskId: null,
|
||||||
|
pdaTaskStatus: null,
|
||||||
|
confirmationStatus: 'PENDING',
|
||||||
|
canConfirm: true,
|
||||||
|
readyForPda: false,
|
||||||
|
blockingReasons: [],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const detail = {
|
const detail = {
|
||||||
@@ -54,6 +69,7 @@ describe('platform loading lists API', () => {
|
|||||||
expect(result.total).toBe(1)
|
expect(result.total).toBe(1)
|
||||||
expect(result.records[0]?.planId).toBe('1900000000000000001')
|
expect(result.records[0]?.planId).toBe('1900000000000000001')
|
||||||
expect(result.records[0]?.containerBookingId).toBe('1900000000000000004')
|
expect(result.records[0]?.containerBookingId).toBe('1900000000000000004')
|
||||||
|
expect(result.records[0]?.readiness.oceanBookingId).toBe('1900000000000000005')
|
||||||
expect(result.records[0]?.customers[0]?.accountId).toBe('1900000000000000002')
|
expect(result.records[0]?.customers[0]?.accountId).toBe('1900000000000000002')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,6 +108,42 @@ describe('platform loading lists API', () => {
|
|||||||
expect(loaded.summary.status).toBe('COMPLETED')
|
expect(loaded.summary.status).toBe('COMPLETED')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('confirms the whole preload plan with expectedVersion and the caller operation id', async () => {
|
||||||
|
const post = vi.spyOn(http, 'post').mockResolvedValue({
|
||||||
|
code: '200', message: 'ok', data: {
|
||||||
|
planId: 1900000000000000001n,
|
||||||
|
containerBookingId: 1900000000000000004n,
|
||||||
|
confirmationStatus: 'CONFIRMED',
|
||||||
|
version: 4,
|
||||||
|
readiness: {
|
||||||
|
...preloadSummary.readiness,
|
||||||
|
confirmedPreloadCount: 1,
|
||||||
|
confirmationStatus: 'CONFIRMED',
|
||||||
|
canConfirm: false,
|
||||||
|
readyForPda: true,
|
||||||
|
pdaTaskId: 1900000000000000006n,
|
||||||
|
pdaTaskStatus: 'PENDING',
|
||||||
|
blockingReasons: ['PRELOAD_ALREADY_CONFIRMED'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await confirmPreloadPlan('1900000000000000001', 3, 'confirm-operation-1')
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledWith(
|
||||||
|
'/platform/v1/first-leg/loading-plans/1900000000000000001/confirm',
|
||||||
|
{ expectedVersion: 3 },
|
||||||
|
{ headers: { 'Idempotency-Key': 'confirm-operation-1' } },
|
||||||
|
)
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
planId: '1900000000000000001',
|
||||||
|
containerBookingId: '1900000000000000004',
|
||||||
|
confirmationStatus: 'CONFIRMED',
|
||||||
|
version: 4,
|
||||||
|
readiness: { pdaTaskId: '1900000000000000006', readyForPda: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('uses stable plan and warehouse ids for customs document actions', async () => {
|
it('uses stable plan and warehouse ids for customs document actions', async () => {
|
||||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: { id: 6, documentType: 'CUSTOMS_DECLARATION', fileId: 9, fileName: 'declaration.pdf', version: 2 } })
|
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: { id: 6, documentType: 'CUSTOMS_DECLARATION', fileId: 9, fileName: 'declaration.pdf', version: 2 } })
|
||||||
const file = new File(['declaration'], 'declaration.pdf', { type: 'application/pdf' })
|
const file = new File(['declaration'], 'declaration.pdf', { type: 'application/pdf' })
|
||||||
|
|||||||
@@ -7,12 +7,38 @@ export interface LoadingCustomer {
|
|||||||
customerName: string | null
|
customerName: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PreloadConfirmationStatus = 'PENDING' | 'PARTIALLY_CONFIRMED' | 'CONFIRMED'
|
||||||
|
export type PreloadBlockingReason =
|
||||||
|
| 'PRELOAD_NOT_PUSHED'
|
||||||
|
| 'LOADING_PLAN_NOT_EDITING'
|
||||||
|
| 'CONTAINER_NO_MISSING'
|
||||||
|
| 'PRELOAD_LINK_MISSING'
|
||||||
|
| 'OCEAN_BOOKING_NOT_LINKED'
|
||||||
|
| 'OCEAN_BOOKING_NOT_RELEASED'
|
||||||
|
| 'PDA_TASK_ALREADY_CREATED'
|
||||||
|
| 'PRELOAD_ALREADY_CONFIRMED'
|
||||||
|
|
||||||
|
export interface PreloadReadiness {
|
||||||
|
preloadPushed: boolean
|
||||||
|
containerNoReady: boolean
|
||||||
|
preloadLinkCount: number
|
||||||
|
confirmedPreloadCount: number
|
||||||
|
oceanBookingId: string | null
|
||||||
|
oceanBookingStatus: 'CONFIRMED' | 'CHANGE_PENDING' | 'RELEASED' | 'CANCELLED' | null
|
||||||
|
pdaTaskId: string | null
|
||||||
|
pdaTaskStatus: string | null
|
||||||
|
confirmationStatus: PreloadConfirmationStatus
|
||||||
|
canConfirm: boolean
|
||||||
|
readyForPda: boolean
|
||||||
|
blockingReasons: PreloadBlockingReason[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface PreloadSummary {
|
export interface PreloadSummary {
|
||||||
planId: string
|
planId: string
|
||||||
containerBookingId: string
|
containerBookingId: string
|
||||||
preloadNo: string
|
preloadNo: string
|
||||||
containerNo: string | null
|
containerNo: string | null
|
||||||
internalContainerNo: string | null
|
cabinetNo: string | null
|
||||||
customers: LoadingCustomer[]
|
customers: LoadingCustomer[]
|
||||||
warehouseId: string | null
|
warehouseId: string | null
|
||||||
warehouseName: string | null
|
warehouseName: string | null
|
||||||
@@ -26,6 +52,7 @@ export interface PreloadSummary {
|
|||||||
totalGrossWeightKg: number
|
totalGrossWeightKg: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
version: number
|
version: number
|
||||||
|
readiness: PreloadReadiness
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfirmedSummary {
|
export interface ConfirmedSummary {
|
||||||
@@ -35,7 +62,7 @@ export interface ConfirmedSummary {
|
|||||||
warehouseId: string | null
|
warehouseId: string | null
|
||||||
warehouseName: string | null
|
warehouseName: string | null
|
||||||
containerNo: string | null
|
containerNo: string | null
|
||||||
internalContainerNo: string | null
|
cabinetNo: string | null
|
||||||
status: string
|
status: string
|
||||||
totalPackageCount: string
|
totalPackageCount: string
|
||||||
totalGrossWeightKg: number
|
totalGrossWeightKg: number
|
||||||
@@ -142,6 +169,14 @@ export interface TransportUpdateRequest {
|
|||||||
operationReason: string | null
|
operationReason: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PreloadConfirmationResponse {
|
||||||
|
planId: string
|
||||||
|
containerBookingId: string
|
||||||
|
confirmationStatus: 'CONFIRMED'
|
||||||
|
version: number
|
||||||
|
readiness: PreloadReadiness
|
||||||
|
}
|
||||||
|
|
||||||
type RawId = string | number | null | undefined
|
type RawId = string | number | null | undefined
|
||||||
type RawPage<T extends LoadingListSummary> = Omit<LoadingListPage<T>, 'page' | 'size' | 'total' | 'pages'> & {
|
type RawPage<T extends LoadingListSummary> = Omit<LoadingListPage<T>, 'page' | 'size' | 'total' | 'pages'> & {
|
||||||
page: string | number
|
page: string | number
|
||||||
@@ -160,6 +195,15 @@ function optionalId(value: RawId) {
|
|||||||
return value === null || value === undefined ? null : String(value)
|
return value === null || value === undefined ? null : String(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePreloadReadiness(raw: PreloadReadiness): PreloadReadiness {
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
oceanBookingId: optionalId(raw.oceanBookingId),
|
||||||
|
pdaTaskId: optionalId(raw.pdaTaskId),
|
||||||
|
blockingReasons: raw.blockingReasons || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePreloadSummary(raw: PreloadSummary): PreloadSummary {
|
function normalizePreloadSummary(raw: PreloadSummary): PreloadSummary {
|
||||||
return {
|
return {
|
||||||
...raw,
|
...raw,
|
||||||
@@ -168,6 +212,7 @@ function normalizePreloadSummary(raw: PreloadSummary): PreloadSummary {
|
|||||||
warehouseId: optionalId(raw.warehouseId),
|
warehouseId: optionalId(raw.warehouseId),
|
||||||
skuCount: String(raw.skuCount ?? '0'),
|
skuCount: String(raw.skuCount ?? '0'),
|
||||||
totalPackageCount: String(raw.totalPackageCount ?? '0'),
|
totalPackageCount: String(raw.totalPackageCount ?? '0'),
|
||||||
|
readiness: normalizePreloadReadiness(raw.readiness),
|
||||||
customers: (raw.customers || []).map((customer) => ({
|
customers: (raw.customers || []).map((customer) => ({
|
||||||
...customer,
|
...customer,
|
||||||
accountId: requiredId(customer.accountId),
|
accountId: requiredId(customer.accountId),
|
||||||
@@ -206,6 +251,7 @@ function normalizeHistory(raw: LoadingHistory): LoadingHistory {
|
|||||||
operatorId: requiredId(raw.operatorId),
|
operatorId: requiredId(raw.operatorId),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDocument(raw: BusinessDocument | null | undefined): BusinessDocument | null {
|
function normalizeDocument(raw: BusinessDocument | null | undefined): BusinessDocument | null {
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
return { ...raw, id: raw.id == null ? null : requiredId(raw.id), fileId: requiredId(raw.fileId), uploadedBy: optionalId(raw.uploadedBy), completedBy: optionalId(raw.completedBy), version: Number(raw.version ?? 0) }
|
return { ...raw, id: raw.id == null ? null : requiredId(raw.id), fileId: requiredId(raw.fileId), uploadedBy: optionalId(raw.uploadedBy), completedBy: optionalId(raw.completedBy), version: Number(raw.version ?? 0) }
|
||||||
@@ -262,6 +308,21 @@ export async function updatePreloadTransport(planId: string, warehouseId: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function confirmPreloadPlan(planId: string, expectedVersion: number, operationId: string) {
|
||||||
|
const response = await http.post<ApiEnvelope<PreloadConfirmationResponse>, ApiEnvelope<PreloadConfirmationResponse>>(
|
||||||
|
`${basePath}/loading-plans/${encodeURIComponent(planId)}/confirm`,
|
||||||
|
{ expectedVersion },
|
||||||
|
{ headers: { 'Idempotency-Key': operationId } },
|
||||||
|
)
|
||||||
|
const result = unwrapApiResponse(response)
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
planId: requiredId(result.planId),
|
||||||
|
containerBookingId: requiredId(result.containerBookingId),
|
||||||
|
readiness: normalizePreloadReadiness(result.readiness),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getLoadingDeclaration(planId: string, warehouseId: string) {
|
export async function getLoadingDeclaration(planId: string, warehouseId: string) {
|
||||||
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs`, { params: { warehouseId } })
|
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs`, { params: { warehouseId } })
|
||||||
return normalizeDocument(unwrapApiResponse(response))
|
return normalizeDocument(unwrapApiResponse(response))
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ const props = defineProps<{
|
|||||||
detail: LoadingDetail | null
|
detail: LoadingDetail | null
|
||||||
error: ApiErrorDetails | null
|
error: ApiErrorDetails | null
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
|
confirmSaving?: boolean
|
||||||
canManageCustoms?: boolean
|
canManageCustoms?: boolean
|
||||||
customsSaving?: boolean
|
customsSaving?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{ retry: []; edit: []; attachCustoms: []; completeCustoms: [] }>()
|
const emit = defineEmits<{ retry: []; edit: []; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
|
||||||
|
|
||||||
const itemColumns: DataTableColumn[] = [
|
const itemColumns: DataTableColumn[] = [
|
||||||
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', fixed: 'left', width: 92, hideable: false, movable: false },
|
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', fixed: 'left', width: 92, hideable: false, movable: false },
|
||||||
@@ -45,6 +46,28 @@ const itemColumns: DataTableColumn[] = [
|
|||||||
|
|
||||||
const summary = computed<LoadingListSummary | null>(() => props.detail?.summary || null)
|
const summary = computed<LoadingListSummary | null>(() => props.detail?.summary || null)
|
||||||
const preloadSummary = computed(() => props.mode === 'preload' ? summary.value as PreloadSummary | null : null)
|
const preloadSummary = computed(() => props.mode === 'preload' ? summary.value as PreloadSummary | null : null)
|
||||||
|
const blockingReasonLabels: Record<string, string> = {
|
||||||
|
PRELOAD_NOT_PUSHED: '尚未推送预装清单',
|
||||||
|
LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态',
|
||||||
|
CONTAINER_NO_MISSING: '尚未录入集装箱号',
|
||||||
|
PRELOAD_LINK_MISSING: '装柜明细尚未完整关联入库预装票据',
|
||||||
|
OCEAN_BOOKING_NOT_LINKED: '尚未关联真实订舱',
|
||||||
|
OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱',
|
||||||
|
PDA_TASK_ALREADY_CREATED: 'PDA 任务已创建,无需重复确认',
|
||||||
|
PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认',
|
||||||
|
}
|
||||||
|
const confirmationStatusLabels: Record<string, string> = {
|
||||||
|
PENDING: '待确认', PARTIALLY_CONFIRMED: '部分票据已确认', CONFIRMED: '已确认',
|
||||||
|
}
|
||||||
|
const oceanBookingStatusLabels: Record<string, string> = {
|
||||||
|
CONFIRMED: '已确认', CHANGE_PENDING: '改船处理中', RELEASED: '已放舱', CANCELLED: '已取消',
|
||||||
|
}
|
||||||
|
const pdaTaskStatusLabels: Record<string, string> = {
|
||||||
|
PENDING: '待领取', CLAIMED: '已领取', LOADING: '作业中', AWAITING_REVIEW: '待人工复核', COMPLETED: '已完成',
|
||||||
|
}
|
||||||
|
const blockingReasonText = computed(() => (preloadSummary.value?.readiness.blockingReasons || [])
|
||||||
|
.map((reason) => blockingReasonLabels[reason] || '存在未满足的确认条件')
|
||||||
|
.join(';'))
|
||||||
const feeCustomers = computed(() => {
|
const feeCustomers = computed(() => {
|
||||||
const customers = new Map<string, string | null>()
|
const customers = new Map<string, string | null>()
|
||||||
for (const item of props.detail?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
|
for (const item of props.detail?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
|
||||||
@@ -62,6 +85,7 @@ function actionLabel(action: string) {
|
|||||||
return ({
|
return ({
|
||||||
CREATE: '创建计划', ASSIGN: '分配货物', ADJUST: '调整货物', DELETE: '移除货物',
|
CREATE: '创建计划', ASSIGN: '分配货物', ADJUST: '调整货物', DELETE: '移除货物',
|
||||||
RECOMMEND: '智能排柜', PUSH_PRELOAD: '推送预装', UPDATE_TRANSPORT: '修改运输信息',
|
RECOMMEND: '智能排柜', PUSH_PRELOAD: '推送预装', UPDATE_TRANSPORT: '修改运输信息',
|
||||||
|
CONFIRM_PRELOAD: '确认预装',
|
||||||
START_LOADING: '开始拣货', COMPLETE: '完成装柜',
|
START_LOADING: '开始拣货', COMPLETE: '完成装柜',
|
||||||
} as Record<string, string>)[action] || '状态更新'
|
} as Record<string, string>)[action] || '状态更新'
|
||||||
}
|
}
|
||||||
@@ -86,13 +110,16 @@ function formatNumber(value?: number | null, digits = 3) {
|
|||||||
<DetailPageLayout :ariaLabel="title">
|
<DetailPageLayout :ariaLabel="title">
|
||||||
<template #summary>
|
<template #summary>
|
||||||
<section class="detail-summary" aria-label="清单基本信息">
|
<section class="detail-summary" aria-label="清单基本信息">
|
||||||
|
<div><span>柜次号</span><strong>{{ summary.cabinetNo || '—' }}</strong></div>
|
||||||
|
<div><span>集装箱号</span><strong>{{ summary.containerNo || '待录入' }}</strong></div>
|
||||||
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
|
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
|
||||||
<div><span>集装箱号</span><strong>{{ summary.containerNo || '—' }}</strong></div>
|
|
||||||
<div><span>柜号</span><strong>{{ summary.internalContainerNo || '—' }}</strong></div>
|
|
||||||
<div><span>状态</span><StatusBadge :status="loadingListStatusLabel(summary.status)" /></div>
|
<div><span>状态</span><StatusBadge :status="loadingListStatusLabel(summary.status)" /></div>
|
||||||
<template v-if="preloadSummary">
|
<template v-if="preloadSummary">
|
||||||
<div><span>船名 / 航次</span><strong>{{ preloadSummary.vesselName || '—' }} / {{ preloadSummary.voyageNo || '—' }}</strong></div>
|
<div><span>船名 / 航次</span><strong>{{ preloadSummary.vesselName || '—' }} / {{ preloadSummary.voyageNo || '—' }}</strong></div>
|
||||||
<div><span>箱型</span><strong>{{ preloadSummary.containerType || '—' }}</strong></div>
|
<div><span>箱型</span><strong>{{ preloadSummary.containerType || '—' }}</strong></div>
|
||||||
|
<div><span>预装确认</span><StatusBadge :status="confirmationStatusLabels[preloadSummary.readiness.confirmationStatus] || '未知状态'" /></div>
|
||||||
|
<div><span>订舱状态</span><strong>{{ preloadSummary.readiness.oceanBookingStatus ? (oceanBookingStatusLabels[preloadSummary.readiness.oceanBookingStatus] || '未知状态') : '未关联' }}</strong></div>
|
||||||
|
<div><span>PDA 状态</span><strong>{{ preloadSummary.readiness.pdaTaskStatus ? (pdaTaskStatusLabels[preloadSummary.readiness.pdaTaskStatus] || '未知状态') : (preloadSummary.readiness.readyForPda ? '可下发' : '未就绪') }}</strong></div>
|
||||||
<div class="detail-summary__wide"><span>客户</span><strong>{{ preloadSummary.customers.map((item) => item.customerName || '客户信息缺失').join('、') || '客户信息缺失' }}</strong></div>
|
<div class="detail-summary__wide"><span>客户</span><strong>{{ preloadSummary.customers.map((item) => item.customerName || '客户信息缺失').join('、') || '客户信息缺失' }}</strong></div>
|
||||||
</template>
|
</template>
|
||||||
<div><span>创建时间</span><strong>{{ formatDateTime(summary.createdAt) }}</strong></div>
|
<div><span>创建时间</span><strong>{{ formatDateTime(summary.createdAt) }}</strong></div>
|
||||||
@@ -100,8 +127,11 @@ function formatNumber(value?: number | null, digits = 3) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)" class="detail-actions">
|
<div v-if="mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)" class="detail-actions">
|
||||||
<el-button type="primary" @click="emit('edit')"><Pencil :size="16" />编辑运输信息</el-button>
|
<el-button type="primary" @click="emit('edit')"><Pencil :size="16" />{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}</el-button>
|
||||||
|
<el-button type="success" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
<el-alert v-if="preloadSummary?.readiness.confirmationStatus === 'CONFIRMED'" type="success" :closable="false" show-icon title="整份预装计划已确认" :description="preloadSummary.readiness.pdaTaskStatus ? `PDA 任务状态:${pdaTaskStatusLabels[preloadSummary.readiness.pdaTaskStatus] || '未知状态'}` : 'PDA 前置条件已满足,等待服务端生成任务。'" />
|
||||||
|
<el-alert v-else-if="preloadSummary && !preloadSummary.readiness.canConfirm" type="warning" :closable="false" show-icon title="当前暂不能确认预装" :description="blockingReasonText || '请刷新详情获取最新确认条件。'" />
|
||||||
<div v-else-if="mode === 'confirmed'" class="detail-actions"><el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button><el-button v-if="canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" type="primary" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" />完成报关</el-button><StatusBadge v-if="detail.customsDocument?.completedAt" status="报关完成" /></div>
|
<div v-else-if="mode === 'confirmed'" class="detail-actions"><el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button><el-button v-if="canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" type="primary" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" />完成报关</el-button><StatusBadge v-if="detail.customsDocument?.completedAt" status="报关完成" /></div>
|
||||||
|
|
||||||
<section class="detail-section surface-panel">
|
<section class="detail-section surface-panel">
|
||||||
|
|||||||
@@ -27,22 +27,36 @@ describe('LoadingListsView', () => {
|
|||||||
expect(source).not.toContain("key: 'warehouseId'")
|
expect(source).not.toContain("key: 'warehouseId'")
|
||||||
expect(source).not.toContain('loadingWarehouses')
|
expect(source).not.toContain('loadingWarehouses')
|
||||||
expect(source).not.toContain('warehouseId: warehouseId || undefined')
|
expect(source).not.toContain('warehouseId: warehouseId || undefined')
|
||||||
expect(source).toContain("selectedWarehouseId.value = String(row.warehouseId || '')")
|
expect(source).toContain('query: { warehouseId: String(row.warehouseId || \'\') }')
|
||||||
expect(source).not.toContain("ElMessage.warning('请先选择仓库')")
|
expect(source).not.toContain("ElMessage.warning('请先选择仓库')")
|
||||||
expect(source).not.toContain("'请先选择仓库后查询'")
|
expect(source).not.toContain("'请先选择仓库后查询'")
|
||||||
expect(source).toContain('MISSING_WAREHOUSE_CONTEXT')
|
expect(preloadDetailSource).toContain('MISSING_WAREHOUSE_CONTEXT')
|
||||||
|
expect(confirmedDetailSource).toContain('MISSING_WAREHOUSE_CONTEXT')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the shared query, table, dialog and error components without a detail drawer', () => {
|
it('uses the shared query and table components while keeping details on route pages', () => {
|
||||||
expect(source).toContain('QueryToolbar')
|
expect(source).toContain('QueryToolbar')
|
||||||
expect(source).toContain('DataTable')
|
expect(source).toContain('DataTable')
|
||||||
expect(source).toContain('AppDialog')
|
|
||||||
expect(source).toContain('LoadErrorState')
|
expect(source).toContain('LoadErrorState')
|
||||||
expect(source).toContain('ApiErrorMeta')
|
expect(source).toContain('ApiErrorMeta')
|
||||||
|
expect(preloadDetailSource).toContain('AppDialog')
|
||||||
|
expect(confirmedDetailSource).toContain('AppDialog')
|
||||||
expect(detailSource).not.toContain('AppDrawer')
|
expect(detailSource).not.toContain('AppDrawer')
|
||||||
expect(detailSource).toContain('ContainerFeesPanel')
|
expect(detailSource).toContain('ContainerFeesPanel')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('distinguishes the generated cabinet reference from the physical container number', () => {
|
||||||
|
expect(source).toContain("label: '柜次号'")
|
||||||
|
expect(source).toContain("label: '集装箱号'")
|
||||||
|
expect(source).toContain('装柜清单号/柜次号/集装箱号')
|
||||||
|
expect(detailSource).toContain('<span>柜次号</span>')
|
||||||
|
expect(detailSource).toContain('<span>集装箱号</span>')
|
||||||
|
expect(source).not.toContain("label: '柜号'")
|
||||||
|
expect(detailSource).not.toContain('<span>柜号</span>')
|
||||||
|
expect(source).toContain("key: 'cabinetNo', prop: 'cabinetNo'")
|
||||||
|
expect(source).not.toContain('internalContainerNo')
|
||||||
|
})
|
||||||
|
|
||||||
it('opens both preload and confirmed details as independent route pages', () => {
|
it('opens both preload and confirmed details as independent route pages', () => {
|
||||||
expect(source).toContain("name: 'preload-plan-detail'")
|
expect(source).toContain("name: 'preload-plan-detail'")
|
||||||
expect(source).toContain("name: 'loading-list-detail'")
|
expect(source).toContain("name: 'loading-list-detail'")
|
||||||
@@ -55,20 +69,19 @@ describe('LoadingListsView', () => {
|
|||||||
expect(routesSource).toContain("name: 'loading-list-detail'")
|
expect(routesSource).toContain("name: 'loading-list-detail'")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the real transport edit contract in a 620px centered modal', () => {
|
it('keeps the real transport edit contract in a 620px centered modal on the detail page', () => {
|
||||||
expect(source).toContain('class="loading-transport-dialog"')
|
expect(source).not.toContain('class="loading-transport-dialog"')
|
||||||
expect(source).toContain('width="min(620px, calc(100vw - 28px))"')
|
|
||||||
expect(preloadDetailSource).toContain('width="min(620px, calc(100vw - 28px))"')
|
expect(preloadDetailSource).toContain('width="min(620px, calc(100vw - 28px))"')
|
||||||
expect(source).not.toContain('备注信息(仅自己可见)')
|
expect(preloadDetailSource).not.toContain('备注信息(仅自己可见)')
|
||||||
expect(source).not.toContain('备注信息(客户可见)')
|
expect(preloadDetailSource).not.toContain('备注信息(客户可见)')
|
||||||
expect(source).not.toContain('备注信息(仓库可见)')
|
expect(preloadDetailSource).not.toContain('备注信息(仓库可见)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('only exposes transport editing for LOADING preload details', () => {
|
it('only exposes transport editing for LOADING preload details', () => {
|
||||||
expect(detailSource).toContain("mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)")
|
expect(detailSource).toContain("mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)")
|
||||||
expect(detailSource).toContain('报关资料')
|
expect(detailSource).toContain('报关资料')
|
||||||
expect(detailSource).toContain('完成报关')
|
expect(detailSource).toContain('完成报关')
|
||||||
expect(source).toContain('expectedVersion: detail.value.summary.version')
|
expect(preloadDetailSource).toContain('expectedVersion: detail.value.summary.version')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders business labels instead of raw loading-plan status codes', () => {
|
it('renders business labels instead of raw loading-plan status codes', () => {
|
||||||
@@ -78,16 +91,17 @@ describe('LoadingListsView', () => {
|
|||||||
expect(source).not.toContain('仅装柜中允许修改')
|
expect(source).not.toContain('仅装柜中允许修改')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes a row edit action only for loading preload plans', () => {
|
it('keeps transport editing as a detail-page action', () => {
|
||||||
expect(source).toContain("isPreload && canEdit && ['EDITING', 'LOADING'].includes(row.status)")
|
expect(source).not.toContain('openEditFromRow(row)')
|
||||||
expect(source).toContain('openEditFromRow(row)')
|
expect(source).not.toContain('编辑运输信息')
|
||||||
expect(source).toContain('await loadDetail()')
|
expect(detailSource).toContain("{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}")
|
||||||
|
expect(preloadDetailSource).toContain("const editDialogTitle = computed(() => detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号')")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refreshes the detail version on A0409 while preserving transport input', () => {
|
it('refreshes the detail version on A0409 while preserving transport input', () => {
|
||||||
expect(source).toContain("details.code === 'A0409'")
|
expect(preloadDetailSource).toContain("details.code === 'A0409'")
|
||||||
expect(source).toContain('await loadDetail()')
|
expect(preloadDetailSource).toContain('await loadDetail()')
|
||||||
expect(source).toContain('当前输入已保留,请核对后重新保存')
|
expect(preloadDetailSource).toContain('当前输入已保留,请核对后重新保存')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens a validated plan and warehouse deep link without mixing list modes', () => {
|
it('opens a validated plan and warehouse deep link without mixing list modes', () => {
|
||||||
@@ -99,6 +113,16 @@ describe('LoadingListsView', () => {
|
|||||||
expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH')
|
expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses backend readiness for whole-plan confirmation and translates blocking reasons', () => {
|
||||||
|
expect(detailSource).toContain(':disabled="!preloadSummary?.readiness.canConfirm"')
|
||||||
|
expect(detailSource).toContain("PRELOAD_NOT_PUSHED: '尚未推送预装清单'")
|
||||||
|
expect(detailSource).toContain("OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱'")
|
||||||
|
expect(detailSource).toContain("PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认'")
|
||||||
|
expect(detailSource).toContain("confirmationStatus === 'CONFIRMED'")
|
||||||
|
expect(detailSource).toContain("pdaTaskStatusLabels")
|
||||||
|
expect(preloadDetailSource).toContain('@confirm-preload="openConfirmPreload"')
|
||||||
|
})
|
||||||
|
|
||||||
it('passes translated status text directly to the badge', () => {
|
it('passes translated status text directly to the badge', () => {
|
||||||
expect(source).toContain('<StatusBadge :status="loadingListStatusLabel(row.status)" />')
|
expect(source).toContain('<StatusBadge :status="loadingListStatusLabel(row.status)" />')
|
||||||
expect(source).not.toContain('<StatusBadge :status="row.status" />')
|
expect(source).not.toContain('<StatusBadge :status="row.status" />')
|
||||||
@@ -107,10 +131,8 @@ describe('LoadingListsView', () => {
|
|||||||
|
|
||||||
it('uses bordered list actions with a stable 320px layout', () => {
|
it('uses bordered list actions with a stable 320px layout', () => {
|
||||||
expect(source).toContain('size="small" type="primary" plain')
|
expect(source).toContain('size="small" type="primary" plain')
|
||||||
expect(source).toContain('size="small" @click="openEditFromRow(row)"')
|
|
||||||
expect(source).toContain('编辑运输信息')
|
|
||||||
expect(source).not.toContain('<el-button v-if="canView" link')
|
expect(source).not.toContain('<el-button v-if="canView" link')
|
||||||
expect(source).toContain("key: 'actions', label: '操作', fixed: 'left', width: 230")
|
expect(source).toContain("key: 'actions', label: '操作', fixed: 'left', width: 110")
|
||||||
expect(source).toContain('flex-wrap: wrap')
|
expect(source).toContain('flex-wrap: wrap')
|
||||||
expect(source).toContain('@media (max-width: 720px)')
|
expect(source).toContain('@media (max-width: 720px)')
|
||||||
expect(source).toContain('.row-actions { gap: 4px; }')
|
expect(source).toContain('.row-actions { gap: 4px; }')
|
||||||
@@ -119,6 +141,7 @@ describe('LoadingListsView', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('does not expose unknown history action codes', () => {
|
it('does not expose unknown history action codes', () => {
|
||||||
|
expect(detailSource).toContain("CONFIRM_PRELOAD: '确认预装'")
|
||||||
expect(detailSource).toContain("[action] || '状态更新'")
|
expect(detailSource).toContain("[action] || '状态更新'")
|
||||||
expect(detailSource).not.toContain('[action] || action')
|
expect(detailSource).not.toContain('[action] || action')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { Eye } from '@lucide/vue'
|
||||||
import { Eye, Pencil } from '@lucide/vue'
|
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
@@ -16,12 +14,9 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
|||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
import { loadingListStatusLabel } from '../status'
|
import { loadingListStatusLabel } from '../status'
|
||||||
import {
|
import {
|
||||||
getPreloadPlan,
|
|
||||||
listConfirmedLoadingLists,
|
listConfirmedLoadingLists,
|
||||||
listPreloadPlans,
|
listPreloadPlans,
|
||||||
updatePreloadTransport,
|
|
||||||
type ConfirmedSummary,
|
type ConfirmedSummary,
|
||||||
type LoadingDetail,
|
|
||||||
type LoadingListMode,
|
type LoadingListMode,
|
||||||
type LoadingListQuery,
|
type LoadingListQuery,
|
||||||
type LoadingListSummary,
|
type LoadingListSummary,
|
||||||
@@ -41,16 +36,6 @@ const total = ref(0)
|
|||||||
const customers = ref<PlatformTenantResource[]>([])
|
const customers = ref<PlatformTenantResource[]>([])
|
||||||
const customersLoading = ref(false)
|
const customersLoading = ref(false)
|
||||||
const query = reactive<QueryModel>({ customerId: '', keyword: '', createdRange: [] })
|
const query = reactive<QueryModel>({ customerId: '', keyword: '', createdRange: [] })
|
||||||
const detailLoading = ref(false)
|
|
||||||
const detailError = ref<ApiErrorDetails | null>(null)
|
|
||||||
const detail = ref<LoadingDetail | null>(null)
|
|
||||||
const selectedPlanId = ref('')
|
|
||||||
const selectedWarehouseId = ref('')
|
|
||||||
const editOpen = ref(false)
|
|
||||||
const editSaving = ref(false)
|
|
||||||
const editError = ref<ApiErrorDetails | null>(null)
|
|
||||||
const editFormRef = ref<FormInstance>()
|
|
||||||
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
|
|
||||||
const handledDeepLink = ref('')
|
const handledDeepLink = ref('')
|
||||||
|
|
||||||
const isPreload = computed(() => props.mode === 'preload')
|
const isPreload = computed(() => props.mode === 'preload')
|
||||||
@@ -58,18 +43,17 @@ const title = computed(() => isPreload.value ? '预装清单' : '装柜清单')
|
|||||||
const listPermission = computed(() => isPreload.value ? 'platform:firstleg:preload:list' : 'platform:firstleg:loading-list:list')
|
const listPermission = computed(() => isPreload.value ? 'platform:firstleg:preload:list' : 'platform:firstleg:loading-list:list')
|
||||||
const canList = computed(() => access.can(listPermission.value))
|
const canList = computed(() => access.can(listPermission.value))
|
||||||
const canView = computed(() => canList.value)
|
const canView = computed(() => canList.value)
|
||||||
const canEdit = computed(() => isPreload.value && access.can('platform:firstleg:preload:edit'))
|
|
||||||
const fields = computed<QueryField[]>(() => [
|
const fields = computed<QueryField[]>(() => [
|
||||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
|
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, 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: '关键词', placeholder: isPreload.value ? '预装单号/集装箱号/柜号' : '装柜清单号/集装箱号/柜号', width: 250 },
|
{ key: 'keyword', type: 'input', label: '关键词', placeholder: isPreload.value ? '预装单号/柜次号/集装箱号' : '装柜清单号/柜次号/集装箱号', width: 270 },
|
||||||
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 260 },
|
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 260 },
|
||||||
])
|
])
|
||||||
|
|
||||||
const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
|
const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
|
||||||
{ key: 'actions', label: '操作', fixed: 'left', width: 230, slotName: 'actions', hideable: false, movable: false },
|
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
|
||||||
{ key: 'preloadNo', prop: 'preloadNo', label: '预装单号', minWidth: 165 },
|
{ key: 'preloadNo', prop: 'preloadNo', label: '预装单号', minWidth: 165 },
|
||||||
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
|
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
|
||||||
{ key: 'internalContainerNo', prop: 'internalContainerNo', label: '柜号', minWidth: 130 },
|
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150 },
|
||||||
{ key: 'customers', label: '客户', minWidth: 190, slotName: 'customers' },
|
{ key: 'customers', label: '客户', minWidth: 190, slotName: 'customers' },
|
||||||
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 },
|
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 },
|
||||||
{ key: 'vesselVoyage', label: '船名 / 航次', minWidth: 175, slotName: 'vesselVoyage' },
|
{ key: 'vesselVoyage', label: '船名 / 航次', minWidth: 175, slotName: 'vesselVoyage' },
|
||||||
@@ -84,7 +68,7 @@ const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
|
|||||||
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
|
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
|
||||||
{ key: 'loadingListNo', prop: 'loadingListNo', label: '装柜清单号', minWidth: 175 },
|
{ key: 'loadingListNo', prop: 'loadingListNo', label: '装柜清单号', minWidth: 175 },
|
||||||
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
|
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
|
||||||
{ key: 'internalContainerNo', prop: 'internalContainerNo', label: '柜号', minWidth: 130 },
|
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150 },
|
||||||
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 },
|
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 },
|
||||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
|
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
|
||||||
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 90, align: 'right' },
|
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 90, align: 'right' },
|
||||||
@@ -97,12 +81,6 @@ const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
|
|||||||
{ key: 'createdAt', label: '创建时间', minWidth: 155, slotName: 'createdAt' },
|
{ key: 'createdAt', label: '创建时间', minWidth: 155, slotName: 'createdAt' },
|
||||||
])
|
])
|
||||||
|
|
||||||
const editRules: FormRules = {
|
|
||||||
containerNo: [{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' }],
|
|
||||||
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
|
|
||||||
operationReason: [{ max: 500, message: '修改原因不能超过 500 个字符', trigger: 'blur' }],
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(value?: string | null) {
|
function formatDateTime(value?: string | null) {
|
||||||
return value ? value.replace('T', ' ').slice(0, 16) : '—'
|
return value ? value.replace('T', ' ').slice(0, 16) : '—'
|
||||||
}
|
}
|
||||||
@@ -165,27 +143,6 @@ function search() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function loadDetail() {
|
|
||||||
if (!canView.value) return
|
|
||||||
if (!selectedPlanId.value || !selectedWarehouseId.value) {
|
|
||||||
detail.value = null
|
|
||||||
detailError.value = {
|
|
||||||
message: '该清单缺少所属仓库信息,无法安全查询详情,请联系管理员核对数据。',
|
|
||||||
code: 'MISSING_WAREHOUSE_CONTEXT',
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
detailLoading.value = true
|
|
||||||
detailError.value = null
|
|
||||||
try {
|
|
||||||
detail.value = await getPreloadPlan(selectedPlanId.value, selectedWarehouseId.value)
|
|
||||||
} catch (cause) {
|
|
||||||
detailError.value = getApiErrorDetails(cause, '清单详情加载失败,请稍后重试')
|
|
||||||
} finally {
|
|
||||||
detailLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDetail(rowValue: object) {
|
function openDetail(rowValue: object) {
|
||||||
const row = rowValue as LoadingListSummary
|
const row = rowValue as LoadingListSummary
|
||||||
if (isPreload.value) {
|
if (isPreload.value) {
|
||||||
@@ -195,59 +152,6 @@ function openDetail(rowValue: object) {
|
|||||||
void router.push({ name: 'loading-list-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } })
|
void router.push({ name: 'loading-list-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openEditFromRow(rowValue: object) {
|
|
||||||
const row = rowValue as LoadingListSummary
|
|
||||||
if (!isPreload.value || !canEdit.value || !['EDITING', 'LOADING'].includes(row.status)) return
|
|
||||||
selectedPlanId.value = String(row.planId)
|
|
||||||
selectedWarehouseId.value = String(row.warehouseId || '')
|
|
||||||
detail.value = null
|
|
||||||
detailError.value = null
|
|
||||||
await loadDetail()
|
|
||||||
const loadedDetail = detail.value as LoadingDetail | null
|
|
||||||
if (loadedDetail && ['EDITING', 'LOADING'].includes(loadedDetail.summary.status)) openEdit()
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit() {
|
|
||||||
if (!detail.value || !('preloadNo' in detail.value.summary) || !['EDITING', 'LOADING'].includes(detail.value.summary.status)) return
|
|
||||||
editForm.containerNo = detail.value.summary.containerNo || ''
|
|
||||||
editForm.voyageNo = detail.value.summary.voyageNo || ''
|
|
||||||
editForm.operationReason = ''
|
|
||||||
editError.value = null
|
|
||||||
editOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveTransport() {
|
|
||||||
if (!detail.value || !('preloadNo' in detail.value.summary)) return
|
|
||||||
const valid = await editFormRef.value?.validate().catch(() => false)
|
|
||||||
if (!valid) return
|
|
||||||
editSaving.value = true
|
|
||||||
editError.value = null
|
|
||||||
try {
|
|
||||||
detail.value = await updatePreloadTransport(selectedPlanId.value, selectedWarehouseId.value, {
|
|
||||||
containerNo: editForm.containerNo.trim() || null,
|
|
||||||
voyageNo: editForm.voyageNo.trim() || null,
|
|
||||||
operationReason: editForm.operationReason.trim() || null,
|
|
||||||
expectedVersion: detail.value.summary.version,
|
|
||||||
})
|
|
||||||
editOpen.value = false
|
|
||||||
ElMessage.success('运输信息已更新')
|
|
||||||
await load()
|
|
||||||
} catch (cause) {
|
|
||||||
const details = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试')
|
|
||||||
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
|
|
||||||
await loadDetail()
|
|
||||||
editError.value = {
|
|
||||||
...details,
|
|
||||||
message: '运输信息已被其他人修改,系统已刷新最新版本;当前输入已保留,请核对后重新保存。',
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
editError.value = details
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
editSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCustomers() {
|
async function loadCustomers() {
|
||||||
customersLoading.value = true
|
customersLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -305,7 +209,6 @@ onMounted(() => {
|
|||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<el-button v-if="canView" size="small" type="primary" plain @click="openDetail(row)"><Eye :size="15" />查看</el-button>
|
<el-button v-if="canView" size="small" type="primary" plain @click="openDetail(row)"><Eye :size="15" />查看</el-button>
|
||||||
<el-button v-if="isPreload && canEdit && ['EDITING', 'LOADING'].includes(row.status)" size="small" @click="openEditFromRow(row)"><Pencil :size="15" />编辑运输信息</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #customers="{ row }">{{ (row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、') || '—' }}</template>
|
<template #customers="{ row }">{{ (row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、') || '—' }}</template>
|
||||||
@@ -319,26 +222,6 @@ onMounted(() => {
|
|||||||
</section>
|
</section>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
|
|
||||||
<AppDialog
|
|
||||||
v-model="editOpen"
|
|
||||||
class="loading-transport-dialog"
|
|
||||||
title="编辑预装运输信息"
|
|
||||||
width="min(620px, calc(100vw - 28px))"
|
|
||||||
confirm-text="保存"
|
|
||||||
:confirm-loading="editSaving"
|
|
||||||
:confirm-disabled="!canEdit"
|
|
||||||
@confirm="saveTransport"
|
|
||||||
>
|
|
||||||
<el-alert type="info" :closable="false" show-icon title="仅拣货中允许修改;保存时按详情版本执行乐观锁校验。" />
|
|
||||||
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
|
|
||||||
<el-form-item label="集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" show-word-limit placeholder="请输入集装箱号" /></el-form-item>
|
|
||||||
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" show-word-limit placeholder="请输入航次" /></el-form-item>
|
|
||||||
<el-form-item label="修改原因" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填,填写后将记录在操作历史中" /></el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<LoadErrorState v-if="editError" compact title="保存失败" :message="editError.message" :show-retry="false">
|
|
||||||
<template #details><ApiErrorMeta :details="editError" /></template>
|
|
||||||
</LoadErrorState>
|
|
||||||
</AppDialog>
|
|
||||||
</div>
|
</div>
|
||||||
</AdminPage>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
@@ -346,10 +229,7 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.loading-lists-page { display: grid; min-width: 0; gap: 16px; }
|
.loading-lists-page { display: grid; min-width: 0; gap: 16px; }
|
||||||
.table-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
|
.table-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
|
||||||
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 16px; }
|
|
||||||
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
|
||||||
.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
|
.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
|
||||||
.row-actions :deep(.el-button) { margin-left: 0; }
|
.row-actions :deep(.el-button) { margin-left: 0; }
|
||||||
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
|
@media (max-width: 720px) { .row-actions { gap: 4px; } }
|
||||||
@media (max-width: 720px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } .row-actions { gap: 4px; } }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -25,4 +25,30 @@ describe('PreloadPlanDetailView prototype contract', () => {
|
|||||||
expect(source).toContain('width="min(620px, calc(100vw - 28px))"')
|
expect(source).toContain('width="min(620px, calc(100vw - 28px))"')
|
||||||
expect(source).not.toContain('saveMfNote')
|
expect(source).not.toContain('saveMfNote')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('is the formal container-number entry and does not imply that preload directly opens PDA', () => {
|
||||||
|
expect(source).toContain("{ required: true, message: '请输入集装箱号'")
|
||||||
|
expect(source).toContain("detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号'")
|
||||||
|
expect(source).toContain('预装清单不会直接开放 PDA')
|
||||||
|
expect(source).toContain('订舱关联与放舱、预装确认后')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirms the whole plan through readiness with stable idempotency and refreshes the detail', () => {
|
||||||
|
expect(source).toContain('confirmPreloadPlan(summary.planId, summary.version, confirmOperationId.value)')
|
||||||
|
expect(source).toContain('summary?.readiness.canConfirm')
|
||||||
|
expect(source).toContain('confirmSaving.value')
|
||||||
|
expect(source).toContain('await loadDetail()')
|
||||||
|
expect(source).toContain('本操作将确认整份预装计划')
|
||||||
|
expect(source).toContain('全部入库预装票据')
|
||||||
|
expect(source).toContain('当前页面不会伪造 PDA 成功')
|
||||||
|
expect(source).not.toContain('/inbounds/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps backend confirmation errors visible and supports narrow dialogs', () => {
|
||||||
|
expect(source).toContain("confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')")
|
||||||
|
expect(source).toContain(':message="confirmError.message"')
|
||||||
|
expect(source).toContain('width="min(520px, calc(100vw - 28px))"')
|
||||||
|
expect(source).toContain("label=\"柜次号\"")
|
||||||
|
expect(source).toContain("label=\"集装箱号\"")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
||||||
import { getPreloadPlan, updatePreloadTransport, type LoadingDetail, type PreloadSummary } from '../api/platformLoadingLists'
|
import { confirmPreloadPlan, getPreloadPlan, updatePreloadTransport, type LoadingDetail, type PreloadSummary } from '../api/platformLoadingLists'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -21,13 +21,21 @@ const error = ref<ApiErrorDetails | null>(null)
|
|||||||
const editOpen = ref(false)
|
const editOpen = ref(false)
|
||||||
const editSaving = ref(false)
|
const editSaving = ref(false)
|
||||||
const editError = ref<ApiErrorDetails | null>(null)
|
const editError = ref<ApiErrorDetails | null>(null)
|
||||||
|
const confirmOpen = ref(false)
|
||||||
|
const confirmSaving = ref(false)
|
||||||
|
const confirmError = ref<ApiErrorDetails | null>(null)
|
||||||
|
const confirmOperationId = ref('')
|
||||||
const editFormRef = ref<FormInstance>()
|
const editFormRef = ref<FormInstance>()
|
||||||
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
|
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
|
||||||
const planId = computed(() => String(route.params.planId || ''))
|
const planId = computed(() => String(route.params.planId || ''))
|
||||||
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
|
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
|
||||||
const canEdit = computed(() => access.can('platform:firstleg:preload:edit'))
|
const canEdit = computed(() => access.can('platform:firstleg:preload:edit'))
|
||||||
|
const editDialogTitle = computed(() => detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号')
|
||||||
const editRules: FormRules = {
|
const editRules: FormRules = {
|
||||||
containerNo: [{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' }],
|
containerNo: [
|
||||||
|
{ required: true, message: '请输入集装箱号', trigger: 'blur' },
|
||||||
|
{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
|
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
|
||||||
operationReason: [{ max: 500, message: '修改原因不能超过 500 个字符', trigger: 'blur' }],
|
operationReason: [{ max: 500, message: '修改原因不能超过 500 个字符', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
@@ -74,7 +82,7 @@ async function saveTransport() {
|
|||||||
editError.value = null
|
editError.value = null
|
||||||
try {
|
try {
|
||||||
detail.value = await updatePreloadTransport(planId.value, warehouseId.value, {
|
detail.value = await updatePreloadTransport(planId.value, warehouseId.value, {
|
||||||
containerNo: editForm.containerNo.trim() || null,
|
containerNo: editForm.containerNo.trim(),
|
||||||
voyageNo: editForm.voyageNo.trim() || null,
|
voyageNo: editForm.voyageNo.trim() || null,
|
||||||
operationReason: editForm.operationReason.trim() || null,
|
operationReason: editForm.operationReason.trim() || null,
|
||||||
expectedVersion: detail.value.summary.version,
|
expectedVersion: detail.value.summary.version,
|
||||||
@@ -92,6 +100,32 @@ async function saveTransport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openConfirmPreload() {
|
||||||
|
const summary = detail.value?.summary
|
||||||
|
if (!canEdit.value || !summary?.readiness.canConfirm) return
|
||||||
|
confirmError.value = null
|
||||||
|
confirmOperationId.value = `admin-preload-confirm-${summary.planId}-${summary.version}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
confirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitConfirmPreload() {
|
||||||
|
const summary = detail.value?.summary
|
||||||
|
if (!summary?.readiness.canConfirm || confirmSaving.value) return
|
||||||
|
confirmSaving.value = true
|
||||||
|
confirmError.value = null
|
||||||
|
try {
|
||||||
|
const result = await confirmPreloadPlan(summary.planId, summary.version, confirmOperationId.value)
|
||||||
|
await loadDetail()
|
||||||
|
confirmOpen.value = false
|
||||||
|
confirmOperationId.value = ''
|
||||||
|
ElMessage.success(result.readiness.readyForPda ? '预装已确认,PDA 任务已具备下发条件' : '预装已确认')
|
||||||
|
} catch (cause) {
|
||||||
|
confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
confirmSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => void loadDetail())
|
onMounted(() => void loadDetail())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -99,13 +133,21 @@ onMounted(() => void loadDetail())
|
|||||||
<AdminPage>
|
<AdminPage>
|
||||||
<div class="preload-detail-page">
|
<div class="preload-detail-page">
|
||||||
<el-button link type="primary" class="back-button" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button>
|
<el-button link type="primary" class="back-button" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button>
|
||||||
|
<el-alert
|
||||||
|
v-if="detail"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
:title="detail.summary.containerNo ? '集装箱号已录入,继续完成订舱放舱与预装确认' : '下一步:录入集装箱号'"
|
||||||
|
description="预装清单不会直接开放 PDA;需完成集装箱号、订舱关联与放舱、预装确认后,才由服务端生成 PDA 任务。"
|
||||||
|
/>
|
||||||
<LoadErrorState v-if="error && !detail" title="预装清单详情加载失败" :message="error.message" :loading="loading" @retry="loadDetail">
|
<LoadErrorState v-if="error && !detail" title="预装清单详情加载失败" :message="error.message" :loading="loading" @retry="loadDetail">
|
||||||
<template #details><ApiErrorMeta :details="error" /></template>
|
<template #details><ApiErrorMeta :details="error" /></template>
|
||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
<LoadingListDetailDrawer v-else :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" @retry="loadDetail" @edit="openEdit" />
|
<LoadingListDetailDrawer v-else :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :confirm-saving="confirmSaving" @retry="loadDetail" @edit="openEdit" @confirm-preload="openConfirmPreload" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppDialog v-model="editOpen" class="loading-transport-dialog" title="编辑预装运输信息" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
|
<AppDialog v-model="editOpen" class="loading-transport-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
|
||||||
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
|
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
|
||||||
<el-form-item label="集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入集装箱号" /></el-form-item>
|
<el-form-item label="集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入集装箱号" /></el-form-item>
|
||||||
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
||||||
@@ -113,6 +155,18 @@ onMounted(() => void loadDetail())
|
|||||||
</el-form>
|
</el-form>
|
||||||
<LoadErrorState v-if="editError" compact title="保存失败" :message="editError.message" :show-retry="false"><template #details><ApiErrorMeta :details="editError" /></template></LoadErrorState>
|
<LoadErrorState v-if="editError" compact title="保存失败" :message="editError.message" :show-retry="false"><template #details><ApiErrorMeta :details="editError" /></template></LoadErrorState>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|
||||||
|
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
|
||||||
|
<div class="confirm-content">
|
||||||
|
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" description="系统会原子确认本计划包含的全部入库预装票据;成功后由服务端按真实条件创建唯一 PDA 任务,当前页面不会伪造 PDA 成功。" />
|
||||||
|
<el-descriptions v-if="detail" :column="1" border>
|
||||||
|
<el-descriptions-item label="预装单号">{{ detail.summary.preloadNo }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="柜次号">{{ detail.summary.cabinetNo || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="集装箱号">{{ detail.summary.containerNo || '待录入' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<LoadErrorState v-if="confirmError" compact title="确认预装失败" :message="confirmError.message" :show-retry="false"><template #details><ApiErrorMeta :details="confirmError" /></template></LoadErrorState>
|
||||||
|
</div>
|
||||||
|
</AppDialog>
|
||||||
</AdminPage>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -121,6 +175,7 @@ onMounted(() => void loadDetail())
|
|||||||
.back-button { width: fit-content; }
|
.back-button { width: fit-content; }
|
||||||
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
|
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
|
||||||
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||||
|
.confirm-content { display: grid; gap: 16px; }
|
||||||
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
|
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
|
||||||
@media (max-width: 720px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
|
@media (max-width: 720px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ describe('loading list routes', () => {
|
|||||||
['loading-plans', 'platform:firstleg:preload:list'],
|
['loading-plans', 'platform:firstleg:preload:list'],
|
||||||
['loading-plans/:planId', 'platform:firstleg:preload:list'],
|
['loading-plans/:planId', 'platform:firstleg:preload:list'],
|
||||||
['loading-lists', 'platform:firstleg:loading-list:list'],
|
['loading-lists', 'platform:firstleg:loading-list:list'],
|
||||||
|
['loading-lists/:planId', 'platform:firstleg:loading-list:list'],
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe('OceanBookingDetailView interaction contract', () => {
|
|||||||
expect(source).toContain("form.data.shippingCompanyName || '船公司名称缺失'")
|
expect(source).toContain("form.data.shippingCompanyName || '船公司名称缺失'")
|
||||||
expect(source).toContain("form.data.bookingAgentName || '订舱代理名称缺失'")
|
expect(source).toContain("form.data.bookingAgentName || '订舱代理名称缺失'")
|
||||||
expect(source).toContain("from '@/modules/master-data'")
|
expect(source).toContain("from '@/modules/master-data'")
|
||||||
expect(source).toContain("from '@/modules/suppliers/api/platformSuppliers'")
|
expect(source).toContain("from '@/modules/suppliers'")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the translated status once in the detail heading', () => {
|
it('renders the translated status once in the detail heading', () => {
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
|||||||
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
||||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||||
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
||||||
import { listSuppliers } from '@/modules/suppliers/api/platformSuppliers'
|
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||||
import type { Supplier } from '@/modules/suppliers/types'
|
|
||||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||||
import {
|
import {
|
||||||
createOceanBooking,
|
createOceanBooking,
|
||||||
|
|||||||
@@ -45,4 +45,9 @@ describe('TruckAppointmentForm interaction contract', () => {
|
|||||||
expect(source).toContain('placeholder="请输入目的门店地址"')
|
expect(source).toContain('placeholder="请输入目的门店地址"')
|
||||||
expect(source).toContain('placeholder="请输入拖车预约备注(可选)"')
|
expect(source).toContain('placeholder="请输入拖车预约备注(可选)"')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('labels the generated cabinet reference and physical container number separately', () => {
|
||||||
|
expect(source).toContain('柜次号:${item.cabinetNo')
|
||||||
|
expect(source).toContain('集装箱号:${item.containerNo')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
|
|||||||
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
|
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :xs="24"><el-divider content-position="left">关联资源</el-divider></el-col>
|
<el-col :xs="24"><el-divider content-position="left">关联资源</el-divider></el-col>
|
||||||
<el-col :xs="24" :sm="12"><el-form-item label="关联柜次"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="请选择柜次" :loading="optionsLoading" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-for="item in containers" :key="item.id" :label="`${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`" :value="item.id" /></el-select></el-form-item></el-col>
|
<el-col :xs="24" :sm="12"><el-form-item label="关联柜次"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="请选择柜次" :loading="optionsLoading" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-for="item in containers" :key="item.id" :label="`柜次号:${item.cabinetNo || item.bookingNo || '待生成'} / 集装箱号:${item.containerNo || '待录入'}`" :value="item.id" /></el-select></el-form-item></el-col>
|
||||||
<el-col :xs="24" :sm="12"><el-form-item label="关联订舱"><el-select :model-value="draft.oceanBookingId" filterable clearable placeholder="请选择订舱单" :loading="optionsLoading" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-for="item in bookings" :key="item.id" :label="item.bookingNo || `订舱 ${item.id}`" :value="item.id" /></el-select></el-form-item></el-col>
|
<el-col :xs="24" :sm="12"><el-form-item label="关联订舱"><el-select :model-value="draft.oceanBookingId" filterable clearable placeholder="请选择订舱单" :loading="optionsLoading" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-for="item in bookings" :key="item.id" :label="item.bookingNo || `订舱 ${item.id}`" :value="item.id" /></el-select></el-form-item></el-col>
|
||||||
<el-col :xs="24" :sm="12"><el-form-item label="客户"><el-select :model-value="draft.customerId" filterable clearable placeholder="请选择客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-if="draft.customerId && !selectableCustomers.some((item) => String(item.id) === String(draft.customerId))" :label="draft.customerName || '当前客户'" :value="String(draft.customerId)" /><el-option v-for="item in selectableCustomers" :key="String(item.id)" :label="customerLabel(item)" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
<el-col :xs="24" :sm="12"><el-form-item label="客户"><el-select :model-value="draft.customerId" filterable clearable placeholder="请选择客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-if="draft.customerId && !selectableCustomers.some((item) => String(item.id) === String(draft.customerId))" :label="draft.customerName || '当前客户'" :value="String(draft.customerId)" /><el-option v-for="item in selectableCustomers" :key="String(item.id)" :label="customerLabel(item)" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||||
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-select v-model="draft.warehouseId" filterable clearable placeholder="请选择仓库" :loading="optionsLoading" style="width:100%"><el-option v-for="item in selectableWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-select v-model="draft.warehouseId" filterable clearable placeholder="请选择仓库" :loading="optionsLoading" style="width:100%"><el-option v-for="item in selectableWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
firstLegTransportModeLabel,
|
firstLegTransportModeLabel,
|
||||||
} from '@/shared/utils/firstLegBusinessLabels'
|
} from '@/shared/utils/firstLegBusinessLabels'
|
||||||
import { UnifiedOrderWorkflowPanel } from '@/modules/unified-orders/workflows'
|
import { UnifiedOrderWorkflowPanel } from '@/modules/unified-orders/workflows'
|
||||||
import SmartLoadingDialog from '@/modules/container-loading/components/SmartLoadingDialog.vue'
|
import { SmartLoadingDialog } from '@/modules/container-loading'
|
||||||
import {
|
import {
|
||||||
unifiedOrderHistoryActionLabel,
|
unifiedOrderHistoryActionLabel,
|
||||||
unifiedOrderLoadingStatusLabel,
|
unifiedOrderLoadingStatusLabel,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
真实接口均使用 `/api/platform/v1/inbounds/{inboundId}`:
|
真实接口均使用 `/api/platform/v1/inbounds/{inboundId}`:
|
||||||
|
|
||||||
- `POST /split/smart`:智能分柜/保存预装清单
|
- `POST /split/smart`:智能分柜/保存预装清单
|
||||||
- `GET /preload`、`POST /preload/confirming`、`POST /preload/confirm`:预装清单查询与确认
|
- `GET /preload`:单票预装状态与明细只读查询;整份计划的确认统一在预装清单真实 `loadingPlan` 详情完成
|
||||||
- `GET/POST /loading/picking`、`/loading/picking/start`、`/loading/picking/complete`:拣货
|
- `GET/POST /loading/picking`、`/loading/picking/start`、`/loading/picking/complete`:拣货
|
||||||
- `POST /platform/v1/files/loading-evidence/{tenantId}/{inboundId}`:上传真实装柜证据文件
|
- `POST /platform/v1/files/loading-evidence/{tenantId}/{inboundId}`:上传真实装柜证据文件
|
||||||
- `GET/POST /loading/evidence`:查询并登记五类装柜证据及锁码
|
- `GET/POST /loading/evidence`:查询并登记五类装柜证据及锁码
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
adjustSmartLoadingItem,
|
adjustSmartLoadingItem,
|
||||||
assignSmartLoadingItem,
|
assignSmartLoadingItem,
|
||||||
completeLoading,
|
completeLoading,
|
||||||
confirmingPreload,
|
|
||||||
deleteSmartLoadingItem,
|
deleteSmartLoadingItem,
|
||||||
downloadEvidenceFile,
|
downloadEvidenceFile,
|
||||||
getContainerBooking,
|
getContainerBooking,
|
||||||
@@ -62,15 +61,6 @@ describe('unified order workflow API contract', () => {
|
|||||||
await expect(getPreload('2', '8')).rejects.toThrow('无权限')
|
await expect(getPreload('2', '8')).rejects.toThrow('无权限')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('supports the intermediate confirming preload action', async () => {
|
|
||||||
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'WAIT_SPLIT', preloadPlan: null } })
|
|
||||||
await confirmingPreload('2', '8', { expectedVersion: 1 })
|
|
||||||
expect(http.post).toHaveBeenCalledWith(
|
|
||||||
'/platform/v1/inbounds/8/preload/confirming', { expectedVersion: 1 },
|
|
||||||
expect.objectContaining({ params: { tenantId: '2' } }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uploads a real evidence file and voids evidence through audited endpoints', async () => {
|
it('uploads a real evidence file and voids evidence through audited endpoints', async () => {
|
||||||
vi.mocked(http.post)
|
vi.mocked(http.post)
|
||||||
.mockResolvedValueOnce({ data: { fileId: '88', fileName: 'empty.jpg' } })
|
.mockResolvedValueOnce({ data: { fileId: '88', fileName: 'empty.jpg' } })
|
||||||
|
|||||||
@@ -186,10 +186,6 @@ export interface SmartSplitRequest {
|
|||||||
remark?: string
|
remark?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfirmPreloadRequest {
|
|
||||||
expectedVersion: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpsertEvidenceRequest {
|
export interface UpsertEvidenceRequest {
|
||||||
expectedVersion: number
|
expectedVersion: number
|
||||||
evidenceType: EvidenceType
|
evidenceType: EvidenceType
|
||||||
@@ -446,30 +442,6 @@ export async function smartSplit(
|
|||||||
return normalizeSplitAction(unwrapApiResponse(response))
|
return normalizeSplitAction(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmingPreload(
|
|
||||||
tenantId: string | number,
|
|
||||||
inboundId: string | number,
|
|
||||||
request: ConfirmPreloadRequest,
|
|
||||||
) {
|
|
||||||
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
|
|
||||||
`${inboundBasePath}/${inboundId}/preload/confirming`, request,
|
|
||||||
writeOptions('preload-confirming', inboundId, tenantId),
|
|
||||||
)
|
|
||||||
return normalizeSplitAction(unwrapApiResponse(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function confirmPreload(
|
|
||||||
tenantId: string | number,
|
|
||||||
inboundId: string | number,
|
|
||||||
request: ConfirmPreloadRequest,
|
|
||||||
) {
|
|
||||||
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
|
|
||||||
`${inboundBasePath}/${inboundId}/preload/confirm`, request,
|
|
||||||
writeOptions('preload-confirm', inboundId, tenantId),
|
|
||||||
)
|
|
||||||
return normalizeSplitAction(unwrapApiResponse(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPicking(tenantId: string | number, inboundId: string | number) {
|
export async function getPicking(tenantId: string | number, inboundId: string | number) {
|
||||||
const response = await http.get<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
|
const response = await http.get<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
|
||||||
`${inboundBasePath}/${inboundId}/loading/picking`, { params: query(tenantId) },
|
`${inboundBasePath}/${inboundId}/loading/picking`, { params: query(tenantId) },
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
|
|||||||
expect(wrapper.find('button[aria-label="编辑第 1 条预装明细"]').exists()).toBe(true)
|
expect(wrapper.find('button[aria-label="编辑第 1 条预装明细"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps single-order preload confirmation read-only and links to the plan list', async () => {
|
||||||
|
const wrapper = mountPanel(['firstleg:inbound:preload-view', 'platform:firstleg:preload:list'])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('计划级预装确认')
|
||||||
|
expect(wrapper.text()).toContain('从实际预装计划详情确认整份计划')
|
||||||
|
expect(wrapper.text()).toContain('前往预装清单')
|
||||||
|
expect(wrapper.text()).not.toContain('提交确认')
|
||||||
|
expect(wrapper.text()).not.toContain('确认预装清单')
|
||||||
|
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue'), 'utf8')
|
||||||
|
expect(source).toContain("router.push({ name: 'loading-plans' })")
|
||||||
|
})
|
||||||
|
|
||||||
it('does not query or expose editing controls without permission', async () => {
|
it('does not query or expose editing controls without permission', async () => {
|
||||||
const wrapper = mountPanel([])
|
const wrapper = mountPanel([])
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
import { RefreshCw, Save, Check, Play, Camera, Eye, Plus, Pencil, Trash2 } from '@lucide/vue'
|
import { RefreshCw, Save, Check, Play, Camera, Eye, Plus, Pencil, Trash2 } from '@lucide/vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
@@ -15,8 +16,6 @@ import { sanitizePreloadItem, validatePreloadItem, validatePreloadItems } from '
|
|||||||
import {
|
import {
|
||||||
completeLoading,
|
completeLoading,
|
||||||
completePicking,
|
completePicking,
|
||||||
confirmingPreload,
|
|
||||||
confirmPreload,
|
|
||||||
downloadEvidenceFile,
|
downloadEvidenceFile,
|
||||||
getEvidence,
|
getEvidence,
|
||||||
getOutboundDispatch,
|
getOutboundDispatch,
|
||||||
@@ -52,6 +51,7 @@ const props = withDefaults(defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{ updated: [] }>()
|
const emit = defineEmits<{ updated: [] }>()
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
|
const router = useRouter()
|
||||||
const preload = ref<PreloadPlan | null>(null)
|
const preload = ref<PreloadPlan | null>(null)
|
||||||
const preloadItems = ref<PreloadItem[]>([])
|
const preloadItems = ref<PreloadItem[]>([])
|
||||||
const picking = ref<PickingTask | null>(null)
|
const picking = ref<PickingTask | null>(null)
|
||||||
@@ -87,8 +87,8 @@ const preloadVersion = computed(() => preload.value?.version ?? 0)
|
|||||||
const pickingExpectedVersion = computed(() => picking.value?.version ?? props.pickingVersion ?? null)
|
const pickingExpectedVersion = computed(() => picking.value?.version ?? props.pickingVersion ?? null)
|
||||||
const inboundExpectedVersion = computed(() => props.inboundVersion)
|
const inboundExpectedVersion = computed(() => props.inboundVersion)
|
||||||
const canSplit = computed(() => !props.readOnly && access.can('firstleg:inbound:split'))
|
const canSplit = computed(() => !props.readOnly && access.can('firstleg:inbound:split'))
|
||||||
const canPreload = computed(() => !props.readOnly && access.can('firstleg:inbound:preload'))
|
|
||||||
const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'))
|
const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'))
|
||||||
|
const canViewLoadingPlans = computed(() => access.can('platform:firstleg:preload:list'))
|
||||||
const canPicking = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:picking:edit'))
|
const canPicking = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:picking:edit'))
|
||||||
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
|
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
|
||||||
// 操作员、理货员由 IAM 授予 loading:edit;拣货员仅能在 PDA 现场采集,不授予该权限。
|
// 操作员、理货员由 IAM 授予 loading:edit;拣货员仅能在 PDA 现场采集,不授予该权限。
|
||||||
@@ -292,18 +292,8 @@ async function runSmartSplit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startPreloadConfirmation() {
|
function openPreloadPlans() {
|
||||||
const result = await run('preload-confirming', () => confirmingPreload(props.tenantId, props.inboundId, {
|
void router.push({ name: 'loading-plans' })
|
||||||
expectedVersion: preloadVersion.value,
|
|
||||||
}), '预装清单已进入确认中')
|
|
||||||
if (result?.preloadPlan) preload.value = result.preloadPlan
|
|
||||||
}
|
|
||||||
|
|
||||||
async function finishPreloadConfirmation() {
|
|
||||||
const result = await run('preload-confirm', () => confirmPreload(props.tenantId, props.inboundId, {
|
|
||||||
expectedVersion: preloadVersion.value,
|
|
||||||
}), '预装清单已确认,订单进入装柜中')
|
|
||||||
if (result?.preloadPlan) preload.value = result.preloadPlan
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickingRequest(): { expectedVersion: number; expectedCount: number; pickedCount: number; remark?: string } | null {
|
function pickingRequest(): { expectedVersion: number; expectedCount: number; pickedCount: number; remark?: string } | null {
|
||||||
@@ -465,8 +455,11 @@ onMounted(() => { void refresh() })
|
|||||||
</template>
|
</template>
|
||||||
<div class="workflow-actions">
|
<div class="workflow-actions">
|
||||||
<el-button :disabled="!canSplit || Boolean(busy) || Boolean(preloadLoadError)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />保存分柜清单</el-button>
|
<el-button :disabled="!canSplit || Boolean(busy) || Boolean(preloadLoadError)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />保存分柜清单</el-button>
|
||||||
<el-button :disabled="!canPreload || !preload || preload.status !== 'PENDING' || Boolean(busy)" :loading="busy === 'preload-confirming'" @click="startPreloadConfirmation"><Play :size="15" />提交确认</el-button>
|
</div>
|
||||||
<el-button type="primary" :disabled="!canPreload || !preload || preload?.status !== 'CONFIRMING' || Boolean(busy)" :loading="busy === 'preload-confirm'" @click="finishPreloadConfirmation"><Check :size="15" />确认预装清单</el-button>
|
<div class="preload-confirmation-guide">
|
||||||
|
<div><strong>计划级预装确认</strong><span>当前单票状态:{{ preloadStatusLabel(preload?.status) }}。请前往预装清单,从实际预装计划详情确认整份计划。</span></div>
|
||||||
|
<el-button v-if="canViewLoadingPlans" type="primary" plain @click="openPreloadPlans"><Eye :size="15" />前往预装清单</el-button>
|
||||||
|
<span v-else class="workflow-hint">当前账号无预装清单列表权限。</span>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -573,6 +566,11 @@ onMounted(() => { void refresh() })
|
|||||||
.preload-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
.preload-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||||
.workflow-actions { margin-top: 14px; }
|
.workflow-actions { margin-top: 14px; }
|
||||||
.workflow-remark { margin-top: 10px; }
|
.workflow-remark { margin-top: 10px; }
|
||||||
|
.preload-confirmation-guide { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
|
||||||
|
.preload-confirmation-guide > div { display: grid; min-width: 0; gap: 4px; }
|
||||||
|
.preload-confirmation-guide strong { color: var(--cc-text, #1f2937); font-size: 13px; }
|
||||||
|
.preload-confirmation-guide span { color: var(--cc-text-secondary, #64748b); font-size: 12px; line-height: 1.5; }
|
||||||
|
.preload-confirmation-guide > .el-button { flex: 0 0 auto; }
|
||||||
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||||
.evidence-item { display: grid; min-width: 0; gap: 8px; align-content: start; padding: 12px; border: 1px solid var(--cc-border); border-radius: 6px; }
|
.evidence-item { display: grid; min-width: 0; gap: 8px; align-content: start; padding: 12px; border: 1px solid var(--cc-border); border-radius: 6px; }
|
||||||
.evidence-item strong { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
.evidence-item strong { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||||
@@ -592,6 +590,7 @@ onMounted(() => { void refresh() })
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.preload-dialog-grid, .evidence-grid { grid-template-columns: 1fr; }
|
.preload-dialog-grid, .evidence-grid { grid-template-columns: 1fr; }
|
||||||
.preload-dialog-grid { gap: 0; }
|
.preload-dialog-grid { gap: 0; }
|
||||||
|
.preload-confirmation-guide { align-items: stretch; flex-direction: column; }
|
||||||
.evidence-item, .gate-grid { grid-template-columns: 1fr; }
|
.evidence-item, .gate-grid { grid-template-columns: 1fr; }
|
||||||
.workflow-actions > .el-button { flex: 1 1 150px; }
|
.workflow-actions > .el-button { flex: 1 1 150px; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user