Merge origin/release/test and preserve procurement direction with request unlock
This commit is contained in:
@@ -33,7 +33,7 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
expect(pane.isVisible()).toBe(true)
|
||||
expect(pane.text()).toContain('LAX → ONT8')
|
||||
expect(pane.text()).toContain('派送异常记录读取失败')
|
||||
expect(pane.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
|
||||
expect(pane.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间'])
|
||||
expect(pane.find('[aria-label="派送异常记录"]').exists()).toBe(false)
|
||||
await pane.get('button').trigger('click'); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
@@ -101,7 +101,7 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
await wrapper.get(`#tab-${name}`).trigger('click'); await flushPromises()
|
||||
expect(wrapper.get(`#pane-${name}`).isVisible()).toBe(true)
|
||||
}
|
||||
expect(wrapper.get('#pane-dispatch').findAll('dd').map(field => field.text())).toEqual(['—', '—', '—', '—'])
|
||||
expect(wrapper.get('#pane-dispatch').findAll('dd').map(field => field.text())).toEqual(['—', '—', '—'])
|
||||
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -121,7 +121,7 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('uses only four accessible colored tabs and switches without another network read', async () => {
|
||||
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
|
||||
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', shipmentNumbers: [], batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledWith('PC/1')
|
||||
@@ -133,8 +133,8 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`)
|
||||
expect(dispatchInfo.isVisible()).toBe(true)
|
||||
expect(dispatchInfo.get('dl').isVisible()).toBe(true)
|
||||
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED')
|
||||
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
|
||||
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.find('[aria-label="正式FBA预约"]').exists()).toBe(true); expect(dispatchInfo.text()).toContain('FBA-AP-1')
|
||||
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间'])
|
||||
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
|
||||
})
|
||||
it('uses exact server order attribution including unknown measurements and real container numbers', async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { stageLabel } from '../dispatchContracts'
|
||||
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
|
||||
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
||||
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
|
||||
import DispatchAppointments from '../components/DispatchAppointments.vue'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
import { formatOverseasDateTime as time } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
@@ -35,7 +36,6 @@ const vehicleFields = computed(() => [
|
||||
const dispatchFields = computed(() => [
|
||||
['路线', text(detail.value?.dispatchInfo.route)], ['发车时间', time(detail.value?.dispatchInfo.departedAt)],
|
||||
['预计到达时间', time(detail.value?.dispatchInfo.estimatedArrivalAt)],
|
||||
['FBA预约', detail.value?.dispatchInfo.fbaAppointments.map(item => [item.destinationWarehouseCode, item.appointmentNo, item.status].filter(Boolean).join(' · ')).join(';') || '—'],
|
||||
])
|
||||
const documentFields = computed(() => [
|
||||
['排车单号', text(detail.value?.documents.dispatchNo)], ['创建时间', time(detail.value?.documents.createdAt)],
|
||||
@@ -78,7 +78,7 @@ onBeforeUnmount(() => { generation++ })
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
|
||||
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields vehicle-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
|
||||
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl>
|
||||
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchAppointments :appointments="detail?.dispatchInfo.fbaAppointments || []" />
|
||||
<LoadErrorState v-if="detail && !Array.isArray(detail.dispatchInfo.exceptions)" compact title="派送异常记录读取失败" message="排车详情返回的异常记录不完整,请重新加载。" :loading="loading" @retry="load" />
|
||||
<section v-else-if="detail && detail.dispatchInfo.exceptions.length" class="detail-section" aria-label="派送异常记录">
|
||||
<h3>派送异常记录</h3>
|
||||
|
||||
@@ -9,7 +9,8 @@ import RowActions from '@/shared/components/RowActions.vue'
|
||||
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
|
||||
import { cancelTransport, getTransport } from '../api/transportTasks'
|
||||
import { detail, listRow, page, reviews } from '../dispatchTestFixtures'
|
||||
import type { DispatchPage } from '../dispatchContracts'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import type { BeforeLoadingSection, DispatchPage, DispatchStage } from '../dispatchContracts'
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
const push = vi.hoisted(() => vi.fn())
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
@@ -442,3 +443,34 @@ it.each([['DISPATCH_REVIEW',1,'DISPATCH_REJECTED'],['LOADING_REVIEW',2,'LOADING_
|
||||
expect(wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[0]!.attributes('aria-selected')).toBe('true')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('splits driver and phone without fallback and shows authoritative appointment and shipment fields', async () => {
|
||||
const row = listRow(); row.resources.driver!.name = null; row.resources.driver!.phone = '555-REAL'
|
||||
row.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', batchNo: 'BATCH-1', appointmentNo: 'CONF-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null, shipmentNumbers: ['SHIP-1', 'SHIP-2'] }]
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([row]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const columns = wrapper.getComponent(DataTable).props('columns')
|
||||
expect(columns.map(column => column.label)).toContain('司机'); expect(columns.map(column => column.label)).toContain('联系电话')
|
||||
expect(columns[0]!.key).toBe('actions')
|
||||
expect(wrapper.text()).toContain('555-REAL'); expect(wrapper.text()).toContain('CONF-1'); expect(wrapper.text()).toContain('SHIP-1、SHIP-2')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['BEFORE_LOADING', 'VEHICLE_INFO'], ['BEFORE_LOADING', 'DISPATCH_REVIEW'], ['BEFORE_LOADING', 'LOADING_REVIEW'],
|
||||
['LOADING', 'VEHICLE_INFO'], ['AFTER_LOADING', 'VEHICLE_INFO'], ['DELIVERING', 'VEHICLE_INFO'], ['DELIVERED', 'VEHICLE_INFO'],
|
||||
] as const)('keeps actual creation time visible in %s / %s for every transport type', async (stage, section) => {
|
||||
const createdAt = '2026-10-22T01:23:45Z'
|
||||
const rows = (['DELIVERY', 'PICKUP', 'LOCAL_TRUCK'] as const).map(taskType => listRow({ dispatchNo: 'PC-' + taskType, taskType, stage, createdAt, plannedDepartureAt: '2026-11-01T11:00:00Z' }))
|
||||
vi.mocked(listDispatches).mockResolvedValue(page(rows))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const state = wrapper.vm as unknown as { stage: DispatchStage; section: BeforeLoadingSection }
|
||||
state.stage = stage; state.section = section; await flushPromises()
|
||||
const table = wrapper.getComponent(DataTable), columns = table.props('columns')
|
||||
expect(columns[0]!.key).toBe('actions')
|
||||
expect(columns.find(column => column.key === 'createdAt')).toMatchObject({ label: '创建时间', slotName: 'createdAt' })
|
||||
const cells = wrapper.findAll('tbody td').filter(cell => cell.text() === formatOverseasDateTime(createdAt))
|
||||
expect(cells).toHaveLength(3)
|
||||
expect(getDispatch).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -70,7 +70,12 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : 220, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
|
||||
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
|
||||
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
|
||||
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机', minWidth: 100, slotName: 'driver' },
|
||||
{ key: 'driverPhone', label: '联系电话', minWidth: 140, slotName: 'driverPhone' },
|
||||
{ key: 'appointmentNo', label: '预约号', minWidth: 160, slotName: 'appointmentNo' },
|
||||
{ key: 'appointmentAt', label: '确认送达时间', minWidth: 170, slotName: 'appointmentAt' },
|
||||
{ key: 'shipmentNumbers', label: '货件号', minWidth: 180, slotName: 'shipmentNumbers' },
|
||||
{ key: 'vehicleStatus', label: '车辆状态', minWidth: 110, slotName: 'vehicleStatus' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
...(showSections.value && section.value === 'VEHICLE_INFO' ? [{ key: 'returnReason', label: '退回原因', minWidth: 240, slotName: 'returnReason' }] : []),
|
||||
@@ -215,10 +220,15 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="row.taskType === 'PICKUP' ? 3 : 2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
|
||||
<template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template>
|
||||
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</template>
|
||||
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}</template>
|
||||
<template #driverPhone="{ row }">{{ row.resources.driver?.phone || '—' }}</template>
|
||||
<template #appointmentNo="{ row }"><div v-for="(item, index) in row.fbaAppointments" :key="index">{{ item.appointmentNo || '—' }}</div><span v-if="!row.fbaAppointments?.length">—</span></template>
|
||||
<template #appointmentAt="{ row }"><div v-for="(item, index) in row.fbaAppointments" :key="index">{{ time(item.confirmedAt) }}</div><span v-if="!row.fbaAppointments?.length">—</span></template>
|
||||
<template #shipmentNumbers="{ row }"><div v-for="(item, index) in row.fbaAppointments" :key="index">{{ item.shipmentNumbers?.join('、') || '—' }}</div><span v-if="!row.fbaAppointments?.length">—</span></template>
|
||||
<template #vehicleStatus="{ row }"><el-tag v-if="row.status === 'ACTIVE' && vehicleStatusForStage(row.stage)" :type="vehicleStatusForStage(row.stage)?.type">{{ vehicleStatusForStage(row.stage)?.label }}</el-tag><span v-else>—</span></template>
|
||||
<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>
|
||||
</DataTable></div>
|
||||
</ListPageLayout></AdminPage>
|
||||
|
||||
@@ -266,7 +266,7 @@ onMounted(() => { void load() })
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="planEditOpen" :title="`编辑入库单 · ${planEditTarget?.orderNo || '—'}`" fullscreenable confirm-text="保存" :loading="planEditLoading" :confirm-disabled="planEditLoading || !planEditForm.warehouseId" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="入库计划来源"><h3 class="density-dialog-section__title">计划信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ planEditTarget?.orderNo || '—' }}</td></tr></tbody></table></section>
|
||||
<LoadErrorState v-if="mutationError" compact title="入库单保存失败" :message="mutationError.message" :loading="planEditSaving" retry-text="重试保存" @retry="submitPlanEdit"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
|
||||
<section class="density-dialog-section" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
|
||||
</div></AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,7 @@ import DataTable from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { listContainerArrivalTimes } from '@/modules/container-loading'
|
||||
import {
|
||||
changeContainerReceivingWarehouse,
|
||||
createManualInboundPlan,
|
||||
@@ -35,6 +36,10 @@ vi.mock('@/shared/downloadCsv', () => ({ saveCsv: vi.fn() }))
|
||||
const routerPush = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('@/modules/container-loading', async () => ({
|
||||
...await import('../../container-loading/warehouseArrival'),
|
||||
listContainerArrivalTimes: vi.fn(),
|
||||
}))
|
||||
vi.mock('../api/lastMileOrders', () => ({
|
||||
getReturnOrderStatistics: vi.fn(),
|
||||
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
|
||||
@@ -54,6 +59,10 @@ function memoryStorage(): Storage {
|
||||
}
|
||||
|
||||
const bookingId = '2092873463568597001'
|
||||
const arrivalTime = {
|
||||
sourceContainerBookingId: bookingId, estimatedWarehouseArrivalAt: '2026-09-01T08:00:00',
|
||||
actualWarehouseArrivalAt: null, version: 1, planCount: 0, plannedDate: '2026-09-01', isDefault: false,
|
||||
}
|
||||
const emptyReturnStatistics = { totals: { orderCount: 0, skuTypes: 0, expectedQuantity: 0, receivedQuantity: 0, remainingQuantity: 0, receivedBoxCount: 0, receivedParcelCount: 0, receivedPalletCount: 0 }, pageDimensions: [] }
|
||||
beforeEach(() => {
|
||||
vi.mocked(getReturnOrderStatistics).mockReset().mockResolvedValue(emptyReturnStatistics)
|
||||
@@ -171,6 +180,12 @@ async function selectRows(wrapper: Awaited<ReturnType<typeof mountView>>, rows:
|
||||
await wrapper.vm.$nextTick()
|
||||
}
|
||||
|
||||
/** 定向验证入库弹框,独立于列表页签和批量操作入口。 */
|
||||
async function openArrivalPlan(wrapper: Awaited<ReturnType<typeof mountView>>, source = selectedRow()) {
|
||||
;(wrapper.vm as unknown as { openInboundPlan(source: object): void }).openInboundPlan(source)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function button(wrapper: Awaited<ReturnType<typeof mountView>>, text: string) {
|
||||
return wrapper.findAll('button').find((item) => item.text().includes(text))
|
||||
}
|
||||
@@ -386,6 +401,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }],
|
||||
})
|
||||
vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never)
|
||||
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([arrivalTime])
|
||||
vi.mocked(getContainerInboundPlanPreview).mockReset().mockResolvedValue(preview as never)
|
||||
vi.mocked(getLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: false, nodes: [], version: 0 })
|
||||
vi.mocked(updateLastMileOrderHold).mockReset().mockResolvedValue({ orderId: '2092873463568596994', held: true, chargeable: true, version: 1 })
|
||||
@@ -654,11 +670,35 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(confirm).toBeUndefined()
|
||||
})
|
||||
|
||||
it('blocks manual plan creation when the cabinet has no frozen expected arrival time', async () => {
|
||||
it.each([
|
||||
[{ ...arrivalTime, estimatedWarehouseArrivalAt: '2026-10-25T09:30:00', plannedDate: '2026-10-25' }, '2026-10-25 09:30'],
|
||||
[{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, plannedDate: '2026-11-01', isDefault: true }, '2026-11-01(默认推算)'],
|
||||
[{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, planCount: 1, plannedDate: '2026-11-02' }, '2026-11-02'],
|
||||
])('reads the latest cabinet arrival on opening and permits planning with %s', async (arrival, expectedText) => {
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([arrival])
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' })])
|
||||
await button(wrapper, '入库')!.trigger('click')
|
||||
await openArrivalPlan(wrapper, arrival.estimatedWarehouseArrivalAt ? selectedRow() : selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' }))
|
||||
|
||||
expect(listContainerArrivalTimes).toHaveBeenCalledWith([bookingId])
|
||||
const source = wrapper.get('[aria-label="入库来源"]')
|
||||
expect(source.text()).toContain(expectedText)
|
||||
if (!arrival.estimatedWarehouseArrivalAt) expect(source.text()).not.toContain('00:00')
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(createManualInboundPlan).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(createManualInboundPlan).mock.calls[0][0]).not.toHaveProperty('plannedArrivalDate')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ arrivals: [] },
|
||||
{ arrivals: [{ ...arrivalTime, estimatedWarehouseArrivalAt: null, plannedDate: null }] },
|
||||
{ arrivals: [{ ...arrivalTime, version: -1 }] },
|
||||
])('blocks manual planning when the authoritative cabinet arrival is missing or inconsistent: $arrivals', async ({ arrivals }) => {
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue(arrivals)
|
||||
const wrapper = await openPending()
|
||||
await openArrivalPlan(wrapper)
|
||||
|
||||
const confirm = [...document.body.querySelectorAll('button')]
|
||||
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
|
||||
@@ -666,6 +706,37 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows arrival read failures and does not reuse the list timestamp', async () => {
|
||||
vi.mocked(listContainerArrivalTimes).mockRejectedValue(new ApiResponseError('到仓来源暂不可读', 'A0503', 503))
|
||||
const wrapper = await openPending()
|
||||
await openArrivalPlan(wrapper)
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
expect(dialog.text()).toContain('到仓来源暂不可读')
|
||||
expect(dialog.get('[aria-label="入库来源"]').text()).not.toContain('2026-09-01')
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps confirmation blocked during arrival loading and ignores an older opening of the same order', async () => {
|
||||
let resolveOld!: (value: typeof arrivalTime[]) => void
|
||||
vi.mocked(listContainerArrivalTimes).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = await openPending()
|
||||
await openArrivalPlan(wrapper)
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
||||
dialog.vm.$emit('update:modelValue', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValueOnce([{ ...arrivalTime, estimatedWarehouseArrivalAt: '2026-10-26T10:00:00', plannedDate: '2026-10-26' }])
|
||||
await openArrivalPlan(wrapper)
|
||||
expect(dialog.get('[aria-label="入库来源"]').text()).toContain('2026-10-26 10:00')
|
||||
resolveOld([arrivalTime]); await flushPromises()
|
||||
expect(dialog.get('[aria-label="入库来源"]').text()).toContain('2026-10-26 10:00')
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
})
|
||||
|
||||
it('locks one-click dropship manual plans to the frozen source pallet count', async () => {
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({
|
||||
...preview,
|
||||
@@ -1238,11 +1309,17 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(routerPush).toHaveBeenCalledWith({ name: 'overseas-return-inventory', query: expect.objectContaining({ packing: '1', returnNos: 'RETURN-1' }) })
|
||||
})
|
||||
it('keeps manual-plan drafts through native fullscreen with plain frozen source facts', async () => {
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '入库')!.trigger('click'); await flushPromises()
|
||||
const wrapper = await mountView()
|
||||
await openArrivalPlan(wrapper)
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
expect(dialog.props('width')).toContain('720px')
|
||||
expect(dialog.props('width')).toBe('min(1160px, calc(100vw - 48px))')
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('top')).toBe('6vh')
|
||||
expect(dialog.get('[aria-label="入库来源"] h3').text()).toContain('来源摘要')
|
||||
expect(dialog.get('[aria-label="入库计划登记"] h3').text()).toBe('计划设置')
|
||||
const sourceRows = dialog.findAll('.inbound-plan-source tbody tr')
|
||||
expect(sourceRows[0].get('td').attributes('colspan')).toBe('3')
|
||||
expect(sourceRows[1].findAll('th').map(cell => cell.text())).toEqual(['计划入库仓', '入仓码'])
|
||||
expect(dialog.props('confirmText')).toBe('保存并生成入库单')
|
||||
expect(dialog.props('title')).toContain('LMO-260827-0001')
|
||||
expect(dialog.get('[data-tone="source"]').find('input').exists()).toBe(false)
|
||||
await dialog.get('textarea').setValue('全屏后保留')
|
||||
@@ -1253,9 +1330,8 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
it('locks manual-plan form edits and preserves a failed draft and request context', async () => {
|
||||
let reject!: (reason: unknown) => void
|
||||
vi.mocked(createManualInboundPlan).mockReturnValue(new Promise((_, no) => { reject = no }))
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '入库')!.trigger('click'); await flushPromises()
|
||||
const wrapper = await mountView()
|
||||
await openArrivalPlan(wrapper)
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
await dialog.get('textarea').setValue('保留入库备注')
|
||||
await dialog.findAll('button').find(item => item.text() === '保存并生成入库单')!.trigger('click'); await flushPromises()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ChevronDown, ChevronUp, Download, Pencil, PackagePlus, SlidersHorizonta
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { listContainerArrivalTimes, projectWarehouseArrival, type ContainerArrivalTime } from '@/modules/container-loading'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -110,12 +111,23 @@ const inboundPlanSourcePalletCount = ref<number | null>(null)
|
||||
const inboundPlanPreviewLoading = ref(false)
|
||||
const inboundPlanPreviewError = ref('')
|
||||
const inboundPlanIsDropship = computed(() => actionTarget.value?.orderTypeCode === 'ONE_CLICK_DROPSHIP')
|
||||
const inboundPlanSubmitDisabled = computed(() => !actionTarget.value?.estimatedWarehouseAt
|
||||
const inboundPlanArrival = ref<ContainerArrivalTime | null>(null)
|
||||
const inboundPlanArrivalLoading = ref(false)
|
||||
const inboundPlanArrivalError = ref('')
|
||||
let inboundPlanArrivalRequest = 0
|
||||
const inboundPlanArrivalProjection = computed(() => {
|
||||
const arrival = inboundPlanArrival.value
|
||||
return projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
})
|
||||
const inboundPlanSubmitDisabled = computed(() => inboundPlanArrivalLoading.value || !inboundPlanArrivalProjection.value.date
|
||||
|| (inboundPlanIsDropship.value
|
||||
&& (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null
|
||||
|| inboundPlanSourcePalletCount.value <= 0)))
|
||||
const inboundPlanForm = reactive({ inboundCode: '', palletCount: 1, palletType: 'WOOD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
||||
const inboundPlanExpectedArrival = computed(() => actionTarget.value?.warehouseEta || '—')
|
||||
const inboundPlanExpectedArrival = computed(() => {
|
||||
const { date, time, isDefault } = inboundPlanArrivalProjection.value
|
||||
return date ? `${date}${time ? ` ${time}` : ''}${isDefault ? '(默认推算)' : ''}` : '—'
|
||||
})
|
||||
const holdOpen = ref(false)
|
||||
const holdForm = reactive({ held: true, chargeable: true, reason: '' })
|
||||
const manualNodesOpen = ref(false)
|
||||
@@ -633,9 +645,37 @@ function openInboundPlan(source?: object) {
|
||||
inboundPlanPreviewError.value = ''
|
||||
Object.assign(inboundPlanForm, { inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: inboundPlanMinimumPalletCount.value, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
||||
actionError.value = null
|
||||
inboundPlanArrival.value = null
|
||||
inboundPlanArrivalError.value = ''
|
||||
inboundPlanOpen.value = true
|
||||
void refreshInboundPlanArrival(target)
|
||||
void refreshManualPalletMinimum(target)
|
||||
}
|
||||
/** 每次打开重新读取柜级权威日期,避免使用列表旧值或在页面推算默认日期。 */
|
||||
async function refreshInboundPlanArrival(target: LastMileOrderListItem) {
|
||||
const request = ++inboundPlanArrivalRequest
|
||||
inboundPlanArrivalLoading.value = true
|
||||
try {
|
||||
if (!target.sourceContainerBookingId) {
|
||||
inboundPlanArrivalError.value = '尾程订单未绑定来源柜,无法取得预计到仓时间'
|
||||
return
|
||||
}
|
||||
const arrivals = await listContainerArrivalTimes([target.sourceContainerBookingId])
|
||||
if (request !== inboundPlanArrivalRequest || !inboundPlanOpen.value) return
|
||||
const arrival = arrivals.find(item => item.sourceContainerBookingId === target.sourceContainerBookingId)
|
||||
if (!arrival || arrival.version < 0) {
|
||||
inboundPlanArrivalError.value = '来源柜预计到仓日期缺失或不一致,请先核对柜级时间'
|
||||
return
|
||||
}
|
||||
inboundPlanArrival.value = arrival
|
||||
} catch (error) {
|
||||
if (request === inboundPlanArrivalRequest && inboundPlanOpen.value) {
|
||||
inboundPlanArrivalError.value = getApiErrorDetails(error, '预计到仓时间加载失败,请重新打开入库计划重试').message
|
||||
}
|
||||
} finally {
|
||||
if (request === inboundPlanArrivalRequest) inboundPlanArrivalLoading.value = false
|
||||
}
|
||||
}
|
||||
async function refreshManualPalletMinimum(target: LastMileOrderListItem) {
|
||||
if (!target.sourceContainerBookingId) return
|
||||
inboundPlanPreviewLoading.value = true
|
||||
@@ -671,7 +711,7 @@ async function submitInboundPlan() {
|
||||
ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划')
|
||||
return
|
||||
}
|
||||
if (!row || !row.overseasWarehouseId || !row.estimatedWarehouseAt || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
|
||||
if (!row || !row.overseasWarehouseId || inboundPlanArrivalLoading.value || !inboundPlanArrivalProjection.value.date || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
|
||||
ElMessage.warning('当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数')
|
||||
return
|
||||
}
|
||||
@@ -1288,25 +1328,28 @@ onMounted(() => { void load() })
|
||||
</LoadErrorState>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
|
||||
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
|
||||
<AppDialog class="inbound-plan-dialog admin-density-dialog admin-workbench is-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(1160px, calc(100vw - 48px))" top="6vh" fullscreenable confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading || inboundPlanArrivalLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
|
||||
<div class="inbound-plan-content">
|
||||
<el-alert title="保存后生成入库单;首个入库计划生成后,整柜预计到仓时间不可更改。" type="info" :closable="false" show-icon />
|
||||
<el-alert v-if="inboundPlanPreviewError" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
|
||||
<el-form class="command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">入库来源 <InlineHelp title="入库来源说明">取整柜预计到仓时间,同柜所有入库计划保持一致;已生成计划后锁定。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</InlineHelp></h3>
|
||||
<table class="density-table last-mile-dialog-facts"><tbody>
|
||||
<tr><th scope="row">预计到仓时间</th><td>{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></td></tr>
|
||||
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td></tr><tr><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
|
||||
<el-alert v-if="inboundPlanArrivalError" :title="inboundPlanArrivalError" type="error" :closable="false" show-icon />
|
||||
<el-form class="inbound-plan-form command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">来源摘要 <InlineHelp title="入库来源说明">优先使用整柜人工维护的预计到仓时间;未维护时,按到港当地日期加两个自然日默认推算。首个入库计划生成后冻结日期,同柜计划保持一致。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</InlineHelp></h3>
|
||||
<table class="density-table last-mile-dialog-facts inbound-plan-source"><colgroup><col class="inbound-plan-source__label" /><col /><col class="inbound-plan-source__label" /><col /></colgroup><tbody>
|
||||
<tr><th scope="row">预计到仓时间</th><td colspan="3"><div class="inbound-plan-arrival">{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></div></td></tr>
|
||||
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划登记</h3>
|
||||
<section class="density-dialog-section" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划设置</h3>
|
||||
<table class="density-table density-table--editable last-mile-command-table"><tbody>
|
||||
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="inbound-plan-pallet-note density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="inbound-plan-pallet-note density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="是否外配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</el-form>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="holdOpen" :title="`${holdForm.held ? '暂扣订单' : '解除暂扣'} · ${actionTarget?.orderNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
|
||||
<el-alert :title="holdForm.held ? '暂扣后不可创建出库计划;解除暂扣后恢复出库计划操作。' : '解除暂扣后恢复该订单的出库计划操作。'" type="warning" :closable="false" show-icon />
|
||||
@@ -1332,6 +1375,14 @@ onMounted(() => { void load() })
|
||||
.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%; }
|
||||
.inbound-plan-content { display: grid; min-width: 0; gap: var(--admin-space-5); }
|
||||
.inbound-plan-form { margin-top: 0; }
|
||||
.inbound-plan-source__label { width: 112px; }
|
||||
.inbound-plan-source td { overflow-wrap: anywhere; }
|
||||
.inbound-plan-arrival { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.inbound-plan-form :deep(.el-form-item__content) { min-width: 0; }
|
||||
.inbound-plan-form :deep(.el-select) { width: 100%; }
|
||||
.inbound-plan-pallet-note { flex-basis: 100%; margin-top: var(--admin-space-2); }
|
||||
.manual-nodes-editor th { width: 180px; }
|
||||
.relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; }
|
||||
.last-mile-toolbar { min-width: 0; }
|
||||
|
||||
@@ -306,7 +306,7 @@ onMounted(load)
|
||||
<LoadErrorState v-if="createError" title="出库操作未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState>
|
||||
<el-alert v-if="!access.can(permission.outboundCreate)" title="当前账号没有新建出库权限。" type="warning" :closable="false" show-icon />
|
||||
<el-form class="create-outbound-form density-form density-dialog-sections" label-position="left" label-width="120px" :disabled="createLocked">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="出库模式"><h3 class="density-dialog-section__title">出库类型</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="出库类型" required>
|
||||
<section class="density-dialog-section" aria-label="出库模式"><h3 class="density-dialog-section__title">出库类型</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="出库类型" required>
|
||||
<el-radio-group v-model="outboundType" @change="changeOutboundType">
|
||||
<el-radio-button label="ONE_CLICK_DROPSHIP">一件代发出库</el-radio-button>
|
||||
<el-radio-button label="RETURN_OUTBOUND">退货出库</el-radio-button>
|
||||
@@ -314,7 +314,7 @@ onMounted(load)
|
||||
</el-radio-group>
|
||||
</el-form-item></td></tr></tbody></table></section>
|
||||
<template v-if="outboundType === 'ONE_CLICK_DROPSHIP'">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="一件代发出库范围"><h3 class="density-dialog-section__title">出库范围</h3><table class="density-table density-table--editable outbound-editor-table dropship-search-row"><tbody><tr><td><el-form-item label="海外仓" required>
|
||||
<section class="density-dialog-section" aria-label="一件代发出库范围"><h3 class="density-dialog-section__title">出库范围</h3><table class="density-table density-table--editable outbound-editor-table dropship-search-row"><tbody><tr><td><el-form-item label="海外仓" required>
|
||||
<el-select :disabled="createLocked" v-model="selectedWarehouseId" filterable clearable default-first-option aria-label="搜索海外仓" placeholder="输入仓库名称或编码搜索" @change="changeDropshipWarehouse">
|
||||
<el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" />
|
||||
</el-select>
|
||||
@@ -342,7 +342,6 @@ onMounted(load)
|
||||
<el-table-column prop="volumeWeightKg" label="可出材积重(kg)" width="135" align="right" />
|
||||
<el-table-column prop="volumeCbm" label="可出体积(m³)" width="125" align="right" />
|
||||
</el-table>
|
||||
<div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="density-dialog-note">暂无可直接出库的已完成入库订单</div>
|
||||
<div class="candidate-pagination">
|
||||
<span aria-live="polite" title="翻页保留选择">已选择 {{ selectedDropshipIds.length }} 条 · 第 {{ dropshipPage }} / {{ Math.max(1, Math.ceil(filteredDropshipCandidates.length / dropshipPageSize)) }} 页</span>
|
||||
<el-pagination :disabled="createLocked" v-model:current-page="dropshipPage" v-model:page-size="dropshipPageSize" :page-sizes="[5, 10, 20, 50]" :pager-count="5" :total="filteredDropshipCandidates.length" layout="total, sizes, prev, pager, next, jumper" @size-change="dropshipPage = 1" />
|
||||
@@ -361,17 +360,17 @@ onMounted(load)
|
||||
<el-table-column prop="volumeCbm" label="总体积(m³)" width="120" align="right" />
|
||||
<el-table-column label="换标" width="110"><template #default="{ row }">{{ row.needRelabel ? `已换标${row.relabelNo ? ` · ${row.relabelNo}` : ''}` : '原箱出库' }}</template></el-table-column>
|
||||
<el-table-column label="主单目的去向" min-width="240" show-overflow-tooltip><template #default="{ row }"><StatusBadge :status="transferHasDestination(row as WarehouseTransferOutboundCandidate) ? 'READY' : 'PENDING'" :label="transferDestinationLabel(row as WarehouseTransferOutboundCandidate)" /></template></el-table-column>
|
||||
</el-table><p v-if="!inventoryLoading && !transferCandidates.length" class="density-dialog-note">暂无已入库且尚无目的地的仓储转运单</p></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="转运目的地登记"><h3 class="density-dialog-section__title">目的地</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="目的地类型" required><el-radio-group v-model="destinationType"><el-radio-button label="FBA">FBA目的仓</el-radio-button><el-radio-button label="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item></td></tr><tr v-if="destinationType === 'FBA'"><td colspan="2"><el-form-item label="FBA目的仓" required><el-select v-model="selectedDestinationWarehouseId" filterable clearable placeholder="请选择FBA目的仓"><el-option v-for="item in fbaWarehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item></td></tr><template v-else><tr><td><el-form-item label="私人仓地址" required><el-input v-model="deliveryAddress" placeholder="请输入完整派送地址" /></el-form-item></td><td><el-form-item label="邮编" required><el-input v-model="postalCode" maxlength="32" placeholder="请输入邮编" /></el-form-item></td></tr><tr><td><el-form-item label="联系人" required><el-input v-model="contactName" placeholder="请输入联系人" /></el-form-item></td><td><el-form-item label="联系电话" required><el-input v-model="contactPhone" placeholder="请输入联系电话" /></el-form-item></td></tr></template></tbody></table></section>
|
||||
</el-table></section>
|
||||
<section class="density-dialog-section" aria-label="转运目的地登记"><h3 class="density-dialog-section__title">目的地</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="目的地类型" required><el-radio-group v-model="destinationType"><el-radio-button label="FBA">FBA目的仓</el-radio-button><el-radio-button label="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item></td></tr><tr v-if="destinationType === 'FBA'"><td colspan="2"><el-form-item label="FBA目的仓" required><el-select v-model="selectedDestinationWarehouseId" filterable clearable placeholder="请选择FBA目的仓"><el-option v-for="item in fbaWarehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item></td></tr><template v-else><tr><td><el-form-item label="私人仓地址" required><el-input v-model="deliveryAddress" placeholder="请输入完整派送地址" /></el-form-item></td><td><el-form-item label="邮编" required><el-input v-model="postalCode" maxlength="32" placeholder="请输入邮编" /></el-form-item></td></tr><tr><td><el-form-item label="联系人" required><el-input v-model="contactName" placeholder="请输入联系人" /></el-form-item></td><td><el-form-item label="联系电话" required><el-input v-model="contactPhone" placeholder="请输入联系电话" /></el-form-item></td></tr></template></tbody></table></section>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="退货出库资料"><h3 class="density-dialog-section__title">退货出库资料<InlineHelp title="退货出库说明"><p>整包裹、整箱或整托出库。包裹或箱子只出部分商品时,请先在退货合箱作业生成新箱并贴标,再选择新箱出库。</p></InlineHelp></h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td><el-form-item v-if="outboundType === 'RETURN_OUTBOUND'" label="客户" required>
|
||||
<section class="density-dialog-section" aria-label="退货出库资料"><h3 class="density-dialog-section__title">退货出库资料<InlineHelp title="退货出库说明"><p>整包裹、整箱或整托出库。包裹或箱子只出部分商品时,请先在退货合箱作业生成新箱并贴标,再选择新箱出库。</p></InlineHelp></h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td><el-form-item v-if="outboundType === 'RETURN_OUTBOUND'" label="客户" required>
|
||||
<el-select v-model="selectedCustomerId" :loading="inventoryLoading" clearable filterable placeholder="请选择客户" @change="selectCustomer">
|
||||
<el-option v-for="item in customerOptions" :key="item.value" :value="item.value" :label="item.label" />
|
||||
</el-select>
|
||||
</el-form-item></td><td><el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="createLocked || !selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item></td><td><el-form-item label="退货单" required><el-select v-model="selectedReturnNo" filterable placeholder="选择已签收入库的退货单" @change="selectedReturnMarks = []"><el-option v-for="no in returnOrderOptions" :key="no" :label="no" :value="no" /></el-select></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="箱托方式"><el-radio-group v-model="returnUnitType" @change="changeReturnUnitType"><el-radio-button value="BOX">按包裹 / 箱号出库</el-radio-button><el-radio-button value="PALLET">按托盘出库</el-radio-button></el-radio-group></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-if="returnUnitType === 'PALLET' && returnPallets.length" class="density-dialog-section" data-tone="work" aria-label="整托选择"><h3 class="density-dialog-section__title">整托选择</h3><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :disabled="createLocked || Boolean(candidateError)" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></section>
|
||||
<section v-if="returnUnitType === 'PALLET' && returnPallets.length" class="density-dialog-section" aria-label="整托选择"><h3 class="density-dialog-section__title">整托选择</h3><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :disabled="createLocked || Boolean(candidateError)" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></section>
|
||||
<div class="return-lists">
|
||||
<section class="return-list-card density-dialog-section" data-tone="source">
|
||||
<h3 class="density-dialog-section__title">SKU库存</h3>
|
||||
@@ -387,7 +386,7 @@ onMounted(load)
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="本次出库备注"><h3 class="density-dialog-section__title">备注</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="备注"><el-input v-model="remark" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" aria-label="本次出库备注"><h3 class="density-dialog-section__title">备注</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="备注"><el-input v-model="remark" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" /></el-form-item></td></tr></tbody></table></section>
|
||||
</el-form>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('PalletsView', () => {
|
||||
})
|
||||
|
||||
it('loads the all-warehouse pallet list and summary when the page opens', async () => {
|
||||
vi.mocked(getPalletSummary).mockResolvedValueOnce({ totalCount: 250, idleCount: 100, occupiedCount: 98, scrappedCount: 52 })
|
||||
vi.mocked(getPalletSummary).mockResolvedValueOnce({ totalCount: 1250, idleCount: 1100, occupiedCount: 98, scrappedCount: 52 })
|
||||
|
||||
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -42,10 +42,10 @@ describe('PalletsView', () => {
|
||||
expect(getPalletSummary).toHaveBeenCalledWith('', { page: 1, size: 10, keyword: undefined, palletType: undefined, status: undefined })
|
||||
expect(wrapper.get('[aria-label="托盘管理列表"]').text()).toContain('PLT-1')
|
||||
expect(wrapper.get('[aria-label="托盘管理列表"]').text()).not.toContain('请先选择仓库后查询')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('托盘总数250')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('闲置托盘100')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('占用托盘98')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('已报废52')
|
||||
const cards = wrapper.get('[aria-label="海外托盘数量概览"]').findAll('article')
|
||||
expect(cards.map(card => card.get('h2').text())).toEqual(['托盘总数', '闲置托盘', '占用托盘', '已报废'])
|
||||
expect(cards.map(card => card.get('strong').text())).toEqual(['1,250', '1,100', '98', '52'])
|
||||
expect(cards.map(card => card.get('.workbench-metric-value > span').text())).toEqual(['个', '个', '个', '个'])
|
||||
})
|
||||
|
||||
it('prints one pallet from the all-warehouse list and allows retry after failure', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { Layers, Package, PackageCheck, PackageX, Plus } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -167,7 +167,24 @@ onMounted(initialize)
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="mutationError" />
|
||||
<section class="summary-grid workbench-panel is-flush" aria-label="海外托盘数量概览"><article><span>托盘总数</span><strong>{{ summary.totalCount }}</strong></article><article><span>闲置托盘</span><strong class="idle">{{ summary.idleCount }}</strong></article><article><span>占用托盘</span><strong class="occupied">{{ summary.occupiedCount }}</strong></article><article><span>已报废</span><strong>{{ summary.scrappedCount }}</strong></article></section>
|
||||
<section class="summary-grid" aria-label="海外托盘数量概览">
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Layers aria-hidden="true" /></span><h2 class="workbench-metric-label">托盘总数</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.totalCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackageCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">闲置托盘</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.idleCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Package aria-hidden="true" /></span><h2 class="workbench-metric-label">占用托盘</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.occupiedCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||||
</article>
|
||||
<article class="workbench-metric workbench-tone is-compact" data-tone="rose">
|
||||
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackageX aria-hidden="true" /></span><h2 class="workbench-metric-label">已报废</h2></div>
|
||||
<div class="workbench-metric-value"><strong>{{ summary.scrappedCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
|
||||
</article>
|
||||
</section>
|
||||
<ListPageLayout aria-label="托盘管理列表">
|
||||
|
||||
|
||||
@@ -203,13 +220,9 @@ onMounted(initialize)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.summary-grid span { color:var(--workbench-muted); font-size:var(--admin-font-caption); }.summary-grid .idle { color:var(--el-color-success); }.summary-grid .occupied { color:var(--el-color-warning); }
|
||||
.pallet-editor-table { table-layout: fixed; }
|
||||
.pallet-editor-table td:not([colspan]) { width: 50%; }
|
||||
|
||||
.summary-grid { display: flex; gap: 0; flex-wrap: wrap; }
|
||||
.summary-grid article { display: flex; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border: 0; border-radius: 0; background:var(--workbench-surface); flex: 1 1 200px; align-items: baseline; border-right: 1px solid var(--workbench-border); }
|
||||
.summary-grid article:last-child { border-right: 0; }
|
||||
.summary-grid strong { font-size: var(--admin-font-body); }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--workbench-grid-gap); }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -262,7 +262,9 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
|
||||
it('connects native same-warehouse multi-customer draft containers and independent resources', () => {
|
||||
const dialog = source('../components/SmartDispatchDialog.vue')
|
||||
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-actions', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
|
||||
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-actions', '洛杉矶', '纽约', '先建空单后加货', 'vehicle: null, driver: null']) expect(dialog).toContain(symbol)
|
||||
expect(dialog).not.toContain('调整已有排车货物')
|
||||
expect(dialog).not.toContain('SmartDispatchExistingCargo')
|
||||
// A filtered candidate must still submit its complete server-owned selection group.
|
||||
expect(dialog).toContain('candidatePage.assertCurrent(cargo)')
|
||||
expect(dialog).toContain('cargo: expandSelectionGroups(cargo)')
|
||||
|
||||
Reference in New Issue
Block a user