fix(client): read all customs order pages with bounded concurrency
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
import { getMerchantUnifiedOrders } from '../api/unifiedOrders'
|
||||||
|
import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '../api/customs'
|
||||||
|
import { isCustomsOrderCandidate } from './customsEligibility'
|
||||||
|
|
||||||
|
export interface CustomsDocumentRow {
|
||||||
|
order: { id: string; orderNo: string | null; businessSummary: { inboundCode: string } }
|
||||||
|
detail: MerchantCustomsContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Isolated from the shared order store: scan sequential pages, with at most
|
||||||
|
// four document requests in flight. Inconsistent pagination must be visible.
|
||||||
|
export async function readCustomsDocuments() {
|
||||||
|
const records: CustomsDocumentRow[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
let expectedTotal: number | undefined
|
||||||
|
let failedCount = 0
|
||||||
|
const size = 200
|
||||||
|
for (let page = 1; ; page += 1) {
|
||||||
|
const response = await getMerchantUnifiedOrders({ page, size })
|
||||||
|
const total = Number(response.total)
|
||||||
|
const pages = Number(response.pages)
|
||||||
|
if (!Number.isSafeInteger(total) || total < 0 || Number(response.page) !== page
|
||||||
|
|| Number(response.size) !== size || pages !== Math.ceil(total / size)
|
||||||
|
|| (expectedTotal !== undefined && total !== expectedTotal)
|
||||||
|
|| response.records.length !== Math.min(size, Math.max(0, total - (page - 1) * size))) {
|
||||||
|
throw new Error('订单分页数据不完整或已变化,请重新加载报关资料')
|
||||||
|
}
|
||||||
|
expectedTotal = total
|
||||||
|
for (const row of response.records) {
|
||||||
|
const id = String(row.orderId)
|
||||||
|
if (seen.has(id)) throw new Error('订单分页存在重复记录,请重新加载报关资料')
|
||||||
|
seen.add(id)
|
||||||
|
}
|
||||||
|
const candidates = response.records.filter(row => isCustomsOrderCandidate({ status: row.mainStatus, taxMode: row.taxMode ?? 'BUY_DECLARATION' }))
|
||||||
|
for (let offset = 0; offset < candidates.length; offset += 4) {
|
||||||
|
const results = await Promise.all(candidates.slice(offset, offset + 4).map(async row => {
|
||||||
|
const id = String(row.orderId)
|
||||||
|
try {
|
||||||
|
const detail = await getMerchantCustomsForOrder(id)
|
||||||
|
if (!detail.inbounds.some(item => item.orderId === id && item.taxMode === 'TAX_REFUND')) return null
|
||||||
|
return { order: { id, orderNo: row.orderNo, businessSummary: { inboundCode: row.inboundCode ?? '' } }, detail }
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMerchantCustomsUnavailable(error)) failedCount += 1
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
for (const result of results) if (result) records.push(result)
|
||||||
|
}
|
||||||
|
if (page >= pages) break
|
||||||
|
}
|
||||||
|
return { records, failedCount }
|
||||||
|
}
|
||||||
@@ -17,19 +17,90 @@ const eligibility = evaluate(readFileSync(new URL('../utils/customsEligibility.t
|
|||||||
const order = (id, status = 'LOADED', taxMode = 'TAX_REFUND') => ({ id, status, taxMode, orderNo: `ORDER-${id}`, businessSummary: { inboundCode: `IN-${id}` } })
|
const order = (id, status = 'LOADED', taxMode = 'TAX_REFUND') => ({ id, status, taxMode, orderNo: `ORDER-${id}`, businessSummary: { inboundCode: `IN-${id}` } })
|
||||||
const detail = id => ({ status: 'COMPLETED', merchantStatus: 'CUSTOMS_COMPLETED', inbounds: [{ orderId: id, taxMode: 'TAX_REFUND' }] })
|
const detail = id => ({ status: 'COMPLETED', merchantStatus: 'CUSTOMS_COMPLETED', inbounds: [{ orderId: id, taxMode: 'TAX_REFUND' }] })
|
||||||
function harness(t, orders, getDetail = async id => detail(id)) {
|
function harness(t, orders, getDetail = async id => detail(id)) {
|
||||||
const store = { orders, loadError: '', load: async () => {} }, calls = []
|
const store = { loadError: '', failPage: 0, transformPage: value => value }, calls = [], pageCalls = []
|
||||||
|
const reader = evaluate(readFileSync(new URL('../utils/customsDocumentsReader.ts', import.meta.url), 'utf8'), {
|
||||||
|
'./customsEligibility': eligibility,
|
||||||
|
'../api/unifiedOrders': { getMerchantUnifiedOrders: async ({ page, size }) => {
|
||||||
|
pageCalls.push({ page, size })
|
||||||
|
if (store.loadError || store.failPage === page) throw new Error(store.loadError || '第二页订单查询失败')
|
||||||
|
return store.transformPage({ page, size, total: orders.length, pages: Math.ceil(orders.length / size),
|
||||||
|
records: orders.slice((page - 1) * size, page * size).map(row => ({ orderId: row.id, orderNo: row.orderNo, mainStatus: row.status, taxMode: row.taxMode, inboundCode: row.businessSummary.inboundCode })) })
|
||||||
|
} },
|
||||||
|
'../api/customs': { getMerchantCustomsForOrder: id => { calls.push(id); return getDetail(id) }, isMerchantCustomsUnavailable: error => error?.code === 'A0404' },
|
||||||
|
})
|
||||||
const source = readFileSync(new URL('./CustomsDocumentsView.vue', import.meta.url), 'utf8').match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
const source = readFileSync(new URL('./CustomsDocumentsView.vue', import.meta.url), 'utf8').match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
||||||
const scope = vue.effectScope()
|
const scope = vue.effectScope()
|
||||||
const view = scope.run(() => evaluate(source + '\nexport { load, records, loading, errorMessage, openDetail, selectedOrderId, selectedDetail, dialogOpen, search, queryModel, filteredRecords }', {
|
const view = scope.run(() => evaluate(source + '\nexport { load, records, loading, errorMessage, openDetail, selectedOrderId, selectedDetail, dialogOpen, search, queryModel, filteredRecords }', {
|
||||||
vue: { ...vue, onMounted() {} },
|
vue: { ...vue, onMounted() {} },
|
||||||
'../public': { useFirstLegOrdersStore: () => store },
|
'../utils/customsDocumentsReader': reader,
|
||||||
'../utils/customsEligibility': eligibility,
|
|
||||||
'../api/customs': { getMerchantCustomsForOrder: id => { calls.push(id); return getDetail(id) }, isMerchantCustomsUnavailable: error => error?.code === 'A0404' },
|
|
||||||
}))
|
}))
|
||||||
t.after(() => scope.stop())
|
t.after(() => scope.stop())
|
||||||
return { view, store, calls }
|
return { view, store, calls, pageCalls }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('finds historical refund documents beyond the first 200 unified orders', async t => {
|
||||||
|
const orders = Array.from({ length: 200 }, (_, i) => order(`buy-${i}`, 'COMPLETED', 'BUY_DECLARATION'))
|
||||||
|
orders.push(order('historical', 'COMPLETED'))
|
||||||
|
const { view, calls, pageCalls } = harness(t, orders)
|
||||||
|
await view.load()
|
||||||
|
assert.deepEqual(calls, ['historical'])
|
||||||
|
assert.equal(view.records.value[0].order.id, 'historical')
|
||||||
|
assert.deepEqual(pageCalls, [{ page: 1, size: 200 }, { page: 2, size: 200 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('second page failure is explicit and retry does not retain an incomplete result', async t => {
|
||||||
|
const orders = Array.from({ length: 201 }, (_, i) => order(String(i)))
|
||||||
|
const { view, store, pageCalls } = harness(t, orders)
|
||||||
|
store.failPage = 2
|
||||||
|
await view.load()
|
||||||
|
assert.equal(view.records.value.length, 0)
|
||||||
|
assert.equal(view.errorMessage.value, '第二页订单查询失败')
|
||||||
|
store.failPage = 0
|
||||||
|
await view.load()
|
||||||
|
assert.equal(view.records.value.length, 201)
|
||||||
|
assert.equal(view.errorMessage.value, '')
|
||||||
|
assert.equal(pageCalls.length, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty pages, duplicate records and changing totals cannot silently truncate history', async t => {
|
||||||
|
for (const change of [
|
||||||
|
response => response.page === 2 ? { ...response, records: [] } : response,
|
||||||
|
response => response.page === 2 ? { ...response, records: [{ ...response.records[0], orderId: '0' }] } : response,
|
||||||
|
response => response.page === 2 ? { ...response, total: 202 } : response,
|
||||||
|
]) {
|
||||||
|
const { view, store, pageCalls } = harness(t, Array.from({ length: 201 }, (_, i) => order(String(i), 'COMPLETED', 'BUY_DECLARATION')))
|
||||||
|
store.transformPage = change
|
||||||
|
await view.load()
|
||||||
|
assert.ok(view.errorMessage.value)
|
||||||
|
assert.equal(view.records.value.length, 0)
|
||||||
|
assert.equal(pageCalls.length, 2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('document fetch concurrency never exceeds four and each candidate is requested once', async t => {
|
||||||
|
let active = 0, peak = 0
|
||||||
|
const { view, calls, pageCalls } = harness(t, Array.from({ length: 205 }, (_, i) => order(String(i))), async id => {
|
||||||
|
active += 1; peak = Math.max(peak, active)
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
active -= 1
|
||||||
|
return detail(id)
|
||||||
|
})
|
||||||
|
await view.load()
|
||||||
|
assert.equal(peak, 4)
|
||||||
|
assert.equal(new Set(calls).size, 205)
|
||||||
|
assert.equal(calls.length, 205)
|
||||||
|
assert.equal(pageCalls.length, 2)
|
||||||
|
assert.equal(view.records.value.length, 205)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty result uses one page request and no document requests', async t => {
|
||||||
|
const { view, calls, pageCalls } = harness(t, [])
|
||||||
|
await view.load()
|
||||||
|
assert.equal(pageCalls.length, 1)
|
||||||
|
assert.equal(calls.length, 0)
|
||||||
|
assert.equal(view.errorMessage.value, '')
|
||||||
|
})
|
||||||
|
|
||||||
test('tax-refund documents remain accessible through delivered and completed orders; buy orders and preloading are excluded', async t => {
|
test('tax-refund documents remain accessible through delivered and completed orders; buy orders and preloading are excluded', async t => {
|
||||||
const stages = ['DRAFT', 'PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_PALLETIZE', 'WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED', 'COMPLETED']
|
const stages = ['DRAFT', 'PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_PALLETIZE', 'WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED', 'COMPLETED']
|
||||||
const orders = stages.flatMap(status => [order(status, status), order(`BUY-${status}`, status, 'BUY_DECLARATION')])
|
const orders = stages.flatMap(status => [order(status, status), order(`BUY-${status}`, status, 'BUY_DECLARATION')])
|
||||||
|
|||||||
@@ -4,13 +4,10 @@ import { RefreshCcw } from '@lucide/vue'
|
|||||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||||
import type { TableAction } from '@/components/common/tableActions'
|
import type { TableAction } from '@/components/common/tableActions'
|
||||||
import { useFirstLegOrdersStore } from '../public'
|
import type { MerchantCustomsContainer } from '../api/customs'
|
||||||
import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '../api/customs'
|
import { readCustomsDocuments, type CustomsDocumentRow } from '../utils/customsDocumentsReader'
|
||||||
import { isCustomsOrderCandidate } from '../utils/customsEligibility'
|
|
||||||
import CustomsDocumentsDialog from '../components/CustomsDocumentsDialog.vue'
|
import CustomsDocumentsDialog from '../components/CustomsDocumentsDialog.vue'
|
||||||
import type { FirstLegOrder } from '../types/firstLegOrder'
|
|
||||||
|
|
||||||
const store = useFirstLegOrdersStore()
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const queryModel = ref<QueryModel>({ orderNo: '', inboundCode: '', containerNo: '', status: '' })
|
const queryModel = ref<QueryModel>({ orderNo: '', inboundCode: '', containerNo: '', status: '' })
|
||||||
@@ -37,7 +34,7 @@ const columns: DataTableColumn[] = [
|
|||||||
{ label: '资料覆盖', slotName: 'coverage', width: 110, align: 'center' },
|
{ label: '资料覆盖', slotName: 'coverage', width: 110, align: 'center' },
|
||||||
{ label: '状态', slotName: 'status', width: 130 },
|
{ label: '状态', slotName: 'status', width: 130 },
|
||||||
]
|
]
|
||||||
const records = ref<Array<{ order: FirstLegOrder; detail: MerchantCustomsContainer }>>([])
|
const records = ref<CustomsDocumentRow[]>([])
|
||||||
const dialogOpen = ref(false)
|
const dialogOpen = ref(false)
|
||||||
const selectedOrderId = ref('')
|
const selectedOrderId = ref('')
|
||||||
const selectedDetail = ref<MerchantCustomsContainer | null>(null)
|
const selectedDetail = ref<MerchantCustomsContainer | null>(null)
|
||||||
@@ -77,12 +74,8 @@ async function load() {
|
|||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
loading.value = true; errorMessage.value = ''
|
loading.value = true; errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
await store.load()
|
const { records: loadedRecords, failedCount } = await readCustomsDocuments()
|
||||||
if (store.loadError) throw new Error(store.loadError)
|
records.value = loadedRecords
|
||||||
const candidates = store.orders.filter(isCustomsOrderCandidate)
|
|
||||||
const results = await Promise.allSettled(candidates.map(async (order) => ({ order, detail: await getMerchantCustomsForOrder(order.id) })))
|
|
||||||
records.value = results.flatMap((result) => result.status === 'fulfilled' && result.value.detail.inbounds.some((item) => item.orderId === result.value.order.id && item.taxMode === 'TAX_REFUND') ? [result.value] : [])
|
|
||||||
const failedCount = results.filter((result) => result.status === 'rejected' && !isMerchantCustomsUnavailable(result.reason)).length
|
|
||||||
if (failedCount) errorMessage.value = `${failedCount} 票订单的报关资料加载失败,请重新加载`
|
if (failedCount) errorMessage.value = `${failedCount} 票订单的报关资料加载失败,请重新加载`
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
records.value = []
|
records.value = []
|
||||||
@@ -90,7 +83,7 @@ async function load() {
|
|||||||
} finally { loading.value = false }
|
} finally { loading.value = false }
|
||||||
}
|
}
|
||||||
function openDetail(item: unknown) {
|
function openDetail(item: unknown) {
|
||||||
const value = item as { order: FirstLegOrder; detail: MerchantCustomsContainer }
|
const value = item as CustomsDocumentRow
|
||||||
selectedOrderId.value = value.order.id; selectedDetail.value = value.detail; dialogOpen.value = true
|
selectedOrderId.value = value.order.id; selectedDetail.value = value.detail; dialogOpen.value = true
|
||||||
}
|
}
|
||||||
function updateDetail(value: MerchantCustomsContainer) {
|
function updateDetail(value: MerchantCustomsContainer) {
|
||||||
|
|||||||
Reference in New Issue
Block a user