This commit is contained in:
2026-08-25 10:34:28 +08:00
72 changed files with 5664 additions and 300 deletions
@@ -1,20 +1,32 @@
# 海外仓真实接口接入矩阵
浏览器只通过共享 `overseasHttp` 请求海外网关,模块路径统一从 `/platform/v1/...` 开始。长整型 ID 全程按字符串处理。以下结论来自 `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service` 当前 Controller/DTO;三者当前没有静态 OpenAPI YAML,本轮未把 Springdoc 注解之外的路径推断为正式接口。
浏览器统一通过共享 `overseasHttp` 请求海外 Gateway,模块传入 `/platform/v1/...`;Controller 完整路径带 `/api` 前缀。长整型 ID 及业务单号均按字符串传递,path 参数使用 `encodeURIComponent`。接口事实已核对 Controller/DTO、Springdoc 与 `lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml`,不再沿用“无静态 OpenAPI”的旧结论。
| 原型页面 | 页面 ID | 当前真实接口 | 接入状态 | 说明 |
|---|---|---|---|---|
| 尾程订单列表 | `fba-last-mile-order` | `GET /platform/v1/last-leg/orders` | 已接入 | 显式 `customerId`;支持 `page/size/orderNo/inboundCode/containerNo/status`。原型其余筛选保留但禁用,不发送无效参数。 |
| 尾程订单详情 | `fba-last-mile-order-detail` | `GET /platform/v1/last-leg/orders/{id}` | 已接入 | 展示订单、货物、状态历史和既有单据编号;出库/派送编号只读,不提供离仓动作。 |
| 入库单列表/详情/FBA 预约 | `fba-inbound-order*` | `GET /platform/v1/last-leg/inbound-plans`、`GET /{planNo}`、`POST /fba-appointments/batch`、`GET/PUT /fba-appointments/{appointmentNo}` | 已接入 | 显式 `customerId`、真实分页和筛选;支持按入库明细勾选、同客户/同 FBA 仓校验、预计装车时间以及预约编辑。 |
| 换标单列表/详情 | `fba-relabel-order*` | `GET /platform/v1/last-leg/relabel-applications`、`GET /{relabelNo}` | 已接入只读 | 列表 DTO 可展示单号、SKU、箱数和状态;详情补产品名、标签要求、文件。预算/费用继续显示空值并记录缺口,未伪造金额。 |
| 库存/进销存/详情 | `fba-inventory*` | `GET /platform/v1/last-leg/inventory/summary|balances|balances/{id}/positions|ledger` | 已接入只读 | 列表、汇总、库位/托盘位置与流水已接入;余额返回 SKU/产品名数组,流水返回 SKU 和库位代码。 |
| 托盘列表/详情 | `fba-pallet*` | `GET/POST /platform/v1/warehouse-admin/{warehouseId}/pallets`、`GET/PUT/DELETE /{id}` | 已接入 | 所有请求显式携带 `customerId` 和仓库范围;新增、编辑、删除使用真实 DTO 和真实 Controller 端点。 |
| 箱号列表/详情 | `fba-carton*` | 无平台查询接口 | 本期暂缓 | 本期已从模块正式路由移除;页面、类型、权限常量与测试源码保留,供后续恢复。只有内部接收事件产生快照,浏览器不得调用内部端点。 |
| 产品库列表/详情 | `fba-product*` | 无平台查询接口 | 本期暂缓 | 本期已从模块正式路由移除;页面、类型、权限常量与测试源码保留,供后续恢复。只有内部一件代发快照写入,未提供平台查询/维护端点。 |
| 页面/能力 | Method + Path | Params / Body(当前 adapter) | 权限与承载 |
|---|---|---|---|
| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 |
| 尾程转目的仓 | `PUT /platform/v1/last-leg/orders/destination` | body:`orderIds:string[],warehouseId,warehouseCode,warehouseName,remark?` | `...:last-mile-order:edit`;失败保留弹框,不提示成功 |
| 尾程转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert` | body:`orderId:string`,不传客户 | `...:inbound-order:create`;不接补托/PDA |
| 入库列表 | `GET /platform/v1/last-leg/inbound-plans` | query:`page,size,planNo,inboundCode,status,warehouse,fbaWarehouseCode,appointmentStatus,dispatchStatus,createdFrom,createdTo`;平台跨客户 | `...:inbound-order:list`;`lineId/planId/orderId/customerId` 均为字符串 |
| 入库详情 | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | query:显式 `customerId`;path 编码 | 同 list;缺客户范围时页面明确报错;`sourceOrderId` 字符串化 |
| FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`customerId,lineIds:string[],appointmentNo,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | `...:inbound-order:fba-appointment`;只允许同客户、同 FBA 仓勾选 |
| FBA 预约详情/编辑 | `GET /fba-appointments/{appointmentNo}?customerId=...`;`PUT` 同 path | PUT body:`customerId,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | GET 同 list;PUT 同 appointment;失败保留弹框和错误元数据 |
| 排车列表/详情 | `GET /platform/v1/last-leg/dispatches`;`GET /dispatches/{dispatchNo}` | list query:`page,size,dispatchNo,vehiclePlate,status,fbaAppointmentNo`;不传客户 | `...:inbound-order:list`;Controller 受 `lastleg.dispatch-query-api.enabled` 控制 |
| 排车候选 | `GET /platform/v1/last-leg/dispatches/candidates` | query:`planIds?`(逗号分隔长 ID)、`orderNo?`,`fbaWarehouseCode?`,`sourceStage?`,`draftToken?` | 同 inbound list;仅发 `PLAN/RECEIVED` |
| 草稿/锁/预排 | `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` |
| 托盘列表/详情 | `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 海外仓;同一弹框会话重试复用同一幂等键,失败保留 |
| 换标状态 | `PUT /platform/v1/last-leg/relabel-applications/{relabelNo}/status` | body:`status,remark?` | `...:relabel-order:status`;仅允许 `PENDING_PROCESSING→PROCESSING/REJECTED/CANCELLED`、`PROCESSING→COMPLETED/REJECTED/CANCELLED` |
状态映射仅使用服务端已有事实:`PENDING_ASSIGNMENT`(待分配)、`DESTINATION_ASSIGNED`(已分配目的仓)、`INBOUND_PLANNED`(已生成入库计划)。未定义的原型状态不发送到接口。
## 原型与范围
菜单、路由与操作按钮使用真实 Controller 权限:`platform:overseas-warehouse:*`(尾程、入库、换标、库存、仓库、库位和托盘资源)。Admin 未接 PDA、扫码、卸柜照片、内部事件及 `allocate/unallocate/pick/ship` 等离仓命令。
当前正式路由只保留尾程订单、入库单、换标单、托盘管理和库存管理;箱号和产品库本期暂缓,不恢复正式路由。
- 页面 ID:尾程 `fba-last-mile-order*`、入库 `fba-inbound-order*`、换标 `fba-relabel-order*`、托盘 `fba-pallet*`、库存 `fba-inventory*`。排车没有独立海外仓原型页,只在入库页智能排车弹框及既有排车查询页承载。
- 托盘原型操作列仅查看、编辑,工具栏仅新增;后端即使存在 DELETE,Admin 也不暴露。所有业务表格操作列保持第一列并固定左侧。
- 箱号、产品库本期暂缓并从正式 routes 移除;海外出库、退货出库、海外派送、PDA、卸柜照片、内部事件以及库存离仓命令均排除。
- 1920×1080、浏览器 100% 的人工截图仍待验收。
+17 -19
View File
@@ -1,26 +1,24 @@
# 四期海外仓前端待补契约
# 四期海外仓前端剩余契约与环境缺口
正式页面已开始接入真实海外接口。后端服务边界现已确认:浏览器请求统一进入 `gateway-overseas/`(RS256/JWKS,Audience=`lastleg-api`);尾程订单、入仓计划、换标、产品/箱号快照和库存业务归 `lastleg-service/`;海外仓库、地址、能力、库位、实物托盘和资源锁归 `warehouse-overseas-service/`。国内 `warehouse/` 只维护“海外仓合作仓”公共主数据类型。逐页现状见 `API_INTEGRATION_MATRIX.md`。
已接请求、权限、客户作用域和错误承载见 `API_INTEGRATION_MATRIX.md`。本文件只保留当前仍影响真实联调或原型完整度的差异,不再把 FBA 预约、换标写接口、托盘公共作用域或静态 OpenAPI 记为“未接/不存在”。
以下前端适配或页面级契约补齐前,页面不得用演示数据或本地成功提示替代真实调用:
## 需后端或产品继续确认
- 尾程订单导入模板、批量导入、异步导出任务及对应独立权限。现有同步 CSV 导出不等同于 Admin 下载中心异步导出。尾程分页与详情已经接入;“待分配”映射为 `PENDING_ASSIGNMENT`,出库单号、派送单号仅展示既有只读关联事实。
- 入库单分页与详情已接入。详情 DTO 仍缺预约状态/时间、排车状态/时间、创建更新时间、备注和应收费用;创建、预约及补托只在原型承载和完整幂等/打印语义冻结后接入。
- 库存汇总、SKU 分页、库位位置和进销存已接入。流水 DTO 仍缺 SKU、产品名、客户名和海外仓名称;详情也没有按 `balanceId` 直接查询余额主记录的端点。
- 换标单分页、详情、创建、编辑/审核、文件上传与费用明细接口,以及状态机和幂等约定。
- 托盘分页、详情、新增、编辑已接入公共资源接口。剩余缺口是独立公共仓库候选接口、FBA 仓字段、业务绑定/关联箱数只读汇总;删除未在原型定义,不新增入口。
- 箱号、产品库本期暂缓并已从模块正式路由移除;既有源码保留,相关平台查询/写接口不再作为本期交付阻断。
- 四期权限资源在 IAM 初始化数据中的正式登记。前端暂按本模块 `permissions.ts` 的稳定编码装配。
- 所有分页响应、错误码、`requestId`/`traceId`、时间/金额精度和长整型 ID 字符串传输约定。
- **换标原型字段**:列表 Summary 仍不含 `productNameZh/labelRequirement/relabelFile/budgetTotal/relabelCost`;本模块没有可用的换标文件上传端点。页面不伪造金额或文件名。创建 DTO 额外要求海外仓、截止时间、联系人、电话、货件号和新 SKU,这是完成真实创建的最小扩展。
- **托盘新增服务端权限**:前端新增按钮按 `platform:overseas-warehouse:pallet:create` 控制,但当前审计的 `PlatformWarehouseResourceController#createPallet` 未见对应 `@PreAuthorize`;需后端补齐或确认由网关等价强制。原型要求“托盘重量”,当前 Save DTO 无该字段,页面不伪造或发送。
- **托盘状态语义**:原型只给闲置/占用,DTO 还允许 `SCRAPPED`。是否允许 Admin 在普通编辑中直接改为报废仍需确认;不新增独立停用/报废动作。PUT 已按冻结契约携带详情 `version`,旧版本 `A0409`。
- **库存导出**:后端已存在只读 `GET /inventory/balances/export` 与 `GET /inventory/ledger/export`(权限 `...:inventory:export`),当前模块页面/adapter 尚无二进制下载实现;原型承载与共享下载约定确认后再接,不能用 JSON 请求替代。
- **入库补充托盘**:`POST /inbound-plans/{planNo}/pallets/supplement` 已存在,但权限、幂等、打印及原型承载未整体冻结,Admin 不接。FBA 预约创建、详情和编辑已经接入。
## 当前缺口的精确请求形状
## 真实环境仍需的数据与配置
- 入库详情:`GET /api/platform/v1/last-leg/inbound-plans/{planNo}` 需补预约/排车/时间/费用,或提供对应只读辅助接口;`sourceOrderId` 已按字符串接入。
- 库存流水:`GET /api/platform/v1/last-leg/inventory/ledger` 需补 `skuCode/productName/customerName/overseasWarehouseName`,并支持 SKU 过滤,才能完整还原进销存和 SKU 详情流水。
- 换标:当前列表响应需补 `productName,labelRequirement,relabelFile,budgetTotal/relabelCost`,或产品明确调整原型字段;写接口必须补正式 `create/edit` 权限。现有状态机为 `PENDING_PROCESSING -> PROCESSING/REJECTED/CANCELLED`、`PROCESSING -> COMPLETED/REJECTED/CANCELLED`。
- 托盘:资源接口已固定公共作用域且不再传客户参数;仍缺无需客户遍历即可获取所有实际海外仓的公共候选接口,以及 FBA 仓和业务绑定汇总字段。
- 权限:入仓、库存、仓库资源已切换 canonical `platform:overseas-warehouse:*`;前端已同步本模块权限常量。换标写弹框字段与后端 Create DTO 仍不一致,因此不开放提交。
- 可获得海外 Token 且持有矩阵 canonical 权限的 Admin 测试账号;失败响应需验证 `requestId/traceId`。
- 至少一个 ACTIVE 客户及其 ACTIVE 海外仓;换标写验收还需允许关联的专用测试尾程订单/SKU,不能操作历史数据。
- 带 `sourceOrderId`、明细和托盘的入仓计划,以及已有 FBA 预约号,用于详情长 ID 与预约编辑。
- 开启 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled`,并准备未锁候选托盘;验证 409 草稿锁冲突、关闭释放和预排回读。
- 至少一个公共海外仓(`customer_id=0`)及公共托盘;编辑需用专用测试资源验证正确 version 与旧 version `A0409`。
- 至少一条含 SKU/产品名、位置和 ledger 的库存事实,用于核对分页、汇总与长 ID;不调用离仓命令。
范围排除:海外仓出库、退货出库、海外派送和其他库存离仓接口。
## 仍然排除
`lastleg-service` 已提供尾程订单、入仓计划、库存查询等 Controller,`warehouse-overseas-service` 已提供平台海外仓、库位和托盘 Controller;接入时以这些服务的 Controller/DTO/OpenAPI 为准。当前 `types.ts` 只冻结原型派生的页面展示字段,不是海外 API DTO;不得据此推导未覆盖页面的分页包络、URL、请求参数、时间/金额精度或写接口。统一区域客户端和对应页面契约完成后再建立实际 API 适配层,并逐页替换空态数据源。
箱号平台、产品库、海外出库、退货出库、海外派送、PDA 扫描/拣货/卸柜照片、内部事件,以及库存 `allocate/unallocate/pick/ship`。不得为自动化覆盖扩展这些前端路径。
@@ -1,13 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { getDispatch, listDispatchCandidates, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet } from './dispatches'
import { createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet } from './dispatches'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } }))
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
describe('dispatch overseas API', () => {
beforeEach(() => {
vi.mocked(overseasHttp.get).mockReset()
vi.mocked(overseasHttp.post).mockReset()
vi.mocked(overseasHttp.delete).mockReset()
})
it('uses the platform dispatch page without customer scope', async () => {
@@ -66,4 +67,18 @@ describe('dispatch overseas API', () => {
expect(overseasHttp.post).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId: '99', draftToken: 'draft-1' })
expect(overseasHttp.post).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken: 'draft-1' })
})
it('loads drafts and preserves long container ids in create and delete paths', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '190000000000000099', capacity: 68, palletIds: [] } })
vi.mocked(overseasHttp.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await listDispatchDrafts()
await createDispatchDraftContainer('draft/1', 68)
await deleteDispatchDraftContainer('draft/1', '190000000000000099')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts')
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken: 'draft/1', capacity: 68 })
expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/draft%2F1/containers/190000000000000099')
})
})
@@ -1,6 +1,6 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OverseasPageResult } from './lastMileOrders'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface DispatchQuery {
page: number
@@ -106,11 +106,11 @@ export interface DispatchItemDto {
}
export async function listDispatches(query: DispatchQuery) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<DispatchSummaryDto>>, ApiEnvelope<OverseasPageResult<DispatchSummaryDto>>>(
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<DispatchSummaryDto>>, ApiEnvelope<OverseasPagePayload<DispatchSummaryDto>>>(
'/platform/v1/last-leg/dispatches',
{ params: query },
)
return unwrapApiResponse(response)
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function getDispatch(dispatchNo: string) {
@@ -1,6 +1,6 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OverseasPageResult } from './lastMileOrders'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface InboundOrderQuery {
page: number
@@ -68,10 +68,10 @@ export interface FbaAppointmentDto {
export type UpdateFbaAppointmentPayload = Omit<BatchFbaAppointmentPayload, 'lineIds' | 'appointmentNo'>
export async function listInboundOrders(query: InboundOrderQuery) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<InboundOrderSummaryDto>>, ApiEnvelope<OverseasPageResult<InboundOrderSummaryDto>>>(
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<InboundOrderSummaryDto>>, ApiEnvelope<OverseasPagePayload<InboundOrderSummaryDto>>>(
'/platform/v1/last-leg/inbound-plans', { params: query },
)
return unwrapApiResponse(response)
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function createFbaAppointment(payload: BatchFbaAppointmentPayload) {
@@ -1,6 +1,6 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OverseasPageResult } from './lastMileOrders'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface InboundPlanQuery {
customerId: string
@@ -60,7 +60,7 @@ export interface InboundPlanDetailDto {
export async function listInboundPlans(query: InboundPlanQuery) {
const { customerId, ...params } = query
return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasPageResult<InboundPlanSummaryDto>>, ApiEnvelope<OverseasPageResult<InboundPlanSummaryDto>>>('/platform/v1/last-leg/inbound-plans', { params: { customerId, ...params } }))
return normalizeOverseasPage(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasPagePayload<InboundPlanSummaryDto>>, ApiEnvelope<OverseasPagePayload<InboundPlanSummaryDto>>>('/platform/v1/last-leg/inbound-plans', { params: { customerId, ...params } })))
}
export async function getInboundPlan(planNo: string, customerId: string) {
@@ -1,6 +1,6 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OverseasPageResult } from './lastMileOrders'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface InventoryBalanceDto {
id: string
@@ -110,10 +110,10 @@ export interface InventoryBalanceQuery {
function params(query: InventoryBalanceQuery) { return { ...query } }
export async function listInventoryBalances(query: InventoryBalanceQuery) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<InventoryBalanceDto>>, ApiEnvelope<OverseasPageResult<InventoryBalanceDto>>>(
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<InventoryBalanceDto>>, ApiEnvelope<OverseasPagePayload<InventoryBalanceDto>>>(
'/platform/v1/last-leg/inventory/balances', { params: params(query) },
)
return unwrapApiResponse(response)
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function getInventorySummary(query: InventoryBalanceQuery) {
@@ -153,9 +153,9 @@ export interface InventoryLedgerQuery {
}
export async function listInventoryLedger(query: InventoryLedgerQuery) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<InventoryLedgerDto>>, ApiEnvelope<OverseasPageResult<InventoryLedgerDto>>>(
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<InventoryLedgerDto>>, ApiEnvelope<OverseasPagePayload<InventoryLedgerDto>>>(
'/platform/v1/last-leg/inventory/ledger',
{ params: { ...query } },
)
return unwrapApiResponse(response)
return normalizeOverseasPage(unwrapApiResponse(response))
}
@@ -8,8 +8,8 @@ describe('last-mile order overseas API', () => {
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
it('loads all platform orders without a customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 2, size: 20, total: 0, pages: 0 } })
await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toMatchObject({ page: 2 })
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '0', pages: '0' } })
await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toEqual({ records: [], page: 2, size: 20, total: 0, pages: 0 })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', {
params: { page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' },
})
@@ -9,6 +9,17 @@ export interface OverseasPageResult<T> {
pages: number
}
export type OverseasPagePayload<T> = Omit<OverseasPageResult<T>, 'page' | 'size' | 'total' | 'pages'> & {
page: number | string
size: number | string
total: number | string
pages: number | string
}
export function normalizeOverseasPage<T>(value: OverseasPagePayload<T>): OverseasPageResult<T> {
return { ...value, page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) }
}
export interface LastMileOrderQuery {
page: number
size: number
@@ -118,11 +129,11 @@ export interface LastMileOrderDetailDto {
}
export async function listLastMileOrders(query: LastMileOrderQuery) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<LastMileOrderSummaryDto>>, ApiEnvelope<OverseasPageResult<LastMileOrderSummaryDto>>>(
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<LastMileOrderSummaryDto>>, ApiEnvelope<OverseasPagePayload<LastMileOrderSummaryDto>>>(
'/platform/v1/last-leg/orders',
{ params: query },
)
return unwrapApiResponse(response)
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function getLastMileOrder(orderId: string) {
@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { createPallet, deletePallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from './pallets'
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from './pallets'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
describe('overseas pallet API', () => {
beforeEach(() => { vi.clearAllMocks() })
@@ -12,22 +12,20 @@ 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 })
vi.mocked(overseasHttp.delete).mockResolvedValue({ code: '200', data: null })
const payload = { palletCode: 'PLT-NEW', palletType: 'WOOD', fbaWarehouseCode: 'ONT8', capacityCbm: 2, unitPrice: 10, status: 'IDLE' }
await listCustomerWarehouses()
await listPallets('200', { page: 1, size: 20, keyword: 'PLT' })
await getPallet('200', '9')
await createPallet('200', payload)
await updatePallet('200', '9', payload)
await deletePallet('200', '9')
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(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets', payload)
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', payload)
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', expect.objectContaining({ palletCode: 'PLT-NEW', fbaWarehouseCode: 'ONT8' }))
expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', {})
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.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets', expect.not.objectContaining({ version: expect.anything() }))
})
})
+11 -14
View File
@@ -1,6 +1,6 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OverseasPageResult } from './lastMileOrders'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface CustomerWarehouseDto {
id: string
@@ -23,6 +23,7 @@ export interface PalletDto {
unitPrice: number
status: string
remark?: string | null
version: number
createdAt?: string | null
updatedAt?: string | null
}
@@ -38,20 +39,24 @@ export interface SavePalletPayload {
remark?: string | null
}
export interface UpdatePalletPayload extends SavePalletPayload {
version: number
}
export async function listCustomerWarehouses() {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<CustomerWarehouseDto>>, ApiEnvelope<OverseasPageResult<CustomerWarehouseDto>>>(
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<CustomerWarehouseDto>>, ApiEnvelope<OverseasPagePayload<CustomerWarehouseDto>>>(
'/platform/v1/warehouse-admin',
{ params: { page: 1, size: 200 } },
)
return unwrapApiResponse(response)
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function listPallets(warehouseId: string, query: { page: number; size: number; keyword?: string; palletType?: string; status?: string }) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<PalletDto>>, ApiEnvelope<OverseasPageResult<PalletDto>>>(
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<PalletDto>>, ApiEnvelope<OverseasPagePayload<PalletDto>>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`,
{ params: { ...query } },
)
return unwrapApiResponse(response)
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function getPallet(warehouseId: string, palletId: string) {
@@ -69,18 +74,10 @@ export async function createPallet(warehouseId: string, payload: SavePalletPaylo
return unwrapApiResponse(response)
}
export async function updatePallet(warehouseId: string, palletId: string, payload: SavePalletPayload) {
export async function updatePallet(warehouseId: string, palletId: string, payload: UpdatePalletPayload) {
const response = await overseasHttp.put<ApiEnvelope<null>, ApiEnvelope<null>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`,
payload,
)
return unwrapApiResponse(response)
}
export async function deletePallet(warehouseId: string, palletId: string) {
const response = await overseasHttp.delete<ApiEnvelope<null>, ApiEnvelope<null>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`,
{},
)
return unwrapApiResponse(response)
}
@@ -1,11 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders } from './relabelOrders'
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions } from './relabelOrders'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
describe('relabel overseas API', () => {
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
beforeEach(() => {
vi.mocked(overseasHttp.get).mockReset()
vi.mocked(overseasHttp.post).mockReset()
vi.mocked(overseasHttp.put).mockReset()
})
it('uses the platform page endpoint without customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
@@ -23,9 +27,17 @@ describe('relabel overseas API', () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: {} })
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: {} })
const payload = { customerOrderNo: 'CO-1', overseasWarehouseId: '9', labelDeadline: '2026-08-20T10:00:00', contactName: 'A', contactPhone: '1', idempotencyKey: 'k', items: [{ skuCode: 'SKU', productNameZh: 'P', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'S', newSkuCode: 'SKU-2' }] }
await createRelabelOrder(payload)
await changeRelabelOrderStatus('RL-1', 'PROCESSING', 'ok')
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', payload)
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications/RL-1/status', { status: 'PROCESSING', remark: 'ok' })
await createRelabelOrder('10001', payload)
await changeRelabelOrderStatus('RL/1', 'PROCESSING', 'ok')
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', payload, { params: { customerId: '10001' } })
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' },
})
})
})
@@ -1,6 +1,6 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OverseasPageResult } from './lastMileOrders'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface RelabelOrderQuery {
page: number
@@ -37,6 +37,15 @@ export interface CreateRelabelPayload {
items: CreateRelabelItemPayload[]
}
export interface RelabelWarehouseOptionDto {
id: string
customerId: string
warehouseCode: string
warehouseName: string
warehouseType: string
status: string
}
export interface RelabelSummaryDto {
id: string
relabelNo: string
@@ -83,10 +92,10 @@ export interface RelabelDetailDto {
}
export async function listRelabelOrders(query: RelabelOrderQuery) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<RelabelSummaryDto>>, ApiEnvelope<OverseasPageResult<RelabelSummaryDto>>>(
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>, ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>>(
'/platform/v1/last-leg/relabel-applications', { params: query },
)
return unwrapApiResponse(response)
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function getRelabelOrder(relabelNo: string) {
@@ -96,9 +105,17 @@ export async function getRelabelOrder(relabelNo: string) {
return unwrapApiResponse(response)
}
export async function createRelabelOrder(payload: CreateRelabelPayload) {
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 createRelabelOrder(customerId: string, payload: CreateRelabelPayload) {
const response = await overseasHttp.post<ApiEnvelope<RelabelDetailDto>, ApiEnvelope<RelabelDetailDto>>(
'/platform/v1/last-leg/relabel-applications', payload,
'/platform/v1/last-leg/relabel-applications', payload, { params: { customerId } },
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,52 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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 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() }))
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) } }
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
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(listPallets).mockReset().mockResolvedValue({ records: [pallet], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(getPallet).mockReset().mockResolvedValue(pallet)
vi.mocked(createPallet).mockReset()
vi.mocked(updatePallet).mockReset().mockResolvedValue(null)
})
it('loads the current detail version and keeps the edit open on a conflict', async () => {
vi.mocked(updatePallet).mockRejectedValueOnce(Object.assign(new Error('version conflict'), { code: 'A0409' }))
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '200', code: '', type: '', status: '' })
await flushPromises()
await wrapper.findAll('button.el-button--small').filter((button) => button.text().includes('编辑')).at(-1)!.trigger('click')
await flushPromises()
const dialog = wrapper.findComponent(AppDialog)
dialog.vm.$emit('confirm')
await flushPromises()
expect(getPallet).toHaveBeenCalledWith('200', '9007199254740993')
expect(updatePallet).toHaveBeenCalledWith('200', '9007199254740993', expect.objectContaining({
palletCode: 'PLT-1', fbaWarehouseCode: 'ONT8', version: 7,
}))
expect(dialog.props('modelValue')).toBe(true)
expect(createPallet).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Pencil, Plus, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Eye, Pencil, Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
@@ -14,7 +14,7 @@ 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, deletePallet, getPallet, listCustomerWarehouses, listPallets, updatePallet, type CustomerWarehouseDto, type PalletDto } from '../api/pallets'
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet, type CustomerWarehouseDto, type PalletDto } from '../api/pallets'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { PalletListItem } from '../types'
@@ -26,7 +26,6 @@ const rows = ref<PalletListItem[]>([])
const loading = ref(false)
const contextLoading = ref(false)
const saving = ref(false)
const deletingId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const loadErrorKind = ref<'context' | 'list'>('context')
@@ -49,7 +48,7 @@ const columns: DataTableColumn[] = [
]
const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = reactive({ id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '' })
const form = reactive({ id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '', version: 0 })
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
@@ -93,14 +92,14 @@ function retryLoad() {
}
function openCreate() {
dialogMode.value = 'create'; Object.assign(form, { id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '' }); mutationError.value = null; dialogOpen.value = true
dialogMode.value = 'create'; Object.assign(form, { id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '', version: 0 }); mutationError.value = null; dialogOpen.value = true
}
async function openEdit(source: object) {
const row = source as PalletListItem; saving.value = true; mutationError.value = null
try {
const detail = await getPallet(row.warehouseId, row.id)
dialogMode.value = 'edit'; Object.assign(form, { id: String(detail.id), warehouseId: String(detail.warehouseId), fbaWarehouseCode: detail.fbaWarehouseCode, code: detail.palletCode, type: detail.palletType, volume: number(detail.capacityCbm), purchasePrice: number(detail.unitPrice), status: detail.status, remark: detail.remark || '' }); dialogOpen.value = true
dialogMode.value = 'edit'; Object.assign(form, { id: String(detail.id), warehouseId: String(detail.warehouseId), fbaWarehouseCode: detail.fbaWarehouseCode, code: detail.palletCode, type: detail.palletType, volume: number(detail.capacityCbm), purchasePrice: number(detail.unitPrice), status: detail.status, remark: detail.remark || '', version: detail.version }); dialogOpen.value = true
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘详情加载失败,请重试') }
finally { saving.value = false }
}
@@ -110,23 +109,13 @@ async function save() {
saving.value = true; mutationError.value = null
const payload = { palletCode: form.code.trim(), palletType: form.type, fbaWarehouseCode: form.fbaWarehouseCode, capacityCbm: form.volume, unitPrice: form.purchasePrice, status: form.status, locationId: null, remark: form.remark.trim() || null }
try {
if (dialogMode.value === 'edit') await updatePallet(form.warehouseId, form.id, payload)
if (dialogMode.value === 'edit') await updatePallet(form.warehouseId, form.id, { ...payload, version: form.version })
else await createPallet(form.warehouseId, payload)
dialogOpen.value = false; ElMessage.success(dialogMode.value === 'edit' ? '托盘已更新' : '托盘已新增'); await load()
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘保存失败,请重试') }
finally { saving.value = false }
}
async function remove(source: object) {
const row = source as PalletListItem
try { await ElMessageBox.confirm(`确认删除托盘 ${row.code}?`, '删除托盘', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }) }
catch { return }
deletingId.value = row.id; mutationError.value = null
try { await deletePallet(row.warehouseId, row.id); ElMessage.success('托盘已删除'); await load() }
catch (error) { mutationError.value = getApiErrorDetails(error, '托盘删除失败,请重试') }
finally { deletingId.value = '' }
}
function search(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as PalletListItem; void router.push({ name: 'overseas-pallet-detail', params: { palletId: row.id }, query: { warehouseId: row.warehouseId } }) }
onMounted(loadWarehouses)
@@ -137,8 +126,8 @@ onMounted(loadWarehouses)
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading || contextLoading" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="search" @reset="page = 1; rows = []; total = 0" /></template>
<template v-if="loadError" #state><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-pallets" row-key="id" :empty-text="filters.warehouseId ? '暂无托盘' : '请先选择仓库后查询'" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.palletEdit)" size="small" plain @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can(permission.palletDelete)" size="small" plain type="danger" :loading="deletingId === (row as PalletListItem).id" @click="remove(row)"><Trash2 :size="15" />删除</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.palletEdit)" size="small" plain @click="openEdit(row)"><Pencil :size="15" />编辑</el-button></template>
<template #pagination-left>共 {{ total }} 条托盘</template>
</DataTable></section>
</ListPageLayout>
@@ -0,0 +1,136 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listPlatformTenants } from '@/modules/iam'
import AppDialog from '@/shared/components/AppDialog.vue'
import {
changeRelabelOrderStatus,
createRelabelOrder,
listRelabelOrders,
listRelabelWarehouseOptions,
} from '../api/relabelOrders'
import RelabelOrdersView from './RelabelOrdersView.vue'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/relabelOrders', () => ({
changeRelabelOrderStatus: vi.fn(),
createRelabelOrder: vi.fn(),
listRelabelOrders: vi.fn(),
listRelabelWarehouseOptions: 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) } }
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}`)
return item
}
function select(wrapper: VueWrapper<any>, placeholder: string) {
const target = wrapper.findAllComponents({ name: 'ElSelect' }).find((candidate) => candidate.props('placeholder') === placeholder)
if (!target) throw new Error(`Missing select: ${placeholder}`)
return target
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('RelabelOrdersView', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listRelabelOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
vi.mocked(listRelabelWarehouseOptions).mockReset()
vi.mocked(createRelabelOrder).mockReset()
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)
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('新增换标单'))!.trigger('click')
await flushPromises()
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(labels).toContain('B仓 · B')
expect(labels).not.toContain('A仓 · A')
})
it('reuses one idempotency key after a failed create retry', async () => {
vi.mocked(listRelabelWarehouseOptions).mockResolvedValue({ records: [{ id: 'WH-1', customerId: '10001', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(createRelabelOrder).mockRejectedValueOnce(new Error('temporary')).mockResolvedValueOnce({ application: { id: '1', relabelNo: 'RL-1' }, items: [], history: [] })
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('新增换标单'))!.trigger('click')
await flushPromises()
const customer = select(wrapper, '请选择 ACTIVE 客户')
customer.vm.$emit('update:modelValue', '10001'); customer.vm.$emit('change', '10001')
await flushPromises()
const warehouse = select(wrapper, '请先选择客户,再选择其海外仓')
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')
formItem(wrapper, '中文品名').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '商品一')
formItem(wrapper, '箱数').findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
formItem(wrapper, '截止时间').findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-30T10:00:00')
formItem(wrapper, '联系人').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '张三')
formItem(wrapper, '联系电话').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '13800000000')
formItem(wrapper, '贴标要求').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '换SKU标')
formItem(wrapper, '货件号').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SHIP-1')
formItem(wrapper, '新SKU').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SKU-2')
await flushPromises()
const dialog = wrapper.findComponent(AppDialog)
dialog.vm.$emit('confirm')
await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
dialog.vm.$emit('confirm')
await flushPromises()
expect(createRelabelOrder).toHaveBeenCalledTimes(2)
const firstKey = vi.mocked(createRelabelOrder).mock.calls[0]![1].idempotencyKey
const retryKey = vi.mocked(createRelabelOrder).mock.calls[1]![1].idempotencyKey
expect(firstKey).toBeTruthy()
expect(retryKey).toBe(firstKey)
expect(createRelabelOrder).toHaveBeenLastCalledWith('10001', expect.objectContaining({
customerOrderNo: 'CO-1', overseasWarehouseId: 'WH-1', idempotencyKey: firstKey,
items: [expect.objectContaining({ skuCode: 'SKU-1', newSkuCode: 'SKU-2', labelRequirement: '换SKU标' })],
}))
})
it('offers only backend-allowed audit transitions', async () => {
vi.mocked(listRelabelOrders).mockResolvedValueOnce({
records: [{ id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', skuCodes: 'SKU-1', totalPackageCount: 2, status: 'PENDING_PROCESSING' }],
page: 1, size: 10, 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')
await flushPromises()
const labels = formItem(wrapper, '目标状态').findAllComponents({ name: 'ElOption' }).map((option) => option.props('label'))
expect(labels).toEqual(expect.arrayContaining(['处理中', '已驳回', '已取消']))
expect(labels).not.toContain('已完成')
})
})
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Eye, Pencil, Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -12,8 +13,7 @@ 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 AppDialog from '@/shared/components/AppDialog.vue'
import { changeRelabelOrderStatus, createRelabelOrder, listRelabelOrders } from '../api/relabelOrders'
import { listCustomerWarehouses, type CustomerWarehouseDto } from '../api/pallets'
import { changeRelabelOrderStatus, createRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type RelabelWarehouseOptionDto } from '../api/relabelOrders'
import { overseasWarehousePermissions as permission } from '../permissions'
import { formatOverseasDateTime } from '../lastMilePresentation'
import type { RelabelOrderListItem } from '../types'
@@ -23,7 +23,11 @@ const router = useRouter()
const rows = ref<RelabelOrderListItem[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const warehouses = ref<CustomerWarehouseDto[]>([])
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)
@@ -46,35 +50,90 @@ const columns: DataTableColumn[] = [
]
const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = reactive({ orderNo: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
const submitting = ref(false)
const editSourceStatus = ref('')
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
const createIdempotencyKey = ref('')
const allowedStatusOptions = computed(() => {
if (editSourceStatus.value === 'PENDING_PROCESSING') return ['PROCESSING', 'REJECTED', 'CANCELLED']
if (editSourceStatus.value === 'PROCESSING') return ['COMPLETED', 'REJECTED', 'CANCELLED']
return []
})
function optionalNumber(value: unknown) { const number = Number(value); return value === '' || value == null || !Number.isFinite(number) ? undefined : number }
function openForm(mode: 'create' | 'edit', source?: object) {
const row = source as RelabelOrderListItem | undefined
dialogMode.value = mode
Object.assign(form, { orderNo: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
if (mode === 'edit' && row) Object.assign(form, { orderNo: String(row.orderNo || ''), relatedOrderNo: String(row.relatedOrderNo || ''), sku: String(row.sku || ''), productName: String(row.productName || ''), cartons: optionalNumber(row.cartons), budgetTotal: optionalNumber(row.budgetTotal), status: relabelStatusCode(row.status), labelRequirement: String(row.labelRequirement || ''), relabelCost: optionalNumber(row.relabelCost), remark: String(row.remark || '') })
if (mode === 'create') void loadWarehouses()
Object.assign(form, { orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
editSourceStatus.value = ''
if (mode === 'edit' && row) {
editSourceStatus.value = relabelStatusCode(row.status)
Object.assign(form, { orderNo: String(row.orderNo || ''), relatedOrderNo: String(row.relatedOrderNo || ''), sku: String(row.sku || ''), productName: String(row.productName || ''), cartons: optionalNumber(row.cartons), budgetTotal: optionalNumber(row.budgetTotal), status: '', labelRequirement: String(row.labelRequirement || ''), relabelCost: optionalNumber(row.relabelCost), remark: String(row.remark || '') })
}
if (mode === 'create') {
createIdempotencyKey.value = globalThis.crypto?.randomUUID?.() || `admin-relabel-${Date.now()}`
warehouses.value = []
void loadCustomers()
}
dialogOpen.value = true
}
async function loadWarehouses() {
try { warehouses.value = (await listCustomerWarehouses()).records }
catch (error) { ElMessage.error(getApiErrorDetails(error, '海外仓列表加载失败,请重试').message) }
async function loadCustomers() {
customersLoading.value = true
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
records.push(...result.records.filter((item) => item.status === 'ACTIVE'))
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} catch (error) { customers.value = []; ElMessage.error(getApiErrorDetails(error, '客户列表加载失败,请重试').message) }
finally { customersLoading.value = false }
}
async function handleCustomerChange(customerId: string) {
const requestSequence = ++warehouseRequestSequence.value
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
} catch (error) {
if (requestSequence === warehouseRequestSequence.value) ElMessage.error(getApiErrorDetails(error, '海外仓列表加载失败,请重试').message)
} finally {
if (requestSequence === warehouseRequestSequence.value) warehousesLoading.value = false
}
}
async function submitForm() {
if (submitting.value) return
try {
if (dialogMode.value === 'create') {
await createRelabelOrder({ customerOrderNo: form.relatedOrderNo, overseasWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName, contactPhone: form.contactPhone, remark: form.remark || undefined, idempotencyKey: `admin-${Date.now()}`, items: [{ skuCode: form.sku, productNameZh: form.productName, totalPackageCount: Number(form.cartons || 0), notifiedPackageCount: Number(form.cartons || 0), shipmentNo: form.shipmentNo, newSkuCode: form.newSkuCode || form.sku, labelRequirement: form.labelRequirement }] })
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
}
submitting.value = true
await createRelabelOrder(form.customerId, { customerOrderNo: form.relatedOrderNo.trim(), overseasWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName.trim(), contactPhone: form.contactPhone.trim(), remark: form.remark.trim() || undefined, idempotencyKey: createIdempotencyKey.value, items: [{ skuCode: form.sku.trim(), productNameZh: form.productName.trim(), totalPackageCount: Number(form.cartons), notifiedPackageCount: Number(form.cartons), shipmentNo: form.shipmentNo.trim(), newSkuCode: form.newSkuCode.trim(), labelRequirement: form.labelRequirement }] })
ElMessage.success('换标单已创建')
} else {
if (!allowedStatusOptions.value.includes(form.status)) { ElMessage.warning('当前状态没有可提交的审核流转'); return }
submitting.value = true
await changeRelabelOrderStatus(form.orderNo, form.status, form.remark || undefined)
ElMessage.success('状态已更新')
}
dialogOpen.value = false
createIdempotencyKey.value = ''
await load()
} catch (error) { ElMessage.error(getApiErrorDetails(error, '操作失败,请重试').message) }
finally { submitting.value = false }
}
function openDetail(source: object) {
@@ -90,6 +149,10 @@ function relabelStatusCode(value: unknown) {
const codes: Record<string, string> = { 待处理: 'PENDING_PROCESSING', 处理中: 'PROCESSING', 已完成: 'COMPLETED', 已驳回: 'REJECTED', 已取消: 'CANCELLED' }
return codes[String(value || '')] || String(value || 'PENDING_PROCESSING')
}
function canAudit(source: object) {
const row = source as RelabelOrderListItem
return ['PENDING_PROCESSING', 'PROCESSING'].includes(relabelStatusCode(row.status))
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
loading.value = true; loadError.value = null
@@ -107,6 +170,7 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load)
watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</script>
<template>
@@ -115,27 +179,28 @@ onMounted(load)
<template v-if="loadError" #state><LoadErrorState title="换标单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="暂无换标单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderStatus)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderStatus) && canAudit(row)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
<template #pagination-left>共 {{ total }} 条换标单</template>
</DataTable></section>
</ListPageLayout>
<AppDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" @confirm="submitForm">
<AppDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting" @confirm="submitForm">
<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" placeholder="请输入关联订单号" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="海外仓" required><el-select v-model="form.warehouseId" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="item.warehouseName" :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" placeholder="请输入商品SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" placeholder="请输入中文品名" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :min="1" :precision="0" controls-position="right" /></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 :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>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系人" required><el-input v-model="form.contactName" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="状态"><el-select v-model="form.status"><el-option v-for="value in ['PROCESSING', 'COMPLETED', 'REJECTED', 'CANCELLED']" :key="value" :label="relabelStatus(value)" :value="value" /></el-select></el-form-item></el-col>
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="目标状态"><el-select v-model="form.status" placeholder="请选择目标状态"><el-option v-for="value in allowedStatusOptions" :key="value" :label="relabelStatus(value)" :value="value" /></el-select></el-form-item></el-col>
</el-row><h3>作业要求</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" placeholder="请选择贴标要求"><el-option v-for="value in ['全覆盖换标', '加贴新标', '移除原标+换标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" :disabled="dialogMode === 'edit'" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" :disabled="dialogMode === 'edit'" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :disabled="dialogMode === 'edit'" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="预算报价(USD)"><el-input-number v-model="form.relabelCost" :min="0" :precision="2" controls-position="right" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="预算报价(只读)"><el-input-number v-model="form.relabelCost" disabled :min="0" :precision="2" controls-position="right" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-col>
</el-row></el-form>
</AppDialog>
@@ -146,7 +146,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(inventoryList).not.toContain('请先选择客户后查询')
})
it('connects pallet CRUD after the overseas contract is available', () => {
it('connects only the prototype-defined pallet create and edit actions', () => {
const value = source('PalletsView.vue')
expect(value).toContain('<AppDialog')
expect(value).toContain('760px')
@@ -156,9 +156,10 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('placeholder="请选择海外仓"')
expect(value).toContain('!form.warehouseId')
expect(value).toContain('updatePallet')
expect(value).toContain('deletePallet')
expect(value).toContain('<el-button type="primary" @click="openCreate">')
expect(value).not.toContain('v-if="access.can(permission.palletCreate)"')
expect(value).not.toContain('deletePallet')
expect(value).toContain('v-if="access.can(permission.palletCreate)"')
expect(value).toContain('version: detail.version')
expect(value).toContain('{ ...payload, version: form.version }')
expect(value).toContain('v-model="form.fbaWarehouseCode"')
expect(value).toContain("warehouseType: 'FBA'")
expect(value).toContain('listAllPlatformWarehouses')