diff --git a/scripts/phase4-development-frontend-acceptance.mjs b/scripts/phase4-development-frontend-acceptance.mjs index c358737c..7321b557 100644 --- a/scripts/phase4-development-frontend-acceptance.mjs +++ b/scripts/phase4-development-frontend-acceptance.mjs @@ -30,10 +30,10 @@ const overseasGateway = normalizeOrigin(option('--overseas-gateway') || process. const password = acceptancePassword() if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD 或本机开发验收凭据文件') -const executable = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const result = spawnSync(executable, ['exec', 'vitest', 'run', '--config', 'vitest.phase4-development.config.ts'], { +const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-development.config.ts'], { cwd: process.cwd(), stdio: 'inherit', + shell: process.platform === 'win32', env: { ...process.env, ADMIN_PHASE4_LIVE: '1', @@ -45,4 +45,5 @@ const result = spawnSync(executable, ['exec', 'vitest', 'run', '--config', 'vite VITE_OVERSEAS_API_BASE_URL: `${overseasGateway}/api`, }, }) +if (result.error) throw result.error process.exit(result.status ?? 1) diff --git a/scripts/phase4-development-frontend-contract.test.mjs b/scripts/phase4-development-frontend-contract.test.mjs index 2613f68a..04f14f9e 100644 --- a/scripts/phase4-development-frontend-contract.test.mjs +++ b/scripts/phase4-development-frontend-contract.test.mjs @@ -10,6 +10,8 @@ test('development runner requires an explicit development environment and gatewa assert.match(runner, /environment !== 'development'/) assert.match(runner, /--gateway/) assert.match(runner, /--overseas-gateway/) + assert.match(runner, /shell: process\.platform === 'win32'/) + assert.match(runner, /if \(result\.error\) throw result\.error/) assert.doesNotMatch(runner, /ADMIN_ACCEPTANCE_PASSWORD\s*[:=]\s*['"][^'"]+['"]/) }) diff --git a/src/modules/overseas-master-data/api/overseasMasterData.test.ts b/src/modules/overseas-master-data/api/overseasMasterData.test.ts index 65c22eed..d192ff54 100644 --- a/src/modules/overseas-master-data/api/overseasMasterData.test.ts +++ b/src/modules/overseas-master-data/api/overseasMasterData.test.ts @@ -138,8 +138,8 @@ describe('overseas master data API', () => { const error = await exportVehicles({}).catch((reason: unknown) => reason) expect(error).toBeInstanceOf(ApiResponseError) expect(error).toMatchObject({ code: 'A0409', message: '导出条件已变化', requestId: 'request-export' }) - expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes/export', { params: { status: 'ACTIVE' }, responseType: 'blob' }) - expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/master-data/exchange-rates/export', { params: {}, responseType: 'blob' }) - expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/vehicles/export', { params: { ownership: 'OWNED' }, responseType: 'blob' }) + expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes/export', { params: { status: 'ACTIVE' }, responseType: 'arraybuffer' }) + expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/master-data/exchange-rates/export', { params: {}, responseType: 'arraybuffer' }) + expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/vehicles/export', { params: { ownership: 'OWNED' }, responseType: 'arraybuffer' }) }) }) diff --git a/src/modules/overseas-master-data/api/overseasMasterData.ts b/src/modules/overseas-master-data/api/overseasMasterData.ts index 842acf90..53901045 100644 --- a/src/modules/overseas-master-data/api/overseasMasterData.ts +++ b/src/modules/overseas-master-data/api/overseasMasterData.ts @@ -34,27 +34,52 @@ const normalizeQuoteDetail = (value: QuoteDetailWire): OverseasWarehouseQuoteDet const normalizeExchangeRate = (value: ExchangeRateWire): MonthlyExchangeRate => ({ ...value, usdToCnyAverage: numeric(value.usdToCnyAverage), recordVersion: numeric(value.recordVersion) }) const normalizeVehicle = (value: VehicleWire): VehicleDetail => ({ ...value, fuelConsumptionPer100Km: numeric(value.fuelConsumptionPer100Km), mileageKm: numeric(value.mileageKm), recordVersion: numeric(value.recordVersion) }) -async function csvError(blob: Blob, status: number) { - if (!blob.type.toLowerCase().includes('json')) return null +type DownloadPayload = Blob | ArrayBuffer | ArrayBufferView | string + +async function downloadText(payload: DownloadPayload) { + if (typeof payload === 'string') return payload + if (payload instanceof Blob) return payload.text() + const bytes = payload instanceof ArrayBuffer + ? new Uint8Array(payload) + : new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength) + return new TextDecoder().decode(bytes) +} + +async function csvError(payload: DownloadPayload, status: number) { + const contentType = payload instanceof Blob ? payload.type.toLowerCase() : '' + const text = await downloadText(payload) + if (!contentType.includes('json') && !/^\s*[\[{]/.test(text)) return null try { - const payload = JSON.parse(await blob.text()) as Partial> - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('invalid envelope') - return new ApiResponseError(payload.message || 'CSV 导出失败', payload.code || 'DOWNLOAD_FAILED', status, - payload.data, payload.requestId, payload.traceId) + const envelope = JSON.parse(text) as Partial> + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) throw new Error('invalid envelope') + return new ApiResponseError(envelope.message || 'CSV 导出失败', envelope.code || 'DOWNLOAD_FAILED', status, + envelope.data, envelope.requestId, envelope.traceId) } catch (error) { if (error instanceof ApiResponseError) return error return new ApiResponseError('CSV 导出接口返回了无法识别的 JSON 响应', 'INVALID_DOWNLOAD_RESPONSE', status) } } +function csvBlob(payload: DownloadPayload) { + if (payload instanceof Blob) return payload + if (typeof payload === 'string') return new Blob([payload], { type: 'text/csv;charset=utf-8' }) + const bytes = payload instanceof ArrayBuffer + ? new Uint8Array(payload) + : new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength) + const copy = new Uint8Array(bytes.byteLength) + copy.set(bytes) + return new Blob([copy.buffer], { type: 'text/csv;charset=utf-8' }) +} async function download(path: string, params: object) { try { - const blob = await overseasHttp.get(path, { params, responseType: 'blob' }) - const error = await csvError(blob, 200) + const payload = await overseasHttp.get(path, { params, responseType: 'arraybuffer' }) + const error = await csvError(payload, 200) if (error) throw error - return blob + return csvBlob(payload) } catch (error) { - if (axios.isAxiosError(error) && error.response?.data instanceof Blob) { - const responseError = await csvError(error.response.data, error.response.status) + const axiosError = axios.isAxiosError(error) ? error : null + const responsePayload = axiosError?.response?.data as DownloadPayload | undefined + if (responsePayload !== undefined) { + const responseError = await csvError(responsePayload, axiosError?.response?.status || 0) if (responseError) throw responseError } throw error diff --git a/src/modules/overseas-warehouse/api/dispatches.ts b/src/modules/overseas-warehouse/api/dispatches.ts index aee553e7..82b7c9b3 100644 --- a/src/modules/overseas-warehouse/api/dispatches.ts +++ b/src/modules/overseas-warehouse/api/dispatches.ts @@ -1,6 +1,6 @@ import { overseasHttp } from '@/shared/api/overseasHttp' import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { OverseasPageResult } from './lastMileOrders' +import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface DispatchQuery { page: number @@ -106,11 +106,11 @@ export interface DispatchItemDto { } export async function listDispatches(query: DispatchQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await overseasHttp.get>, ApiEnvelope>>( '/platform/v1/last-leg/dispatches', { params: query }, ) - return unwrapApiResponse(response) + return normalizeOverseasPage(unwrapApiResponse(response)) } export async function getDispatch(dispatchNo: string) { diff --git a/src/modules/overseas-warehouse/api/inboundOrders.ts b/src/modules/overseas-warehouse/api/inboundOrders.ts index b30db749..858e2312 100644 --- a/src/modules/overseas-warehouse/api/inboundOrders.ts +++ b/src/modules/overseas-warehouse/api/inboundOrders.ts @@ -1,6 +1,6 @@ import { overseasHttp } from '@/shared/api/overseasHttp' import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { OverseasPageResult } from './lastMileOrders' +import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface InboundOrderQuery { page: number @@ -67,10 +67,10 @@ export interface FbaAppointmentDto { export type UpdateFbaAppointmentPayload = Omit export async function listInboundOrders(query: InboundOrderQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await overseasHttp.get>, ApiEnvelope>>( '/platform/v1/last-leg/inbound-plans', { params: query }, ) - return unwrapApiResponse(response) + return normalizeOverseasPage(unwrapApiResponse(response)) } export async function createFbaAppointment(payload: BatchFbaAppointmentPayload) { diff --git a/src/modules/overseas-warehouse/api/inboundPlans.ts b/src/modules/overseas-warehouse/api/inboundPlans.ts index 61313d9a..0877c2c4 100644 --- a/src/modules/overseas-warehouse/api/inboundPlans.ts +++ b/src/modules/overseas-warehouse/api/inboundPlans.ts @@ -1,6 +1,6 @@ import { overseasHttp } from '@/shared/api/overseasHttp' import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { OverseasPageResult } from './lastMileOrders' +import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface InboundPlanQuery { customerId: string @@ -58,7 +58,7 @@ export interface InboundPlanDetailDto { export async function listInboundPlans(query: InboundPlanQuery) { const { customerId, ...params } = query - return unwrapApiResponse(await overseasHttp.get>, ApiEnvelope>>('/platform/v1/last-leg/inbound-plans', { params: { customerId, ...params } })) + return normalizeOverseasPage(unwrapApiResponse(await overseasHttp.get>, ApiEnvelope>>('/platform/v1/last-leg/inbound-plans', { params: { customerId, ...params } }))) } export async function getInboundPlan(planNo: string, customerId: string) { diff --git a/src/modules/overseas-warehouse/api/inventory.ts b/src/modules/overseas-warehouse/api/inventory.ts index 98c2074d..1889c422 100644 --- a/src/modules/overseas-warehouse/api/inventory.ts +++ b/src/modules/overseas-warehouse/api/inventory.ts @@ -1,6 +1,6 @@ import { overseasHttp } from '@/shared/api/overseasHttp' import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { OverseasPageResult } from './lastMileOrders' +import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface InventoryBalanceDto { id: string @@ -110,10 +110,10 @@ export interface InventoryBalanceQuery { function params(query: InventoryBalanceQuery) { return { ...query } } export async function listInventoryBalances(query: InventoryBalanceQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await overseasHttp.get>, ApiEnvelope>>( '/platform/v1/last-leg/inventory/balances', { params: params(query) }, ) - return unwrapApiResponse(response) + return normalizeOverseasPage(unwrapApiResponse(response)) } export async function getInventorySummary(query: InventoryBalanceQuery) { @@ -153,9 +153,9 @@ export interface InventoryLedgerQuery { } export async function listInventoryLedger(query: InventoryLedgerQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await overseasHttp.get>, ApiEnvelope>>( '/platform/v1/last-leg/inventory/ledger', { params: { ...query } }, ) - return unwrapApiResponse(response) + return normalizeOverseasPage(unwrapApiResponse(response)) } diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts index 9f9c6ee6..018f9bb0 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts @@ -8,8 +8,8 @@ describe('last-mile order overseas API', () => { beforeEach(() => vi.mocked(overseasHttp.get).mockReset()) it('loads all platform orders without a customer scope', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 2, size: 20, total: 0, pages: 0 } }) - await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toMatchObject({ page: 2 }) + vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '0', pages: '0' } }) + await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toEqual({ records: [], page: 2, size: 20, total: 0, pages: 0 }) expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', { params: { page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' }, }) diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index 3c1c059e..96504922 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -9,6 +9,17 @@ export interface OverseasPageResult { pages: number } +export type OverseasPagePayload = Omit, 'page' | 'size' | 'total' | 'pages'> & { + page: number | string + size: number | string + total: number | string + pages: number | string +} + +export function normalizeOverseasPage(value: OverseasPagePayload): OverseasPageResult { + return { ...value, page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) } +} + export interface LastMileOrderQuery { page: number size: number @@ -118,11 +129,11 @@ export interface LastMileOrderDetailDto { } export async function listLastMileOrders(query: LastMileOrderQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await overseasHttp.get>, ApiEnvelope>>( '/platform/v1/last-leg/orders', { params: query }, ) - return unwrapApiResponse(response) + return normalizeOverseasPage(unwrapApiResponse(response)) } export async function getLastMileOrder(orderId: string) { diff --git a/src/modules/overseas-warehouse/api/pallets.ts b/src/modules/overseas-warehouse/api/pallets.ts index 5204d8ec..5e478508 100644 --- a/src/modules/overseas-warehouse/api/pallets.ts +++ b/src/modules/overseas-warehouse/api/pallets.ts @@ -1,6 +1,6 @@ import { overseasHttp } from '@/shared/api/overseasHttp' import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { OverseasPageResult } from './lastMileOrders' +import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface CustomerWarehouseDto { id: string @@ -44,19 +44,19 @@ export interface UpdatePalletPayload extends SavePalletPayload { } export async function listCustomerWarehouses() { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await overseasHttp.get>, ApiEnvelope>>( '/platform/v1/warehouse-admin', { params: { page: 1, size: 200 } }, ) - return unwrapApiResponse(response) + return normalizeOverseasPage(unwrapApiResponse(response)) } export async function listPallets(warehouseId: string, query: { page: number; size: number; keyword?: string; palletType?: string; status?: string }) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await overseasHttp.get>, ApiEnvelope>>( `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { params: { ...query } }, ) - return unwrapApiResponse(response) + return normalizeOverseasPage(unwrapApiResponse(response)) } export async function getPallet(warehouseId: string, palletId: string) { diff --git a/src/modules/overseas-warehouse/api/relabelOrders.ts b/src/modules/overseas-warehouse/api/relabelOrders.ts index a0110faa..f6a0b2a6 100644 --- a/src/modules/overseas-warehouse/api/relabelOrders.ts +++ b/src/modules/overseas-warehouse/api/relabelOrders.ts @@ -1,6 +1,6 @@ import { overseasHttp } from '@/shared/api/overseasHttp' import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { OverseasPageResult } from './lastMileOrders' +import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface RelabelOrderQuery { page: number @@ -92,10 +92,10 @@ export interface RelabelDetailDto { } export async function listRelabelOrders(query: RelabelOrderQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await overseasHttp.get>, ApiEnvelope>>( '/platform/v1/last-leg/relabel-applications', { params: query }, ) - return unwrapApiResponse(response) + return normalizeOverseasPage(unwrapApiResponse(response)) } export async function getRelabelOrder(relabelNo: string) { @@ -106,11 +106,11 @@ export async function getRelabelOrder(relabelNo: string) { } export async function listRelabelWarehouseOptions(customerId: string) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await overseasHttp.get>, ApiEnvelope>>( '/platform/v1/warehouse-admin', { params: { customerId, page: 1, size: 200, status: 'ACTIVE' } }, ) - return unwrapApiResponse(response) + return normalizeOverseasPage(unwrapApiResponse(response)) } export async function createRelabelOrder(customerId: string, payload: CreateRelabelPayload) { diff --git a/src/modules/overseas-warehouse/pages/PalletsView.test.ts b/src/modules/overseas-warehouse/pages/PalletsView.test.ts index 9e8b8c6e..48162e79 100644 --- a/src/modules/overseas-warehouse/pages/PalletsView.test.ts +++ b/src/modules/overseas-warehouse/pages/PalletsView.test.ts @@ -36,7 +36,7 @@ describe('PalletsView', () => { await flushPromises() wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '200', code: '', type: '', status: '' }) await flushPromises() - await wrapper.findAll('button').find((button) => button.text().includes('编辑'))!.trigger('click') + await wrapper.findAll('button.el-button--small').filter((button) => button.text().includes('编辑')).at(-1)!.trigger('click') await flushPromises() const dialog = wrapper.findComponent(AppDialog) dialog.vm.$emit('confirm')