统一履约状态和节点日志展示并整理装柜与尾程界面

This commit is contained in:
2026-10-28 16:56:32 +08:00
parent a52a6620fc
commit d990637b38
20 changed files with 270 additions and 43 deletions
@@ -135,7 +135,6 @@ onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsReque
<tr v-if="createdForm.startOffset || createdForm.endOffset"><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
<tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr>
</tbody></table>
<p class="density-dialog-note">预约已保存,请继续在原业务安排车辆。</p>
</section>
<div v-if="canWrite" class="density-actions density-actions--footer"><el-button @click="again">继续新增预约</el-button></div>
</template>
@@ -10,7 +10,7 @@ vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: v
const overview: ContainerOverview = {
container: { id: '7', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', pickupCode: '00123456', pickupCodeUpdatedBy: '13', pickupCodeUpdatedByName: '操作员', pickupCodeUpdatedAt: '2026-08-13T10:30:00', version: 2 },
loadingPlanId: '9',
pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] },
pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_1', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_1', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] },
documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }],
exception: { active: true, reason: '码头查验', markedBy: '13', markedByName: '操作员', markedAt: '2026-08-13T11:00:00' },
exceptionHistory: [{ id: '14', action: 'MARK', reason: '码头查验', operatorId: '13', operatorName: '操作员', occurredAt: '2026-08-13T11:00:00' }],
@@ -26,14 +26,19 @@ describe('ContainerOverviewPanel', () => {
})
it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const types = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
const pda = { ...overview.pda!, photoCounts: types.map(evidenceType => ({ evidenceType, count: 1 })), photos: types.map((evidenceType, index) => ({
...overview.pda!.photos[0]!, evidenceId: String(20 + index), fileId: String(23 + index), evidenceType,
downloadPath: `/api/platform/v1/containers/7/pda/photos/${23 + index}/download`,
})) }
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('DO 资料')
expect(wrapper.text()).toContain('do.pdf')
expect(wrapper.text()).toContain('PDA 作业进度')
expect(wrapper.text()).toContain('待人工复核')
expect(wrapper.text()).toContain('空柜照片')
expect(wrapper.text()).not.toContain('PHOTO_STEP_EMPTY_CONTAINER')
expect(wrapper.findAll('.photo-caption strong').map(caption => caption.text())).toEqual(['空仓照片', '半仓照片', '满仓照片', '锁仓照片', '带锁码照'])
expect(wrapper.text()).not.toContain('PHOTO_STEP_')
expect(wrapper.text()).toContain('一项待复核')
expect(wrapper.text()).toContain('码头查验')
expect(wrapper.text()).not.toContain('更新预计到港时间')
@@ -77,8 +77,8 @@ const workflowStatusLabels: Record<string, string> = {
LOADING: '作业中', AWAITING_REVIEW: '待人工复核', COMPLETED: '已完成',
}
const evidenceTypeLabels: Record<string, string> = {
PHOTO_STEP_EMPTY_CONTAINER: '空柜照片', PHOTO_STEP_HALF_LOADED: '半柜照片', PHOTO_STEP_FULL_CONTAINER: '满柜照片',
EMPTY_CONTAINER: '空柜照片', HALF_LOADED: '半柜照片', FULL_CONTAINER: '满柜照片', SEAL: '封条照片',
PHOTO_STEP_1: '空仓照片', PHOTO_STEP_2: '半仓照片', PHOTO_STEP_3: '满仓照片',
PHOTO_STEP_4: '锁仓照片', SEAL: '带锁码照',
}
function workflowStatusLabel(workflowStatus: string | null | undefined, taskStatus: string) { return workflowStatusLabels[workflowStatus || taskStatus] || '作业状态待同步' }
function evidenceTypeLabel(value: string) { return evidenceTypeLabels[value] || '装柜照片' }
@@ -245,6 +245,7 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getContainer).mockResolvedValueOnce(base as never).mockResolvedValue({ ...base, workbenchStage: 'EMPTY_RETURNED', statusSummary: { terminalDrayageStatus: 'EMPTY_RETURNED' }, allowedActions: [], terminalDrayage: { ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } } as never)
const wrapper = await mountDetail()
expect(wrapper.text()).toContain('待还柜')
expect(wrapper.findAll('dt').find(item => item.text() === '码头拖车状态')!.element.nextElementSibling!.textContent).toBe('待还柜')
expect(wrapper.text()).not.toContain('装柜完成')
await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click')
await flushPromises()
@@ -257,6 +258,7 @@ describe('ContainerDetailView empty return', () => {
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', expect.objectContaining({ expectedVersion: 3, actualAt: '2026-09-09T12:00:00', remark: undefined }), expect.any(String))
expect(getContainer).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('已还柜')
expect(wrapper.findAll('dt').find(item => item.text() === '码头拖车状态')!.element.nextElementSibling!.textContent).toBe('已还柜')
expect(wrapper.find('.detail-section__heading').findAll('button').some((item) => item.text() === '确认还柜')).toBe(false)
})
@@ -319,6 +321,27 @@ describe('domestic container loading list and booking link', () => {
return wrapper
}
it('keeps customs and PDA information only in the overview when switching to the loading list and back', async () => {
canAccess.mockImplementation((code) => ['platform:firstleg:container:list', 'platform:firstleg:loading-list:list', 'platform:firstleg:customs:list'].includes(code))
vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus: 'COMPLETED' } as never)
const wrapper = await mountDomestic()
const pdaPanels = () => wrapper.findAllComponents({ name: 'ContainerOverviewPanel' }).filter(panel => panel.props('sections')?.includes('pda'))
expect(pdaPanels()).toHaveLength(1)
expect(wrapper.findAllComponents({ name: 'ContainerCustomsPanel' })).toHaveLength(1)
await wrapper.findAll('[role="tab"]').find(tab => accessibleText(tab) === '装柜清单')!.trigger('click')
await flushPromises()
const list = wrapper.get('[aria-labelledby="container-loading-list-heading"]')
expect(list.findComponent({ name: 'LoadingListDetailDrawer' }).exists()).toBe(true)
expect(list.findComponent({ name: 'ContainerOverviewPanel' }).exists()).toBe(false)
expect(list.findComponent({ name: 'ContainerCustomsPanel' }).exists()).toBe(false)
expect(pdaPanels()).toHaveLength(1)
expect(wrapper.findAllComponents({ name: 'ContainerCustomsPanel' })).toHaveLength(1)
await wrapper.findAll('[role="tab"]').find(tab => accessibleText(tab) === '送柜报关')!.trigger('click')
await flushPromises()
expect(pdaPanels()[0]!.isVisible()).toBe(true)
expect(wrapper.findComponent({ name: 'ContainerCustomsPanel' }).isVisible()).toBe(true)
})
it.each(['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED'])('opens the loading-list tab using the correct API for %s', async (planStatus) => {
vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus } as never)
const wrapper = await mountDomestic()
@@ -302,16 +302,17 @@ const oceanBookingLinkOpen = ref(false)
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
const documentTitle = computed(() => 'DO 资料')
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
const overseasStageLabels: Record<string, string> = {
WAIT_LOADING: '待装柜', LOADING: '装柜中', WAIT_CUSTOMS: '待出口报关', WAIT_OUTBOUND: '待国内出仓',
DEPARTED_WAREHOUSE: '已从国内仓出仓', IN_TRANSIT: '海运在途', ARRIVED_DESTINATION_PORT: '已到目的港',
WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_READY: '可安排提柜', PICKUP_SCHEDULED: '已预约提柜',
PICKED_UP: '已提柜', UNLOADED: '待还柜', EMPTY_RETURNED: '已还柜',
}
const overseasStageText = computed(() => {
const status = workbenchDetail.value?.statusSummary?.terminalDrayageStatus
|| workbenchDetail.value?.terminalDrayage?.status
|| workbenchDetail.value?.workbenchStage
return ({
WAIT_LOADING: '待装柜', LOADING: '装柜中', WAIT_CUSTOMS: '待出口报关', WAIT_OUTBOUND: '待国内出仓',
DEPARTED_WAREHOUSE: '已从国内仓出仓', IN_TRANSIT: '海运在途', ARRIVED_DESTINATION_PORT: '已到目的港',
WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_READY: '可安排提柜', PICKUP_SCHEDULED: '已预约提柜',
PICKED_UP: '已提柜', UNLOADED: '待还柜', EMPTY_RETURNED: '已还柜',
} as Record<string, string>)[status || ''] || '海外履约中'
return overseasStageLabels[status || ''] || '海外履约中'
})
const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)
const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
@@ -664,13 +665,11 @@ onMounted(async () => {
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading">
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ overseasStageLabels[workbenchDetail?.statusSummary?.terminalDrayageStatus || ''] || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
</section>
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading">
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2></div></header>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
<LoadingListDetailDrawer dense :mode="loadingListMode" back-label="返回货柜概览" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
</section>
@@ -702,7 +701,7 @@ onMounted(async () => {
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
<template v-if="terminal"><p class="density-dialog-note">时间沿用原业务当地时间。</p>
<template v-if="terminal">
<section class="density-dialog-section"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><div class="workbench-evidence"><table class="density-table container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></div></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
@@ -40,6 +40,9 @@ describe('PalletLabelPrintDialog', () => {
await flushPromises()
const dialog = wrapper.getComponent(AppDialog)
expect(dialog.props('title')).toBe(dense ? '散货托盘码 · HLCU1723867' : '散货托盘码')
expect(dialog.text()).not.toContain('编号规则')
expect(dialog.text()).not.toContain('一件代发托盘不在此清单')
expect(dialog.find('section[data-tone]').exists()).toBe(false)
wrapper.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'WAREHOUSE')
await flushPromises()
wrapper.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1)
@@ -58,7 +61,7 @@ describe('PalletLabelPrintDialog', () => {
click.mockRestore()
})
it('shows the server-generated internal code rule and both warehouse codes', async () => {
it('keeps server-generated label numbers and warehouse codes without the extra numbering explanation', async () => {
const wrapper = mount(PalletLabelPrintDialog, {
props: { planId: '10', warehouseId: '20' },
global: {
@@ -67,7 +70,6 @@ describe('PalletLabelPrintDialog', () => {
LoadErrorState: true,
Printer: true,
'el-button': { template: '<button @click="$emit(\'click\')"><slot /></button>' },
'el-alert': { props: ['title'], template: '<p>{{ title }}</p>' },
'el-radio-group': { template: '<div><slot /></div>' },
'el-radio': { template: '<span><slot /></span>' },
'el-select': { template: '<div><slot /></div>' },
@@ -83,7 +85,7 @@ describe('PalletLabelPrintDialog', () => {
await nextTick()
expect(preparePalletLabels).toHaveBeenCalledWith('10', '20')
expect(wrapper.text()).toContain('HLCU1723867-FBA地址内部编号-三位序号')
expect(wrapper.text()).not.toContain('编号规则')
expect(wrapper.text()).toContain('ACY2')
expect(wrapper.text()).toContain('101')
expect(wrapper.text()).toContain('HLCU1723867-101-001')
@@ -56,12 +56,11 @@ async function print() {
<AppDialog class="admin-workbench is-dialog" :class="{ 'admin-density-dialog': dense }" v-model="open" :title="dialogTitle" :width="dense ? 'min(920px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :fullscreenable="dense" confirm-text="生成 PDF" :loading="loading" :confirm-loading="printing" :confirm-disabled="disabledConfirm" @confirm="print">
<LoadErrorState v-if="error" compact title="托盘码操作失败" :message="error.message" :show-retry="false" />
<div class="admin-workbench" v-if="manifest" :class="{ 'density-dialog-sections': dense }">
<section :class="{ 'density-dialog-section': dense }" :data-tone="dense ? 'work' : undefined">
<el-alert type="info" :closable="false" :title="`编号规则:${manifest.containerNo}-FBA地址内部编号-三位序号;一件代发托盘不在此清单。`" />
<section :class="{ 'density-dialog-section': dense }">
<el-radio-group v-model="scope" class="print-scope" :class="{ 'print-scope--dense': dense }"><el-radio value="ALL">全部打印({{ manifest.totalCount }} 张)</el-radio><el-radio value="WAREHOUSE">按 FBA 仓补打</el-radio></el-radio-group>
<div v-if="scope === 'WAREHOUSE'" class="print-options" :class="{ 'density-form': dense }"><el-select v-model="warehouseCode" placeholder="选择 FBA 仓" @change="onWarehouseChange"><el-option v-for="group in manifest.groups" :key="group.destinationWarehouseCode" :label="`${group.destinationWarehouseCode} · ${group.totalCount} 张`" :value="group.destinationWarehouseCode" /></el-select><el-input-number v-model="count" placeholder="打印张数" :min="1" :max="selectedGroup?.totalCount || 1" /><span>张(从该仓第 1 张起)</span></div>
</section>
<section :class="{ 'density-dialog-section': dense }" :data-tone="dense ? 'source' : undefined">
<section :class="{ 'density-dialog-section': dense }">
<el-table :data="manifest.groups" border :max-height="dense ? undefined : 300"><el-table-column label="FBA 仓编码" prop="destinationWarehouseCode" width="140" /><el-table-column label="内部编号" prop="destinationWarehouseInternalCode" width="120" /><el-table-column label="FBA 仓名称" prop="destinationWarehouseName" min-width="160" :show-overflow-tooltip="dense"><template #default="{ row }">{{ row.destinationWarehouseName || '—' }}</template></el-table-column><el-table-column label="托盘数" prop="totalCount" width="90" align="right" /><el-table-column label="编号范围" min-width="230"><template #default="{ row }">{{ row.labels[0]?.palletCode || '—' }}<template v-if="row.labels.length > 1"> ~ {{ row.labels[row.labels.length - 1]?.palletCode }}</template></template></el-table-column></el-table>
</section>
</div>
@@ -446,7 +446,7 @@
| 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 |
|---|---|---|---|---|
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;2026-10-22 用户截图确认移除资料核对、油耗和装车照片改表格;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;嵌入编辑器隐藏资料核对;三个页签仅显示各自资料,装车资料只保留整车装车照片,不再附带BOL及车辆/司机附件,保存资料按钮仅在签收页签显示,切换保留签收草稿;油耗两行、装车照片五行表格,操作列在首列,保留预览、上传/替换和原保存行为;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 4文件27项定向测试及受影响组件与测试源码类型检查通过;页签隔离追加修改后5项定向测试及组件/测试类型检查通过;1920×1080、100%待人工截图确认 |
| 排车列表:装车中、装车后、派送中 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;2026-10-22 用户截图确认移除资料核对、油耗和装车照片改表格;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;嵌入编辑器隐藏资料核对;三个页签仅显示各自资料,装车资料只保留整车装车照片,不再附带BOL及车辆/司机附件,保存资料按钮仅在签收页签显示,切换保留签收草稿;油耗两行、装车照片五行表格,操作列在首列,保留预览、上传/替换和原保存行为;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 4文件27项定向测试及受影响组件与测试源码类型检查通过;页签隔离追加修改后5项定向测试及组件/测试类型检查通过;1920×1080、100%待人工截图确认 |
| 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 |
@@ -448,6 +448,22 @@ it('clears an earlier input rejection after the corrected workflow succeeds', as
wrapper.unmount()
})
it.each([
['DELIVERING', 3, true],
['DELIVERED', 4, false],
] as const)('only opens list document management before delivery completes in %s', async (stage, tabIndex, available) => {
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'DELIVERY', stage, actions: {} })]))
const wrapper = mount(DispatchesView, { global: { ...options.global, stubs: { ...options.global.stubs, DispatchDocumentsDialog: true } } }); await flushPromises()
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[tabIndex]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage }))
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect((row.props('actions') as Array<{ key: string }>).some(item => item.key === 'documents')).toBe(available)
expect(row.findAll('button').some(button => button.text() === '管理资料')).toBe(available)
row.vm.$emit('action', 'documents'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchDocumentsDialog' }).props()).toMatchObject({ modelValue: available, dispatchNo: available ? 'PC/1' : '' })
wrapper.unmount()
})
it('opens the exception form from the dispatching row and hides it without permission', async () => {
const gate = { allowed: true, blockCode: null, blockReason: null }
const current = reviews()
@@ -122,7 +122,7 @@ function switchStage(value: DispatchStage) {
function switchSection(value: BeforeLoadingSection) { section.value = value; page.value = 1; void load() }
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL']
function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(row.stage) }
function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(row.stage) }
// 仅当前调度审核通过的仓库派送排车可下载,退回或审核失效后重新收起入口。
function canDownloadBol(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.reviews?.dispatchReview?.status === 'APPROVED' }
function rowActions(row: DispatchListRow): RowActionItem[] {
@@ -10,6 +10,8 @@ import { ApiResponseError } from '@/shared/api/http'
import DataTable from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import LayoutPreferenceList from '@/shared/components/LayoutPreferenceList.vue'
import { preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { listContainerArrivalTimes } from '@/modules/container-loading'
import {
@@ -322,10 +324,46 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(toolbar.props('availableFields')?.some((field) => field.key === 'orderType')).toBe(true)
})
it('uses the saved first three filters when collapsed and keeps reordered fields after remounting', async () => {
localStorage.setItem(preferenceStorageKey('query', 'anonymous', 'overseas-last-mile-orders-query'), JSON.stringify({
version: 1, order: ['orderNo', 'containerNo', 'inboundCode'], hidden: [],
}))
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
const placeholders = () => toolbar.findAll('.query-toolbar__field').map(field => field.get('input').attributes('placeholder'))
expect(placeholders()).toEqual(['请输入尾程单号', '请输入集装箱号', '请输入入仓码'])
const initialCalls = vi.mocked(listLastMileOrders).mock.calls.length
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), containerNo: 'CONT-15' })
await wrapper.vm.$nextTick()
await button(wrapper, '高级筛选')!.trigger('click')
expect(placeholders()).toHaveLength(16)
expect(placeholders().slice(0, 3)).toEqual(['请输入尾程单号', '请输入集装箱号', '请输入入仓码'])
await toolbar.get('button[aria-label="调整筛选布局"]').trigger('click')
await flushPromises()
toolbar.findComponent(LayoutPreferenceList).vm.$emit('move', 'externalBookingNo', 0)
await wrapper.vm.$nextTick()
await button(wrapper, '收起筛选')!.trigger('click')
expect(placeholders()).toEqual(['请输入外接预订号', '请输入尾程单号', '请输入集装箱号'])
expect(toolbar.props('modelValue').containerNo).toBe('CONT-15')
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(initialCalls)
wrapper.unmount()
const restored = await mountView()
const restoredToolbar = restored.findComponent(QueryToolbar)
const restoredPlaceholders = () => restoredToolbar.findAll('.query-toolbar__field').map(field => field.get('input').attributes('placeholder'))
expect(restoredPlaceholders()).toEqual(['请输入外接预订号', '请输入尾程单号', '请输入集装箱号'])
await restoredToolbar.get('button[aria-label="调整筛选布局"]').trigger('click')
await flushPromises()
restoredToolbar.findComponent(LayoutPreferenceList).vm.$emit('reset')
await restored.vm.$nextTick()
expect(restoredPlaceholders()).toEqual(['请输入尾程单号', '请输入入仓码', '请输入仓库名称或编码'])
})
it('preserves advanced conditions when collapsed and clears them on reset', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
expect(toolbar.props('fields').map((field) => field.key)).toEqual(['orderNo', 'inboundCode', 'warehouse'])
expect(toolbar.findAll('.query-toolbar__field input').map(input => input.attributes('placeholder')))
.toEqual(['请输入尾程单号', '请输入入仓码', '请输入仓库名称或编码'])
const initialCalls = vi.mocked(listLastMileOrders).mock.calls.length
await button(wrapper, '高级筛选')!.trigger('click')
expect(toolbar.props('fields')).toHaveLength(16)
@@ -195,6 +195,9 @@ const statusTabs = ['全部', '开船前', '开船后'] as const
const showAdvancedFilters = ref(false)
const filters = ref<QueryModel>({ businessType: '', businessOrderNo: '', externalBookingNo: '', orderNo: '', relabelNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const allFields: QueryField[] = [
{ key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
{ key: 'warehouse', type: 'input', label: '海外仓', width: 210, placeholder: '请输入仓库名称或编码' },
{ key: 'businessType', type: 'select', label: '业务来源', width: 180, placeholder: '请选择业务来源', options: [
{ label: '本土散板(全部来源)', value: 'LOCAL_LOOSEBOARD' }, { label: '本土卡车', value: 'US_TRUCK' },
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '头程订单', value: 'FIRST_LEG' },
@@ -202,9 +205,7 @@ const allFields: QueryField[] = [
] },
{ key: 'businessOrderNo', type: 'input', label: '关联商业单号', width: 210, placeholder: '请输入商业单号' },
{ key: 'externalBookingNo', type: 'input', label: '外接预订号', width: 210, placeholder: '请输入外接预订号' },
{ key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
{ key: 'relabelNo', type: 'input', label: '换标单号', width: 190, placeholder: '请输入换标单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入LX代码' },
{ key: 'orderType', type: 'select', label: '履约类型', width: 170, placeholder: '请选择履约类型', options: [
{ label: '头程订单', value: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' },
@@ -212,7 +213,6 @@ const allFields: QueryField[] = [
{ label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' },
{ label: '货物废弃订单', value: 'CARGO_DISCARD' },
] },
{ key: 'warehouse', type: 'input', label: '海外仓', width: 210, placeholder: '请输入仓库名称或编码' },
{ key: 'sourceOrderNo', type: 'input', label: '来源订单号', width: 190, placeholder: '请输入来源订单号' },
{ key: 'containerNo', type: 'input', label: '集装箱号', width: 180, placeholder: '请输入集装箱号' },
{ key: 'transferWarehouseCode', type: 'input', label: '转仓代码', width: 170, placeholder: '请输入转仓代码' },
@@ -234,9 +234,7 @@ const allFields: QueryField[] = [
const availableFields = computed(() => allFields.filter((field) => statusTab.value === '全部' || field.key !== 'orderType'))
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
: showAdvancedFilters.value
? availableFields.value
: allFields.filter((field) => ['orderNo', 'inboundCode', 'warehouse'].includes(field.key)))
: availableFields.value)
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
const selectedReceivingWarehouse = computed(() => receivingWarehouseOptions.value.find((row) => (
row.warehouseId === receivingWarehouseForm.warehouseId
@@ -1164,7 +1162,7 @@ onMounted(() => { void load() })
</template>
<template #filters>
<section class="last-mile-toolbar integrated-query-group" aria-label="尾程订单状态与筛选">
<QueryToolbar id="last-mile-orders-advanced-filters" v-model="filters" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset">
<QueryToolbar id="last-mile-orders-advanced-filters" v-model="filters" :fields="fields" :available-fields="availableFields" :max-visible="statusTab !== '柜子' && !showAdvancedFilters ? 3 : undefined" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset">
<template v-if="statusTab !== '柜子'" #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="last-mile-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
@@ -10,6 +10,7 @@ import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
import { h } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { CarrierScheduleNodeFact } from '../types'
enableAutoUnmount(afterEach)
@@ -251,6 +252,39 @@ describe('CarrierScheduleQueryPanel loading states', () => {
expect(wrapper.text()).toContain('船运跟踪 · HEDE · hede-hold')
})
it.each([false, true])('shows recorded usernames for human nodes and retains automatic sources (dense=%s)', async dense => {
const manual: CarrierScheduleNodeFact = {
id: 'manual-1', providerCode: 'MANUAL', providerEventId: 'manual-event-1', providerNodeCode: 'IN_TRANSIT',
standardNode: 'IN_TRANSIT', nodeSequence: 50, observedAt: '2026-10-28T10:00:00',
actualAt: '2026-10-28T10:00:00', sourceReference: 'MANUAL:1000001',
rawPayloadHash: 'a'.repeat(64), operatorName: 'platform_admin',
}
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(getContainerShippingNodes).mockResolvedValue({ containerBookingId: '3', nodes: [
manual,
{ ...manual, id: 'outbound-1', providerCode: 'FIRSTLEG', providerEventId: 'OUTBOUND_CONFIRM', providerNodeCode: 'OUTBOUND_CONFIRM', standardNode: 'DEPARTED_WAREHOUSE', nodeSequence: 10, actualAt: '2026-10-28T09:00:00', sourceReference: 'OUTBOUND_CONFIRM', operatorName: 'outbound_operator' },
{ ...manual, id: 'automatic-1', providerCode: 'COSCO', providerEventId: 'carrier-event-1', providerNodeCode: 'ARRIVED', standardNode: 'ARRIVED_DESTINATION_PORT', nodeSequence: 60, actualAt: '2026-10-28T11:00:00', sourceReference: 'carrier-event-1', operatorName: null },
{ ...manual, id: 'missing-1', providerEventId: 'manual-event-2', providerNodeCode: 'PICKED_UP', standardNode: 'PICKED_UP', nodeSequence: 90, actualAt: '2026-10-28T12:00:00', sourceReference: 'MANUAL:2000002', operatorName: null },
] })
const wrapper = mount(CarrierScheduleQueryPanel, {
props: { dense, hideTechnicalDetails: true, oceanBookingId: '2', containerBookingId: '3', warehouseTimeline: [{ eventType: 'PDA_COMPLETE_LOADING', operatorName: 'pda_operator', occurredAt: '2026-10-28T08:00:00' }] },
global: { plugins: [ElementPlus, pinia] },
})
await flushPromises()
const rows = wrapper.findAll(dense ? '.fulfillment-table tbody tr' : '.fulfillment-timeline li')
const source = (title: string) => {
const row = rows.find(item => item.get('strong').text() === title)!
return dense ? row.findAll('td').at(-1)!.text() : row.get('small').text()
}
expect(source('海运途中')).toBe('platform_admin')
expect(source('已离开国内仓库')).toBe('outbound_operator')
expect(source('完成装柜')).toBe('pda_operator')
expect(source('船舶已到目的港')).toBe('COSCO · carrier-event-1')
expect(source('已提柜')).toBe('操作人未记录')
expect(rows.map(row => row.text()).join('|')).not.toContain('MANUAL:')
expect(rows.map(row => row.text()).join('|')).not.toContain('OUTBOUND_CONFIRM')
})
it('merges warehouse, actual, estimated, manual and exception facts with stable ordering and deduplication', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
const actual = { id: '61', providerCode: 'COSCO', providerEventId: 'actual-61', providerNodeCode: 'LOAD', standardNode: 'LOADED_ON_VESSEL' as const, nodeSequence: 30, observedAt: '2026-08-21T10:05:00', estimatedAt: null, actualAt: '2026-08-21T10:00:00', locationCode: 'CNNGB', locationName: '宁波港', sourceReference: 'actual-source', rawPayloadHash: 'e'.repeat(64), createdAt: '2026-08-21T10:06:00' }
@@ -95,6 +95,12 @@ function nodeText(value?: CarrierScheduleStandardNode | null) { return ({ DEPART
function nodeLocation(item?: CarrierScheduleNodeFact | null) { return item ? [item.locationCode, item.locationName].filter(Boolean).join(' · ') || '—' : '—' }
function exceptionLocation(item: CarrierScheduleExceptionFact) { return [item.locationCode, item.locationName].filter(Boolean).join(' · ') || '—' }
function nodeTimeText(item: CarrierScheduleNodeFact) { return item.actualAt ? `实际 ${formatTime(item.actualAt)}` : item.estimatedAt ? `预计 ${formatTime(item.estimatedAt)}` : `观测 ${formatTime(item.observedAt)}` }
function nodeSourceText(item: CarrierScheduleNodeFact) {
const operatorName = nonBlank(item.operatorName)
if (operatorName) return operatorName
if (/MANUAL|PLATFORM/i.test(item.providerCode) || item.providerCode === 'FIRSTLEG') return '操作人未记录'
return [item.providerCode, item.sourceReference].filter(Boolean).join(' · ') || '—'
}
function exceptionText(value: CarrierScheduleExceptionFact['exceptionCode']) { return ({ CUSTOMS_HOLD: '海关扣留', TERMINAL_HOLD: '码头扣留', DOCUMENT_HOLD: '单证扣留', UNKNOWN_HOLD: '其他扣留' } as Record<string, string>)[value] || value }
function providerErrorText(value?: string | null) {
const message = nonBlank(value)
@@ -189,7 +195,7 @@ const fulfillmentEvents = computed<FulfillmentEvent[]>(() => {
const timing = actual ? `实际 ${formatTime(item.actualAt)}` : item.estimatedAt ? `预计 ${formatTime(item.estimatedAt)}` : `观测 ${formatTime(item.observedAt)}`
const expected = actual && item.estimatedAt ? ` · 原预计 ${formatTime(item.estimatedAt)}` : ''
const type = manual ? '人工补录' : item.standardNode === 'DEPARTED_WAREHOUSE' && actual ? '确认离仓' : '船运节点'
values.push({ key: `node-${key}`, time, type, title: nodeText(item.standardNode), summary: `${timing}${expected} · ${nodeLocation(item)}`, source: [item.providerCode, item.sourceReference].filter(Boolean).join(' · ') || '—', actual })
values.push({ key: `node-${key}`, time, type, title: nodeText(item.standardNode), summary: `${timing}${expected} · ${nodeLocation(item)}`, source: nodeSourceText(item), actual })
}
for (const item of exceptions.value) values.push({ key: `exception-${item.id}`, time: item.observedAt, type: '异常', title: `${exceptionText(item.exceptionCode)} · ${item.active ? '发生' : '已解除'}`, summary: `${exceptionLocation(item)} · ${item.providerStatus}`, source: `船运跟踪 · ${[item.providerCode, item.sourceReference].filter(Boolean).join(' · ')}`, actual: true })
return values.sort((left, right) => {
@@ -285,7 +291,6 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
<AppDialog class="container-node-dialog admin-workbench is-dialog" :class="{ 'admin-density-dialog': dense }" v-model="manualDialogOpen" :title="manualDialogTitle" :width="dense ? 'min(780px, calc(100vw - 28px))' : 'min(620px, calc(100vw - 28px))'" :fullscreenable="dense" append-to-body confirm-text="确认补录" :confirm-loading="manualSaving" @confirm="submitManualNode">
<el-alert v-if="manualError" type="error" :closable="false" show-icon title="补录未成功,已保留当前填写内容"><template #default><ApiErrorMeta :details="manualError" /></template></el-alert>
<el-alert v-if="manualValidationError" type="warning" :closable="false" show-icon :title="manualValidationError" />
<el-alert type="info" :closable="false" title="观测时间按提交时刻记录;预计事实不推进实际进度,既有事实不可编辑或删除。" />
<section v-if="dense" class="density-dialog-section">
<el-form class="density-form">
<div class="workbench-evidence"><table class="density-table manual-editor-table" aria-label="人工补录船节点"><tbody>
+1
View File
@@ -83,6 +83,7 @@ export interface CarrierScheduleNodeFact {
locationName?: string | null
sourceReference?: string | null
rawPayloadHash: string
operatorName?: string | null
createdAt?: string | null
}
@@ -510,16 +510,45 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
expect(wrapper.text()).not.toContain('新增关联')
})
it('renders actual loading and overseas business identifiers inside order detail', async () => {
it.each([
{
loadingStatus: 'LOADING', pdaTaskStatus: 'IN_PROGRESS', overseasReceivingStatus: 'RECEIVING',
expectedStatuses: { 装柜状态: '配载中', PDA状态: '作业中', 海外收货状态: '收货中' },
},
{
loadingStatus: 'LOADING', pdaTaskStatus: 'UNASSIGNED', overseasReceivingStatus: 'RECEIVING',
expectedStatuses: { 装柜状态: '配载中', PDA状态: '未分配', 海外收货状态: '收货中' },
},
{
loadingStatus: 'LOADING', pdaTaskStatus: 'PROCESSING', overseasReceivingStatus: 'RECEIVING',
expectedStatuses: { 装柜状态: '配载中', PDA状态: '完成处理中', 海外收货状态: '收货中' },
},
{
loadingStatus: 'LOADING', pdaTaskStatus: 'COMPLETION_FAILED', overseasReceivingStatus: 'RECEIVING',
expectedStatuses: { 装柜状态: '配载中', PDA状态: '完成失败', 海外收货状态: '收货中' },
},
{
loadingStatus: 'COMPLETED', pdaTaskStatus: 'COMPLETED', overseasReceivingStatus: 'PENDING_ASSIGNMENT',
expectedStatuses: { 装柜状态: '装柜完成', PDA状态: '已完成', 海外收货状态: '待分配' },
},
{
loadingStatus: 'NEW_LOADING_STATE', pdaTaskStatus: 'NEW_PDA_STATE', overseasReceivingStatus: 'NEW_RECEIVING_STATE',
expectedStatuses: { 装柜状态: '未知状态', PDA状态: '未知状态', 海外收货状态: '未知状态' },
},
{
loadingStatus: null, pdaTaskStatus: null, overseasReceivingStatus: null,
expectedStatuses: { 装柜状态: '—', PDA状态: '—', 海外收货状态: '—' },
},
])('renders actual loading and overseas business identifiers with Chinese statuses ($loadingStatus/$pdaTaskStatus)', async ({ loadingStatus, pdaTaskStatus, overseasReceivingStatus, expectedStatuses }) => {
vi.mocked(getLoadingAssociationSummary).mockResolvedValue({
inboundId: '7001', inboundCode: 'NYEXX2608310001', loadingPlanId: '9001', loadingPlanNo: 'LP-260831-001',
containerBookingId: '9002', cabinetNo: 'CAB-260831-001', containerNo: 'MSCU1234567', loadingStatus: 'LOADING',
taskId: '9003', pdaTaskStatus: 'IN_PROGRESS', coLoadGroupId: null, coLoadGroupNo: null, items: [],
containerBookingId: '9002', cabinetNo: 'CAB-260831-001', containerNo: 'MSCU1234567', loadingStatus,
taskId: '9003', pdaTaskStatus, coLoadGroupId: null, coLoadGroupNo: null, items: [],
})
vi.mocked(getOverseasInboundCodeRelationship).mockResolvedValue({
inboundCode: 'NYEXX2608310001', found: true, customer: null,
overseasWarehouse: { warehouseId: '9004', warehouseCode: 'LAX', warehouseName: '洛杉矶海外仓' },
overseasReceivingStatus: 'RECEIVING', taillegOrders: [{ orderNo: 'LL-260831-001' }],
overseasReceivingStatus, taillegOrders: [{ orderNo: 'LL-260831-001' }],
inboundPlans: [{ planNo: 'IP-260831-001' }], palletCodes: ['PLT-LAX-001'],
inventory: [{ skuCode: 'SKU-001', packageCount: 2 }],
})
@@ -532,6 +561,9 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
expect(wrapper.text()).toContain('LL-260831-001')
expect(wrapper.text()).toContain('IP-260831-001')
expect(wrapper.text()).toContain('PLT-LAX-001')
const facts = Object.fromEntries(wrapper.findAll('.subsequent-fulfillment .fulfillment-facts > div')
.map(item => [item.get('dt').text(), item.get('dd').text()]))
expect(facts).toMatchObject(expectedStatuses)
expect(wrapper.text()).not.toContain('9001')
expect(wrapper.text()).not.toContain('9004')
})
@@ -65,6 +65,8 @@ import { SmartLoadingDialog } from '@/modules/container-loading'
import {
unifiedOrderHistoryActionLabel,
unifiedOrderLoadingStatusLabel,
unifiedOrderOverseasReceivingStatusLabel,
unifiedOrderPdaTaskStatusLabel,
unifiedOrderReviewStatusLabel,
unifiedOrderStatusLabel,
} from '@/modules/unified-orders/status'
@@ -2674,12 +2676,12 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
<dl v-if="hasLoadingAssociation" class="fulfillment-facts">
<div><dt>装柜计划</dt><dd>{{ loadingAssociation?.loadingPlanNo || '—' }}</dd></div>
<div><dt>柜号</dt><dd>{{ loadingAssociation?.containerNo || loadingAssociation?.cabinetNo || '—' }}</dd></div>
<div><dt>装柜状态</dt><dd>{{ loadingAssociation?.loadingStatus || '—' }}</dd></div>
<div><dt>PDA状态</dt><dd>{{ loadingAssociation?.pdaTaskStatus || '—' }}</dd></div>
<div><dt>装柜状态</dt><dd>{{ unifiedOrderLoadingStatusLabel(loadingAssociation?.loadingStatus) }}</dd></div>
<div><dt>PDA状态</dt><dd>{{ unifiedOrderPdaTaskStatusLabel(loadingAssociation?.pdaTaskStatus) }}</dd></div>
</dl>
<dl v-if="hasOverseasRelationship" class="fulfillment-facts">
<div><dt>海外仓</dt><dd>{{ overseasRelationship?.overseasWarehouse?.warehouseName || '尚未分配海外仓' }}</dd></div>
<div><dt>海外收货状态</dt><dd>{{ overseasRelationship?.overseasReceivingStatus || '—' }}</dd></div>
<div><dt>海外收货状态</dt><dd>{{ unifiedOrderOverseasReceivingStatusLabel(overseasRelationship?.overseasReceivingStatus) }}</dd></div>
<div class="wide"><dt>尾程订单</dt><dd>{{ overseasRelationship?.taillegOrders.map((item) => item.orderNo).join('、') || '—' }}</dd></div>
<div class="wide"><dt>入库计划</dt><dd>{{ overseasRelationship?.inboundPlans.map((item) => item.planNo).join('、') || '—' }}</dd></div>
<div class="wide"><dt>托盘码</dt><dd>{{ overseasRelationship?.palletCodes.join('、') || '—' }}</dd></div>
+28
View File
@@ -4,6 +4,8 @@ import {
unifiedOrderFulfillmentStatusLabel,
unifiedOrderHistoryActionLabel,
unifiedOrderLoadingStatusLabel,
unifiedOrderOverseasReceivingStatusLabel,
unifiedOrderPdaTaskStatusLabel,
unifiedOrderReviewStatusLabel,
unifiedOrderStatusLabel,
} from './status'
@@ -14,13 +16,31 @@ describe('unified order status labels', () => {
expect(unifiedOrderStatusLabel('WAIT_SPLIT')).toBe('待分柜')
expect(unifiedOrderReviewStatusLabel('APPROVED')).toBe('审核通过')
expect(unifiedOrderLoadingStatusLabel('LOADED')).toBe('已配载')
expect(unifiedOrderLoadingStatusLabel('COMPLETED')).toBe('装柜完成')
expect(unifiedOrderPdaTaskStatusLabel('COMPLETED')).toBe('已完成')
expect(unifiedOrderPdaTaskStatusLabel('IN_PROGRESS')).toBe('作业中')
expect(unifiedOrderOverseasReceivingStatusLabel('PENDING_ASSIGNMENT')).toBe('待分配')
expect(unifiedOrderOverseasReceivingStatusLabel('RECEIVING')).toBe('收货中')
expect(unifiedOrderHistoryActionLabel('DECLARATION_UPDATE')).toBe('客户申报资料变更')
})
it.each([
['UNASSIGNED', '未分配'],
['IN_PROGRESS', '作业中'],
['AWAITING_REVIEW', '待人工复核'],
['PROCESSING', '完成处理中'],
['COMPLETION_FAILED', '完成失败'],
['COMPLETED', '已完成'],
])('translates loading-association API task status %s', (status, label) => {
expect(unifiedOrderPdaTaskStatusLabel(status)).toBe(label)
})
it('never exposes unknown backend enums', () => {
expect(unifiedOrderStatusLabel('NEW_ORDER_STATE')).toBe('未知状态')
expect(unifiedOrderReviewStatusLabel('NEW_REVIEW_STATE')).toBe('未知状态')
expect(unifiedOrderLoadingStatusLabel('NEW_LOADING_STATE')).toBe('未知状态')
expect(unifiedOrderPdaTaskStatusLabel('NEW_PDA_STATE')).toBe('未知状态')
expect(unifiedOrderOverseasReceivingStatusLabel('NEW_RECEIVING_STATE')).toBe('未知状态')
expect(unifiedOrderHistoryActionLabel('NEW_ACTION')).toBe('状态更新')
expect(unifiedOrderFulfillmentRoleLabel('NEW_ROLE')).toBe('未知类型')
expect(unifiedOrderFulfillmentStatusLabel('NEW_FRAGMENT_STATE', 'REMAINDER')).toBe('未知状态')
@@ -30,4 +50,12 @@ describe('unified order status labels', () => {
expect(unifiedOrderFulfillmentStatusLabel(null, 'ACTIVE_REMAINDER')).toBe('当前余量')
expect(unifiedOrderFulfillmentStatusLabel(undefined, 'COMPLETED_FRAGMENT')).toBe('已完成分片')
})
it('normalizes downstream status codes and keeps missing values empty', () => {
expect(unifiedOrderPdaTaskStatusLabel(' completed ')).toBe('已完成')
expect(unifiedOrderOverseasReceivingStatusLabel(' pending_assignment ')).toBe('待分配')
expect(unifiedOrderLoadingStatusLabel(null)).toBe('—')
expect(unifiedOrderPdaTaskStatusLabel(undefined)).toBe('—')
expect(unifiedOrderOverseasReceivingStatusLabel(' ')).toBe('—')
})
})
+44
View File
@@ -34,6 +34,42 @@ const loadingStatusLabels: Record<string, string> = {
COMPLETED: '装柜完成',
}
const pdaTaskStatusLabels: Record<string, string> = {
UNASSIGNED: '未分配',
PENDING: '待领取',
CLAIMED: '已领取',
LOADING: '作业中',
IN_PROGRESS: '作业中',
AWAITING_REVIEW: '待人工复核',
PROCESSING: '完成处理中',
COMPLETION_FAILED: '完成失败',
COMPLETED: '已完成',
}
const overseasReceivingStatusLabels: Record<string, string> = {
PENDING_REVIEW: '待审核',
PENDING_ASSIGNMENT: '待分配',
ASSIGNED: '待入库',
PDA_PENDING: '待入库',
PENDING_RECEIPT: '待签收入库',
SIGNED: '已签收',
RECEIVING: '收货中',
PICKING: '拣货中',
PENDING_RECONCILIATION: '待差异核对',
PARTIAL_INBOUND: '部分入库',
PARTIALLY_RECEIVED: '部分入库',
WAIT_PDA_RECEIVING: '入库测量中',
INBOUND_MEASURING: '入库测量中',
WAIT_CUSTOMER_CONFIRMATION: '待客户确认实测',
RECEIVED: '已收货',
INBOUND_COMPLETED: '全部入库',
COMPLETED: '已完成',
IN_TRANSIT: '运输中',
DESTINATION_ASSIGNED: '已分配目的仓',
INBOUND_PLANNED: '已生成入库计划',
CANCELLED: '已取消',
}
const historyActionLabels: Record<string, string> = {
CREATE: '创建订单',
CREATED: '创建订单',
@@ -81,6 +117,14 @@ export function unifiedOrderLoadingStatusLabel(value: string | null | undefined,
return statusLabel(value, loadingStatusLabels, emptyLabel)
}
export function unifiedOrderPdaTaskStatusLabel(value: string | null | undefined, emptyLabel = '—') {
return statusLabel(value, pdaTaskStatusLabels, emptyLabel)
}
export function unifiedOrderOverseasReceivingStatusLabel(value: string | null | undefined, emptyLabel = '—') {
return statusLabel(value, overseasReceivingStatusLabels, emptyLabel)
}
export function unifiedOrderHistoryActionLabel(value: string | null | undefined) {
const action = normalized(value)
if (!action) return '状态更新'
+3 -1
View File
@@ -1357,7 +1357,9 @@ describe('approved Admin density ownership', () => {
expect(read('shared/components/DataTable.vue')).toContain('margin-left: var(--cc-list-action-sibling-margin, 8px)')
expect(shared).toContain('.density-actions { --cc-list-action-sibling-margin: 0px;')
expect(read('modules/loading-lists/components/LoadingListsView.vue')).toContain('class="business-page loading-lists-page admin-density"')
expect(read('modules/loading-lists/components/PalletLabelPrintDialog.vue')).toContain("'admin-density-dialog': dense")
const palletPrintDialog = read('modules/loading-lists/components/PalletLabelPrintDialog.vue')
expect(palletPrintDialog).toContain("'admin-density-dialog': dense")
expect(palletPrintDialog).not.toContain(':data-tone=')
expect(read('modules/container-loading/components/ContainerSimulationCreate.vue')).toContain("'admin-density-dialog': dense")
expect(read('modules/container-loading/components/ContainerSimulationCreate.vue')).toContain('width="min(800px, calc(100vw - 32px))"')
for (const file of ['loading-lists/components/LoadingListDetailDrawer.vue', 'loading-lists/components/PalletLabelPrintDialog.vue', 'container-loading/components/ContainerSimulationCreate.vue']) {