fix(client): retain customs access after order completion
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import type { FirstLegOrder } from '../types/firstLegOrder'
|
||||
|
||||
export function isCustomsOrderCandidate(order: Pick<FirstLegOrder, 'status' | 'taxMode'>): boolean {
|
||||
return order.taxMode === 'TAX_REFUND'
|
||||
&& (order.status === 'LOADED' || order.status === 'DELIVERED' || order.status === 'COMPLETED')
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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'
|
||||
|
||||
function evaluate(source, dependencies = {}) {
|
||||
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const exports = {}
|
||||
new Function('require', 'exports', code)(id => {
|
||||
assert.ok(id in dependencies, `Unexpected dependency ${id}`)
|
||||
return dependencies[id]
|
||||
}, exports)
|
||||
return exports
|
||||
}
|
||||
const eligibility = evaluate(readFileSync(new URL('../utils/customsEligibility.ts', import.meta.url), 'utf8'))
|
||||
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' }] })
|
||||
function harness(t, orders, getDetail = async id => detail(id)) {
|
||||
const store = { orders, loadError: '', load: async () => {} }, calls = []
|
||||
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 view = scope.run(() => evaluate(source + '\nexport { load, records, loading, errorMessage, openDetail, selectedOrderId, selectedDetail, dialogOpen, search, queryModel, filteredRecords }', {
|
||||
vue: { ...vue, onMounted() {} },
|
||||
'../public': { useFirstLegOrdersStore: () => store },
|
||||
'../utils/customsEligibility': eligibility,
|
||||
'../api/customs': { getMerchantCustomsForOrder: id => { calls.push(id); return getDetail(id) }, isMerchantCustomsUnavailable: error => error?.code === 'A0404' },
|
||||
}))
|
||||
t.after(() => scope.stop())
|
||||
return { view, store, calls }
|
||||
}
|
||||
|
||||
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 orders = stages.flatMap(status => [order(status, status), order(`BUY-${status}`, status, 'BUY_DECLARATION')])
|
||||
const { view, calls } = harness(t, orders)
|
||||
await view.load()
|
||||
assert.deepEqual(calls, ['LOADED', 'DELIVERED', 'COMPLETED'])
|
||||
assert.deepEqual(view.records.value.map(row => row.order.id), calls)
|
||||
const completed = view.records.value.find(row => row.order.id === 'COMPLETED')
|
||||
view.openDetail(completed)
|
||||
assert.equal(view.selectedOrderId.value, 'COMPLETED')
|
||||
assert.equal(view.selectedDetail.value.status, 'COMPLETED')
|
||||
assert.equal(view.dialogOpen.value, true)
|
||||
view.queryModel.value.status = 'COMPLETED'; view.search()
|
||||
assert.equal(view.filteredRecords.value.length, 3)
|
||||
assert.equal(calls.length, 3, 'local filtering does not refetch documents')
|
||||
})
|
||||
|
||||
test('only matching server-returned tax-refund ownership is displayed', async t => {
|
||||
const { view } = harness(t, [order('one'), order('two')], async id => id === 'one' ? detail('someone-else') : { inbounds: [{ orderId: id, taxMode: 'BUY_DECLARATION' }] })
|
||||
await view.load()
|
||||
assert.equal(view.records.value.length, 0)
|
||||
})
|
||||
|
||||
test('partial failures are visible and retry replaces the result; unavailable customs is not a transport failure', async t => {
|
||||
let fail = true
|
||||
const { view } = harness(t, [order('ok'), order('retry'), order('unavailable')], async id => {
|
||||
if (id === 'unavailable') throw Object.assign(new Error('not associated'), { code: 'A0404' })
|
||||
if (id === 'retry' && fail) throw new Error('network')
|
||||
return detail(id)
|
||||
})
|
||||
await view.load()
|
||||
assert.equal(view.records.value.length, 1)
|
||||
assert.equal(view.errorMessage.value, '1 票订单的报关资料加载失败,请重新加载')
|
||||
fail = false; await view.load()
|
||||
assert.equal(view.records.value.length, 2)
|
||||
assert.equal(view.errorMessage.value, '')
|
||||
})
|
||||
|
||||
test('order query failure is shown instead of a successful empty result', async t => {
|
||||
const { view, store, calls } = harness(t, [order('old')])
|
||||
await view.load()
|
||||
store.loadError = '订单查询失败'; await view.load()
|
||||
assert.equal(view.records.value.length, 0)
|
||||
assert.equal(view.errorMessage.value, '订单查询失败')
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(view.loading.value, false)
|
||||
})
|
||||
|
||||
test('repeated refresh during an active load does not duplicate requests', async t => {
|
||||
let resolve
|
||||
const pending = new Promise(done => { resolve = done })
|
||||
const { view, calls } = harness(t, [order('one')], () => pending)
|
||||
const first = view.load()
|
||||
await Promise.resolve()
|
||||
await view.load()
|
||||
assert.deepEqual(calls, ['one'])
|
||||
resolve(detail('one')); await first
|
||||
assert.equal(view.loading.value, false)
|
||||
})
|
||||
@@ -5,7 +5,8 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||
import type { TableAction } from '@/components/common/tableActions'
|
||||
import { useFirstLegOrdersStore } from '../public'
|
||||
import { getMerchantCustomsForOrder, type MerchantCustomsContainer } from '../api/customs'
|
||||
import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '../api/customs'
|
||||
import { isCustomsOrderCandidate } from '../utils/customsEligibility'
|
||||
import CustomsDocumentsDialog from '../components/CustomsDocumentsDialog.vue'
|
||||
import type { FirstLegOrder } from '../types/firstLegOrder'
|
||||
|
||||
@@ -77,9 +78,12 @@ async function load() {
|
||||
loading.value = true; errorMessage.value = ''
|
||||
try {
|
||||
await store.load()
|
||||
const candidates = store.orders.filter((order) => order.status === 'LOADED' && order.taxMode === 'TAX_REFUND')
|
||||
if (store.loadError) throw new Error(store.loadError)
|
||||
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} 票订单的报关资料加载失败,请重新加载`
|
||||
} catch (error) {
|
||||
records.value = []
|
||||
errorMessage.value = error instanceof Error ? error.message : '报关资料加载失败,请稍后重试'
|
||||
|
||||
@@ -684,8 +684,8 @@ test('opens the server-owned inbound-code association from the order detail', ()
|
||||
assert.doesNotMatch(source, /openInboundCodeAssociation\([^)]*\.id\)/)
|
||||
})
|
||||
|
||||
test('opens customer customs documents only for server-confirmed loaded tax-refund orders', () => {
|
||||
assert.match(source, /order\.status !== 'LOADED'/)
|
||||
test('opens customer customs documents only for server-confirmed tax-refund orders after loading', () => {
|
||||
assert.match(source, /!isCustomsOrderCandidate\(order\)/)
|
||||
assert.match(source, /item\.orderId === order\.id && item\.taxMode === 'TAX_REFUND'/)
|
||||
assert.match(source, /getMerchantCustomsForOrder\(order\.id\)/)
|
||||
assert.match(source, /if \(eligibleCustomsDetail\(order\)\) \{\s*actions\.push\(\{ key: 'customs', label: '报关资料'/)
|
||||
|
||||
@@ -15,6 +15,7 @@ import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPane
|
||||
import CustomsDocumentsDialog from '@/modules/first-leg/components/CustomsDocumentsDialog.vue'
|
||||
import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling'
|
||||
import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '@/modules/first-leg/api/customs'
|
||||
import { isCustomsOrderCandidate } from '../utils/customsEligibility'
|
||||
import {
|
||||
estimateMerchantFirstLegPrice,
|
||||
createIdempotencyKey,
|
||||
@@ -499,12 +500,12 @@ function selectOrderQueue(queue: OrderQueue) {
|
||||
|
||||
function eligibleCustomsDetail(order: FirstLegOrder) {
|
||||
const value = customsByOrder[order.id]
|
||||
if (order.status !== 'LOADED' || !value?.merchantStatus) return null
|
||||
if (!isCustomsOrderCandidate(order) || !value?.merchantStatus) return null
|
||||
return value.inbounds.some((item) => item.orderId === order.id && item.taxMode === 'TAX_REFUND') ? value : null
|
||||
}
|
||||
|
||||
async function loadCustomsEligibility(order: FirstLegOrder) {
|
||||
if (order.status !== 'LOADED' || customsByOrder[order.id] !== undefined || customsEligibilityPending.has(order.id)) return
|
||||
if (!isCustomsOrderCandidate(order) || customsByOrder[order.id] !== undefined || customsEligibilityPending.has(order.id)) return
|
||||
customsEligibilityPending.add(order.id)
|
||||
try {
|
||||
customsByOrder[order.id] = await getMerchantCustomsForOrder(order.id)
|
||||
|
||||
Reference in New Issue
Block a user