This commit is contained in:
2026-08-06 13:56:04 +08:00
parent 6736893128
commit a813426588
35 changed files with 2822 additions and 489 deletions
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getPlatformUnifiedOrder, listPlatformUnifiedOrders } from './unifiedOrders'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
describe('unified orders API contract', () => {
beforeEach(() => vi.mocked(http.get).mockReset())
it('uses the C02 platform orders endpoint and contract filters', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: '2', size: '20', total: '0', pages: '0' } })
await expect(listPlatformUnifiedOrders({
keyword: 'CC', orderNo: 'CC-1', mainStatus: 'WAIT_SPLIT', customerId: '30001',
receivingWarehouseId: '11', destinationWarehouseId: '22', page: 2, size: 20,
})).resolves.toMatchObject({ page: 2, size: 20, total: 0, pages: 0 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders', {
params: {
keyword: 'CC', orderNo: 'CC-1', mainStatus: 'WAIT_SPLIT', customerId: '30001',
receivingWarehouseId: '11', destinationWarehouseId: '22', page: 2, size: 20,
},
})
})
it('normalizes C02 row IDs and keeps inboundId for measurement routing', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', measurement: null, preload: null, loading: null, delivery: null, transport: null }],
page: 1, size: 20, total: 1, pages: 1,
} })
await expect(listPlatformUnifiedOrders({ page: 1, size: 20 })).resolves.toMatchObject({
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3 }],
})
})
it('loads the C02 detail shape and passes tenantId only on the detail boundary', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
summary: { orderId: '90001', tenantId: '208', mainStatus: 'WAIT_SPLIT' },
order: { order: {}, cargoes: [], parties: [], internalNotes: [], statusHistory: [] },
timeline: [],
} })
await expect(getPlatformUnifiedOrder('208', '90001')).resolves.toMatchObject({
summary: { orderId: '90001' }, order: { internalNotes: [] }, timeline: [],
})
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/90001', { params: { tenantId: '208' } })
})
it('does not expose a guessed generic action endpoint', async () => {
expect(http).not.toHaveProperty('post')
})
})
@@ -0,0 +1,264 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export const unifiedOrdersApiPath = '/platform/v1/orders'
export type UnifiedOrderMainStatus =
| 'PENDING_REVIEW'
| 'WAIT_MEASURE'
| 'INBOUNDED'
| 'WAIT_SPLIT'
| 'LOADING'
| 'LOADED'
| 'DELIVERED'
export const unifiedOrderStatusOptions: Array<{ value: UnifiedOrderMainStatus; label: string }> = [
{ value: 'PENDING_REVIEW', label: '待审核' },
{ value: 'WAIT_MEASURE', label: '待测量数据' },
{ value: 'INBOUNDED', label: '已入库' },
{ value: 'WAIT_SPLIT', label: '待分柜' },
{ value: 'LOADING', label: '装柜中' },
{ value: 'LOADED', label: '装柜完成' },
{ value: 'DELIVERED', label: '派送完成' },
]
export interface UnifiedOrderMeasurementSummary {
reviewStatus: string | null
actualGrossWeightKg: number | null
actualVolumeCbm: number | null
chargeableWeightKg: number | null
completedAt: string | null
}
export interface UnifiedOrderPreloadSummary {
status: string | null
version: number | null
itemCount: number | null
updatedAt: string | null
}
export interface UnifiedOrderLoadingSummary {
status: string | null
loadingVersion: number | null
pickingVersion: number | null
expectedCount: number | null
pickedCount: number | null
evidenceCount: number | null
sealNo: string | null
updatedAt: string | null
}
export interface UnifiedOrderDeliverySummary {
status: string | null
completedAt: string | null
trackingNo: string | null
}
export interface UnifiedOrderTransportSummary {
loadingStatus: string | null
containerNo: string | null
cabinetNo: string | null
vesselName: string | null
voyageNo: string | null
sailingAt: string | null
portArrivalAt: string | null
}
export interface UnifiedOrderResource {
orderId: number | string
tenantId: number | string
orderNo: string | null
mainStatus: UnifiedOrderMainStatus
nextAction: string | null
internalOrderStatus: string | null
customerId: number | string | null
customerName: string | null
channelName: string | null
receivingWarehouseId: number | string | null
receivingWarehouseName: string | null
destinationWarehouseId: number | string | null
destinationWarehouseName: string | null
inboundId: number | string | null
inboundVersion: number | null
inboundCode: string | null
expectedShipDate: string | null
totalPackageCount: number | null
measurement: UnifiedOrderMeasurementSummary | null
preload: UnifiedOrderPreloadSummary | null
loading: UnifiedOrderLoadingSummary | null
delivery: UnifiedOrderDeliverySummary | null
transport: UnifiedOrderTransportSummary | null
createdAt: string | null
updatedAt: string | null
}
export interface UnifiedOrderTimelineEvent {
source: string | null
eventType: string | null
mainStatus: UnifiedOrderMainStatus | null
action: string | null
remark: string | null
operatorId: number | string | null
operatorName: string | null
occurredAt: string | null
}
export interface UnifiedOrderDetailOrderSnapshot {
id: number | string | null
tenantId: number | string | null
orderNo: string | null
customerId: number | string | null
customerName: string | null
transportMode: string | null
destinationCountryCode: string | null
destinationWarehouseId: number | string | null
destinationWarehouseName: string | null
expectedShipDate: string | null
receivingWarehouseId: number | string | null
receivingWarehouseName: string | null
channelName: string | null
createdAt: string | null
updatedAt: string | null
}
export interface UnifiedOrderDetailOrderResource {
order: UnifiedOrderDetailOrderSnapshot | null
cargoes: Array<Record<string, unknown>>
parties: Array<Record<string, unknown>>
internalNotes: UnifiedOrderInternalNoteResource[]
statusHistory: UnifiedOrderStatusHistoryResource[]
}
export interface UnifiedOrderInternalNoteResource {
id: number | string
content: string
createdBy: number | string | null
createdAt: string | null
}
export interface UnifiedOrderStatusHistoryResource {
id: number | string
fromStatus: string | null
toStatus: string | null
action: string | null
reason: string | null
operatorId: number | string | null
operatorName: string | null
occurredAt: string | null
}
export interface UnifiedOrderDetailResource {
summary: UnifiedOrderResource
order: UnifiedOrderDetailOrderResource
timeline: UnifiedOrderTimelineEvent[]
}
export interface UnifiedOrderPageResource {
records: UnifiedOrderResource[]
page: number
size: number
total: number
pages: number
}
export interface UnifiedOrderQuery {
tenantId?: string
keyword?: string
orderNo?: string
mainStatus?: UnifiedOrderMainStatus
customerId?: string
receivingWarehouseId?: string
destinationWarehouseId?: string
createdFrom?: string
createdTo?: string
page: number
size: number
}
type PagePayload = Omit<UnifiedOrderPageResource, 'page' | 'size' | 'total' | 'pages'> & {
page: number | string
size: number | string
total: number | string
pages: number | string
}
function integer(value: number | string | null | undefined, fallback: number) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : fallback
}
function nullableInteger(value: number | string | null | undefined) {
if (value === null || value === undefined || value === '') return null
return integer(value, 0)
}
function numberValue(value: number | string | null | undefined) {
if (value === null || value === undefined || value === '') return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function id(value: number | string | null | undefined) {
return value === null || value === undefined || value === '' ? null : String(value)
}
function queryParams(query: UnifiedOrderQuery) {
return Object.fromEntries(Object.entries(query).filter(([, value]) => value !== undefined && value !== ''))
}
function normalizeRow(raw: UnifiedOrderResource): UnifiedOrderResource {
return {
...raw,
orderId: String(raw.orderId),
tenantId: String(raw.tenantId),
customerId: id(raw.customerId),
receivingWarehouseId: id(raw.receivingWarehouseId),
destinationWarehouseId: id(raw.destinationWarehouseId),
inboundId: id(raw.inboundId),
inboundVersion: nullableInteger(raw.inboundVersion),
totalPackageCount: nullableInteger(raw.totalPackageCount),
measurement: raw.measurement ? {
...raw.measurement,
actualGrossWeightKg: numberValue(raw.measurement.actualGrossWeightKg),
actualVolumeCbm: numberValue(raw.measurement.actualVolumeCbm),
chargeableWeightKg: numberValue(raw.measurement.chargeableWeightKg),
} : null,
preload: raw.preload ? { ...raw.preload, version: nullableInteger(raw.preload.version), itemCount: nullableInteger(raw.preload.itemCount) } : null,
loading: raw.loading ? {
...raw.loading,
loadingVersion: nullableInteger(raw.loading.loadingVersion),
pickingVersion: nullableInteger(raw.loading.pickingVersion),
expectedCount: nullableInteger(raw.loading.expectedCount),
pickedCount: nullableInteger(raw.loading.pickedCount),
evidenceCount: nullableInteger(raw.loading.evidenceCount),
} : null,
transport: raw.transport ? { ...raw.transport } : null,
}
}
export async function listPlatformUnifiedOrders(query: UnifiedOrderQuery) {
const response = await http.get<ApiEnvelope<PagePayload>, ApiEnvelope<PagePayload>>(unifiedOrdersApiPath, {
params: queryParams(query),
})
const result = unwrapApiResponse(response)
return {
...result,
records: (result.records || []).map(normalizeRow),
page: integer(result.page, query.page),
size: integer(result.size, query.size),
total: integer(result.total, 0),
pages: integer(result.pages, 0),
} satisfies UnifiedOrderPageResource
}
export async function getPlatformUnifiedOrder(tenantId: string, orderId: string) {
const response = await http.get<ApiEnvelope<UnifiedOrderDetailResource>, ApiEnvelope<UnifiedOrderDetailResource>>(
`${unifiedOrdersApiPath}/${orderId}`,
{ params: { tenantId } },
)
const result = unwrapApiResponse(response)
return { ...result, summary: normalizeRow(result.summary) }
}
export function unifiedOrderStatusLabel(status: UnifiedOrderMainStatus) {
return unifiedOrderStatusOptions.find((item) => item.value === status)?.label || status
}
@@ -0,0 +1,51 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { unifiedOrderStatusOptions } from '../api/unifiedOrders'
const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/pages/UnifiedOrdersView.vue'), 'utf8')
describe('UnifiedOrdersView contract', () => {
it('only exposes the seven product main statuses', () => {
expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([
'待审核', '待测量数据', '已入库', '待分柜', '装柜中', '装柜完成', '派送完成',
])
for (const legacyLabel of ['待客户确认', '差异处理中', '派送中', '进入分柜']) {
expect(source).not.toContain(legacyLabel)
}
})
it('covers real list states and keeps operation column first and left-fixed', () => {
expect(source).toContain("{ key: 'actions', label: '操作', width: 180, fixed: 'left'")
expect(source).toContain('title="订单列表暂时无法加载"')
expect(source).toContain('empty-text="暂无符合条件的订单"')
expect(source).toContain(':loading="loading"')
expect(source).toContain('v-model:page="page"')
expect(source).toContain('v-model:page-size="pageSize"')
})
it('keeps business actions as explicit C01/C03/C04/C06 boundaries', () => {
expect(source).toContain('nextActionBoundary')
expect(source).toContain('待接入:${label}')
expect(source).toContain('CONFIRM_PRELOAD')
expect(source).toContain("can('platform:firstleg:inbound:view')")
expect(source).toContain('openMeasurement')
expect(source).not.toContain('updateMainStatus')
expect(source).not.toContain('人工进入分柜')
expect(source).not.toContain('performPlatformUnifiedOrderAction')
expect(source).not.toContain('/actions/')
})
it('provides unified detail, history and internal note states', () => {
expect(source).toContain('getPlatformUnifiedOrder')
expect(source).toContain('aria-label="状态历史"')
expect(source).toContain('aria-label="内部备注"')
expect(source).toContain('订单详情暂时无法加载')
})
it('exposes the C06 workflow entry only for inbound-backed downstream states', () => {
expect(source).toContain("name: 'unified-order-workflow'")
expect(source).toContain("['WAIT_SPLIT', 'LOADING', 'LOADED'].includes(row.mainStatus)")
expect(source).toContain('后半程作业')
})
})
@@ -0,0 +1,327 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ClipboardList, Eye, RefreshCw } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
listPlatformUnifiedOrders,
unifiedOrderStatusOptions,
type UnifiedOrderDetailResource,
type UnifiedOrderMainStatus,
type UnifiedOrderResource,
} from '@/modules/unified-orders/api/unifiedOrders'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
type FilterModel = QueryModel & {
tenantId: string
keyword: string
mainStatus: UnifiedOrderMainStatus | ''
createdRange: string[]
}
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const records = ref<UnifiedOrderResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const customerLoading = ref(false)
const customerLoadError = ref<ApiErrorDetails | null>(null)
const detail = ref<UnifiedOrderDetailResource | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const filters = ref<FilterModel>({ tenantId: '', keyword: '', mainStatus: '', createdRange: [] })
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const queryFields = computed<QueryField[]>(() => [
{
key: 'tenantId',
type: 'select',
width: 220,
placeholder: customerLoadError.value ? '客户列表加载失败' : customerLoading.value ? '正在加载客户' : '全部客户',
options: customers.value.map((customer) => ({
value: String(customer.id),
label: `${customer.legalName?.trim() || customer.tenantName}(${customer.tenantCode || '无客户编码'})`,
})),
props: { 'aria-label': '客户', filterable: true, loading: customerLoading.value, disabled: Boolean(customerLoadError.value) },
},
{
key: 'mainStatus',
type: 'select',
width: 160,
placeholder: '全部主状态',
options: unifiedOrderStatusOptions,
props: { 'aria-label': '订单主状态' },
},
{ key: 'keyword', type: 'input', width: 260, placeholder: '订单号、入仓码或客户名称', props: { 'aria-label': '订单关键词' } },
{ key: 'createdRange', type: 'daterange', width: 300, placeholder: '创建日期范围', props: { 'aria-label': '创建日期范围' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单信息', minWidth: 220, slotName: 'order' },
{ key: 'mainStatus', label: '主状态', width: 132, slotName: 'mainStatus' },
{ key: 'customer', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'receivingWarehouseName', label: '平台收货仓', minWidth: 160, slotName: 'receivingWarehouseName' },
{ key: 'destinationWarehouseName', label: '目的仓', minWidth: 160, slotName: 'destinationWarehouseName' },
{ key: 'transport', label: '运输/装柜', minWidth: 180, slotName: 'transport' },
{ key: 'cargo', label: '货物数据', minWidth: 180, slotName: 'cargo' },
{ key: 'expectedShipDate', label: '预计出运', minWidth: 150, slotName: 'expectedShipDate' },
{ key: 'updatedAt', label: '最近更新', minWidth: 150, slotName: 'updatedAt' },
]
function can(permission: string) {
return access.can(permission)
}
function customerName(row: UnifiedOrderResource) {
return row.customerName?.trim() || customers.value.find((item) => String(item.id) === String(row.tenantId))?.tenantName || `客户 ${row.tenantId}`
}
function formatDate(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 16) : '—'
}
function formatNumber(value: number | null | undefined, digits = 2) {
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
function statusLabel(value: UnifiedOrderMainStatus | string | null) {
return unifiedOrderStatusOptions.find((item) => item.value === value)?.label || value || '状态更新'
}
const nextActionLabels: Record<string, string> = {
APPROVE_ORDER: '审核通过',
CONFIRM_MEASUREMENT: '完成测量',
SMART_SPLIT: '智能分柜',
CONFIRM_PRELOAD: '确认预装清单',
COMPLETE_LOADING: '确认装柜完成',
COMPLETE_DELIVERY: '确认派送完成',
}
function nextActionBoundary(row: UnifiedOrderResource) {
const label = row.nextAction ? nextActionLabels[row.nextAction] || row.nextAction : ''
return label ? `待接入:${label}` : ''
}
async function loadCustomers() {
customerLoading.value = true
customerLoadError.value = null
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200 })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} catch (error) {
customers.value = []
customerLoadError.value = getApiErrorDetails(error, '客户列表加载失败,暂时无法按客户筛选')
} finally {
customerLoading.value = false
}
}
async function loadOrders() {
loading.value = true
loadError.value = null
try {
const range = filters.value.createdRange
const result = await listPlatformUnifiedOrders({
tenantId: filters.value.tenantId || undefined,
keyword: filters.value.keyword.trim() || undefined,
mainStatus: filters.value.mainStatus || undefined,
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
page: page.value,
size: pageSize.value,
})
records.value = result.records
total.value = result.total
} catch (error) {
records.value = []
total.value = 0
loadError.value = getApiErrorDetails(error, '统一订单列表加载失败,请检查 C02 统一查询接口和订单权限')
} finally {
loading.value = false
}
}
function search() {
if (page.value !== 1) {
page.value = 1
return
}
void loadOrders()
}
function reset() {
filters.value = { tenantId: '', keyword: '', mainStatus: '', createdRange: [] }
page.value = 1
void loadOrders()
}
function openDetail(row: UnifiedOrderResource) {
void router.push({ name: 'unified-order-detail', params: { orderId: String(row.orderId) }, query: { tenantId: String(row.tenantId) } })
}
function openMeasurement(row: UnifiedOrderResource) {
if (!row.inboundId || !can('platform:firstleg:inbound:view')) return
void router.push({ name: 'inbound-order-detail', params: { inboundId: String(row.inboundId) }, query: { tenantId: String(row.tenantId) } })
}
function workflowEligible(row: UnifiedOrderResource) {
return Boolean(row.inboundId && ['WAIT_SPLIT', 'LOADING', 'LOADED'].includes(row.mainStatus))
}
function openWorkflow(row: UnifiedOrderResource) {
if (!workflowEligible(row) || !can('platform:firstleg:inbound:view')) return
void router.push({
name: 'unified-order-workflow',
params: { orderId: String(row.orderId) },
query: { tenantId: String(row.tenantId) },
})
}
function closeDetail() {
detailOpen.value = false
detail.value = null
detailError.value = null
void router.push({ name: 'unified-orders' })
}
async function loadDetail() {
if (!orderId.value) return
const tenantId = typeof route.query.tenantId === 'string' ? route.query.tenantId : ''
if (!tenantId) {
detailError.value = { message: '订单详情缺少客户标识,请从订单列表重新打开' }
detailOpen.value = true
return
}
detailLoading.value = true
detailError.value = null
detailOpen.value = true
try {
detail.value = await getPlatformUnifiedOrder(tenantId, orderId.value)
} catch (error) {
detail.value = null
detailError.value = getApiErrorDetails(error, '统一订单详情加载失败,请检查 C02 统一详情接口和订单权限')
} finally {
detailLoading.value = false
}
}
watch([page, pageSize], () => { void loadOrders() })
watch(orderId, (value) => {
if (value) void loadDetail()
else detailOpen.value = false
}, { immediate: true })
onMounted(() => {
void loadCustomers()
void loadOrders()
})
</script>
<template>
<section class="unified-orders-page">
<div class="page-intro">
<div>
<h1>订单列表</h1>
<p>平台围绕同一订单主线跟进审核、测量、分柜、装柜和派送。</p>
</div>
<el-button :loading="loading" @click="loadOrders"><RefreshCw :size="16" />刷新</el-button>
</div>
<QueryToolbar v-model="filters" ariaLabel="订单列表筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="reset" />
<LoadErrorState v-if="loadError" class="orders-load-error" title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<div v-else class="table-card">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表">
<template #actions="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
<el-button v-if="(row as UnifiedOrderResource).mainStatus === 'WAIT_MEASURE' && (row as UnifiedOrderResource).inboundId && can('platform:firstleg:inbound:view')" link type="primary" @click="openMeasurement(row as UnifiedOrderResource)">测量作业</el-button>
<el-button v-if="workflowEligible(row as UnifiedOrderResource) && can('platform:firstleg:inbound:view')" link type="primary" @click="openWorkflow(row as UnifiedOrderResource)"><ClipboardList :size="15" />后半程作业</el-button>
<span v-if="nextActionBoundary(row as UnifiedOrderResource)" class="action-boundary">{{ nextActionBoundary(row as UnifiedOrderResource) }}</span>
</div>
</template>
<template #order="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).orderNo || `订单 ${(row as UnifiedOrderResource).orderId}` }}</strong><span v-if="(row as UnifiedOrderResource).inboundCode">入仓码 {{ (row as UnifiedOrderResource).inboundCode }}</span></div></template>
<template #mainStatus="{ row }"><StatusBadge :status="statusLabel((row as UnifiedOrderResource).mainStatus)" /></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong><span>客户 ID {{ (row as UnifiedOrderResource).tenantId }}</span></div></template>
<template #receivingWarehouseName="{ row }">{{ (row as UnifiedOrderResource).receivingWarehouseName || '—' }}</template>
<template #destinationWarehouseName="{ row }">{{ (row as UnifiedOrderResource).destinationWarehouseName || '—' }}</template>
<template #transport="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).transport?.containerNo || (row as UnifiedOrderResource).transport?.loadingStatus || '—' }}</strong><span v-if="(row as UnifiedOrderResource).transport?.vesselName">{{ (row as UnifiedOrderResource).transport?.vesselName }} · {{ (row as UnifiedOrderResource).transport?.voyageNo || '—' }}</span></div></template>
<template #cargo="{ row }"><span>{{ (row as UnifiedOrderResource).totalPackageCount ?? '—' }} 箱</span><span v-if="(row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== null && (row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualGrossWeightKg) }} kg</span><span v-if="(row as UnifiedOrderResource).measurement?.actualVolumeCbm !== null && (row as UnifiedOrderResource).measurement?.actualVolumeCbm !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualVolumeCbm, 3) }} m³</span></template>
<template #expectedShipDate="{ row }">{{ formatDate((row as UnifiedOrderResource).expectedShipDate) }}</template>
<template #updatedAt="{ row }">{{ formatDate((row as UnifiedOrderResource).updatedAt) }}</template>
</DataTable>
</div>
<AppDrawer v-model="detailOpen" :title="detail ? `订单详情 · ${detail.summary.orderNo || detail.summary.orderId}` : '订单详情'" size="min(760px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
<LoadErrorState v-if="detailError" title="订单详情暂时无法加载" :message="detailError.message" :loading="detailLoading" @retry="loadDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<div v-else-if="detail" class="detail-content">
<section class="detail-summary" aria-label="订单概览">
<div class="detail-summary__header"><div><span class="eyebrow">订单主状态</span><h2>{{ detail.summary.orderNo || `订单 ${detail.summary.orderId}` }}</h2></div><div class="detail-summary__actions"><StatusBadge :status="statusLabel(detail.summary.mainStatus)" /><el-button v-if="workflowEligible(detail.summary) && can('platform:firstleg:inbound:view')" type="primary" plain @click="openWorkflow(detail.summary)"><ClipboardList :size="15" />后半程作业</el-button></div></div>
<dl class="detail-grid"><div><dt>客户</dt><dd>{{ customerName(detail.summary) }}</dd></div><div><dt>入仓码</dt><dd>{{ detail.summary.inboundCode || '—' }}</dd></div><div><dt>平台收货仓</dt><dd>{{ detail.summary.receivingWarehouseName || '—' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.summary.destinationWarehouseName || '—' }}</dd></div><div><dt>下一步</dt><dd>{{ nextActionBoundary(detail.summary) || '—' }}</dd></div><div><dt>最近更新</dt><dd>{{ formatDate(detail.summary.updatedAt) }}</dd></div></dl>
</section>
<section class="detail-section" aria-label="状态历史"><h3>状态历史</h3><el-empty v-if="detail.timeline.length === 0" description="暂无状态历史" :image-size="56" /><ol v-else class="history-list"><li v-for="(item, index) in detail.timeline" :key="`${item.source || 'event'}-${item.occurredAt || index}`"><StatusBadge :status="statusLabel(item.mainStatus)" /><div><strong>{{ item.action || item.eventType || '状态更新' }}</strong><p>{{ item.operatorName || '系统' }} · {{ formatDate(item.occurredAt) }}</p><p v-if="item.remark">{{ item.remark }}</p></div></li></ol></section>
<section class="detail-section" aria-label="内部备注"><h3>内部备注</h3><el-empty v-if="detail.order.internalNotes.length === 0" description="暂无内部备注" :image-size="56" /><ul v-else class="notes-list"><li v-for="item in detail.order.internalNotes" :key="String(item.id)"><p>{{ item.content }}</p><span>操作人 {{ item.createdBy || '系统' }} · {{ formatDate(item.createdAt) }}</span></li></ul></section>
</div>
</AppDrawer>
</section>
</template>
<style scoped>
.unified-orders-page { min-width: 0; }
.page-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.page-intro h1 { margin: 0; color: var(--cc-text); font-size: 22px; line-height: 1.35; }
.page-intro p { margin: 5px 0 0; color: var(--cc-muted); font-size: 13px; line-height: 1.6; }
.page-intro :deep(.el-button) { flex: 0 0 auto; }
.orders-load-error { margin-top: 12px; }
.table-card { margin-top: 16px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.row-actions :deep(.el-button) { margin-left: 0; }
.primary-cell { display: grid; min-width: 0; gap: 4px; }
.primary-cell strong { overflow-wrap: anywhere; }
.primary-cell span { color: var(--cc-muted); font-size: 12px; overflow-wrap: anywhere; }
.detail-content { display: grid; gap: 22px; min-width: 0; }
.detail-summary, .detail-section { padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.detail-summary__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.detail-summary__actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.eyebrow { color: var(--cc-muted); font-size: 12px; }
.detail-summary h2 { margin: 4px 0 0; color: var(--cc-text); font-size: 18px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 20px; margin: 20px 0 0; }
.detail-grid div { min-width: 0; }
.detail-grid dt { color: var(--cc-muted); font-size: 12px; }
.detail-grid dd { margin: 4px 0 0; color: var(--cc-text); overflow-wrap: anywhere; }
.detail-section h3 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
.history-list, .notes-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.history-list li { display: flex; align-items: flex-start; gap: 10px; padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.history-list li:last-child, .notes-list li:last-child { padding-bottom: 0; border-bottom: 0; }
.history-list strong { font-size: 13px; }
.history-list p, .notes-list p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.notes-list li { padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.notes-list li > p { margin: 0; color: var(--cc-text); font-size: 13px; }
.notes-list span { display: block; margin-top: 5px; color: var(--cc-muted); font-size: 12px; }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .detail-grid { grid-template-columns: 1fr; } .detail-summary__header { flex-direction: column; } .detail-summary__actions { justify-content: flex-start; } .row-actions { flex-wrap: wrap; } }
</style>
+37
View File
@@ -0,0 +1,37 @@
import type { RouteRecordRaw } from 'vue-router'
export const unifiedOrderRoutes = [
{
path: 'orders',
name: 'unified-orders',
component: () => import('./pages/UnifiedOrdersView.vue'),
meta: {
title: '订单列表',
description: '围绕同一订单主线跟进审核、测量、分柜、装柜和派送',
permission: 'platform:order:list',
permissionAnyOf: ['platform:firstleg:inbound:list'],
},
},
{
path: 'orders/:orderId/workflow',
name: 'unified-order-workflow',
component: () => import('./workflows/pages/UnifiedOrderWorkflowView.vue'),
meta: {
title: '订单后半程作业',
description: '处理分柜、预装、装柜和派送作业',
permission: 'platform:firstleg:inbound:view',
permissionAnyOf: ['platform:firstleg:inbound:list', 'platform:order:list'],
},
},
{
path: 'orders/:orderId',
name: 'unified-order-detail',
component: () => import('./pages/UnifiedOrdersView.vue'),
meta: {
title: '订单详情',
description: '查看订单主状态、状态历史和平台备注',
permission: 'platform:order:view',
permissionAnyOf: ['platform:order:list', 'platform:firstleg:inbound:view'],
},
},
] satisfies RouteRecordRaw[]
@@ -0,0 +1,16 @@
# C06 Admin 后半程作业
本目录只承载统一订单详情的后半程作业接入点,供 C05 统一订单列表在详情抽屉或作业页中装配。
真实接口均使用 `/api/platform/v1/inbounds/{inboundId}`:
- `POST /split/smart`:智能分柜/保存预装清单
- `GET /preload`、`POST /preload/confirming`、`POST /preload/confirm`:预装清单查询与确认
- `GET/POST /loading/picking`、`/loading/picking/start`、`/loading/picking/complete`:拣货
- `GET/POST /loading/evidence`:四步照片与封签证据元数据
- `POST /loading/complete`:完成装柜
- `POST /delivery/complete`:确认派送完成
所有写请求带 `Idempotency-Key` 和服务端要求的 `expectedVersion`。`loading/complete` 与 `delivery/complete` 使用入库单版本;拣货使用拣货任务版本。当前没有 File 服务上传接口,证据组件不会把本地路径伪装成已上传文件。
权限码、状态流转和请求字段以 Firstleg Controller/DTO 为准;本模块不新增“进入分柜”操作,也不定义通用 `/actions/{ACTION}` 接口。
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
completeDelivery,
completeLoading,
confirmingPreload,
getPicking,
getPreload,
smartSplit,
} from './api'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), post: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
describe('unified order workflow API contract', () => {
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.post).mockReset()
})
it('uses the C03 preload endpoints and expected version', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { id: 1, inboundId: 8, tenantId: 2, status: 'PENDING', version: 3, items: [] } })
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'WAIT_SPLIT', preloadPlan: null } })
await getPreload('2', '8')
await smartSplit('2', '8', { expectedVersion: 3, items: [] })
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/8/preload', { params: { tenantId: '2' } })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/inbounds/8/split/smart', { expectedVersion: 3, items: [] },
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
)
})
it('uses C04 picking and loading paths without a generic action endpoint', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { id: 1, inboundId: 8, status: 'PICKING', version: 4 } })
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'LOADED', evidences: null } })
await getPicking('2', '8')
await completeLoading('2', '8', { expectedVersion: 7 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/8/loading/picking', { params: { tenantId: '2' } })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/inbounds/8/loading/complete', { expectedVersion: 7 },
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
)
expect(vi.mocked(http.post).mock.calls.some(([url]) => String(url).includes('/actions/'))).toBe(false)
})
it('uses the inbound version for delivery completion', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'DELIVERED', evidences: null } })
await completeDelivery('2', '8', { expectedVersion: 9, remark: '已确认派送' })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/inbounds/8/delivery/complete', { expectedVersion: 9, remark: '已确认派送' },
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
)
})
it('does not swallow non-success responses', async () => {
vi.mocked(http.get).mockRejectedValue(new Error('无权限'))
await expect(getPreload('2', '8')).rejects.toThrow('无权限')
})
it('supports the intermediate confirming preload action', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'WAIT_SPLIT', preloadPlan: null } })
await confirmingPreload('2', '8', { expectedVersion: 1 })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/inbounds/8/preload/confirming', { expectedVersion: 1 },
expect.objectContaining({ params: { tenantId: '2' } }),
)
})
})
+229
View File
@@ -0,0 +1,229 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
const inboundBasePath = '/platform/v1/inbounds'
export type PreloadStatus = 'PENDING' | 'CONFIRMING' | 'CONFIRMED' | string
export type EvidenceType = 'PHOTO_STEP_1' | 'PHOTO_STEP_2' | 'PHOTO_STEP_3' | 'PHOTO_STEP_4' | 'SEAL'
export interface PreloadItem {
containerCode: string | null
cargoReference: string | null
packageCount: number | null
volumeCbm: number | null
remark: string | null
}
export interface PreloadPlan {
id: number | string
inboundId: number | string
tenantId: number | string
status: PreloadStatus
version: number
items: PreloadItem[]
remark: string | null
createdAt: string | null
updatedAt: string | null
}
export interface SplitActionResponse {
mainStatus: string
preloadPlan: PreloadPlan | null
}
export interface PickingTask {
id: number | string
inboundId: number | string
status: string
version: number
expectedCount: number | null
pickedCount: number | null
remark: string | null
}
export interface Evidence {
id: number | string
inboundId: number | string
tenantId: number | string
evidenceType: EvidenceType
fileId: number | string | null
fileName: string | null
sealNo: string | null
remark: string | null
version: number
createdAt: string | null
updatedAt: string | null
}
export interface EvidenceList {
inboundId: number | string
evidences: Evidence[]
}
export interface LoadingActionResponse {
mainStatus: string
evidences: EvidenceList | null
}
export interface PickingRequest {
expectedVersion: number
expectedCount: number
pickedCount: number
remark?: string
}
export interface SmartSplitRequest {
expectedVersion: number
items: PreloadItem[]
remark?: string
}
export interface ConfirmPreloadRequest {
expectedVersion: number
}
export interface UpsertEvidenceRequest {
expectedVersion: number
evidenceType: EvidenceType
fileId?: number | string
fileName?: string
sealNo?: string
remark?: string
}
export interface CompleteLoadingRequest {
expectedVersion: number
remark?: string
}
export interface CompleteDeliveryRequest {
expectedVersion: number
remark?: string
}
function query(tenantId: string | number) {
return { tenantId: String(tenantId) }
}
function idempotencyKey(operation: string, inboundId: string | number) {
return `admin-${operation}-${inboundId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function writeOptions(operation: string, inboundId: string | number, tenantId: string | number) {
return { params: query(tenantId), headers: { 'Idempotency-Key': idempotencyKey(operation, inboundId) } }
}
export async function getPreload(tenantId: string | number, inboundId: string | number) {
const response = await http.get<ApiEnvelope<PreloadPlan>, ApiEnvelope<PreloadPlan>>(
`${inboundBasePath}/${inboundId}/preload`, { params: query(tenantId) },
)
return unwrapApiResponse(response)
}
export async function smartSplit(
tenantId: string | number,
inboundId: string | number,
request: SmartSplitRequest,
) {
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
`${inboundBasePath}/${inboundId}/split/smart`, request, writeOptions('smart-split', inboundId, tenantId),
)
return unwrapApiResponse(response)
}
export async function confirmingPreload(
tenantId: string | number,
inboundId: string | number,
request: ConfirmPreloadRequest,
) {
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
`${inboundBasePath}/${inboundId}/preload/confirming`, request,
writeOptions('preload-confirming', inboundId, tenantId),
)
return unwrapApiResponse(response)
}
export async function confirmPreload(
tenantId: string | number,
inboundId: string | number,
request: ConfirmPreloadRequest,
) {
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
`${inboundBasePath}/${inboundId}/preload/confirm`, request,
writeOptions('preload-confirm', inboundId, tenantId),
)
return unwrapApiResponse(response)
}
export async function getPicking(tenantId: string | number, inboundId: string | number) {
const response = await http.get<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
`${inboundBasePath}/${inboundId}/loading/picking`, { params: query(tenantId) },
)
return unwrapApiResponse(response)
}
export async function startPicking(
tenantId: string | number,
inboundId: string | number,
request: PickingRequest,
) {
const response = await http.post<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
`${inboundBasePath}/${inboundId}/loading/picking/start`, request,
writeOptions('picking-start', inboundId, tenantId),
)
return unwrapApiResponse(response)
}
export async function completePicking(
tenantId: string | number,
inboundId: string | number,
request: PickingRequest,
) {
const response = await http.post<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
`${inboundBasePath}/${inboundId}/loading/picking/complete`, request,
writeOptions('picking-complete', inboundId, tenantId),
)
return unwrapApiResponse(response)
}
export async function getEvidence(tenantId: string | number, inboundId: string | number) {
const response = await http.get<ApiEnvelope<EvidenceList>, ApiEnvelope<EvidenceList>>(
`${inboundBasePath}/${inboundId}/loading/evidence`, { params: query(tenantId) },
)
return unwrapApiResponse(response)
}
export async function upsertEvidence(
tenantId: string | number,
inboundId: string | number,
request: UpsertEvidenceRequest,
) {
const response = await http.post<ApiEnvelope<LoadingActionResponse>, ApiEnvelope<LoadingActionResponse>>(
`${inboundBasePath}/${inboundId}/loading/evidence`, request,
writeOptions(`evidence-${request.evidenceType}`, inboundId, tenantId),
)
return unwrapApiResponse(response)
}
export async function completeLoading(
tenantId: string | number,
inboundId: string | number,
request: CompleteLoadingRequest,
) {
const response = await http.post<ApiEnvelope<LoadingActionResponse>, ApiEnvelope<LoadingActionResponse>>(
`${inboundBasePath}/${inboundId}/loading/complete`, request,
writeOptions('loading-complete', inboundId, tenantId),
)
return unwrapApiResponse(response)
}
export async function completeDelivery(
tenantId: string | number,
inboundId: string | number,
request: CompleteDeliveryRequest,
) {
const response = await http.post<ApiEnvelope<LoadingActionResponse>, ApiEnvelope<LoadingActionResponse>>(
`${inboundBasePath}/${inboundId}/delivery/complete`, request,
writeOptions('delivery-complete', inboundId, tenantId),
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,388 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RefreshCw, Save, Check, Play, Camera, Truck } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError } from '@/shared/api/http'
import {
completeDelivery,
completeLoading,
completePicking,
confirmingPreload,
confirmPreload,
getEvidence,
getPicking,
getPreload,
smartSplit,
startPicking,
upsertEvidence,
type Evidence,
type EvidenceType,
type PickingTask,
type PreloadItem,
type PreloadPlan,
} from '@/modules/unified-orders/workflows/api'
import type { UnifiedOrderMainStatus } from '@/modules/unified-orders/api/unifiedOrders'
const props = withDefaults(defineProps<{
tenantId: string
inboundId: string | number
mainStatus: UnifiedOrderMainStatus
inboundVersion?: number | null
pickingVersion?: number | null
deliveryTrackingNo?: string | null
}>(), {
inboundVersion: null,
pickingVersion: null,
deliveryTrackingNo: null,
})
const emit = defineEmits<{ updated: [] }>()
const access = useAccessPreviewStore()
const preload = ref<PreloadPlan | null>(null)
const preloadItems = ref<PreloadItem[]>([])
const picking = ref<PickingTask | null>(null)
const evidences = ref<Evidence[]>([])
const deliveryRemark = ref('')
const loadingRemark = ref('')
const splitRemark = ref('')
const busy = ref('')
const loading = ref(false)
const error = ref('')
const notice = ref('')
const evidenceTypes: Array<{ type: EvidenceType; label: string }> = [
{ type: 'PHOTO_STEP_1', label: '装柜照片 1' },
{ type: 'PHOTO_STEP_2', label: '装柜照片 2' },
{ type: 'PHOTO_STEP_3', label: '装柜照片 3' },
{ type: 'PHOTO_STEP_4', label: '装柜照片 4' },
{ type: 'SEAL', label: '封签' },
]
const isSplit = computed(() => props.mainStatus === 'WAIT_SPLIT')
const isLoading = computed(() => props.mainStatus === 'LOADING')
const isLoaded = computed(() => props.mainStatus === 'LOADED')
const preloadVersion = computed(() => preload.value?.version ?? 0)
const pickingExpectedVersion = computed(() => picking.value?.version ?? props.pickingVersion ?? null)
const inboundExpectedVersion = computed(() => props.inboundVersion)
const canSplit = computed(() => access.can('firstleg:inbound:split'))
const canPreload = computed(() => access.can('firstleg:inbound:preload'))
const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'))
const canPicking = computed(() => access.can('platform:firstleg:inbound:picking:edit'))
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
const canEvidence = computed(() => access.can('platform:firstleg:inbound:loading:edit'))
const canEvidenceView = computed(() => access.can('platform:firstleg:inbound:loading:view'))
const canLoadingComplete = computed(() => access.can('platform:firstleg:inbound:loading:complete'))
const canDeliveryComplete = computed(() => access.can('platform:firstleg:inbound:delivery:complete'))
function itemTemplate(): PreloadItem {
return { containerCode: '', cargoReference: '', packageCount: null, volumeCbm: 0, remark: '' }
}
function addItem() {
preloadItems.value.push(itemTemplate())
}
function removeItem(index: number) {
preloadItems.value.splice(index, 1)
}
function evidence(type: EvidenceType) {
return evidences.value.find((item) => item.evidenceType === type)
}
function idempotentMessage(action: string) {
return busy.value === action ? '正在提交,请勿重复点击' : ''
}
async function run<T>(action: string, operation: () => Promise<T>, message: string) {
busy.value = action
error.value = ''
notice.value = ''
try {
const result = await operation()
notice.value = message
emit('updated')
return result
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '操作失败,请重试'
return null
} finally {
busy.value = ''
}
}
async function loadPreload() {
if (!canPreloadView.value || !props.inboundId) return
loading.value = true
error.value = ''
try {
preload.value = await getPreload(props.tenantId, props.inboundId)
preloadItems.value = preload.value.items?.map((item) => ({ ...item })) || []
} catch (cause) {
preload.value = null
preloadItems.value = []
const missing = cause instanceof ApiResponseError
&& (cause.status === 404 || cause.code === 'A0404' || cause.code === 'NOT_FOUND')
error.value = missing ? '' : cause instanceof Error ? cause.message : '预装清单加载失败,请重试'
} finally {
loading.value = false
}
}
async function loadLoading() {
loading.value = true
error.value = ''
const messages: string[] = []
if (canPickingView.value) {
try {
picking.value = await getPicking(props.tenantId, props.inboundId)
} catch (cause) {
picking.value = null
messages.push(cause instanceof Error ? cause.message : '拣货任务加载失败')
}
}
if (canEvidenceView.value) {
try {
evidences.value = (await getEvidence(props.tenantId, props.inboundId)).evidences || []
} catch (cause) {
evidences.value = []
messages.push(cause instanceof Error ? cause.message : '装柜证据加载失败')
}
}
error.value = messages.join(';')
loading.value = false
}
async function refresh() {
if (isSplit.value) await loadPreload()
if (isLoading.value) await loadLoading()
}
async function runSmartSplit() {
const result = await run('smart-split', () => smartSplit(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
items: preloadItems.value,
remark: splitRemark.value || undefined,
}), '智能分柜已提交')
if (result) {
preload.value = result.preloadPlan
preloadItems.value = result.preloadPlan?.items?.map((item) => ({ ...item })) || []
}
}
async function startPreloadConfirmation() {
const result = await run('preload-confirming', () => confirmingPreload(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
}), '预装清单已进入确认中')
if (result?.preloadPlan) preload.value = result.preloadPlan
}
async function finishPreloadConfirmation() {
const result = await run('preload-confirm', () => confirmPreload(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
}), '预装清单已确认,订单进入装柜中')
if (result?.preloadPlan) preload.value = result.preloadPlan
}
function pickingRequest(): { expectedVersion: number; expectedCount: number; pickedCount: number; remark?: string } | null {
if (pickingExpectedVersion.value === null) {
error.value = '拣货版本缺失,请刷新订单详情后重试'
return null
}
return {
expectedVersion: pickingExpectedVersion.value,
expectedCount: picking.value?.expectedCount ?? 0,
pickedCount: picking.value?.pickedCount ?? 0,
remark: loadingRemark.value || undefined,
}
}
async function startPickingTask() {
const request = pickingRequest()
if (!request) return
const result = await run('picking-start', () => startPicking(props.tenantId, props.inboundId, request), '拣货任务已开始')
if (result) picking.value = result
}
async function completePickingTask() {
const request = pickingRequest()
if (!request) return
const result = await run('picking-complete', () => completePicking(props.tenantId, props.inboundId, request), '拣货已完成')
if (result) picking.value = result
}
async function saveEvidence(type: EvidenceType) {
const current = evidence(type)
const result = await run(`evidence-${type}`, () => upsertEvidence(props.tenantId, props.inboundId, {
expectedVersion: current?.version ?? 0,
evidenceType: type,
fileId: current?.fileId || undefined,
fileName: current?.fileName || undefined,
sealNo: current?.sealNo || undefined,
remark: current?.remark || undefined,
}), `${type === 'SEAL' ? '封签' : '证据'}已保存`)
if (result?.evidences) evidences.value = result.evidences.evidences || []
}
async function finishLoading() {
const expectedVersion = inboundExpectedVersion.value
if (expectedVersion === null) {
error.value = '入库单版本缺失,刷新订单详情后才能确认装柜完成'
return
}
const result = await run('loading-complete', () => completeLoading(props.tenantId, props.inboundId, {
expectedVersion,
remark: loadingRemark.value || undefined,
}), '装柜完成已提交')
if (result) await refresh()
}
async function finishDelivery() {
const expectedVersion = inboundExpectedVersion.value
if (expectedVersion === null) {
error.value = '入库单版本缺失,刷新订单详情后才能确认派送完成'
return
}
await run('delivery-complete', () => completeDelivery(props.tenantId, props.inboundId, {
expectedVersion,
remark: deliveryRemark.value || undefined,
}), '派送完成已提交')
}
function updateEvidenceField(type: EvidenceType, field: 'fileName' | 'sealNo' | 'remark', value: string) {
const current = evidence(type)
if (current) {
current[field] = value || null
return
}
evidences.value.push({
id: `draft-${type}`,
inboundId: props.inboundId,
tenantId: props.tenantId,
evidenceType: type,
fileId: null,
fileName: field === 'fileName' ? value : null,
sealNo: field === 'sealNo' ? value : null,
remark: field === 'remark' ? value : null,
version: 0,
createdAt: null,
updatedAt: null,
})
}
watch(() => props.mainStatus, () => { void refresh() })
onMounted(() => { void refresh() })
</script>
<template>
<section class="workflow-panel" aria-label="订单后半程作业">
<div class="workflow-heading">
<div>
<h2>后半程作业</h2>
<p>订单按待分柜、装柜中、装柜完成和派送完成推进;资料可修改,客户确认不阻断。</p>
</div>
<el-button text :loading="loading" aria-label="刷新作业数据" @click="refresh"><RefreshCw :size="15" />刷新</el-button>
</div>
<el-alert v-if="error" class="workflow-alert" type="error" :closable="false" show-icon>
<template #title>{{ error }}</template>
<el-button link type="danger" @click="refresh">重试</el-button>
</el-alert>
<el-alert v-if="notice" class="workflow-alert" type="success" :closable="false" show-icon>{{ notice }}</el-alert>
<el-alert v-if="busy" class="workflow-alert" type="info" :closable="false">{{ idempotentMessage(busy) }}</el-alert>
<template v-if="isSplit">
<el-card shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>预装清单</span><el-tag>{{ preload?.status || '待确认' }}</el-tag></div></template>
<el-empty v-if="!preload && !loading" description="暂无预装清单,可直接生成分柜方案" />
<div class="preload-items" role="group" aria-label="预装清单明细">
<div v-for="(item, index) in preloadItems" :key="index" class="preload-row">
<el-input v-model="item.containerCode" aria-label="柜号" placeholder="柜号" />
<el-input v-model="item.cargoReference" aria-label="货物引用" placeholder="货物引用" />
<el-input-number v-model="item.packageCount" aria-label="件数" :min="1" controls-position="right" />
<el-input-number v-model="item.volumeCbm" aria-label="体积" :min="0" :precision="3" controls-position="right" />
<el-button text type="danger" aria-label="删除预装项" @click="removeItem(index)">删除</el-button>
</div>
<el-button plain @click="addItem">新增预装项</el-button>
<el-input v-model="splitRemark" class="workflow-remark" type="textarea" :rows="2" aria-label="分柜备注" placeholder="分柜备注(可选)" />
</div>
<div class="workflow-actions">
<el-button :disabled="!canSplit || Boolean(busy)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />智能分柜/保存调整</el-button>
<el-button :disabled="!canPreload || !preload || Boolean(busy)" :loading="busy === 'preload-confirming'" @click="startPreloadConfirmation"><Play :size="15" />提交确认</el-button>
<el-button type="primary" :disabled="!canPreload || !preload || preload?.status !== 'CONFIRMING' || Boolean(busy)" :loading="busy === 'preload-confirm'" @click="finishPreloadConfirmation"><Check :size="15" />确认预装清单</el-button>
</div>
</el-card>
</template>
<template v-if="isLoading">
<el-card shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>拣货与装柜</span><el-tag type="warning">装柜中</el-tag></div></template>
<el-descriptions v-if="picking" :column="2" border>
<el-descriptions-item label="拣货状态">{{ picking.status || '—' }}</el-descriptions-item>
<el-descriptions-item label="拣货版本">{{ picking.version }}</el-descriptions-item>
<el-descriptions-item label="应拣件数">{{ picking.expectedCount ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="已拣件数">{{ picking.pickedCount ?? '—' }}</el-descriptions-item>
</el-descriptions>
<el-empty v-else-if="!loading" description="暂无拣货任务" />
<div class="workflow-actions">
<el-button :disabled="!canPicking || !picking || Boolean(busy)" :loading="busy === 'picking-start'" @click="startPickingTask"><Play :size="15" />开始拣货</el-button>
<el-button :disabled="!canPicking || !picking || Boolean(busy)" :loading="busy === 'picking-complete'" @click="completePickingTask"><Check :size="15" />完成拣货</el-button>
</div>
<el-input v-model="loadingRemark" class="workflow-remark" type="textarea" :rows="2" aria-label="装柜备注" placeholder="装柜备注(可选)" />
</el-card>
<el-card shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>装柜证据与封签</span><el-tag type="info">四步照片</el-tag></div></template>
<div class="evidence-grid">
<div v-for="entry in evidenceTypes" :key="entry.type" class="evidence-item">
<strong><Camera :size="15" />{{ entry.label }}</strong>
<el-input v-if="entry.type === 'SEAL'" :model-value="evidence(entry.type)?.sealNo || ''" aria-label="封签号" placeholder="封签号" @update:model-value="updateEvidenceField(entry.type, 'sealNo', $event)" />
<el-input v-else :model-value="evidence(entry.type)?.fileName || ''" aria-label="照片文件名" placeholder="文件上传完成后填写文件名" @update:model-value="updateEvidenceField(entry.type, 'fileName', $event)" />
<el-button plain :disabled="!canEvidence || Boolean(busy)" :loading="busy === `evidence-${entry.type}`" @click="saveEvidence(entry.type)">保存</el-button>
</div>
</div>
<p class="workflow-hint">当前只保存证据元数据;真实文件上传需接 File 服务契约,不会把本地路径伪装成已上传文件。</p>
<div class="workflow-actions">
<el-button type="primary" :disabled="!canLoadingComplete || inboundExpectedVersion === null || Boolean(busy)" :loading="busy === 'loading-complete'" @click="finishLoading"><Check :size="15" />完成装柜</el-button>
<span v-if="inboundExpectedVersion === null" class="workflow-hint">缺少入库版本,刷新订单详情后可提交</span>
</div>
</el-card>
</template>
<el-card v-if="isLoaded" shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>派送</span><el-tag type="success">装柜完成</el-tag></div></template>
<el-descriptions v-if="props.deliveryTrackingNo" :column="1" border>
<el-descriptions-item label="派送单号">{{ props.deliveryTrackingNo }}</el-descriptions-item>
</el-descriptions>
<el-input v-model="deliveryRemark" class="workflow-remark" type="textarea" :rows="3" aria-label="派送备注" placeholder="派送备注(可选)" />
<div class="workflow-actions">
<el-button type="primary" :disabled="!canDeliveryComplete || inboundExpectedVersion === null || Boolean(busy)" :loading="busy === 'delivery-complete'" @click="finishDelivery"><Truck :size="15" />确认派送完成</el-button>
<span v-if="inboundExpectedVersion === null" class="workflow-hint">缺少入库版本,刷新订单详情后可提交</span>
</div>
</el-card>
</section>
</template>
<style scoped>
.workflow-panel { display: grid; gap: 14px; }
.workflow-heading, .card-title, .workflow-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.workflow-heading { justify-content: space-between; }
.workflow-heading h2 { margin: 0; font-size: 17px; }
.workflow-heading p { margin: 4px 0 0; color: var(--cc-text-secondary, #64748b); font-size: 13px; }
.workflow-card { border-radius: 12px; }
.card-title { justify-content: space-between; }
.preload-items { display: grid; gap: 10px; }
.preload-row { display: grid; grid-template-columns: 1fr 1fr 120px 120px auto; gap: 8px; align-items: center; }
.workflow-actions { margin-top: 14px; }
.workflow-remark { margin-top: 10px; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.evidence-item { display: grid; grid-template-columns: minmax(100px, auto) 1fr auto; gap: 8px; align-items: center; }
.evidence-item strong { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.workflow-alert { margin-bottom: 0; }
.workflow-hint { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
@media (max-width: 640px) {
.preload-row, .evidence-grid { grid-template-columns: 1fr; }
.evidence-item { grid-template-columns: 1fr; }
.workflow-actions > .el-button { flex: 1 1 150px; }
}
</style>
@@ -0,0 +1,3 @@
export { default as UnifiedOrderWorkflowPanel } from './components/UnifiedOrderWorkflowPanel.vue'
export { default as UnifiedOrderWorkflowView } from './pages/UnifiedOrderWorkflowView.vue'
export * from './api'
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getPlatformUnifiedOrder, type UnifiedOrderDetailResource } from '@/modules/unified-orders/api/unifiedOrders'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import UnifiedOrderWorkflowPanel from '@/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue'
const route = useRoute()
const detail = ref<UnifiedOrderDetailResource | null>(null)
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const orderId = computed(() => String(route.params.orderId || ''))
const tenantId = computed(() => typeof route.query.tenantId === 'string' ? route.query.tenantId : '')
const summary = computed(() => detail.value?.summary || null)
async function load() {
if (!orderId.value || !tenantId.value) {
error.value = { message: '订单作业上下文缺少订单号或客户标识' }
return
}
loading.value = true
error.value = null
try {
detail.value = await getPlatformUnifiedOrder(tenantId.value, orderId.value)
} catch (cause) {
detail.value = null
error.value = getApiErrorDetails(cause, '订单作业上下文加载失败')
} finally {
loading.value = false
}
}
onMounted(() => { void load() })
</script>
<template>
<section class="workflow-page">
<LoadErrorState v-if="error" title="后半程作业暂时无法加载" :message="error.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="error" /></template>
</LoadErrorState>
<UnifiedOrderWorkflowPanel
v-else-if="summary && summary.inboundId"
:tenant-id="tenantId"
:inbound-id="summary.inboundId"
:main-status="summary.mainStatus"
:inbound-version="summary.inboundVersion"
:picking-version="summary.loading?.pickingVersion"
:delivery-tracking-no="summary.delivery?.trackingNo"
@updated="load"
/>
<el-empty v-else-if="!loading" description="该订单尚未生成入库作业,暂无后半程操作" />
<el-skeleton v-else :rows="5" animated />
</section>
</template>
<style scoped>
.workflow-page { display: grid; gap: 16px; }
</style>