feat(phase4): automate admin integration acceptance

This commit is contained in:
2026-08-24 15:41:36 +08:00
parent 91db777cb8
commit 964480a617
34 changed files with 1231 additions and 202 deletions
@@ -1,13 +1,16 @@
# 海外通用资料真实接口与剩余缺口
# 海外通用资料真实接口边界
三域统一经 `overseasHttp` 调用 `/api/platform/v1/last-leg/master-data`,长 ID 始终按字符串处理,分页使用 `records/page/size/total/pages`,更新与状态操作提交 `recordVersion`。
三域统一经 `overseasHttp` 使用浏览器业务路径 `/platform/v1/last-leg/master-data`,由海外 Gateway 对应到服务端 `/api/platform/v1/last-leg/master-data`。长 ID 始终按字符串处理,分页使用 `records/page/size/total/pages`,更新与状态操作提交 `recordVersion`。
- 报价:已接分页、详情、创建、编辑、启停、CSV 导出和 `/overseas-quotes/warehouse-options`。浏览器不调用国内 `/internal/warehouses/overseas-partners`。
- 汇率:已接摘要、分页、详情、创建、覆盖编辑、状态和 CSV 导出。
- 车辆:已接分页、独立详情、创建、编辑、状态和 CSV 导出。
剩余后端契约缺口:
当前明确边界:
- 报价和车辆 Controller/OpenAPI 没有删除端点,前端不展示删除操作。
- 车辆详情明确不包含派送履约与车辆更换历史;页面保留原型分区并标记当前接口边界,不拼装其他订单数据。
- 权限资源由总控统一写入 IAM/SQL;本模块只使用 canonical `platform:overseas-master-data:{quote|exchange-rate|vehicle}:{list|create|edit|status|export}`。
- 报价创建请求包含 `warehouseId`、`versionCode`,编辑请求不提交这两个服务端不可编辑字段;所有编辑与状态请求提交当前 `recordVersion`。
- 金额、汇率、里程和版本字段在 API 适配层统一转成 `number`,报价与车辆长 ID 保持字符串透传。
- CSV 下载只接受真实 Blob;JSON 错误 Blob 转为带 `requestId/traceId` 的业务错误,不保存为文件。
@@ -6,15 +6,15 @@
|---|---|---|---|---|---|---|
| 合作仓 `gen-partner-warehouse` | 列表/筛选 | `10-general.html:50-56` | 现有仓库管理页内分类 | 自有仓筛选、代码/地址、国家;操作列在表格末尾(与仓库稳定页的全局操作列第一约定冲突时服从全局约定) | 现有仓库页新增 `OVERSEAS_PARTNER` 类型筛选,不新增重复页面 | 使用国内公共仓库接口与 `platform:warehouse:list`;普通 `OVERSEAS` 语义不变 |
| 合作仓 | 新增/编辑 | `overlays.html:1731-1756`、`app.js:1655-1701` | 居中弹框 `modal-fba-add` | 最大约 560px;单列标签表单 | 复用现有仓库居中弹框,开放 `OVERSEAS_PARTNER` 新增和编辑 | 分别复用 `platform:warehouse:create`、`platform:warehouse:edit`,不跨区域查询报价候选 |
| 海外仓报价 `gen-overseas-quote` | 列表/筛选 | `10-general.html:152-169`、`app.js:14007-14039` | 独立列表页 | 五项筛选;操作列固定在第一列;横向密集表格 | 已接海外分页与服务端合作仓候选 | 使用 quote:list,不跨区拼装候选 |
| 海外仓报价 | 详情 | `10-general.html:201-210`、`app.js:14043-14081` | 居中弹框 `modal-gen-overseas-quote-detail` | 约 980px;摘要、仓储作业费用、中转派送价格 | 已接真实详情弹框 | 使用 quote:list |
| 海外仓报价 | 新增/编辑 | `10-general.html:171-199`、`app.js:14135-14202` | 居中弹框 `modal-gen-overseas-quote` | 最大约 1180px;基础信息、费用明细、派送价格;正文滚动 | 已接真实创建/编辑与 recordVersion | quote:create/edit 独立校验 |
| 海外仓报价 | 启停/删除/导出 | `10-general.html:154-169`、`app.js:14203-14218` | 列表行操作/工具栏 | 操作列第一列 | 无记录;写入口禁用 | 等独立动作权限与接口 |
| 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3373-3397` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 已接摘要与真实分页 | 统一展示 `1 USD = X CNY` |
| 汇率管理 | 更新/编辑 | `overlays.html:1648-1658`、`app.js:3399-3434` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 已接创建/覆盖、状态与导出 | recordVersion 冲突保留服务端错误 |
| 车辆 `vehicle` | 列表/筛选 | `10-general.html:299-351`、`app.js:9719-9806`;当前对话于 2026-08-19 修正双页签问题,并指定复用统一订单列表 `UnifiedOrdersView.vue` 的 `status-tabs` 样式 | 独立列表页 | 原型同时使用国内/海外区域页签与自有/外派归属页签,形成重复主切换;原型操作列在末尾 | 正式模块将区域降为查询下拉框,仅保留自有/外派归属页签;归属页签直接沿用订单列表的查询面板、底部指示条和键盘切换结构;操作列按全局约定移到第一列 | 当前对话确认高于原型;保留会改变表格列结构的归属页签,区域作为普通筛选;等待查询接口 |
| 车辆 | 详情 | `10-general.html:421-448`、`app.js:9871-9947` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 已接真实车辆详情;履约与更换区显示后端明确排除说明 | 不拼装订单/派送数据 |
| 车辆 | 新增/编辑 | `10-general.html:353-383`、`app.js:9808-9869` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 已接创建/编辑、独立状态与导出 | create/edit/status 权限独立 |
| 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:9759-9806` | 列表行操作/工具栏 | 原型列表操作 | 无记录;写入口禁用 | 等独立动作权限与接口 |
| 海外仓报价 `gen-overseas-quote` | 列表/筛选 | `10-general.html:152-169`、`app.js:14860-14894` | 独立列表页 | 五项筛选;操作列固定在第一列;横向密集表格 | 已接海外分页与服务端合作仓候选 | 使用 quote:list,不跨区拼装候选 |
| 海外仓报价 | 详情 | `10-general.html:208-216`、`app.js:14896-14933` | 居中弹框 `modal-gen-overseas-quote-detail` | 约 980px;摘要、仓储作业费用、中转派送价格 | 已接真实详情弹框 | 使用 quote:list |
| 海外仓报价 | 新增/编辑 | `10-general.html:177-204`、`app.js:14935-15055` | 居中弹框 `modal-gen-overseas-quote` | 最大约 1180px;基础信息、费用明细、派送价格;正文滚动 | 已接真实创建/编辑与 recordVersion | quote:create/edit 独立校验 |
| 海外仓报价 | 启停/删除/导出 | `10-general.html:154-169`、`app.js:15056-15070` | 启停、删除为列表行操作;导出在工具栏 | 操作列第一列 | 已接真实启停与 CSV 导出;服务端无删除端点,未展示删除 | quote:status/export 独立校验;不推断 DELETE |
| 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3673-3699` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 已接摘要与真实分页 | 统一展示 `1 USD = X CNY` |
| 汇率管理 | 更新/编辑 | `overlays.html:1695-1704`、`app.js:3701-3728` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 已接创建/覆盖、状态与导出 | recordVersion 冲突保留服务端错误 |
| 车辆 `vehicle` | 列表/筛选 | `10-general.html:299-351`、`app.js:10437-10456`;当前对话于 2026-08-19 修正双页签问题,并指定复用统一订单列表 `UnifiedOrdersView.vue` 的 `status-tabs` 样式 | 独立列表页 | 原型同时使用国内/海外区域页签与自有/外派归属页签,形成重复主切换;原型操作列在末尾 | 已接真实分页;区域降为查询下拉框,仅保留自有/外派归属页签;操作列按全局约定移到第一列 | 当前对话确认高于原型;查询提交 ownership/region/status,不拼装数据 |
| 车辆 | 详情 | `10-general.html:421-448`、`app.js:10546-10591` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 已接真实车辆详情;履约与更换区显示后端明确排除说明 | 不拼装订单/派送数据 |
| 车辆 | 新增/编辑 | `10-general.html:363-394`、`app.js:10458-10529` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 已接创建/编辑、独立状态与导出 | create/edit/status 权限独立 |
| 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:10437-10456` | 删除为列表行操作;导出在工具栏 | 原型列表操作 | 已接真实 CSV 导出;服务端无删除端点,未展示删除 | vehicle:export 独立校验;不推断 DELETE |
原型未定义车辆文件预览、审核/确认流程;本模块不自行补充。
@@ -1,55 +1,143 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import { overseasHttp } from '@/shared/api/overseasHttp'
import {
changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle,
exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getExchangeRateSummary, getQuote, getVehicle,
listExchangeRates, listQuotes, listQuoteWarehouseOptions, listVehicles, updateExchangeRate, updateQuote, updateVehicle,
type QuoteCreate, type QuoteUpdate,
} from './overseasMasterData'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() } }))
const ok = (data: unknown) => ({ code: '200', message: 'success', data })
const longId = '9223372036854775807'
const quoteSummary = {
id: longId, name: '报价', warehouseId: '9223372036854775806', warehouseCode: 'US-LAX', warehouseName: '洛杉矶合作仓',
countryCode: 'US', providerName: '服务商', currency: 'USD', effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31',
versionCode: 'V1', status: 'INACTIVE', recordVersion: '7', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null,
}
const quoteDetail = {
summary: quoteSummary,
warehouseFees: [{ item: '入库', billingUnit: 'PALLET', unitPrice: '1.2500', minimumCharge: '2.5000', remark: null }],
transitPrices: [{ fbaWarehouseCode: 'ONT8', deliveryMethod: 'TRUCK', estimatedMiles: '12.50', price: '18.7500', minimumCharge: null, remark: null }],
createdBy: 'admin', createdAt: '2026-08-24T09:00:00',
}
const exchangeRate = { month: '2026-08', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: '7.12345678', status: 'ACTIVE', recordVersion: '3', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null }
const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: '12.50', mileageKm: '101', driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: '4', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }
beforeEach(() => vi.clearAllMocks())
describe('overseas master data API', () => {
it('uses the overseas quote contract including server-owned partner options', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ records: [], page: '1', size: '20', total: '0', pages: '0' }))
await listQuotes({ page: 1, size: 20, keyword: 'V1' })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes', { params: { page: 1, size: 20, keyword: 'V1' } })
vi.mocked(overseasHttp.get).mockResolvedValue(ok([])); await listQuoteWarehouseOptions()
expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options')
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ summary: { id: '9223372036854775807' }, warehouseFees: [], transitPrices: [] })); await getQuote('9223372036854775807')
expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/overseas-quotes/9223372036854775807')
const payload = { name: '报价', warehouseId: '9', providerName: '服务商', currency: 'USD' as const, effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1', warehouseFees: [], transitPrices: [], remark: null }
vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createQuote(payload)
vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateQuote('9', { ...payload, recordVersion: 2 })
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({})); await changeQuoteStatus('9', 'INACTIVE', 2)
expect(overseasHttp.patch).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes/9/status', { status: 'INACTIVE', recordVersion: 2 })
it('normalizes quote page/detail decimals and preserves long string IDs', async () => {
vi.mocked(overseasHttp.get)
.mockResolvedValueOnce(ok({ records: [quoteSummary], page: '1', size: '20', total: '1', pages: '1' }))
.mockResolvedValueOnce(ok(quoteDetail))
.mockResolvedValueOnce(ok([{ warehouseId: quoteSummary.warehouseId, warehouseCode: 'US-LAX', warehouseName: '洛杉矶合作仓', countryCode: 'US' }]))
const result = await listQuotes({ page: 1, size: 20, keyword: 'V1' })
expect(result).toMatchObject({ page: 1, size: 20, total: 1, pages: 1 })
expect(result.records[0]).toMatchObject({ id: longId, warehouseId: quoteSummary.warehouseId, recordVersion: 7 })
const detail = await getQuote(longId)
expect(detail.summary.id).toBe(longId)
expect(detail.summary.recordVersion).toBe(7)
expect(detail.warehouseFees[0]).toMatchObject({ unitPrice: 1.25, minimumCharge: 2.5 })
expect(detail.transitPrices[0]).toMatchObject({ estimatedMiles: 12.5, price: 18.75, minimumCharge: null })
await listQuoteWarehouseOptions()
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes', { params: { page: 1, size: 20, keyword: 'V1' } })
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, `/platform/v1/last-leg/master-data/overseas-quotes/${longId}`)
expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options')
})
it('uses the monthly exchange-rate contract', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ records: [], page: 1, size: 20, total: 0, pages: 0 })); await listExchangeRates({ page: 1, size: 20, monthFrom: '2026-08', monthTo: '2026-08' })
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ latestMonth: '2026-08' })); await getExchangeRateSummary()
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ month: '2026-08' })); await getExchangeRate('2026-08')
vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createExchangeRate({ month: '2026-08', usdToCnyAverage: 7.18, remark: null })
vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateExchangeRate('2026-08', { recordVersion: 1, usdToCnyAverage: 7.19, remark: null })
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({})); await changeExchangeRateStatus('2026-08', 'INACTIVE', 1, '停用')
expect(overseasHttp.patch).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/exchange-rates/2026-08/status', { status: 'INACTIVE', recordVersion: 1, remark: '停用' })
it('keeps quote create/update DTOs distinct and sends recordVersion on writes', async () => {
const create: QuoteCreate = {
name: '报价', warehouseId: quoteSummary.warehouseId, providerName: '服务商', currency: 'USD',
effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1',
warehouseFees: [{ item: '入库', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: null, remark: null }],
transitPrices: [], remark: null,
}
const update: QuoteUpdate = {
name: '报价-编辑', providerName: '服务商', currency: 'USD', effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31',
warehouseFees: create.warehouseFees, transitPrices: [], remark: null, recordVersion: 7,
}
vi.mocked(overseasHttp.post).mockResolvedValue(ok(quoteDetail))
vi.mocked(overseasHttp.put).mockResolvedValue(ok({ ...quoteDetail, summary: { ...quoteSummary, recordVersion: '8' } }))
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({ ...quoteDetail, summary: { ...quoteSummary, status: 'ACTIVE', recordVersion: '9' } }))
await createQuote(create)
const updated = await updateQuote(longId, update)
await changeQuoteStatus(longId, 'ACTIVE', 8)
expect(updated.summary.recordVersion).toBe(8)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes', create)
expect(overseasHttp.put).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/overseas-quotes/${longId}`, update)
expect(overseasHttp.patch).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/overseas-quotes/${longId}/status`, { status: 'ACTIVE', recordVersion: 8 })
expect(update).not.toHaveProperty('warehouseId')
expect(update).not.toHaveProperty('versionCode')
})
it('uses the vehicle contract and preserves string IDs', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ records: [], page: 1, size: 20, total: 0, pages: 0 })); await listVehicles({ page: 1, size: 20, ownership: 'OWNED' })
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ id: '9223372036854775807' })); await getVehicle('9223372036854775807')
expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/vehicles/9223372036854775807')
const payload = { plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12, mileageKm: 1, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null, status: 'IN_SERVICE' as const }
vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createVehicle(payload)
vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateVehicle('9', { ...payload, recordVersion: 1 })
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({})); await changeVehicleStatus('9', 'INACTIVE', 1)
expect(overseasHttp.patch).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/vehicles/9/status', { status: 'INACTIVE', recordVersion: 1 })
it('normalizes exchange-rate decimal/version and uses month as the stable key', async () => {
vi.mocked(overseasHttp.get)
.mockResolvedValueOnce(ok({ records: [exchangeRate], page: '1', size: '20', total: '1', pages: '1' }))
.mockResolvedValueOnce(ok(exchangeRate))
.mockResolvedValueOnce(ok({ latestMonth: '2026-08', latestUsdToCnyAverage: '7.12345678', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }))
const page = await listExchangeRates({ page: 1, size: 20, monthFrom: '2026-08', monthTo: '2026-08' })
const detail = await getExchangeRate('2026-08')
const summary = await getExchangeRateSummary()
expect(page.records[0]).toMatchObject({ usdToCnyAverage: 7.12345678, recordVersion: 3 })
expect(detail).toMatchObject({ month: '2026-08', usdToCnyAverage: 7.12345678, recordVersion: 3 })
expect(summary.latestUsdToCnyAverage).toBe(7.12345678)
vi.mocked(overseasHttp.post).mockResolvedValue(ok(exchangeRate))
vi.mocked(overseasHttp.put).mockResolvedValue(ok({ ...exchangeRate, recordVersion: '4' }))
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({ ...exchangeRate, status: 'INACTIVE', recordVersion: '5' }))
await createExchangeRate({ month: '2026-08', usdToCnyAverage: 7.12345678, remark: null })
await updateExchangeRate('2026-08', { recordVersion: 3, usdToCnyAverage: 7.2, remark: null })
await changeExchangeRateStatus('2026-08', 'INACTIVE', 4, '停用')
expect(overseasHttp.patch).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/exchange-rates/2026-08/status', { status: 'INACTIVE', recordVersion: 4, remark: '停用' })
})
it('downloads each CSV through overseasHttp', async () => {
const blob = new Blob(['csv']); vi.mocked(overseasHttp.get).mockResolvedValue(blob)
await exportQuotes({ status: 'ACTIVE' }); await exportExchangeRates({}); await exportVehicles({ ownership: 'OWNED' })
it('normalizes vehicle decimals/version while preserving long string IDs', async () => {
vi.mocked(overseasHttp.get)
.mockResolvedValueOnce(ok({ records: [vehicle], page: '1', size: '20', total: '1', pages: '1' }))
.mockResolvedValueOnce(ok(vehicle))
const result = await listVehicles({ page: 1, size: 20, ownership: 'OWNED' })
const detail = await getVehicle(longId)
expect(result.records[0]).toMatchObject({ id: longId, fuelConsumptionPer100Km: 12.5, mileageKm: 101, recordVersion: 4 })
expect(detail.id).toBe(longId)
const editable = { plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null }
vi.mocked(overseasHttp.post).mockResolvedValue(ok(vehicle))
vi.mocked(overseasHttp.put).mockResolvedValue(ok({ ...vehicle, recordVersion: '5' }))
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({ ...vehicle, status: 'INACTIVE', recordVersion: '6' }))
await createVehicle({ ...editable, status: 'IN_SERVICE' })
await updateVehicle(longId, { ...editable, recordVersion: 4 })
await changeVehicleStatus(longId, 'INACTIVE', 5)
expect(overseasHttp.put).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/vehicles/${longId}`, { ...editable, recordVersion: 4 })
expect(overseasHttp.patch).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/vehicles/${longId}/status`, { status: 'INACTIVE', recordVersion: 5 })
})
it('preserves A0409 metadata instead of translating it into success', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: 'A0409', message: '记录版本已变化', data: null, requestId: 'request-conflict', traceId: 'trace-conflict' })
const error = await updateExchangeRate('2026-08', { recordVersion: 1, usdToCnyAverage: 7.2, remark: null }).catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({ code: 'A0409', message: '记录版本已变化', requestId: 'request-conflict', traceId: 'trace-conflict' })
})
it('downloads real CSV Blobs and rejects JSON error Blobs with trace metadata', async () => {
const csv = new Blob(['id,status\n1,ACTIVE\n'], { type: 'text/csv;charset=UTF-8' })
vi.mocked(overseasHttp.get)
.mockResolvedValueOnce(csv)
.mockResolvedValueOnce(csv)
.mockResolvedValueOnce(csv)
.mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'A0409', message: '导出条件已变化', data: null, requestId: 'request-export' })], { type: 'application/json' }))
await expect(exportQuotes({ status: 'ACTIVE' })).resolves.toBe(csv)
await expect(exportExchangeRates({})).resolves.toBe(csv)
await expect(exportVehicles({ ownership: 'OWNED' })).resolves.toBe(csv)
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' })
@@ -1,42 +1,99 @@
import axios from 'axios'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
MasterDataStatus, MonthlyExchangeRate, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption,
MasterDataStatus, MonthlyExchangeRate, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption,
VehicleDetail, VehicleOwnership, VehicleRegion, VehicleStatus,
} from '../types'
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
type PagePayload<T> = Omit<PageResult<T>, 'page' | 'size' | 'total' | 'pages'> & { page: number | string; size: number | string; total: number | string; pages: number | string }
const base = '/platform/v1/last-leg/master-data'
function page<T>(value: PagePayload<T>): PageResult<T> { return { ...value, page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) } }
async function getPage<T>(path: string, params: object) { return page(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PagePayload<T>>, ApiEnvelope<PagePayload<T>>>(path, { params }))) }
async function download(path: string, params: object) { return overseasHttp.get<Blob, Blob>(path, { params, responseType: 'blob' }) }
type Numeric = number | string
type QuoteWire = Omit<OverseasWarehouseQuote, 'recordVersion'> & { recordVersion: Numeric }
type FeeWire = Omit<OverseasWarehouseFee, 'unitPrice' | 'minimumCharge'> & { unitPrice: Numeric; minimumCharge: Numeric | null }
type TransitPriceWire = Omit<OverseasTransitPrice, 'estimatedMiles' | 'price' | 'minimumCharge'> & { estimatedMiles: Numeric | null; price: Numeric; minimumCharge: Numeric | null }
type QuoteDetailWire = Omit<OverseasWarehouseQuoteDetail, 'summary' | 'warehouseFees' | 'transitPrices'> & { summary: QuoteWire; warehouseFees: FeeWire[]; transitPrices: TransitPriceWire[] }
type ExchangeRateWire = Omit<MonthlyExchangeRate, 'usdToCnyAverage' | 'recordVersion'> & { usdToCnyAverage: Numeric; recordVersion: Numeric }
type ExchangeRateSummary = { latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }
type ExchangeRateSummaryWire = Omit<ExchangeRateSummary, 'latestUsdToCnyAverage'> & { latestUsdToCnyAverage: Numeric | null }
type VehicleWire = Omit<VehicleDetail, 'fuelConsumptionPer100Km' | 'mileageKm' | 'recordVersion'> & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric }
function page<T, W>(value: PagePayload<W>, normalize: (record: W) => T): PageResult<T> {
return { ...value, records: value.records.map(normalize), page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) }
}
async function getPage<T, W>(path: string, params: object, normalize: (record: W) => T) {
return page(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PagePayload<W>>, ApiEnvelope<PagePayload<W>>>(path, { params })), normalize)
}
const numeric = (value: Numeric) => Number(value)
const nullableNumeric = (value: Numeric | null) => value == null ? null : Number(value)
const normalizeQuote = (value: QuoteWire): OverseasWarehouseQuote => ({ ...value, recordVersion: numeric(value.recordVersion) })
const normalizeFee = (value: FeeWire): OverseasWarehouseFee => ({ ...value, unitPrice: numeric(value.unitPrice), minimumCharge: nullableNumeric(value.minimumCharge) })
const normalizeTransitPrice = (value: TransitPriceWire): OverseasTransitPrice => ({ ...value, estimatedMiles: nullableNumeric(value.estimatedMiles), price: numeric(value.price), minimumCharge: nullableNumeric(value.minimumCharge) })
const normalizeQuoteDetail = (value: QuoteDetailWire): OverseasWarehouseQuoteDetail => ({ ...value, summary: normalizeQuote(value.summary), warehouseFees: value.warehouseFees.map(normalizeFee), transitPrices: value.transitPrices.map(normalizeTransitPrice) })
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
try {
const payload = JSON.parse(await blob.text()) as Partial<ApiEnvelope<unknown>>
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)
} catch (error) {
if (error instanceof ApiResponseError) return error
return new ApiResponseError('CSV 导出接口返回了无法识别的 JSON 响应', 'INVALID_DOWNLOAD_RESPONSE', status)
}
}
async function download(path: string, params: object) {
try {
const blob = await overseasHttp.get<Blob, Blob>(path, { params, responseType: 'blob' })
const error = await csvError(blob, 200)
if (error) throw error
return blob
} catch (error) {
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
const responseError = await csvError(error.response.data, error.response.status)
if (responseError) throw responseError
}
throw error
}
}
export interface QuoteQuery { page: number; size: number; keyword?: string; warehouseId?: string; providerName?: string; currency?: string; status?: MasterDataStatus }
export type QuoteSave = Pick<OverseasWarehouseQuote, 'name' | 'providerName' | 'currency' | 'effectiveStart' | 'effectiveEnd' | 'warehouseFees' | 'transitPrices' | 'remark'>
& { warehouseId?: string; versionCode?: string; recordVersion?: number }
export const listQuotes = (query: QuoteQuery) => getPage<OverseasWarehouseQuote>(`${base}/overseas-quotes`, query)
export async function getQuote(id: string) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`)) }
type QuoteDetailFields = { warehouseFees: OverseasWarehouseFee[]; transitPrices: OverseasTransitPrice[] }
type QuoteEditableFields = Pick<OverseasWarehouseQuote, 'name' | 'providerName' | 'currency' | 'effectiveStart' | 'remark'>
& { effectiveEnd: string } & QuoteDetailFields
export type QuoteCreate = QuoteEditableFields & Pick<OverseasWarehouseQuote, 'warehouseId' | 'versionCode'>
export type QuoteUpdate = QuoteEditableFields & Pick<OverseasWarehouseQuote, 'recordVersion'>
export const listQuotes = (query: QuoteQuery) => getPage<OverseasWarehouseQuote, QuoteWire>(`${base}/overseas-quotes`, query, normalizeQuote)
export async function getQuote(id: string) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`))) }
export async function listQuoteWarehouseOptions() { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PartnerWarehouseOption[]>, ApiEnvelope<PartnerWarehouseOption[]>>(`${base}/overseas-quotes/warehouse-options`)) }
export async function createQuote(payload: QuoteSave) { return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes`, payload)) }
export async function updateQuote(id: string, payload: QuoteSave) { return unwrapApiResponse(await overseasHttp.put<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload)) }
export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion })) }
export async function createQuote(payload: QuoteCreate) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.post<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes`, payload))) }
export async function updateQuote(id: string, payload: QuoteUpdate) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.put<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload))) }
export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion }))) }
export const exportQuotes = (query: Omit<QuoteQuery, 'page' | 'size'>) => download(`${base}/overseas-quotes/export`, query)
export interface ExchangeRateQuery { page: number; size: number; monthFrom?: string; monthTo?: string; status?: MasterDataStatus }
export const listExchangeRates = (query: ExchangeRateQuery) => getPage<MonthlyExchangeRate>(`${base}/exchange-rates`, query)
export async function getExchangeRate(month: string) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates/${encodeURIComponent(month)}`)) }
export async function getExchangeRateSummary() { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<{ latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }>, ApiEnvelope<{ latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }>>(`${base}/exchange-rates/summary`)) }
export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates`, payload)) }
export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return unwrapApiResponse(await overseasHttp.put<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload)) }
export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark })) }
export const listExchangeRates = (query: ExchangeRateQuery) => getPage<MonthlyExchangeRate, ExchangeRateWire>(`${base}/exchange-rates`, query, normalizeExchangeRate)
export async function getExchangeRate(month: string) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}`))) }
export async function getExchangeRateSummary(): Promise<ExchangeRateSummary> {
const value = unwrapApiResponse(await overseasHttp.get<ApiEnvelope<ExchangeRateSummaryWire>, ApiEnvelope<ExchangeRateSummaryWire>>(`${base}/exchange-rates/summary`))
return { ...value, latestUsdToCnyAverage: nullableNumeric(value.latestUsdToCnyAverage) }
}
export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.post<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates`, payload))) }
export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.put<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload))) }
export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark }))) }
export const exportExchangeRates = (query: Omit<ExchangeRateQuery, 'page' | 'size'>) => download(`${base}/exchange-rates/export`, query)
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus }
export type VehicleSave = Omit<VehicleDetail, 'id' | 'recordVersion' | 'updatedBy' | 'updatedAt' | 'status'> & { status?: VehicleStatus; recordVersion?: number }
export const listVehicles = (query: VehicleQuery) => getPage<VehicleDetail>(`${base}/vehicles`, query)
export async function getVehicle(id: string) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles/${encodeURIComponent(id)}`)) }
export async function createVehicle(payload: VehicleSave) { return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles`, payload)) }
export async function updateVehicle(id: string, payload: VehicleSave) { return unwrapApiResponse(await overseasHttp.put<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles/${encodeURIComponent(id)}`, payload)) }
export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion })) }
type VehicleEditableFields = Omit<VehicleDetail, 'id' | 'recordVersion' | 'updatedBy' | 'updatedAt' | 'status'>
export type VehicleCreate = VehicleEditableFields & Pick<VehicleDetail, 'status'>
export type VehicleUpdate = VehicleEditableFields & Pick<VehicleDetail, 'recordVersion'>
export const listVehicles = (query: VehicleQuery) => getPage<VehicleDetail, VehicleWire>(`${base}/vehicles`, query, normalizeVehicle)
export async function getVehicle(id: string) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}`))) }
export async function createVehicle(payload: VehicleCreate) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.post<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles`, payload))) }
export async function updateVehicle(id: string, payload: VehicleUpdate) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.put<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}`, payload))) }
export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion }))) }
export const exportVehicles = (query: Omit<VehicleQuery, 'page' | 'size'>) => download(`${base}/vehicles/export`, query)
@@ -0,0 +1,29 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { saveCsv } from './downloadCsv'
describe('saveCsv', () => {
beforeEach(() => vi.stubGlobal('URL', { createObjectURL: vi.fn(), revokeObjectURL: vi.fn() }))
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
it('downloads the provided Blob with the requested file name and always revokes the URL', () => {
const blob = new Blob(['id,status\n1,ACTIVE\n'], { type: 'text/csv;charset=UTF-8' })
const createObjectURL = vi.mocked(URL.createObjectURL).mockReturnValue('blob:overseas-master-data')
const revokeObjectURL = vi.mocked(URL.revokeObjectURL)
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
saveCsv(blob, 'vehicles.csv')
expect(createObjectURL).toHaveBeenCalledWith(blob)
expect(click).toHaveBeenCalledOnce()
expect(revokeObjectURL).toHaveBeenCalledWith('blob:overseas-master-data')
})
it('revokes the Blob URL even when the browser click fails', () => {
vi.mocked(URL.createObjectURL).mockReturnValue('blob:failed-download')
const revokeObjectURL = vi.mocked(URL.revokeObjectURL)
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => { throw new Error('browser blocked') })
expect(() => saveCsv(new Blob(['csv']), 'quotes.csv')).toThrow('browser blocked')
expect(revokeObjectURL).toHaveBeenCalledWith('blob:failed-download')
})
})
@@ -55,6 +55,19 @@ describe('overseas master data real contract pages', () => {
expect(view).toContain('access.can(permission.')
expect(view).toContain('ElMessage.success')
}
expect(source('OverseasQuotesView')).toContain('导出CSV')
expect(source('VehiclesView')).toContain('导出CSV')
expect(source('ExchangeRatesView')).toContain("'monthly-exchange-rates.csv'")
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
expect(source(name)).not.toMatch(/deleteQuote|deleteExchangeRate|deleteVehicle/)
}
expect(source('OverseasQuotesView')).toContain('changeQuoteStatus(row.id,next,row.recordVersion)')
expect(source('ExchangeRatesView')).toContain('changeExchangeRateStatus(row.month, next, row.recordVersion, null)')
expect(source('VehiclesView')).toContain('changeVehicleStatus(row.id,statusTarget.value,row.recordVersion)')
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
expect(source(name)).toContain("masterDataErrorMessage(")
expect(source(name)).toContain('saveCsv(await export')
}
})
it('uses an independent API-backed vehicle detail page without inventing fulfillment history', () => {
@@ -81,5 +94,13 @@ describe('overseas master data real contract pages', () => {
expect(gaps).toContain('/overseas-quotes/warehouse-options')
expect(gaps).toContain('浏览器不调用国内')
expect(gaps).toContain('没有删除端点')
expect(gaps).toContain('JSON 错误 Blob')
expect(gaps).not.toContain('尚无已确认')
expect(matrixSource()).not.toContain('写入口禁用')
expect(matrixSource()).not.toContain('等待查询接口')
})
})
function matrixSource() {
return readFileSync(resolve(process.cwd(), 'src/modules/overseas-master-data/PROTOTYPE_MATRIX.md'), 'utf8')
}
@@ -0,0 +1,111 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import { ElMessage } from 'element-plus'
import * as api from '../api/overseasMasterData'
import ExchangeRatesView from './ExchangeRatesView.vue'
import OverseasQuotesView from './OverseasQuotesView.vue'
import VehiclesView from './VehiclesView.vue'
vi.mock('element-plus', () => ({
ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) },
}))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/overseasMasterData', () => ({
listQuotes: vi.fn(), listQuoteWarehouseOptions: vi.fn(), getQuote: vi.fn(), createQuote: vi.fn(), updateQuote: vi.fn(), changeQuoteStatus: vi.fn(), exportQuotes: vi.fn(),
listExchangeRates: vi.fn(), getExchangeRateSummary: vi.fn(), getExchangeRate: vi.fn(), createExchangeRate: vi.fn(), updateExchangeRate: vi.fn(), changeExchangeRateStatus: vi.fn(), exportExchangeRates: vi.fn(),
listVehicles: vi.fn(), getVehicle: vi.fn(), createVehicle: vi.fn(), updateVehicle: vi.fn(), changeVehicleStatus: vi.fn(), exportVehicles: vi.fn(),
}))
const longId = '9223372036854775807'
const quote = { id: longId, name: '真实报价', warehouseId: '9223372036854775806', warehouseName: '合作仓', warehouseCode: 'US-LAX', countryCode: 'US', providerName: 'Provider', currency: 'USD' as const, effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1', status: 'INACTIVE' as const, updatedAt: '2026-08-24T10:00:00', updatedBy: 'admin', recordVersion: 7, remark: null }
const quoteDetail = { summary: quote, warehouseFees: [{ item: '入库', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: null, remark: null }], transitPrices: [], createdBy: 'admin', createdAt: '2026-08-24T09:00:00' }
const rate = { month: '2026-08', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: 7.12345678, status: 'ACTIVE' as const, recordVersion: 3, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null }
const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, status: 'IN_SERVICE' as const, remark: null, recordVersion: 4, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }
const emptyPage = { records: [], page: 1, size: 20, total: 0, pages: 0 }
const slot = { template: '<div><slot/><slot name="header"/><slot name="toolbar"/><slot name="metrics"/><slot name="state"/></div>' }
const stubs = {
AdminPage: slot,
ListPageLayout: slot,
QueryToolbar: true,
LoadErrorState: true,
DataTable: {
props: ['data'],
template: '<div><slot name="toolbar-actions"/><slot v-if="data && data[0]" name="actions" :row="data[0]"/></div>',
},
AppDialog: {
props: ['modelValue'], emits: ['confirm', 'update:modelValue'],
template: '<div v-if="modelValue" data-test="dialog"><slot/><button data-test="dialog-confirm" @click="$emit(\'confirm\')">confirm</button></div>',
},
ElButton: { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot/></button>' },
ElForm: slot, ElFormItem: slot, ElInput: true, ElSelect: slot, ElOption: true, ElDatePicker: true, ElInputNumber: true,
ElTable: slot, ElTableColumn: true, ElTag: slot,
}
const mountView = (component: object) => shallowMount(component, { global: { stubs } })
const button = (wrapper: ReturnType<typeof mountView>, label: string) => {
const found = wrapper.findAll('button').find((item) => item.text().includes(label))
expect(found, `button ${label}`).toBeDefined()
return found!
}
describe('overseas master data failed writes preserve editor context', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(api.listQuotes).mockResolvedValue({ ...emptyPage, records: [quote], total: 1, pages: 1 })
vi.mocked(api.listQuoteWarehouseOptions).mockResolvedValue([{ warehouseId: quote.warehouseId, warehouseCode: 'US-LAX', warehouseName: '合作仓', countryCode: 'US' }])
vi.mocked(api.getQuote).mockResolvedValue(quoteDetail)
vi.mocked(api.listExchangeRates).mockResolvedValue({ ...emptyPage, records: [rate], total: 1, pages: 1 })
vi.mocked(api.getExchangeRateSummary).mockResolvedValue({ latestMonth: rate.month, latestUsdToCnyAverage: rate.usdToCnyAverage, updatedBy: 'admin', updatedAt: rate.updatedAt })
vi.mocked(api.getExchangeRate).mockResolvedValue(rate)
vi.mocked(api.listVehicles).mockResolvedValue({ ...emptyPage, records: [vehicle], total: 1, pages: 1 })
vi.mocked(api.getVehicle).mockResolvedValue(vehicle)
})
it('keeps quote edit open, sends only update fields, and shows A0409 request ID', async () => {
vi.mocked(api.updateQuote).mockRejectedValue(new ApiResponseError('记录版本已变化', 'A0409', 200, null, 'req-quote-409'))
const wrapper = mountView(OverseasQuotesView)
await flushPromises()
await button(wrapper, '编辑').trigger('click')
await flushPromises()
await wrapper.get('[data-test="dialog-confirm"]').trigger('click')
await flushPromises()
expect(api.updateQuote).toHaveBeenCalledWith(longId, expect.objectContaining({ recordVersion: 7, name: quote.name }))
const payload = vi.mocked(api.updateQuote).mock.calls[0]?.[1]
expect(payload).not.toHaveProperty('warehouseId')
expect(payload).not.toHaveProperty('versionCode')
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true)
expect(ElMessage.error).toHaveBeenCalledWith(expect.stringContaining('req-quote-409'))
})
it('keeps exchange-rate decimal/version in the open editor after A0409', async () => {
vi.mocked(api.updateExchangeRate).mockRejectedValue(new ApiResponseError('汇率版本已变化', 'A0409', 200, null, 'req-rate-409'))
const wrapper = mountView(ExchangeRatesView)
await flushPromises()
await button(wrapper, '编辑').trigger('click')
await flushPromises()
await wrapper.get('[data-test="dialog-confirm"]').trigger('click')
await flushPromises()
expect(api.updateExchangeRate).toHaveBeenCalledWith('2026-08', { recordVersion: 3, usdToCnyAverage: 7.12345678, remark: null })
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true)
expect(ElMessage.error).toHaveBeenCalledWith(expect.stringContaining('req-rate-409'))
})
it('keeps vehicle long ID, decimal and recordVersion in the open editor after A0409', async () => {
vi.mocked(api.updateVehicle).mockRejectedValue(new ApiResponseError('车辆版本已变化', 'A0409', 200, null, 'req-vehicle-409'))
const wrapper = mountView(VehiclesView)
await flushPromises()
await button(wrapper, '编辑').trigger('click')
await flushPromises()
await wrapper.get('[data-test="dialog-confirm"]').trigger('click')
await flushPromises()
expect(api.updateVehicle).toHaveBeenCalledWith(longId, expect.objectContaining({ recordVersion: 4, fuelConsumptionPer100Km: 12.5, mileageKm: 101 }))
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true)
expect(ElMessage.error).toHaveBeenCalledWith(expect.stringContaining('req-vehicle-409'))
})
})
@@ -56,7 +56,7 @@ watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions(
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-query" :loading="loading" @search="search" /></template>
<template #state><LoadErrorState v-if="error" title="海外仓报价加载失败" :message="error.message" @retry="load" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-quotes-table" row-key="id" empty-text="暂无海外仓报价">
<template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出Excel</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template>
<template #actions="{row}"><el-button link type="primary" @click="openDetail(row as OverseasWarehouseQuote)"><Eye :size="15"/>详情</el-button><el-button v-if="access.can(permission.quoteEdit)" link type="primary" @click="openEdit(row as OverseasWarehouseQuote)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.quoteStatus)" link :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'danger':'success'" @click="toggle(row as OverseasWarehouseQuote)"><Power :size="15"/>{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'停用':'启用'}}</el-button></template>
<template #status="{row}"><el-tag :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'success':'info'">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'启用':'停用'}}</el-tag></template>
</DataTable></section>
@@ -22,6 +22,6 @@ function openStatus(row:VehicleDetail){statusRow.value=row;statusTarget.value=ro
async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();saveCsv(await exportVehicles(q),'vehicles.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}}
watch([page,pageSize],load);onMounted(load)
</script>
<template><AdminPage><ListPageLayout aria-label="车辆列表"><template #toolbar><div class="query-panel"><div class="status-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])">{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template><template #state><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template><section class="surface-panel vehicle-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆"><template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出Excel</el-button></template><template #actions="{row}"><el-button link type="primary" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})"><Eye :size="15"/>查看</el-button><el-button v-if="access.can(permission.vehicleEdit)" link type="primary" @click="openEdit(row as VehicleDetail)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.vehicleStatus)" link type="primary" @click="openStatus(row as VehicleDetail)"><Power :size="15"/>状态</el-button></template><template #status="{row}"><el-tag>{{(row as VehicleDetail).status}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
<template><AdminPage><ListPageLayout aria-label="车辆列表"><template #toolbar><div class="query-panel"><div class="status-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])">{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template><template #state><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template><section class="surface-panel vehicle-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆"><template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template><template #actions="{row}"><el-button link type="primary" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})"><Eye :size="15"/>查看</el-button><el-button v-if="access.can(permission.vehicleEdit)" link type="primary" @click="openEdit(row as VehicleDetail)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.vehicleStatus)" link type="primary" @click="openStatus(row as VehicleDetail)"><Power :size="15"/>状态</el-button></template><template #status="{row}"><el-tag>{{(row as VehicleDetail).status}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
<AppDialog v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(760px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form label-position="top"><div class="form-grid"><el-form-item label="车辆归属"><el-select v-model="form.ownership"><el-option label="自有车辆" value="OWNED"/><el-option label="外派车辆" value="DISPATCHED"/></el-select></el-form-item><el-form-item label="车牌号"><el-input v-model="form.plateNumber"/></el-form-item><el-form-item label="车型"><el-input v-model="form.model"/></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2"/></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0"/></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone"/></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany"/></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="s" :value="s"/></el-select></el-form-item><el-form-item label="备注"><el-input v-model="form.remark"/></el-form-item></div></el-form></AppDialog><AppDialog v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="s" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#fff}.status-tabs{display:flex;border-bottom:1px solid var(--cc-border);background:#f9fafb}.status-tab{min-height:44px;padding:0 18px;border:0;background:transparent;color:var(--cc-muted)}.status-tab.is-active{border-bottom:3px solid var(--cc-primary);color:var(--cc-primary);font-weight:600}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px}</style>
+1 -9
View File
@@ -1,10 +1,4 @@
/**
* 四期原型展示模型。
*
* 报价、汇率和车辆主数据尚无已确认的海外服务端 DTO;这些类型不能用于猜测
* API 路径或直接序列化请求。真实接口落地后应按 Controller/OpenAPI 单独建立
* API DTO,并在适配层转换为以下页面模型。
*/
/** 四期海外主数据页面模型,由模块 API 适配层从 Lastleg 稳定 DTO 转换。 */
export type MasterDataStatus = 'ACTIVE' | 'INACTIVE'
export type QuoteCurrency = 'USD' | 'CNY'
export type VehicleRegion = 'DOMESTIC' | 'OVERSEAS'
@@ -45,8 +39,6 @@ export interface OverseasWarehouseQuote {
updatedBy: string
recordVersion: number
remark: string | null
warehouseFees: OverseasWarehouseFee[]
transitPrices: OverseasTransitPrice[]
}
export interface OverseasWarehouseQuoteDetail { summary: OverseasWarehouseQuote; warehouseFees: OverseasWarehouseFee[]; transitPrices: OverseasTransitPrice[]; createdBy: string; createdAt: string }