Release/test 合并上生产 #2
@@ -1,6 +1,6 @@
|
||||
# 海外仓真实接口接入矩阵
|
||||
|
||||
浏览器统一通过共享 `overseasHttp` 请求海外 Gateway,模块传入 `/platform/v1/...`;Controller 完整路径带 `/api` 前缀。长整型 ID 及业务单号均按字符串传递,path 参数使用 `encodeURIComponent`。接口事实已核对 Controller/DTO、Springdoc 与 `lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml`,不再沿用“无静态 OpenAPI”的旧结论。
|
||||
海外业务与库位/托盘资源通过共享 `overseasHttp` 请求海外 Gateway;仓库主数据候选只通过国内 `http` 请求国内 Warehouse。长整型 ID 及业务单号均按字符串传递,path 参数使用 `encodeURIComponent`。接口事实已核对 Controller/DTO、Springdoc 与 `lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml`,不再沿用“海外 Gateway 提供仓库根列表”的旧结论。
|
||||
|
||||
| 页面/能力 | Method + Path | Params / Body(当前 adapter) | 权限与承载 |
|
||||
|---|---|---|---|
|
||||
@@ -16,12 +16,12 @@
|
||||
| 草稿/锁/预排 | `GET /dispatches/drafts`;`POST /drafts/containers`;`DELETE /drafts/{draftToken}/containers/{containerId}`;`POST /draft-locks/lock|unlock|release`;`POST /pre-arrange` | 车厢:`draftToken,capacity`;锁:`planPalletId,draftToken,draftContainerId?`;释放:`draftToken`;预排:`planPalletIds:string[],draftToken` | `...:inbound-order:smart-dispatch`;Controller 受 `lastleg.smart-dispatch-api.enabled` 控制;409 锁冲突显示真实错误;不调用编辑、取消、depart、PDA 或派送 |
|
||||
| 库存汇总/余额 | `GET /platform/v1/last-leg/inventory/summary`;`GET /balances` | summary 强制 `page=1,size=200`;余额保留页面分页;支持 `skuCode,productName` 与仓库/入库/批次/库位筛选;不传客户 | `...:inventory:list`;只读,不调用 allocate/unallocate/pick/ship |
|
||||
| 库存位置/进销存 | `GET /inventory/balances/{balanceId}/positions`;`GET /inventory/ledger` | position:`palletNo?,batchNo?,inventoryStatus?`;ledger 含 `page,size,skuCode` 与业务单/仓库/时间筛选 | 同 inventory list;`balanceId` 编码;只展示后端事实 |
|
||||
| 公共海外仓候选 | `GET /platform/v1/warehouse-admin` | `page=1,size=200`,不传 `customerId` | `...:warehouse:list`;仓储资源固定公共作用域 `customer_id=0` |
|
||||
| 公共海外收货仓候选(国内 HTTP) | `GET /platform/v1/warehouse-admin` | `tenantId=0,page=1,size=200,capabilityCode=RECEIVING,warehouseType=OVERSEAS,status=ACTIVE` | 国内 Warehouse 是唯一仓库主数据源;托盘、库存和换标复用公共候选并保留字符串 ID,不请求海外仓库根路径 |
|
||||
| 托盘列表/详情 | `GET /platform/v1/warehouse-admin/{warehouseId}/pallets`;`GET /pallets/{palletId}` | list:`page,size,keyword?,palletType?,status?`;无客户参数 | `...:pallet:list`;仓库/托盘长 ID 编码 |
|
||||
| 托盘新增 | `POST /platform/v1/warehouse-admin/{warehouseId}/pallets` | body:`palletCode,palletType,fbaWarehouseCode,capacityCbm,unitPrice,status,locationId,remark`,明确不带 `version` | 前端 `...:pallet:create`;失败不模拟成功 |
|
||||
| 托盘编辑 | `PUT /platform/v1/warehouse-admin/{warehouseId}/pallets/{palletId}` | 新增同字段,并必传详情返回的 `version:number` | `...:pallet:edit`;旧版本返回 `A0409`,页面保留真实冲突错误。原型未定义删除,模块不保留删除 adapter/入口 |
|
||||
| 换标列表/详情 | `GET /platform/v1/last-leg/relabel-applications`;`GET /{relabelNo}` | list:`page,size,skuCode?,status?`;平台跨客户;详情 path 编码 | `...:relabel-order:list`;未返回字段不填演示值 |
|
||||
| 换标创建 | `POST /platform/v1/last-leg/relabel-applications?customerId={positiveLong}` | `customerId` 只在 query;body:`customerOrderNo,overseasWarehouseId,labelDeadline,contactName,contactPhone,remark?,idempotencyKey,items[]` | `...:relabel-order:create`;显式 ACTIVE 客户→该客户 ACTIVE 海外仓;同一弹框会话重试复用同一幂等键,失败保留 |
|
||||
| 换标创建 | `POST /platform/v1/last-leg/relabel-applications?customerId={positiveLong}` | `customerId` 只在 query;body:`customerOrderNo,overseasWarehouseId,labelDeadline,contactName,contactPhone,remark?,idempotencyKey,items[]` | `...:relabel-order:create`;显式 ACTIVE 客户与公共 ACTIVE+OVERSEAS+RECEIVING 收货仓分别选择;同一弹框会话重试复用同一幂等键,失败保留 |
|
||||
| 换标状态 | `PUT /platform/v1/last-leg/relabel-applications/{relabelNo}/status` | body:`status,remark?` | `...:relabel-order:status`;仅允许 `PENDING_PROCESSING→PROCESSING/REJECTED/CANCELLED`、`PROCESSING→COMPLETED/REJECTED/CANCELLED` |
|
||||
|
||||
## 原型与范围
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
## 真实环境仍需的数据与配置
|
||||
|
||||
- 可获得海外 Token 且持有矩阵 canonical 权限的 Admin 测试账号;失败响应需验证 `requestId/traceId`。
|
||||
- 至少一个 ACTIVE 客户及其 ACTIVE 海外仓;换标写验收还需允许关联的专用测试尾程订单/SKU,不能操作历史数据。
|
||||
- 至少一个 ACTIVE 客户,以及国内 Warehouse 中 `tenantId=0`、`OVERSEAS`、`ACTIVE`、启用 `RECEIVING` 的公共收货仓;换标写验收还需允许关联的专用测试尾程订单/SKU,不能操作历史数据。
|
||||
- 带 `sourceOrderId`、明细和托盘的入仓计划,以及已有 FBA 预约号,用于详情长 ID 与预约编辑。
|
||||
- 开启 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled`,并准备未锁候选托盘;验证 409 草稿锁冲突、关闭释放和预排回读。
|
||||
- 至少一个公共海外仓(`customer_id=0`)及公共托盘;编辑需用专用测试资源验证正确 version 与旧 version `A0409`。
|
||||
- 至少一个国内公共海外收货仓 ID,以及 Warehouse Overseas 中引用同一 ID 的公共托盘;新增需验证洛杉矶仓下 `IDLE + ABE3`,编辑需验证正确 version 与旧 version `A0409`。
|
||||
- 至少一条含 SKU/产品名、位置和 ledger 的库存事实,用于核对分页、汇总与长 ID;不调用离仓命令。
|
||||
|
||||
## 仍然排除
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = (relativePath: string) => readFileSync(path.resolve(process.cwd(), relativePath), 'utf8')
|
||||
const palletsApi = source('src/modules/overseas-warehouse/api/pallets.ts')
|
||||
const palletsPage = source('src/modules/overseas-warehouse/pages/PalletsView.vue')
|
||||
const iamApi = source('src/modules/iam/api/platformIam.ts')
|
||||
|
||||
describe('Admin pallet warehouse routing contract', () => {
|
||||
it('loads ACTIVE OVERSEAS receiving warehouse candidates from domestic Warehouse only', () => {
|
||||
expect(palletsApi).toContain("import { listSharedReceivingOverseasWarehouseOptions } from '@/modules/iam'")
|
||||
expect(palletsApi).toContain('await listSharedReceivingOverseasWarehouseOptions()')
|
||||
expect(palletsPage).toContain('listReceivingOverseasWarehouses()')
|
||||
expect(iamApi).toContain("'/platform/v1/warehouse-admin'")
|
||||
for (const filter of ["tenantId: '0'", "capabilityCode: 'RECEIVING'", "warehouseType: 'OVERSEAS'", "status: 'ACTIVE'"]) {
|
||||
expect(iamApi).toContain(filter)
|
||||
}
|
||||
})
|
||||
|
||||
it('never calls the nonexistent overseas warehouse-admin root and always scopes pallet calls by warehouseId', () => {
|
||||
expect(palletsApi).not.toMatch(/overseasHttp\.get[^\n]*\(\s*['"]\/platform\/v1\/warehouse-admin['"]/)
|
||||
expect(palletsApi).not.toContain('listCustomerWarehouses')
|
||||
expect(palletsApi).toContain('`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`')
|
||||
expect(palletsApi).toContain('`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listSharedReceivingOverseasWarehouseOptions } from '@/modules/iam'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from './pallets'
|
||||
import { createPallet, getPallet, listPallets, listReceivingOverseasWarehouses, updatePallet } from './pallets'
|
||||
|
||||
vi.mock('@/modules/iam', () => ({ listSharedReceivingOverseasWarehouseOptions: vi.fn() }))
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||
|
||||
describe('overseas pallet API', () => {
|
||||
@@ -12,20 +14,21 @@ describe('overseas pallet API', () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: page })
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: '9' })
|
||||
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: null })
|
||||
const payload = { palletCode: 'PLT-NEW', palletType: 'WOOD', fbaWarehouseCode: 'ONT8', capacityCbm: 2, unitPrice: 10, status: 'IDLE' }
|
||||
vi.mocked(listSharedReceivingOverseasWarehouseOptions).mockResolvedValue([{ id: '200', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', countryCode: 'US' }])
|
||||
const payload = { palletCode: 'PLT-NEW', palletType: 'WOOD', fbaWarehouseCode: 'ABE3', capacityCbm: 2, unitPrice: 10, status: 'IDLE' }
|
||||
|
||||
await listCustomerWarehouses()
|
||||
await expect(listReceivingOverseasWarehouses()).resolves.toMatchObject({ records: [{ id: '200', customerId: '0', warehouseCode: 'LAX', warehouseType: 'OVERSEAS', status: 'ACTIVE' }] })
|
||||
await listPallets('200', { page: 1, size: 20, keyword: 'PLT' })
|
||||
await getPallet('200', '9')
|
||||
await createPallet('200', payload)
|
||||
await updatePallet('200', '9', { ...payload, version: 7 })
|
||||
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/warehouse-admin', { params: { page: 1, size: 200 } })
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/warehouse-admin/200/pallets', { params: { page: 1, size: 20, keyword: 'PLT' } })
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/warehouse-admin/200/pallets/9', {})
|
||||
expect(listSharedReceivingOverseasWarehouseOptions).toHaveBeenCalledOnce()
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/warehouse-admin/200/pallets', { params: { page: 1, size: 20, keyword: 'PLT' } })
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/warehouse-admin/200/pallets/9', {})
|
||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets', payload)
|
||||
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', { ...payload, version: 7 })
|
||||
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', expect.objectContaining({ palletCode: 'PLT-NEW', fbaWarehouseCode: 'ONT8', version: 7 }))
|
||||
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', expect.objectContaining({ palletCode: 'PLT-NEW', fbaWarehouseCode: 'ABE3', status: 'IDLE', version: 7 }))
|
||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets', expect.not.objectContaining({ version: expect.anything() }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { listSharedReceivingOverseasWarehouseOptions } from '@/modules/iam'
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
|
||||
export interface CustomerWarehouseDto {
|
||||
export interface ReceivingOverseasWarehouseDto {
|
||||
id: string
|
||||
customerId: string
|
||||
warehouseCode: string
|
||||
@@ -43,12 +44,16 @@ export interface UpdatePalletPayload extends SavePalletPayload {
|
||||
version: number
|
||||
}
|
||||
|
||||
export async function listCustomerWarehouses() {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<CustomerWarehouseDto>>, ApiEnvelope<OverseasPagePayload<CustomerWarehouseDto>>>(
|
||||
'/platform/v1/warehouse-admin',
|
||||
{ params: { page: 1, size: 200 } },
|
||||
)
|
||||
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||
export async function listReceivingOverseasWarehouses() {
|
||||
const records: ReceivingOverseasWarehouseDto[] = (await listSharedReceivingOverseasWarehouseOptions()).map((warehouse) => ({
|
||||
id: warehouse.id,
|
||||
customerId: '0',
|
||||
warehouseCode: warehouse.warehouseCode,
|
||||
warehouseName: warehouse.warehouseName,
|
||||
warehouseType: 'OVERSEAS',
|
||||
status: 'ACTIVE',
|
||||
}))
|
||||
return { records, page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0 }
|
||||
}
|
||||
|
||||
export async function listPallets(warehouseId: string, query: { page: number; size: number; keyword?: string; palletType?: string; status?: string }) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listSharedReceivingOverseasWarehouseOptions } from '@/modules/iam'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions } from './relabelOrders'
|
||||
|
||||
vi.mock('@/modules/iam', () => ({ listSharedReceivingOverseasWarehouseOptions: vi.fn() }))
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||
|
||||
describe('relabel overseas API', () => {
|
||||
@@ -9,6 +11,7 @@ describe('relabel overseas API', () => {
|
||||
vi.mocked(overseasHttp.get).mockReset()
|
||||
vi.mocked(overseasHttp.post).mockReset()
|
||||
vi.mocked(overseasHttp.put).mockReset()
|
||||
vi.mocked(listSharedReceivingOverseasWarehouseOptions).mockReset()
|
||||
})
|
||||
|
||||
it('uses the platform page endpoint without customer scope', async () => {
|
||||
@@ -33,11 +36,10 @@ describe('relabel overseas API', () => {
|
||||
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications/RL%2F1/status', { status: 'PROCESSING', remark: 'ok' })
|
||||
})
|
||||
|
||||
it('loads ACTIVE overseas warehouses for the explicitly selected customer', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 200, total: 0, pages: 0 } })
|
||||
await listRelabelWarehouseOptions('10001')
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin', {
|
||||
params: { customerId: '10001', page: 1, size: 200, status: 'ACTIVE' },
|
||||
})
|
||||
it('loads shared ACTIVE OVERSEAS RECEIVING warehouses from the domestic candidate contract', async () => {
|
||||
vi.mocked(listSharedReceivingOverseasWarehouseOptions).mockResolvedValue([{ id: '200', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', countryCode: 'US' }])
|
||||
await expect(listRelabelWarehouseOptions()).resolves.toMatchObject({ records: [{ id: '200', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' }] })
|
||||
expect(listSharedReceivingOverseasWarehouseOptions).toHaveBeenCalledOnce()
|
||||
expect(overseasHttp.get).not.toHaveBeenCalledWith('/platform/v1/warehouse-admin', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { listSharedReceivingOverseasWarehouseOptions } from '@/modules/iam'
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
|
||||
export interface RelabelOrderQuery {
|
||||
@@ -105,12 +106,16 @@ export async function getRelabelOrder(relabelNo: string) {
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function listRelabelWarehouseOptions(customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<RelabelWarehouseOptionDto>>, ApiEnvelope<OverseasPagePayload<RelabelWarehouseOptionDto>>>(
|
||||
'/platform/v1/warehouse-admin',
|
||||
{ params: { customerId, page: 1, size: 200, status: 'ACTIVE' } },
|
||||
)
|
||||
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||
export async function listRelabelWarehouseOptions() {
|
||||
const records: RelabelWarehouseOptionDto[] = (await listSharedReceivingOverseasWarehouseOptions()).map((warehouse) => ({
|
||||
id: warehouse.id,
|
||||
customerId: '0',
|
||||
warehouseCode: warehouse.warehouseCode,
|
||||
warehouseName: warehouse.warehouseName,
|
||||
warehouseType: 'OVERSEAS',
|
||||
status: 'ACTIVE',
|
||||
}))
|
||||
return { records, page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0 }
|
||||
}
|
||||
|
||||
export async function createRelabelOrder(customerId: string, payload: CreateRelabelPayload) {
|
||||
|
||||
@@ -11,14 +11,14 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
|
||||
import { listCustomerWarehouses, type CustomerWarehouseDto } from '../api/pallets'
|
||||
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import { toInventoryListItem } from '../inventoryPresentation'
|
||||
import { overseasWarehousePermissions } from '../permissions'
|
||||
import type { InventoryListItem } from '../types'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const warehouses = ref<CustomerWarehouseDto[]>([])
|
||||
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
|
||||
const rows = ref<InventoryListItem[]>([])
|
||||
const summary = ref<InventoryDashboardDto | null>(null)
|
||||
const loading = ref(false)
|
||||
@@ -59,7 +59,7 @@ const columns: DataTableColumn[] = [
|
||||
async function loadWarehouses() {
|
||||
if (!access.can(overseasWarehousePermissions.warehouseList)) return
|
||||
contextLoading.value = true; loadError.value = null
|
||||
try { warehouses.value = (await listCustomerWarehouses()).records }
|
||||
try { warehouses.value = (await listReceivingOverseasWarehouses()).records }
|
||||
catch (error) { loadError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') }
|
||||
finally { contextLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getPallet, listCustomerWarehouses, type PalletDto } from '../api/pallets'
|
||||
import { getPallet, listReceivingOverseasWarehouses, type PalletDto } from '../api/pallets'
|
||||
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -29,7 +29,7 @@ async function load() {
|
||||
if (!warehouseId.value || palletId.value === '—') { loadError.value = getApiErrorDetails(new Error('托盘详情缺少仓库参数')); return }
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const [pallet, warehouses] = await Promise.all([getPallet(warehouseId.value, palletId.value), listCustomerWarehouses()])
|
||||
const [pallet, warehouses] = await Promise.all([getPallet(warehouseId.value, palletId.value), listReceivingOverseasWarehouses()])
|
||||
detail.value = pallet
|
||||
const warehouse = warehouses.records.find((item) => String(item.id) === warehouseId.value)
|
||||
warehouseName.value = warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : warehouseId.value
|
||||
|
||||
@@ -4,13 +4,13 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from '../api/pallets'
|
||||
import { createPallet, getPallet, listPallets, listReceivingOverseasWarehouses, updatePallet } from '../api/pallets'
|
||||
import PalletsView from './PalletsView.vue'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/pallets', () => ({ createPallet: vi.fn(), getPallet: vi.fn(), listCustomerWarehouses: vi.fn(), listPallets: vi.fn(), updatePallet: vi.fn() }))
|
||||
vi.mock('../api/pallets', () => ({ createPallet: vi.fn(), getPallet: vi.fn(), listPallets: vi.fn(), listReceivingOverseasWarehouses: vi.fn(), updatePallet: vi.fn() }))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
|
||||
@@ -21,9 +21,9 @@ describe('PalletsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', memoryStorage())
|
||||
vi.mocked(listCustomerWarehouses).mockReset().mockResolvedValue({ records: [{ id: '200', customerId: '0', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([{ id: '300', warehouseCode: 'ONT8', warehouseName: 'ONT8', warehouseType: 'FBA', status: 'ACTIVE' } as never])
|
||||
const pallet = { id: '9007199254740993', customerId: '0', warehouseId: '200', locationId: null, palletCode: 'PLT-1', palletType: 'WOOD', fbaWarehouseCode: 'ONT8', capacityCbm: 2, unitPrice: 10, status: 'IDLE', remark: null, version: 7 }
|
||||
vi.mocked(listReceivingOverseasWarehouses).mockReset().mockResolvedValue({ records: [{ id: '200', customerId: '0', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([{ id: '300', warehouseCode: 'ABE3', warehouseName: 'ABE3', warehouseType: 'FBA', status: 'ACTIVE' } as never])
|
||||
const pallet = { id: '9007199254740993', customerId: '0', warehouseId: '200', locationId: null, palletCode: 'PLT-1', palletType: 'WOOD', fbaWarehouseCode: 'ABE3', capacityCbm: 2, unitPrice: 10, status: 'IDLE', remark: null, version: 7 }
|
||||
vi.mocked(listPallets).mockReset().mockResolvedValue({ records: [pallet], page: 1, size: 10, total: 1, pages: 1 })
|
||||
vi.mocked(getPallet).mockReset().mockResolvedValue(pallet)
|
||||
vi.mocked(createPallet).mockReset()
|
||||
@@ -44,9 +44,31 @@ describe('PalletsView', () => {
|
||||
|
||||
expect(getPallet).toHaveBeenCalledWith('200', '9007199254740993')
|
||||
expect(updatePallet).toHaveBeenCalledWith('200', '9007199254740993', expect.objectContaining({
|
||||
palletCode: 'PLT-1', fbaWarehouseCode: 'ONT8', version: 7,
|
||||
palletCode: 'PLT-1', fbaWarehouseCode: 'ABE3', version: 7,
|
||||
}))
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
expect(createPallet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates an IDLE ABE3 pallet under the selected Los Angeles warehouse id', async () => {
|
||||
vi.mocked(createPallet).mockResolvedValueOnce('9007199254740994')
|
||||
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('新增托盘'))!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const warehouse = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择海外仓')!
|
||||
const fbaWarehouse = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择所属 FBA 仓')!
|
||||
const palletCode = wrapper.findAllComponents({ name: 'ElInput' }).find((item) => item.props('placeholder') === '请输入托盘编码')!
|
||||
warehouse.vm.$emit('update:modelValue', '200')
|
||||
fbaWarehouse.vm.$emit('update:modelValue', 'ABE3')
|
||||
palletCode.vm.$emit('update:modelValue', 'PLT-LAX-ABE3')
|
||||
await flushPromises()
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
|
||||
expect(createPallet).toHaveBeenCalledWith('200', expect.objectContaining({
|
||||
palletCode: 'PLT-LAX-ABE3', fbaWarehouseCode: 'ABE3', status: 'IDLE',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,13 +14,13 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet, type CustomerWarehouseDto, type PalletDto } from '../api/pallets'
|
||||
import { createPallet, getPallet, listPallets, listReceivingOverseasWarehouses, updatePallet, type PalletDto, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import type { PalletListItem } from '../types'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const router = useRouter()
|
||||
const warehouses = ref<CustomerWarehouseDto[]>([])
|
||||
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
|
||||
const fbaWarehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const rows = ref<PalletListItem[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -63,7 +63,7 @@ async function loadWarehouses() {
|
||||
contextLoading.value = true; loadError.value = null; loadErrorKind.value = 'context'
|
||||
try {
|
||||
const [warehousePage, platformFbaWarehouses] = await Promise.all([
|
||||
listCustomerWarehouses(),
|
||||
listReceivingOverseasWarehouses(),
|
||||
listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' }),
|
||||
])
|
||||
warehouses.value = warehousePage.records
|
||||
|
||||
@@ -23,8 +23,6 @@ vi.mock('../api/relabelOrders', () => ({
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
|
||||
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done }); return { promise, resolve } }
|
||||
|
||||
function formItem(wrapper: VueWrapper<any>, label: string) {
|
||||
const item = wrapper.findAllComponents({ name: 'ElFormItem' }).find((candidate) => candidate.props('label') === label)
|
||||
if (!item) throw new Error(`Missing form item: ${label}`)
|
||||
@@ -51,10 +49,8 @@ describe('RelabelOrdersView', () => {
|
||||
vi.mocked(changeRelabelOrderStatus).mockReset()
|
||||
})
|
||||
|
||||
it('keeps only the latest selected customer warehouse response', async () => {
|
||||
const first = deferred<Awaited<ReturnType<typeof listRelabelWarehouseOptions>>>()
|
||||
const second = deferred<Awaited<ReturnType<typeof listRelabelWarehouseOptions>>>()
|
||||
vi.mocked(listRelabelWarehouseOptions).mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
|
||||
it('loads shared receiving warehouse options independently from the selected customer', async () => {
|
||||
vi.mocked(listRelabelWarehouseOptions).mockResolvedValue({ records: [{ id: 'WH-B', customerId: '0', warehouseCode: 'B', warehouseName: 'B仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('新增换标单'))!.trigger('click')
|
||||
@@ -63,17 +59,10 @@ describe('RelabelOrdersView', () => {
|
||||
const customer = select(wrapper, '请选择 ACTIVE 客户')
|
||||
customer.vm.$emit('update:modelValue', '10001'); customer.vm.$emit('change', '10001')
|
||||
await flushPromises()
|
||||
customer.vm.$emit('update:modelValue', '10002'); customer.vm.$emit('change', '10002')
|
||||
await flushPromises()
|
||||
|
||||
second.resolve({ records: [{ id: 'WH-B', customerId: '10002', warehouseCode: 'B', warehouseName: 'B仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
await flushPromises()
|
||||
first.resolve({ records: [{ id: 'WH-A', customerId: '10001', warehouseCode: 'A', warehouseName: 'A仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
await flushPromises()
|
||||
|
||||
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => option.props('label'))
|
||||
expect(listRelabelWarehouseOptions).toHaveBeenCalledOnce()
|
||||
expect(labels).toContain('B仓 · B')
|
||||
expect(labels).not.toContain('A仓 · A')
|
||||
})
|
||||
|
||||
it('reuses one idempotency key after a failed create retry', async () => {
|
||||
@@ -87,7 +76,7 @@ describe('RelabelOrdersView', () => {
|
||||
const customer = select(wrapper, '请选择 ACTIVE 客户')
|
||||
customer.vm.$emit('update:modelValue', '10001'); customer.vm.$emit('change', '10001')
|
||||
await flushPromises()
|
||||
const warehouse = select(wrapper, '请先选择客户,再选择其海外仓')
|
||||
const warehouse = select(wrapper, '请选择 ACTIVE+RECEIVING 海外仓')
|
||||
warehouse.vm.$emit('update:modelValue', 'WH-1')
|
||||
formItem(wrapper, '关联订单').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'CO-1')
|
||||
formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SKU-1')
|
||||
|
||||
@@ -27,7 +27,6 @@ const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const warehouses = ref<RelabelWarehouseOptionDto[]>([])
|
||||
const warehousesLoading = ref(false)
|
||||
const warehouseRequestSequence = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
@@ -74,6 +73,7 @@ function openForm(mode: 'create' | 'edit', source?: object) {
|
||||
createIdempotencyKey.value = globalThis.crypto?.randomUUID?.() || `admin-relabel-${Date.now()}`
|
||||
warehouses.value = []
|
||||
void loadCustomers()
|
||||
void loadWarehouses()
|
||||
}
|
||||
dialogOpen.value = true
|
||||
}
|
||||
@@ -95,19 +95,17 @@ async function loadCustomers() {
|
||||
finally { customersLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleCustomerChange(customerId: string) {
|
||||
const requestSequence = ++warehouseRequestSequence.value
|
||||
async function loadWarehouses() {
|
||||
form.warehouseId = ''
|
||||
warehouses.value = []
|
||||
if (!customerId) { warehousesLoading.value = false; return }
|
||||
warehousesLoading.value = true
|
||||
try {
|
||||
const records = (await listRelabelWarehouseOptions(customerId)).records.filter((item) => item.status === 'ACTIVE')
|
||||
if (requestSequence === warehouseRequestSequence.value && customerId === form.customerId) warehouses.value = records
|
||||
const records = (await listRelabelWarehouseOptions()).records.filter((item) => item.status === 'ACTIVE')
|
||||
warehouses.value = records
|
||||
} catch (error) {
|
||||
if (requestSequence === warehouseRequestSequence.value) ElMessage.error(getApiErrorDetails(error, '海外仓列表加载失败,请重试').message)
|
||||
ElMessage.error(getApiErrorDetails(error, '公共海外收货仓加载失败,请重试').message)
|
||||
} finally {
|
||||
if (requestSequence === warehouseRequestSequence.value) warehousesLoading.value = false
|
||||
warehousesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +113,7 @@ async function submitForm() {
|
||||
if (submitting.value) return
|
||||
try {
|
||||
if (dialogMode.value === 'create') {
|
||||
if (!form.customerId || !form.warehouseId) { ElMessage.warning('请先选择客户和该客户的海外仓'); return }
|
||||
if (!form.customerId || !form.warehouseId) { ElMessage.warning('请选择客户和公共海外收货仓'); return }
|
||||
if (!form.relatedOrderNo.trim() || !form.sku.trim() || !form.productName.trim() || !form.cartons || !form.labelDeadline || !form.contactName.trim() || !form.contactPhone.trim() || !form.shipmentNo.trim() || !form.newSkuCode.trim() || !form.labelRequirement) {
|
||||
ElMessage.warning('请完整填写换标单必填信息')
|
||||
return
|
||||
@@ -187,8 +185,8 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
|
||||
<el-form label-position="top"><h3>订单信息</h3><el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="换标单号"><el-input :model-value="dialogMode === 'create' ? '系统自动生成' : form.orderNo || '—'" readonly /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" :disabled="dialogMode === 'edit'" placeholder="请输入关联订单号" /></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择 ACTIVE 客户" @change="handleCustomerChange"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="海外仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" :disabled="!form.customerId" placeholder="请先选择客户,再选择其海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择 ACTIVE 客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="公共海外收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" placeholder="请选择 ACTIVE+RECEIVING 海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" :disabled="dialogMode === 'edit'" placeholder="请输入商品SKU" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" :disabled="dialogMode === 'edit'" placeholder="请输入中文品名" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :disabled="dialogMode === 'edit'" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col>
|
||||
|
||||
Reference in New Issue
Block a user