Files
client-web/src/modules/first-leg/store/firstLegOrders.ts
T

646 lines
36 KiB
TypeScript

import { ref } from 'vue'
import { defineStore } from 'pinia'
import {
createMerchantFirstLegOrderBatch,
createMerchantFirstLegOrder,
createIdempotencyKey,
getMerchantFirstLegOrder,
getMerchantWarehouseDetail,
getMerchantWarehouses,
submitMerchantFirstLegOrder,
updateMerchantFirstLegOrder,
cancelMerchantFirstLegOrder,
type ApiFirstLegStatus,
type FirstLegOrderSaveRequest,
type MerchantFirstLegOrder,
type MerchantFirstLegOrderDetail,
type MerchantBatchDraftResponse,
type MerchantWarehouse,
} from '@/modules/first-leg/api/firstLeg'
import {
getMerchantUnifiedOrder,
getMerchantOrderWorkbench,
type MerchantUnifiedOrderDetail,
type MerchantOrderWorkbenchQuery,
type MerchantOrderProgress,
type OrderQueueCounts,
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, getFirstLegStatusLabel } from '@/modules/first-leg/utils/firstLegOrder'
import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination'
export type ClientWarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS'
export type ClientWarehouse = PlatformReceivingWarehouse & {
warehouseType: ClientWarehouseType
countryCode: 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' || status === 'PROCESSING') return 'WAIT_MEASURE'
if (status === 'COMPLETED') return 'COMPLETED'
if (status === 'CANCELLED') return 'CANCELLED'
return 'PENDING_REVIEW'
}
function clientTransport(order: MerchantFirstLegOrder): FirstLegOrder['transportMode'] {
if (order.transportMode === 'SEA') return order.containerMode === 'FULL_CONTAINER' ? 'FCL' : 'LCL'
if (order.transportMode === 'AIR') return 'AIR'
return 'ROAD'
}
function unifiedTransport(containerMode: MerchantUnifiedOrderRow['containerMode']): FirstLegOrder['transportMode'] {
if (containerMode === 'FULL_CONTAINER') return 'FCL'
if (containerMode === 'LCL') return 'LCL'
return null
}
function apiTransport(draft: FirstLegOrderDraft): FirstLegOrderSaveRequest['transportMode'] {
if (draft.transportMode === 'AIR') return 'AIR'
if (draft.transportMode === 'ROAD') return 'TRUCK'
return 'SEA'
}
function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, customerName: string): FirstLegOrderSaveRequest {
return {
inboundCode: draft.inboundCode?.trim() || undefined,
customerName: customerName.trim(),
fulfillmentType: draft.fulfillmentType ?? 'STANDARD',
lxCode: draft.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? draft.lxCode?.trim() || undefined : undefined,
transportMode: apiTransport(draft),
tradeTerm: draft.tradeTerm || undefined,
destinationCountryCode: draft.destinationCountryCode.trim().toUpperCase(),
destinationWarehouseId: draft.destinationType === 'PRIVATE' ? undefined : draft.destinationWarehouseId,
destinationWarehouseName: draft.destinationWarehouseName,
expectedShipDate: draft.expectedDepartureDate || undefined,
customerRemark: draft.customerRemark || undefined,
senderAddressId: draft.pickupMethod === 'PICKUP' ? draft.senderAddressId : undefined,
privateDestinationAddressId: draft.destinationType === 'PRIVATE' ? draft.privateDestinationAddressId : undefined,
cargoes: draft.items.map((item) => ({
cargoName: item.cargoNameCn,
cargoNameEn: item.cargoNameEn || undefined,
hsCode: item.hsCode || undefined,
customsHsCode: item.customsHsCode || undefined,
packageType: item.packageType,
packageCount: item.packageCount,
packingGroupNo: draft.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? item.packingGroupNo.trim() || undefined : undefined,
grossWeightKg: item.grossWeightKg,
volumeCbm: item.volumeCbm,
lengthCm: item.lengthCm || undefined,
widthCm: item.widthCm || undefined,
heightCm: item.heightCm || undefined,
declaredValue: item.declaredValue || undefined,
currency: item.currency || undefined,
dangerousGoods: item.dangerousGoods,
woodenProduct: item.woodenProduct,
cargoRemark: item.remark || undefined,
skuNo: item.sku || undefined,
upcNo: item.upc || undefined,
totalQuantity: item.pieces,
unitsPerPackage: item.unitsPerPackage || undefined,
netWeightKg: item.netWeightKg || undefined,
unitDeclaredValue: item.unitDeclaredValue || undefined,
material: item.material || undefined,
usage: item.usage || undefined,
model: item.model || undefined,
brand: item.brand || undefined,
productLengthCm: item.productLengthCm || undefined,
productWidthCm: item.productWidthCm || undefined,
productHeightCm: item.productHeightCm || undefined,
shipmentId: item.shipmentId || undefined,
referenceId: item.referenceId || undefined,
productImageFileId: item.productImageFileId || undefined,
})),
pickupMethod: draft.pickupMethod,
receivingWarehouseId: warehouse.id,
receivingWarehouseName: warehouse.name,
channelCode: draft.channelCode,
channelName: draft.channelName,
pricingTaxMode: draft.pricingTaxMode,
taxMode: draft.taxMode,
containerMode: draft.transportMode === 'FCL' ? 'FULL_CONTAINER' : draft.transportMode === 'LCL' ? 'LCL' : undefined,
customsInspection: draft.customsInspection,
destinationType: draft.destinationType,
amazonWarehouseCode: draft.destinationType === 'FBA' ? draft.amazonWarehouseCode || undefined : undefined,
shipmentId: draft.destinationType === 'FBA' ? draft.items.find((item) => item.shipmentId)?.shipmentId || undefined : undefined,
referenceId: draft.items.find((item) => item.referenceId)?.referenceId || draft.referenceNo || undefined,
}
}
function prepareDraftForSave(draft: FirstLegOrderDraft, warehouses: ClientWarehouse[]) {
const warehouse = warehouses.find((item) => item.id === draft.receivingWarehouseId)
if (!warehouse || warehouse.warehouseType !== 'DOMESTIC') throw new Error('所选国内收货仓当前不可用')
if (draft.destinationType !== 'PRIVATE') {
const destinationWarehouse = warehouses.find((item) => item.id === draft.destinationWarehouseId && item.warehouseType === draft.destinationType)
if (!destinationWarehouse) throw new Error('所选目的仓当前不可用,请重新选择')
draft.destinationWarehouseName = destinationWarehouse.name
draft.destinationCountryCode = destinationWarehouse.countryCode
draft.amazonWarehouseCode = draft.destinationType === 'FBA' ? destinationWarehouse.code : ''
} else {
draft.destinationWarehouseId = ''
}
return warehouse
}
function warehouseSnapshot(warehouse?: PlatformReceivingWarehouse): PlatformReceivingWarehouseSnapshot | null {
return warehouse ? { id: warehouse.id, code: warehouse.code, name: warehouse.name, province: warehouse.province, city: warehouse.city, address: warehouse.address } : null
}
function orderWarehouseSnapshot(order: MerchantFirstLegOrder, warehouse?: PlatformReceivingWarehouse): PlatformReceivingWarehouseSnapshot | null {
if (order.receivingWarehouseAddress) {
return {
id: String(order.receivingWarehouseId),
code: warehouse?.code ?? '',
name: order.receivingWarehouseName,
// Order stores the complete formatted address, including province and city.
// Do not prefix this snapshot with fields from the current warehouse master.
province: '',
city: '',
address: order.receivingWarehouseAddress,
}
}
return warehouseSnapshot(warehouse)
}
function partySnapshot(detail: MerchantFirstLegOrderDetail, type: 'SENDER' | 'RECEIVER'): FirstLegPartySnapshot | null {
const party = (detail.parties ?? []).find((item) => item.partyType === type)
if (!party) return null
return {
id: String(party.id), code: type, name: party.companyName, role: type === 'SENDER' ? 'SHIPPER' : 'CONSIGNEE',
contactName: party.contactName, phone: party.phone, email: party.email ?? '', countryRegion: party.countryCode,
province: party.province ?? '', city: party.city, postalCode: party.postalCode ?? '', address: party.addressLine,
}
}
function timeline(detail: MerchantFirstLegOrderDetail): FirstLegOrderTimelineItem[] {
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 {
transportFragments: [],
customerFeeConfirmationRequired: summary?.customerFeeConfirmationRequired ?? false,
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 ?? '',
estimatedSailingAt: summary?.transport?.estimatedSailingAt ?? '',
estimatedPortArrivalAt: summary?.transport?.estimatedPortArrivalAt ?? '',
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: '测量数据已确认',
PALLETIZE_DROPSHIP: '一件代发组托',
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 ?? '',
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 ?? 0,
}))
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 {
const warehouse = warehouses.find((item) => item.id === String(order.receivingWarehouseId))
const shipper = detail ? partySnapshot(detail, 'SENDER') : null
const consignee = detail ? partySnapshot(detail, 'RECEIVER') : null
return {
id: String(order.id ?? order.orderId ?? ''), orderNo: order.orderNo, referenceNo: order.referenceId ?? '',
transportMode: order.transportMode ? clientTransport(order) : null,
businessType: 'EXPORT', fulfillmentType: order.fulfillmentType ?? 'STANDARD', lxCode: order.lxCode ?? '', origin: shipper?.city ?? '', destination: order.destinationWarehouseName || order.destinationCountryCode || '',
receivingWarehouseId: String(order.receivingWarehouseId), receivingWarehouseName: order.receivingWarehouseName,
receivingWarehouseSnapshot: orderWarehouseSnapshot(order, warehouse), expectedDepartureDate: order.expectedShipDate ?? '', etd: '', eta: '',
shipperPartyId: shipper?.id ?? '', shipperPartySnapshot: shipper, shipper: shipper?.name ?? '',
consigneePartyId: consignee?.id ?? '', consigneePartySnapshot: consignee, consignee: consignee?.name ?? '',
expressCompanyName: order.expressCompany ?? '', trackingNumber: order.expressTrackingNo ?? '', skuNos: order.skuNos ?? [],
totalNetWeightKg: order.totalNetWeightKg ?? null, totalQuantity: order.totalQuantity ?? null,
chargeableVolumeCbm: null, inboundedAt: order.inboundedAt ?? '', businessDate: '',
priority: 'NORMAL', customerRemark: order.customerRemark ?? '', operatorName: '',
status: clientStatus(order.status), version: order.version ?? 0,
items: (detail?.cargoes ?? []).map((item) => ({
id: String(item.id), sku: item.skuNo ?? '', upc: item.upcNo ?? '', cargoNameCn: item.cargoName, cargoNameEn: item.cargoNameEn ?? '',
productImageFileId: item.productImageFileId == null ? '' : String(item.productImageFileId),
productImageName: '', productImagePreviewUrl: '', productImageUploading: false, productImageError: '',
customsHsCode: item.customsHsCode ?? '', lengthCm: item.lengthCm ?? 0, widthCm: item.widthCm ?? 0,
heightCm: item.heightCm ?? 0, unitsPerPackage: item.unitsPerPackage ?? 1,
pieces: item.totalQuantity ?? item.packageCount, packageCount: item.packageCount, packingGroupNo: item.packingGroupNo ?? '', grossWeightKg: item.grossWeightKg,
volumeCbm: item.volumeCbm, remark: item.cargoRemark ?? '',
packageType: item.packageType, hsCode: item.hsCode ?? '', declaredValue: item.declaredValue ?? 0,
currency: item.currency ?? 'USD', dangerousGoods: item.dangerousGoods ?? false,
woodenProduct: item.woodenProduct ?? false,
netWeightKg: item.netWeightKg ?? 0, unitDeclaredValue: item.unitDeclaredValue ?? 0,
material: item.material ?? '', usage: item.usage ?? item.productUsage ?? '', model: item.model ?? '', brand: item.brand ?? '',
productLengthCm: item.productLengthCm ?? 0, productWidthCm: item.productWidthCm ?? 0,
productHeightCm: item.productHeightCm ?? 0, shipmentId: item.shipmentId ?? order.shipmentId ?? '',
referenceId: item.referenceId ?? order.referenceId ?? '',
})),
timeline: detail ? timeline(detail) : [], businessSummary: {
...businessSummary(),
totalPackageCount: order.totalPackageCount ?? null,
actualGrossWeightKg: order.totalGrossWeightKg ?? null,
actualVolumeCbm: order.totalVolumeCbm ?? null,
chargeableWeightKg: order.chargeableWeightKg ?? null,
inboundCode: order.inboundCode ?? '',
}, 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,
channelName: order.channelName, carrierId: order.carrierId == null ? '' : String(order.carrierId), carrierName: order.carrierName ?? '',
pricingTaxMode: order.pricingTaxMode ?? 'TAX_INCLUDED', taxMode: order.taxMode, customsInspection: order.customsInspection,
destinationType: order.destinationType, amazonWarehouseCode: order.amazonWarehouseCode ?? '', shipmentId: order.shipmentId ?? '',
requestedPickupAt: '', receivingTime: order.receivingTime ?? '', submittedAt: order.submittedAt ?? '', completedAt: order.completedAt ?? '',
}
}
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: summary.referenceId ?? '', transportMode: unifiedTransport(summary.containerMode),
businessType: 'EXPORT', fulfillmentType: summary.fulfillmentType ?? 'STANDARD', lxCode: summary.lxCode ?? '', 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: summary.expressCompany ?? '',
trackingNumber: summary.expressTrackingNo ?? summary.delivery?.trackingNo ?? '', skuNos: summary.skuNos ?? [], totalNetWeightKg: summary.totalNetWeightKg ?? null,
totalQuantity: summary.totalQuantity ?? null, chargeableVolumeCbm: summary.chargeableVolumeCbm ?? null,
inboundedAt: summary.inboundCompletedAt ?? '', businessDate: '', priority: 'NORMAL', customerRemark: summary.customerRemark ?? '', operatorName: '',
status: summary.mainStatus, version: summary.orderVersion, items: [], timeline: [], businessSummary: businessSummary(summary),
createdAt: summary.createdAt, updatedAt: summary.updatedAt, tradeTerm: '', destinationCountryCode: '',
destinationWarehouseId: summary.destinationWarehouseId == null ? '' : String(summary.destinationWarehouseId),
destinationWarehouseName: summary.destinationWarehouseName ?? '', pickupMethod: summary.pickupMethod ?? 'SELF_EXPRESS', channelCode: '',
channelName: summary.channelName ?? '', pricingTaxMode: 'TAX_INCLUDED', carrierId: '', carrierName: '', taxMode: summary.taxMode ?? 'BUY_DECLARATION', customsInspection: summary.customsInspection ?? false,
destinationType: summary.destinationType ?? 'PRIVATE', amazonWarehouseCode: summary.destinationCode ?? '', shipmentId: summary.shipmentId ?? '',
requestedPickupAt: summary.requestedPickupAt ?? '', receivingTime: '', submittedAt: '', completedAt: '',
}
}
function mapUnifiedOrderDetail(detail: MerchantUnifiedOrderDetail, warehouses: PlatformReceivingWarehouse[]): FirstLegOrder {
if (!detail?.summary || !detail.order?.order) throw new Error('订单详情数据不完整,请刷新列表后重试')
const mapped = mapOrder(detail.order.order, warehouses, detail.order)
mapped.id = String(detail.summary.orderId)
const rawStatus = detail.order.order.status
mapped.status = rawStatus === 'CANCELLED' || rawStatus === 'DRAFT' ? rawStatus : detail.summary.mainStatus
mapped.fulfillmentType = detail.summary.fulfillmentType ?? mapped.fulfillmentType
mapped.lxCode = detail.summary.lxCode ?? mapped.lxCode
mapped.businessSummary = { ...businessSummary(detail.summary), transportFragments: detail.transportFragments }
mapped.timeline = unifiedTimeline(detail)
const summary = mapUnifiedOrderRow(detail.summary, warehouses)
mapped.referenceNo = summary.referenceNo
mapped.transportMode = summary.transportMode
mapped.expressCompanyName = summary.expressCompanyName
mapped.trackingNumber = summary.trackingNumber
mapped.skuNos = summary.skuNos
mapped.totalNetWeightKg = summary.totalNetWeightKg
mapped.totalQuantity = summary.totalQuantity
mapped.chargeableVolumeCbm = summary.chargeableVolumeCbm
mapped.inboundedAt = summary.inboundedAt
mapped.pickupMethod = summary.pickupMethod
mapped.requestedPickupAt = summary.requestedPickupAt
mapped.destinationType = summary.destinationType
mapped.amazonWarehouseCode = summary.amazonWarehouseCode
mapped.shipmentId = summary.shipmentId
return mapped
}
function mapWarehouse(item: MerchantWarehouse): ClientWarehouse {
return {
id: String(item.id), code: item.warehouseCode, name: item.warehouseName, province: '',
city: '', address: '', contactName: item.contactName ?? '', contactPhone: item.contactPhone ?? '',
businessHours: '', status: item.status, remark: item.remark ?? '', updatedAt: item.updatedAt ?? '',
warehouseType: item.warehouseType as ClientWarehouseType, countryCode: item.countryCode,
}
}
export interface DraftEditSession {
readonly orderId: string
readonly expectedVersion: number
}
export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () => {
const orders = ref<FirstLegOrder[]>([])
const orderDetails = ref<Record<string, FirstLegOrder>>({})
const total = ref(0)
const queueCounts = ref<OrderQueueCounts>({ all: 0, fee: 0, customs: 0, draft: 0, loaded: 0, declared: 0, cleared: 0, scheduled: 0, delivering: 0, delivered: 0 })
const orderProgress = ref<Record<string, MerchantOrderProgress>>({})
const queryChannelNames = ref<string[]>([])
let listRequest = 0
const pendingSubmissions = new Map<string, { key: string; payload: { expectedVersion: number } }>()
const submissionForms = new Map<string, { snapshot: string; order: FirstLegOrder }>()
const submittingIds = new Set<string>()
const pendingEdits = new WeakMap<DraftEditSession, { key: string; payload: { expectedVersion: number; draft: FirstLegOrderSaveRequest } }>()
const pendingCancellations = new Map<string, { key: string; payload: { expectedVersion: number; reason: string } }>()
function beginDraftEdit(detail: FirstLegOrder): DraftEditSession {
if (!canClientEditFirstLegOrder(detail.status) || !Number.isSafeInteger(detail.version) || detail.version < 0) throw new Error('请重新打开草稿详情后编辑')
return Object.freeze({ orderId: detail.id, expectedVersion: detail.version })
}
const warehouses = ref<ClientWarehouse[]>([])
const loading = ref(false)
const loadedFromApi = ref(false)
const loadError = ref('')
const warehouseLoadError = ref('')
const loadedWarehouseTypes = ref<ClientWarehouseType[]>([])
const warehouseTypeRequests = new Map<ClientWarehouseType, Promise<ClientWarehouse[]>>()
function getById(id: string) { return orders.value.find((item) => item.id === id) ?? orderDetails.value[id] }
async function loadWarehouseType(warehouseType: ClientWarehouseType, force = false) {
if (warehouseType === 'DOMESTIC' && force) warehouseLoadError.value = ''
if (!force && loadedWarehouseTypes.value.includes(warehouseType)) return
const pending = warehouseTypeRequests.get(warehouseType)
if (pending) return pending
const request = (async () => {
const records = await loadAllWarehouseRecords(warehouseType, getMerchantWarehouses)
return records
.filter((item) => item.status === 'ACTIVE' && item.warehouseType === warehouseType)
.map(mapWarehouse)
})()
warehouseTypeRequests.set(warehouseType, request)
try {
const nextWarehouses = await request
warehouses.value = [...warehouses.value.filter((item) => item.warehouseType !== warehouseType), ...nextWarehouses]
loadedWarehouseTypes.value = [...loadedWarehouseTypes.value.filter((item) => item !== warehouseType), warehouseType]
} catch (error) {
if (warehouseType === 'DOMESTIC') warehouseLoadError.value = error instanceof Error ? error.message : '公共仓库数据加载失败'
throw error
} finally {
if (warehouseTypeRequests.get(warehouseType) === request) warehouseTypeRequests.delete(warehouseType)
}
}
async function load(query: MerchantOrderWorkbenchQuery = {}) {
const request = ++listRequest
loading.value = true
loadedFromApi.value = true
loadError.value = ''
orders.value = []
total.value = 0
// Keep the last confirmed category counts while refreshing; replace them only after validation.
orderProgress.value = {}
const [orderResult, warehouseResult] = await Promise.allSettled([
getMerchantOrderWorkbench(query), loadWarehouseType('DOMESTIC'),
])
if (request !== listRequest) return 0
if (warehouseResult.status === 'rejected') warehouseLoadError.value = warehouseResult.reason instanceof Error ? warehouseResult.reason.message : '公共仓库数据加载失败'
if (orderResult.status === 'fulfilled') {
try {
const result = orderResult.value
const expectedPage = query.page ?? 1, size = query.size ?? 10
const count = Number(result.page.total), pages = Math.ceil(count / size)
if (!Number.isSafeInteger(count) || count < 0 || Number(result.page.page) !== expectedPage
|| Number(result.page.size) !== size || Number(result.page.pages) !== pages
|| result.page.records.length !== Math.min(size, Math.max(0, count - (expectedPage - 1) * size))
|| new Set(result.page.records.map(row => String(row.orderId))).size !== result.page.records.length
|| ['all', 'fee', 'customs', 'draft', 'loaded', 'declared', 'cleared', 'scheduled', 'delivering', 'delivered'].some(key => { const value = result.queues[key as keyof typeof result.queues]; return !Number.isSafeInteger(Number(value)) || Number(value) < 0 })
|| Number(result.queues[query.queue ?? 'all']) !== count
|| !Array.isArray(result.progress) || result.progress.length !== result.page.records.length
|| new Set(result.progress.map(p => String(p.orderId))).size !== result.progress.length
|| result.progress.some(p => !result.page.records.some(row => String(row.orderId) === String(p.orderId))
|| !['none', 'loaded', 'declared', 'cleared', 'scheduled', 'delivering', 'delivered'].includes(p.stage) || typeof p.partial !== 'boolean')
|| !Array.isArray(result.channelNames)) {
loadError.value = '订单分页数据不完整,请重新加载'
} else {
orders.value = result.page.records.map(item => mapUnifiedOrderRow(item, warehouses.value))
total.value = count
queueCounts.value = Object.fromEntries(Object.entries(result.queues).map(([key, value]) => [key, Number(value)])) as OrderQueueCounts
orderProgress.value = Object.fromEntries(result.progress.map(p => [String(p.orderId), p]))
queryChannelNames.value = result.channelNames
}
} catch { loadError.value = '订单分页数据不完整,请重新加载' }
} else loadError.value = orderResult.reason instanceof Error ? orderResult.reason.message : '订单数据加载失败'
if (loadError.value) {
queueCounts.value = { all: 0, fee: 0, customs: 0, draft: 0, loaded: 0, declared: 0, cleared: 0, scheduled: 0, delivering: 0, delivered: 0 }
}
loading.value = false
return orders.value.length
}
function upsertOrder(mapped: FirstLegOrder) {
const index = orders.value.findIndex((item) => item.id === mapped.id)
if (index >= 0) orders.value[index] = mapped
orderDetails.value[mapped.id] = mapped
return mapped
}
async function loadOrderAggregate(id: string) {
const detail = await getMerchantFirstLegOrder(id)
return upsertOrder(mapOrder(detail.order, warehouses.value, detail))
}
async function loadDetail(id: string) {
const detail = await getMerchantUnifiedOrder(id)
return upsertOrder(mapUnifiedOrderDetail(detail, warehouses.value))
}
async function loadWarehouseDetail(id: string) {
const detail = await getMerchantWarehouseDetail(id)
const mapped = mapWarehouse(detail.warehouse)
const address = detail.addresses[0]
const receivingCapability = detail.capabilities.find((item) => item.enabled && item.cutoffTime)
if (address) {
mapped.province = address.stateProvince ?? ''
mapped.city = address.city ?? ''
mapped.address = [address.district, address.addressLine1, address.addressLine2].filter(Boolean).join(' ')
}
if (receivingCapability?.cutoffTime) mapped.businessHours = `每日 ${receivingCapability.cutoffTime.slice(0, 5)} 前`
const index = warehouses.value.findIndex((item) => item.id === id)
if (index >= 0) warehouses.value[index] = mapped
return mapped
}
async function saveDraft(draft: FirstLegOrderDraft, customerName: string, id?: string, editSession?: DraftEditSession) {
if (hasPendingSubmission(id)) throw new Error('上次提交结果未确认,请重试原提交,或确认重新编辑后再保存')
const warehouse = prepareDraftForSave(draft, warehouses.value)
if (id) {
if (!editSession || editSession.orderId !== id) throw new Error('请重新打开草稿详情后编辑')
const payload = { expectedVersion: editSession.expectedVersion, draft: savePayload(draft, warehouse, customerName) }
let pending = pendingEdits.get(editSession)
if (pending && JSON.stringify(pending.payload) !== JSON.stringify(payload)) throw new Error('上次保存结果未确认,请重试原内容,或重新打开详情后编辑')
// Observe an uncertain previous outcome without replacing its frozen version.
if (pending) await getMerchantFirstLegOrder(id)
if (!pending) {
pending = { key: createIdempotencyKey(`first-leg-edit-${id}`), payload: JSON.parse(JSON.stringify(payload)) }
pendingEdits.set(editSession, pending)
}
await updateMerchantFirstLegOrder(id, pending.payload, pending.key)
const saved = await loadOrderAggregate(id)
pendingEdits.delete(editSession)
return saved
}
const createdId = await createMerchantFirstLegOrder(savePayload(draft, warehouse, customerName))
return loadOrderAggregate(String(createdId))
}
async function cancelOrder(id: string, expectedVersion: number, reason: string) {
if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0 || !reason.trim() || reason.trim().length > 500) throw new Error('取消订单需要详情版本及 1–500 字取消原因')
const payload = { expectedVersion, reason: reason.trim() }
let pending = pendingCancellations.get(id)
if (pending && JSON.stringify(pending.payload) !== JSON.stringify(payload)) throw new Error('上次取消结果未确认,请使用原版本和原因重试')
if (pending) await getMerchantFirstLegOrder(id)
if (!pending) {
pending = { key: createIdempotencyKey(`first-leg-cancel-${id}`), payload }
pendingCancellations.set(id, pending)
}
await cancelMerchantFirstLegOrder(id, pending.payload, pending.key)
pendingCancellations.delete(id)
orders.value = orders.value.filter(order => order.id !== id)
}
async function prepareCancellation(id: string) {
const { order } = await getMerchantFirstLegOrder(id)
const labels: Record<string, string> = { DRAFT: '草稿', SUBMITTED: '审核中', APPROVED: '已审核', ACCEPTED: '已接单', REJECTED: '已退回' }
if (!Object.hasOwn(labels, order.status)) throw new Error('当前订单状态不允许取消,请刷新订单查看最新进度')
if (!Number.isSafeInteger(order.version) || order.version < 0) throw new Error('订单版本缺失,请重新加载详情')
pendingCancellations.delete(id)
return { id, expectedVersion: order.version, businessNo: order.orderNo || order.inboundCode || '—', statusLabel: labels[order.status] }
}
async function saveDraftBatch(
drafts: Array<{ clientRowId: string; draft: FirstLegOrderDraft }>,
customerName: string,
idempotencyKey: string,
): Promise<MerchantBatchDraftResponse> {
const items = drafts.map(({ clientRowId, draft }) => {
const warehouse = prepareDraftForSave(draft, warehouses.value)
return { clientRowId, draft: savePayload(draft, warehouse, customerName) }
})
return createMerchantFirstLegOrderBatch(items, idempotencyKey)
}
async function submitSaved(id: string, companyAuditStatus?: MerchantTenantStatus) {
if (companyAuditStatus && companyAuditStatus !== 'ACTIVE') throw new Error('企业资料尚未审核通过,请先完善企业资料并等待平台审核')
if (submittingIds.has(id)) throw new Error('订单正在提交,请稍候')
submittingIds.add(id)
try {
let pending = pendingSubmissions.get(id)
// Always observe the raw detail; a retry must retain the original command.
const { order } = await getMerchantFirstLegOrder(id)
if (!pending) {
if (order.status !== 'DRAFT') throw new Error('当前订单不是草稿,请刷新订单查看最新状态')
if (!Number.isSafeInteger(order.version) || order.version < 0) throw new Error('订单版本缺失,请重新加载详情')
pending = { key: createIdempotencyKey(`first-leg-submit-${id}`), payload: { expectedVersion: order.version } }
pendingSubmissions.set(id, pending)
}
await submitMerchantFirstLegOrder(id, pending.payload, pending.key)
const submitted = await loadOrderAggregate(id)
pendingSubmissions.delete(id)
submissionForms.delete(id)
return submitted
} finally {
submittingIds.delete(id)
}
}
function hasPendingSubmission(id?: string) { return !!id && (pendingSubmissions.has(id) || submissionForms.has(id)) }
function isSubmissionRetry(id: string | undefined, draft: FirstLegOrderDraft) {
return !!id && hasPendingSubmission(id) && submissionForms.get(id)?.snapshot === JSON.stringify(draft)
}
async function beginNewSubmissionEdit(id: string) {
if (submittingIds.has(id)) throw new Error('订单正在提交,请稍候')
const detail = await getMerchantFirstLegOrder(id)
if (detail.order.status !== 'DRAFT') throw new Error('订单已不在草稿状态,不能重新编辑,请刷新订单查看最新状态')
if (!Number.isSafeInteger(detail.order.version) || detail.order.version < 0) throw new Error('订单版本缺失,请重新加载详情')
const order = mapOrder(detail.order, warehouses.value, detail)
const session = beginDraftEdit(order)
pendingSubmissions.delete(id)
submissionForms.delete(id)
upsertOrder(order)
return session
}
async function saveDraftForSubmission(draft: FirstLegOrderDraft, customerName: string, id?: string, editSession?: DraftEditSession) {
if (id && isSubmissionRetry(id, draft)) return submissionForms.get(id)!.order
const snapshot = JSON.stringify(draft)
const order = await saveDraft(JSON.parse(snapshot), customerName, id, editSession)
submissionForms.set(order.id, { snapshot, order })
return order
}
async function loadDraftDetail(id: string) {
return loadOrderAggregate(id)
}
return { orders, orderDetails, total, queueCounts, orderProgress, queryChannelNames, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, getById, load, loadDetail, loadDraftDetail, loadWarehouseDetail, loadWarehouseType, beginDraftEdit, saveDraft, prepareCancellation, cancelOrder, saveDraftBatch, submitSaved, hasPendingSubmission, isSubmissionRetry, beginNewSubmissionEdit, saveDraftForSubmission }
})