This commit is contained in:
2026-08-14 19:59:32 +08:00
parent f7594ebd39
commit 1540b80c89
29 changed files with 780 additions and 587 deletions
@@ -58,13 +58,14 @@ describe('OutboundDispatchesView', () => {
const wrapper = mountPage()
await flushPromises()
expect(listOutboundDispatches).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请选择订单 / 入库单或柜次后查询')
expect(wrapper.text()).toContain('请选择入仓码或柜次后查询')
const toolbar = await setFilters(wrapper, { orderId: '20001' })
toolbar.vm.$emit('search')
await flushPromises()
expect(listOutboundDispatches).toHaveBeenCalledOnce()
expect(listOutboundDispatches).toHaveBeenCalledWith({ orderId: '20001', inboundId: '30001', containerBookingId: undefined })
expect(wrapper.text()).toContain('ORD-001')
expect(wrapper.text()).toContain('IN-001')
expect(wrapper.text()).not.toContain('ORD-001')
expect(wrapper.text()).toContain('已放舱')
expect(wrapper.text()).toContain('已完成')
expect(wrapper.text()).toContain('待复核')
@@ -92,7 +93,7 @@ describe('OutboundDispatchesView', () => {
await resetButton?.trigger('click')
await flushPromises()
expect(listOutboundDispatches).toHaveBeenCalledOnce()
expect(wrapper.text()).toContain('请选择订单 / 入库单或柜次后查询')
expect(wrapper.text()).toContain('请选择入仓码或柜次后查询')
})
it('shows loading, empty and recoverable error states', async () => {
@@ -116,7 +117,8 @@ describe('OutboundDispatchesView', () => {
await retry?.trigger('click')
await flushPromises()
expect(listOutboundDispatches).toHaveBeenCalledTimes(3)
expect(wrapper.text()).toContain('ORD-001')
expect(wrapper.text()).toContain('IN-001')
expect(wrapper.text()).not.toContain('ORD-001')
})
it('ignores stale list responses when a newer query wins', async () => {
@@ -133,14 +135,16 @@ describe('OutboundDispatchesView', () => {
await flushPromises()
first.resolve([row])
await flushPromises()
expect(wrapper.text()).toContain('ORD-002')
expect(wrapper.text()).not.toContain('ORD-001')
expect(wrapper.text()).toContain('IN-002')
expect(wrapper.text()).not.toContain('IN-001')
expect(wrapper.text()).not.toContain('ORD-002')
})
it('keeps the operation column first and guarded workflow actions', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
const columnSource = source.slice(source.indexOf('const columns'))
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("prop: 'orderNo'"))
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("prop: 'inboundCode'"))
expect(columnSource).not.toContain("prop: 'orderNo'")
expect(columnSource).toContain("fixed: 'left'")
expect(source).toContain('aggregate.gate?.items')
expect(source).toContain('detail.timeline')
@@ -40,14 +40,13 @@ let disposed = false
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'orderId', type: 'select', label: '订单 / 入库单', width: 280, placeholder: filters.value.customerId ? '请选择该客户的订单' : '请选择订单或入库单', options: orders.value.map((item) => ({ label: `${item.orderNo || item.orderId} / ${item.inboundCode || item.inboundId || '未入库'}`, value: String(item.orderId), disabled: item.inboundId == null })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'orderId', type: 'select', label: '入仓码', width: 280, placeholder: filters.value.customerId ? '请选择该客户的入仓码' : '请选择入仓码', options: orders.value.map((item) => ({ label: item.inboundCode || '入仓码暂缺', value: String(item.orderId), disabled: item.inboundId == null })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'containerBookingId', type: 'select', label: '柜次', width: 240, placeholder: '请选择柜号或集装箱号', options: containers.value.map((item) => ({ label: `${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`, value: item.id })), props: { filterable: true, loading: optionsLoading.value } },
])
const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 240, slotName: 'actions', hideable: false, movable: false },
{ prop: 'orderNo', label: '订单号', minWidth: 145 },
{ prop: 'inboundCode', label: '入库单号', minWidth: 145 },
{ prop: 'inboundCode', label: '入仓码', minWidth: 145 },
{ prop: 'customerName', label: '客户', minWidth: 140 },
{ prop: 'containerNo', label: '柜号', minWidth: 130 },
{ prop: 'oceanBookingStatus', label: '订舱', width: 105, slotName: 'booking' },
@@ -200,7 +199,7 @@ onBeforeUnmount(() => { disposed = true; listRequestId += 1; optionRequestId +=
<LoadErrorState v-if="error" title="国内出仓加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
</template>
<section v-if="!error" class="surface-panel table-panel">
<el-empty v-if="!queried" description="请选择订单 / 入库单或柜次后查询" />
<el-empty v-if="!queried" description="请选择入仓码或柜次后查询" />
<DataTable v-else :data="rows" :columns="columns" :loading="loading" stripe row-key="inboundId" preference-key="outbound-dispatches" empty-text="暂无符合条件的出仓记录">
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" size="small" type="primary" plain @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus === 'READY_FOR_DISPATCH'" size="small" type="primary" @click="action(row, 'confirm-dispatch')"><Send :size="15" />确认</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:reverse') && row.dispatchStatus === 'WAITING_FOR_LOADING'" size="small" type="danger" plain @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></div></template>
<template #booking="{ row }"><StatusBadge :status="oceanBookingStatusLabel(row.oceanBookingStatus)" /></template><template #truck="{ row }"><StatusBadge :status="truckStatusLabel(row.truckAppointmentStatus)" /></template><template #pick="{ row }"><StatusBadge :status="pickTaskStatusLabel(row.pickTaskStatus)" /></template><template #status="{ row }"><StatusBadge :status="outboundDispatchStatusLabel(row.dispatchStatus)" /></template><template #gate="{ row }"><el-tag :type="row.gate?.passed ? 'success' : 'danger'">{{ row.gate?.passed ? '通过' : '未通过' }}</el-tag></template>
@@ -210,7 +209,7 @@ onBeforeUnmount(() => { disposed = true; listRequestId += 1; optionRequestId +=
<AppDialog v-model="detailOpen" class="outbound-detail-dialog" title="国内出仓详情" width="min(900px, calc(100vw - 28px))" :show-confirm="false">
<LoadErrorState v-if="actionError" title="出仓详情加载失败" :message="actionError.message" :loading="detailLoading" @retry="retryDetail"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
<div v-else-if="detailLoading" class="detail-loading" aria-busy="true"><el-skeleton :rows="5" animated /></div>
<template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="订单号">{{ detail.aggregate.orderNo }}</el-descriptions-item><el-descriptions-item label="入库单号">{{ detail.aggregate.inboundCode }}</el-descriptions-item><el-descriptions-item label="柜号">{{ detail.aggregate.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="出仓状态"><StatusBadge :status="outboundDispatchStatusLabel(detail.aggregate.dispatchStatus)" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table class="gate-table" :data="detail.aggregate.gate?.items || []" border stripe><el-table-column label="结果" width="80"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '阻断' }}</el-tag></template></el-table-column><el-table-column prop="name" label="门禁" min-width="140" /><el-table-column prop="actual" label="当前情况" min-width="180" /><el-table-column prop="requirement" label="要求" min-width="180" /></el-table><h3>整体时间线</h3><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="`${entry.module}-${entry.occurredAt}-${entry.action}`" :timestamp="entry.occurredAt">{{ timelineModuleLabel(entry.module) }} · {{ timelineActionLabel(entry.action) }}:{{ timelineStatusLabel(entry.module, entry.fromStatus) }} → {{ timelineStatusLabel(entry.module, entry.toStatus) }}<small>{{ entry.operatorName || '系统' }} {{ entry.remark || '' }}</small></el-timeline-item></el-timeline></template>
<template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="入仓码">{{ detail.aggregate.inboundCode || '—' }}</el-descriptions-item><el-descriptions-item label="柜号">{{ detail.aggregate.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="出仓状态"><StatusBadge :status="outboundDispatchStatusLabel(detail.aggregate.dispatchStatus)" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table class="gate-table" :data="detail.aggregate.gate?.items || []" border stripe><el-table-column label="结果" width="80"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '阻断' }}</el-tag></template></el-table-column><el-table-column prop="name" label="门禁" min-width="140" /><el-table-column prop="actual" label="当前情况" min-width="180" /><el-table-column prop="requirement" label="要求" min-width="180" /></el-table><h3>整体时间线</h3><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="`${entry.module}-${entry.occurredAt}-${entry.action}`" :timestamp="entry.occurredAt">{{ timelineModuleLabel(entry.module) }} · {{ timelineActionLabel(entry.action) }}:{{ timelineStatusLabel(entry.module, entry.fromStatus) }} → {{ timelineStatusLabel(entry.module, entry.toStatus) }}<small>{{ entry.operatorName || '系统' }} {{ entry.remark || '' }}</small></el-timeline-item></el-timeline></template>
</AppDialog>
</AdminPage>
</template>