发布当前测试版本至临时灰度环境 #11
@@ -1,4 +1,4 @@
|
||||
import { ApiError, apiRequest } from '@/api/http'
|
||||
import { apiRequest } from '@/api/http'
|
||||
import type { ApiContainerMode, ApiDestinationType, ApiFirstLegStatus, ApiPickupMethod, ApiTaxMode, MerchantFirstLegOrderDetail, PageResult } from '@/modules/first-leg/api/firstLeg'
|
||||
|
||||
export type ApiUnifiedOrderMainStatus =
|
||||
@@ -60,11 +60,12 @@ export interface MerchantUnifiedTransportSummary {
|
||||
|
||||
export interface MerchantUnifiedOrderRow {
|
||||
orderId: number | string
|
||||
orderVersion: number
|
||||
tenantId: number | string
|
||||
orderNo: string | null
|
||||
fulfillmentType?: 'STANDARD' | 'ONE_CLICK_DROPSHIP' | null
|
||||
lxCode?: string | null
|
||||
mainStatus: ApiUnifiedOrderMainStatus
|
||||
mainStatus: ApiUnifiedOrderMainStatus | 'DRAFT' | 'CANCELLED'
|
||||
nextAction: string | null
|
||||
customerFeeConfirmationRequired: boolean
|
||||
internalOrderStatus: ApiFirstLegStatus | null
|
||||
@@ -158,6 +159,31 @@ export interface MerchantUnifiedOrderQuery {
|
||||
|
||||
const unifiedOrderBaseUrl = '/merchant/v1/orders'
|
||||
|
||||
export interface MerchantOrderWorkbenchQuery {
|
||||
cargoKeyword?: string
|
||||
receivingWarehouseId?: string
|
||||
channelName?: string
|
||||
destinationType?: string
|
||||
trackingNumber?: string
|
||||
mainStatus?: string
|
||||
createdFrom?: string
|
||||
createdTo?: string
|
||||
queue?: 'all' | 'fee' | 'customs' | 'draft'
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface MerchantOrderWorkbench {
|
||||
page: PageResult<MerchantUnifiedOrderRow>
|
||||
queues: { all: number; fee: number; customs: number; draft: number }
|
||||
channelNames: string[]
|
||||
}
|
||||
|
||||
export function getMerchantOrderWorkbench(params: MerchantOrderWorkbenchQuery = {}) {
|
||||
return apiRequest<MerchantOrderWorkbench>({ method: 'GET', url: `${unifiedOrderBaseUrl}/workbench`,
|
||||
params: { ...params, page: params.page ?? 1, size: params.size ?? 10 } })
|
||||
}
|
||||
|
||||
export function getMerchantUnifiedOrders(params: MerchantUnifiedOrderQuery = {}) {
|
||||
const { page, size, ...filters } = params
|
||||
return apiRequest<PageResult<MerchantUnifiedOrderRow>>({
|
||||
@@ -173,7 +199,3 @@ export function getMerchantUnifiedOrder(orderId: number | string) {
|
||||
url: `${unifiedOrderBaseUrl}/${orderId}`,
|
||||
})
|
||||
}
|
||||
|
||||
export function isUnifiedOrderQueryUnavailable(error: unknown) {
|
||||
return error instanceof ApiError && (error.status === 404 || error.code === 'A0404')
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ function harness(t) {
|
||||
'@/modules/first-leg/api/firstLeg': api,
|
||||
'@/modules/first-leg/api/unifiedOrders': {
|
||||
getMerchantUnifiedOrder: async () => ({ summary: { orderId: 'one', mainStatus: null }, order: { order: { id: 'one', status: behavior.status, version: behavior.version, receivingWarehouseId: 'warehouse', receivingWarehouseAddress: behavior.receivingWarehouseAddress } }, timeline: [] }),
|
||||
isUnifiedOrderQueryUnavailable: () => false,
|
||||
},
|
||||
'@/modules/first-leg/utils/firstLegOrder': { canClientEditFirstLegOrder: status => status === 'DRAFT', getFirstLegStatusLabel: value => value },
|
||||
'@/modules/first-leg/utils/warehousePagination': {},
|
||||
@@ -131,7 +130,7 @@ test('edit freezes detail version rather than cached list version and replays sa
|
||||
const h = harness(t)
|
||||
const original = await h.store.loadDraftDetail('one')
|
||||
const session = h.store.beginDraftEdit(original)
|
||||
h.store.orders[0].version = 0
|
||||
h.store.orders = [{ ...original, version: 0 }]
|
||||
h.behavior.failWrite = true
|
||||
await assert.rejects(h.store.saveDraft(h.draft, '测试企业', 'one', session), /响应丢失/)
|
||||
const first = h.calls.find(call => call.method === 'PUT')
|
||||
@@ -233,6 +232,7 @@ function cancellationViewHarness() {
|
||||
}
|
||||
const ElMessage = { error: message => messages.push(message), warning: message => messages.push(message), success: message => messages.push(message) }
|
||||
const globals = { canCancelOrder, cancellingOrderId, pendingCancellation, store, ElMessageBox, ElMessage, ApiError,
|
||||
loadOrderPage: async () => calls.push(['load']),
|
||||
isDetailPage: vue.ref(true), router: { push: async route => calls.push(['route', route]) } }
|
||||
const handler = new Function(...Object.keys(globals), code + '\nreturn cancelOrder')(...Object.values(globals))
|
||||
return { handler, calls, messages, prompts, canCancelOrder, pendingCancellation, behavior, ApiError }
|
||||
|
||||
@@ -27,22 +27,10 @@ test('renders estimated sailing from the current API without substituting actual
|
||||
assert.equal(map({ transport: null }).estimatedPortArrivalAt, '')
|
||||
})
|
||||
|
||||
test('maps the first-leg aggregate list fields without loading detail rows', () => {
|
||||
assert.match(apiSource, /interface MerchantFirstLegOrderRow/)
|
||||
assert.match(apiSource, /skuNos: string \| string\[\] \| null/)
|
||||
assert.match(apiSource, /cargoes\?: Array<\{ skuNo\?: string \| null \}>/)
|
||||
assert.match(apiSource, /apiRequest<PageResult<MerchantFirstLegOrderRow>>/)
|
||||
assert.match(storeSource, /function mapFirstLegOrderRow/)
|
||||
for (const field of ['inboundId', 'inboundCode', 'channelName', 'destinationWarehouseName', 'pickupMethod', 'packageCount', 'expressCompany', 'trackingNo', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'remark', 'shipmentId', 'fulfillmentType', 'lxCode', 'referenceId', 'createdAt']) {
|
||||
assert.match(storeSource, new RegExp(`row\\.${field}`))
|
||||
}
|
||||
assert.match(storeSource, /row\.skuNos/)
|
||||
assert.match(storeSource, /row\.cargoes/)
|
||||
assert.match(storeSource, /chargeableWeightKg: null/)
|
||||
assert.match(storeSource, /chargeableVolumeCbm: null/)
|
||||
assert.match(apiSource, /pickupMethod: ApiPickupMethod \\| null/)
|
||||
assert.match(storeSource, /orderResult\.value\.records\.map\(mapFirstLegOrderRow\)/)
|
||||
assert.match(storeSource, /orderResult\.value\.records\.map\(\(item\) => mapUnifiedOrderRow\(item, warehouses\.value\)\)/)
|
||||
test('maps the current workbench row without loading detail rows', () => {
|
||||
assert.match(storeSource, /result\.page\.records\.map\(item => mapUnifiedOrderRow\(item, warehouses\.value\)\)/)
|
||||
assert.match(storeSource, /version: summary\.orderVersion/)
|
||||
assert.doesNotMatch(storeSource, /function mapFirstLegOrderRow/)
|
||||
})
|
||||
|
||||
test('declares the documented merchant warehouse and first-leg endpoints', () => {
|
||||
@@ -85,14 +73,10 @@ test('maps transport and status fields instead of treating container mode as tra
|
||||
assert.doesNotMatch(storeSource, /status === 'COMPLETED'\) return 'DELIVERED'/)
|
||||
})
|
||||
|
||||
test('prefers unified reads and only falls back to the real legacy API when C02 is unavailable', () => {
|
||||
test('uses only the formal workbench read and surfaces failures', () => {
|
||||
assert.match(storeSource, /getMerchantOrderWorkbench\(query\)/)
|
||||
assert.match(storeSource, /orders\.value = \[\]/)
|
||||
assert.match(storeSource, /warehouses\.value = \[\]/)
|
||||
assert.match(storeSource, /Promise\.allSettled\(\[getOrdersForClient\(query\), loadWarehouseType\('DOMESTIC', true\)\]\)/)
|
||||
assert.match(storeSource, /getMerchantUnifiedOrders\(unifiedQuery\)/)
|
||||
assert.match(storeSource, /isUnifiedOrderQueryUnavailable\(error\)/)
|
||||
assert.match(storeSource, /getMerchantFirstLegOrders\(legacyQuery\(query\)\)/)
|
||||
assert.doesNotMatch(storeSource, /DEMO_PLATFORM_RECEIVING_WAREHOUSES|initialOrders/)
|
||||
assert.doesNotMatch(storeSource, /isUnifiedOrderQueryUnavailable|getOrdersForClient|getMerchantFirstLegOrders|initialOrders/)
|
||||
})
|
||||
|
||||
test('declares the final C02 row and detail contract in the API adapter', () => {
|
||||
@@ -115,7 +99,6 @@ test('declares the final C02 row and detail contract in the API adapter', () =>
|
||||
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 unified order pagination valid when the store omits page and size', () => {
|
||||
@@ -123,21 +106,10 @@ test('keeps unified order pagination valid when the store omits page and size',
|
||||
assert.match(unifiedApiSource, /params: \{ page: page \?\? 1, size: size \?\? 200, \.\.\.filters \}/)
|
||||
})
|
||||
|
||||
test('keeps drafts on the legacy read path without assigning a unified main status', () => {
|
||||
assert.match(storeSource, /getMerchantFirstLegOrders\(legacyQuery\(query, 'DRAFT'\)\)/)
|
||||
assert.match(storeSource, /const listOrder = getById\(id\)/)
|
||||
assert.match(storeSource, /listOrder\?\.status === 'DRAFT' \? loadLegacyDetail\(id, listOrder\) : loadUnifiedDetail\(id\)/)
|
||||
assert.match(storeSource, /detail\.order\.lxCode == null && listOrder\?\.lxCode/)
|
||||
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, /inboundCode: query\.inboundCode/)
|
||||
assert.match(storeSource, /getMerchantUnifiedOrders\(unifiedQuery\)/)
|
||||
test('draft editing reads the order aggregate while navigation uses unified detail', () => {
|
||||
assert.match(storeSource, /return loadOrderAggregate\(id\)/)
|
||||
assert.match(storeSource, /const detail = await getMerchantUnifiedOrder\(id\)/)
|
||||
assert.doesNotMatch(storeSource, /loadLegacyDetail|draftOnly|includeDrafts/)
|
||||
})
|
||||
|
||||
test('maps C02 authoritative status, transport, cargo and pickup snapshots', () => {
|
||||
@@ -195,7 +167,7 @@ test('separates domestic receiving warehouses from public destinations', () => {
|
||||
assert.match(apiSource, /warehouseType\?: MerchantWarehouseType/)
|
||||
assert.match(apiSource, /params: \{ page: 1, size: 200, status: 'ACTIVE', \.\.\.params \}/)
|
||||
assert.match(storeSource, /loadAllWarehouseRecords\(warehouseType, getMerchantWarehouses\)/)
|
||||
assert.match(storeSource, /loadWarehouseType\('DOMESTIC', true\)/)
|
||||
assert.match(storeSource, /loadWarehouseType\('DOMESTIC'\)/)
|
||||
assert.match(storeSource, /warehouse\.warehouseType !== 'DOMESTIC'/)
|
||||
assert.match(storeSource, /item\.warehouseType === draft\.destinationType/)
|
||||
assert.match(storeSource, /draft\.amazonWarehouseCode = draft\.destinationType === 'FBA' \? destinationWarehouse\.code : ''/)
|
||||
@@ -264,5 +236,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, draftLoadError/)
|
||||
assert.match(storeSource, /return \{ orders, orderDetails, total, queueCounts, queryChannelNames, warehouses, loading, loadedFromApi, loadError, warehouseLoadError/)
|
||||
})
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
createMerchantFirstLegOrderBatch,
|
||||
createMerchantFirstLegOrder,
|
||||
createIdempotencyKey,
|
||||
getMerchantFirstLegOrder,
|
||||
getMerchantFirstLegOrders,
|
||||
getMerchantWarehouseDetail,
|
||||
getMerchantWarehouses,
|
||||
submitMerchantFirstLegOrder,
|
||||
@@ -14,17 +13,15 @@ import {
|
||||
type ApiFirstLegStatus,
|
||||
type FirstLegOrderSaveRequest,
|
||||
type MerchantFirstLegOrder,
|
||||
type MerchantFirstLegOrderRow,
|
||||
type MerchantFirstLegOrderDetail,
|
||||
type MerchantBatchDraftResponse,
|
||||
type MerchantWarehouse,
|
||||
} from '@/modules/first-leg/api/firstLeg'
|
||||
import {
|
||||
getMerchantUnifiedOrder,
|
||||
getMerchantUnifiedOrders,
|
||||
isUnifiedOrderQueryUnavailable,
|
||||
getMerchantOrderWorkbench,
|
||||
type MerchantUnifiedOrderDetail,
|
||||
type MerchantUnifiedOrderQuery,
|
||||
type MerchantOrderWorkbenchQuery,
|
||||
type MerchantUnifiedOrderRow,
|
||||
} from '@/modules/first-leg/api/unifiedOrders'
|
||||
import type { MerchantTenantStatus } from '@/modules/tenant-management/public'
|
||||
@@ -342,7 +339,7 @@ function mapUnifiedOrderRow(summary: MerchantUnifiedOrderRow, warehouses: Platfo
|
||||
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: 0, items: [], timeline: [], businessSummary: businessSummary(summary),
|
||||
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: '',
|
||||
@@ -352,36 +349,6 @@ function mapUnifiedOrderRow(summary: MerchantUnifiedOrderRow, warehouses: Platfo
|
||||
}
|
||||
}
|
||||
|
||||
function mapFirstLegOrderRow(row: MerchantFirstLegOrderRow): FirstLegOrder {
|
||||
const skuNos = Array.isArray(row.skuNos)
|
||||
? row.skuNos
|
||||
: (row.skuNos ?? '').split('、').map((value) => value.trim()).filter(Boolean)
|
||||
const resolvedSkuNos = skuNos.length ? skuNos : (row.cargoes ?? [])
|
||||
.map((cargo) => cargo.skuNo?.trim())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
const uniqueSkuNos = [...new Set(resolvedSkuNos)]
|
||||
return {
|
||||
id: String(row.orderId), orderNo: row.orderNo, referenceNo: row.referenceId ?? '', transportMode: null,
|
||||
businessType: 'EXPORT', fulfillmentType: row.fulfillmentType ?? 'STANDARD', lxCode: row.lxCode ?? '', origin: '', destination: row.destinationWarehouseName ?? '', receivingWarehouseId: '',
|
||||
receivingWarehouseName: '', receivingWarehouseSnapshot: null, expectedDepartureDate: '', etd: '', eta: '',
|
||||
shipperPartyId: '', shipperPartySnapshot: null, shipper: '', consigneePartyId: '', consigneePartySnapshot: null,
|
||||
consignee: row.receivingCompanyName ?? '', expressCompanyName: row.expressCompany ?? '', trackingNumber: row.trackingNo ?? '',
|
||||
skuNos: uniqueSkuNos, totalNetWeightKg: row.totalNetWeightKg, totalQuantity: row.totalQuantity,
|
||||
chargeableVolumeCbm: null, inboundedAt: row.inboundedAt ?? '', businessDate: '', priority: 'NORMAL', customerRemark: row.remark ?? '',
|
||||
operatorName: '', status: clientStatus(row.status), version: 0, items: [], timeline: [],
|
||||
businessSummary: {
|
||||
...businessSummary(), totalPackageCount: row.packageCount, actualGrossWeightKg: row.totalGrossWeightKg ?? row.weightKg,
|
||||
actualVolumeCbm: row.totalVolumeCbm, chargeableWeightKg: null,
|
||||
inboundId: row.inboundId == null ? '' : String(row.inboundId), inboundCode: row.inboundCode ?? '',
|
||||
},
|
||||
createdAt: row.createdAt, updatedAt: row.createdAt, tradeTerm: '', destinationCountryCode: '', destinationWarehouseId: '',
|
||||
destinationWarehouseName: row.destinationWarehouseName ?? '', pickupMethod: row.pickupMethod ?? 'SELF_EXPRESS', channelCode: '',
|
||||
channelName: row.channelName ?? '', pricingTaxMode: 'TAX_INCLUDED', carrierId: '', carrierName: '', taxMode: 'BUY_DECLARATION',
|
||||
customsInspection: false, destinationType: row.destinationType ?? 'PRIVATE', amazonWarehouseCode: '',
|
||||
shipmentId: row.shipmentId ?? row.internationalOrderNo ?? '', 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)
|
||||
@@ -410,59 +377,6 @@ function mapUnifiedOrderDetail(detail: MerchantUnifiedOrderDetail, warehouses: P
|
||||
return mapped
|
||||
}
|
||||
|
||||
type OrderListReadResult =
|
||||
| { source: 'first-leg'; records: MerchantFirstLegOrderRow[] }
|
||||
| { source: 'unified'; records: MerchantUnifiedOrderRow[]; drafts: MerchantFirstLegOrderRow[] }
|
||||
|
||||
interface ClientOrderReadQuery extends MerchantUnifiedOrderQuery {
|
||||
fbaCode?: string
|
||||
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 response = await getMerchantFirstLegOrders(legacyQuery(query, 'DRAFT'))
|
||||
return { source: 'first-leg', records: response.records }
|
||||
}
|
||||
|
||||
const unifiedQuery: MerchantUnifiedOrderQuery = {
|
||||
keyword: query.keyword,
|
||||
orderNo: query.orderNo,
|
||||
mainStatus: query.mainStatus,
|
||||
receivingWarehouseId: query.receivingWarehouseId,
|
||||
destinationWarehouseId: query.destinationWarehouseId,
|
||||
inboundCode: query.inboundCode,
|
||||
createdFrom: query.createdFrom,
|
||||
createdTo: query.createdTo,
|
||||
fbaCode: query.fbaCode,
|
||||
page: query.page,
|
||||
size: query.size,
|
||||
}
|
||||
try {
|
||||
const [response, draftResponse] = await Promise.all([
|
||||
getMerchantUnifiedOrders(unifiedQuery),
|
||||
query.includeDrafts
|
||||
? getMerchantFirstLegOrders(legacyQuery(query, 'DRAFT'))
|
||||
: Promise.resolve({ records: [] as MerchantFirstLegOrderRow[] }),
|
||||
])
|
||||
return { source: 'unified', records: response.records, drafts: draftResponse.records }
|
||||
} catch (error) {
|
||||
if (!isUnifiedOrderQueryUnavailable(error)) throw error
|
||||
const response = await getMerchantFirstLegOrders(legacyQuery(query))
|
||||
return { source: 'first-leg', records: response.records }
|
||||
}
|
||||
}
|
||||
|
||||
function mapWarehouse(item: MerchantWarehouse): ClientWarehouse {
|
||||
return {
|
||||
id: String(item.id), code: item.warehouseCode, name: item.warehouseName, province: '',
|
||||
@@ -479,6 +393,11 @@ export interface DraftEditSession {
|
||||
|
||||
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({ all: 0, fee: 0, customs: 0, draft: 0 })
|
||||
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>()
|
||||
@@ -494,13 +413,10 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
|
||||
const loadedFromApi = ref(false)
|
||||
const loadError = ref('')
|
||||
const warehouseLoadError = ref('')
|
||||
const draftLoadError = ref('')
|
||||
const loadedWarehouseTypes = ref<ClientWarehouseType[]>([])
|
||||
const warehouseTypeRequests = new Map<ClientWarehouseType, Promise<ClientWarehouse[]>>()
|
||||
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) }
|
||||
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 = ''
|
||||
@@ -527,28 +443,40 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
|
||||
}
|
||||
}
|
||||
|
||||
async function load(query: ClientOrderReadQuery = {}) {
|
||||
async function load(query: MerchantOrderWorkbenchQuery = {}) {
|
||||
const request = ++listRequest
|
||||
loading.value = true
|
||||
loadedFromApi.value = true
|
||||
loadError.value = ''
|
||||
warehouseLoadError.value = ''
|
||||
draftLoadError.value = ''
|
||||
loadedWarehouseTypes.value = []
|
||||
warehouseTypeRequests.clear()
|
||||
orders.value = []
|
||||
warehouses.value = []
|
||||
const [orderResult, warehouseResult] = await Promise.allSettled([getOrdersForClient(query), loadWarehouseType('DOMESTIC', true)])
|
||||
if (warehouseResult.status === 'rejected' && !warehouseLoadError.value) warehouseLoadError.value = warehouseResult.reason instanceof Error ? warehouseResult.reason.message : '公共仓库数据加载失败'
|
||||
total.value = 0
|
||||
queueCounts.value = { all: 0, fee: 0, customs: 0, draft: 0 }
|
||||
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') {
|
||||
orders.value = orderResult.value.source === 'unified'
|
||||
? [
|
||||
...orderResult.value.drafts.map(mapFirstLegOrderRow),
|
||||
...orderResult.value.records.map((item) => mapUnifiedOrderRow(item, warehouses.value)),
|
||||
]
|
||||
: orderResult.value.records.map(mapFirstLegOrderRow)
|
||||
} else {
|
||||
loadError.value = orderResult.reason instanceof Error ? orderResult.reason.message : '订单数据加载失败'
|
||||
}
|
||||
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'].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.channelNames)) {
|
||||
loadError.value = '订单分页数据不完整,请重新加载'
|
||||
} else {
|
||||
orders.value = result.page.records.map(item => mapUnifiedOrderRow(item, warehouses.value))
|
||||
total.value = count
|
||||
queueCounts.value = { all: Number(result.queues.all), fee: Number(result.queues.fee), customs: Number(result.queues.customs), draft: Number(result.queues.draft) }
|
||||
queryChannelNames.value = result.channelNames
|
||||
}
|
||||
} catch { loadError.value = '订单分页数据不完整,请重新加载' }
|
||||
} else loadError.value = orderResult.reason instanceof Error ? orderResult.reason.message : '订单数据加载失败'
|
||||
loading.value = false
|
||||
return orders.value.length
|
||||
}
|
||||
@@ -556,38 +484,18 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
|
||||
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)
|
||||
orderDetails.value[mapped.id] = mapped
|
||||
return mapped
|
||||
}
|
||||
|
||||
async function loadLegacyDetail(id: string, listOrder?: FirstLegOrder) {
|
||||
async function loadOrderAggregate(id: string) {
|
||||
const detail = await getMerchantFirstLegOrder(id)
|
||||
const mapped = mapOrder(detail.order, warehouses.value, detail)
|
||||
// Older detail responses may omit newly added dropship fields. Keep the
|
||||
// values already returned by the list row so editing a draft does not
|
||||
// clear the persisted LX code in the form.
|
||||
if (detail.order.fulfillmentType == null && listOrder?.fulfillmentType) {
|
||||
mapped.fulfillmentType = listOrder.fulfillmentType
|
||||
}
|
||||
if (detail.order.lxCode == null && listOrder?.lxCode) {
|
||||
mapped.lxCode = listOrder.lxCode
|
||||
}
|
||||
return upsertOrder(mapped)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return upsertOrder(mapOrder(detail.order, warehouses.value, detail))
|
||||
}
|
||||
|
||||
async function loadDetail(id: string) {
|
||||
const listOrder = getById(id)
|
||||
return listOrder?.status === 'DRAFT' ? loadLegacyDetail(id, listOrder) : loadUnifiedDetail(id)
|
||||
const detail = await getMerchantUnifiedOrder(id)
|
||||
return upsertOrder(mapUnifiedOrderDetail(detail, warehouses.value))
|
||||
}
|
||||
|
||||
async function loadWarehouseDetail(id: string) {
|
||||
@@ -621,12 +529,12 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
|
||||
pendingEdits.set(editSession, pending)
|
||||
}
|
||||
await updateMerchantFirstLegOrder(id, pending.payload, pending.key)
|
||||
const saved = await loadLegacyDetail(id)
|
||||
const saved = await loadOrderAggregate(id)
|
||||
pendingEdits.delete(editSession)
|
||||
return saved
|
||||
}
|
||||
const createdId = await createMerchantFirstLegOrder(savePayload(draft, warehouse, customerName))
|
||||
return loadLegacyDetail(String(createdId))
|
||||
return loadOrderAggregate(String(createdId))
|
||||
}
|
||||
|
||||
async function cancelOrder(id: string, expectedVersion: number, reason: string) {
|
||||
@@ -680,7 +588,7 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
|
||||
pendingSubmissions.set(id, pending)
|
||||
}
|
||||
await submitMerchantFirstLegOrder(id, pending.payload, pending.key)
|
||||
const submitted = await loadLegacyDetail(id)
|
||||
const submitted = await loadOrderAggregate(id)
|
||||
pendingSubmissions.delete(id)
|
||||
submissionForms.delete(id)
|
||||
return submitted
|
||||
@@ -717,8 +625,8 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
|
||||
}
|
||||
|
||||
async function loadDraftDetail(id: string) {
|
||||
return loadLegacyDetail(id, getById(id))
|
||||
return loadOrderAggregate(id)
|
||||
}
|
||||
|
||||
return { orders, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, draftLoadError, activeCount, pendingCount, getById, load, loadDetail, loadDraftDetail, loadWarehouseDetail, loadWarehouseType, beginDraftEdit, saveDraft, prepareCancellation, cancelOrder, saveDraftBatch, submitSaved, hasPendingSubmission, isSubmissionRetry, beginNewSubmissionEdit, saveDraftForSubmission }
|
||||
return { orders, orderDetails, total, queueCounts, queryChannelNames, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, getById, load, loadDetail, loadDraftDetail, loadWarehouseDetail, loadWarehouseType, beginDraftEdit, saveDraft, prepareCancellation, cancelOrder, saveDraftBatch, submitSaved, hasPendingSubmission, isSubmissionRetry, beginNewSubmissionEdit, saveDraftForSubmission }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
import * as vue from 'vue'
|
||||
import * as pinia from 'pinia'
|
||||
|
||||
const source = readFileSync(new URL('./firstLegOrders.ts', import.meta.url), 'utf8')
|
||||
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const row = (id, mainStatus = 'LOADED') => ({ orderId: String(id), orderNo: `FL-${id}`, mainStatus, orderVersion: 7 })
|
||||
function result(page = 1, size = 10, total = 215) {
|
||||
return {
|
||||
page: { page, size, total, pages: Math.ceil(total / size), records: Array.from({ length: Math.min(size, Math.max(0, total - (page - 1) * size)) }, (_, i) => row((page - 1) * size + i + 1)) },
|
||||
queues: { all: total, fee: 2, customs: 1, draft: 3 }, channelNames: ['AIR', 'SEA'],
|
||||
}
|
||||
}
|
||||
function deferred() { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b }); return { promise, resolve, reject } }
|
||||
function harness(t, read) {
|
||||
const context = pinia.createPinia(); pinia.setActivePinia(context)
|
||||
t.after(() => pinia.disposePinia(context))
|
||||
const calls = [], details = [], warehouseCalls = []
|
||||
const deps = {
|
||||
vue, pinia,
|
||||
'@/modules/first-leg/api/firstLeg': {},
|
||||
'@/modules/first-leg/api/unifiedOrders': {
|
||||
getMerchantOrderWorkbench: query => { calls.push(structuredClone(query)); return read(query) },
|
||||
getMerchantUnifiedOrder: async id => {
|
||||
details.push(id)
|
||||
return { summary: row(id, 'DRAFT'), order: { order: { id, status: 'DRAFT', version: 7 } }, timeline: [] }
|
||||
},
|
||||
},
|
||||
'@/modules/first-leg/utils/firstLegOrder': { getFirstLegStatusLabel: value => value },
|
||||
'@/modules/first-leg/utils/warehousePagination': { loadAllWarehouseRecords: async type => { warehouseCalls.push(type); return [] } },
|
||||
}
|
||||
const exports = {}
|
||||
new Function('require', 'exports', code)(id => { assert.ok(id in deps, id); return deps[id] }, exports)
|
||||
return { store: exports.useFirstLegOrdersStore(), calls, details, warehouseCalls }
|
||||
}
|
||||
|
||||
test('loads beyond 200 with server totals and forwards every filter using one request per page', async t => {
|
||||
const h = harness(t, q => result(q.page, q.size))
|
||||
const query = { page: 21, size: 10, cargoKeyword: 'SKU', channelName: 'SEA', receivingWarehouseId: '42', destinationType: 'FBA', trackingNumber: 'TRACK', mainStatus: 'LOADED', queue: 'all', createdFrom: '2026-10-01T00:00:00', createdTo: '2026-10-03T23:59:59.999' }
|
||||
assert.equal(await h.store.load(query), 10)
|
||||
assert.equal(h.store.orders[0].id, '201')
|
||||
assert.equal(h.store.total, 215)
|
||||
assert.deepEqual(h.calls, [query])
|
||||
assert.deepEqual(h.store.queueCounts, { all: 215, fee: 2, customs: 1, draft: 3 })
|
||||
assert.deepEqual(h.store.queryChannelNames, ['AIR', 'SEA'])
|
||||
await h.store.load({ page: 22, size: 10 })
|
||||
assert.deepEqual(h.store.orders.map(item => item.id), ['211', '212', '213', '214', '215'])
|
||||
assert.equal(h.calls.length, 2)
|
||||
assert.deepEqual(h.warehouseCalls, ['DOMESTIC'])
|
||||
})
|
||||
|
||||
test('late success cannot replace newer rows, counts, channels or loading state', async t => {
|
||||
const a = deferred(), b = deferred()
|
||||
const h = harness(t, q => q.page === 1 ? a.promise : b.promise)
|
||||
const first = h.store.load({ page: 1 }), second = h.store.load({ page: 2 })
|
||||
b.resolve(result(2)); await second
|
||||
a.resolve(result(1, 10, 999)); await first
|
||||
assert.equal(h.store.orders[0].id, '11'); assert.equal(h.store.total, 215)
|
||||
assert.equal(h.store.queueCounts.all, 215); assert.equal(h.store.loading, false)
|
||||
})
|
||||
|
||||
test('late failure cannot clear the current page and an older completion cannot stop loading a newer page', async t => {
|
||||
const a = deferred(), b = deferred(), c = deferred()
|
||||
const h = harness(t, q => [a, b, c][q.page - 1].promise)
|
||||
const first = h.store.load({ page: 1 }), second = h.store.load({ page: 2 })
|
||||
a.resolve(result(1)); await first
|
||||
assert.equal(h.store.loading, true); assert.equal(h.store.orders.length, 0)
|
||||
const third = h.store.load({ page: 3 })
|
||||
c.resolve(result(3)); await third
|
||||
b.reject(new Error('old failure')); await second
|
||||
assert.equal(h.store.orders[0].id, '21'); assert.equal(h.store.loadError, '')
|
||||
})
|
||||
|
||||
test('current failure clears stale records and totals, never falls back, and retry restores the page', async t => {
|
||||
let fail = false
|
||||
const h = harness(t, () => fail ? Promise.reject(new Error('查询失败')) : result())
|
||||
await h.store.load(); fail = true; await h.store.load()
|
||||
assert.equal(h.store.orders.length, 0); assert.equal(h.store.total, 0)
|
||||
assert.equal(h.store.queueCounts.all, 0); assert.equal(h.store.loadError, '查询失败')
|
||||
assert.equal(h.store.loading, false)
|
||||
fail = false; await h.store.load(); assert.equal(h.store.orders.length, 10)
|
||||
assert.equal(h.calls.length, 3); assert.equal(h.store.loadError, '')
|
||||
})
|
||||
|
||||
test('rejects inconsistent metadata, truncated pages and duplicate rows without displaying partial data', async t => {
|
||||
let response
|
||||
const h = harness(t, () => response)
|
||||
const corruptions = [r => { r.page.total = -1 }, r => { r.page.pages = 1 }, r => { r.page.page = 2 }, r => { r.page.records.pop() }, r => { r.page.records[1] = r.page.records[0] }, r => { r.queues.all = 99 }, r => { delete r.queues }, r => { r.channelNames = null }]
|
||||
for (const corrupt of corruptions) {
|
||||
response = result(); corrupt(response); await h.store.load()
|
||||
assert.equal(h.store.orders.length, 0); assert.equal(h.store.total, 0)
|
||||
assert.match(h.store.loadError, /分页数据不完整/); assert.equal(h.store.loading, false)
|
||||
}
|
||||
})
|
||||
|
||||
test('draft and cancelled rows retain authoritative versions; off-page detail cannot inflate list or totals', async t => {
|
||||
const response = result(1, 10, 2)
|
||||
response.page.records = [row(1, 'DRAFT'), row(2, 'CANCELLED')]
|
||||
const h = harness(t, () => response)
|
||||
await h.store.load()
|
||||
assert.deepEqual(h.store.orders.map(item => [item.status, item.version]), [['DRAFT', 7], ['CANCELLED', 7]])
|
||||
await h.store.loadDetail('900')
|
||||
assert.equal(h.store.orders.length, 2); assert.equal(h.store.total, 2)
|
||||
assert.equal(h.store.getById('900').status, 'DRAFT')
|
||||
})
|
||||
|
||||
test('queue total is checked against the selected queue rather than all orders', async t => {
|
||||
const response = result(1, 10, 2)
|
||||
response.queues = { all: 215, fee: 2, customs: 1, draft: 3 }
|
||||
const h = harness(t, () => response)
|
||||
await h.store.load({ queue: 'fee' })
|
||||
assert.equal(h.store.total, 2); assert.equal(h.store.queueCounts.all, 215)
|
||||
assert.equal(h.store.loadError, '')
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const source = readFileSync(new URL('./customsEligibility.ts', import.meta.url), 'utf8')
|
||||
const exports = {}
|
||||
@@ -17,17 +17,16 @@ test('upload queue contains only customer uploads still required, while complete
|
||||
for (const status of ['LOADED', 'DELIVERED', 'COMPLETED']) assert.equal(exports.isCustomsOrderCandidate({ status, taxMode: 'TAX_REFUND' }), true)
|
||||
})
|
||||
|
||||
test('actual page count and queue filter both drop a record when its upload is complete', () => {
|
||||
test('upload status changes refresh server counts while initial detail reads do not reload the list', async () => {
|
||||
const page = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8')
|
||||
const block = page.slice(page.indexOf('const orderQueueCounts ='), page.indexOf('const pagedOrders ='))
|
||||
const rows = ref([{ id: 'waiting', businessSummary: {} }, { id: 'completed', businessSummary: {} }])
|
||||
const states = ref({ waiting: { status: 'WAITING_CUSTOMER_DOCUMENTS', merchantStatus: 'WAITING_UPLOAD' }, completed: { status: 'COMPLETED', merchantStatus: 'CUSTOMS_COMPLETED' } })
|
||||
const queue = ref('customs')
|
||||
const read = new Function('computed', 'queryMatchedOrders', 'activeOrderQueue', 'eligibleCustomsDetail', 'needsCustomsUpload', block + '\nreturn { orderQueueCounts, filteredOrders }')
|
||||
const view = read(computed, rows, queue, order => states.value[order.id], exports.needsCustomsUpload)
|
||||
assert.equal(view.orderQueueCounts.value.customs, 1)
|
||||
assert.deepEqual(view.filteredOrders.value.map(row => row.id), ['waiting'])
|
||||
states.value.waiting.merchantStatus = 'DOCUMENT_UPLOADED'
|
||||
assert.equal(view.orderQueueCounts.value.customs, 0); assert.equal(view.filteredOrders.value.length, 0)
|
||||
queue.value = 'all'; assert.equal(view.filteredOrders.value.length, 2)
|
||||
const block = page.slice(page.indexOf('function updateCustomsDetail('), page.indexOf('const detailOrder ='))
|
||||
const js = ts.transpileModule(block, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const states = {}, id = ref('one'), create = ref(false), detail = ref(false)
|
||||
let reloads = 0
|
||||
const update = new Function('customsByOrder', 'customsDialogOrderId', 'isCreatePage', 'isDetailPage', 'loadOrderPage', js + ';return updateCustomsDetail')(states, id, create, detail, async () => reloads++)
|
||||
update({ merchantStatus: 'WAITING_UPLOAD' }); assert.equal(reloads, 0)
|
||||
update({ merchantStatus: 'WAITING_UPLOAD' }); assert.equal(reloads, 0)
|
||||
update({ merchantStatus: 'DOCUMENT_UPLOADED' }); assert.equal(reloads, 1)
|
||||
assert.equal(states.one.merchantStatus, 'DOCUMENT_UPLOADED')
|
||||
detail.value = true; update({ merchantStatus: 'WAITING_UPLOAD' }); assert.equal(reloads, 1)
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@ test('exposes real list, draft, submit, and detail workflow entries', () => {
|
||||
assert.match(source, /function submitOrder\(\)/)
|
||||
assert.match(source, /async function openDetail\(order: FirstLegOrder\)/)
|
||||
assert.match(source, /Promise\.allSettled\(\[/)
|
||||
assert.match(source, /const orderPageRequest = isCreatePage\.value \|\| isDetailPage\.value\s*\? store\.loadWarehouseType\('DOMESTIC', true\)\s*:\s*store\.load\(\{ includeDrafts: true \}\)/)
|
||||
assert.match(source, /const orderPageRequest = isCreatePage\.value \|\| isDetailPage\.value\s*\? store\.loadWarehouseType\('DOMESTIC', true\)\s*:\s*loadOrderPage\(\)/)
|
||||
assert.match(source, /Promise\.allSettled\(\[\s*orderPageRequest,\s*companyRequest/)
|
||||
assert.match(source, /tenantStore\.loadCompanyContext\(\)/)
|
||||
assert.match(source, /await store\.loadDetail\(orderId\)/)
|
||||
@@ -66,7 +66,7 @@ test('copies an order into the dedicated create page without reusing order ident
|
||||
})
|
||||
|
||||
test('keeps create-page support errors scoped to create-page requests', () => {
|
||||
assert.match(source, /if \(!isCreatePage\.value && store\.draftLoadError\)/)
|
||||
assert.doesNotMatch(source, /store\.draftLoadError/)
|
||||
})
|
||||
|
||||
test('blocks order submission before company approval while preserving draft actions', () => {
|
||||
@@ -114,7 +114,7 @@ test('uses the shared query toolbar and data table without metric cards', () =>
|
||||
assert.match(source, /<QueryToolbar v-model="filters" class="first-leg-query-toolbar" layout="grid" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders" \/>/)
|
||||
assert.match(source, /async function searchOrders\(model: QueryModel\)/)
|
||||
assert.match(source, /await store\.load\(\{/)
|
||||
assert.match(source, /<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="visibleOrderColumns"/)
|
||||
assert.match(source, /<DataTable :page="tablePage" :page-size="tablePageSize" @update:page="changeOrderPage" @update:page-size="changeOrderPageSize" :data="pagedOrders" :columns="visibleOrderColumns"/)
|
||||
assert.match(source, /\{ key: 'actions', label: '操作', width: 284, fixed: 'left', align: 'left', headerAlign: 'left', slotName: 'actions' \}/)
|
||||
assert.doesNotMatch(source, /order-metrics|订单概览/)
|
||||
})
|
||||
@@ -247,9 +247,9 @@ test('uses only fields present on C02 rows before loading full detail', () => {
|
||||
assert.match(source, /displayListValue/)
|
||||
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 !== 'CANCELLED' \? statusValue : undefined/)
|
||||
assert.match(source, /draftOnly: statusValue === 'DRAFT'/)
|
||||
assert.match(source, /:total="store\.total"/)
|
||||
assert.match(source, /mainStatus: text\('status'\)/)
|
||||
assert.match(source, /queue: activeOrderQueue\.value, page: requestedPage, size: tablePageSize\.value/)
|
||||
assert.match(source, /createdFrom: range\[0\] \? `\$\{range\[0\]\}T00:00:00`/)
|
||||
})
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ async function cancelOrder(order: FirstLegOrder) {
|
||||
await store.cancelOrder(command.id, command.expectedVersion, command.reason)
|
||||
pendingCancellation.value = undefined
|
||||
ElMessage.success('订单已取消')
|
||||
await store.load({ includeDrafts: true })
|
||||
await loadOrderPage()
|
||||
if (isDetailPage.value) await router.push({ name: 'first-leg-orders' })
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
@@ -135,7 +135,7 @@ const appliedFilters = ref<QueryModel>({ ...filters.value })
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'cargoKeyword', label: '入仓码 / SKU', type: 'input', placeholder: '请输入入仓码或 SKU', props: { 'aria-label': '入仓码或 SKU' } },
|
||||
{ key: 'receivingWarehouseId', label: '收货仓', type: 'select', placeholder: '全部', options: availableWarehouses.value.map((item) => ({ label: item.name, value: item.id })), props: { 'aria-label': '收货仓' } },
|
||||
{ key: 'channelName', label: '渠道名称', type: 'select', placeholder: '全部', options: [...new Set(store.orders.map((item) => item.channelName).filter(Boolean))].map((name) => ({ label: name, value: name })), props: { 'aria-label': '渠道名称' } },
|
||||
{ key: 'channelName', label: '渠道名称', type: 'select', placeholder: '全部', options: store.queryChannelNames.map((name) => ({ label: name, value: name })), props: { 'aria-label': '渠道名称' } },
|
||||
{ key: 'createdRange', label: '创建时间', type: 'daterange', placeholder: '请选择创建时间', props: { 'aria-label': '创建时间' } },
|
||||
{ key: 'status', label: '当前状态', type: 'select', placeholder: '全部', options: FIRST_LEG_ORDER_STATUSES.filter(item => item.code !== 'CANCELLED').map((item) => ({ label: item.label, value: item.code })), props: { 'aria-label': '当前状态' } },
|
||||
{ key: 'destinationType', label: '目的仓类型', type: 'select', placeholder: '全部', options: [{ label: 'FBA 仓', value: 'FBA' }, { label: '海外仓', value: 'OVERSEAS' }, { label: '私人地址', value: 'PRIVATE' }], props: { 'aria-label': '目的仓类型' } },
|
||||
@@ -500,49 +500,47 @@ useUnsavedWorkRegistration({
|
||||
dirty: editorDirty,
|
||||
})
|
||||
|
||||
function matchesAppliedFilters(order: FirstLegOrder) {
|
||||
const statusValue = appliedFilters.value.status
|
||||
const createdRangeValue = appliedFilters.value.createdRange
|
||||
const textValue = (key: string) => typeof appliedFilters.value[key] === 'string' ? String(appliedFilters.value[key]).trim().toLowerCase() : ''
|
||||
const receivingWarehouseId = textValue('receivingWarehouseId')
|
||||
const channelName = textValue('channelName')
|
||||
const cargoKeyword = textValue('cargoKeyword')
|
||||
const destinationType = textValue('destinationType')
|
||||
const trackingNumber = textValue('trackingNumber')
|
||||
const status = typeof statusValue === 'string' ? statusValue as FirstLegOrderStatus | '' : ''
|
||||
const createdRange = Array.isArray(createdRangeValue) ? createdRangeValue.filter((value): value is string => typeof value === 'string') : []
|
||||
const matchesWarehouse = !receivingWarehouseId || order.receivingWarehouseId.toLowerCase() === receivingWarehouseId
|
||||
const matchesChannel = !channelName || order.channelName.toLowerCase() === channelName
|
||||
const matchesCargo = !cargoKeyword || [order.businessSummary.inboundCode, ...order.skuNos, ...order.items.map((item) => item.sku)]
|
||||
.filter(Boolean).some((value) => String(value).toLowerCase().includes(cargoKeyword))
|
||||
const matchesDestinationType = !destinationType || order.destinationType.toLowerCase() === destinationType
|
||||
const matchesTrackingNumber = !trackingNumber || order.trackingNumber.toLowerCase().includes(trackingNumber)
|
||||
const matchesStatus = !status || order.status === status
|
||||
const matchesDate = createdRange.length !== 2 || (order.createdAt.slice(0, 10) >= createdRange[0] && order.createdAt.slice(0, 10) <= createdRange[1])
|
||||
return matchesWarehouse && matchesChannel && matchesCargo && matchesDestinationType && matchesTrackingNumber && matchesStatus && matchesDate
|
||||
}
|
||||
const orderQueueCounts = computed(() => store.queueCounts)
|
||||
const pagedOrders = computed(() => store.orders)
|
||||
|
||||
const queryMatchedOrders = computed(() => store.orders.filter(matchesAppliedFilters))
|
||||
const orderQueueCounts = computed(() => ({
|
||||
all: queryMatchedOrders.value.length,
|
||||
fee: queryMatchedOrders.value.filter((order) => order.businessSummary.customerFeeConfirmationRequired).length,
|
||||
customs: queryMatchedOrders.value.filter((order) => needsCustomsUpload(eligibleCustomsDetail(order))).length,
|
||||
draft: queryMatchedOrders.value.filter((order) => order.status === 'DRAFT').length,
|
||||
}))
|
||||
const filteredOrders = computed(() => queryMatchedOrders.value.filter((order) => {
|
||||
if (activeOrderQueue.value === 'fee') return order.businessSummary.customerFeeConfirmationRequired
|
||||
if (activeOrderQueue.value === 'customs') return needsCustomsUpload(eligibleCustomsDetail(order))
|
||||
if (activeOrderQueue.value === 'draft') return order.status === 'DRAFT'
|
||||
return true
|
||||
}))
|
||||
const pagedOrders = computed(() => {
|
||||
const start = (tablePage.value - 1) * tablePageSize.value
|
||||
return filteredOrders.value.slice(start, start + tablePageSize.value)
|
||||
})
|
||||
|
||||
function selectOrderQueue(queue: OrderQueue) {
|
||||
async function selectOrderQueue(queue: OrderQueue) {
|
||||
activeOrderQueue.value = queue
|
||||
tablePage.value = 1
|
||||
await loadOrderPage()
|
||||
}
|
||||
|
||||
async function changeOrderPage(page: number) {
|
||||
tablePage.value = page
|
||||
await loadOrderPage()
|
||||
}
|
||||
|
||||
async function changeOrderPageSize(size: number) {
|
||||
tablePageSize.value = size
|
||||
tablePage.value = 1
|
||||
await loadOrderPage()
|
||||
}
|
||||
|
||||
let orderPageRequestVersion = 0
|
||||
async function loadOrderPage() {
|
||||
const requestVersion = ++orderPageRequestVersion
|
||||
const text = (key: string) => typeof appliedFilters.value[key] === 'string' ? String(appliedFilters.value[key]).trim() || undefined : undefined
|
||||
const range = Array.isArray(appliedFilters.value.createdRange) ? appliedFilters.value.createdRange : []
|
||||
const requestedPage = tablePage.value
|
||||
await store.load({
|
||||
cargoKeyword: text('cargoKeyword'), receivingWarehouseId: text('receivingWarehouseId'),
|
||||
channelName: text('channelName'), destinationType: text('destinationType'),
|
||||
trackingNumber: text('trackingNumber'), mainStatus: text('status'),
|
||||
createdFrom: range[0] ? `${range[0]}T00:00:00` : undefined,
|
||||
createdTo: range[1] ? `${range[1]}T23:59:59.999` : undefined,
|
||||
queue: activeOrderQueue.value, page: requestedPage, size: tablePageSize.value,
|
||||
})
|
||||
if (requestVersion !== orderPageRequestVersion) return
|
||||
const lastPage = Math.max(1, Math.ceil(store.total / tablePageSize.value))
|
||||
if (!store.loadError && requestedPage > lastPage) {
|
||||
tablePage.value = lastPage
|
||||
await loadOrderPage()
|
||||
return
|
||||
}
|
||||
selectedOrders.value = []
|
||||
orderTableVersion.value += 1
|
||||
}
|
||||
@@ -586,7 +584,9 @@ function openInboundCodeAssociation(inboundCode: string) {
|
||||
}
|
||||
|
||||
function updateCustomsDetail(value: MerchantCustomsContainer) {
|
||||
const previous = customsByOrder[customsDialogOrderId.value]
|
||||
if (customsDialogOrderId.value) customsByOrder[customsDialogOrderId.value] = value
|
||||
if (!isCreatePage.value && !isDetailPage.value && previous && previous.merchantStatus !== value.merchantStatus) void loadOrderPage()
|
||||
}
|
||||
|
||||
const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined)
|
||||
@@ -652,7 +652,7 @@ async function loadPageData() {
|
||||
: Promise.resolve()
|
||||
const orderPageRequest = isCreatePage.value || isDetailPage.value
|
||||
? store.loadWarehouseType('DOMESTIC', true)
|
||||
: store.load({ includeDrafts: true })
|
||||
: loadOrderPage()
|
||||
const addressRequest = isCreatePage.value ? loadOrderAddresses() : Promise.resolve()
|
||||
const [ordersResult, companyResult, destinationResult, policyResult, addressResult] = await Promise.allSettled([
|
||||
orderPageRequest,
|
||||
@@ -673,7 +673,6 @@ async function loadPageData() {
|
||||
}
|
||||
if (!isCreatePage.value && ordersResult.status === 'rejected' && !store.loadError) supportingDataErrors.value.push(`订单加载失败:${errorMessage(ordersResult.reason)}`)
|
||||
if (store.warehouseLoadError) supportingDataErrors.value.push(`公共仓库加载失败:${store.warehouseLoadError}`)
|
||||
if (!isCreatePage.value && store.draftLoadError) supportingDataErrors.value.push(`订单草稿加载失败:${store.draftLoadError}`)
|
||||
if (destinationResult.status === 'rejected') supportingDataErrors.value.push(`目的仓加载失败:${errorMessage(destinationResult.reason)}`)
|
||||
if (addressResult.status === 'rejected') supportingDataErrors.value.push(`企业地址簿加载失败:${errorMessage(addressResult.reason)}`)
|
||||
await autofillWarehouseSelections()
|
||||
@@ -1528,6 +1527,7 @@ async function retryInboundBilling() {
|
||||
}
|
||||
|
||||
function handleInboundFeeConfirmed() {
|
||||
if (!isCreatePage.value && !isDetailPage.value) void loadOrderPage()
|
||||
if (!feeConfirmationOpen.value) void retryInboundBilling()
|
||||
}
|
||||
|
||||
@@ -1953,7 +1953,7 @@ async function submitBatchImport() {
|
||||
batchImportIdempotencyKey.value,
|
||||
)
|
||||
const result = batchImportResult.value.result
|
||||
if (result.succeeded && auth.hasPermission('order:first-leg:list')) await store.load({ includeDrafts: true })
|
||||
if (result.succeeded && auth.hasPermission('order:first-leg:list')) await loadOrderPage()
|
||||
if (result.failed) ElMessage.warning(`导入完成:成功 ${result.succeeded} 条,失败 ${result.failed} 条`)
|
||||
else ElMessage.success(`已成功导入 ${result.succeeded} 条订单草稿`)
|
||||
} catch (error) {
|
||||
@@ -2087,37 +2087,13 @@ async function submitOrder() {
|
||||
}
|
||||
|
||||
async function searchOrders(model: QueryModel) {
|
||||
const createdRange = model.createdRange
|
||||
appliedFilters.value = {
|
||||
...model,
|
||||
createdRange: Array.isArray(createdRange) ? createdRange.filter((value): value is string => typeof value === 'string') : [],
|
||||
appliedFilters.value = { ...model,
|
||||
createdRange: Array.isArray(model.createdRange) ? model.createdRange.filter((value): value is string => typeof value === 'string') : [],
|
||||
}
|
||||
tablePage.value = 1
|
||||
try {
|
||||
const statusValue = typeof model.status === 'string' ? model.status as FirstLegOrderStatus | '' : ''
|
||||
const cargoKeyword = typeof model.cargoKeyword === 'string' ? model.cargoKeyword.trim() : ''
|
||||
const range = Array.isArray(createdRange) ? createdRange.filter((value): value is string => typeof value === 'string') : []
|
||||
const query = {
|
||||
keyword: undefined,
|
||||
inboundCode: cargoKeyword || undefined,
|
||||
receivingWarehouseId: typeof model.receivingWarehouseId === 'string' && model.receivingWarehouseId ? model.receivingWarehouseId : undefined,
|
||||
mainStatus: statusValue && statusValue !== 'DRAFT' && statusValue !== 'CANCELLED' ? 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,
|
||||
}
|
||||
await store.load(query)
|
||||
const cargoMatched = cargoKeyword && store.orders.some((order) => order.status !== 'DRAFT' && orderMatchesCargoKeyword(order, cargoKeyword))
|
||||
if (cargoKeyword && !cargoMatched && statusValue !== 'DRAFT') {
|
||||
await store.load({ ...query, keyword: undefined, inboundCode: undefined, fbaCode: cargoKeyword })
|
||||
}
|
||||
selectedOrders.value = []
|
||||
orderTableVersion.value += 1
|
||||
} catch {
|
||||
// Store keeps the recoverable error state rendered below the query toolbar.
|
||||
}
|
||||
await loadOrderPage()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -2126,7 +2102,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<QueryToolbar v-model="filters" class="first-leg-query-toolbar" layout="grid" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders" />
|
||||
|
||||
<div class="client-card order-table-card">
|
||||
<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="visibleOrderColumns" :key="orderTableVersion" :loading="store.loading" :total="filteredOrders.length" :page-sizes="[10, 20, 50]" row-key="id" empty-text="暂无订单" class="prototype-order-table" :row-actions="getOrderRowActions" @selection-change="handleOrderSelectionChange">
|
||||
<DataTable :page="tablePage" :page-size="tablePageSize" @update:page="changeOrderPage" @update:page-size="changeOrderPageSize" :data="pagedOrders" :columns="visibleOrderColumns" :key="orderTableVersion" :loading="store.loading" :total="store.total" :page-sizes="[10, 20, 50]" row-key="id" empty-text="暂无订单" class="prototype-order-table" :row-actions="getOrderRowActions" @selection-change="handleOrderSelectionChange">
|
||||
<template #toolbar>
|
||||
<nav class="order-queue-tabs" aria-label="订单事项分类">
|
||||
<button type="button" :class="{ active: activeOrderQueue === 'all' }" @click="selectOrderQueue('all')">全部</button>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { ref } from 'vue'
|
||||
import ts from 'typescript'
|
||||
|
||||
const source = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8').match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
||||
const ast = ts.createSourceFile('view.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
||||
const names = ['loadOrderPage', 'changeOrderPage', 'changeOrderPageSize', 'selectOrderQueue', 'searchOrders']
|
||||
const functions = ast.statements.filter(n => ts.isFunctionDeclaration(n) && names.includes(n.name?.text)).map(n => n.getText(ast)).join('\n')
|
||||
const code = ts.transpileModule('let orderPageRequestVersion = 0;\n' + functions, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
function harness() {
|
||||
const calls = []
|
||||
const values = {
|
||||
tablePage: ref(1), tablePageSize: ref(10), activeOrderQueue: ref('all'), appliedFilters: ref({}),
|
||||
selectedOrders: ref(['previous']), orderTableVersion: ref(0),
|
||||
store: { total: 215, loadError: '', load: async q => { calls.push(q) } },
|
||||
}
|
||||
const handlers = new Function(...Object.keys(values), code + ';return {' + names.join(',') + '}')(...Object.values(values))
|
||||
return { ...values, ...handlers, calls }
|
||||
}
|
||||
test('page events request server pages; size, queue and search reset to the first page', async () => {
|
||||
const h = harness()
|
||||
await h.changeOrderPage(21); assert.equal(h.calls.at(-1).page, 21)
|
||||
await h.changeOrderPageSize(50); assert.equal(h.calls.at(-1).page, 1); assert.equal(h.calls.at(-1).size, 50)
|
||||
await h.selectOrderQueue('customs'); assert.equal(h.calls.at(-1).queue, 'customs'); assert.equal(h.tablePage.value, 1)
|
||||
await h.searchOrders({ cargoKeyword: ' SKU ', status: 'DRAFT', createdRange: ['2026-10-01', '2026-10-03'] })
|
||||
assert.equal(h.calls.at(-1).cargoKeyword, 'SKU'); assert.equal(h.calls.at(-1).mainStatus, 'DRAFT')
|
||||
assert.equal(h.calls.at(-1).createdFrom, '2026-10-01T00:00:00')
|
||||
assert.equal(h.calls.at(-1).createdTo, '2026-10-03T23:59:59.999')
|
||||
assert.deepEqual(h.selectedOrders.value, [])
|
||||
})
|
||||
test('deletion on the last page clamps and reloads once; failed reads retain the requested page for retry', async () => {
|
||||
const h = harness(); h.store.total = 200
|
||||
await h.changeOrderPage(22)
|
||||
assert.deepEqual(h.calls.map(q => q.page), [22, 20]); assert.equal(h.tablePage.value, 20)
|
||||
h.store.total = 0; h.store.loadError = 'failed'
|
||||
await h.changeOrderPage(22)
|
||||
assert.equal(h.tablePage.value, 22); assert.equal(h.calls.length, 3)
|
||||
})
|
||||
test('old same-page response cannot clamp a newer query or clear its current selection', async () => {
|
||||
const h = harness(); let release
|
||||
h.store.load = () => new Promise(resolve => { release = resolve })
|
||||
const first = h.loadOrderPage(); const oldRelease = release
|
||||
const second = h.searchOrders({ cargoKeyword: 'new' })
|
||||
h.store.total = 215; release(); await second
|
||||
h.selectedOrders.value = ['new-selection']
|
||||
oldRelease(); await first
|
||||
assert.deepEqual(h.selectedOrders.value, ['new-selection'])
|
||||
assert.equal(h.orderTableVersion.value, 1)
|
||||
})
|
||||
@@ -60,7 +60,7 @@ function harness({ token = 'customer-A', rejectDownload, rejectList, pending, cl
|
||||
: { code: '200', message: '成功', data: { deliveryNo: 'DL/A', files: [file] } } }
|
||||
} }) },
|
||||
'@chongchong/shared': { parseApiJsonResponse: value => value },
|
||||
'@/api/session': { getAccessToken: () => token, getRefreshToken: () => '', persistTokenPair() {}, clearSessionTokens() {} },
|
||||
'@/api/session': { getAccessToken: () => token, accessTokenIdentity: value => value, getRefreshToken: () => '', persistTokenPair() {}, clearSessionTokens() {} },
|
||||
'@/api/errorDetails': compile('../../api/errorDetails.ts', {}),
|
||||
'@/api/types': compile('../../api/types.ts', {}),
|
||||
'@/api/crossTabRefresh': compile('../../api/crossTabRefresh.ts', {}),
|
||||
|
||||
Reference in New Issue
Block a user