Compare commits

...
5 Commits
Author SHA1 Message Date
liuyihe a2ef3542f4 优化 2026-10-29 15:49:53 +08:00
liuyihe 07a160e843 优化 2026-10-29 14:31:47 +08:00
liuyihe 8970ad52fa 优化 2026-10-29 13:37:22 +08:00
ouyangxq ac2ecb4663 优化 2026-10-28 17:18:01 +08:00
ouyangxq d990637b38 统一履约状态和节点日志展示并整理装柜与尾程界面 2026-10-28 16:56:32 +08:00
36 changed files with 365 additions and 113 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" />
@@ -728,7 +728,7 @@ watch(detailRouteId, (statementId) => {
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="createStatement">
<el-alert v-if="createError" type="warning" :closable="true" show-icon title="暂时无法生成对账单" @close="createError = null"><p>{{ createError.message }}</p></el-alert>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="对账单生成设置">
<section class="density-dialog-section workbench-evidence" aria-label="对账单生成设置">
<h3 class="density-dialog-section__title">生成设置 <InlineHelp title="对账单生成说明">按账期自动汇总,或指定审核通过、已发布且未出账的应收费用;生成币种为CNY。</InlineHelp></h3>
<el-form class="density-form" label-position="left" label-width="90px" :disabled="createBusy"><table class="density-table density-table--editable statement-create-table"><tbody>
<tr><td colspan="2"><el-form-item label="生成方式"><el-radio-group v-model="createForm.generationMode"><el-radio-button value="BY_CUTOFF">按账期自动汇总</el-radio-button><el-radio-button value="SELECTED" :disabled="!canSelectSources">指定费用生成</el-radio-button></el-radio-group></el-form-item></td></tr>
@@ -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>
@@ -130,3 +130,6 @@
| 本土散板 | 新增、编辑 | 既有独立路由,与卡车复用运输表单 | 同步布局修复,保留运输双列、货物四列及散板专属字段 | 同上 |
本次仅调整 Admin 运输表单展示,不改采购编辑的宽度或 Client 表单。在 `apps/admin-web` 经 `local-check.ps1` 执行:`pnpm exec vitest run src/modules/overseas-business/pages/OverseasBusinessViews.test.ts src/modules/overseas-business/contract.test.ts src/styles/adminDensity.test.ts`,3 文件 115 项通过;`pnpm typecheck`、`pnpm build` 通过。共享完整 `pnpm test`(任务名 `Admin restored dispatch full regression`,13:55:33–14:05:50)退出码 0,覆盖本次未再变化的运输表单源码;其他在途排车变更由对应任务验证,不据此推定其最新快照通过。视觉仍待人工截图确认。
2026-10-29 用户截图调整:采购详情显示客户驳回原因,收货批次提供重新上传照片;各批旧图全部替换前不可再次采购完成。本土卡车新增/编辑数量改托数,托数和预算均须大于0。自动检查与人工视觉验收分别记录;待人工1920×1080、100%截图确认。
@@ -4,9 +4,10 @@ export interface ProcurementWorkflowReceipt { id: string; quantity: number; file
export interface ProcurementOutbound { id: string; outboundNo: string; quantity: number; trackingNo: string; status: string; fileIds: string[]; createdAt: string; completedAt: string | null }
export interface ProcurementWorkflow {
orderNo: string; version: number; status: string; receivedQuantity: number; stockQuantity: number; reservedQuantity: number; availableQuantity: number
rejectionReason: string | null; photosNeedUpdate: boolean
receipts: ProcurementWorkflowReceipt[]; outbounds: ProcurementOutbound[]
}
export interface WorkflowCommand { version: number; idempotencyKey: string; quantity?: number; trackingNo?: string; fileIds?: string[] }
export interface WorkflowCommand { version: number; idempotencyKey: string; receiptId?: string; quantity?: number; trackingNo?: string; fileIds?: string[] }
const base = '/platform/v1/last-leg/overseas-procurements'
export async function getProcurementWorkflow(orderNo: string, customerId: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/workflow`, { params: { customerId } }))
@@ -21,8 +21,8 @@ const open = ref(false), saving = ref(false), uploading = ref(false), checkingFe
const action = ref(''), batchId = ref(''), warehouses = ref<OverseasPurchaseWarehouseOption[]>([]), warehouseLoading = ref(false)
const form = reactive({ quantity: 1, trackingNo: '', fileIds: [] as string[], warehouseId: '', decision: 'APPROVE' as 'APPROVE' | 'REJECT', reason: '' })
const uploadedNames = ref<string[]>([])
const title = computed(() => ({ review: '审核采购单', receipts: '登记收货', 'request-confirmation': '采购完成', outbounds: '创建出库单', 'edit-outbound': '编辑出库单', dispatch: '开始派送', cancel: '取消出库单', complete: '上传签收凭证' })[action.value] || '采购操作')
const permission = computed(() => `platform:overseas-business:purchase:${action.value === 'review' ? 'review' : ['receipts', 'request-confirmation'].includes(action.value) ? 'receive-overseas' : 'ship-customer'}`)
const title = computed(() => ({ review: '审核采购单', receipts: '登记收货', 'receipt-photos': '重新上传收货照片', 'request-confirmation': '采购完成', outbounds: '创建出库单', 'edit-outbound': '编辑出库单', dispatch: '开始派送', cancel: '取消出库单', complete: '上传签收凭证' })[action.value] || '采购操作')
const permission = computed(() => `platform:overseas-business:purchase:${action.value === 'review' ? 'review' : ['receipts', 'receipt-photos', 'request-confirmation'].includes(action.value) ? 'receive-overseas' : 'ship-customer'}`)
const can = (name: string) => access.can(`platform:overseas-business:purchase:${name}`)
const permissionHint = computed(() => props.workflow.status === 'RECEIVING' && !can('receive-overseas')
? '当前账号未开通采购收货权限,请联系管理员开通。'
@@ -33,9 +33,9 @@ let fingerprint = '', requestKey = '', openedScope = '', dialogGeneration = 0
const scope = () => `${props.customerId}:${props.workflow.orderNo}`
watch(scope, () => { dialogGeneration++; open.value = false; error.value = null })
watch(open, value => { if (!value) dialogGeneration++ })
async function begin(name: string, batch?: ProcurementOutbound) {
async function begin(name: string, batch?: ProcurementOutbound | ProcurementWorkflowReceipt) {
if (saving.value || uploading.value || checkingFees.value) return
const required = name === 'review' ? 'review' : ['receipts', 'request-confirmation'].includes(name) ? 'receive-overseas' : 'ship-customer'
const required = name === 'review' ? 'review' : ['receipts', 'receipt-photos', 'request-confirmation'].includes(name) ? 'receive-overseas' : 'ship-customer'
if (!can(required)) return
const current = ++dialogGeneration
if (name === 'outbounds') {
@@ -49,7 +49,7 @@ async function begin(name: string, batch?: ProcurementOutbound) {
}
action.value = name; batchId.value = batch?.id || ''; error.value = null; warehouseLoading.value = false
Object.assign(form, { quantity: 1, trackingNo: '', fileIds: [], warehouseId: '', decision: 'APPROVE', reason: '' }); uploadedNames.value = []
if (name === 'edit-outbound' && batch) Object.assign(form, { quantity: batch.quantity, trackingNo: batch.trackingNo })
if (name === 'edit-outbound' && batch && 'trackingNo' in batch) Object.assign(form, { quantity: batch.quantity, trackingNo: batch.trackingNo })
fingerprint = ''; requestKey = ''; openedScope = scope(); open.value = true; captureBaseline()
if (name === 'review') {
warehouseLoading.value = true
@@ -64,7 +64,7 @@ async function upload(event: Event) {
const current = dialogGeneration; const orderNo = props.workflow.orderNo; const customer = props.customerId; uploading.value = true; error.value = null
try {
for (const file of files) {
if (action.value === 'receipts' && !file.type.startsWith('image/')) throw new Error('收货凭证请选择图片')
if (['receipts', 'receipt-photos'].includes(action.value) && !file.type.startsWith('image/')) throw new Error('收货凭证请选择图片')
const result = await uploadProcurementFile(orderNo, customer, 'RECEIPT', file)
if (current !== dialogGeneration) return
form.fileIds.push(result.fileId); uploadedNames.value.push(file.name)
@@ -79,8 +79,8 @@ async function save() {
if (['receipts', 'outbounds', 'edit-outbound'].includes(action.value) && (!Number.isSafeInteger(form.quantity) || form.quantity < 1)) { ElMessage.warning('请输入有效数量'); return }
if (action.value === 'receipts' && form.quantity > props.detail.customerView.order.purchaseQuantity - props.workflow.receivedQuantity) { ElMessage.warning('收货数量不能超过剩余采购数量'); return }
if (['outbounds', 'edit-outbound'].includes(action.value) && (!form.trackingNo.trim() || form.quantity > props.workflow.availableQuantity + (action.value === 'edit-outbound' ? (props.workflow.outbounds.find(row => row.id === batchId.value)?.quantity || 0) : 0))) { ElMessage.warning('请填写物流跟踪号,数量不能超过可出库库存'); return }
if (['receipts', 'complete'].includes(action.value) && !form.fileIds.length) { ElMessage.warning(action.value === 'receipts' ? '请上传收货照片' : '请上传签收文件或图片'); return }
const data = { version: props.workflow.version, ...(action.value === 'receipts' ? { quantity: form.quantity, fileIds: [...form.fileIds] } : ['outbounds', 'edit-outbound'].includes(action.value) ? { quantity: form.quantity, trackingNo: form.trackingNo.trim() } : action.value === 'complete' ? { fileIds: [...form.fileIds] } : {}) }
if (['receipts', 'receipt-photos', 'complete'].includes(action.value) && !form.fileIds.length) { ElMessage.warning(action.value === 'receipts' ? '请上传收货照片' : '请上传签收文件或图片'); return }
const data = { version: props.workflow.version, ...(action.value === 'receipts' ? { quantity: form.quantity, fileIds: [...form.fileIds] } : ['outbounds', 'edit-outbound'].includes(action.value) ? { quantity: form.quantity, trackingNo: form.trackingNo.trim() } : action.value === 'receipt-photos' ? { receiptId: batchId.value, fileIds: [...form.fileIds] } : action.value === 'complete' ? { fileIds: [...form.fileIds] } : {}) }
const next = JSON.stringify([action.value, batchId.value, form.warehouseId, form.decision, form.reason, data])
if (fingerprint !== next || !requestKey) { fingerprint = next; requestKey = crypto.randomUUID() }
const current = dialogGeneration
@@ -88,7 +88,7 @@ async function save() {
try {
if (action.value === 'review') await submitProcurementCommand(props.workflow.orderNo, props.customerId, 'review', { version: props.workflow.version, idempotencyKey: requestKey, decision: form.decision, purchaseWarehouseId: form.decision === 'APPROVE' ? form.warehouseId : undefined, reason: form.reason.trim() || undefined })
else if (action.value === 'edit-outbound') await editProcurementOutbound(props.workflow.orderNo, props.customerId, batchId.value, { ...data, idempotencyKey: requestKey })
else await submitProcurementWorkflow(props.workflow.orderNo, props.customerId, batchId.value ? `outbounds/${encodeURIComponent(batchId.value)}/${action.value}` : action.value, { ...data, idempotencyKey: requestKey })
else await submitProcurementWorkflow(props.workflow.orderNo, props.customerId, batchId.value && action.value !== 'receipt-photos' ? `outbounds/${encodeURIComponent(batchId.value)}/${action.value}` : action.value, { ...data, idempotencyKey: requestKey })
if (current !== dialogGeneration) return
captureBaseline(); open.value = false; ElMessage.success('操作成功'); emit('changed')
} catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '操作失败') }
@@ -108,21 +108,22 @@ const fileName = (id: string) => props.detail.files.find(file => file.fileId ===
</script>
<template>
<section class="procurement-workflow">
<el-alert v-if="workflow.rejectionReason" title="客户驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" show-icon />
<div class="density-actions">
<el-button v-if="workflow.status === 'PENDING_REVIEW' && can('review')" type="primary" @click="begin('review')">审核</el-button>
<template v-if="workflow.status === 'RECEIVING'"><el-button type="primary" :disabled="!can('receive-overseas') || workflow.receivedQuantity >= detail.customerView.order.purchaseQuantity" @click="begin('receipts')">登记收货</el-button><el-button :disabled="!can('receive-overseas') || !workflow.receivedQuantity" @click="begin('request-confirmation')">采购完成</el-button></template>
<template v-if="workflow.status === 'RECEIVING'"><el-button type="primary" :disabled="!can('receive-overseas') || workflow.receivedQuantity >= detail.customerView.order.purchaseQuantity" @click="begin('receipts')">登记收货</el-button><el-button :disabled="!can('receive-overseas') || !workflow.receivedQuantity || workflow.photosNeedUpdate" @click="begin('request-confirmation')">采购完成</el-button></template>
<el-button v-if="['CONFIRMED', 'IN_TRANSIT', 'DELIVERING'].includes(workflow.status)" type="primary" :loading="checkingFees" :disabled="!can('ship-customer') || workflow.availableQuantity < 1" @click="begin('outbounds')">创建出库单</el-button>
</div>
<el-text v-if="permissionHint" type="warning" role="status">{{ permissionHint }}</el-text>
<dl class="density-fields inventory"><div><dt>累计收货</dt><dd>{{ workflow.receivedQuantity }}</dd></div><div><dt>采购库存</dt><dd>{{ workflow.stockQuantity }}</dd></div><div><dt>出库处理中</dt><dd>{{ workflow.reservedQuantity }}</dd></div><div><dt>可出库</dt><dd>{{ workflow.availableQuantity }}</dd></div></dl>
<h3>收货记录</h3><DataTable :data="workflow.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收货记录"><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
<h3>收货记录</h3><DataTable :data="workflow.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收货记录"><template #files="{ row }"><el-button v-if="workflow.status === 'RECEIVING' && workflow.rejectionReason && can('receive-overseas')" link type="primary" @click="begin('receipt-photos', row as ProcurementWorkflowReceipt)">重新上传照片</el-button><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
<h3>出库单</h3><DataTable :data="workflow.outbounds" :columns="outboundColumns" row-key="id" empty-text="暂无出库单"><template #actions="{ row }"><template v-if="row.status === 'IN_TRANSIT'"><el-button :disabled="!can('ship-customer')" link type="primary" @click="begin('edit-outbound', row as ProcurementOutbound)">编辑</el-button><el-button :disabled="!can('ship-customer')" link type="danger" @click="begin('cancel', row as ProcurementOutbound)">取消</el-button><el-button :disabled="!can('ship-customer')" link type="primary" @click="begin('dispatch', row as ProcurementOutbound)">开始派送</el-button></template><el-button v-if="row.status === 'DELIVERING'" :disabled="!can('ship-customer')" link type="primary" @click="begin('complete', row as ProcurementOutbound)">上传签收凭证</el-button></template><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="title" width="580px" confirm-text="确认" :confirm-loading="saving" :confirm-disabled="uploading || warehouseLoading" @confirm="save">
<el-form :model="form" class="density-form density-form--outlined" label-position="top" :disabled="saving">
<template v-if="action === 'review'"><el-form-item label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item><el-form-item v-if="form.decision === 'APPROVE'" label="收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehouseLoading"><el-option v-for="warehouse in warehouses" :key="warehouse.warehouseId" :value="warehouse.warehouseId" :label="warehouse.warehouseName" /></el-select></el-form-item><el-form-item v-else label="拒绝原因" required><el-input v-model="form.reason" type="textarea" :rows="2" maxlength="1000" /></el-form-item></template>
<el-form-item v-if="['receipts', 'outbounds', 'edit-outbound'].includes(action)" :label="action === 'receipts' ? '本批收货数量' : '本批出库数量'" required><el-input-number v-model="form.quantity" :min="1" :max="action === 'outbounds' ? workflow.availableQuantity : workflow.availableQuantity + (batchId ? (workflow.outbounds.find(row => row.id === batchId)?.quantity || 0) : 0)" :precision="0" /></el-form-item>
<el-form-item v-if="['outbounds', 'edit-outbound'].includes(action)" label="物流跟踪号" required><el-input v-model="form.trackingNo" maxlength="100" /></el-form-item>
<el-form-item v-if="['receipts', 'complete'].includes(action)" :label="action === 'receipts' ? '收货照片' : '签收文件或图片'" required><input type="file" multiple :accept="action === 'receipts' ? 'image/*' : 'image/*,application/pdf'" :disabled="saving || uploading" @change="upload" /><div v-for="name in uploadedNames" :key="name">{{ name }}</div></el-form-item>
<el-form-item v-if="['receipts', 'receipt-photos', 'complete'].includes(action)" :label="action !== 'complete' ? '收货照片' : '签收文件或图片'" required><input type="file" multiple :accept="action !== 'complete' ? 'image/*' : 'image/*,application/pdf'" :disabled="saving || uploading" @change="upload" /><div v-for="name in uploadedNames" :key="name">{{ name }}</div></el-form-item>
<p v-if="action === 'request-confirmation'">确认采购完成并提交客户确认?</p><p v-if="action === 'dispatch'">确认本批次开始派送?</p><p v-if="action === 'cancel'">确认取消本批出库单?取消后库存占用将释放。</p>
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
</AppDialog>
@@ -45,7 +45,7 @@ defineExpose({
<div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'">
<el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" /></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
<el-form-item v-if="kind === 'truck'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
<el-form-item v-if="kind === 'truck'" label="托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="1" :precision="0" controls-position="right" /></el-form-item>
<el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input-number v-model="model.totalWeightKg" :min="0" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" /></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
@@ -64,7 +64,7 @@ async function load() {
} else {
const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value)
fleetArrangement.value = detail.fleetArrangement
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', vehicleType: detail.vehicleType || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, remark: detail.remark || '', status: detail.status, version: detail.version })
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', vehicleType: detail.vehicleType || '', estimatedPalletCount: detail.palletCount, originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, remark: detail.remark || '', status: detail.status, version: detail.version })
}
captureBaseline()
} catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单详情加载失败`) } finally { loading.value = false }
@@ -33,12 +33,12 @@ export function createTransportDemandForm(): TransportDemandForm {
}
const requiredText = (kind: TransportDemandKind) => ({
origin: kind === 'truck' ? 'B点取货地' : '起运地',
destination: kind === 'truck' ? 'C点送达地' : '目的地',
originContactName: kind === 'truck' ? 'B点联系人' : '起运地联系人',
originContactPhone: kind === 'truck' ? 'B点联系电话' : '起运地联系电话',
destinationContactName: kind === 'truck' ? 'C点联系人' : '目的地联系人',
destinationContactPhone: kind === 'truck' ? 'C点联系电话' : '目的地联系电话',
origin: kind === 'truck' ? '取货地' : '起运地',
destination: kind === 'truck' ? '送达地' : '目的地',
originContactName: kind === 'truck' ? '取货联系人' : '起运地联系人',
originContactPhone: kind === 'truck' ? '取货联系电话' : '起运地联系电话',
destinationContactName: kind === 'truck' ? '送达联系人' : '目的地联系人',
destinationContactPhone: kind === 'truck' ? '送达联系电话' : '目的地联系电话',
} as const)
export function localMinuteInput(value: unknown) {
@@ -53,11 +53,11 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
}
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
if (field === 'estimatedPalletCount' && kind !== 'looseboard') continue
if (field === 'packageCount' && kind === 'truck') continue
rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }]
}
rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }]
const pickupLabel = kind === 'truck' ? 'B点取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? 'C点送达时间' : '送货时间'
rules.budgetAmount = [{ required: kind === 'truck', type: 'number', min: kind === 'truck' ? 0.01 : 0, message: '请输入大于 0 的预算总价', trigger: 'change' }]
const pickupLabel = kind === 'truck' ? '取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? '送达时间' : '送货时间'
rules.pickupAt = [{ required: true, message: `请选择${pickupLabel}`, trigger: 'change' }]
rules.deliveryAt = [{ required: true, message: `请选择${deliveryLabel}`, trigger: 'change' }, {
validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error(`${deliveryLabel}不能早于${pickupLabel}`) : undefined), trigger: 'change',
@@ -71,7 +71,7 @@ export function transportDemandError(kind: TransportDemandKind, form: TransportD
for (const [field, label] of Object.entries(labels)) {
if (!String(form[field as keyof typeof labels] || '').trim()) return `请输入${label}`
}
const pickupLabel = kind === 'truck' ? 'B点取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? 'C点送达时间' : '送货时间'
const pickupLabel = kind === 'truck' ? '取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? '送达时间' : '送货时间'
for (const [field, label] of [['pickupAt', pickupLabel], ['deliveryAt', deliveryLabel]] as const) {
if (!form[field]) return `请选择${label}`
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}`
@@ -81,6 +81,8 @@ export function transportDemandError(kind: TransportDemandKind, form: TransportD
if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字`
}
if (!Number.isInteger(form.packageCount) || kind === 'looseboard' && !Number.isInteger(form.estimatedPalletCount)) return '件数和预计托数须为整数'
if (kind === 'truck' && (!Number.isSafeInteger(form.estimatedPalletCount) || form.estimatedPalletCount <= 0)) return '请输入大于 0 的托数'
if (kind === 'truck' && (form.budgetAmount == null || !Number.isFinite(form.budgetAmount) || form.budgetAmount <= 0)) return '请输入大于 0 的预算总价'
if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0'
if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种'
return undefined
@@ -99,7 +101,7 @@ function commonPayload(form: TransportDemandForm) {
}
type CommonPayload = ReturnType<typeof commonPayload>
type TruckPayload = CommonPayload & { vehicleType: 'PALLET_15' | 'PALLET_30' }
type TruckPayload = Omit<CommonPayload, 'packageCount'> & { palletCount: number; vehicleType: 'PALLET_15' | 'PALLET_30' }
type LooseboardPayload = CommonPayload & { productName: string | undefined; estimatedPalletCount: number }
export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload
@@ -109,6 +111,6 @@ export function buildTransportDemand(kind: TransportDemandKind, form: TransportD
if (failure) throw new Error(failure)
const common = commonPayload(form)
return kind === 'truck'
? { ...common, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' }
? { ...(({ packageCount: _packages, ...rest }) => rest)(common), palletCount: form.estimatedPalletCount, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' }
: { ...common, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
}
+1 -1
View File
@@ -55,7 +55,7 @@ export interface LocalTransportDetail extends LocalTransportOrder {
}
export interface CreateLocalTransportOrderRequest {
vehicleType: TruckVehicleType; origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
packageCount?: number; totalWeightKg: number; totalVolumeCbm: number
palletCount: number; totalWeightKg: number; totalVolumeCbm: number
budgetAmount?: number | null; currency?: string
originContactName: string; originContactPhone: string; destinationContactName: string; destinationContactPhone: string
pickupAt: string; deliveryAt: string; remark?: string | null; idempotencyKey: string
@@ -19,6 +19,7 @@ export interface DriverQuery {
region?: DriverDraft['region']
licenseState?: DriverLicenseState
expectedEnd?: string
dispatchNo?: string
}
function numeric(value: Numeric): number {
@@ -25,11 +25,11 @@ import CapacityOfferSettingsDialog from '../components/CapacityOfferSettingsDial
import TripCostPanel from '../components/TripCostPanel.vue'
import TripHandoverPanel from '../components/TripHandoverPanel.vue'
import { getTrip, bindTripDispatch, unbindTripDispatch, cancelTrip, getTripLoading, updateTripPlan,
getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading } from '../api/trips'
getTripCompletion, completeTrip, listTripHistory, reviewTripDispatch, reviewTripLoading } from '../api/trips'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { driverQualificationLabels } from '../driverTypes'
import { tripStatusLabels, tripReviewLabels, type TripDetail, type TripLoading, type TripHistory, type TripCompletionInspection,
type TripCapacityPlanning, type TripComplete, type TripResourceRelease, type ReleasedVehicleStatus } from '../tripTypes'
type TripCapacityPlanning, type TripComplete } from '../tripTypes'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const detail = ref<TripDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null)
@@ -40,9 +40,7 @@ const appointmentsOpen = ref(false)
const appointmentsPanel = ref<InstanceType<typeof TripAppointmentPanel> | null>(null)
const inspection = ref<TripCompletionInspection | null>(null), inspectionLoading = ref(false)
const history = ref<TripHistory[]>([]), historyPage = ref(1), historyTotal = ref(0), historyLoading = ref(false)
const resourceKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), actualEndAt = ref(new Date().toISOString()), endLocation = ref('')
const vehicleStatus = ref<ReleasedVehicleStatus>('IN_SERVICE')
const completionRequest = ref<TripComplete | null>(null), releaseRequest = ref<TripResourceRelease | null>(null)
const completionRequest = ref<TripComplete | null>(null)
let generation = 0, inspectionSequence = 0, historySequence = 0
const isPickup = computed(() => detail.value?.trip.tripPurpose === 'PICKUP')
const planned = computed(() => detail.value?.trip.status === 'PLANNED')
@@ -67,9 +65,7 @@ const actionLabels: Record<string, string> = { PICKUP_PLAN: '安排上门取货'
BOOKING_CREATE: '登记外接询价', BOOKING_EDIT: '更新外接询价', BOOKING_CONFIRM: '确认外接承运', BOOKING_CANCEL: '取消外接预订', BOOKING_INCOMING: '登记外接来货', BOOKING_RECEIPT: '确认外接实收', PLAN_CAPACITY: '核对计划容量', REMARK: '更新备注', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货审核',
CONFIRM_LOAD: '确认装载', DEPART: '整车发车', HANDOVER: '在途交接', COMPLETE: '货物结案', CANCEL: '取消计划', RELEASE_VEHICLE: '结束车辆任务', RELEASE_DRIVER: '结束司机任务' }
function accept(value: TripDetail) {
if (value.trip.status === 'COMPLETED' && detail.value?.trip.status !== 'COMPLETED') actualEndAt.value = new Date().toISOString()
detail.value = value; operationError.value = null; endLocation.value ||= value.trip.stops.at(-1)?.name || ''
if (value.trip.vehicleRelease && resourceKind.value === 'VEHICLE') resourceKind.value = 'DRIVER'
detail.value = value; operationError.value = null
}
async function load() {
if (pickupLocked.value) { ElMessage.warning('请先重试并确认原取货操作'); return }
@@ -78,7 +74,7 @@ async function load() {
loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = route.query.tab === 'loading' ? 'loading' : 'overview'
editorOpen.value = false; busy.value = ''; dispatchNo.value = ''; inspection.value = null; history.value = []; historyPage.value = 1
appointmentsOpen.value = false
completionRequest.value = null; releaseRequest.value = null; endLocation.value = ''
completionRequest.value = null
try { const result = await getTrip(String(route.params.tripNo)); if (current === generation) accept(result) }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '车次详情加载失败') }
finally { if (current === generation) loading.value = false }
@@ -90,7 +86,7 @@ async function run(action: string, command: () => Promise<TripDetail>): Promise<
catch (cause) {
if (current === generation) {
operationError.value = getApiErrorDetails(cause, '操作未完成')
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { completionRequest.value = null; releaseRequest.value = null }
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { completionRequest.value = null }
}
return false
} finally { if (current === generation) busy.value = '' }
@@ -165,23 +161,13 @@ async function inspect() {
async function complete() {
const trip = detail.value?.trip, current = generation
if (!trip || !inspection.value?.canComplete) return
try { await ElMessageBox.confirm('确认本车货物均已签收或完成异常处置?货物结案后,还需分别结束车辆和司机任务。', '货物结案', { customClass: 'admin-density-confirm admin-workbench is-dialog' }) }
try { await ElMessageBox.confirm('确认本车货物均已签收或完成异常处置?结案后将自动释放车辆和司机。', '货物结案', { customClass: 'admin-density-confirm admin-workbench is-dialog' }) }
catch { return }
if (current !== generation) return
completionRequest.value ||= { recordVersion: inspection.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: '核对全部货物终态后结案' }
if (await run('complete', () => completeTrip(trip.tripNo, completionRequest.value!))) { completionRequest.value = null; inspection.value = null }
}
async function release() {
const trip = detail.value?.trip, current = generation
if (!trip) return
if (!actualEndAt.value || !endLocation.value.trim()) { ElMessage.warning('请填写实际任务结束时间和地点'); return }
try { await ElMessageBox.confirm(`确认${resourceKind.value === 'VEHICLE' ? '车辆' : '司机'}已结束本次任务,可以安排下一车次?`, '结束资源任务', { customClass: 'admin-density-confirm admin-workbench is-dialog' }) }
catch { return }
if (current !== generation) return
releaseRequest.value ||= { recordVersion: trip.recordVersion, resourceKind: resourceKind.value, actualEndAt: new Date(actualEndAt.value).toISOString(),
endLocation: endLocation.value.trim(), vehicleStatus: resourceKind.value === 'VEHICLE' ? vehicleStatus.value : null, idempotencyKey: crypto.randomUUID(), reason: '确认资源实际任务结束' }
if (await run('release', () => releaseTripResource(trip.tripNo, releaseRequest.value!))) { releaseRequest.value = null; resourceKind.value = detail.value?.trip.vehicleRelease ? 'DRIVER' : 'VEHICLE' }
}
async function loadHistory() {
if (!detail.value) return
const current = ++historySequence; historyLoading.value = true; operationError.value = null
@@ -225,12 +211,8 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
<FormSection v-if="!isPickup && detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
<div class="workbench-table"><DataTable :data="inspection?.outcomes || []" :columns="outcomeColumns" row-key="deliveryNo"><template #status="{ row }">{{ row.status === 'DELIVERED' && row.returnedPackages > 0 ? '余货已签收' : outcomeLabels[row.status] || '待处理' }}</template><template #terminalAt="{ row }">{{ time(row.terminalAt) }}</template></DataTable></div>
</FormSection>
<FormSection class="density-form-section" title="车辆和司机分别结束任务"><p>货物结案与资源释放分别记录;仍有返程或转场任务时,等实际任务结束再释放。</p><dl class="density-fields"><div><dt>车辆任务</dt><dd>{{ detail.trip.vehicleRelease ? `${time(detail.trip.vehicleRelease.actualEndAt)} · ${detail.trip.vehicleRelease.endLocation}` : '尚未结束' }}</dd></div><div><dt>司机任务</dt><dd>{{ detail.trip.driverRelease ? `${time(detail.trip.driverRelease.actualEndAt)} · ${detail.trip.driverRelease.endLocation}` : '尚未结束' }}</dd></div></dl>
<el-form v-if="detail.trip.status === 'COMPLETED' && access.can(permission.tripRelease) && (!detail.trip.vehicleRelease || !detail.trip.driverRelease)" label-position="top" class="density-form release-form" :disabled="!!busy || !!releaseRequest" @submit.prevent="release">
<el-form-item label="结束任务的资源"><el-select v-model="resourceKind"><el-option label="车辆" value="VEHICLE" :disabled="!!detail.trip.vehicleRelease" /><el-option label="司机" value="DRIVER" :disabled="!!detail.trip.driverRelease" /></el-select></el-form-item>
<el-form-item label="实际任务结束时间"><el-date-picker v-model="actualEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="实际结束地点"><el-input v-model="endLocation" placeholder="请输入实际任务结束地点" /></el-form-item>
<el-form-item v-if="resourceKind === 'VEHICLE'" label="车辆结束状态"><el-select v-model="vehicleStatus"><el-option label="在役可调度" value="IN_SERVICE" /><el-option label="维修" value="MAINTENANCE" /><el-option label="停用" value="INACTIVE" /><el-option label="已归还" value="RETURNED" /></el-select></el-form-item>
</el-form><el-button v-if="detail.trip.status === 'COMPLETED' && access.can(permission.tripRelease) && (!detail.trip.vehicleRelease || !detail.trip.driverRelease)" type="primary" :loading="busy === 'release'" :disabled="!!busy" @click="release">{{ releaseRequest ? '重试原结束请求' : '确认结束资源任务' }}</el-button>
<FormSection class="density-form-section" title="车辆和司机释放记录"><p>派送完成后自动释放车辆和司机。</p><dl class="density-fields"><div><dt>车辆任务</dt><dd>{{ detail.trip.vehicleRelease ? `${time(detail.trip.vehicleRelease.actualEndAt)} · ${detail.trip.vehicleRelease.endLocation}` : '尚未结束' }}</dd></div><div><dt>司机任务</dt><dd>{{ detail.trip.driverRelease ? `${time(detail.trip.driverRelease.actualEndAt)} · ${detail.trip.driverRelease.endLocation}` : '尚未结束' }}</dd></div></dl>
</FormSection>
</template>
<FormSection v-if="activeTab === 'history'" class="density-form-section" title="操作历史"><div class="workbench-table"><DataTable v-model:page="historyPage" :page-size="20" :data="history" :total="historyTotal" :loading="historyLoading" :columns="historyColumns" row-key="recordVersion"><template #time="{ row }">{{ time(row.occurredAt) }}</template><template #action="{ row }">{{ actionLabels[row.action] || '更新记录' }}</template></DataTable></div></FormSection>
@@ -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%待人工截图确认 |
@@ -473,3 +473,6 @@
依据用户PDA截图标注,五类统一为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字。PDA每类最多5张,可多选追加、逐张更换;Admin排车资料仍沿五行表格展示,每类逐图预览/更换,自有和外配照片弹框展示全部当前图片,油耗保持单张。五类各至少1张与装车确认共同控制自动派送。PDA相关75项、后端47项、Admin照片编辑17项及照片弹框4项通过,定向类型检查通过;扩大到运营布局时另有2项既存关闭行为断言失败,未改动该运营代码。未部署,PDA真机及Admin人工截图待确认。
- 同日双端复核:Admin装车资料补齐多选上传;PDA/Admin保存响应异常时重读服务端当前照片,核对失败保留刷新入口,停止本批后续上传。旧图替换不影响同类其他图,批次已成功部分保留。Admin 6文件32项、PDA 3文件38项及定向类型检查通过;此前2项运营弹框过期断言已按仅busy期间锁定的现行规则修正并通过。后端本轮未改,沿用47项验证结果;未部署、未做真机/人工截图验收。
2026-10-29 用户截图调整:排车列表查看路线更名查看轨迹。已完成本土卡车详情补充货物数量卡片、起讫地址与联系人、司机/车辆及证照照片、实际发车和完成时间。维护车辆/司机候选排除其他车次实际占用,当前车次已选资源允许保留。尾程查询占位提示字体12px。自动检查与人工视觉验收分别记录;待人工1920×1080、100%截图确认。
@@ -3,7 +3,7 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder
export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string }
export interface CargoDiscardSummary { skuCodes?: string[]; skuCount?: number; requestedPackages?: number; actualPackages?: number | null; id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
export interface CargoDiscardItem { quantityUnit?: string | null; actualDiscardPackages?: number | null; pdaStatus?: string | null; cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
export interface CargoDiscardItem { actualCartonMarks?: string[]; palletNo?: string | null; quantityUnit?: string | null; actualDiscardPackages?: number | null; pdaStatus?: string | null; cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
export interface CargoDiscardStatistics { orderCount: number; skuCount: number; requestedPackages: number; actualPackages: number | null; requestedPieces: number | null; actualPieces: number | null; palletCount: number | null }
export async function getCargoDiscardStatistics(query: CargoDiscardQuery) {
const response = await http.get<ApiEnvelope<CargoDiscardStatistics>, ApiEnvelope<CargoDiscardStatistics>>('/platform/v1/last-leg/cargo-discards/statistics', { params: query })
@@ -4,7 +4,7 @@ import type { Resources, ReviewAttachments, Version } from '../dispatchContracts
export type TransportType = 'PICKUP' | 'LOCAL_TRUCK'
export const transportLabels = { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }
export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; departureEvidence?: PickupEvidence[]; completionEvidence: PickupEvidence[]; dispatchRecordVersion: Version; resources: Resources | null }
export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; departureEvidence?: PickupEvidence[]; completionEvidence: PickupEvidence[]; dispatchRecordVersion: Version; resources: Resources | null; cargo: { palletCount: number; packageCount: number; totalWeightKg: number | string | null; totalVolumeCbm: number | string | null; origin: string; originAddress: string | null; originContactName: string; originContactPhone: string; destination: string; destinationAddress: string | null; destinationContactName: string; destinationContactPhone: string } | null }
export type TransportAssignment = TripAssignment & { dispatchRecordVersion?: Version; reviewPhotos?: ReviewAttachments; masterLicenseFileId?: string | null }
export interface TruckCandidate { customerId: string; orderNo: string; origin: string; originAddress: string; destination: string; destinationAddress: string; palletCount: number; packageCount: number }
export interface TruckPlan { customerId: string; orderNo: string; warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; idempotencyKey: string }
@@ -12,9 +12,9 @@ it('carries the selected master license for preview without carrying another dri
const candidate: DriverDetail = { id: '22', driverNo: 'DR22', details: { name: '司机乙', phone: '555-0022', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LICENSE22', licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2028-01-01', licenseZone: 'UTC', remark: null, profile: null }, active: true, reviewStatus: 'APPROVED', licenseFileId: '33', recordVersion: 1, updatedBy: 'operator', updatedAt: '2026-10-23T00:00:00Z', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2028-01-01' }, licenseState: 'VALID', warehouseCode: null, warehouseName: null }
vi.mocked(listDrivers).mockResolvedValue({ records: [candidate], page: 1, size: 100, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
const value = resources()
const wrapper = mount(DispatchResourceFields, { props: { ...value, disabled: false }, global: { plugins: [ElementPlus] } })
const wrapper = mount(DispatchResourceFields, { props: { ...value, disabled: false, dispatchNo: 'PC-OWN' }, global: { plugins: [ElementPlus] } })
const state = wrapper.vm as unknown as { searchDrivers(): Promise<void>; selectDriver(id: string): void }
await state.searchDrivers(); state.selectDriver('22'); await flushPromises()
await state.searchDrivers(); expect(listDrivers).toHaveBeenCalledWith(expect.objectContaining({ dispatchNo: 'PC-OWN' })); state.selectDriver('22'); await flushPromises()
expect(wrapper.emitted('update:driver')!.at(-1)![0]).toMatchObject({ driverId: '22', evidence: { fileIds: [], masterLicenseFileId: '33' } })
wrapper.unmount()
})
@@ -76,7 +76,7 @@ it('filters real unavailable statuses, sends dispatch context, and lets an inval
expect(listVehicles).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'IN_SERVICE', region: 'OVERSEAS', dispatchNo: 'PC-REAL' }))
const options = wrapper.findAllComponents({ name: 'ElOption' })
expect(options.find(item => item.props('value') === 'good')?.props('disabled')).toBe(false)
expect(options.find(item => item.props('value') === 'bad')?.props('disabled')).toBe(true)
expect(options.find(item => item.props('value') === 'bad')).toBeUndefined()
state.selectVehicle('bad'); expect(wrapper.emitted('update:vehicle')).toBeUndefined()
state.selectVehicle(null); expect(wrapper.emitted('update:vehicle')!.at(-1)![0]).toMatchObject({ vehicleId: null })
wrapper.unmount()
@@ -36,7 +36,7 @@ async function searchVehicles(keyword = '') {
async function searchDrivers(keyword = '') {
if (!access.can(resourcePermission.driverList)) { error.value = '缺少司机档案读取权限'; return }
const sequence = ++driverSequence
try { const result = await listDrivers({ page: 1, size: 100, keyword, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: driver.value.evidence?.expectedTaskEndAt || undefined }); if (sequence === driverSequence) drivers.value = result.records }
try { const result = await listDrivers({ page: 1, size: 100, keyword, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', dispatchNo: props.dispatchNo, expectedEnd: driver.value.evidence?.expectedTaskEndAt || undefined }); if (sequence === driverSequence) drivers.value = result.records }
catch (cause) { if (sequence === driverSequence) error.value = cause instanceof Error ? cause.message : '司机候选读取失败' }
}
function changeVehicle() { vehicle.value = { mode: vehicle.value.mode, vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] } }
@@ -57,7 +57,7 @@ function selectDriver(id: string) {
<template v-if="continuous">
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<table class="density-table density-table--editable dispatch-resource-table"><tbody>
<tr><td><el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" clearable filterable remote no-data-text="暂无可用车辆" :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicleOptions.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" disabled /><el-option v-for="item in vehicleOptions" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item><el-form-item v-else label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item></td></tr>
<tr><td><el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" clearable filterable remote no-data-text="暂无可用车辆" :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><template #label>{{ vehicle.plate || '请选择车辆' }}</template><el-option v-for="item in vehicleOptions" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item><el-form-item v-else label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item></td></tr>
<template v-if="vehicle.mode === 'EXTERNAL'">
<tr><td><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item></td><td><el-form-item v-if="vehicle.capacity" label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
<tr v-if="vehicle.capacity"><td><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td><td><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
@@ -74,7 +74,7 @@ function selectDriver(id: string) {
<p v-if="error" role="alert">{{ error }}</p>
<div class="resource-grid">
<el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item>
<el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" clearable filterable remote no-data-text="暂无可用车辆" :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicleOptions.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" disabled /><el-option v-for="item in vehicleOptions" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item>
<el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" clearable filterable remote no-data-text="暂无可用车辆" :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><template #label>{{ vehicle.plate || '请选择车辆' }}</template><el-option v-for="item in vehicleOptions" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item>
<template v-else>
<el-form-item label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item>
<template v-if="vehicle.capacity"><el-form-item label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" /></el-form-item><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item></template>
@@ -133,7 +133,7 @@ async function candidates() {
try {
const [v, d] = await Promise.all([listVehicles({ page: 1, size: 100, region: 'OVERSEAS', status: 'IN_SERVICE' }), listDrivers({ page: 1, size: 100, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: trip.value.expectedDriverEndAt })])
const [vo, dr] = await Promise.all([transportOccupancy(props.dispatchNo, 'VEHICLE', v.records.map(row => row.id)), transportOccupancy(props.dispatchNo, 'DRIVER', d.records.map(row => row.id))])
occupancy.value = Object.fromEntries([...vo.map(row => [`V:${row.resourceId}`, row.available]), ...dr.map(row => [`D:${row.resourceId}`, row.available])]); vehicles.value = v.records; drivers.value = d.records
occupancy.value = Object.fromEntries([...vo.map(row => [`V:${row.resourceId}`, row.available]), ...dr.map(row => [`D:${row.resourceId}`, row.available])]); vehicles.value = v.records.filter(row => occupancy.value[`V:${row.id}`] === true); drivers.value = d.records.filter(row => occupancy.value[`D:${row.id}`] === true && row.qualification.canDispatch)
} catch (cause) { error.value = getApiErrorDetails(cause, '资源候选加载失败') }
finally { candidateBusy.value = false }
}
@@ -206,6 +206,10 @@ onBeforeUnmount(clearTruckPreviewUrls)
<el-button link :disabled="locked" aria-label="刷新运输任务" @click="load"><RefreshCw :size="14" />刷新</el-button>
</div>
</header>
<dl v-if="detail.cargo" class="transport-summary-cards" aria-label="货物数量统计">
<div><dt>托数</dt><dd>{{ detail.cargo.palletCount }} 托</dd></div><div><dt>箱数</dt><dd>{{ detail.cargo.packageCount }} 箱</dd></div>
<div><dt>总重量</dt><dd>{{ detail.cargo.totalWeightKg ?? '—' }} kg</dd></div><div><dt>总体积</dt><dd>{{ detail.cargo.totalVolumeCbm ?? '—' }} m³</dd></div>
</dl>
<section class="workbench-panel is-compact transport-resources" aria-label="车辆信息">
<div class="transport-section-heading"><h2 class="workbench-section-title">车辆信息</h2><el-button v-if="canChangeResources && editable && !resourceFormOpen" link type="primary" :disabled="locked" @click="editingResources = true">维护车辆信息</el-button></div>
<dl class="transport-facts">
@@ -213,12 +217,23 @@ onBeforeUnmount(clearTruckPreviewUrls)
<div><dt>计划发车</dt><dd>{{ time(trip.plannedDepartureAt) }}</dd></div>
<div><dt>预计结束</dt><dd>{{ time(trip.expectedDriverEndAt) }}</dd></div>
<template v-if="!resourceFormOpen"><div><dt>车辆</dt><dd>{{ trip.vehiclePlate || '待分配' }}</dd></div><div><dt>司机</dt><dd>{{ trip.driverName || '待分配' }}<span v-if="trip.driverPhone" class="transport-phone">{{ trip.driverPhone }}</span></dd></div></template>
<div><dt>实际发车</dt><dd>{{ time(trip.departedAt) }}</dd></div>
<div><dt>实际完成</dt><dd>{{ time(trip.completedAt) }}</dd></div>
<div><dt>最近更新</dt><dd>{{ time(trip.updatedAt) }}</dd></div>
<div><dt>车辆容量</dt><dd>{{ trip.maxPalletSpaces ?? '—' }} 托 / {{ trip.maxLoadKg ?? '—' }} kg / {{ trip.maxVolumeCbm ?? '—' }} m³</dd></div>
<div><dt>驾驶证号</dt><dd>{{ detail.resources?.driver?.evidence?.licenseNo || '—' }}</dd></div>
<div><dt>驾驶证有效期</dt><dd>{{ detail.resources?.driver?.evidence?.licenseExpiresOn || '—' }}</dd></div>
<div v-if="detail.orderNo"><dt>订单号</dt><dd>{{ detail.orderNo }}</dd></div>
<div v-if="detail.taskType === 'LOCAL_TRUCK'"><dt>路线</dt><dd>{{ trip.stops.map(stop => stop.name).join(' → ') }}</dd></div>
</dl>
<div v-if="readonly || ['COMPLETED', 'CANCELLED'].includes(trip.status)" class="transport-review-photos" aria-label="车辆司机资料">
<DispatchEvidenceFiles photo-card :model-value="detail.resources?.vehicle?.evidenceFileIds || []" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆照片" readonly />
<DispatchEvidenceFiles photo-card :model-value="detail.resources?.driver?.evidence?.photoFileIds || []" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" readonly />
<DispatchEvidenceFiles photo-card :model-value="detail.resources?.driver?.evidence?.fileIds || []" :dispatch-no="dispatchNo" kind="DRIVER" label="驾驶证照片" :master-license="detail.resources?.driver?.driverId && detail.resources.driver.evidence?.masterLicenseFileId ? { driverId: detail.resources.driver.driverId, fileId: detail.resources.driver.evidence.masterLicenseFileId } : null" readonly />
</div>
<el-form v-if="resourceFormOpen" label-position="left" :disabled="locked" class="density-form transport-resource-form">
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="(value: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="(value: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
<section v-if="['PICKUP', 'LOCAL_TRUCK'].includes(detail.taskType)" class="transport-review-photos" aria-label="调度审核照片">
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :master-resource="vehicleId ? { kind: 'VEHICLE', id: vehicleId } : null" @available="masterPhotoAvailable.vehicle = $event" :disabled="locked" @busy="photoBusy.vehicle = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driverId ? { kind: 'DRIVER', id: driverId } : null" @available="masterPhotoAvailable.driver = $event" :disabled="locked" @busy="photoBusy.driver = $event" />
@@ -228,6 +243,12 @@ onBeforeUnmount(clearTruckPreviewUrls)
<div class="density-actions transport-resource-save"><el-button type="primary" :loading="busy || candidateBusy" :disabled="locked || !vehicleId || !driverId || !photosComplete" @click="assign">保存车辆司机</el-button><el-button v-if="trip.vehicleId && trip.driverId" :disabled="locked" @click="editingResources = false; vehicleId = trip.vehicleId; driverId = trip.driverId; restoreVehiclePhotos()">收起</el-button></div>
</el-form>
</section>
<section v-if="detail.cargo" class="workbench-panel is-compact" aria-label="货物与取送信息">
<h2 class="workbench-section-title">货物与取送信息</h2><dl class="transport-facts">
<div><dt>取货地</dt><dd>{{ detail.cargo.origin }}</dd></div><div><dt>取货地址</dt><dd>{{ detail.cargo.originAddress || '—' }}</dd></div><div><dt>取货联系人</dt><dd>{{ detail.cargo.originContactName }} {{ detail.cargo.originContactPhone }}</dd></div>
<div><dt>送达地</dt><dd>{{ detail.cargo.destination }}</dd></div><div><dt>送达地址</dt><dd>{{ detail.cargo.destinationAddress || '—' }}</dd></div><div><dt>送达联系人</dt><dd>{{ detail.cargo.destinationContactName }} {{ detail.cargo.destinationContactPhone }}</dd></div>
</dl>
</section>
<section v-if="detail.taskType === 'PICKUP'" class="transport-pickups" aria-label="取货明细">
<div class="transport-section-heading"><h2 class="workbench-section-title"><Package :size="16" aria-hidden="true" />取货明细</h2><span class="workbench-meta">{{ detail.pickups.length }} 单 · 预报 {{ detail.pickups.reduce((total, row) => total + row.packageCount, 0) }} 箱</span></div>
<div v-if="!detail.pickups.length" class="workbench-meta">暂无取货明细</div>
@@ -276,6 +297,11 @@ onBeforeUnmount(clearTruckPreviewUrls)
</template>
<style scoped>
.transport-summary-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 12px 0; }
.transport-summary-cards > div { padding: 16px; border: 1px solid var(--cc-border, #e3eaf3); border-radius: 8px; background: #f8fbff; }
.transport-summary-cards dt { color: var(--cc-text-muted, #788699); font-size: 12px; }
.transport-summary-cards dd { margin: 8px 0 0; color: #243951; font-size: 18px; font-weight: 600; }
.transport-execution { display: grid; gap: var(--admin-space-5); min-width: 0; padding: var(--admin-space-5); }
.transport-toolbar, .transport-section-heading, .transport-identity { display: flex; align-items: center; gap: var(--admin-space-4); }
.transport-toolbar, .transport-section-heading { justify-content: space-between; flex-wrap: wrap; }
@@ -102,6 +102,8 @@ onMounted(() => { void loadCustomers(); void load() })
<el-table-column label="申请数量" width="120"><template #default="{ row }">{{ quantity(row.discardPackages, row.quantityUnit || '单位未记录') }}</template></el-table-column>
<el-table-column label="实际废弃" width="150"><template #default="{ row }">{{ quantity(row.actualDiscardPackages, row.quantityUnit || '单位未记录') }}</template></el-table-column>
<el-table-column label="所选箱唛" min-width="180"><template #default="{ row }"><LineClampText :content="row.cartonMarks?.join('、') || ''" /></template></el-table-column>
<el-table-column prop="palletNo" label="废弃托盘" min-width="160" />
<el-table-column label="实际废弃箱唛" min-width="180"><template #default="{ row }"><LineClampText :content="row.actualCartonMarks?.join('、') || ''" /></template></el-table-column>
</el-table>
<h3>废弃现场照片</h3><div v-if="detail.photos.length" class="discard-photo-grid"><div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item">
<el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported />
@@ -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()
@@ -87,7 +87,7 @@ const columns = computed<DataTableColumn[]>(() => [
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'review', label: section.value === 'DISPATCH_REVIEW' ? '调度审核' : '理货员审核', minWidth: 210, slotName: 'review', afterColumn: 'dispatchNo' }] : []),
{ key: 'counts', label: '托数/箱数/已拣', minWidth: 160, slotName: 'counts' }, { key: 'planned', label: '计划发车', minWidth: 165, slotName: 'planned' },
{ key: 'departure', label: '实际发车', minWidth: 165, slotName: 'departure' },
...(stage.value === 'DELIVERING' ? [{ key: 'routeMap', label: '路线', width: 100, slotName: 'routeMap' }] : []),
...(stage.value === 'DELIVERING' ? [{ key: 'routeMap', label: '轨迹', width: 100, slotName: 'routeMap' }] : []),
])
const preferenceKey = computed(() => `overseas-dispatches-${stage.value}-${section.value}`)
let sequence = 0
@@ -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[] {
@@ -246,7 +246,7 @@ onBeforeUnmount(() => { sequence++ })
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
<template #returnReason="{ row }">{{ dispatchRejectionReason(row.reviews) || '—' }}</template>
<template #createdAt="{ row }">{{ time(row.createdAt) }}</template>
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></template>
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看轨迹</el-button></template>
</DataTable></div>
</ListPageLayout></AdminPage>
<TransportTaskCreateDialog v-model="transportOpen" :type="transportType" @created="load" />
@@ -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 class="last-mile-query" 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 ? '收起筛选' : '高级筛选' }}
@@ -1371,6 +1369,7 @@ onMounted(() => { void load() })
</template>
<style scoped>
.last-mile-query :deep(input::placeholder), .last-mile-query :deep(.el-select__placeholder), .last-mile-query :deep(.el-range-input::placeholder) { font-size: 12px; }
.last-mile-dialog-facts, .last-mile-command-table, .manual-nodes-editor { table-layout: fixed; }
.last-mile-dialog-facts th { width: var(--admin-label-width); }
.last-mile-command-table td:not([colspan]) { width: 50%; }
@@ -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 '状态更新'
+6 -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']) {
@@ -1716,6 +1718,9 @@ describe('approved Admin density ownership', () => {
expect(arap).toContain('label-position="left" label-width="110px" :disabled="reviewSaving"')
const statements = read('modules/finance/pages/FinanceCustomerStatementsView.vue')
const popups = statements.slice(statements.indexOf('<AppDialog'))
const createPopup = popups.split('</AppDialog>')[0]!
expect(createPopup).toContain('class="density-dialog-section workbench-evidence" aria-label="对账单生成设置"')
expect(createPopup).not.toMatch(/data-tone="(?:summary|source|work)"/)
expect(popups.match(/fullscreenable/g)).toHaveLength(2)
expect(popups).toContain('label-position="left" label-width="90px" :disabled="createBusy"')
expect(popups).toContain('label-width="110px" :disabled="adjustBusy"')