This commit is contained in:
2026-08-11 18:43:56 +08:00
parent 6456278ec8
commit 6622815d6c
37 changed files with 739 additions and 205 deletions
@@ -22,7 +22,7 @@ const customer = { id: '10001', tenantCode: 'CUS-1', tenantName: '示例客户',
const order = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户' } as UnifiedOrderResource
const secondOrder = { ...order, orderId: '20002', orderNo: 'ORD-002', inboundId: '30002', inboundCode: 'IN-002' } as UnifiedOrderResource
const container = { id: '40001', bookingNo: 'BK-001', cabinetNo: 'CAB-001', containerNo: 'MSCU-001' } as ContainerBookingResource
const row = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户', containerBookingId: '40001', containerNo: 'MSCU-001', dispatchStatus: 'WAITING', version: 1, gate: { passed: true, items: [], checkedAt: '2026-08-11T10:00:00' } } as OutboundDispatchResource
const row = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户', containerBookingId: '40001', containerNo: 'MSCU-001', oceanBookingStatus: 'RELEASED', truckAppointmentStatus: 'COMPLETED', pickTaskStatus: 'AWAITING_REVIEW', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, gate: { passed: true, items: [], checkedAt: '2026-08-11T10:00:00' } } as OutboundDispatchResource
enableAutoUnmount(afterEach)
@@ -65,6 +65,11 @@ describe('OutboundDispatchesView', () => {
expect(listOutboundDispatches).toHaveBeenCalledOnce()
expect(listOutboundDispatches).toHaveBeenCalledWith({ orderId: '20001', inboundId: '30001', containerBookingId: undefined })
expect(wrapper.text()).toContain('ORD-001')
expect(wrapper.text()).toContain('已放舱')
expect(wrapper.text()).toContain('已完成')
expect(wrapper.text()).toContain('待复核')
expect(wrapper.text()).toContain('待确认出仓')
expect(wrapper.text()).not.toContain('READY_FOR_DISPATCH')
})
it('does not issue a search when reset returns to the initial state', async () => {
@@ -132,6 +137,11 @@ describe('OutboundDispatchesView', () => {
expect(source).toContain('detail.timeline')
expect(source).toContain("'confirm-dispatch'")
expect(source).toContain("'reverse-dispatch'")
expect(source).toContain("row.dispatchStatus === 'READY_FOR_DISPATCH'")
expect(source).toContain("row.dispatchStatus === 'WAITING_FOR_LOADING'")
expect(source).toContain('timelineModuleLabel(entry.module)')
expect(source).toContain('timelineActionLabel(entry.action)')
expect(source).toContain('timelineStatusLabel(entry.module, entry.fromStatus)')
expect(source).toContain("error.value?.code === 'A0409'")
expect(getOutboundDispatchDetail).not.toHaveBeenCalled()
})
@@ -17,6 +17,7 @@ import { listPlatformUnifiedOrders, type UnifiedOrderResource } from '@/modules/
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
import { changeOutboundDispatch, getOutboundDispatchDetail, listOutboundDispatches } from '../api/platformOutboundDispatch'
import type { OutboundDispatchDetail, OutboundDispatchQuery, OutboundDispatchResource } from '../types'
import { oceanBookingStatusLabel, outboundDispatchStatusLabel, pickTaskStatusLabel, timelineActionLabel, timelineModuleLabel, timelineStatusLabel, truckStatusLabel } from '../statusLabels'
type DispatchFilters = QueryModel & { customerId: string; orderId: string; containerBookingId: string }
@@ -188,15 +189,15 @@ onBeforeUnmount(() => { disposed = true; listRequestId += 1; optionRequestId +=
<section v-if="!error" class="surface-panel table-panel">
<el-empty v-if="!queried" description="请选择订单 / 入库单或柜次后查询" />
<DataTable v-else :data="rows" :columns="columns" :loading="loading" row-key="inboundId" preference-key="outbound-dispatches" empty-text="暂无符合条件的出仓记录">
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" link type="primary" @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus !== 'DISPATCHED'" link 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 === 'DISPATCHED'" link type="danger" @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></template>
<template #booking="{ row }"><StatusBadge :status="row.oceanBookingStatus || '未关联'" /></template><template #truck="{ row }"><StatusBadge :status="row.truckAppointmentStatus || '未关联'" /></template><template #pick="{ row }"><StatusBadge :status="row.pickTaskStatus || '未关联'" /></template><template #status="{ row }"><StatusBadge :status="row.dispatchStatus || '待处理'" /></template><template #gate="{ row }"><el-tag :type="row.gate?.passed ? 'success' : 'danger'">{{ row.gate?.passed ? '通过' : '未通过' }}</el-tag></template>
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" link type="primary" @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus === 'READY_FOR_DISPATCH'" link 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'" link type="danger" @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></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>
</DataTable>
</section>
</ListPageLayout>
<AppDialog v-model="detailOpen" title="国内出仓详情" width="900px" :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="detail.aggregate.dispatchStatus || '待处理'" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table :data="detail.aggregate.gate?.items || []"><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">{{ entry.module }} · {{ entry.action }}:{{ entry.fromStatus || '—' }} → {{ 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.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 :data="detail.aggregate.gate?.items || []"><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>
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { oceanBookingStatusLabel, outboundDispatchStatusLabel, pickTaskStatusLabel, timelineActionLabel, timelineModuleLabel, timelineStatusLabel, truckStatusLabel } from './statusLabels'
describe('outbound dispatch status labels', () => {
it('maps list statuses to business Chinese', () => {
expect(outboundDispatchStatusLabel('READY_FOR_DISPATCH')).toBe('待确认出仓')
expect(outboundDispatchStatusLabel('WAITING_FOR_LOADING')).toBe('等待装柜')
expect(oceanBookingStatusLabel('RELEASED')).toBe('已放舱')
expect(truckStatusLabel('PICKUP_IN_PROGRESS')).toBe('提柜中')
expect(pickTaskStatusLabel('PENDING')).toBe('待拣货')
expect(pickTaskStatusLabel('IN_PROGRESS')).toBe('拣货中')
expect(pickTaskStatusLabel('AWAITING_REVIEW')).toBe('待复核')
expect(pickTaskStatusLabel('COMPLETED')).toBe('已完成')
})
it('localizes timeline modules, actions and module-specific states', () => {
expect(timelineModuleLabel('OUTBOUND_DISPATCH')).toBe('国内出仓')
expect(timelineActionLabel('CONFIRM_DISPATCH')).toBe('确认出仓')
expect(timelineStatusLabel('OUTBOUND_DISPATCH', 'READY_FOR_DISPATCH')).toBe('待确认出仓')
expect(timelineStatusLabel('OCEAN_BOOKING', 'RELEASED')).toBe('已放舱')
expect(timelineStatusLabel('TRUCK_APPOINTMENT', 'COMPLETED')).toBe('已完成')
expect(timelineStatusLabel('INBOUND', 'INBOUNDED')).toBe('已入库')
})
it('does not expose unknown backend enum values', () => {
expect(outboundDispatchStatusLabel('NEW_BACKEND_STATE')).toBe('未知状态')
expect(timelineModuleLabel('NEW_MODULE')).toBe('其他业务流程')
expect(timelineActionLabel('NEW_ACTION')).toBe('状态更新')
expect(timelineStatusLabel('NEW_MODULE', 'NEW_STATE')).toBe('未知状态')
})
})
@@ -0,0 +1,111 @@
import { truckAppointmentStatusLabels } from '@/modules/trucking'
import { oceanBookingStatusLabel as sharedOceanBookingStatusLabel } from '@/modules/ocean-bookings'
const dispatchStatusLabels: Record<string, string> = {
READY_FOR_DISPATCH: '待确认出仓',
WAITING_FOR_LOADING: '等待装柜',
}
const pickTaskStatusLabels: Record<string, string> = {
PENDING: '待拣货',
IN_PROGRESS: '拣货中',
AWAITING_REVIEW: '待复核',
COMPLETED: '已完成',
CANCELLED: '已取消',
}
const inboundStatusLabels: Record<string, string> = {
WAIT_MEASURE: '待测量',
MEASURING: '测量中',
MEASURE_PENDING: '待测量审核',
MEASURE_REJECTED: '测量已驳回',
WAIT_PRICING: '待计费',
FEE_CONFIRMING: '费用处理中',
INBOUNDING: '入库处理中',
INBOUNDED: '已入库',
VOIDED: '已作废',
}
const loadingPlanStatusLabels: Record<string, string> = {
WAIT_LOADING: '等待装柜',
EDITING: '编辑中',
PRELOADED: '已预装',
CONFIRMED: '已确认',
LOADING: '装柜中',
COMPLETED: '已完成',
CANCELLED: '已取消',
}
const moduleLabels: Record<string, string> = {
INBOUND: '入库作业',
LOADING_PLAN: '装柜计划',
PDA_PICK: 'PDA 拣货',
LOADING_EVIDENCE: '装柜证据',
OCEAN_BOOKING: '海运订舱',
TRUCK_APPOINTMENT: '拖车预约',
OUTBOUND_DISPATCH: '国内出仓',
}
const actionLabels: Record<string, string> = {
CREATE: '创建',
UPDATE: '更新',
SUBMIT: '提交',
APPROVE: '审核通过',
REJECT: '审核驳回',
CONFIRM: '确认',
RELEASE: '放舱',
FAIL: '标记失败',
CANCEL: '取消',
CHANGE_REQUEST: '申请改船',
REQUEST_CHANGE: '申请改船',
CONFIRM_CHANGE: '确认改船',
FAIL_CHANGE: '驳回改船',
CONFIRM_DISPATCH: '确认出仓',
REVERSE_DISPATCH: '撤销出仓',
UPSERT_EVIDENCE: '登记装柜证据',
VOID_EVIDENCE: '作废装柜证据',
START: '开始作业',
COMPLETE: '完成作业',
}
function mapped(value: string | null | undefined, labels: Record<string, string>, empty: string) {
if (!value) return empty
return labels[value] || '未知状态'
}
export function outboundDispatchStatusLabel(value: string | null | undefined) {
return mapped(value, dispatchStatusLabels, '待确认出仓')
}
export function oceanBookingStatusLabel(value: string | null | undefined) {
return sharedOceanBookingStatusLabel(value, '未关联')
}
export function truckStatusLabel(value: string | null | undefined) {
return mapped(value, truckAppointmentStatusLabels as Record<string, string>, '未关联')
}
export function pickTaskStatusLabel(value: string | null | undefined) {
return mapped(value, pickTaskStatusLabels, '未关联')
}
export function timelineModuleLabel(value: string | null | undefined) {
if (!value) return '业务流程'
return moduleLabels[value] || '其他业务流程'
}
export function timelineActionLabel(value: string | null | undefined) {
if (!value) return '状态更新'
return actionLabels[value] || '状态更新'
}
export function timelineStatusLabel(module: string | null | undefined, value: string | null | undefined) {
if (!value) return '—'
if (module === 'OUTBOUND_DISPATCH') return outboundDispatchStatusLabel(value)
if (module === 'OCEAN_BOOKING') return oceanBookingStatusLabel(value)
if (module === 'TRUCK_APPOINTMENT') return truckStatusLabel(value)
if (module === 'PDA_PICK') return pickTaskStatusLabel(value)
if (module === 'INBOUND') return mapped(value, inboundStatusLabels, '—')
if (module === 'LOADING_PLAN') return mapped(value, loadingPlanStatusLabels, '—')
return '未知状态'
}