fix(customs): expose clearance query failure with scoped retry
This commit is contained in:
@@ -7,7 +7,7 @@ import * as vue from 'vue'
|
|||||||
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
||||||
function harness(t) {
|
function harness(t) {
|
||||||
const source = readFileSync(new URL('./CustomsDocumentsDialog.vue', import.meta.url), 'utf8').match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
const source = readFileSync(new URL('./CustomsDocumentsDialog.vue', import.meta.url), 'utf8').match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
||||||
const code = ts.transpileModule(source + '\nexport { loadDetail, detail, clearanceResults, loading, errorMessage, selectedInboundIds }', { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
const code = ts.transpileModule(source + '\nexport { loadDetail, detail, clearanceResults, loading, errorMessage, selectedInboundIds, clearanceError, clearanceLoading, retryClearanceResults, close }', { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
const props = vue.reactive({ modelValue: false, orderId: 'A', canEdit: true }), requests = [], clearances = [], updates = [], view = {}
|
const props = vue.reactive({ modelValue: false, orderId: 'A', canEdit: true }), requests = [], clearances = [], updates = [], view = {}
|
||||||
const unmounts = []
|
const unmounts = []
|
||||||
const dependencies = { vue: { ...vue, onBeforeUnmount: fn => unmounts.push(fn) }, 'element-plus': { ElMessage: {} }, '@/modules/first-leg/api/customs': {
|
const dependencies = { vue: { ...vue, onBeforeUnmount: fn => unmounts.push(fn) }, 'element-plus': { ElMessage: {} }, '@/modules/first-leg/api/customs': {
|
||||||
@@ -69,3 +69,62 @@ test('unmount invalidates pending detail requests', async t => {
|
|||||||
h.requests[0].resolve(detail('A')); await tick()
|
h.requests[0].resolve(detail('A')); await tick()
|
||||||
assert.equal(h.clearances.length, 0); assert.equal(h.updates.length, 0); assert.equal(h.view.detail.value, null)
|
assert.equal(h.clearances.length, 0); assert.equal(h.updates.length, 0); assert.equal(h.view.detail.value, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('result failure retains documents and exposes error; retry queries only results and deduplicates clicks', async t => {
|
||||||
|
const h = harness(t); h.props.modelValue = true; await tick()
|
||||||
|
h.requests[0].resolve({ ...detail('A'), documents: [{ documentId: 'document-A' }] }); await tick()
|
||||||
|
h.clearances[0].reject(new Error('结果服务暂不可用')); await tick()
|
||||||
|
assert.equal(h.view.errorMessage.value, '')
|
||||||
|
assert.equal(h.view.clearanceError.value, '结果服务暂不可用')
|
||||||
|
assert.equal(h.view.detail.value.documents[0].documentId, 'document-A')
|
||||||
|
assert.equal(h.view.loading.value, false)
|
||||||
|
const retry = h.view.retryClearanceResults()
|
||||||
|
await h.view.retryClearanceResults()
|
||||||
|
assert.equal(h.requests.length, 1); assert.equal(h.clearances.length, 2)
|
||||||
|
assert.equal(h.view.clearanceLoading.value, true)
|
||||||
|
h.clearances[1].resolve([{ id: 'result-A' }]); await retry
|
||||||
|
assert.equal(h.view.clearanceError.value, '')
|
||||||
|
assert.deepEqual(h.view.clearanceResults.value, [{ id: 'result-A' }])
|
||||||
|
assert.equal(h.view.clearanceLoading.value, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('normal open requests detail and results exactly once and distinguishes successful empty results', async t => {
|
||||||
|
const h = harness(t); h.props.modelValue = true; await tick()
|
||||||
|
h.requests[0].resolve(detail('A')); await tick(); h.clearances[0].resolve([]); await tick()
|
||||||
|
assert.equal(h.requests.length, 1); assert.equal(h.clearances.length, 1)
|
||||||
|
assert.equal(h.view.clearanceError.value, '')
|
||||||
|
assert.equal(h.view.clearanceLoading.value, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const transition of ['switch', 'close', 'unmount']) {
|
||||||
|
for (const outcome of ['resolve', 'reject']) {
|
||||||
|
test(`${transition} ignores stale result retry ${outcome}`, async t => {
|
||||||
|
const h = harness(t); h.props.modelValue = true; await tick()
|
||||||
|
h.requests[0].resolve(detail('A')); await tick(); h.clearances[0].reject(new Error('first failure')); await tick()
|
||||||
|
const retry = h.view.retryClearanceResults()
|
||||||
|
if (transition === 'switch') { h.props.orderId = 'B'; await tick() }
|
||||||
|
else if (transition === 'close') { h.view.close(); h.props.modelValue = false; await tick() }
|
||||||
|
else h.unmount()
|
||||||
|
if (outcome === 'resolve') h.clearances[1].resolve([{ id: 'old-A' }])
|
||||||
|
else h.clearances[1].reject(new Error('old retry failure'))
|
||||||
|
await retry
|
||||||
|
assert.equal(h.view.clearanceError.value, '')
|
||||||
|
assert.deepEqual(h.view.clearanceResults.value, [])
|
||||||
|
if (transition === 'switch') {
|
||||||
|
h.requests[1].resolve(detail('B')); await tick(); h.clearances[2].resolve([{ id: 'B' }]); await tick()
|
||||||
|
assert.deepEqual(h.view.clearanceResults.value, [{ id: 'B' }])
|
||||||
|
} else {
|
||||||
|
await h.view.retryClearanceResults()
|
||||||
|
assert.equal(h.clearances.length, 2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('result error and retry reuse existing section error styling before the empty state', () => {
|
||||||
|
const source = readFileSync(new URL('./CustomsDocumentsDialog.vue', import.meta.url), 'utf8')
|
||||||
|
assert.match(source, /v-if="clearanceError" class="customs-error" role="alert"/)
|
||||||
|
assert.match(source, /@click="retryClearanceResults"/)
|
||||||
|
assert.match(source, /v-else-if="clearanceLoading"/)
|
||||||
|
assert.match(source, /v-else-if="!clearanceResults.length"/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -46,8 +46,12 @@ const replaceInput = ref<HTMLInputElement>()
|
|||||||
const mutating = ref(false)
|
const mutating = ref(false)
|
||||||
const downloadingKey = ref('')
|
const downloadingKey = ref('')
|
||||||
const clearanceResults = ref<MerchantClearanceResult[]>([])
|
const clearanceResults = ref<MerchantClearanceResult[]>([])
|
||||||
|
const clearanceError = ref('')
|
||||||
|
const clearanceLoading = ref(false)
|
||||||
|
let clearanceRequest = 0
|
||||||
|
let disposed = false
|
||||||
let detailRequest = 0
|
let detailRequest = 0
|
||||||
onBeforeUnmount(() => { detailRequest += 1 })
|
onBeforeUnmount(() => { disposed = true; detailRequest += 1; resetClearanceResults() })
|
||||||
|
|
||||||
const taxRefundInbounds = computed(() => detail.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND') ?? [])
|
const taxRefundInbounds = computed(() => detail.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND') ?? [])
|
||||||
const locked = computed(() => Boolean(detail.value?.startedAt) || detail.value?.status === 'DECLARING' || detail.value?.status === 'COMPLETED')
|
const locked = computed(() => Boolean(detail.value?.startedAt) || detail.value?.status === 'DECLARING' || detail.value?.status === 'COMPLETED')
|
||||||
@@ -68,9 +72,40 @@ function initializeSelection(value: MerchantCustomsContainer) {
|
|||||||
selectedInboundIds.value = currentOrderIds
|
selectedInboundIds.value = currentOrderIds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetClearanceResults() {
|
||||||
|
clearanceRequest += 1
|
||||||
|
clearanceResults.value = []
|
||||||
|
clearanceError.value = ''
|
||||||
|
clearanceLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClearanceResults() {
|
||||||
|
if (disposed || !props.modelValue || !props.orderId || !detail.value || clearanceLoading.value) return
|
||||||
|
const request = ++clearanceRequest
|
||||||
|
const parentRequest = detailRequest
|
||||||
|
const orderId = props.orderId
|
||||||
|
const isCurrent = () => !disposed && request === clearanceRequest && parentRequest === detailRequest && props.modelValue && props.orderId === orderId
|
||||||
|
clearanceLoading.value = true
|
||||||
|
clearanceError.value = ''
|
||||||
|
try {
|
||||||
|
const results = await getMerchantClearanceResults(orderId)
|
||||||
|
if (isCurrent()) clearanceResults.value = results
|
||||||
|
} catch (error) {
|
||||||
|
if (isCurrent()) clearanceError.value = merchantCustomsErrorMessage(error, '海关申报结果加载失败')
|
||||||
|
} finally {
|
||||||
|
if (isCurrent()) clearanceLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryClearanceResults() {
|
||||||
|
if (loading.value) return
|
||||||
|
await loadClearanceResults()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDetail() {
|
async function loadDetail() {
|
||||||
if (!props.modelValue || !props.orderId) return
|
if (disposed || !props.modelValue || !props.orderId) return
|
||||||
const request = ++detailRequest
|
const request = ++detailRequest
|
||||||
|
resetClearanceResults()
|
||||||
const orderId = props.orderId
|
const orderId = props.orderId
|
||||||
const isCurrent = () => request === detailRequest && props.modelValue && props.orderId === orderId
|
const isCurrent = () => request === detailRequest && props.modelValue && props.orderId === orderId
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -80,10 +115,8 @@ async function loadDetail() {
|
|||||||
if (!isCurrent()) return
|
if (!isCurrent()) return
|
||||||
detail.value = result
|
detail.value = result
|
||||||
initializeSelection(result)
|
initializeSelection(result)
|
||||||
let results: MerchantClearanceResult[] = []
|
await loadClearanceResults()
|
||||||
try { results = await getMerchantClearanceResults(orderId) } catch { /* Detail remains available for a failed result query. */ }
|
|
||||||
if (!isCurrent()) return
|
if (!isCurrent()) return
|
||||||
clearanceResults.value = results
|
|
||||||
emit('updated', result)
|
emit('updated', result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isCurrent()) return
|
if (!isCurrent()) return
|
||||||
@@ -97,7 +130,7 @@ async function loadDetail() {
|
|||||||
watch(() => [props.modelValue, props.orderId] as const, ([open]) => {
|
watch(() => [props.modelValue, props.orderId] as const, ([open]) => {
|
||||||
detailRequest += 1
|
detailRequest += 1
|
||||||
detail.value = null
|
detail.value = null
|
||||||
clearanceResults.value = []
|
resetClearanceResults()
|
||||||
selectedInboundIds.value = []
|
selectedInboundIds.value = []
|
||||||
editingDocument.value = null
|
editingDocument.value = null
|
||||||
replacingDocument.value = null
|
replacingDocument.value = null
|
||||||
@@ -111,6 +144,8 @@ watch(() => [props.modelValue, props.orderId] as const, ([open]) => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
|
detailRequest += 1
|
||||||
|
resetClearanceResults()
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +380,13 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) {
|
|||||||
<div class="customs-section-title">
|
<div class="customs-section-title">
|
||||||
<div><h3>海关申报结果</h3><p>平台完成正式申报后,客户可查看与本订单相关的查验、补充资料和放行结果。</p></div>
|
<div><h3>海关申报结果</h3><p>平台完成正式申报后,客户可查看与本订单相关的查验、补充资料和放行结果。</p></div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="!clearanceResults.length" description="暂无海关结果" :image-size="48" />
|
<div v-if="clearanceError" class="customs-error" role="alert">
|
||||||
|
<strong>海关申报结果加载失败</strong>
|
||||||
|
<span>{{ clearanceError }}</span>
|
||||||
|
<el-button :loading="clearanceLoading" @click="retryClearanceResults">重新加载</el-button>
|
||||||
|
</div>
|
||||||
|
<el-skeleton v-else-if="clearanceLoading" :rows="2" animated />
|
||||||
|
<el-empty v-else-if="!clearanceResults.length" description="暂无海关结果" :image-size="48" />
|
||||||
<div v-else class="clearance-result-list">
|
<div v-else class="clearance-result-list">
|
||||||
<div v-for="item in clearanceResults" :key="item.id" class="clearance-result-row">
|
<div v-for="item in clearanceResults" :key="item.id" class="clearance-result-row">
|
||||||
<div><strong>{{ clearanceResultLabel(item) }}</strong><span>{{ formatDateTime(item.occurredAt) }} · 报关单号 {{ item.customsEntryNo || '—' }}</span></div>
|
<div><strong>{{ clearanceResultLabel(item) }}</strong><span>{{ formatDateTime(item.occurredAt) }} · 报关单号 {{ item.customsEntryNo || '—' }}</span></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user