diff --git a/src/api/overseasFailure.test.mjs b/src/api/overseasFailure.test.mjs new file mode 100644 index 0000000..df61ce0 --- /dev/null +++ b/src/api/overseasFailure.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import ts from 'typescript' + +const source = await readFile(new URL('./types.ts', import.meta.url), 'utf8') +const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, +}).outputText +const { isApiFailure, isApiEnvelope } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`) + +test('recognizes a security failure without data and preserves diagnostics', () => { + const failure = { code: 'A0403', message: 'Access denied', requestId: 'request-1', traceId: 'trace-1' } + assert.equal(isApiFailure(failure), true) + assert.equal(isApiEnvelope(failure), false) + assert.equal(failure.requestId, 'request-1') + assert.equal(failure.traceId, 'trace-1') +}) + +test('does not accept malformed or data-less successful responses', () => { + for (const value of [null, {}, { code: 403, message: 'Denied' }, { code: 'A0403' }, + { code: 'A0403', message: 'Denied', traceId: {} }, { code: '200', message: 'OK' }]) { + assert.equal(isApiFailure(value), false) + } + assert.equal(isApiEnvelope({ code: '200', message: 'OK' }), false) + assert.equal(isApiEnvelope({ code: '200', message: 'OK', data: [] }), true) +}) + +test('overseas transport handles business-code and HTTP security failures', async () => { + const transport = await readFile(new URL('./overseasHttp.ts', import.meta.url), 'utf8') + assert.match(transport, /if \(isApiFailure\(response.data\)\)/) + assert.match(transport, /isApiEnvelope\(body\) \|\| isApiFailure\(body\)/) +}) diff --git a/src/api/overseasHttp.ts b/src/api/overseasHttp.ts index 72e4a1c..3abc261 100644 --- a/src/api/overseasHttp.ts +++ b/src/api/overseasHttp.ts @@ -1,6 +1,6 @@ import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' import { parseApiJsonResponse } from '@chongchong/shared' -import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types' +import { ApiError, isApiEnvelope, isApiFailure } from '@/api/types' import { getOverseasAccessToken } from '@/api/session' import { refreshAuthenticatedSession } from '@/api/http' @@ -37,7 +37,11 @@ transport.interceptors.response.use((response) => response, async (error) => { export async function overseasApiRequest(config: AxiosRequestConfig) { try { - const response = await transport.request>(config) + const response = await transport.request(config) + if (isApiFailure(response.data)) { + throw new ApiError(response.data.message, response.data.code, response.status, undefined, + response.data.traceId, response.data.requestId) + } if (!isApiEnvelope(response.data) || response.data.code !== '200') { throw new ApiError( isApiEnvelope(response.data) ? response.data.message : '海外接口响应格式错误', @@ -48,12 +52,12 @@ export async function overseasApiRequest(config: AxiosRequestConfig) { isApiEnvelope(response.data) ? response.data.requestId : null, ) } - return response.data.data + return response.data.data as T } catch (error) { if (error instanceof ApiError) throw error if (axios.isAxiosError(error)) { const body = error.response?.data - if (isApiEnvelope(body)) { + if (isApiEnvelope(body) || isApiFailure(body)) { throw new ApiError(body.message, body.code, error.response?.status, undefined, body.traceId, body.requestId) } throw new ApiError(error.code === 'ERR_NETWORK' ? '无法连接海外服务' : error.message, error.code || 'OVERSEAS_HTTP_ERROR', error.response?.status) diff --git a/src/api/types.ts b/src/api/types.ts index e262136..0a405f0 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -30,3 +30,13 @@ export function isApiEnvelope(value: unknown): value is ApiEnvelope { const record = value as Record return typeof record.code === 'string' && typeof record.message === 'string' && 'data' in record } + +// Security failures have no business data, unlike successful API responses. +export function isApiFailure(value: unknown): value is Omit, 'data'> { + if (!value || typeof value !== 'object') return false + const record = value as Record + return typeof record.code === 'string' && record.code !== '200' + && typeof record.message === 'string' + && (record.requestId == null || typeof record.requestId === 'string') + && (record.traceId == null || typeof record.traceId === 'string') +} diff --git a/src/modules/first-leg/public.ts b/src/modules/first-leg/public.ts index 50e9e80..5ba3b00 100644 --- a/src/modules/first-leg/public.ts +++ b/src/modules/first-leg/public.ts @@ -1,3 +1,4 @@ export { useFirstLegOrdersStore } from './store/firstLegOrders' +export { getMerchantWarehouses, type MerchantWarehouse } from './api/firstLeg' export { getFirstLegStatusLabel, getFirstLegStatusTone } from './utils/firstLegOrder' export { default as InboundCodeAssociationDialog } from './components/InboundCodeAssociationDialog.vue' diff --git a/src/modules/overseas-warehouse/api/returnInventory.ts b/src/modules/overseas-warehouse/api/returnInventory.ts index 560a504..44c7a9f 100644 --- a/src/modules/overseas-warehouse/api/returnInventory.ts +++ b/src/modules/overseas-warehouse/api/returnInventory.ts @@ -13,6 +13,15 @@ export interface ReturnInventorySummaryItem { export interface ReturnInventoryCartonItem { skuCode: string; productNameZh?: string; quantity: number } +export interface ReturnInventoryCartonSummary { + cartonMark: string; overseasWarehouseId: string; sourceBatchNo?: string; sourceReturnNo?: string; status: string + skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string +} + +export function listReturnInventoryCartons(params: Record) { + return overseasApiRequest>({ method: 'GET', url: '/merchant/v1/last-leg/return-inventory/cartons', params }) +} + export interface ReturnInventoryCartonDetail { cartonMark: string; overseasWarehouseId: string; sourceBatchNo?: string; sourceReturnNo?: string; status: string skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; receivedAt: string; items: ReturnInventoryCartonItem[] diff --git a/src/modules/overseas-warehouse/views/ReturnInventoryView.test.mjs b/src/modules/overseas-warehouse/views/ReturnInventoryView.test.mjs new file mode 100644 index 0000000..184c0c0 --- /dev/null +++ b/src/modules/overseas-warehouse/views/ReturnInventoryView.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const inventory = readFileSync(new URL('./ReturnInventoryView.vue', import.meta.url), 'utf8') +const orders = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8') + +test('inventory failures are visible and are not presented as empty inventory', () => { + assert.match(inventory, /catch \(error\)/) + assert.match(inventory, /]*fixed="left"/) + } + assert.match(inventory, /prop="updatedAt" label="库存更新时间"/) + assert.doesNotMatch(inventory, /实收并上架|最近入库时间/) +}) + +test('carton management uses server carton pagination rather than deduplicating SKU pages', () => { + assert.match(inventory, /if \(props.cartonOnly\) \{\s*const page = await listReturnInventoryCartons/) + assert.match(inventory, /:data="cartonSummaryRows" row-key="cartonMark"/) + assert.match(inventory, /cartonSummaryRows.value = \[\]/) +}) + +test('printing opens its window before awaiting the label and closes on failure', () => { + const print = inventory.slice(inventory.indexOf('async function printLabel'), inventory.indexOf('onMounted(')) + assert.ok(print.indexOf('window.open(') < print.indexOf('await getReturnInventoryLabel(')) + assert.match(print, /if \(printWindow.closed\) return/) + assert.match(print, /catch \(error\) \{\s*printWindow.close\(\)/) +}) + +test('warehouse selectors and displays use the catalog instead of internal IDs', () => { + assert.match(inventory, /from '@\/modules\/first-leg\/public'/) + assert.match(inventory, /warehouseType: 'OVERSEAS', page, size: 200/) + assert.match(inventory, /warehouseLabel\(cartonDetail.overseasWarehouseId\)/) + assert.match(inventory, /warehouse\.warehouseName/) + assert.match(inventory, /warehouse\.warehouseCode/) + assert.doesNotMatch(inventory, /海外仓 ID|\{\{ cartonDetail.overseasWarehouseId \}\}|prop="overseasWarehouseId"/) +}) diff --git a/src/modules/overseas-warehouse/views/ReturnInventoryView.vue b/src/modules/overseas-warehouse/views/ReturnInventoryView.vue index 670b3af..cddd34d 100644 --- a/src/modules/overseas-warehouse/views/ReturnInventoryView.vue +++ b/src/modules/overseas-warehouse/views/ReturnInventoryView.vue @@ -2,11 +2,15 @@ import { onMounted, reactive, ref } from 'vue' import { ElMessage } from 'element-plus' import { Printer, Search } from '@lucide/vue' +import { ApiError } from '@/api/types' +import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public' import { getReturnInventoryCarton, getReturnInventoryLabel, listReturnInventory, listReturnInventorySummary, + listReturnInventoryCartons, + type ReturnInventoryCartonSummary, type ReturnInventoryCartonDetail, type ReturnInventoryItem, type ReturnInventorySummaryItem, @@ -16,8 +20,33 @@ const activeTab = ref<'summary' | 'cartons'>('summary') const props = defineProps<{ cartonOnly?: boolean }>() if (props.cartonOnly) activeTab.value = 'cartons' const loading = ref(false) +const loadError = ref('') +const warehouses = ref([]) +const warehouseError = ref('') +const warehousesLoading = ref(false) +function warehouseLabel(warehouseId: string) { + const warehouse = warehouses.value.find(item => String(item.id) === String(warehouseId)) + return warehouse ? `${warehouse.warehouseName} · ${warehouse.warehouseCode}` : '—' +} +async function loadWarehouses() { + warehousesLoading.value = true + warehouseError.value = '' + try { + const result: MerchantWarehouse[] = [] + for (let page = 1; ; page++) { + const response = await getMerchantWarehouses({ warehouseType: 'OVERSEAS', page, size: 200 }) + result.push(...response.records) + if (!response.records.length || result.length >= Number(response.total)) break + } + warehouses.value = result + } catch (error) { + warehouses.value = [] + warehouseError.value = error instanceof Error ? error.message : '海外仓目录加载失败,请重试' + } finally { warehousesLoading.value = false } +} const summaryRows = ref([]) const cartonRows = ref([]) +const cartonSummaryRows = ref([]) const total = ref(0) const showCartonDetail = ref(false) const cartonDetail = ref() @@ -37,16 +66,35 @@ function params(includeCartonMark: boolean) { async function load() { loading.value = true + loadError.value = '' try { + if (props.cartonOnly) { + const page = await listReturnInventoryCartons(params(true)) + cartonSummaryRows.value = page.records + total.value = Number(page.total) + return + } if (activeTab.value === 'summary') { const page = await listReturnInventorySummary(params(false)) summaryRows.value = page.records - total.value = page.total + total.value = Number(page.total) return } const page = await listReturnInventory(params(true)) cartonRows.value = page.records - total.value = page.total + total.value = Number(page.total) + } catch (error) { + summaryRows.value = [] + cartonRows.value = [] + cartonSummaryRows.value = [] + total.value = 0 + const message = error instanceof ApiError && (error.code === 'A0403' || error.status === 403) + ? '当前账号无权访问退货库存,请联系管理员核对海外库存权限。' + : error instanceof Error ? error.message : '退货库存加载失败,请重试' + const diagnostics = error instanceof ApiError + ? [error.code, error.requestId && `requestId: ${error.requestId}`, error.traceId && `traceId: ${error.traceId}`].filter(Boolean).join(' · ') + : '' + loadError.value = diagnostics ? `${message}(${diagnostics})` : message } finally { loading.value = false } } @@ -67,51 +115,71 @@ async function openCarton(cartonMark: string) { } async function printLabel(cartonMark: string) { + const printWindow = window.open('', '_blank') + if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return } + printWindow.opener = null + printWindow.document.title = '正在加载退货箱唛' + printWindow.document.body.textContent = '正在加载退货箱唛,请稍候…' try { const label = await getReturnInventoryLabel(cartonMark) - const printWindow = window.open('', '_blank') - if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return } - printWindow.opener = null + if (printWindow.closed) return printWindow.document.open() printWindow.document.write(label.printableHtml) printWindow.document.close() printWindow.focus() printWindow.print() - } catch (error) { ElMessage.error(error instanceof Error ? error.message : '箱唛加载失败') } + } catch (error) { + printWindow.close() + ElMessage.error(error instanceof Error ? error.message : '箱唛加载失败') + } } -onMounted(load) +onMounted(() => { void load(); void loadWarehouses() })