From 85ca0139287242c89ea1bf99fd23eec46e92d544 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 13:56:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/src/index.ts | 11 +- .../dashboard/views/DashboardView.test.mjs | 2 +- src/modules/dashboard/views/DashboardView.vue | 2 +- src/modules/first-leg/api/unifiedOrders.ts | 148 ++++++++++ .../components/OrderProgressPanel.vue | 111 ++++++++ src/modules/first-leg/routes.ts | 6 +- .../first-leg/store/firstLegOrders.test.mjs | 62 ++++- src/modules/first-leg/store/firstLegOrders.ts | 253 ++++++++++++++++-- src/modules/first-leg/types/firstLegOrder.ts | 44 ++- .../first-leg/utils/firstLegOrder.test.mjs | 12 +- src/modules/first-leg/utils/firstLegOrder.ts | 15 +- .../views/FirstLegOrdersView.test.mjs | 39 ++- .../first-leg/views/FirstLegOrdersView.vue | 98 +++++-- src/navigation/clientNavigation.test.mjs | 2 +- 14 files changed, 727 insertions(+), 78 deletions(-) create mode 100644 src/modules/first-leg/api/unifiedOrders.ts create mode 100644 src/modules/first-leg/components/OrderProgressPanel.vue diff --git a/shared/src/index.ts b/shared/src/index.ts index 5bf0aef..ff5fa2a 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -85,11 +85,12 @@ export interface ApiResponse { 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'] diff --git a/src/modules/dashboard/views/DashboardView.test.mjs b/src/modules/dashboard/views/DashboardView.test.mjs index 4de34c7..ba777db 100644 --- a/src/modules/dashboard/views/DashboardView.test.mjs +++ b/src/modules/dashboard/views/DashboardView.test.mjs @@ -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 小时内与您联系/) }) diff --git a/src/modules/dashboard/views/DashboardView.vue b/src/modules/dashboard/views/DashboardView.vue index 199f846..ad0eb72 100644 --- a/src/modules/dashboard/views/DashboardView.vue +++ b/src/modules/dashboard/views/DashboardView.vue @@ -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) => { diff --git a/src/modules/first-leg/api/unifiedOrders.ts b/src/modules/first-leg/api/unifiedOrders.ts new file mode 100644 index 0000000..f3a2042 --- /dev/null +++ b/src/modules/first-leg/api/unifiedOrders.ts @@ -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 +} + +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>({ + method: 'GET', + url: unifiedOrderBaseUrl, + params: { page: 1, size: 200, ...params }, + }) +} + +export function getMerchantUnifiedOrder(orderId: number | string) { + return apiRequest({ + method: 'GET', + url: `${unifiedOrderBaseUrl}/${orderId}`, + }) +} + +export function isUnifiedOrderQueryUnavailable(error: unknown) { + return error instanceof ApiError && (error.status === 404 || error.code === 'A0404') +} diff --git a/src/modules/first-leg/components/OrderProgressPanel.vue b/src/modules/first-leg/components/OrderProgressPanel.vue new file mode 100644 index 0000000..66f596b --- /dev/null +++ b/src/modules/first-leg/components/OrderProgressPanel.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/src/modules/first-leg/routes.ts b/src/modules/first-leg/routes.ts index bf02e5a..8d8234b 100644 --- a/src/modules/first-leg/routes.ts +++ b/src/modules/first-leg/routes.ts @@ -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', diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs index 4166adc..59f9224 100644 --- a/src/modules/first-leg/store/firstLegOrders.test.mjs +++ b/src/modules/first-leg/store/firstLegOrders.test.mjs @@ -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/) }) diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index ea69e08..3f0dbb4 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -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> = { } 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> = { + 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 { + 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 } }) diff --git a/src/modules/first-leg/types/firstLegOrder.ts b/src/modules/first-leg/types/firstLegOrder.ts index 029ed34..0e40a7d 100644 --- a/src/modules/first-leg/types/firstLegOrder.ts +++ b/src/modules/first-leg/types/firstLegOrder.ts @@ -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 | 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 +>, 'transportMode'> & { + transportMode: FirstLegTransportMode +} diff --git a/src/modules/first-leg/utils/firstLegOrder.test.mjs b/src/modules/first-leg/utils/firstLegOrder.test.mjs index 7a95a4e..748da4a 100644 --- a/src/modules/first-leg/utils/firstLegOrder.test.mjs +++ b/src/modules/first-leg/utils/firstLegOrder.test.mjs @@ -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', () => { diff --git a/src/modules/first-leg/utils/firstLegOrder.ts b/src/modules/first-leg/utils/firstLegOrder.ts index 97dd1c9..e4f6b2b 100644 --- a/src/modules/first-leg/utils/firstLegOrder.ts +++ b/src/modules/first-leg/utils/firstLegOrder.ts @@ -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 } => 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, diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index f1d6803..df9e0ea 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -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/) - assert.match(source, //) + assert.match(source, //) assert.match(source, /async function searchOrders\(model: QueryModel\)/) - assert.match(source, /await store\.load\(\)/) + assert.match(source, /await store\.load\(\{/) assert.match(source, / { + assert.match(source, /import OrderProgressPanel/) + assert.match(source, //) + assert.match(source, /

关键业务摘要<\/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>共 \{\{ filteredOrders\.length \}\} 条<\/span>/) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index fccf6c9..fd51950 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -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({ keyword: '', status: '', transportMode: '', createdRange: [] }) +const filters = ref({ keyword: '', status: '', createdRange: [] }) const appliedFilters = ref({ ...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)[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) {