import { afterAll, beforeAll, describe, expect, it } from 'vitest' import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios' import { mkdirSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import { createPinia, setActivePinia } from 'pinia' import { createMemoryHistory, createRouter } from 'vue-router' import { defineComponent, nextTick } from 'vue' import { mount, type VueWrapper } from '@vue/test-utils' import ElementPlus from 'element-plus' import { http, ApiResponseError } from '@/shared/api/http' import { clearOverseasAccessToken, overseasHttp } from '@/shared/api/overseasHttp' import { useAuthStore } from '@/core/auth/auth' import { listPlatformTenants } from '@/modules/iam/api/platformIam' import { changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle, exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getQuote, getVehicle, listExchangeRates, listQuoteWarehouseOptions, listQuotes, listVehicles, updateExchangeRate, updateQuote, updateVehicle, } from '@/modules/overseas-master-data/api/overseasMasterData' import { createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder, listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases, listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateUsTruckOrder, } from '@/modules/overseas-business/api/platformOverseasBusiness' import { listLastMileOrders, getLastMileOrder } from '@/modules/overseas-warehouse/api/lastMileOrders' import { listInboundOrders } from '@/modules/overseas-warehouse/api/inboundOrders' import { getInboundPlan } from '@/modules/overseas-warehouse/api/inboundPlans' import { listDispatchCandidates, listDispatchDrafts, listDispatches } from '@/modules/overseas-warehouse/api/dispatches' import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from '@/modules/overseas-warehouse/api/inventory' import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from '@/modules/overseas-warehouse/api/pallets' import { listRelabelOrders } from '@/modules/overseas-warehouse/api/relabelOrders' import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '@/modules/port-schedules/api/platformCarrierScheduleQueries' import OverseasQuotesView from '@/modules/overseas-master-data/pages/OverseasQuotesView.vue' import PurchaseOrdersView from '@/modules/overseas-business/pages/PurchaseOrdersView.vue' import TransportOrdersView from '@/modules/overseas-business/pages/TransportOrdersView.vue' import LastMileOrdersView from '@/modules/overseas-warehouse/pages/LastMileOrdersView.vue' import InboundOrdersView from '@/modules/overseas-warehouse/pages/InboundOrdersView.vue' import InventoryView from '@/modules/overseas-warehouse/pages/InventoryView.vue' import DispatchesView from '@/modules/overseas-warehouse/pages/DispatchesView.vue' import RelabelOrdersView from '@/modules/overseas-warehouse/pages/RelabelOrdersView.vue' type Region = 'domestic' | 'overseas' interface TraceStep { region: Region; method: string; path: string; params: unknown; body: unknown; httpStatus: number; code?: string; requestId?: string; traceId?: string; authorization: boolean } interface CoverageGap { area: string; reason: string } const enabled = process.env.ADMIN_PHASE4_LIVE === '1' const marker = `ADMIN-FRONT-P4-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}` const traces: TraceStep[] = [] const coverageGaps: CoverageGap[] = [] const createdResources: Record = {} const wrappers: VueWrapper[] = [] function redact(value: unknown): unknown { if (Array.isArray(value)) return value.map(redact) if (!value || typeof value !== 'object') return value return Object.fromEntries(Object.entries(value as Record).map(([key, item]) => [key, /password|token|authorization/i.test(key) ? '[REDACTED]' : redact(item)])) } function parsed(value: unknown) { if (typeof value !== 'string') return value try { return JSON.parse(value) } catch { return value } } function envelope(value: unknown) { const body = parsed(value) as { code?: unknown; requestId?: unknown; traceId?: unknown } | null return body && typeof body === 'object' ? body : null } function installTracingAdapter(client: AxiosInstance, region: Region) { const nodeAdapter = axios.getAdapter('http') client.defaults.adapter = async (config: InternalAxiosRequestConfig) => { try { const response = await nodeAdapter(config) const body = envelope(response.data) traces.push({ region, method: String(config.method || 'GET').toUpperCase(), path: String(config.url || ''), params: redact(config.params), body: redact(parsed(config.data)), httpStatus: response.status, code: body?.code == null ? undefined : String(body.code), requestId: body?.requestId == null ? undefined : String(body.requestId), traceId: body?.traceId == null ? undefined : String(body.traceId), authorization: Boolean(config.headers?.Authorization) }) return response } catch (error) { if (axios.isAxiosError(error)) { const body = envelope(error.response?.data) traces.push({ region, method: String(config.method || 'GET').toUpperCase(), path: String(config.url || ''), params: redact(config.params), body: redact(parsed(config.data)), httpStatus: error.response?.status || 0, code: body?.code == null ? undefined : String(body.code), requestId: body?.requestId == null ? undefined : String(body.requestId), traceId: body?.traceId == null ? undefined : String(body.traceId), authorization: Boolean(config.headers?.Authorization) }) } throw error } } } function gap(area: string, reason: string) { coverageGaps.push({ area, reason }) } function numericId(value: unknown) { return /^[1-9]\d*$/.test(String(value ?? '')) } function expectPage(value: { records: unknown[]; page: number; size: number; total: number; pages: number }) { expect(Array.isArray(value.records)).toBe(true); expect(value.page).toBeGreaterThanOrEqual(1); expect(value.size).toBeGreaterThan(0); expect(value.total).toBeGreaterThanOrEqual(value.records.length); expect(value.pages).toBeGreaterThanOrEqual(0) } async function expectConflict(action: () => Promise) { await expect(action()).rejects.toSatisfy((error: unknown) => { if (error instanceof ApiResponseError) return error.code.endsWith('409') if (!axios.isAxiosError(error)) return false const response = parsed(error.response?.data) as { code?: unknown } | undefined return error.response?.status === 409 || String(response?.code || '').endsWith('409') }) } function futureDate(days: number) { return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10) } async function masterDataLifecycle() { const options = await listQuoteWarehouseOptions() const quotePage = await listQuotes({ page: 1, size: 20 }) expectPage(quotePage) if (options.length) { const quote = await createQuote({ name: `${marker} 报价`, warehouseId: options[0].warehouseId, providerName: `${marker} Provider`, currency: 'USD', effectiveStart: '2098-01-01', effectiveEnd: '2098-12-31', versionCode: marker.slice(-32), warehouseFees: [{ item: 'Storage QA', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: 2.5, remark: marker }], transitPrices: [], remark: marker }) const id = quote.summary.id createdResources.quote = id let detail = await getQuote(id) const stale = detail.summary.recordVersion detail = await updateQuote(id, { recordVersion: stale, name: `${marker} 报价-编辑`, providerName: detail.summary.providerName, currency: detail.summary.currency, effectiveStart: detail.summary.effectiveStart, effectiveEnd: detail.summary.effectiveEnd || '2098-12-31', warehouseFees: detail.warehouseFees, transitPrices: detail.transitPrices, remark: `${marker} edited` }) await expectConflict(() => updateQuote(id, { recordVersion: stale, name: 'STALE', providerName: detail.summary.providerName, currency: detail.summary.currency, effectiveStart: detail.summary.effectiveStart, effectiveEnd: detail.summary.effectiveEnd || '2098-12-31', warehouseFees: detail.warehouseFees, transitPrices: detail.transitPrices, remark: marker })) detail = await changeQuoteStatus(id, 'ACTIVE', detail.summary.recordVersion) expect(detail.summary.status).toBe('ACTIVE') expect((await exportQuotes({ keyword: marker })).size).toBeGreaterThan(0) detail = await changeQuoteStatus(id, 'INACTIVE', detail.summary.recordVersion) expect(detail.summary.status).toBe('INACTIVE') } else gap('海外报价', 'warehouse-options 为空,未猜测仓库 ID,跳过报价写生命周期') let exchangeMonth = '' for (let index = 0; index < 24 && !exchangeMonth; index += 1) { const month = `${2097 + Math.floor(index / 12)}-${String(index % 12 + 1).padStart(2, '0')}` const page = await listExchangeRates({ page: 1, size: 20, monthFrom: month, monthTo: month }) if (!page.total) exchangeMonth = month } if (!exchangeMonth) throw new Error('安全汇率月份池已占满') let rate = await createExchangeRate({ month: exchangeMonth, usdToCnyAverage: 7.123456, remark: marker }) createdResources.exchangeRate = exchangeMonth const staleRate = rate.recordVersion rate = await updateExchangeRate(exchangeMonth, { recordVersion: staleRate, usdToCnyAverage: 7.234567, remark: `${marker} edited` }) await expectConflict(() => updateExchangeRate(exchangeMonth, { recordVersion: staleRate, usdToCnyAverage: 7.9, remark: 'STALE' })) rate = await getExchangeRate(exchangeMonth) rate = await changeExchangeRateStatus(exchangeMonth, 'INACTIVE', rate.recordVersion, `${marker} cleanup`) expect(rate.status).toBe('INACTIVE') expect((await exportExchangeRates({ monthFrom: exchangeMonth, monthTo: exchangeMonth })).size).toBeGreaterThan(0) let vehicle = await createVehicle({ plateNumber: `QA-${marker.slice(-18)}`, model: 'Phase4 Front QA', fuelConsumptionPer100Km: 18.5, mileageKm: 100, driverOrDispatchCompany: marker, phone: '+1-213-555-0188', region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: marker }) createdResources.vehicle = vehicle.id const staleVehicle = vehicle.recordVersion vehicle = await updateVehicle(vehicle.id, { recordVersion: staleVehicle, plateNumber: vehicle.plateNumber, model: 'Phase4 Front QA Edited', fuelConsumptionPer100Km: 18.75, mileageKm: 101, driverOrDispatchCompany: marker, phone: vehicle.phone, region: vehicle.region, ownership: vehicle.ownership, remark: `${marker} edited` }) await expectConflict(() => updateVehicle(vehicle.id, { recordVersion: staleVehicle, plateNumber: vehicle.plateNumber, model: 'STALE', fuelConsumptionPer100Km: 19, mileageKm: 102, driverOrDispatchCompany: marker, phone: vehicle.phone, region: vehicle.region, ownership: vehicle.ownership, remark: 'STALE' })) vehicle = await getVehicle(vehicle.id) vehicle = await changeVehicleStatus(vehicle.id, 'INACTIVE', vehicle.recordVersion) expect(vehicle.status).toBe('INACTIVE') expect((await exportVehicles({ keyword: vehicle.plateNumber })).size).toBeGreaterThan(0) expectPage(await listVehicles({ page: 1, size: 20, keyword: marker })) } async function cleanupTransport(path: string, customerId: string, version: number) { await overseasHttp.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } }) } async function overseasBusinessLifecycle() { const customers = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records.filter(item => item.status === 'ACTIVE') const active = customers.find(item => numericId(item.id)) if (!active) { gap('卡车/散板', 'IAM 无 ACTIVE 客户'); return } const customerId = String(active.id) expect(Array.isArray(await listOverseasPurchaseCustomerOptions())).toBe(true) expect(Array.isArray(await listOverseasPurchaseWarehouseOptions())).toBe(true) expectPage(await listOverseasPurchases({ page: 1, size: 20 })) const common = { origin: `${marker}-ORIGIN`, originAddress: 'Long Beach', destination: `${marker}-DEST`, destinationAddress: 'Ontario', containerType: '40HQ', packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88, currency: 'USD', contactName: '四期前端验收', contactInfo: '13800000000', requestedDate: futureDate(7), remark: marker } let truck = await createUsTruckOrder(customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, idempotencyKey: `${marker}-truck` }) createdResources.truck = truck.orderNo try { truck = await getUsTruckOrder(truck.orderNo, customerId) const stale = truck.version truck = await updateUsTruckOrder(truck.orderNo, customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, destination: `${marker}-DEST-EDIT`, version: stale }) await expectConflict(() => updateUsTruckOrder(truck.orderNo, customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, destination: 'STALE', version: stale })) } finally { truck = await getUsTruckOrder(truck.orderNo, customerId) if (truck.status === 'PENDING_REVIEW') await cleanupTransport(`/platform/v1/last-leg/us-truck-orders/${encodeURIComponent(truck.orderNo)}`, customerId, truck.version) } expectPage(await listUsTruckOrders({ page: 1, size: 20, customerId })) let loose = await createLocalLooseboardOrder(customerId, { transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', ...common, idempotencyKey: `${marker}-looseboard` }) createdResources.looseboard = loose.orderNo try { loose = await getLocalLooseboardOrder(loose.orderNo, customerId) const stale = loose.version loose = await updateLocalLooseboardOrder(loose.orderNo, customerId, { transportMode: loose.transportMode, palletRequired: loose.palletRequired, transportType: loose.transportType, ...common, destination: `${marker}-DEST-EDIT`, version: stale }) await expectConflict(() => updateLocalLooseboardOrder(loose.orderNo, customerId, { transportMode: loose.transportMode, palletRequired: loose.palletRequired, transportType: loose.transportType, ...common, destination: 'STALE', version: stale })) } finally { loose = await getLocalLooseboardOrder(loose.orderNo, customerId) if (loose.status === 'PENDING_ACCEPTANCE') await cleanupTransport(`/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(loose.orderNo)}`, customerId, loose.version) } expectPage(await listLocalLooseboardOrders({ page: 1, size: 20, customerId })) } async function overseasWarehouseCoverage() { const lastMile = await listLastMileOrders({ page: 1, size: 20 }); expectPage(lastMile) if (lastMile.records[0]) await getLastMileOrder(String(lastMile.records[0].id)) const inbound = await listInboundOrders({ page: 1, size: 20 }); expectPage(inbound) if (inbound.records[0]) await getInboundPlan(inbound.records[0].planNo, inbound.records[0].customerId) expectPage(await listDispatches({ page: 1, size: 20 })) expect(Array.isArray(await listDispatchCandidates({}))).toBe(true) expect(Array.isArray(await listDispatchDrafts())).toBe(true) const inventoryQuery = { page: 1, size: 20 } const balances = await listInventoryBalances(inventoryQuery); expectPage(balances) await getInventorySummary(inventoryQuery) await listInventoryLedger({ page: 1, size: 20 }) if (balances.records[0]) await listInventoryPositions(String(balances.records[0].id), 1, 20) expectPage(await listRelabelOrders({ page: 1, size: 20 })) const warehouses = (await listCustomerWarehouses()).records.filter(item => item.status === 'ACTIVE') if (!warehouses.length) { gap('公共托盘', '实际海外仓为空,跳过前端托盘写生命周期'); return } const warehouse = warehouses[0] const payload = { palletCode: `QA-${marker.slice(-20)}`, palletType: 'STANDARD', fbaWarehouseCode: 'ONT8', capacityCbm: 2.5, unitPrice: 10, status: 'IDLE', remark: marker } const palletId = await createPallet(warehouse.id, payload) createdResources.pallet = palletId try { const pallet = await getPallet(warehouse.id, palletId) await updatePallet(warehouse.id, palletId, { ...payload, capacityCbm: 2.75, remark: `${marker} edited`, version: pallet.version }) await expectConflict(() => updatePallet(warehouse.id, palletId, { ...payload, capacityCbm: 3, remark: 'STALE', version: pallet.version })) expectPage(await listPallets(warehouse.id, { page: 1, size: 20, keyword: payload.palletCode })) } finally { const current = await getPallet(warehouse.id, palletId) if (current.status !== 'SCRAPPED') await updatePallet(warehouse.id, palletId, { ...payload, status: 'SCRAPPED', remark: `${marker} cleanup`, version: current.version }) } gap('FBA预约/智能预排/换标新增', '没有本轮 marker 入仓计划和托盘候选;为避免操作历史数据,本轮只自动覆盖列表、详情、候选和草稿查询') } async function carrierCoverage() { const page = await listCarrierScheduleQueries({ page: 1, size: 20 }) expectPage(page) if (page.records[0]) await getCarrierScheduleQuery(page.records[0].query.id) else gap('船节点人工补录', '环境无真实查询任务;未创建或修改历史任务') } async function waitForTrace(method: string, path: string, start: number) { const deadline = Date.now() + 20_000 while (Date.now() < deadline) { if (traces.slice(start).some(item => item.method === method && item.path.startsWith(path))) return await new Promise(resolve => setTimeout(resolve, 50)) } throw new Error(`页面未发出 ${method} ${path}`) } async function mountPage(component: Parameters[0], method: string, path: string, props?: Record) { const start = traces.length const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: defineComponent({ template: '
' }) }] }) await router.push('/'); await router.isReady() const wrapper = mount(component, { attachTo: document.body, props, global: { plugins: [ElementPlus, router, setActivePinia(createPinia())] } }) wrappers.push(wrapper) await waitForTrace(method, path, start) await nextTick() expect(wrapper.text()).not.toContain('服务暂时不可用') } async function pageRequestCoverage() { await mountPage(OverseasQuotesView, 'GET', '/platform/v1/last-leg/master-data/overseas-quotes') await mountPage(PurchaseOrdersView, 'GET', '/platform/v1/last-leg/overseas-procurements') await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/us-truck-orders', { domain: 'truck' }) await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/local-looseboard-orders', { domain: 'looseboard' }) await mountPage(LastMileOrdersView, 'GET', '/platform/v1/last-leg/orders') await mountPage(InboundOrdersView, 'GET', '/platform/v1/last-leg/inbound-plans') await mountPage(InventoryView, 'GET', '/platform/v1/last-leg/inventory/balances') await mountPage(DispatchesView, 'GET', '/platform/v1/last-leg/dispatches') await mountPage(RelabelOrdersView, 'GET', '/platform/v1/last-leg/relabel-applications') } describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', () => { beforeAll(async () => { Object.defineProperty(window, 'matchMedia', { writable: true, value: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) }) globalThis.ResizeObserver = class { observe() {}; unobserve() {}; disconnect() {} } installTracingAdapter(http, 'domestic'); installTracingAdapter(overseasHttp, 'overseas') const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(pinia) await auth.login(process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin', process.env.ADMIN_ACCEPTANCE_PASSWORD || '') expect(localStorage.getItem('cc-admin-token')).toBeTruthy() clearOverseasAccessToken() await listVehicles({ page: 1, size: 1 }) expect(traces.some(item => item.region === 'domestic' && item.path === '/auth/refresh')).toBe(true) expect(traces.some(item => item.region === 'overseas' && item.authorization)).toBe(true) }) it('runs marker-owned adapter lifecycles and real page requests', async () => { await masterDataLifecycle() await overseasBusinessLifecycle() await overseasWarehouseCoverage() await carrierCoverage() await pageRequestCoverage() expect(traces.length).toBeGreaterThan(20) }) afterAll(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) const reportDir = resolve(process.cwd(), 'reports') mkdirSync(reportDir, { recursive: true }) const report = { generatedAt: new Date().toISOString(), marker, summary: { requests: traces.length, coverageGaps: coverageGaps.length }, traces, coverageGaps, createdResources } const file = resolve(reportDir, `phase4-frontend-development-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`) writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, 'utf8') console.info(`PHASE4_FRONTEND_REPORT=${file}`) }) })