统一履约状态和节点日志展示并整理装柜与尾程界面
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('—')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 '状态更新'
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
Reference in New Issue
Block a user