This commit is contained in:
2026-08-06 13:56:13 +08:00
parent 356bec03fb
commit 85ca013928
14 changed files with 727 additions and 78 deletions
+6 -5
View File
@@ -85,11 +85,12 @@ export interface ApiResponse<T> {
export const FIRST_LEG_ORDER_STATUSES = [
{ code: 'DRAFT', label: '草稿' },
{ code: 'PENDING_REVIEW', label: '待审核' },
{ code: 'ACCEPTED', label: '已受理' },
{ code: 'BOOKED', label: '已订舱' },
{ code: 'IN_TRANSIT', label: '运输中' },
{ code: 'COMPLETED', label: '已完成' },
{ code: 'CANCELLED', label: '已取消' },
{ code: 'WAIT_MEASURE', label: '待测量数据' },
{ code: 'INBOUNDED', label: '已入库' },
{ code: 'WAIT_SPLIT', label: '待分柜' },
{ code: 'LOADING', label: '装柜中' },
{ code: 'LOADED', label: '装柜完成' },
{ code: 'DELIVERED', label: '派送完成' },
] as const
export type FirstLegOrderStatus = (typeof FIRST_LEG_ORDER_STATUSES)[number]['code']
@@ -31,7 +31,7 @@ test('matches the prototype business overview while keeping real order routing a
assert.match(source, /美国本土卡/)
assert.match(source, /美本土散板/)
assert.match(source, /void router\.push\('\/orders\/first-leg\/create'\)/)
assert.match(source, /order\.status === 'COMPLETED'/)
assert.match(source, /order\.status === 'DELIVERED'/)
assert.match(source, /currentMonthOrderCount/)
assert.match(source, /提交订单后,专属客户经理将在 24 小时内与您联系/)
})
@@ -46,7 +46,7 @@ const accountName = computed(() => {
return auth.user?.tenantName || auth.user?.name || '企业客户'
})
const accountInitial = computed(() => accountName.value.trim().slice(0, 1).toUpperCase() || 'C')
const completedOrderCount = computed(() => orders.orders.filter((order) => order.status === 'COMPLETED').length)
const completedOrderCount = computed(() => orders.orders.filter((order) => order.status === 'DELIVERED').length)
const currentMonthOrderCount = computed(() => {
const now = new Date()
return orders.orders.filter((order) => {
+148
View File
@@ -0,0 +1,148 @@
import { ApiError, apiRequest } from '@/api/http'
import type { ApiFirstLegStatus, MerchantFirstLegOrderDetail, PageResult } from '@/modules/first-leg/api/firstLeg'
export type ApiUnifiedOrderMainStatus =
| 'PENDING_REVIEW'
| 'WAIT_MEASURE'
| 'INBOUNDED'
| 'WAIT_SPLIT'
| 'LOADING'
| 'LOADED'
| 'DELIVERED'
export interface MerchantUnifiedMeasurementSummary {
reviewStatus: string | null
actualGrossWeightKg: number | null
actualVolumeCbm: number | null
chargeableWeightKg: number | null
completedAt: string | null
}
export interface MerchantUnifiedPreloadSummary {
status: string
version: number
itemCount: number
updatedAt: string | null
}
export interface MerchantUnifiedLoadingSummary {
status: string
loadingVersion: number
pickingVersion: number
expectedCount: number | null
pickedCount: number | null
evidenceCount: number | null
sealNo: string | null
updatedAt: string | null
}
export interface MerchantUnifiedDeliverySummary {
status: string
completedAt: string | null
trackingNo: string | null
}
export interface MerchantUnifiedTransportSummary {
loadingStatus: string | null
containerNo: string | null
cabinetNo: string | null
vesselName: string | null
voyageNo: string | null
sailingAt: string | null
portArrivalAt: string | null
}
export interface MerchantUnifiedOrderRow {
orderId: number | string
tenantId: number | string
orderNo: string | null
mainStatus: ApiUnifiedOrderMainStatus
nextAction: string | null
internalOrderStatus: ApiFirstLegStatus | null
customerId: number | string | null
customerName: string
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
actualGrossWeightKg: number | null
actualVolumeCbm: number | null
measurement: MerchantUnifiedMeasurementSummary | null
preload: MerchantUnifiedPreloadSummary | null
loading: MerchantUnifiedLoadingSummary | null
delivery: MerchantUnifiedDeliverySummary | null
transport: MerchantUnifiedTransportSummary | null
createdAt: string
updatedAt: string
}
export interface MerchantUnifiedTimelineEvent {
source: string
eventType: string
mainStatus: ApiUnifiedOrderMainStatus | null
action: string | null
remark: string | null
operatorId: number | string | null
operatorName: string | null
occurredAt: string | null
}
export interface MerchantUnifiedCustomNode {
id: number | string
name: string
nodeType: string
sortOrder: number
customerVisible: boolean
plannedAt: string | null
completedAt: string | null
operatorName: string | null
remark: string | null
attachmentIds: Array<number | string>
}
export interface MerchantUnifiedOrderDetail {
summary: MerchantUnifiedOrderRow
order: MerchantFirstLegOrderDetail
timeline: MerchantUnifiedTimelineEvent[]
// C02 does not return custom nodes yet. Optionality keeps the client forward-compatible without inventing data.
customNodes?: MerchantUnifiedCustomNode[]
}
export interface MerchantUnifiedOrderQuery {
keyword?: string
orderNo?: string
mainStatus?: ApiUnifiedOrderMainStatus
receivingWarehouseId?: number | string
destinationWarehouseId?: number | string
createdFrom?: string
createdTo?: string
page?: number
size?: number
}
const unifiedOrderBaseUrl = '/merchant/v1/orders'
export function getMerchantUnifiedOrders(params: MerchantUnifiedOrderQuery = {}) {
return apiRequest<PageResult<MerchantUnifiedOrderRow>>({
method: 'GET',
url: unifiedOrderBaseUrl,
params: { page: 1, size: 200, ...params },
})
}
export function getMerchantUnifiedOrder(orderId: number | string) {
return apiRequest<MerchantUnifiedOrderDetail>({
method: 'GET',
url: `${unifiedOrderBaseUrl}/${orderId}`,
})
}
export function isUnifiedOrderQueryUnavailable(error: unknown) {
return error instanceof ApiError && (error.status === 404 || error.code === 'A0404')
}
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Check, CircleDot, Clock3, Paperclip } from '@lucide/vue'
import type { FirstLegOrder } from '@/modules/first-leg/types/firstLegOrder'
import {
FIRST_LEG_PROGRESS_STAGES,
getFirstLegProgressIndex,
getFirstLegStatusLabel,
} from '@/modules/first-leg/utils/firstLegOrder'
const props = defineProps<{ order: FirstLegOrder }>()
const currentIndex = computed(() => getFirstLegProgressIndex(props.order.status))
const timeline = computed(() => [...props.order.timeline].reverse())
function stageState(index: number) {
if (index < currentIndex.value) return 'is-complete'
if (index === currentIndex.value) return 'is-current'
return 'is-pending'
}
function formatDateTime(value: string) {
if (!value) return '时间待更新'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
</script>
<template>
<section class="order-progress" aria-labelledby="order-progress-title">
<div class="progress-heading">
<div>
<h3 id="order-progress-title">订单进度</h3>
<span>从订单提交到派送完成</span>
</div>
<el-tag :type="order.status === 'DELIVERED' ? 'success' : order.status === 'DRAFT' || order.status === 'PENDING_REVIEW' ? 'warning' : 'info'" effect="light">
{{ getFirstLegStatusLabel(order.status) }}
</el-tag>
</div>
<ol class="progress-stages" aria-label="订单主状态阶段">
<li v-for="(stage, index) in FIRST_LEG_PROGRESS_STAGES" :key="stage.code" :class="stageState(index)" :aria-current="index === currentIndex ? 'step' : undefined">
<span class="stage-marker" aria-hidden="true"><Check v-if="index < currentIndex" :size="13" /><CircleDot v-else :size="13" /></span>
<span class="stage-label">{{ stage.label }}</span>
</li>
</ol>
<p v-if="order.status === 'DRAFT'" class="draft-progress-note">订单提交后开始记录主状态进度。</p>
<div class="timeline-heading">
<h4>进度记录</h4>
<span>{{ timeline.length }} 条</span>
</div>
<el-empty v-if="timeline.length === 0" class="timeline-empty" description="暂无进度记录" :image-size="52" />
<el-timeline v-else class="order-timeline">
<el-timeline-item v-for="item in timeline" :key="item.id" :timestamp="formatDateTime(item.occurredAt)" placement="top" :type="item.kind === 'MAIN_STATUS' ? 'primary' : 'info'">
<div class="timeline-title">
<strong>{{ item.title }}</strong>
<el-tag v-if="item.kind === 'CUSTOM_NODE'" size="small" effect="plain">业务节点</el-tag>
<el-tag v-else-if="item.status" size="small" effect="plain">{{ getFirstLegStatusLabel(item.status) }}</el-tag>
</div>
<p v-if="item.description">{{ item.description }}</p>
<div class="timeline-meta">
<span>{{ item.operatorName || '平台' }}</span>
<span v-if="item.kind === 'CUSTOM_NODE' && item.plannedAt"><Clock3 :size="13" />计划 {{ formatDateTime(item.plannedAt) }}</span>
<span v-if="item.attachmentCount"><Paperclip :size="13" />{{ item.attachmentCount }} 个附件</span>
</div>
</el-timeline-item>
</el-timeline>
</section>
</template>
<style scoped>
.order-progress { margin-top: 22px; }
.progress-heading, .timeline-heading, .timeline-title, .timeline-meta { display: flex; align-items: center; }
.progress-heading { justify-content: space-between; gap: 14px; }
.progress-heading > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.progress-heading h3, .timeline-heading h4 { margin: 0; color: var(--navy); }
.progress-heading h3 { font-size: 15px; }
.progress-heading span, .timeline-heading span { color: var(--muted); font-size: 11px; }
.progress-stages { display: grid; grid-template-columns: repeat(7, minmax(76px, 1fr)); margin: 16px 0 0; padding: 0; overflow-x: auto; list-style: none; }
.progress-stages li { position: relative; display: flex; min-width: 76px; align-items: center; flex-direction: column; gap: 7px; color: #96a0a6; font-size: 10px; text-align: center; }
.progress-stages li::before { position: absolute; top: 10px; right: 50%; left: -50%; height: 2px; background: #e1e6e8; content: ''; }
.progress-stages li:first-child::before { display: none; }
.progress-stages li.is-complete::before, .progress-stages li.is-current::before { background: var(--primary); }
.stage-marker { position: relative; z-index: 1; display: grid; width: 22px; height: 22px; place-items: center; border: 2px solid #d9e0e2; border-radius: 50%; background: #fff; }
.is-complete .stage-marker, .is-current .stage-marker { border-color: var(--primary); color: var(--primary); }
.is-complete .stage-marker { background: var(--primary); color: #fff; }
.is-current .stage-label { color: var(--navy); font-weight: 700; }
.draft-progress-note { margin: 12px 0 0; padding: 10px 12px; background: #f6f8f8; color: var(--muted); font-size: 11px; }
.timeline-heading { justify-content: space-between; margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--border); }
.timeline-heading h4 { font-size: 13px; }
.order-timeline { margin-top: 14px; padding-left: 5px; }
.timeline-title { flex-wrap: wrap; gap: 8px; }
.timeline-title strong { color: var(--navy); font-size: 12px; }
.order-timeline p { margin: 6px 0; overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.55; white-space: pre-wrap; }
.timeline-meta { flex-wrap: wrap; gap: 12px; color: var(--muted); font-size: 10px; }
.timeline-meta span { display: inline-flex; align-items: center; gap: 4px; }
.timeline-empty { padding: 12px 0 0; }
@media (max-width: 720px) {
.progress-stages { grid-template-columns: repeat(7, 82px); padding-bottom: 8px; }
.progress-heading { align-items: flex-start; }
}
</style>
+3 -3
View File
@@ -11,8 +11,8 @@ export const firstLegModule = {
component: () => import('./views/FirstLegOrdersView.vue'),
props: { createPage: false },
release: 'active',
meta: { title: '头程订单', description: '创建、查询并跟进本企业头程订单', permission: 'order:first-leg:list' },
navigation: { key: 'first-leg-orders', title: '头程下单', icon: FileText },
meta: { title: '订单列表', description: '创建订单并跟进从提交到派送完成的统一进度', permission: 'order:first-leg:list' },
navigation: { key: 'first-leg-orders', title: '订单列表', icon: FileText },
},
{
path: 'orders/first-leg/create',
@@ -20,7 +20,7 @@ export const firstLegModule = {
component: () => import('./views/FirstLegOrdersView.vue'),
props: { createPage: true },
release: 'active',
meta: { title: '创建头程订单', description: '填写发货、目的地和货物信息', permission: 'order:first-leg:create' },
meta: { title: '创建订单', description: '填写发货、目的地和货物信息', permission: 'order:first-leg:create' },
},
{
path: 'first-leg/import',
@@ -4,6 +4,7 @@ import test from 'node:test'
const storeSource = readFileSync(new URL('./firstLegOrders.ts', import.meta.url), 'utf8')
const apiSource = readFileSync(new URL('../api/firstLeg.ts', import.meta.url), 'utf8')
const unifiedApiSource = readFileSync(new URL('../api/unifiedOrders.ts', import.meta.url), 'utf8')
test('declares the documented merchant warehouse and first-leg endpoints', () => {
assert.match(apiSource, /url: '\/merchant\/v1\/warehouses'/)
@@ -18,16 +19,57 @@ test('maps transport and status fields instead of treating container mode as tra
assert.match(storeSource, /draft\.transportMode === 'ROAD'\) return 'TRUCK'/)
assert.match(storeSource, /status === 'SUBMITTED'\) return 'PENDING_REVIEW'/)
assert.match(storeSource, /status === 'REJECTED'\) return 'PENDING_REVIEW'/)
assert.match(storeSource, /status === 'PROCESSING'\) return 'IN_TRANSIT'/)
assert.match(storeSource, /status === 'APPROVED' \|\| status === 'ACCEPTED' \|\| status === 'PROCESSING'\) return 'WAIT_MEASURE'/)
assert.match(storeSource, /status === 'COMPLETED'\) return 'DELIVERED'/)
})
test('uses real API data without falling back to demo warehouses or orders', () => {
test('prefers unified reads and only falls back to the real legacy API when C02 is unavailable', () => {
assert.match(storeSource, /orders\.value = \[\]/)
assert.match(storeSource, /warehouses\.value = \[\]/)
assert.match(storeSource, /Promise\.allSettled\(\[getMerchantFirstLegOrders\(\), getMerchantWarehouses\(\)\]\)/)
assert.match(storeSource, /Promise\.allSettled\(\[getOrdersForClient\(query\), getMerchantWarehouses\(\)\]\)/)
assert.match(storeSource, /getMerchantUnifiedOrders\(unifiedQuery\)/)
assert.match(storeSource, /isUnifiedOrderQueryUnavailable\(error\)/)
assert.match(storeSource, /getMerchantFirstLegOrders\(legacyQuery\(query\)\)/)
assert.doesNotMatch(storeSource, /DEMO_PLATFORM_RECEIVING_WAREHOUSES|initialOrders/)
})
test('declares the final C02 row and detail contract in the API adapter', () => {
assert.match(unifiedApiSource, /const unifiedOrderBaseUrl = '\/merchant\/v1\/orders'/)
assert.doesNotMatch(unifiedApiSource, /orders\/unified/)
assert.match(unifiedApiSource, /orderId: number \| string/)
assert.match(unifiedApiSource, /mainStatus: ApiUnifiedOrderMainStatus/)
assert.match(unifiedApiSource, /nextAction: string \| null/)
assert.match(unifiedApiSource, /loadingVersion: number/)
assert.match(unifiedApiSource, /pickingVersion: number/)
assert.match(unifiedApiSource, /summary: MerchantUnifiedOrderRow/)
assert.match(unifiedApiSource, /order: MerchantFirstLegOrderDetail/)
assert.match(unifiedApiSource, /timeline: MerchantUnifiedTimelineEvent\[\]/)
assert.match(unifiedApiSource, /customNodes\?: MerchantUnifiedCustomNode\[\]/)
assert.match(unifiedApiSource, /error\.status === 404 \|\| error\.code === 'A0404'/)
})
test('keeps drafts on the legacy read path without assigning a unified main status', () => {
assert.match(storeSource, /getMerchantFirstLegOrders\(legacyQuery\(query, 'DRAFT'\)\)/)
assert.match(storeSource, /getById\(id\)\?\.status === 'DRAFT' \? loadLegacyDetail\(id\) : loadUnifiedDetail\(id\)/)
assert.match(storeSource, /return loadLegacyDetail\(String\(createdId\)\)/)
assert.match(storeSource, /draftLoadError/)
})
test('routes supported filters to C02 without sending DRAFT as a main status', () => {
assert.match(storeSource, /interface ClientOrderReadQuery extends MerchantUnifiedOrderQuery/)
assert.match(storeSource, /if \(query\.draftOnly\)/)
assert.match(storeSource, /const unifiedQuery: MerchantUnifiedOrderQuery/)
assert.match(storeSource, /getMerchantUnifiedOrders\(unifiedQuery\)/)
})
test('maps C02 rows by orderId and leaves absent transport details unknown', () => {
assert.match(storeSource, /id: String\(summary\.orderId\)/)
assert.match(storeSource, /transportMode: null/)
assert.match(storeSource, /summary\?\.loading\?\.loadingVersion/)
assert.match(storeSource, /summary\?\.loading\?\.pickingVersion/)
assert.match(storeSource, /summary\?\.inboundId == null \? '' : String\(summary\.inboundId\)/)
})
test('sends backend-required channel, customs, destination and cargo fields', () => {
for (const field of ['pickupMethod', 'channelCode', 'channelName', 'taxMode', 'customsInspection', 'destinationType', 'packageType']) {
assert.match(storeSource, new RegExp(field))
@@ -63,7 +105,17 @@ test('sends an idempotency key for create and submit writes', () => {
test('does not expose the contradictory backend rejected state in the client model', () => {
assert.match(storeSource, /status === 'REJECTED'\) return 'PENDING_REVIEW'/)
assert.match(storeSource, /item\.toStatus === 'SUBMITTED' \|\| item\.toStatus === 'REJECTED' \? 'SUBMITTED'/)
assert.match(storeSource, /item\.toStatus === 'REJECTED' \? '订单信息已补充'/)
})
test('maps the final backend timeline and keeps missing custom nodes empty', () => {
assert.match(storeSource, /detail\.timeline\.flatMap/)
assert.match(storeSource, /item\.mainStatus/)
assert.match(storeSource, /\(detail\.customNodes \?\? \[\]\)/)
assert.match(storeSource, /\.filter\(\(item\) => item\.customerVisible\)/)
assert.match(storeSource, /kind: 'CUSTOM_NODE'/)
assert.match(storeSource, /status: null/)
assert.match(storeSource, /\.sort\(\(left, right\)/)
})
test('maps backend warehouse snapshots and fulfillment courier fields', () => {
@@ -78,5 +130,5 @@ test('keeps failed order loads distinct from empty results', () => {
assert.match(storeSource, /const loadError = ref\(''\)/)
assert.match(storeSource, /orderResult\.reason instanceof Error/)
assert.match(storeSource, /warehouseLoadError/)
assert.match(storeSource, /return \{ orders, warehouses, loading, loadedFromApi, loadError, warehouseLoadError/)
assert.match(storeSource, /return \{ orders, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, draftLoadError/)
})
+229 -24
View File
@@ -15,11 +15,19 @@ import {
type MerchantFirstLegOrderDetail,
type MerchantWarehouse,
} from '@/modules/first-leg/api/firstLeg'
import {
getMerchantUnifiedOrder,
getMerchantUnifiedOrders,
isUnifiedOrderQueryUnavailable,
type MerchantUnifiedOrderDetail,
type MerchantUnifiedOrderQuery,
type MerchantUnifiedOrderRow,
} from '@/modules/first-leg/api/unifiedOrders'
import type { MerchantTenantStatus } from '@/modules/tenant-management/public'
import type { PlatformReceivingWarehouse, PlatformReceivingWarehouseSnapshot } from '@chongchong/shared'
import type { FirstLegPartySnapshot } from '@/modules/first-leg/data/firstLegParties'
import type { FirstLegOrder, FirstLegOrderDraft, FirstLegOrderTimelineItem } from '@/modules/first-leg/types/firstLegOrder'
import { canClientEditFirstLegOrder } from '@/modules/first-leg/utils/firstLegOrder'
import { canClientEditFirstLegOrder, getFirstLegStatusLabel } from '@/modules/first-leg/utils/firstLegOrder'
export type ClientWarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS'
export type ClientWarehouse = PlatformReceivingWarehouse & {
@@ -32,12 +40,14 @@ const countryCodes: Readonly<Record<string, string>> = {
}
function clientStatus(status: ApiFirstLegStatus): FirstLegOrder['status'] {
if (status === 'DRAFT') return 'DRAFT'
if (status === 'SUBMITTED') return 'PENDING_REVIEW'
// 后端联调任务曾出现 REJECTED,但一期已确认订单不设驳回状态;兼容旧数据时仍按待审核展示。
if (status === 'REJECTED') return 'PENDING_REVIEW'
if (status === 'APPROVED' || status === 'ACCEPTED') return 'ACCEPTED'
if (status === 'PROCESSING') return 'IN_TRANSIT'
return status
if (status === 'APPROVED' || status === 'ACCEPTED' || status === 'PROCESSING') return 'WAIT_MEASURE'
if (status === 'COMPLETED') return 'DELIVERED'
if (status === 'CANCELLED') return 'PENDING_REVIEW'
return 'PENDING_REVIEW'
}
function clientTransport(order: MerchantFirstLegOrder): FirstLegOrder['transportMode'] {
@@ -152,14 +162,97 @@ function partySnapshot(detail: MerchantFirstLegOrderDetail, type: 'SENDER' | 'RE
}
function timeline(detail: MerchantFirstLegOrderDetail): FirstLegOrderTimelineItem[] {
return detail.statusHistory.map((item) => ({
id: String(item.id),
event: item.toStatus === 'SUBMITTED' || item.toStatus === 'REJECTED' ? 'SUBMITTED' : item.toStatus === 'CANCELLED' ? 'CANCELLED' : item.toStatus === 'COMPLETED' ? 'COMPLETED' : item.toStatus === 'ACCEPTED' || item.toStatus === 'APPROVED' ? 'ACCEPTED' : 'IN_TRANSIT',
title: item.toStatus === 'REJECTED' ? '平台要求补充订单信息' : item.action || `状态更新为 ${item.toStatus}`,
description: item.reason ?? '',
return detail.statusHistory.map((item) => {
const status = clientStatus(item.toStatus)
return {
id: String(item.id),
kind: 'MAIN_STATUS',
status: status === 'DRAFT' ? null : status,
nodeType: '',
title: item.toStatus === 'REJECTED' ? '订单信息已补充' : item.action || '订单进度已更新',
description: item.reason ?? '',
operatorName: item.operatorName ?? '',
occurredAt: item.occurredAt,
plannedAt: '',
completed: true,
attachmentCount: 0,
}
})
}
function businessSummary(summary?: MerchantUnifiedOrderRow): FirstLegOrder['businessSummary'] {
return {
totalPackageCount: summary?.totalPackageCount ?? null,
actualGrossWeightKg: summary?.actualGrossWeightKg ?? null,
actualVolumeCbm: summary?.actualVolumeCbm ?? null,
chargeableWeightKg: summary?.measurement?.chargeableWeightKg ?? null,
measurementCompletedAt: summary?.measurement?.completedAt ?? '',
preloadStatus: summary?.preload?.status ?? '',
preloadItemCount: summary?.preload?.itemCount ?? null,
loadingStatus: summary?.loading?.status ?? '',
loadingVersion: summary?.loading?.loadingVersion ?? null,
pickingVersion: summary?.loading?.pickingVersion ?? null,
expectedPickCount: summary?.loading?.expectedCount ?? null,
pickedCount: summary?.loading?.pickedCount ?? null,
evidenceCount: summary?.loading?.evidenceCount ?? null,
sealNo: summary?.loading?.sealNo ?? '',
deliveryStatus: summary?.delivery?.status ?? '',
deliveryCompletedAt: summary?.delivery?.completedAt ?? '',
deliveryTrackingNo: summary?.delivery?.trackingNo ?? '',
containerNo: summary?.transport?.containerNo ?? '',
cabinetNo: summary?.transport?.cabinetNo ?? '',
vesselName: summary?.transport?.vesselName ?? '',
voyageNo: summary?.transport?.voyageNo ?? '',
inboundId: summary?.inboundId == null ? '' : String(summary.inboundId),
inboundVersion: summary?.inboundVersion ?? null,
inboundCode: summary?.inboundCode ?? '',
nextAction: summary?.nextAction ?? '',
}
}
const unifiedActionLabels: Readonly<Record<string, string>> = {
SUBMIT_ORDER: '订单已提交',
APPROVE_ORDER: '订单审核通过',
CONFIRM_MEASUREMENT: '测量数据已确认',
SMART_SPLIT: '分柜方案已更新',
CONFIRM_PRELOAD: '预装清单已确认',
COMPLETE_LOADING: '装柜已完成',
COMPLETE_DELIVERY: '派送已完成',
}
function unifiedTimeline(detail: MerchantUnifiedOrderDetail): FirstLegOrderTimelineItem[] {
const statusItems: FirstLegOrderTimelineItem[] = detail.timeline.flatMap((item, index) => item.mainStatus ? [{
id: `status-${item.source}-${item.eventType}-${index}`,
kind: 'MAIN_STATUS' as const,
status: item.mainStatus,
nodeType: '',
title: (item.action && unifiedActionLabels[item.action]) || (item.mainStatus ? `进入${getFirstLegStatusLabel(item.mainStatus)}` : '订单进度已更新'),
description: item.remark ?? '',
operatorName: item.operatorName ?? '',
occurredAt: item.occurredAt,
}))
occurredAt: item.occurredAt ?? '',
plannedAt: '',
completed: true,
attachmentCount: 0,
}] : [])
const customItems: FirstLegOrderTimelineItem[] = (detail.customNodes ?? [])
.filter((item) => item.customerVisible)
.map((item) => ({
id: `node-${item.id}`,
kind: 'CUSTOM_NODE',
status: null,
nodeType: item.nodeType,
title: item.name,
description: item.remark ?? '',
operatorName: item.operatorName ?? '',
occurredAt: item.completedAt ?? item.plannedAt ?? '',
plannedAt: item.plannedAt ?? '',
completed: Boolean(item.completedAt),
attachmentCount: item.attachmentIds.length,
}))
return [...statusItems, ...customItems].sort((left, right) => {
const timeDifference = Date.parse(left.occurredAt) - Date.parse(right.occurredAt)
return Number.isNaN(timeDifference) ? 0 : timeDifference
})
}
function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWarehouse[], detail?: MerchantFirstLegOrderDetail): FirstLegOrder {
@@ -182,7 +275,7 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
packageType: item.packageType, hsCode: item.hsCode ?? '', declaredValue: item.declaredValue ?? 0,
currency: item.currency ?? 'USD', dangerousGoods: item.dangerousGoods ?? false,
})),
timeline: detail ? timeline(detail) : [], createdAt: order.createdAt, updatedAt: order.updatedAt,
timeline: detail ? timeline(detail) : [], businessSummary: businessSummary(), createdAt: order.createdAt, updatedAt: order.updatedAt,
tradeTerm: order.tradeTerm ?? '', destinationCountryCode: order.destinationCountryCode,
destinationWarehouseId: order.destinationWarehouseId ? String(order.destinationWarehouseId) : '',
destinationWarehouseName: order.destinationWarehouseName, pickupMethod: order.pickupMethod, channelCode: order.channelCode,
@@ -191,6 +284,91 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
}
}
function mapUnifiedOrderRow(summary: MerchantUnifiedOrderRow, warehouses: PlatformReceivingWarehouse[]): FirstLegOrder {
const warehouse = warehouses.find((item) => item.id === String(summary.receivingWarehouseId))
return {
id: String(summary.orderId), orderNo: summary.orderNo, referenceNo: '', transportMode: null,
businessType: 'EXPORT', origin: '', destination: summary.destinationWarehouseName ?? '',
receivingWarehouseId: summary.receivingWarehouseId == null ? '' : String(summary.receivingWarehouseId),
receivingWarehouseName: summary.receivingWarehouseName ?? '', receivingWarehouseSnapshot: warehouseSnapshot(warehouse),
expectedDepartureDate: summary.expectedShipDate ?? '', etd: '', eta: '', shipperPartyId: '', shipperPartySnapshot: null,
shipper: '', consigneePartyId: '', consigneePartySnapshot: null, consignee: '', expressCompanyName: '',
trackingNumber: summary.delivery?.trackingNo ?? '', priority: 'NORMAL', customerRemark: '', operatorName: '',
status: summary.mainStatus, version: 0, items: [], timeline: [], businessSummary: businessSummary(summary),
createdAt: summary.createdAt, updatedAt: summary.updatedAt, tradeTerm: '', destinationCountryCode: '',
destinationWarehouseId: summary.destinationWarehouseId == null ? '' : String(summary.destinationWarehouseId),
destinationWarehouseName: summary.destinationWarehouseName ?? '', pickupMethod: 'SELF_EXPRESS', channelCode: '',
channelName: summary.channelName ?? '', taxMode: 'BUY_DECLARATION', customsInspection: false,
destinationType: 'PRIVATE', amazonWarehouseCode: '', shipmentId: '',
}
}
function mapUnifiedOrderDetail(detail: MerchantUnifiedOrderDetail, warehouses: PlatformReceivingWarehouse[]): FirstLegOrder {
const mapped = mapOrder(detail.order.order, warehouses, detail.order)
mapped.id = String(detail.summary.orderId)
mapped.status = detail.summary.mainStatus
mapped.businessSummary = businessSummary(detail.summary)
mapped.timeline = unifiedTimeline(detail)
mapped.trackingNumber = detail.summary.delivery?.trackingNo ?? mapped.trackingNumber
return mapped
}
type OrderListReadResult =
| { source: 'unified'; records: MerchantUnifiedOrderRow[]; drafts: MerchantFirstLegOrder[]; draftError: string }
| { source: 'legacy'; records: MerchantFirstLegOrder[] }
interface ClientOrderReadQuery extends MerchantUnifiedOrderQuery {
draftOnly?: boolean
includeDrafts?: boolean
}
function legacyQuery(query: ClientOrderReadQuery, status?: ApiFirstLegStatus) {
return {
keyword: query.keyword,
status,
createdFrom: query.createdFrom,
createdTo: query.createdTo,
}
}
async function getOrdersForClient(query: ClientOrderReadQuery = {}): Promise<OrderListReadResult> {
if (query.draftOnly) {
const drafts = await getMerchantFirstLegOrders(legacyQuery(query, 'DRAFT'))
return { source: 'legacy', records: drafts.records }
}
const includeDrafts = query.includeDrafts ?? true
const unifiedQuery: MerchantUnifiedOrderQuery = {
keyword: query.keyword,
orderNo: query.orderNo,
mainStatus: query.mainStatus,
receivingWarehouseId: query.receivingWarehouseId,
destinationWarehouseId: query.destinationWarehouseId,
createdFrom: query.createdFrom,
createdTo: query.createdTo,
page: query.page,
size: query.size,
}
try {
const response = await getMerchantUnifiedOrders(unifiedQuery)
if (!includeDrafts) return { source: 'unified', records: response.records, drafts: [], draftError: '' }
try {
const drafts = await getMerchantFirstLegOrders(legacyQuery(query, 'DRAFT'))
return { source: 'unified', records: response.records, drafts: drafts.records, draftError: '' }
} catch (error) {
return {
source: 'unified',
records: response.records,
drafts: [],
draftError: error instanceof Error ? error.message : '订单草稿加载失败',
}
}
} catch (error) {
if (!isUnifiedOrderQueryUnavailable(error)) throw error
const response = await getMerchantFirstLegOrders(legacyQuery(query))
return { source: 'legacy', records: response.records }
}
}
function mapWarehouse(item: MerchantWarehouse): ClientWarehouse {
return {
id: String(item.id), code: item.warehouseCode, name: item.warehouseName, province: '',
@@ -207,19 +385,21 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
const loadedFromApi = ref(false)
const loadError = ref('')
const warehouseLoadError = ref('')
const activeCount = computed(() => orders.value.filter((item) => ['PENDING_REVIEW', 'ACCEPTED', 'BOOKED', 'IN_TRANSIT'].includes(item.status)).length)
const draftLoadError = ref('')
const activeCount = computed(() => orders.value.filter((item) => item.status !== 'DRAFT' && item.status !== 'DELIVERED').length)
const pendingCount = computed(() => orders.value.filter((item) => item.status === 'PENDING_REVIEW').length)
function getById(id: string) { return orders.value.find((item) => item.id === id) }
async function load() {
async function load(query: ClientOrderReadQuery = {}) {
loading.value = true
loadedFromApi.value = true
loadError.value = ''
warehouseLoadError.value = ''
draftLoadError.value = ''
orders.value = []
warehouses.value = []
const [orderResult, warehouseResult] = await Promise.allSettled([getMerchantFirstLegOrders(), getMerchantWarehouses()])
const [orderResult, warehouseResult] = await Promise.allSettled([getOrdersForClient(query), getMerchantWarehouses()])
if (warehouseResult.status === 'fulfilled') {
warehouses.value = warehouseResult.value.records
.filter((item) => item.status === 'ACTIVE' && ['DOMESTIC', 'FBA', 'OVERSEAS'].includes(item.warehouseType))
@@ -228,22 +408,47 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
warehouseLoadError.value = warehouseResult.reason instanceof Error ? warehouseResult.reason.message : '公共仓库数据加载失败'
}
if (orderResult.status === 'fulfilled') {
orders.value = orderResult.value.records.map((item) => mapOrder(item, warehouses.value))
if (orderResult.value.source === 'unified') {
draftLoadError.value = orderResult.value.draftError
orders.value = [
...orderResult.value.drafts.map((item) => mapOrder(item, warehouses.value)),
...orderResult.value.records.map((item) => mapUnifiedOrderRow(item, warehouses.value)),
].sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
} else {
orders.value = orderResult.value.records.map((item) => mapOrder(item, warehouses.value))
}
} else {
loadError.value = orderResult.reason instanceof Error ? orderResult.reason.message : '订单数据加载失败'
}
loading.value = false
}
async function loadDetail(id: string) {
const detail = await getMerchantFirstLegOrder(id)
const mapped = mapOrder(detail.order, warehouses.value, detail)
const index = orders.value.findIndex((item) => item.id === id)
function upsertOrder(mapped: FirstLegOrder) {
const index = orders.value.findIndex((item) => item.id === mapped.id)
if (index >= 0) orders.value[index] = mapped
else orders.value.unshift(mapped)
return mapped
}
async function loadLegacyDetail(id: string) {
const detail = await getMerchantFirstLegOrder(id)
return upsertOrder(mapOrder(detail.order, warehouses.value, detail))
}
async function loadUnifiedDetail(id: string) {
try {
const detail = await getMerchantUnifiedOrder(id)
return upsertOrder(mapUnifiedOrderDetail(detail, warehouses.value))
} catch (error) {
if (!isUnifiedOrderQueryUnavailable(error)) throw error
return loadLegacyDetail(id)
}
}
async function loadDetail(id: string) {
return getById(id)?.status === 'DRAFT' ? loadLegacyDetail(id) : loadUnifiedDetail(id)
}
async function loadWarehouseDetail(id: string) {
const detail = await getMerchantWarehouseDetail(id)
const mapped = mapWarehouse(detail.warehouse)
@@ -276,18 +481,18 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
const order = getById(id)
if (!order || !canClientEditFirstLegOrder(order.status)) throw new Error('当前订单状态不允许编辑')
await updateMerchantFirstLegOrder(id, savePayload(draft, warehouse, customerName))
return loadDetail(id)
return loadLegacyDetail(id)
}
const createdId = await createMerchantFirstLegOrder(savePayload(draft, warehouse, customerName))
return loadDetail(String(createdId))
return loadLegacyDetail(String(createdId))
}
async function submit(draft: FirstLegOrderDraft, customerName: string, id?: string, companyAuditStatus?: MerchantTenantStatus) {
if (companyAuditStatus && companyAuditStatus !== 'ACTIVE') throw new Error('企业资料尚未审核通过,请先完善企业资料并等待平台审核')
const order = await saveDraft(draft, customerName, id)
await submitMerchantFirstLegOrder(order.id)
return loadDetail(order.id)
return loadUnifiedDetail(order.id)
}
return { orders, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, activeCount, pendingCount, getById, load, loadDetail, loadWarehouseDetail, saveDraft, submit }
return { orders, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, draftLoadError, activeCount, pendingCount, getById, load, loadDetail, loadWarehouseDetail, saveDraft, submit }
})
+40 -4
View File
@@ -30,18 +30,51 @@ export interface FirstLegCargoItem {
export interface FirstLegOrderTimelineItem {
id: string
event: 'DRAFT_SAVED' | 'SUBMITTED' | 'CANCELLED' | 'ACCEPTED' | 'BOOKED' | 'IN_TRANSIT' | 'COMPLETED'
kind: 'MAIN_STATUS' | 'CUSTOM_NODE'
status: Exclude<FirstLegOrderStatus, 'DRAFT'> | null
nodeType: string
title: string
description: string
operatorName: string
occurredAt: string
plannedAt: string
completed: boolean
attachmentCount: number
}
export interface FirstLegOrderBusinessSummary {
totalPackageCount: number | null
actualGrossWeightKg: number | null
actualVolumeCbm: number | null
chargeableWeightKg: number | null
measurementCompletedAt: string
preloadStatus: string
preloadItemCount: number | null
loadingStatus: string
loadingVersion: number | null
pickingVersion: number | null
expectedPickCount: number | null
pickedCount: number | null
evidenceCount: number | null
sealNo: string
deliveryStatus: string
deliveryCompletedAt: string
deliveryTrackingNo: string
containerNo: string
cabinetNo: string
vesselName: string
voyageNo: string
inboundId: string
inboundVersion: number | null
inboundCode: string
nextAction: string
}
export interface FirstLegOrder {
id: string
orderNo: string | null
referenceNo: string
transportMode: FirstLegTransportMode
transportMode: FirstLegTransportMode | null
businessType: FirstLegBusinessType
origin: string
destination: string
@@ -66,6 +99,7 @@ export interface FirstLegOrder {
version: number
items: FirstLegCargoItem[]
timeline: FirstLegOrderTimelineItem[]
businessSummary: FirstLegOrderBusinessSummary
createdAt: string
updatedAt: string
tradeTerm: string
@@ -82,7 +116,7 @@ export interface FirstLegOrder {
shipmentId: string
}
export type FirstLegOrderDraft = Pick<
export type FirstLegOrderDraft = Omit<Pick<
FirstLegOrder,
| 'referenceNo'
| 'transportMode'
@@ -112,4 +146,6 @@ export type FirstLegOrderDraft = Pick<
| 'destinationType'
| 'amazonWarehouseCode'
| 'shipmentId'
>
>, 'transportMode'> & {
transportMode: FirstLegTransportMode
}
@@ -56,8 +56,12 @@ function validDraft() {
}
test('uses the confirmed status and transport labels', () => {
assert.equal(getFirstLegStatusLabel('BOOKED'), '已订舱')
assert.equal(getFirstLegStatusLabel('IN_TRANSIT'), '运输中')
assert.equal(getFirstLegStatusLabel('WAIT_MEASURE'), '待测量数据')
assert.equal(getFirstLegStatusLabel('INBOUNDED'), '已入库')
assert.equal(getFirstLegStatusLabel('WAIT_SPLIT'), '待分柜')
assert.equal(getFirstLegStatusLabel('LOADING'), '装柜中')
assert.equal(getFirstLegStatusLabel('LOADED'), '装柜完成')
assert.equal(getFirstLegStatusLabel('DELIVERED'), '派送完成')
assert.equal(getFirstLegTransportModeLabel('FCL'), '海运整箱 FCL')
})
@@ -66,8 +70,8 @@ test('only allows draft orders to be edited in the confirmed phase-one flow', ()
assert.equal(canClientEditFirstLegOrder('PENDING_REVIEW'), false)
assert.equal(canClientCancelFirstLegOrder('DRAFT'), true)
assert.equal(canClientCancelFirstLegOrder('PENDING_REVIEW'), true)
assert.equal(canClientCancelFirstLegOrder('ACCEPTED'), false)
assert.equal(canClientCancelFirstLegOrder('BOOKED'), false)
assert.equal(canClientCancelFirstLegOrder('WAIT_MEASURE'), false)
assert.equal(canClientCancelFirstLegOrder('WAIT_SPLIT'), false)
})
test('validates the confirmed minimum submission fields', () => {
+12 -3
View File
@@ -18,14 +18,14 @@ export function getFirstLegStatusLabel(status: FirstLegOrderStatus) {
return statusLabels.get(status) ?? status
}
export function getFirstLegTransportModeLabel(mode: FirstLegTransportMode) {
export function getFirstLegTransportModeLabel(mode: FirstLegTransportMode | null) {
if (!mode) return '运输方式待更新'
return transportModeLabels.get(mode) ?? mode
}
export function getFirstLegStatusTone(status: FirstLegOrderStatus): 'success' | 'warning' | 'info' | 'danger' {
if (status === 'COMPLETED') return 'success'
if (status === 'DELIVERED') return 'success'
if (status === 'DRAFT' || status === 'PENDING_REVIEW') return 'warning'
if (status === 'CANCELLED') return 'danger'
return 'info'
}
@@ -37,6 +37,15 @@ export function canClientCancelFirstLegOrder(status: FirstLegOrderStatus) {
return status === 'DRAFT' || status === 'PENDING_REVIEW'
}
export const FIRST_LEG_PROGRESS_STAGES = FIRST_LEG_ORDER_STATUSES.filter(
(item): item is (typeof FIRST_LEG_ORDER_STATUSES)[number] & { code: Exclude<FirstLegOrderStatus, 'DRAFT'> } => item.code !== 'DRAFT',
)
export function getFirstLegProgressIndex(status: FirstLegOrderStatus) {
if (status === 'DRAFT') return -1
return FIRST_LEG_PROGRESS_STAGES.findIndex((item) => item.code === status)
}
export function createEmptyCargoItem(id = `cargo-${Date.now()}`): FirstLegCargoItem {
return {
id,
@@ -5,6 +5,7 @@ import test from 'node:test'
const source = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8')
const routerSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
const editorShellSource = readFileSync(new URL('../components/FirstLegOrderEditorShell.vue', import.meta.url), 'utf8')
const progressSource = readFileSync(new URL('../components/OrderProgressPanel.vue', import.meta.url), 'utf8')
test('routes the confirmed first-leg path to the implemented order view', () => {
assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/)
@@ -67,14 +68,48 @@ test('uses the shared query toolbar and data table without metric cards', () =>
assert.match(source, /import QueryToolbar, \{ type QueryField, type QueryModel \}/)
assert.match(source, /import DataTable, \{ type DataTableColumn \}/)
assert.match(source, /const filters = ref<QueryModel>/)
assert.match(source, /<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="头程订单查询" @search="searchOrders" \/>/)
assert.match(source, /<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders" \/>/)
assert.match(source, /async function searchOrders\(model: QueryModel\)/)
assert.match(source, /await store\.load\(\)/)
assert.match(source, /await store\.load\(\{/)
assert.match(source, /<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="orderColumns"/)
assert.match(source, /\{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' \}/)
assert.doesNotMatch(source, /order-metrics|订单概览/)
})
test('shows one unified progress line, C02 business summaries, and optional customer-visible nodes', () => {
assert.match(source, /import OrderProgressPanel/)
assert.match(source, /<OrderProgressPanel :order="detailOrder" \/>/)
assert.match(source, /<h3>关键业务摘要<\/h3>/)
assert.match(source, /progressSummaryItems/)
assert.match(source, /summary\.preloadStatus/)
assert.match(source, /summary\.expectedPickCount/)
assert.match(source, /summary\.deliveryTrackingNo/)
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
assert.match(progressSource, /从订单提交到派送完成/)
assert.match(progressSource, /item\.kind === 'CUSTOM_NODE'/)
assert.match(progressSource, /业务节点/)
assert.match(progressSource, /暂无进度记录/)
assert.doesNotMatch(progressSource, /客户确认|差异处理中|派送中/)
})
test('uses only fields present on C02 rows before loading full detail', () => {
assert.match(source, /getOrderCargoSummary/)
assert.match(source, /目的地 \/ 渠道/)
assert.match(source, /运输方式待更新|渠道待更新/)
assert.doesNotMatch(source, /key: 'operator'/)
assert.doesNotMatch(source, /key: 'transportMode', type: 'select'/)
assert.match(source, /store\.draftLoadError/)
assert.match(source, /mainStatus: statusValue && statusValue !== 'DRAFT' \? statusValue : undefined/)
assert.match(source, /draftOnly: statusValue === 'DRAFT'/)
assert.match(source, /createdFrom: range\[0\] \? `\$\{range\[0\]\}T00:00:00`/)
})
test('keeps the seven-stage progress and business summary usable on mobile', () => {
assert.match(progressSource, /grid-template-columns: repeat\(7, 82px\)/)
assert.match(progressSource, /overflow-x: auto/)
assert.match(source, /\.fulfillment-summary \{ grid-template-columns: 1fr; \}/)
})
test('keeps the first viewport compact and moves create into the table toolbar', () => {
assert.match(source, /class="order-table-toolbar"/)
assert.match(source, /<strong>订单列表<\/strong><span>共 \{\{ filteredOrders\.length \}\} 条<\/span>/)
@@ -9,6 +9,7 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v
import { ApiError } from '@/api/http'
import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue'
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue'
import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling'
import { useFirstLegOrdersStore } from '@/modules/first-leg/store/firstLegOrders'
import { useTenantAdministrationStore } from '@/modules/tenant-management/public'
@@ -40,12 +41,11 @@ const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create
const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
const availableWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC'))
const availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType))
const filters = ref<QueryModel>({ keyword: '', status: '', transportMode: '', createdRange: [] })
const filters = ref<QueryModel>({ keyword: '', status: '', createdRange: [] })
const appliedFilters = ref<QueryModel>({ ...filters.value })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '订单号、参考号、路线或品名', props: { 'aria-label': '订单关键词' } },
{ key: 'keyword', type: 'input', placeholder: '订单号、渠道或仓库', props: { 'aria-label': '订单关键词' } },
{ key: 'status', type: 'select', placeholder: '全部状态', options: FIRST_LEG_ORDER_STATUSES.map((item) => ({ label: item.label, value: item.code })), props: { 'aria-label': '订单状态' } },
{ key: 'transportMode', type: 'select', placeholder: '全部运输方式', options: FIRST_LEG_TRANSPORT_MODES.map((item) => ({ label: item.label, value: item.code })), props: { 'aria-label': '运输方式' } },
{ key: 'createdRange', type: 'daterange', placeholder: '订单创建日期', props: { 'aria-label': '订单创建日期' } },
]
const channelOptions = [
@@ -57,11 +57,10 @@ const channelOptions = [
const orderColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单', minWidth: 190, slotName: 'order' },
{ key: 'route', label: '运输路线', minWidth: 190, slotName: 'route' },
{ key: 'route', label: '目的地 / 渠道', minWidth: 190, slotName: 'route' },
{ key: 'warehouse', label: '收货仓', prop: 'receivingWarehouseName', minWidth: 120 },
{ key: 'cargo', label: '货物', minWidth: 150, slotName: 'cargo' },
{ key: 'cargo', label: '货物摘要', minWidth: 150, slotName: 'cargo' },
{ key: 'status', label: '状态', width: 120, slotName: 'status' },
{ key: 'operator', label: '负责人', width: 110, slotName: 'operator' },
{ key: 'updatedAt', label: '更新时间', width: 155, slotName: 'updatedAt' },
]
const tablePage = ref(1)
@@ -226,26 +225,23 @@ const unsavedRouteKey = props.createPage ? 'first-leg-order-create' : 'first-leg
useUnsavedWorkRegistration({
key: `first-leg-order-editor:${unsavedRouteKey}`,
routeKey: unsavedRouteKey,
label: props.createPage ? '创建头程订单' : '头程订单编辑',
label: props.createPage ? '创建订单' : '订单编辑',
dirty: editorDirty,
})
const filteredOrders = computed(() => store.orders.filter((order) => {
const keywordValue = appliedFilters.value.keyword
const statusValue = appliedFilters.value.status
const transportModeValue = appliedFilters.value.transportMode
const createdRangeValue = appliedFilters.value.createdRange
const keyword = typeof keywordValue === 'string' ? keywordValue.trim().toLowerCase() : ''
const status = typeof statusValue === 'string' ? statusValue as FirstLegOrderStatus | '' : ''
const transportMode = typeof transportModeValue === 'string' ? transportModeValue : ''
const createdRange = Array.isArray(createdRangeValue) ? createdRangeValue.filter((value): value is string => typeof value === 'string') : []
const matchesKeyword = !keyword || [order.orderNo, order.referenceNo, order.origin, order.destination, order.items.map((item) => item.cargoNameCn).join(' ')]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword))
const matchesStatus = !status || order.status === status
const matchesMode = !transportMode || order.transportMode === transportMode
const matchesDate = createdRange.length !== 2 || (order.createdAt.slice(0, 10) >= createdRange[0] && order.createdAt.slice(0, 10) <= createdRange[1])
return matchesKeyword && matchesStatus && matchesMode && matchesDate
return matchesKeyword && matchesStatus && matchesDate
}))
const pagedOrders = computed(() => {
const start = (tablePage.value - 1) * tablePageSize.value
@@ -254,6 +250,29 @@ const pagedOrders = computed(() => {
const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined)
const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null)
const progressSummaryItems = computed(() => {
const order = detailOrder.value
if (!order) return []
const inbound = inboundBilling.value?.inbound.summary
const summary = order.businessSummary
const formatMetric = (value: number | null | undefined, unit = '') => value == null ? '待更新' : `${value.toLocaleString()}${unit ? ` ${unit}` : ''}`
const preloadStatus = ({ PENDING: '待确认', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[summary.preloadStatus] ?? summary.preloadStatus
const transportReference = [summary.containerNo, summary.cabinetNo, summary.vesselName, summary.voyageNo].filter(Boolean).join(' · ')
const pickingProgress = summary.expectedPickCount == null
? '待更新'
: `${summary.pickedCount ?? 0} / ${summary.expectedPickCount}`
return [
{ label: '实际箱数', value: formatMetric(summary.totalPackageCount ?? inbound?.totalPackageCount, '箱') },
{ label: '实测毛重', value: formatMetric(summary.actualGrossWeightKg ?? inbound?.actualGrossWeightKg, 'kg') },
{ label: '实测体积', value: formatMetric(summary.actualVolumeCbm ?? inbound?.actualVolumeCbm, 'm³') },
{ label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' },
{ label: '拣货进度', value: pickingProgress },
{ label: '装柜资料', value: transportReference || summary.sealNo || '待更新' },
{ label: '作业证据', value: formatMetric(summary.evidenceCount, '项') },
{ label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') },
{ label: '派送信息', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `完成于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待更新') },
]
})
const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId))
const selectedDestinationWarehouse = computed(() => store.warehouses.find((item) => item.id === form.destinationWarehouseId && item.warehouseType === form.destinationType))
const hasActiveFilters = computed(() => {
@@ -261,11 +280,10 @@ const hasActiveFilters = computed(() => {
return Boolean(
(typeof values.keyword === 'string' && values.keyword.trim())
|| values.status
|| values.transportMode
|| (Array.isArray(values.createdRange) && values.createdRange.length),
)
})
const emptyDescription = computed(() => hasActiveFilters.value ? '没有符合筛选条件的头程订单' : '还没有头程订单')
const emptyDescription = computed(() => hasActiveFilters.value ? '没有符合筛选条件的订单' : '还没有订单')
async function loadPageData() {
supportingDataLoading.value = true
@@ -285,6 +303,7 @@ async function loadPageData() {
}
if (ordersResult.status === 'rejected' && !store.loadError) supportingDataErrors.value.push(`订单加载失败:${errorMessage(ordersResult.reason)}`)
if (store.warehouseLoadError) supportingDataErrors.value.push(`公共仓库加载失败:${store.warehouseLoadError}`)
if (store.draftLoadError) supportingDataErrors.value.push(`订单草稿加载失败:${store.draftLoadError}`)
await autofillWarehouseSelections()
supportingDataLoading.value = false
}
@@ -293,6 +312,12 @@ function errorMessage(error: unknown) {
return error instanceof Error ? error.message : '服务暂时不可用'
}
function getOrderCargoSummary(order: FirstLegOrder) {
if (order.items.length) return `${order.items[0]?.cargoNameCn || '货物'}${order.items.length > 1 ? `等 ${order.items.length} 项` : ''}`
if (order.businessSummary.totalPackageCount != null) return `${order.businessSummary.totalPackageCount} 箱`
return '待更新'
}
onMounted(() => {
loadPrivateAddresses()
void loadPageData()
@@ -338,7 +363,7 @@ function replaceForm(draft: FirstLegOrderDraft) {
function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
return {
referenceNo: order.referenceNo,
transportMode: order.transportMode,
transportMode: order.transportMode ?? 'FCL',
businessType: order.businessType,
origin: order.origin,
destination: order.destination,
@@ -743,7 +768,17 @@ async function searchOrders(model: QueryModel) {
}
tablePage.value = 1
try {
await store.load()
const statusValue = typeof model.status === 'string' ? model.status as FirstLegOrderStatus | '' : ''
const keyword = typeof model.keyword === 'string' ? model.keyword.trim() : ''
const range = Array.isArray(createdRange) ? createdRange.filter((value): value is string => typeof value === 'string') : []
await store.load({
keyword: keyword || undefined,
mainStatus: statusValue && statusValue !== 'DRAFT' ? statusValue : undefined,
draftOnly: statusValue === 'DRAFT',
includeDrafts: !statusValue,
createdFrom: range[0] ? `${range[0]}T00:00:00` : undefined,
createdTo: range[1] ? `${range[1]}T23:59:59` : undefined,
})
} catch {
// Store keeps the recoverable error state rendered below the query toolbar.
}
@@ -753,7 +788,7 @@ async function searchOrders(model: QueryModel) {
<template>
<section class="first-leg-page" :class="{ 'is-create-route': isCreatePage }">
<template v-if="!isCreatePage">
<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="头程订单查询" @search="searchOrders" />
<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders" />
<div class="client-card order-table-card">
<div class="order-table-toolbar">
@@ -777,7 +812,7 @@ async function searchOrders(model: QueryModel) {
</div>
<template v-else>
<div class="desktop-order-table">
<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="orderColumns" :loading="store.loading" :total="filteredOrders.length" :page-sizes="[10, 20, 50]" row-key="id" empty-text="暂无头程订单">
<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="orderColumns" :loading="store.loading" :total="filteredOrders.length" :page-sizes="[10, 20, 50]" row-key="id" empty-text="暂无订单">
<template #actions="{ row }">
<div class="order-row-actions">
<el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button>
@@ -788,10 +823,9 @@ async function searchOrders(model: QueryModel) {
<button class="order-number-link" type="button" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '未提交草稿' }}</button>
<span class="order-reference">{{ (row as FirstLegOrder).referenceNo || '无客户参考号' }}</span>
</template>
<template #route="{ row }"><strong>{{ (row as FirstLegOrder).origin }} → {{ (row as FirstLegOrder).destination }}</strong><span class="order-reference">{{ getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) }}</span></template>
<template #cargo="{ row }">{{ (row as FirstLegOrder).items[0]?.cargoNameCn || '-' }}<span v-if="(row as FirstLegOrder).items.length > 1" class="order-reference">等 {{ (row as FirstLegOrder).items.length }} 项</span></template>
<template #route="{ row }"><strong>{{ (row as FirstLegOrder).destination || '目的地待更新' }}</strong><span class="order-reference">{{ (row as FirstLegOrder).transportMode ? getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) : (row as FirstLegOrder).channelName || '渠道待更新' }}</span></template>
<template #cargo="{ row }">{{ getOrderCargoSummary(row as FirstLegOrder) }}</template>
<template #status="{ row }"><el-tag :type="getFirstLegStatusTone((row as FirstLegOrder).status)" effect="light">{{ getFirstLegStatusLabel((row as FirstLegOrder).status) }}</el-tag></template>
<template #operator="{ row }">{{ (row as FirstLegOrder).operatorName || '-' }}</template>
<template #updatedAt="{ row }">{{ formatDateTime((row as FirstLegOrder).updatedAt) }}</template>
<template #empty>
<el-empty :description="emptyDescription">
@@ -805,7 +839,7 @@ async function searchOrders(model: QueryModel) {
<article v-for="order in filteredOrders" :key="order.id" class="mobile-order-card">
<header><button type="button" @click="openDetail(order)">{{ order.orderNo || '未提交草稿' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header>
<span class="order-reference">{{ order.referenceNo || '无客户参考号' }}</span>
<dl><div><dt>运输路线</dt><dd>{{ order.origin }} → {{ order.destination }}</dd></div><div><dt>收货仓</dt><dd>{{ order.receivingWarehouseName }}</dd></div><div><dt>货物</dt><dd>{{ order.items[0]?.cargoNameCn || '-' }}<span v-if="order.items.length > 1">等 {{ order.items.length }} 项</span></dd></div><div><dt>负责人</dt><dd>{{ order.operatorName || '-' }}</dd></div></dl>
<dl><div><dt>目的地</dt><dd>{{ order.destination || '待更新' }}</dd></div><div><dt>收货仓</dt><dd>{{ order.receivingWarehouseName || '待更新' }}</dd></div><div><dt>货物摘要</dt><dd>{{ getOrderCargoSummary(order) }}</dd></div><div><dt>运输渠道</dt><dd>{{ order.transportMode ? getFirstLegTransportModeLabel(order.transportMode) : order.channelName || '待更新' }}</dd></div></dl>
<footer><span>更新于 {{ formatDateTime(order.updatedAt) }}</span><div><el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button></div></footer>
</article>
</div>
@@ -824,7 +858,7 @@ async function searchOrders(model: QueryModel) {
</div>
<el-alert v-if="isCreatePage && !supportingDataLoading && availableWarehouses.length === 0" title="当前没有可用的平台收货仓" description="请联系平台维护并启用收货仓后再创建订单。" type="warning" :closable="false" show-icon />
<FirstLegOrderEditorShell v-model="editorOpen" :page="isCreatePage" :title="editingId ? '编辑订单草稿' : '创建头程订单'" :before-close="requestCloseEditor" @back="closeEditor">
<FirstLegOrderEditorShell v-model="editorOpen" :page="isCreatePage" :title="editingId ? '编辑订单草稿' : '创建订单'" :before-close="requestCloseEditor" @back="closeEditor">
<el-form ref="orderFormRef" v-loading="isCreatePage && (supportingDataLoading || store.loading)" :model="form" :rules="orderFormRules" label-position="top" class="order-form prototype-order-form" scroll-to-error>
<section class="order-form-section prototype-section">
<div class="order-section-heading"><div><h3>发货信息</h3><p>先选择货物如何送到国内收货仓,并填写本次运输安排。</p></div></div>
@@ -918,6 +952,7 @@ async function searchOrders(model: QueryModel) {
<div><span>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</span><h2>{{ detailOrder.orderNo || '未提交草稿' }}</h2><p>{{ detailOrder.origin }} → {{ detailOrder.destination }}</p></div>
<el-tag :type="getFirstLegStatusTone(detailOrder.status)" effect="light">{{ getFirstLegStatusLabel(detailOrder.status) }}</el-tag>
</div>
<OrderProgressPanel :order="detailOrder" />
<dl class="order-detail-grid">
<div><dt>客户参考号</dt><dd>{{ detailOrder.referenceNo || '-' }}</dd></div><div><dt>运输方式</dt><dd>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</dd></div>
<div><dt>发货方</dt><dd>{{ detailOrder.shipperPartySnapshot?.name || detailOrder.shipper }}<span v-if="detailOrder.shipperPartySnapshot" class="detail-subline">{{ detailOrder.shipperPartySnapshot.code }} · {{ detailOrder.shipperPartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.shipperPartySnapshot.phone || '未填写电话' }}<br />{{ detailOrder.shipperPartySnapshot.countryRegion }} {{ detailOrder.shipperPartySnapshot.province }} {{ detailOrder.shipperPartySnapshot.city }} {{ detailOrder.shipperPartySnapshot.address }}</span></dd></div>
@@ -925,6 +960,12 @@ async function searchOrders(model: QueryModel) {
<div><dt>平台收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div><div><dt>负责人</dt><dd>{{ detailOrder.operatorName || '未提供' }}</dd></div>
<div><dt>期望出发</dt><dd>{{ detailOrder.expectedDepartureDate || '-' }}</dd></div><div><dt>ETD / ETA</dt><dd>{{ detailOrder.etd || '-' }} / {{ detailOrder.eta || '-' }}</dd></div>
</dl>
<section v-if="detailOrder.status !== 'DRAFT'" class="detail-section">
<h3>关键业务摘要</h3>
<dl class="fulfillment-summary">
<div v-for="item in progressSummaryItems" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div>
</dl>
</section>
<section class="detail-section">
<h3>履约快递信息</h3>
<div v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><PackageCheck :size="20" /><div><span>快递公司</span><strong>{{ detailOrder.expressCompanyName || '待平台补充' }}</strong></div><div><span>快递单号</span><strong>{{ detailOrder.trackingNumber || '待平台补充' }}</strong></div></div>
@@ -939,7 +980,6 @@ async function searchOrders(model: QueryModel) {
:error="inboundBillingError"
@retry="retryInboundBilling"
/>
<section class="detail-section"><h3>处理时间线</h3><el-timeline><el-timeline-item v-for="item in [...detailOrder.timeline].reverse()" :key="item.id" :timestamp="item.occurredAt" placement="top"><strong>{{ item.title }}</strong><p>{{ item.description || '-' }}</p><span>{{ item.operatorName || '未提供' }}</span></el-timeline-item></el-timeline></section>
</template>
</div>
<template #footer><div v-if="detailOrder" class="editor-actions"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div></template>
@@ -952,7 +992,7 @@ async function searchOrders(model: QueryModel) {
:global(.client-main:has(> .first-leg-page:not(.is-create-route)) > .client-page-heading) { margin-bottom: 0; }
:global(.client-main:has(> .first-leg-page:not(.is-create-route)) > .client-page-heading > div) { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; border: 0; clip: rect(0 0 0 0); white-space: nowrap; }
.first-leg-query-toolbar { flex-wrap: nowrap; }
.first-leg-query-toolbar :deep(.query-toolbar__filters) { display: grid; min-width: 0; flex: 1 1 auto; grid-template-columns: minmax(160px, 1.4fr) minmax(100px, .72fr) minmax(100px, .72fr) minmax(190px, 1.15fr); }
.first-leg-query-toolbar :deep(.query-toolbar__filters) { display: grid; min-width: 0; flex: 1 1 auto; grid-template-columns: minmax(180px, 1.4fr) minmax(120px, .72fr) minmax(210px, 1.15fr); }
.first-leg-query-toolbar :deep(.query-toolbar__filters .el-input), .first-leg-query-toolbar :deep(.query-toolbar__filters .el-select), .first-leg-query-toolbar :deep(.query-toolbar__filters .el-date-editor) { width: 100%; }
.first-leg-query-toolbar :deep(.query-toolbar__actions) { align-self: flex-start; flex-wrap: nowrap; margin-left: auto; }
.order-eligibility { display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px; border: 1px solid; }
@@ -1048,6 +1088,12 @@ async function searchOrders(model: QueryModel) {
.detail-subline { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; line-height: 1.5; }
.detail-section { margin-top: 22px; }
.detail-section h3 { margin: 0 0 12px; font-size: 15px; }
.fulfillment-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border: 1px solid var(--border); }
.fulfillment-summary > div { min-width: 0; padding: 13px; border-bottom: 1px solid var(--border); }
.fulfillment-summary > div:not(:nth-child(3n)) { border-right: 1px solid var(--border); }
.fulfillment-summary > div:nth-last-child(-n + 3) { border-bottom: 0; }
.fulfillment-summary dt { color: var(--muted); font-size: 10px; }
.fulfillment-summary dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 650; }
.express-information { display: grid; grid-template-columns: auto repeat(2, minmax(0, 1fr)); align-items: center; gap: 16px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); }
.express-information > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.express-information span { color: var(--muted); font-size: 11px; }
@@ -1094,7 +1140,9 @@ async function searchOrders(model: QueryModel) {
.order-table-toolbar .el-button { width: 100%; min-height: 40px; }
.order-eligibility { flex-wrap: wrap; }
.order-eligibility .el-button { width: 100%; min-height: 44px; }
.order-form-grid, .order-detail-grid, .cargo-primary-grid, .cargo-secondary-grid { grid-template-columns: 1fr; }
.order-form-grid, .order-detail-grid, .cargo-primary-grid, .cargo-secondary-grid, .fulfillment-summary { grid-template-columns: 1fr; }
.fulfillment-summary > div, .fulfillment-summary > div:not(:nth-child(3n)) { border-right: 0; border-bottom: 1px solid var(--border); }
.fulfillment-summary > div:last-child { border-bottom: 0; }
.prototype-form-row, .prototype-form-row--wide, .prototype-form-row--compact, .prototype-form-row--short, .prototype-form-row--tabs { display: block; margin-bottom: 18px; }
.prototype-form-row :deep(.el-form-item__label) { min-height: auto; justify-content: flex-start; padding: 0 0 8px; line-height: 1.4; }
.selected-destination { width: 100%; margin-left: 0; }
+1 -1
View File
@@ -51,7 +51,7 @@ test('keeps only implemented navigation groups visible', () => {
{ title: '业务总览', children: undefined },
{
title: '头程物流',
children: ['头程下单'],
children: ['订单列表'],
},
{
title: '企业管理',