feat: complete overseas customer and PDA workflows

This commit is contained in:
2026-09-04 12:59:37 +08:00
parent a274d029bb
commit 54cb9ceade
60 changed files with 1598 additions and 277 deletions
+9 -9
View File
@@ -22,7 +22,7 @@
| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 |
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 |
| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;整柜转换固定为 `/convert-container`,锁定同一 `sourceContainerBookingId` 后每票独立生成计划,整批原子且幂等 |
| 入仓计划分页、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token;整柜转换固定为 `/convert-container`,锁定同一 `sourceContainerBookingId` 后每票独立生成计划,整批原子且幂等 |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除 |
@@ -30,10 +30,10 @@
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 |
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 |
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller |
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” |
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,查询、预排与执行边界见“海外仓全流程开发路由” |
| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
@@ -41,20 +41,20 @@
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`;平台维护拜访、附件上传、派送完成/状态 |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`;异常订单建档、关联解析、派送退回候选与标记 |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口要求 `merchant:cross-border:domestic-trucking` |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口要求 `merchant:cross-border:pallet-shipping`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 |
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java`、`config/LastlegRestClientProperties.java`;目标地址与内部服务令牌只来自配置,不在业务子域手工创建客户端 |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` |
| 测试 | `src/test/java/com/baiyue/lastleg/` |
| 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 |
## 四期/五期开发范围路由
## 海外仓全流程开发路由
- 四期范围止于尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购、本土卡车、本土散板以及排车查询/智能预排;不把出库、派送、异常退回或回访倒灌为四期验收项。
- 五期范围承接排车编辑/取消/发车、PDA 排车拣货与出库任务、一件代发出库、排车转派送、客户派送单、异常退回和客户回访。服务端正式入口为 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;海外 PDA 前端工作台尚未接入时只视为后端能力,不得声称设备页面可用。
- 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。
- 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。
- 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成 `WAIT_OUTBOUND` 出库单与派送单,但必须早于发车;PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。
- 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。
- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。
@@ -56,7 +56,7 @@ com.baiyue.lastleg.transfer.service
| 排车/派车 `dispatch-delivery` | `dispatch/`、`delivery/`、未来 `outbound/` | 46 | 20/0 | 主源码无入向 Java 类型边,但涉及明确排除的出库/派送范围,不作为首切片 |
| 换标/转运/废弃 `relabel-transfer-discard` | `relabel/`、`transfer/`、`discard/`,其 PDA 子包保持本域归属 | 61 | 31/0 | 直接使用尾程、库存和一件代发持久化模型;业务事务边界复杂 |
| 一件代发 `dropship` | `dropship/` | 12 | 4/11 | 与尾程消费及废弃库存双向耦合,位于主 SCC |
| 采购/卡车/散板 `procurement-trucking-looseboard` | `procurement/`、`trucking/`、`looseboard/` | 35 | 11/0 | 依赖尾程核心,采购另读取报价目录;仍由四期业务专项维护 |
| 采购/卡车/散板 `procurement-trucking-looseboard` | `procurement/`、`trucking/`、`looseboard/` | 35 | 11/0 | 依赖尾程核心,采购另读取报价目录;仍由海外仓业务业务专项维护 |
| 海外主数据 `overseas-master-data` | `masterdata/quote`、`exchange`、`vehicle` | 28 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 |
| 海外安全投影 `security-projection` | `security/controller`、`security/dto`、`security/service` | 6 | 1/0 | 独立于业务切片与主 SCC;唯一出向为内部 Controller 复用根 `InternalServiceTokenGuard`,无业务切片入向依赖 |
@@ -122,7 +122,7 @@ com.baiyue.lastleg.transfer.service
3. 海外主数据不在 main 或 main+test 的逻辑 SCC 内,车辆子域也没有 Entity/DTO/Mapper 跨切片泄漏。
4. 不包含跨服务 Client、RestClient 或 HTTP Gateway,不受同步依赖超时专项影响。
5. 车辆档案明确不保存派送履约历史;首切片不触碰 `dispatch/`、`delivery/`、出库或派送代码。
6. 当前业务面固定为平台车辆档案列表、详情、创建、编辑、状态和 CSV 导出,不需要补齐四期业务缺口。
6. 当前业务面固定为平台车辆档案列表、详情、创建、编辑、状态和 CSV 导出,不需要补齐海外仓业务业务缺口。
报价子域虽然也不在 SCC,但被采购服务通过 `PartnerWarehouseDirectory` 入向依赖,并包含 `DomesticPartnerWarehouseClient`,因此避让。月度汇率有 8 个生产类,也比车辆档案更大。
@@ -211,8 +211,8 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all -
- `loading`、`shipping`、`inboundplan` 及其他业务切片不得直接依赖 `security/controller|dto|service`;当前 Java 类型图零入向,Node 与 ArchUnit 双重锁定。
- 安全投影不进入主逻辑 SCC,唯一出向为内部 Controller 复用共享令牌守卫,不注册为 loading/shipping/inbound 的模型、Mapper 或应用服务。
## 12. 五期出库派送稳定边界
## 12. 海外仓出库与派送出库派送稳定边界
- `dispatch-delivery -> dropship` 是一件代发出库的稳定业务依赖:出库应用服务按客户范围读取一件代发托盘、箱唛和 SKU 库存,并在 PDA 出库事务中完成扣减。
- `tailleg-handoff -> dispatch-delivery` 是异常退回与客户回访的稳定业务依赖:异常单和回访应用服务按派送业务号读取派送单及操作记录,不引入反向兼容入口或跨服务共享模型。
- 两条边均属于同一 `lastleg-service` 部署单元内的五期正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。
- 两条边均属于同一 `lastleg-service` 部署单元内的海外仓出库与派送正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.inboundplan.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/inbound-plans")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_INBOUND_LIST + "')")
public class MerchantInboundPlanController {
@Resource private InboundPlanService service;
@GetMapping
public ApiResponse<PageResult<InboundPlanListResponse>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid InboundPlanRequests.Query query) {
return ApiResponse.success(service.page(customer(user), query));
}
@GetMapping("/{planNo}")
public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo) {
return ApiResponse.success(service.detailByPlanNo(customer(user), planNo));
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
}
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.looseboard.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/local-looseboard-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD + "')")
public class MerchantLocalLooseboardController {
@Resource private LocalLooseboardService service;
@PostMapping
public ApiResponse<LocalLooseboardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody LocalLooseboardRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request));
}
@GetMapping
public ApiResponse<PageResult<LocalLooseboardResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @Valid LocalLooseboardRequests.MerchantQuery query) {
return ApiResponse.success(service.page(customer(user), query));
}
@GetMapping("/{orderNo}")
public ApiResponse<LocalLooseboardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo) {
return ApiResponse.success(service.detail(customer(user), orderNo));
}
@PutMapping("/{orderNo}")
public ApiResponse<LocalLooseboardResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo, @Valid @RequestBody LocalLooseboardRequests.Update request) {
return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request));
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -65,6 +65,16 @@ public final class LocalLooseboardRequests {
LocalDate requestedFrom,
LocalDate requestedTo) { }
public record MerchantQuery(
@Min(1) long page,
@Min(1) @Max(200) long size,
String orderNo,
String routeKeyword,
String status,
String transportMode,
LocalDate requestedFrom,
LocalDate requestedTo) { }
public record ChangeStatus(
@NotNull @Min(0) Long version,
@NotBlank @Size(max = 32) String status,
@@ -118,6 +118,14 @@ public class LocalLooseboardService {
result.getSize(), result.getTotal(), result.getPages());
}
/** Customer-scoped query that ignores any client-supplied customer filter. */
public PageResult<LocalLooseboardResponses.Summary> page(String customerId,
LocalLooseboardRequests.MerchantQuery query) {
return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
query.orderNo(), query.routeKeyword(), query.status(), query.transportMode(),
query.requestedFrom(), query.requestedTo()));
}
public LocalLooseboardResponses.Detail detail(String customerId, String orderNo) {
LocalLooseboardOrder order = orders.findNo(customerId, orderNo);
if (order == null) throw notFound();
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
import com.baiyue.lastleg.procurement.service.OverseasProcurementService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -20,6 +22,7 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT + "')")
public class MerchantOverseasProcurementController {
@Resource private OverseasProcurementService service;
@@ -32,8 +35,8 @@ public class MerchantOverseasProcurementController {
@GetMapping
public ApiResponse<PageResult<OverseasProcurementResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @Valid OverseasProcurementRequests.Query query) {
return ApiResponse.success(service.page(customer(user), query));
@AuthenticationPrincipal AuthenticatedUser user, @Valid OverseasProcurementRequests.MerchantQuery query) {
return ApiResponse.success(service.page(customer(user), query.scoped()));
}
@GetMapping("/{orderNo}")
@@ -36,6 +36,17 @@ public final class OverseasProcurementRequests {
String productName, String status, String purchaseWarehouseId,
LocalDateTime createdFrom, LocalDateTime createdTo) {}
public record MerchantQuery(
@Min(1) long page, @Min(1) @Max(200) long size,
String orderNo, String purchaseCategory, String productName,
String status, String purchaseWarehouseId,
LocalDateTime createdFrom, LocalDateTime createdTo) {
public Query scoped() {
return new Query(page, size, orderNo, null, purchaseCategory, productName,
status, purchaseWarehouseId, createdFrom, createdTo);
}
}
public record Review(
@NotNull @Min(0) Long version,
@NotBlank String decision,
@@ -36,6 +36,10 @@ public final class OverseasPermissions {
public static final String CUSTOMER_VISIT_EDIT = "platform:overseas-warehouse:customer-visit:edit";
public static final String MERCHANT_DELIVERY_LIST = "merchant:warehouse:delivery:list";
public static final String MERCHANT_DELIVERY_CREATE = "merchant:warehouse:delivery:create";
public static final String MERCHANT_INBOUND_LIST = "merchant:warehouse:inbound:list";
public static final String MERCHANT_PROCUREMENT = "merchant:cross-border:procurement";
public static final String MERCHANT_TRUCKING = "merchant:cross-border:domestic-trucking";
public static final String MERCHANT_LOOSEBOARD = "merchant:cross-border:pallet-shipping";
public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list";
public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit";
public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review";
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
import com.baiyue.lastleg.trucking.service.UsTruckOrderService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
@@ -21,6 +23,7 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/us-truck-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING + "')")
public class MerchantUsTruckOrderController {
@Resource private UsTruckOrderService service;
@@ -32,8 +35,8 @@ public class MerchantUsTruckOrderController {
@GetMapping
public ApiResponse<PageResult<UsTruckOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid UsTruckOrderRequests.Query query) {
return ApiResponse.success(service.page(customer(user), query));
@Valid UsTruckOrderRequests.MerchantQuery query) {
return ApiResponse.success(service.page(customer(user), query.scoped()));
}
@GetMapping("/{orderNo}")
@@ -53,6 +53,16 @@ public final class UsTruckOrderRequests {
String orderNo, String routeKeyword, String status, String transportType,
String customerId, LocalDate requestedFrom, LocalDate requestedTo) {}
public record MerchantQuery(
@Min(1) long page, @Min(1) @Max(200) long size,
String orderNo, String routeKeyword, String status, String transportType,
LocalDate requestedFrom, LocalDate requestedTo) {
public Query scoped() {
return new Query(page, size, orderNo, routeKeyword, status, transportType,
null, requestedFrom, requestedTo);
}
}
public record Update(
@NotNull @Min(0) Long version,
@Size(max = 32) String transportType,
@@ -86,7 +86,7 @@ paths:
description: 权限 lastleg:pda:operate;FBA 从完整 cartonMark 的第一个 # 前提取固定入仓码,并精确匹配当前锁定柜唯一 FBA line;不要求逐箱 manifest,不校验 SKU 或箱序号。OVERSEAS 必须命中真实 manifest。两者均不返回 SKU,也不登记收货。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [cartonMark], properties: { cartonMark: { type: string } } } } } }
responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '404': { description: 入仓码未命中当前柜,或 OVERSEAS 箱码未命中真实 manifest }, '409': { description: 缺少 #、空入仓码前缀、跨柜或当前柜多重命中 } }
responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '404': { description: 入仓码未命中当前柜,或 OVERSEAS 箱码未命中真实 manifest }, '409': { description: '缺少 #、空入仓码前缀、跨柜或当前柜多重命中' } }
/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve:
post:
summary: 解析当前柜实物托盘码
@@ -714,20 +714,125 @@ paths:
/api/merchant/v1/last-leg/overseas-procurements:
post:
summary: 客户创建海外采购订单
description: 客户 ID 只取认证身份;新单固定进入 PENDING_REVIEW。
description: 权限 merchant:cross-border:procurement;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '409': { description: 幂等冲突 } }
get:
summary: 客户分页查询自己的海外采购订单
description: 权限 merchant:cross-border:procurement;客户范围只取认证 Token,不接收 customerId。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 统一 ApiResponse 包装的采购订单分页 } }
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: orderNo, in: query, schema: { type: string } }
- { name: purchaseCategory, in: query, schema: { type: string } }
- { name: productName, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
- { name: purchaseWarehouseId, in: query, schema: { type: string } }
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
responses: { '200': { description: 当前客户采购订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementPageResponse' } } } } }
/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:
get:
summary: 客户查询自己的海外采购订单详情及流水
description: 权限 merchant:cross-border:procurement;列表权限包含详情只读,客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
responses: { '200': { description: 当前客户采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
/api/merchant/v1/last-leg/us-truck-orders:
get:
summary: 客户分页查询自己的美国本土卡订单
description: 权限 merchant:cross-border:domestic-trucking;客户范围只取认证 Token,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: orderNo, in: query, schema: { type: string } }
- { name: routeKeyword, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/TruckStatus' } }
- { name: transportType, in: query, schema: { type: string } }
- { name: requestedFrom, in: query, schema: { type: string, format: date } }
- { name: requestedTo, in: query, schema: { type: string, format: date } }
responses: { '200': { description: 当前客户本土卡订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/TruckPageResponse' } } } } }
post:
summary: 客户创建美国本土卡订单
description: 权限 merchant:cross-border:domestic-trucking;新单固定进入 PENDING_REVIEW,客户不能调用平台审核或状态推进端点。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCreate' } } } }
responses: { '200': { description: 本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 幂等冲突 } }
/api/merchant/v1/last-leg/us-truck-orders/{orderNo}:
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
get:
summary: 客户查询自己的美国本土卡订单详情
description: 权限 merchant:cross-border:domestic-trucking;不泄露其他客户订单。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
patch:
summary: 客户编辑待审核的美国本土卡订单
description: 权限 merchant:cross-border:domestic-trucking;仅 PENDING_REVIEW 可编辑,version 必填;不能修改状态。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckUpdate' } } } }
responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 版本或状态冲突 } }
/api/merchant/v1/last-leg/local-looseboard-orders:
get:
summary: 客户分页查询自己的本土散板订单
description: 权限 merchant:cross-border:pallet-shipping;客户范围只取认证 Token,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: orderNo, in: query, schema: { type: string } }
- { name: routeKeyword, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/LooseboardStatus' } }
- { name: transportMode, in: query, schema: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } }
- { name: requestedFrom, in: query, schema: { type: string, format: date } }
- { name: requestedTo, in: query, schema: { type: string, format: date } }
responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } }
post:
summary: 客户创建本土散板订单
description: 权限 merchant:cross-border:pallet-shipping;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } }
responses: { '200': { description: 本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 幂等冲突 } }
/api/merchant/v1/last-leg/local-looseboard-orders/{orderNo}:
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
get:
summary: 客户查询自己的本土散板订单详情
description: 权限 merchant:cross-border:pallet-shipping;不泄露其他客户订单。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
put:
summary: 客户编辑待接单的本土散板订单
description: 权限 merchant:cross-border:pallet-shipping;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } }
responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } }
/api/merchant/v1/last-leg/inbound-plans:
get:
summary: 客户分页查询自己的海外入库订单
description: 权限 merchant:warehouse:inbound:list;客户范围只取认证 Token,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: planNo, in: query, schema: { type: string } }
- { name: inboundCode, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
- { name: overseasWarehouseId, in: query, schema: { type: string } }
- { name: destinationWarehouseCode, in: query, schema: { type: string } }
- { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED] } }
- { name: dispatchStatus, in: query, schema: { type: string, enum: [UNSCHEDULED, SCHEDULED] } }
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
responses: { '200': { description: 当前客户入库订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanPageResponse' } } } } }
/api/merchant/v1/last-leg/inbound-plans/{planNo}:
get:
summary: 客户查询自己的海外入库订单详情
description: 权限 merchant:warehouse:inbound:list;列表权限包含详情只读,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } }, '404': { description: 当前客户下入库订单不存在 } }
/api/platform/v1/last-leg/us-truck-orders:
get:
summary: 分页查询本土卡车订单
@@ -1570,6 +1675,325 @@ components:
properties:
status: { $ref: '#/components/schemas/RelabelStatus' }
remark: { type: string, nullable: true, maxLength: 1000 }
MerchantApiResponse:
type: object
required: [code, data]
properties:
code: { type: string, example: '200' }
message: { type: string, nullable: true }
data: { nullable: true }
requestId: { type: string, nullable: true }
traceId: { type: string, nullable: true }
timestamp: { type: string, format: date-time, nullable: true }
ProcurementPageResponse:
allOf:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/ProcurementPage' } }
ProcurementDetailResponse:
allOf:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/ProcurementDetail' } }
TruckPageResponse:
allOf:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/TruckPage' } }
TruckDetailResponse:
allOf:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/TruckDetail' } }
LooseboardPageResponse:
allOf:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/LooseboardPage' } }
LooseboardDetailResponse:
allOf:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/LooseboardDetail' } }
InboundPlanPageResponse:
allOf:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/InboundPlanPage' } }
InboundPlanDetailResponse:
allOf:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/InboundPlanDetail' } }
PageMetadata:
type: object
required: [page, size, total, pages]
properties:
page: { type: integer, format: int64 }
size: { type: integer, format: int64 }
total: { type: integer, format: int64 }
pages: { type: integer, format: int64 }
ProcurementPage:
allOf:
- { $ref: '#/components/schemas/PageMetadata' }
- type: object
required: [records]
properties: { records: { type: array, items: { $ref: '#/components/schemas/ProcurementSummary' } } }
ProcurementSummary:
type: object
required: [id, orderNo, customerId, purchaseCategory, productName, purchaseQuantity, inboundQuantity, outboundQuantity, inventoryQuantity, status, version, createdAt, updatedAt]
properties:
id: { type: string, pattern: '^[0-9]+$' }
orderNo: { type: string }
customerId: { type: string }
customerName: { type: string, nullable: true }
purchaseCategory: { type: string }
productName: { type: string }
productSpecification: { type: string, nullable: true }
purchaseQuantity: { type: integer, format: int64 }
budgetUnitPrice: { type: number, nullable: true }
currency: { type: string, nullable: true }
purchaseWarehouseId: { type: string, nullable: true }
purchaseWarehouseName: { type: string, nullable: true }
inboundQuantity: { type: integer, format: int64 }
outboundQuantity: { type: integer, format: int64 }
inventoryQuantity: { type: integer, format: int64 }
status: { type: string, description: PENDING_REVIEW/APPROVED/REJECTED 或按审核后出库数量派生的 PENDING_OUTBOUND/PARTIALLY_OUTBOUND/OUTBOUNDED }
version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
ProcurementDetail:
type: object
required: [summary, histories, outboundDetails]
properties:
summary: { $ref: '#/components/schemas/ProcurementSummary' }
productUrl: { type: string, nullable: true }
receivingAddress: { type: string, nullable: true }
contactName: { type: string, nullable: true }
contactPhone: { type: string, nullable: true }
domesticCarrier: { type: string, nullable: true }
domesticTrackingNo: { type: string, nullable: true }
overseasCarrier: { type: string, nullable: true }
overseasTrackingNo: { type: string, nullable: true }
estimatedArrivalAt: { type: string, format: date-time, nullable: true }
remark: { type: string, nullable: true }
createdBy: { type: string, nullable: true }
reviewedBy: { type: string, nullable: true }
reviewRemark: { type: string, nullable: true }
reviewedAt: { type: string, format: date-time, nullable: true }
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
outboundDetails: { type: array, items: { $ref: '#/components/schemas/ProcurementOutboundDetail' } }
ProcurementOutboundDetail:
type: object
properties:
id: { type: string }
quantity: { type: integer, format: int64 }
carrier: { type: string, nullable: true }
trackingNo: { type: string, nullable: true }
shippedAt: { type: string, format: date-time, nullable: true }
remark: { type: string, nullable: true }
TruckPage:
allOf:
- { $ref: '#/components/schemas/PageMetadata' }
- type: object
required: [records]
properties: { records: { type: array, items: { $ref: '#/components/schemas/TruckSummary' } } }
TruckSummary:
type: object
required: [id, customerId, orderNo, origin, destination, palletCount, packageCount, receivedPalletCount, allocatedPalletCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt]
properties:
id: { type: string, pattern: '^[0-9]+$' }
customerId: { type: string }
orderNo: { type: string }
transportType: { type: string, nullable: true }
origin: { type: string }
destination: { type: string }
containerType: { type: string, nullable: true }
palletCount: { type: integer }
packageCount: { type: integer }
receivedPalletCount: { type: integer }
allocatedPalletCount: { type: integer }
totalWeightKg: { type: number }
totalVolumeCbm: { type: number }
budgetAmount: { type: number, nullable: true }
currency: { type: string, nullable: true }
contactName: { type: string, nullable: true }
contactInfo: { type: string, nullable: true }
requestedDate: { type: string, format: date, nullable: true }
status: { $ref: '#/components/schemas/TruckStatus' }
version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
TruckDetail:
type: object
required: [summary, pallets, histories]
properties:
summary: { $ref: '#/components/schemas/TruckSummary' }
originAddress: { type: string, nullable: true }
destinationAddress: { type: string, nullable: true }
remark: { type: string, nullable: true }
createdBy: { type: string, nullable: true }
pallets: { type: array, items: { $ref: '#/components/schemas/TruckPallet' } }
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
TruckPallet:
type: object
properties:
id: { type: string, pattern: '^[0-9]+$' }
palletNo: { type: string }
sequenceNo: { type: integer }
source: { type: string }
status: { type: string }
warehousePalletId: { type: string, pattern: '^[0-9]+$', nullable: true }
packageCount: { type: integer, nullable: true }
receivedAt: { type: string, format: date-time, nullable: true }
LooseboardPage:
allOf:
- { $ref: '#/components/schemas/PageMetadata' }
- type: object
required: [records]
properties: { records: { type: array, items: { $ref: '#/components/schemas/LooseboardSummary' } } }
LooseboardSummary:
type: object
required: [id, customerId, orderNo, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt]
properties:
id: { type: string, pattern: '^[0-9]+$' }
customerId: { type: string }
orderNo: { type: string }
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
palletRequired: { type: string, enum: [YES, NO] }
transportType: { type: string }
origin: { type: string }
destination: { type: string }
containerType: { type: string, nullable: true }
packageCount: { type: integer }
totalWeightKg: { type: number }
totalVolumeCbm: { type: number }
budgetAmount: { type: number, nullable: true }
currency: { type: string, nullable: true }
contactName: { type: string, nullable: true }
contactInfo: { type: string, nullable: true }
requestedDate: { type: string, format: date, nullable: true }
status: { $ref: '#/components/schemas/LooseboardStatus' }
version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
LooseboardDetail:
type: object
required: [summary, histories]
properties:
summary: { $ref: '#/components/schemas/LooseboardSummary' }
originAddress: { type: string, nullable: true }
destinationAddress: { type: string, nullable: true }
remark: { type: string, nullable: true }
createdBy: { type: string, nullable: true }
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
BusinessHistory:
type: object
properties:
operationType: { type: string }
fromStatus: { type: string, nullable: true }
toStatus: { type: string, nullable: true }
operatorId: { type: string, nullable: true }
idempotencyKey: { type: string, nullable: true }
changeDetail: { type: string, nullable: true }
remark: { type: string, nullable: true }
createdAt: { type: string, format: date-time }
InboundPlanPage:
allOf:
- { $ref: '#/components/schemas/PageMetadata' }
- type: object
required: [records]
properties: { records: { type: array, items: { $ref: '#/components/schemas/InboundPlanSummary' } } }
InboundPlanSummary:
type: object
required: [lineId, planId, planNo, customerId, orderId, inboundCode, packageCount, pickedPackageCount, palletCount, status, createdAt, version]
properties:
lineId: { type: string, pattern: '^[0-9]+$' }
planId: { type: string, pattern: '^[0-9]+$' }
planNo: { type: string }
customerId: { type: string }
orderId: { type: string, pattern: '^[0-9]+$' }
inboundCode: { type: string }
orderType: { type: string, nullable: true }
warehouseId: { type: string, nullable: true }
warehouseCode: { type: string, nullable: true }
warehouseName: { type: string, nullable: true }
fbaWarehouseCode: { type: string, nullable: true }
customerName: { type: string, nullable: true }
packageCount: { type: integer }
pickedPackageCount: { type: integer }
volumeCbm: { type: number, nullable: true }
palletCount: { type: integer }
status: { type: string }
appointmentNo: { type: string, nullable: true }
appointmentStatus: { type: string, nullable: true }
appointmentAt: { type: string, format: date-time, nullable: true }
expectedLoadingAt: { type: string, format: date-time, nullable: true }
dispatchStatus: { type: string, nullable: true }
createdAt: { type: string, format: date-time }
version: { type: integer }
InboundPlanDetail:
type: object
required: [id, planNo, customerId, status, lineCount, palletCount, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version]
properties:
id: { type: string, pattern: '^[0-9]+$' }
planNo: { type: string }
customerId: { type: string }
sourceOrderId: { type: string, pattern: '^[0-9]+$', nullable: true }
containerNo: { type: string, nullable: true }
status: { type: string }
lineCount: { type: integer }
palletCount: { type: integer }
totalVolumeCbm: { type: number }
expectedPackageCount: { type: integer }
pickedPackageCount: { type: integer }
lines: { type: array, items: { $ref: '#/components/schemas/InboundPlanLine' } }
pallets: { type: array, items: { $ref: '#/components/schemas/InboundPlanPallet' } }
allocations: { type: array, items: { $ref: '#/components/schemas/InboundPlanAllocation' } }
version: { type: integer }
InboundPlanLine:
type: object
properties:
id: { type: string, pattern: '^[0-9]+$' }
orderId: { type: string, pattern: '^[0-9]+$' }
inboundCode: { type: string }
fbaWarehouseCode: { type: string, nullable: true }
destinationWarehouseId: { type: string, nullable: true }
destinationWarehouseCode: { type: string, nullable: true }
destinationWarehouseName: { type: string, nullable: true }
volumeCbm: { type: number }
requiredPackageCount: { type: integer }
plannedPalletCount: { type: integer }
InboundPlanPallet:
type: object
properties:
id: { type: string, pattern: '^[0-9]+$' }
palletNo: { type: string }
destinationWarehouseId: { type: string, nullable: true }
destinationWarehouseCode: { type: string, nullable: true }
destinationWarehouseName: { type: string, nullable: true }
capacityCbm: { type: number }
allocatedVolumeCbm: { type: number }
sequenceNo: { type: integer }
warehousePalletId: { type: string, pattern: '^[0-9]+$', nullable: true }
warehouseLocationId: { type: string, pattern: '^[0-9]+$', nullable: true }
warehouseResourceLockId: { type: string, pattern: '^[0-9]+$', nullable: true }
assignmentStatus: { type: string, nullable: true }
lockedAt: { type: string, format: date-time, nullable: true }
actualVolumeCbm: { type: number, nullable: true }
actualPackageCount: { type: integer }
InboundPlanAllocation:
type: object
properties:
lineId: { type: string, pattern: '^[0-9]+$' }
palletId: { type: string, pattern: '^[0-9]+$' }
allocatedVolumeCbm: { type: number }
plannedPackageCount: { type: integer }
countedPackageCount: { type: integer }
pickedPackageCount: { type: integer }
actualVolumeCbm: { type: number, nullable: true }
status: { type: string }
version: { type: integer }
ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] }
ProcurementReview:
type: object
@@ -75,33 +75,70 @@ paths:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
- { name: status, in: query, schema: { type: string, enum: [IN_PROGRESS, SUBMITTED] } }
responses: { '200': { description: 盘点任务分页 } }
responses:
'200': { description: 盘点任务分页, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeSummaryPageResponse' } } } }
post:
summary: 按库位或托盘创建盘点任务
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCreate' } } } }
responses: { '200': { description: 创建后的完整盘点任务 } }
responses:
'200': { description: 创建后的完整盘点任务, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeDetailResponse' } } } }
/api/pda/v1/last-leg/stocktakes/{taskNo}:
get:
summary: 查询盘点任务详情
parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 盘点任务及冻结库存位置 } }
responses:
'200': { description: 盘点任务及冻结库存位置, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeDetailResponse' } } } }
/api/pda/v1/last-leg/stocktakes/{taskNo}/counts:
post:
summary: 提交一个库存位置的实盘箱数
parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCount' } } } }
responses: { '200': { description: 已计数明细 }, '409': { description: 版本或幂等冲突 } }
responses:
'200': { description: 已计数明细, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeItemResponse' } } } }
'409': { description: 版本或幂等冲突 }
/api/pda/v1/last-leg/stocktakes/{taskNo}/submit:
post:
summary: 提交盘点并按差异调整库存
parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeSubmit' } } } }
responses: { '200': { description: 已提交盘点任务 }, '409': { description: 未全盘、库存版本变化或负库存风险 } }
responses:
'200': { description: 已提交盘点任务, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeDetailResponse' } } } }
'409': { description: 未全盘、库存版本变化或负库存风险 }
/api/pda/v1/last-leg/inventory-moves:
post:
summary: 同仓整托移库
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMove' } } } }
responses: { '200': { description: 移库结果 }, '409': { description: 跨仓、资源或库存版本冲突 } }
responses:
'200': { description: 移库结果, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMoveResponse' } } } }
'409': { description: 跨仓、资源或库存版本冲突 }
/api/pda/v1/last-leg/inventory/resolve:
post:
summary: 扫码查询Token绑定仓库存
description: 支持箱唛、SKU、入仓码、托盘码或库位码;不接收customerId或warehouseId。
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } }
responses:
'200': { description: 匹配的库存位置列表, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryScanResponse' } } } }
/api/pda/v1/last-leg/locations/resolve:
post:
summary: 扫码查询Token绑定仓库位
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } }
responses:
'200': { description: 库位及占用摘要, content: { application/json: { schema: { $ref: '#/components/schemas/LocationResponse' } } } }
/api/pda/v1/last-leg/pallets/resolve:
post:
summary: 扫码查询Token绑定仓托盘
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } }
responses:
'200': { description: 托盘及其库存内容, content: { application/json: { schema: { $ref: '#/components/schemas/PalletResponse' } } } }
/api/pda/v1/last-leg/operations:
get:
summary: 查询当前操作人的仓内操作历史
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: operationType, in: query, required: false, schema: { type: string } }
responses:
'200': { description: 当前Token绑定仓及操作人的历史分页, content: { application/json: { schema: { $ref: '#/components/schemas/OperationPageResponse' } } } }
/api/pda/v1/last-leg/pallet-operations/pallets/resolve:
post:
summary: 扫码查询托盘及库存内容
@@ -130,58 +167,355 @@ paths:
/api/pda/v1/last-leg/relabel-tasks/candidates:
get:
summary: 查询Token绑定仓可执行换标任务
responses: { '200': { description: 待认领、处理中或待复核换标任务 } }
responses:
'200': { description: 待认领、处理中或待复核换标任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCandidatesResponse' } } } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}:
get:
summary: 查询换标现场任务详情
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 申请明细、逐件事实、照片与历史 } }
responses:
'200': { description: 申请明细、逐件事实、照片与历史, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/claim:
post:
summary: 认领换标任务并推进申请至处理中
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } }
responses: { '200': { description: 已认领任务 }, '409': { description: 仓库、状态或幂等冲突 } }
responses: { '200': { description: 已认领任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 仓库、状态或幂等冲突 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units:
post:
summary: 登记一件正常换标事实
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } }
responses: { '200': { description: 更新后的任务 }, '409': { description: 旧标重复、新标不符或版本冲突 } }
responses: { '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 旧标重复、新标不符或版本冲突 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip:
post:
summary: 登记一件有原因的跳过事实
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelSkip' } } } }
responses: { '200': { description: 更新后的任务 } }
responses: { '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions:
post:
summary: 登记一件换标异常事实
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelException' } } } }
responses: { '200': { description: 更新后的任务 } }
responses: { '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files:
post:
summary: 上传换标完成现场照片
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
responses: { '200': { description: OVERSEAS_RELABEL_PHOTO文件元数据 } }
responses: { '200': { description: OVERSEAS_RELABEL_PHOTO文件元数据, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPhotoResponse' } } } } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:
post:
summary: 提交现场任务等待复核,不提前完成申请
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } }
responses: { '200': { description: WAIT_REVIEW任务 }, '409': { description: 箱数未核平、照片或版本冲突 } }
responses: { '200': { description: WAIT_REVIEW任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 箱数未核平、照片或版本冲突 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:
post:
summary: 现场复核;APPROVE才完成既有换标申请,REWORK退回处理
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelReview' } } } }
responses: { '200': { description: COMPLETED或PROCESSING任务 }, '409': { description: 状态或版本冲突 } }
responses: { '200': { description: COMPLETED或PROCESSING任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 状态或版本冲突 } }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
schemas:
ApiResponse:
type: object
required: [code, data]
properties:
code: { type: string, example: '200' }
message: { type: string, nullable: true }
data: { nullable: true }
requestId: { type: string, nullable: true }
traceId: { type: string, nullable: true }
timestamp: { type: string, format: date-time, nullable: true }
StocktakeSummaryPageResponse:
allOf:
- { $ref: '#/components/schemas/ApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/StocktakeSummaryPage' } }
StocktakeDetailResponse:
allOf:
- { $ref: '#/components/schemas/ApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/StocktakeDetail' } }
StocktakeItemResponse:
allOf:
- { $ref: '#/components/schemas/ApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/StocktakeItem' } }
InventoryMoveResponse:
allOf:
- { $ref: '#/components/schemas/ApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/InventoryMoveResult' } }
InventoryScanResponse:
allOf:
- { $ref: '#/components/schemas/ApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/InventoryScan' } }
LocationResponse:
allOf:
- { $ref: '#/components/schemas/ApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/WarehouseLocation' } }
PalletResponse:
allOf:
- { $ref: '#/components/schemas/ApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/WarehousePallet' } }
OperationPageResponse:
allOf:
- { $ref: '#/components/schemas/ApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/OperationPage' } }
RelabelCandidatesResponse:
allOf:
- { $ref: '#/components/schemas/ApiResponse' }
- type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/RelabelCandidate' } }
RelabelDetailResponse:
allOf:
- { $ref: '#/components/schemas/ApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/RelabelDetail' } }
RelabelPhotoResponse:
allOf:
- { $ref: '#/components/schemas/ApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/RelabelPhoto' } }
StocktakeSummaryPage:
type: object
required: [records, page, size, total, pages]
properties:
records: { type: array, items: { $ref: '#/components/schemas/StocktakeSummary' } }
page: { type: integer, format: int64 }
size: { type: integer, format: int64 }
total: { type: integer, format: int64 }
pages: { type: integer, format: int64 }
StocktakeSummary:
type: object
required: [taskNo, scopeType, scopeCode, status, positionCount, countedCount, differenceCount, version, createdAt]
properties:
taskNo: { type: string }
scopeType: { type: string, enum: [LOCATION, PALLET] }
scopeCode: { type: string }
status: { type: string, enum: [IN_PROGRESS, SUBMITTED] }
positionCount: { type: integer }
countedCount: { type: integer }
differenceCount: { type: integer }
version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time }
submittedAt: { type: string, format: date-time, nullable: true }
StocktakeItem:
type: object
required: [itemId, customerId, balanceId, positionId, skuCode, inboundCode, palletId, palletCode, locationId, locationCode, bookPackages, status, positionVersion, version]
properties:
itemId: { type: string, pattern: '^[0-9]+$' }
customerId: { type: string }
balanceId: { type: string, pattern: '^[0-9]+$' }
positionId: { type: string, pattern: '^[0-9]+$' }
skuCode: { type: string }
productName: { type: string, nullable: true }
inboundCode: { type: string }
palletId: { type: string, pattern: '^[0-9]+$' }
palletCode: { type: string }
locationId: { type: string, pattern: '^[0-9]+$' }
locationCode: { type: string }
bookPackages: { type: integer, format: int64 }
actualPackages: { type: integer, format: int64, nullable: true }
differencePackages: { type: integer, format: int64, nullable: true }
status: { type: string }
positionVersion: { type: integer, format: int64 }
version: { type: integer, format: int64 }
StocktakeDetail:
type: object
required: [task, items]
properties:
task: { $ref: '#/components/schemas/StocktakeSummary' }
items: { type: array, items: { $ref: '#/components/schemas/StocktakeItem' } }
InventoryMoveResult:
type: object
required: [moveNo, palletId, palletCode, sourceLocationId, sourceLocationCode, targetLocationId, targetLocationCode, customerCount, positionCount, packageCount, palletVersion, status, completedAt, replayed]
properties:
moveNo: { type: string }
palletId: { type: string, pattern: '^[0-9]+$' }
palletCode: { type: string }
sourceLocationId: { type: string, pattern: '^[0-9]+$' }
sourceLocationCode: { type: string }
targetLocationId: { type: string, pattern: '^[0-9]+$' }
targetLocationCode: { type: string }
customerCount: { type: integer }
positionCount: { type: integer }
packageCount: { type: integer, format: int64 }
palletVersion: { type: integer }
status: { type: string }
completedAt: { type: string, format: date-time }
replayed: { type: boolean }
InventoryScan:
type: object
required: [queryCode, items]
properties:
queryCode: { type: string }
items: { type: array, items: { $ref: '#/components/schemas/InventoryItem' } }
InventoryItem:
type: object
required: [customerId, balanceId, positionId, skuCode, inboundCode, palletId, palletCode, availablePackages, holdPackages, receivingPackages, status, version]
properties:
customerId: { type: string }
customerName: { type: string, nullable: true }
balanceId: { type: string, pattern: '^[0-9]+$' }
positionId: { type: string, pattern: '^[0-9]+$' }
skuCode: { type: string }
productName: { type: string, nullable: true }
inboundCode: { type: string }
cartonMark: { type: string, nullable: true }
palletId: { type: string, pattern: '^[0-9]+$' }
palletCode: { type: string }
locationId: { type: string, pattern: '^[0-9]+$', nullable: true }
locationCode: { type: string, nullable: true }
availablePackages: { type: integer, format: int64 }
holdPackages: { type: integer, format: int64 }
receivingPackages: { type: integer, format: int64 }
status: { type: string }
version: { type: integer, format: int64 }
WarehouseLocation:
type: object
required: [locationId, locationCode, locationType, status, occupiedPackages, inventoryPositionCount]
properties:
locationId: { type: string, pattern: '^[0-9]+$' }
locationCode: { type: string }
locationType: { type: string }
maxLoadKg: { type: number, nullable: true }
status: { type: string }
occupiedPackages: { type: integer, format: int64 }
inventoryPositionCount: { type: integer }
WarehousePallet:
type: object
required: [palletId, palletCode, status, version, locationId, locationCode, occupiedPackages, inventoryPositionCount, inventory]
properties:
palletId: { type: string, pattern: '^[0-9]+$' }
palletCode: { type: string }
status: { type: string }
version: { type: integer }
locationId: { type: string, pattern: '^[0-9]+$' }
locationCode: { type: string }
occupiedPackages: { type: integer, format: int64 }
inventoryPositionCount: { type: integer }
inventory: { type: array, items: { $ref: '#/components/schemas/InventoryItem' } }
OperationPage:
type: object
required: [records, page, size, total, pages]
properties:
records: { type: array, items: { $ref: '#/components/schemas/WarehouseOperation' } }
page: { type: integer, format: int64 }
size: { type: integer, format: int64 }
total: { type: integer, format: int64 }
pages: { type: integer, format: int64 }
WarehouseOperation:
type: object
required: [operationId, operationType, businessKey, status, occurredAt]
properties:
operationId: { type: string, pattern: '^[0-9]+$' }
operationType: { type: string }
businessKey: { type: string }
status: { type: string }
summary: { type: string, nullable: true }
customerId: { type: string, nullable: true }
occurredAt: { type: string, format: date-time }
RelabelCandidate:
type: object
required: [relabelNo, applicationStatus, taskStatus, itemCount, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, claimedByCurrentOperator]
properties:
relabelNo: { type: string }
customerOrderNo: { type: string, nullable: true }
labelDeadline: { type: string, format: date-time, nullable: true }
applicationStatus: { type: string }
taskStatus: { type: string }
itemCount: { type: integer }
expectedPackages: { type: integer }
relabelledPackages: { type: integer }
skippedPackages: { type: integer }
exceptionPackages: { type: integer }
version: { type: integer }
claimedByCurrentOperator: { type: boolean }
RelabelDetail:
type: object
required: [relabelNo, applicationStatus, taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, completionPhotoFileIds, items, unitFacts, history]
properties:
relabelNo: { type: string }
customerOrderNo: { type: string, nullable: true }
labelDeadline: { type: string, format: date-time, nullable: true }
contactName: { type: string, nullable: true }
contactPhone: { type: string, nullable: true }
applicationStatus: { type: string }
taskStatus: { type: string }
expectedPackages: { type: integer }
relabelledPackages: { type: integer }
skippedPackages: { type: integer }
exceptionPackages: { type: integer }
version: { type: integer }
operatorName: { type: string, nullable: true }
reviewerName: { type: string, nullable: true }
completionPhotoFileIds: { type: array, items: { type: string, pattern: '^[0-9]+$' } }
completionRemark: { type: string, nullable: true }
reviewDecision: { type: string, nullable: true }
reviewRemark: { type: string, nullable: true }
claimedAt: { type: string, format: date-time, nullable: true }
submittedAt: { type: string, format: date-time, nullable: true }
reviewedAt: { type: string, format: date-time, nullable: true }
items: { type: array, items: { $ref: '#/components/schemas/RelabelItem' } }
unitFacts: { type: array, items: { $ref: '#/components/schemas/RelabelUnitFact' } }
history: { type: array, items: { $ref: '#/components/schemas/RelabelHistory' } }
RelabelItem:
type: object
properties:
itemId: { type: string, pattern: '^[0-9]+$' }
skuCode: { type: string, nullable: true }
productNameZh: { type: string, nullable: true }
shipmentNo: { type: string, nullable: true }
trackingCode: { type: string, nullable: true }
newSkuCode: { type: string, nullable: true }
labelRequirement: { type: string, nullable: true }
relabelFileId: { type: string, nullable: true }
relabelFileName: { type: string, nullable: true }
relabelFileUrl: { type: string, nullable: true }
expectedPackages: { type: integer }
relabelledPackages: { type: integer }
skippedPackages: { type: integer }
exceptionPackages: { type: integer }
RelabelUnitFact:
type: object
properties:
factId: { type: string, pattern: '^[0-9]+$' }
itemId: { type: string, pattern: '^[0-9]+$' }
oldLabel: { type: string, nullable: true }
newLabel: { type: string, nullable: true }
result: { type: string }
exceptionCode: { type: string, nullable: true }
reason: { type: string, nullable: true }
operatorName: { type: string, nullable: true }
occurredAt: { type: string, format: date-time }
RelabelHistory:
type: object
properties:
action: { type: string }
fromStatus: { type: string, nullable: true }
toStatus: { type: string, nullable: true }
operatorName: { type: string, nullable: true }
detail: { type: string, nullable: true }
createdAt: { type: string, format: date-time }
RelabelPhoto:
type: object
required: [fileId, fileName, contentType, sizeBytes]
properties:
fileId: { type: string, pattern: '^[0-9]+$' }
fileName: { type: string }
contentType: { type: string }
sizeBytes: { type: integer, format: int64 }
ScanCode:
type: object
required: [code]
@@ -0,0 +1,101 @@
package com.baiyue.lastleg.merchant;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.controller.MerchantInboundPlanController;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import com.baiyue.lastleg.looseboard.controller.MerchantLocalLooseboardController;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
import com.baiyue.lastleg.procurement.controller.MerchantOverseasProcurementController;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.trucking.controller.MerchantUsTruckOrderController;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import java.util.Arrays;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class MerchantOverseasCustomerApiContractTest {
@Mock LocalLooseboardService looseboardService;
@Mock InboundPlanService inboundPlanService;
private MerchantLocalLooseboardController looseboard;
private MerchantInboundPlanController inbound;
@BeforeEach
void setUp() {
looseboard = new MerchantLocalLooseboardController();
inbound = new MerchantInboundPlanController();
ReflectionTestUtils.setField(looseboard, "service", looseboardService);
ReflectionTestUtils.setField(inbound, "service", inboundPlanService);
}
@Test
void merchantControllersUseFrozenPathsAndExistingClientPermissionCodes() {
assertController(MerchantOverseasProcurementController.class,
"/api/merchant/v1/last-leg/overseas-procurements", OverseasPermissions.MERCHANT_PROCUREMENT);
assertController(MerchantUsTruckOrderController.class,
"/api/merchant/v1/last-leg/us-truck-orders", OverseasPermissions.MERCHANT_TRUCKING);
assertController(MerchantLocalLooseboardController.class,
"/api/merchant/v1/last-leg/local-looseboard-orders", OverseasPermissions.MERCHANT_LOOSEBOARD);
assertController(MerchantInboundPlanController.class,
"/api/merchant/v1/last-leg/inbound-plans", OverseasPermissions.MERCHANT_INBOUND_LIST);
}
@Test
void looseboardAndInboundQueriesUseAuthenticatedCustomerScope() {
AuthenticatedUser merchant = user(101L, "MERCHANT");
LocalLooseboardRequests.MerchantQuery looseboardQuery = new LocalLooseboardRequests.MerchantQuery(1, 20,
null, null, null, null, null, null);
InboundPlanRequests.Query inboundQuery = new InboundPlanRequests.Query(1, 20, null, null, null,
null, null, null, null, null, null, null, null);
looseboard.page(merchant, looseboardQuery);
inbound.page(merchant, inboundQuery);
verify(looseboardService).page("101", looseboardQuery);
verify(inboundPlanService).page("101", inboundQuery);
}
@Test
void merchantQueryDtosDoNotAcceptCustomerScope() {
for (Class<?> type : Set.of(LocalLooseboardRequests.MerchantQuery.class,
com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests.MerchantQuery.class,
com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests.MerchantQuery.class,
InboundPlanRequests.Query.class)) {
assertThat(Arrays.stream(type.getRecordComponents()).map(component -> component.getName()))
.doesNotContain("customerId", "warehouseId", "operatorId");
}
}
@Test
void merchantEndpointsRejectPlatformIdentityBeforeCallingServices() {
AuthenticatedUser platform = user(0L, "PLATFORM");
assertThatThrownBy(() -> looseboard.detail(platform, "LB-1"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("403");
assertThatThrownBy(() -> inbound.detail(platform, "IP-1"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("403");
}
private static void assertController(Class<?> type, String path, String permission) {
assertThat(type.getAnnotation(RequestMapping.class).value()).containsExactly(path);
assertThat(type.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
}
private static AuthenticatedUser user(long customerId, String appCode) {
return new AuthenticatedUser(7L, customerId, customerId, 8L, "operator", appCode, "WEB", Set.of());
}
}
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.merchant;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class MerchantOverseasOpenApiContractTest {
@Test
void documentsFrozenMerchantPathsPermissionsResponsesAndConflictSemantics() throws Exception {
String yaml;
try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) {
assertThat(input).isNotNull();
yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
for (String path : new String[] {
"/api/merchant/v1/last-leg/overseas-procurements:",
"/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:",
"/api/merchant/v1/last-leg/us-truck-orders:",
"/api/merchant/v1/last-leg/us-truck-orders/{orderNo}:",
"/api/merchant/v1/last-leg/local-looseboard-orders:",
"/api/merchant/v1/last-leg/local-looseboard-orders/{orderNo}:",
"/api/merchant/v1/last-leg/inbound-plans:",
"/api/merchant/v1/last-leg/inbound-plans/{planNo}:"}) {
assertThat(yaml).contains(path);
assertThat(pathBlock(yaml, path)).doesNotContain("name: customerId");
}
assertThat(yaml).contains("merchant:cross-border:procurement")
.contains("merchant:cross-border:domestic-trucking")
.contains("merchant:cross-border:pallet-shipping")
.contains("merchant:warehouse:inbound:list")
.contains("#/components/schemas/ProcurementDetailResponse")
.contains("#/components/schemas/TruckDetailResponse")
.contains("#/components/schemas/LooseboardDetailResponse")
.contains("#/components/schemas/InboundPlanDetailResponse")
.contains("PENDING_REVIEW").contains("PENDING_ACCEPTANCE")
.contains("version 必填").contains("幂等冲突");
}
private static String pathBlock(String yaml, String path) {
int start = yaml.indexOf(" " + path);
int end = yaml.indexOf("\n /api/", start + path.length());
return yaml.substring(start, end < 0 ? yaml.length() : end);
}
}
@@ -50,6 +50,10 @@ class PdaOperationsOpenApiContractTest {
"/api/pda/v1/last-leg/stocktakes/{taskNo}/counts:",
"/api/pda/v1/last-leg/stocktakes/{taskNo}/submit:",
"/api/pda/v1/last-leg/inventory-moves:",
"/api/pda/v1/last-leg/inventory/resolve:",
"/api/pda/v1/last-leg/locations/resolve:",
"/api/pda/v1/last-leg/pallets/resolve:",
"/api/pda/v1/last-leg/operations:",
"/api/pda/v1/last-leg/pallet-operations/pallets/resolve:",
"/api/pda/v1/last-leg/pallet-operations/assemble:",
"/api/pda/v1/last-leg/pallet-operations/disassemble:",
@@ -74,12 +78,15 @@ class PdaOperationsOpenApiContractTest {
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pick:",
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:");
for (String path : paths) assertTrue(yaml.contains(path), path);
assertEquals(28, paths.size());
assertEquals(32, paths.size());
assertTrue(yaml.contains("enum: [LOCATION, PALLET]"));
assertTrue(yaml.contains("enum: [APPROVE, REWORK]"));
assertTrue(yaml.contains("enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER]"));
for (String field : SERVER_OWNED) assertFalse(yaml.contains(field + ":"), field);
assertFalse(yaml.contains("/api/pda/v1/last-leg/delivery"));
for (String response : List.of("StocktakeSummaryPageResponse", "StocktakeDetailResponse",
"StocktakeItemResponse", "InventoryMoveResponse", "InventoryScanResponse", "LocationResponse",
"PalletResponse", "OperationPageResponse", "RelabelCandidatesResponse", "RelabelDetailResponse",
"RelabelPhotoResponse")) assertTrue(yaml.contains("#/components/schemas/" + response), response);
}
private static void assertNoServerOwnedFields(Class<?> type) {
@@ -62,7 +62,7 @@ class PdaWarehouseContractTest {
@Test
void openApiAndGatewayPublishOnlyStablePdaPaths() throws Exception {
String openapi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"));
String openapi = Files.readString(Path.of("src/main/resources/openapi/overseas-pda-operations-v1.yaml"));
for (String path : Set.of("/api/pda/v1/last-leg/inventory/resolve:",
"/api/pda/v1/last-leg/locations/resolve:", "/api/pda/v1/last-leg/pallets/resolve:",
"/api/pda/v1/last-leg/operations:")) assertThat(openapi).contains(path);
@@ -33,9 +33,9 @@ class OverseasProcurementControllerTest {
@Test
void merchantAlwaysUsesTenantFromTrustedIdentity() {
var query = new OverseasProcurementRequests.Query(1, 20, null, null, null, null, null, null, null, null);
var query = new OverseasProcurementRequests.MerchantQuery(1, 20, null, null, null, null, null, null, null);
merchant.page(user(100L, "MERCHANT", Set.of()), query);
verify(service).page("100", query);
verify(service).page("100", query.scoped());
}
@Test
@@ -48,7 +48,7 @@ class OverseasProcurementControllerTest {
@Test
void merchantEndpointRejectsPlatformIdentity() {
var query = new OverseasProcurementRequests.Query(1, 20, null, null, null, null, null, null, null, null);
var query = new OverseasProcurementRequests.MerchantQuery(1, 20, null, null, null, null, null, null, null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> merchant.page(user(0L, "PLATFORM", Set.of()), query));
assertEquals(403, error.getStatusCode().value());
@@ -31,8 +31,8 @@ class UsTruckOrderControllerTest {
@Test
void merchantListAlwaysUsesTrustedTenant() {
var query = query(); merchant.page(user(100L, "MERCHANT", Set.of()), query);
verify(service).page("100", query);
var query = merchantQuery(); merchant.page(user(100L, "MERCHANT", Set.of()), query);
verify(service).page("100", query.scoped());
}
@Test
@@ -92,7 +92,9 @@ class UsTruckOrderControllerTest {
verify(service).update("100", "TK1", "7", request);
}
private UsTruckOrderRequests.Query query() { return new UsTruckOrderRequests.Query(1, 20, null, null, null, null, null, null, null); }
private UsTruckOrderRequests.MerchantQuery merchantQuery() {
return new UsTruckOrderRequests.MerchantQuery(1, 20, null, null, null, null, null, null);
}
private AuthenticatedUser user(long tenant, String app, Set<String> permissions) {
return new AuthenticatedUser(7L, tenant, tenant, 8L, "operator", app, "WEB", permissions);
}