贴标申请,仓储转运

This commit is contained in:
2026-09-17 17:37:29 +08:00
parent 3b8b6f5aa2
commit 29f05b7b09
106 changed files with 5019 additions and 471 deletions
+2 -2
View File
@@ -7,7 +7,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 208 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 136 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 138 |
- 国内和海外是两个部署区域,不是把微服务合并为两个 schema。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两区域可分别在独立空实例执行,没有跨区域 SQL 依赖。海外不访问国内 IAM/Warehouse;国内不创建、查询或修改海外业务表。
@@ -24,7 +24,7 @@
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/136`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/138`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
File diff suppressed because one or more lines are too long
+2
View File
@@ -30,6 +30,8 @@
## 修改约束
- 贴标箱唛总文件和 SKU 总文件分别使用 `OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT`、`OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT`,与明细 PDF 采用同一客户复合归属和 10MiB PDF 校验。`file_object.business_type` 为 VARCHAR(64),存量扩容见海外统一增量和 `scripts/db/relabel-file-scope-incremental.sql`。Print 只负责生成 PDF,不保存上传文件。
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
- 客户单文件资质入口只接受 `LEGAL_PERSON_ID/FREIGHT_FORWARDER_CERTIFICATE/BANK_ACCOUNT_LICENSE`,客户范围只取登录 Token;营业执照不得绕过 `required-package`。同类当前资质已存在时必须显式 `replace=true`,成功后旧元数据标记为已替代;上传不会自动提交审核。
- 客户业务文件按客户、业务 ID 和文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。装柜四步照片与铅封是跨客户物理柜证据,所有者固定为平台(`tenantId=0`),业务 ID 固定为 `containerBookingId`,File 按柜与五类 `LOADING_*` 精确校验且不得跨柜复用;PDA `taskId` 只由 Firstleg 保存为来源/审计,不进入 File 所有权。国内 PDA 禁止直连 File 平台接口,必须经 Firstleg 校验任务、仓库和操作者后,由 Firstleg 使用内部服务令牌访问 File。
@@ -24,7 +24,10 @@ enum BusinessFileCategory {
CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_TRANSFER_MEASUREMENT_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
+1 -1
View File
@@ -6,7 +6,7 @@ USE baiyue_file;
CREATE TABLE IF NOT EXISTS file_object (
id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户ID',
business_type VARCHAR(32) NULL COMMENT '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE',
business_type VARCHAR(64) NULL COMMENT '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE',
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId',
original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
object_key VARCHAR(512) NOT NULL COMMENT '对象存储键',
@@ -4,7 +4,7 @@ info:
version: 1.0.0
description: >-
File 只保存文件并强制客户、业务类型、业务ID与文件ID复合归属。
OVERSEAS_RELABEL_TOTAL_DOCUMENT 的 businessId 为 application.id;
OVERSEAS_RELABEL_TOTAL_DOCUMENT、OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT、OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT 的 businessId 为 application.id;
OVERSEAS_RELABEL_ITEM_DOCUMENT 的 businessId 为 item.id。
Lastleg 必须先校验申请与明细存在、明细所属申请及其客户归属、业务状态和权限。
客户归属未知时失败关闭,不得使用平台归属或替代业务ID。
@@ -88,7 +88,7 @@ components:
fileId: {type: string, pattern: '^[1-9][0-9]*$'}
ownerScope: {type: string, enum: [CUSTOMER]}
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
businessType: {type: string, enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]}
businessType: {type: string, enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT, OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]}
businessId: {type: string, pattern: '^[1-9][0-9]*$'}
fileName: {type: string}
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
@@ -112,7 +112,7 @@ components:
required: true
schema:
type: string
enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]
enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT, OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]
BusinessId:
name: businessId
in: path
@@ -30,8 +30,8 @@ class OverseasExceptionEvidenceContractTest {
try (var stream = getClass().getResourceAsStream("/db/file.sql")) {
assertThat(stream).isNotNull();
String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
assertThat(sql).contains("business_type VARCHAR(32)", "OVERSEAS_EXCEPTION_EVIDENCE");
assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(32);
assertThat(sql).contains("business_type VARCHAR(64)", "OVERSEAS_EXCEPTION_EVIDENCE");
assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(64);
}
}
}
@@ -25,7 +25,7 @@ class RelabelDocumentFileTest {
@TempDir Path root;
@ParameterizedTest
@EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"})
@EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT", "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT"})
void pdfPolicyChecksContentTypeSignatureAndSize(BusinessFileCategory category) {
BusinessFileStorage storage = storage();
byte[] exactLimit = new byte[10 * 1024 * 1024];
@@ -52,7 +52,7 @@ class RelabelDocumentFileTest {
}
@ParameterizedTest
@EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"})
@EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT", "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT"})
void uploadAndReadRequireExactCustomerBusinessTypeBusinessAndFile(BusinessFileCategory category) throws Exception {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "relabel-test"), FileObjectEntity.class);
FileObjectMapper files = mock(FileObjectMapper.class);
@@ -0,0 +1,15 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TransferMeasurementPhotoContractTest {
@Test
void transferMeasurementPhotoIsCustomerOwnedStandardBusinessFile() {
BusinessFileCategory category = BusinessFileCategory.require("OVERSEAS_TRANSFER_MEASUREMENT_PHOTO");
category.validateOwner(10001L);
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.STANDARD);
assertThat(category.storageBusinessId("77")).isEqualTo("77");
}
}
+13 -1
View File
@@ -49,7 +49,7 @@
| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 |
| 入仓后 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 Controller |
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 |
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;涉及箱唛换标时整张申请只能有一个 FBA 目的仓,服务端拒绝明细目的仓不一致的请求。普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 |
| 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`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`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`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” |
@@ -132,6 +132,18 @@
## 修改与验证
- `RelabelBindingGuard` 校验已保存组托历史与当前库存托盘一致,箱唛/混合换标正常完工、客户确认与排车提交均复验;纯 SKU 不要求移托,异常手动完工仍须在客户确认前补齐绑定。创建申请按库存余额串行检查箱唛既有申请,拒绝重复申请;历史扫码全局唯一约束尚未改为多轮复用。
- 已确认箱唛改绑复用 `PdaPalletOperationService.relabelDisassemble` 不带 `actualSource` 的精确来源分支,按现有绑定移动 HOLD 库存,不重新分配冻结锚点;成功同样记录 `PALLET_BIND` 以展示最新目标。`requireUnallocatedRelabel` 在入口与事务锁单后限制排车/出库占用,已创建出库单也不可直接改绑。回归 `PdaPalletOperationServiceTest`。
- PDA旧SKU单扫:`PdaRelabelService.scanSku` 和 `/relabel-tasks/{relabelNo}/skus/scan`,按箱itemId+skuIndex核对旧SKU,服务端取新SKU/数量,逐条记录现有 `SKU_SCAN` 历史,版本、幂等、认领和仓库校验不变;同箱全部SKU完成再推进SKU阶段,混合单需两阶段才生成一条箱事实。无新增字段;前端按历史当前轮次隐藏已完成子项,重复旧SKU须由操作员选实际箱。
- 数量拆托成功在现有 `ll_relabel_pda_history` 写 `PALLET_BIND`,保存本轮、箱唛和目标托盘;同轮已组托箱从候选排除并在事务内防重复,返工使用新的 reviewedAt 轮次。PDA 通过详情 history 展示组托记录;不需要新增库存表字段。
- 换标按数量拆合托:`palletops/PdaPalletOperationService.relabelSources` 返回原入仓库存池的托盘组成,不能视为箱唛定位;`relabel-disassemble` 的 `actualSource` 携带仓位版本及现场箱数,`cartonMarks` 为本批目标绑定确认。实际托盘与冻结锚点不同时,在同余额/批次内仅重新分配本单冻结桶后合托,库存余额及原入仓单归属不变,保留逐仓位流水、物理资源补偿及幂等。每次一条实际来源仓位,可分批多托;已生成出库单禁止此重分配。认领人处理中即可新增托盘,不需客户确认;此能力不等于已具备真机打印或整单强制组托门禁。
- 换标申请生成的 `RELABEL_APPLICATION` 尾程主单使用 `WAIT_RELABEL`(待换标),不进入普通入库计划池;子状态仍区分处理中/待客户确认。客户确认后统一为 `WAIT_OUTBOUND`(待出库),卡车分支按换标申请的 `WAIT_PRE_DISPATCH` 资格参与排车,不重复入库。存量修正脚本 `../scripts/db/relabel-wait-status-incremental.sql` 仅调整尚未生成入库单的遗留待分配换标主单。
- 混合换标 `CARTON_AND_SKU` 使用现有 PDA 历史保存 `MIXED_CARTON/MIXED_SKU` 分项事实(itemId + 退回轮次);同一项防重复,两项全部完成才写一条箱事实并增加整箱计数。详情返回 `cartonProcessedPackages/skuProcessedPackages`;不得将单项完成当作整箱完成,返工后旧轮次不计入当前分项进度。
- 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。
@@ -70,6 +70,27 @@ public class DeliveryTransferController {
return ApiResponse.success(service.createReturnOutbound(requiredCustomer(user, customerId), operator(user), request));
}
@GetMapping("/outbound-orders/warehouse-transfer/candidates")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
@Operation(summary = "List warehouse-transfer pallets that can create an outbound order")
public ApiResponse<java.util.List<DeliveryResponses.WarehouseTransferOutboundCandidate>> warehouseTransferCandidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) String warehouseId) {
requirePlatform(user);
return ApiResponse.success(service.warehouseTransferOutboundCandidates(customerId, warehouseId));
}
@PostMapping("/outbound-orders/warehouse-transfer")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
@Operation(summary = "Create a warehouse-transfer outbound, delivery order and PDA task")
public ApiResponse<DeliveryResponses.OutboundDetail> createWarehouseTransferOutbound(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@Valid @RequestBody DeliveryRequests.WarehouseTransferOutbound request) {
return ApiResponse.success(service.createWarehouseTransferOutbound(
requiredCustomer(user, customerId), operator(user), request));
}
@GetMapping("/outbound-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')")
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
@@ -75,6 +75,20 @@ public final class DeliveryRequests {
@NotNull @Size(min=1, max=200) List<@NotBlank @Size(max=128) String> cartonMarks,
@Size(max=1000) String remark,
@NotBlank @Size(max=255) String idempotencyKey) {}
public record WarehouseTransferOutbound(
@NotBlank @Size(max=64) String transferNo,
@NotNull @Size(min=1, max=100) List<@NotNull Long> palletIds,
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "FBA|PRIVATE") String destinationType,
@Size(max=128) String destinationWarehouseId,
@Size(max=128) String destinationWarehouseCode,
@Size(max=255) String destinationWarehouseName,
@Size(max=1000) String deliveryAddress,
@Size(max=128) String contactName,
@Size(max=64) String contactPhone,
@Size(max=32) String deliveryMethod,
LocalDateTime plannedDeliveryAt,
@Size(max=1000) String remark,
@NotBlank @Size(max=255) String idempotencyKey) {}
public record OneClickItem(@NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages,
@NotBlank @Size(max=128) String sku,
@Size(max=200) List<@NotBlank @Size(max=128) String> cartonMarks) {
@@ -31,7 +31,8 @@ public final class DeliveryResponses {
String remark, LocalDateTime createdAt, long version, List<String> allowedActions,
String bolNo, DeliveryDocumentDtos.Summary documentSummary,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName) {}
String destinationWarehouseName, String destinationType,
String deliveryAddress, String contactName, String contactPhone, String postalCode) {}
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
String receivingAddress, String shipmentNo, String fbaTrackingNo,
@@ -59,6 +60,16 @@ public final class DeliveryResponses {
String cartonMark, String fbaCartonMark,
String destinationWarehouseCode, String destinationWarehouseName,
long totalQuantity, String status) {}
public record WarehouseTransferOutboundCandidate(String customerId, String customerName,
String transferNo, String overseasWarehouseId,
String transferStatus, boolean needRelabel,
String relabelNo, String destinationType,
String destinationWarehouseCode,
String privateDestinationAddress,
String privateDestinationPostalCode,
Long palletId, Long warehousePalletId, String palletNo,
int packageCount, BigDecimal grossWeightKg,
BigDecimal volumeCbm) {}
public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {}
public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {}
public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark,
@@ -26,6 +26,11 @@ public class DeliveryOrder {
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private String destinationType;
private String deliveryAddress;
private String postalCode;
private String contactName;
private String contactPhone;
private String deliveryGroupKey;
private String status;
private String waybillNo;
@@ -39,6 +39,7 @@ public class DeliveryCompletedOutboxService {
Objects.requireNonNull(completedAt, "completedAt");
Map<String, LastlegOrder> bySourceInbound = new LinkedHashMap<>();
for (LastlegOrder order : orders == null ? List.<LastlegOrder>of() : orders) {
if ("RELABEL_APPLICATION".equals(order.getOrderType())) continue;
if (!StringUtils.hasText(order.getSourceInboundId())) continue;
requireText(order.getSourceOrderId(), "sourceOrderId");
requireText(order.getInboundCode(), "inboundCode");
@@ -60,7 +60,9 @@ import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
@@ -97,6 +99,7 @@ public class DeliveryTransferService {
@Resource private InventoryBalanceMapper inventoryBalances;
@Resource private InventoryCommandService inventoryCommands;
@Resource private WarehouseTransferPalletMapper warehouseTransferPallets;
@Resource private WarehouseTransferOrderMapper warehouseTransfers;
@Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private DropshipCartonMapper dropshipCartons;
@Resource private DropshipCartonSkuMapper dropshipCartonSkus;
@@ -217,6 +220,11 @@ public class DeliveryTransferService {
firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()),
destination.getDestinationWarehouseId()));
delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName()));
delivery.setDestinationType(destination.getDestinationType());
delivery.setDeliveryAddress(destination.getDeliveryAddress());
delivery.setPostalCode(destination.getPostalCode());
delivery.setContactName(destination.getContactName());
delivery.setContactPhone(destination.getContactPhone());
delivery.setDeliveryGroupKey(entry.getKey());
LastlegOrder firstOrder = firstTailOrder(customerId, groupItems);
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L);
@@ -395,6 +403,155 @@ public class DeliveryTransferService {
return taskResult(outbound, task);
}
@Transactional(readOnly = true)
public List<DeliveryResponses.WarehouseTransferOutboundCandidate> warehouseTransferOutboundCandidates(
String customerId, String warehouseId) {
List<WarehouseTransferOrder> transfers = warehouseTransfers.selectList(
new LambdaQueryWrapper<WarehouseTransferOrder>()
.eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, trim(customerId))
.eq(StringUtils.hasText(warehouseId), WarehouseTransferOrder::getOperatingWarehouseId, trim(warehouseId))
.eq(WarehouseTransferOrder::getStatus, "WAIT_FBA_DESTINATION")
.orderByDesc(WarehouseTransferOrder::getUpdatedAt));
List<DeliveryResponses.WarehouseTransferOutboundCandidate> result = new ArrayList<>();
for (WarehouseTransferOrder transfer : transfers) {
LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId());
List<WarehouseTransferPallet> completedPallets = warehouseTransferPallets.listOrder(
transfer.getCustomerId(), transfer.getId()).stream()
.filter(pallet -> "COMPLETED".equals(pallet.getStatus()) && pallet.getWarehousePalletId() != null)
.toList();
long available = 0;
for (WarehouseTransferPallet pallet : completedPallets) {
long palletAvailable = inventoryPositions.listPallet(transfer.getCustomerId(), pallet.getWarehousePalletId())
.stream().filter(position -> transfer.getOperatingWarehouseId().equals(position.getOverseasWarehouseId()))
.mapToLong(position -> value(position.getAvailablePackages())).sum();
available += palletAvailable;
}
if (available <= 0 || completedPallets.isEmpty()) continue;
result.add(new DeliveryResponses.WarehouseTransferOutboundCandidate(
transfer.getCustomerId(), order == null ? null : order.getCustomerName(), transfer.getTransferNo(),
transfer.getOperatingWarehouseId(), transfer.getStatus(), Boolean.TRUE.equals(transfer.getNeedRelabel()),
transfer.getRelabelNo(), transfer.getDestinationType(), transfer.getDestinationWarehouseCode(),
transfer.getPrivateDestinationAddress(), transfer.getPrivateDestinationPostalCode(),
null, null, null, Math.toIntExact(available),
completedPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg).filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add),
completedPallets.stream().map(WarehouseTransferPallet::getVolumeCbm).filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add)));
}
return result;
}
@Transactional
public DeliveryResponses.OutboundDetail createWarehouseTransferOutbound(String customerId, String operatorId,
DeliveryRequests.WarehouseTransferOutbound request) {
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, key);
if (replay != null) {
if (!"WAREHOUSE_TRANSFER_OUTBOUND".equals(replay.getOutboundType())
|| !requestHash.equals(replay.getRequestHash())) {
throw conflict("相同幂等键不能用于不同的仓储转运出库请求");
}
return outboundDetail(replay.getOutboundNo());
}
WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, request.transferNo().trim());
if (transfer == null) throw notFound("仓储转运单不存在");
if (!Set.of("READY_FOR_DISPATCH", "WAIT_FBA_DESTINATION").contains(transfer.getStatus())) {
throw conflict("仓储转运单尚未完成换标或已经生成出库单");
}
String destinationType = request.destinationType().trim().toUpperCase(Locale.ROOT);
if ("FBA".equals(destinationType) && !StringUtils.hasText(request.destinationWarehouseCode())) {
throw conflict("FBA出库必须选择目的仓");
}
if ("PRIVATE".equals(destinationType) && (!StringUtils.hasText(request.deliveryAddress())
|| !StringUtils.hasText(request.contactName()) || !StringUtils.hasText(request.contactPhone()))) {
throw conflict("私人仓出库必须填写地址、联系人和联系电话");
}
List<Long> palletIds = request.palletIds().stream().distinct().toList();
if (palletIds.size() != request.palletIds().size()) throw conflict("出库托盘不可重复");
List<WarehouseTransferPallet> selectedPallets = new ArrayList<>();
for (Long palletId : palletIds) {
WarehouseTransferPallet pallet = warehouseTransferPallets.lockOne(customerId, transfer.getId(), palletId);
if (pallet == null || !"COMPLETED".equals(pallet.getStatus()) || pallet.getWarehousePalletId() == null) {
throw conflict("所选托盘不属于该仓储转运单或尚未完成入库:" + palletId);
}
selectedPallets.add(pallet);
}
LocalDateTime now = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", transfer.getOperatingWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(transfer.getOperatingWarehouseId());
outbound.setOutboundType("WAREHOUSE_TRANSFER_OUTBOUND"); outbound.setStatus("WAIT_PICK");
outbound.setRecipientInfo("PRIVATE".equals(destinationType)
? request.contactName().trim() + " / " + request.contactPhone().trim() + " / " + request.deliveryAddress().trim()
: request.destinationWarehouseCode().trim());
outbound.setPalletCount(selectedPallets.size()); outbound.setIdempotencyKey(key);
outbound.setRequestHash(requestHash); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
outboundOrders.insert(outbound);
int packageCount = 0;
for (WarehouseTransferPallet pallet : selectedPallets) {
List<InventoryPosition> positions = inventoryPositions.listPallet(customerId, pallet.getWarehousePalletId())
.stream().filter(position -> transfer.getOperatingWarehouseId().equals(position.getOverseasWarehouseId()))
.filter(position -> value(position.getAvailablePackages()) > 0).toList();
if (positions.isEmpty()) throw conflict("所选托盘没有可出库库存:" + pallet.getPalletNo());
for (InventoryPosition position : positions) {
InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId());
if (balance == null || !customerId.equals(balance.getCustomerId())) throw conflict("托盘库存余额不存在");
int packages = Math.toIntExact(value(position.getAvailablePackages()));
OutboundPalletItem item = new OutboundPalletItem();
item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId());
item.setWarehousePalletId(pallet.getWarehousePalletId()); item.setPalletNo(pallet.getPalletNo());
item.setDestinationWarehouseId(trim(request.destinationWarehouseId()));
item.setDestinationWarehouseCode(trim(request.destinationWarehouseCode()));
item.setDestinationWarehouseName(trim(request.destinationWarehouseName()));
item.setPackageCount(packages); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
item.setSkuCode(balance.getSkuCode()); item.setInboundCode(balance.getInboundCode());
item.setOrderRefs(transfer.getTransferNo()); item.setCreatedAt(now); outboundPallets.insert(item);
packageCount = Math.addExact(packageCount, packages);
}
}
outbound.setPackageCount(packageCount); outboundOrders.updateById(outbound);
LastlegOrder tailOrder = tailOrders.selectById(transfer.getLastlegOrderId());
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", transfer.getOperatingWarehouseId(), delivery.getId()));
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
delivery.setOverseasWarehouseId(transfer.getOperatingWarehouseId());
delivery.setCustomerName(tailOrder == null ? null : tailOrder.getCustomerName());
delivery.setDestinationType(destinationType); delivery.setDestinationWarehouseId(trim(request.destinationWarehouseId()));
delivery.setDestinationWarehouseCode(trim(request.destinationWarehouseCode()));
delivery.setDestinationWarehouseName(trim(request.destinationWarehouseName()));
delivery.setDeliveryAddress(trim(request.deliveryAddress())); delivery.setContactName(trim(request.contactName()));
delivery.setContactPhone(trim(request.contactPhone())); delivery.setDeliveryGroupKey(destinationType + ":" +
firstText(request.destinationWarehouseCode(), request.deliveryAddress()));
delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryMethod(firstText(request.deliveryMethod(), "TRUCK"));
delivery.setPackageCount(packageCount); delivery.setPalletCount(selectedPallets.size());
delivery.setTotalWeightKg(selectedPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
delivery.setTotalVolumeCbm(selectedPallets.stream().map(WarehouseTransferPallet::getVolumeCbm)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); delivery.setRemark(trim(request.remark()));
delivery.setIdempotencyKey(childKey(key, "DELIVERY")); delivery.setCreatedBy(operatorId); delivery.setVersion(0L);
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "仓储转运出库自动生成派送单", now);
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId);
task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId());
task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
transfer.setStatus("WAIT_OUTBOUND");
if ("FBA".equals(destinationType)) transfer.setFbaWarehouseCode(request.destinationWarehouseCode().trim());
transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer);
if (tailOrder != null && customerId.equals(tailOrder.getCustomerId())) {
tailOrder.setCurrentStatus("WAIT_OUTBOUND"); tailOrder.setOutboundOrderNo(outbound.getOutboundNo());
tailOrder.setDeliveryOrderNo(delivery.getDeliveryNo()); tailOrder.setFbaWarehouseCode(trim(request.destinationWarehouseCode()));
tailOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:WAIT_OUTBOUND"); tailOrder.setUpdatedAt(now); tailOrders.updateById(tailOrder);
}
return outboundDetail(outbound.getOutboundNo());
}
private String validateOneClickItems(String customerId, List<DeliveryRequests.OneClickItem> items) {
String warehouseId = null;
Map<String, Long> requestedPackages = new LinkedHashMap<>();
@@ -541,6 +698,40 @@ public class DeliveryTransferService {
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
return taskResult(outbound, task);
}
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
if (request.inventoryPositionId() == null) throw conflict("仓储转运出库必须确认实际库存位置");
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId())
|| !warehouseId.equals(selectedPosition.getOverseasWarehouseId())
|| !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) {
throw conflict("确认的库存位置不属于该仓储转运出库托盘");
}
InventoryBalance balance = inventoryBalances.selectById(selectedPosition.getBalanceId());
if (!matchesOutboundItem(customerId, warehouseId, item, balance)) {
throw conflict("确认的库存SKU或入仓码与出库明细不一致");
}
if (request.packages() > value(item.getPackageCount()) - value(item.getPickedPackageCount())) {
throw conflict("出库数量超过仓储转运出库明细余量");
}
inventoryCommands.allocate(customerId, operatorId, new InventoryCommandRequests.Move(
selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null,
request.idempotencyKey() + ":ALLOCATE", "仓储转运出库分配"));
inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(
selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null,
request.idempotencyKey() + ":PICK", "仓储转运出库拣货"));
inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(
selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null,
request.idempotencyKey() + ":SHIP", "仓储转运出库"));
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId);
pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId());
pick.setInventoryPositionId(selectedPosition.getId()); pick.setPackages(request.packages());
pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue());
item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED");
outboundPallets.updateById(item); updateOutboundPickingStatus(customerId, outbound);
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
return taskResult(outbound, task);
}
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet");
if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) {
@@ -612,8 +803,8 @@ public class DeliveryTransferService {
if (outbound == null || !boundWarehouseId.equals(outbound.getOverseasWarehouseId())) {
throw notFound("Outbound order not found in the bound warehouse");
}
if (!"ONE_CLICK_DROPSHIP".equals(outbound.getOutboundType())) {
throw conflict("整托扫码仅适用于一件代发出库");
if (!Set.of("ONE_CLICK_DROPSHIP", "RELABEL_OUTBOUND", "WAREHOUSE_TRANSFER_OUTBOUND").contains(outbound.getOutboundType())) {
throw conflict("整托扫码仅适用于一件代发或换标出库");
}
if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) {
throw conflict("PDA task is not available for picking");
@@ -695,6 +886,23 @@ public class DeliveryTransferService {
new RelabelRequests.ChangeStatus("COMPLETED", "换标出库PDA作业完成"));
}
}
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
List<String> transferNos = items.stream().map(OutboundPalletItem::getOrderRefs)
.filter(StringUtils::hasText).distinct().toList();
for (String transferNo : transferNos) {
WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo);
if (transfer != null && "WAIT_OUTBOUND".equals(transfer.getStatus())) {
transfer.setStatus("OUTBOUNDED"); transfer.setOutboundAt(now); transfer.setUpdatedAt(now);
warehouseTransfers.updateById(transfer);
LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId());
if (order != null && customerId.equals(order.getCustomerId())) {
order.setCurrentStatus("IN_TRANSIT");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:OUTBOUNDED"); order.setUpdatedAt(now);
tailOrders.updateById(order);
}
}
}
}
return taskResult(outbound, task);
}
@@ -724,6 +932,23 @@ public class DeliveryTransferService {
if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); }
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); }
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
for (String transferNo : outboundItems.stream().map(OutboundPalletItem::getOrderRefs)
.filter(StringUtils::hasText).distinct().toList()) {
WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo);
if (transfer == null || !"WAIT_OUTBOUND".equals(transfer.getStatus())) continue;
String restored = StringUtils.hasText(transfer.getFbaWarehouseCode())
? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION";
transfer.setStatus(restored); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer);
LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId());
if (order != null && customerId.equals(order.getCustomerId())) {
order.setCurrentStatus(restored); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + restored);
order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now);
tailOrders.updateById(order);
}
}
return;
}
if (!existingDeliveries.isEmpty()) {
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) {
String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order);
@@ -958,17 +1183,20 @@ public class DeliveryTransferService {
candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(),
value(candidate.getAvailablePackages()));
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName()); }
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
private LastlegOrder firstTailOrder(String customerId, List<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
private String deliveryGroupKey(DispatchItem item) {
if ("PRIVATE".equals(item.getDestinationType()) && StringUtils.hasText(item.getDeliveryAddress())) {
return "PRIVATE:" + item.getDeliveryAddress().trim() + ":" + Objects.toString(item.getPostalCode(), "").trim();
}
String code = firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode());
if (StringUtils.hasText(code)) return "CODE:" + code.toUpperCase(Locale.ROOT);
if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim();
throw conflict("Dispatch pallet is missing its destination FBA warehouse");
throw conflict("Dispatch pallet is missing its destination");
}
private String resolveLxCodes(String customerId, List<DispatchItem> items) {
List<String> inboundCodes = items.stream().map(DispatchItem::getInboundCode)
@@ -1029,6 +1257,14 @@ public class DeliveryTransferService {
return measuredWeight.add(orderWeight);
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private String hash(Object value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8)));
} catch (JsonProcessingException | NoSuchAlgorithmException error) {
throw new IllegalStateException("无法生成请求摘要", error);
}
}
private static Long parseLong(String value) {
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
catch (NumberFormatException ignored) { return null; }
@@ -46,7 +46,7 @@ public class SmartDispatchController {
@RequestParam(required = false) String sourceStage,
@RequestParam(required = false) String draftToken) {
requirePlatform(user);
if (planIds == null || planIds.isEmpty()) {
if ("RELABEL".equals(sourceStage) || planIds == null || planIds.isEmpty()) {
return ApiResponse.success(service.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken));
}
return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo,
@@ -30,6 +30,11 @@ public class DispatchItem {
private String destinationWarehouseCode;
private String destinationWarehouseName;
private String fbaWarehouseCode;
private String destinationType;
private String deliveryAddress;
private String postalCode;
private String contactName;
private String contactPhone;
private Integer packageCount;
private BigDecimal volumeCbm;
/** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */
@@ -64,16 +64,20 @@ public interface DispatchCandidateMapper {
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
"AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " +
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," +
"MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," +
"MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE 1=1 " +
"WHERE ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " +
"(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " +
"AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " +
"<if test='customerId != null and customerId != \"\"'> AND ip.customer_id=#{customerId}</if> " +
"<if test='planIds != null and planIds.size() > 0'> AND p.plan_id IN " +
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></if> " +
"<if test='orderNo != null and orderNo != \"\"'> AND o.order_no LIKE CONCAT('%',#{orderNo},'%')</if> " +
"<if test='orderNo != null and orderNo != \"\"'> AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%'))</if> " +
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
@@ -146,13 +150,18 @@ public interface DispatchCandidateMapper {
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
"AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " +
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," +
"MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," +
"MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " +
"(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " +
"AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
@@ -184,7 +193,8 @@ public interface DispatchCandidateMapper {
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
"SUM(ri.notified_package_count) AS packageCount,MAX(wtp.volume_cbm) AS volumeCbm,'RELABEL' AS sourceStage," +
"NULL AS cartonMarks,GROUP_CONCAT(DISTINCT COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) " +
"ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks " +
"ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks," +
"'FBA' AS destinationType,NULL AS deliveryAddress,NULL AS postalCode,NULL AS contactName,NULL AS contactPhone " +
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
"JOIN ll_inventory_position pos ON pos.id=ri.source_inventory_position_id AND pos.customer_id=a.customer_id " +
"LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " +
@@ -193,7 +203,7 @@ public interface DispatchCandidateMapper {
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
"AND pos.hold_packages &gt; 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
"ON existing_dispatch.id=di.dispatch_order_id AND existing_dispatch.customer_id=di.customer_id " +
@@ -30,7 +30,12 @@ public record DispatchPalletCandidate(
BigDecimal volumeCbm,
String sourceStage,
String cartonMarks,
String relabelCartonMarks
String relabelCartonMarks,
String destinationType,
String deliveryAddress,
String postalCode,
String contactName,
String contactPhone
) {
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
String overseasWarehouseId, String overseasWarehouseName, String orderNo,
@@ -41,7 +46,7 @@ public record DispatchPalletCandidate(
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, overseasWarehouseId,
overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId,
destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, packageCount, volumeCbm,
sourceStage, null, null);
sourceStage, null, null, null, null, null, null, null);
}
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
@@ -51,7 +56,7 @@ public record DispatchPalletCandidate(
BigDecimal volumeCbm, String sourceStage) {
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode,
palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null);
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null, null, null, null, null, null);
}
public String sourcePalletKey() {
// Once received, the physical pallet is the shared resource even when it contains
@@ -66,6 +66,7 @@ public class DispatchService {
@Resource private LastlegOrderMapper tailOrders;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard;
public String resolveCustomerScopeForPlans(List<Long> planIds) {
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
@@ -107,12 +108,14 @@ public class DispatchService {
public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode,
String sourceStage, String draftToken) {
validateSourceStage(sourceStage);
List<DispatchPalletCandidate> result = candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
List<DispatchPalletCandidate> result = "RELABEL".equals(sourceStage) ? List.of() : candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
trimToNull(sourceStage), trimToNull(draftToken));
if (!StringUtils.hasText(sourceStage) || "RECEIVED".equals(sourceStage)) {
if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) {
List<DispatchPalletCandidate> relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null);
if (relabel != null && !relabel.isEmpty()) {
result = java.util.stream.Stream.concat(result.stream(), relabel.stream()).toList();
result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item ->
!StringUtils.hasText(fbaWarehouseCode) || Objects.toString(item.fbaWarehouseCode(), "")
.toUpperCase(java.util.Locale.ROOT).contains(fbaWarehouseCode.trim().toUpperCase(java.util.Locale.ROOT)))).toList();
}
}
return result;
@@ -270,6 +273,11 @@ public class DispatchService {
if (selected.size() != palletIds.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant");
}
if (selected.stream().anyMatch(candidate -> !hasValidDestination(candidate))) {
boolean legacyMissingFba = selected.stream().allMatch(candidate -> !"PRIVATE".equals(candidate.destinationType()));
throw new ResponseStatusException(HttpStatus.CONFLICT, legacyMissingFba
? "所选托盘尚未设置 FBA 仓,不能智能排车" : "所选托盘尚未设置有效目的地,不能智能排车");
}
assertNoDetainedOrders(customerId, selected);
boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
.anyMatch(value -> value == null || value.isBlank());
@@ -396,6 +404,11 @@ public class DispatchService {
item.setDestinationWarehouseCode(candidate.destinationWarehouseCode());
item.setDestinationWarehouseName(candidate.destinationWarehouseName());
item.setFbaWarehouseCode(candidate.fbaWarehouseCode());
item.setDestinationType(candidate.destinationType());
item.setDeliveryAddress(candidate.deliveryAddress());
item.setPostalCode(candidate.postalCode());
item.setContactName(candidate.contactName());
item.setContactPhone(candidate.contactPhone());
item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0));
item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO));
item.setSourceStage(candidate.sourceStage());
@@ -433,6 +446,7 @@ public class DispatchService {
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
}
relabelBindingGuard.requireBound(application);
List<DispatchPalletCandidate> selected = candidates.relabelAvailable(customerId, null, request.relabelNo());
Set<Long> requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet());
selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList();
@@ -653,10 +667,23 @@ public class DispatchService {
}
private static String candidateFbaCode(DispatchPalletCandidate candidate) {
if ("PRIVATE".equals(candidate.destinationType())) {
return java.util.stream.Stream.of(candidate.deliveryAddress(), candidate.postalCode())
.filter(StringUtils::hasText).map(String::trim).collect(Collectors.joining(" "));
}
return StringUtils.hasText(candidate.fbaWarehouseCode())
? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode());
}
private static boolean hasValidDestination(DispatchPalletCandidate candidate) {
if ("PRIVATE".equals(candidate.destinationType())) {
return StringUtils.hasText(candidate.deliveryAddress()) && StringUtils.hasText(candidate.postalCode())
&& StringUtils.hasText(candidate.contactName()) && StringUtils.hasText(candidate.contactPhone());
}
return StringUtils.hasText(candidate.fbaWarehouseCode())
|| StringUtils.hasText(candidate.destinationWarehouseCode());
}
private static String itemFbaCode(DispatchItem item) {
return StringUtils.hasText(item.getFbaWarehouseCode())
? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode());
@@ -716,8 +743,8 @@ public class DispatchService {
}
private static void validateSourceStage(String sourceStage) {
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED", "RELABEL").contains(sourceStage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN, RECEIVED or RELABEL");
}
}
@@ -41,7 +41,12 @@ public record LastlegOrderSummary(
String nodeSource,
int version,
Integer palletCount,
@Schema(description = "客户预报箱数")
Integer packageCount,
@Schema(description = "PDA 实收箱数;尚未完成收货时为空")
Integer receivedPackageCount,
@Schema(description = "实收减预报箱数;尚未完成收货时为空")
Integer packageDifference,
BigDecimal volumeCbm,
Integer productQuantity,
BigDecimal volumeWeightKg,
@@ -44,6 +44,7 @@ public class LastlegOrder {
private Integer version;
private Integer palletCount;
private Integer packageCount;
private Integer receivedPackageCount;
private Integer pickedPackageCount;
private Integer shippedPackageCount;
private BigDecimal volumeCbm;
@@ -21,6 +21,10 @@ public class InboundPlanLine {
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private String deliveryAddress;
private String postalCode;
private String contactName;
private String contactPhone;
private BigDecimal volumeCbm;
private Integer requiredPackageCount;
private Integer plannedPalletCount;
@@ -22,6 +22,11 @@ public class InboundPlanPallet {
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private String destinationType;
private String deliveryAddress;
private String postalCode;
private String contactName;
private String contactPhone;
private BigDecimal capacityCbm;
private BigDecimal allocatedVolumeCbm;
private Integer sequenceNo;
@@ -6,6 +6,8 @@ import com.fasterxml.jackson.annotation.JsonAlias;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
@@ -293,6 +295,27 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
return palletBatch("/internal/v1/warehouse-pallet-batches/compensate", request);
}
@Override
public PrintableFile printPalletLabel(long warehouseId, long palletId, String transferNo, String requestId) {
ResponseEntity<byte[]> response = client.get()
.uri(builder -> builder.path("/internal/v1/warehouse-pallets/{warehouseId}/{palletId}/label")
.queryParam("transferNo", transferNo).build(warehouseId, palletId))
.header("X-Internal-Service-Token", internalToken)
.header("X-Request-Id", requestId == null ? "" : requestId)
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet label printing failed");
}).toEntity(byte[].class);
byte[] content = response.getBody() == null ? new byte[0] : response.getBody();
if (content.length == 0) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet label printing returned an empty PDF");
String fileName = response.getHeaders().getContentDisposition().getFilename();
String contentType = response.getHeaders().getContentType() == null
? MediaType.APPLICATION_PDF_VALUE : response.getHeaders().getContentType().toString();
return new PrintableFile(content, fileName == null ? "pallet-label.pdf" : fileName, contentType);
}
private PalletBatchResponse palletBatch(String path, Object body) {
PalletBatchEnvelope response = client.post().uri(path)
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
@@ -39,6 +39,9 @@ public interface WarehouseResourceGateway {
default PalletBatchResponse compensatePalletBatch(PalletBatchCompensation request) {
throw new UnsupportedOperationException("Warehouse pallet batch compensation is not configured");
}
default PrintableFile printPalletLabel(long warehouseId, long palletId, String transferNo, String requestId) {
throw new UnsupportedOperationException("Warehouse pallet label printing is not configured");
}
record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId,
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {
@@ -67,4 +70,5 @@ public interface WarehouseResourceGateway {
record PalletBatchItem(long customerId, String businessKey, String inboundPlanNo, int sequenceNo,
long palletId, String palletCode, long locationId, long resourceLockId,
String status) { }
record PrintableFile(byte[] content, String fileName, String contentType) { }
}
@@ -511,9 +511,6 @@ public class InventoryCommandService {
String skuCode = sku(requestedSkuCode);
String destinationId = trim(destinationWarehouseId);
String destinationCode = trim(destinationWarehouseCode);
if (destinationId == null && destinationCode == null) {
throw badRequest("Destination warehouse ID or code is required");
}
if (destinationCode == null) destinationCode = "";
InventoryBalance balance = balances.lockDimension(customerId, frozenWarehouseId,
destinationCode, inboundOrderId, inboundCode);
@@ -2,9 +2,22 @@ package com.baiyue.lastleg.model;
/** Standard main-process statuses for a last-leg order. */
public enum LastlegOrderStatus {
PENDING_REVIEW("待审核"),
INBOUND_MEASURING("入库测量中"),
PENDING_ASSIGNMENT("待分配"),
PENDING_INBOUND("待入库"),
INBOUND_COMPLETED("入库完成"),
WAIT_RELABEL("待换标"),
WAIT_FBA_DESTINATION("待确认目的仓"),
READY_FOR_DISPATCH("待创建出库单"),
WAIT_OUTBOUND("待出库"),
ALLOCATED("已分配库存"),
OUTBOUNDED("已出库"),
PARTIALLY_RECEIVED("部分收货"),
RECEIVED("已收货"),
IN_TRANSIT("运输中"),
COMPLETED("已完成"),
CANCELLED("已取消"),
PENDING_DELIVERY("待派送"),
DELIVERING("派送中"),
DELIVERED("派送完成");
@@ -28,6 +28,13 @@ public class PdaPalletOperationController {
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/relabel-sources")
public ApiResponse<java.util.List<PalletOperationResponses.RelabelSource>> relabelSources(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.RelabelSources request) {
return ApiResponse.success(service.relabelSources(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/relabel-pallet-label")
public ApiResponse<PalletOperationResponses.PrintablePallet> createRelabelPallet(
@AuthenticationPrincipal AuthenticatedUser user,
@@ -13,6 +13,7 @@ public final class PalletOperationRequests {
private PalletOperationRequests() { }
public record Scan(@NotBlank @Size(max = 128) String code) { }
public record RelabelSources(@NotBlank @Size(max = 64) String relabelNo) { }
public record NewRelabelPallet(@NotBlank @Size(max = 64) String relabelNo,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode,
@@ -33,13 +34,19 @@ public final class PalletOperationRequests {
@NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
@Size(max = 500) String reason) { }
/** 换标场景按箱唛拆托并绑定到一个目标托盘。箱唛位置由服务端从换标单快照解析。 */
/** actualSource为现场确认的原入仓库存行;cartonMarks确认本批目标绑定,不推断历史箱唛位置。 */
public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 64) String relabelNo,
@Valid PalletExpectation source,
@Valid PalletExpectation target,
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks,
@Size(max = 500) String reason) { }
@Size(max = 500) String reason,
@Valid MoveLine actualSource) {
public RelabelDisassemble(String idempotencyKey, String relabelNo, PalletExpectation source,
PalletExpectation target, List<String> cartonMarks, String reason) {
this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, null);
}
}
public record Swap(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@@ -5,6 +5,9 @@ import java.util.List;
public final class PalletOperationResponses {
private PalletOperationResponses() { }
public record RelabelSource(long positionId, long positionVersion, String palletCode,
String locationCode, String inboundCode, long stockPackages,
long movablePackages, List<String> cartonMarks) { }
public record InventoryLine(String customerId, String customerName, long balanceId, long positionId,
String inboundCode, String skuCode, String productName,
@@ -23,6 +23,12 @@ public interface PdaPalletOperationMapper extends BaseMapper<PdaPalletOperation>
PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey,
@Param("idempotencyKey") String idempotencyKey);
@Select(CONTENT_SELECT + "WHERE b.customer_id=#{customerId} AND b.overseas_warehouse_id=#{warehouseId} " +
"AND b.id=#{balanceId} AND p.pallet_id>0 " +
"AND (p.available_packages+p.hold_packages)>0 ORDER BY p.pallet_no,p.id")
List<PalletInventoryRow> relabelSources(@Param("customerId") String customerId,
@Param("warehouseId") String warehouseId, @Param("balanceId") long balanceId);
@Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY b.customer_id,b.inbound_code,p.id")
@@ -78,13 +78,42 @@ public class PdaPalletOperationService {
return content(identity.overseasWarehouseId(), pallet);
}
public List<PalletOperationResponses.RelabelSource> relabelSources(PdaWarehouseIdentity identity,
PalletOperationRequests.RelabelSources request) {
RelabelApplication app = relabelApplications.findAnyByRelabelNo(request.relabelNo());
requireRelabelWork(identity, app);
Set<String> boundMarks = boundRelabelMarks(app);
List<RelabelItem> items = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()));
Map<Long, List<RelabelItem>> byBalance = new LinkedHashMap<>();
for (RelabelItem item : items) {
InventoryPosition anchor = positions.selectById(item.getSourceInventoryPositionId());
if (anchor == null || !Objects.equals(anchor.getCustomerId(), app.getCustomerId())
|| !Objects.equals(anchor.getOverseasWarehouseId(), app.getOverseasWarehouseId()))
throw conflict("换标单库存来源缺失,请核对原入仓单");
byBalance.computeIfAbsent(anchor.getBalanceId(), ignored -> new ArrayList<>()).add(item);
}
List<PalletOperationResponses.RelabelSource> result = new ArrayList<>();
byBalance.forEach((balanceId, group) -> {
for (PalletInventoryRow row : mapper.relabelSources(app.getCustomerId(), app.getOverseasWarehouseId(), balanceId)) {
long ownHold = group.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), row.getPositionId()))
.mapToLong(item -> value(item.getNotifiedPackageCount())).sum();
result.add(new PalletOperationResponses.RelabelSource(row.getPositionId(), value(row.getVersion()),
row.getPalletNo(), row.getWarehouseLocationCode(), row.getInboundCode(),
value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages())
+ value(row.getPickedPackages()),
value(row.getAvailablePackages()) + Math.min(ownHold, value(row.getHoldPackages())),
group.stream().map(RelabelItem::getCartonMark).filter(mark -> !boundMarks.contains(mark)).toList()));
}
});
return result;
}
public PalletOperationResponses.PrintablePallet createRelabelPallet(PdaWarehouseIdentity identity,
PalletOperationRequests.NewRelabelPallet request) {
String relabelNo = required(request.relabelNo(), "换标单号不能为空");
RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo);
if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) {
throw conflict("换标单尚未完成客户确认,不能生成新托盘");
}
requireRelabelWork(identity, application);
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("换标单不属于 Token 冻结的海外仓");
}
@@ -153,9 +182,7 @@ public class PdaPalletOperationService {
if (replay != null) return replay;
String relabelNo = required(request.relabelNo(), "换标单号不能为空");
RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo);
if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) {
throw conflict("换标单尚未完成客户确认,不能执行拆托");
}
requireRelabelWork(identity, application);
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("换标单不属于 Token 冻结的海外仓");
}
@@ -177,29 +204,61 @@ public class PdaPalletOperationService {
}
Map<Long, Long> packagesByPosition = new LinkedHashMap<>();
Map<Long, List<RelabelItem>> itemsByPosition = new LinkedHashMap<>();
Map<Long, InventoryPosition> anchors = new LinkedHashMap<>();
InventoryPosition actual = request.actualSource() == null ? null : positions.selectById(request.actualSource().positionId());
if (request.actualSource() != null && (actual == null
|| !Objects.equals(actual.getCustomerId(), application.getCustomerId())
|| !Objects.equals(actual.getPalletId(), source.palletId())
|| !Objects.equals(actual.getOverseasWarehouseId(), application.getOverseasWarehouseId())
|| value(actual.getVersion()) != request.actualSource().expectedPositionVersion()))
throw conflict("实际来源托盘库存已变化,请刷新后确认");
if (actual != null && application.getOutboundOrderNo() != null && !application.getOutboundOrderNo().isBlank())
throw conflict("已生成出库单,请先处理出库单,不能重新分配冻结来源");
if (actual != null && marks.stream().anyMatch(boundRelabelMarks(application)::contains))
throw conflict("本批包含本轮已组托的换标箱,请刷新后选择未组托的箱");
if ("PROCESSING".equals(application.getStatus())) {
var workTask = relabelWorkTasks.find(application.getCustomerId(), application.getId());
for (RelabelItem item : selected) {
Long completed = relabelWorkFacts.selectCount(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact>()
.eq("task_id", workTask.getId()).eq("customer_id", application.getCustomerId())
.eq("item_id", item.getId()).eq("result", "RELABELLED"));
if (completed == null || completed < value(item.getNotifiedPackageCount()))
throw conflict("请先完成本箱全部换标作业,再进行组托");
}
}
for (RelabelItem item : selected) {
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null
|| item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照");
InventoryPosition position = positions.selectById(item.getSourceInventoryPositionId());
if (position == null || !Objects.equals(position.getCustomerId(), application.getCustomerId())
|| !Objects.equals(position.getPalletId(), source.palletId())
|| (actual == null && !Objects.equals(position.getPalletId(), source.palletId()))
|| (actual != null && (!Objects.equals(position.getBalanceId(), actual.getBalanceId())
|| !Objects.equals(position.getBatchNo(), actual.getBatchNo())))
|| !Objects.equals(position.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("扫描的原托盘与换标箱唛实际库存位置不一致");
throw conflict("来源托盘必须属于本次拆托的原入仓单和库存批次");
}
packagesByPosition.merge(position.getId(), item.getNotifiedPackageCount().longValue(), Long::sum);
itemsByPosition.computeIfAbsent(position.getId(), ignored -> new ArrayList<>()).add(item);
anchors.put(position.getId(), position);
Long sourcePosition = actual == null ? position.getId() : actual.getId();
packagesByPosition.merge(sourcePosition, item.getNotifiedPackageCount().longValue(), Long::sum);
itemsByPosition.computeIfAbsent(sourcePosition, ignored -> new ArrayList<>()).add(item);
}
if (actual != null && request.actualSource().packages() != packagesByPosition.get(actual.getId()))
throw conflict("拆出箱数必须等于本次确认绑定的换标箱数");
List<PlannedMove> moves = packagesByPosition.entrySet().stream().map(entry -> {
InventoryPosition position = positions.selectById(entry.getKey());
if (value(position.getHoldPackages()) < entry.getValue()) throw conflict("换标冻结库存箱数不足");
long own = selected.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), position.getId()))
.mapToLong(item -> value(item.getNotifiedPackageCount())).sum();
if (actual == null ? value(position.getHoldPackages()) < entry.getValue()
: value(position.getHoldPackages()) < own || value(position.getAvailablePackages()) < entry.getValue() - own)
throw conflict("来源托盘可拆箱数不足,不能占用其他单据冻结或已分配库存");
return new PlannedMove(entry.getKey(), entry.getValue(), value(position.getVersion()), source.palletId(),
source.palletCode(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.HOLD);
}).toList();
requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target));
requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false);
Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(),
new RelabelMutation(application, itemsByPosition));
new RelabelMutation(application, itemsByPosition, actual == null ? Map.of() : anchors));
String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank()
? "" : ":" + request.reason().trim());
return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request,
@@ -288,6 +347,10 @@ public class PdaPalletOperationService {
throw conflict("库存所在托盘已变化,请重新扫码");
}
if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码");
if (plan.relabelMutation() != null && !plan.relabelMutation().anchors().isEmpty()) {
reconcileRelabelHold(identity, operation, plan.relabelMutation(), move, key);
locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), move.positionId());
}
if (move.bucket() == InventoryBucket.HOLD) requireHeld(locked, move.packages());
else requireMovable(locked, move.packages());
customers.add(locked.getCustomerId());
@@ -296,6 +359,8 @@ public class PdaPalletOperationService {
}
if (plan.relabelMutation() != null) relabelPalletMutations.moveSelectedItems(
plan.relabelMutation().application(), plan.relabelMutation().itemsByPosition(), relabelTargets);
if (plan.relabelMutation() != null)
recordRelabelBinding(identity, plan.relabelMutation(), relabelTargets);
for (PalletChange change : plan.changes()) {
long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId());
boolean shouldBeEmpty = "IDLE".equals(change.targetStatus());
@@ -320,7 +385,8 @@ public class PdaPalletOperationService {
private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) {
RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo());
if (locked == null || !"READY_FOR_OUTBOUND".equals(locked.getStatus())
if (locked != null) requireUnallocatedRelabel(locked);
if (locked == null || !Objects.equals(locked.getStatus(), mutation.application().getStatus())
|| !Objects.equals(locked.getCustomerId(), mutation.application().getCustomerId())
|| !Objects.equals(locked.getOverseasWarehouseId(), String.valueOf(warehouseId))
|| !Objects.equals(locked.getOutboundOrderNo(), mutation.application().getOutboundOrderNo())) {
@@ -328,6 +394,52 @@ public class PdaPalletOperationService {
}
}
/** Redistributes only this application's hold within the same inbound balance; physical totals stay unchanged. */
private void reconcileRelabelHold(PdaWarehouseIdentity identity, PdaPalletOperation operation,
RelabelMutation mutation, PlannedMove move, String key) {
requireRelabelWork(identity, mutation.application());
List<RelabelItem> selected = mutation.itemsByPosition().get(move.positionId());
Set<String> alreadyBound = boundRelabelMarks(mutation.application());
if (selected.stream().anyMatch(item -> alreadyBound.contains(item.getCartonMark())))
throw conflict("换标箱已在本轮组托,请刷新后重试");
requireRelabelDestination(identity.overseasWarehouseId(), selected, physical.resolve(move.targetPalletCode()));
Map<Long, Long> release = new LinkedHashMap<>();
for (RelabelItem item : selected) {
RelabelItem current = relabelItems.selectById(item.getId());
if (current == null || !Objects.equals(current.getSourceInventoryPositionId(), item.getSourceInventoryPositionId())
|| !Objects.equals(current.getNotifiedPackageCount(), item.getNotifiedPackageCount()))
throw conflict("换标箱的绑定已变化,请刷新后重试");
if (!Objects.equals(item.getSourceInventoryPositionId(), move.positionId()))
release.merge(item.getSourceInventoryPositionId(), (long) value(item.getNotifiedPackageCount()), Long::sum);
}
InventoryPosition actual = positions.selectById(move.positionId());
long required = release.values().stream().mapToLong(Long::longValue).sum();
if (value(actual.getAvailablePackages()) < required) throw conflict("实际来源托盘可用箱数不足");
InventoryBalance balance = balances.selectById(actual.getBalanceId());
for (var entry : release.entrySet()) {
PalletInventoryRow locked = mapper.lockPosition(mutation.application().getOverseasWarehouseId(), entry.getKey());
InventoryPosition snapshot = mutation.anchors().get(entry.getKey());
if (locked == null || value(locked.getVersion()) != value(snapshot.getVersion())
|| !Objects.equals(locked.getBalanceId(), actual.getBalanceId())
|| value(locked.getHoldPackages()) < entry.getValue())
throw conflict("原冻结库存已变化,请刷新后重试");
InventoryPosition anchor = positions.selectById(entry.getKey());
adjustRelabelHold(identity, operation, balance, anchor, -entry.getValue(), key + ":RELEASE:" + anchor.getId());
}
if (required > 0) adjustRelabelHold(identity, operation, balance, actual, required, key + ":FREEZE:" + actual.getId());
}
private void adjustRelabelHold(PdaWarehouseIdentity identity, PdaPalletOperation operation,
InventoryBalance balance, InventoryPosition position, long delta, String key) {
long before = value(position.getHoldPackages());
position.setHoldPackages(before + delta);
position.setAvailablePackages(value(position.getAvailablePackages()) - delta);
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(LocalDateTime.now());
positions.updateById(position);
ledger(identity, operation, balance, position, key, before, delta, before + delta,
position.getPalletNo(), position.getPalletNo(), "现场按原入仓单确认拆托来源:冻结桶调整,可用桶反向调整,实物总数不变");
}
private InventoryPosition moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation,
PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) {
InventoryPosition source = positions.selectById(sourceRow.getPositionId());
@@ -458,6 +570,89 @@ public class PdaPalletOperationService {
if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘");
}
@jakarta.annotation.Resource
private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper relabelWorkTasks;
@jakarta.annotation.Resource
private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper relabelWorkFacts;
@jakarta.annotation.Resource
private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper relabelWorkHistory;
private Set<String> boundRelabelMarks(RelabelApplication app) {
var task = relabelWorkTasks.find(app.getCustomerId(), app.getId());
if (task == null) return Set.of();
String round = Objects.toString(task.getReviewedAt(), "INITIAL");
Set<String> marks = new HashSet<>();
var records = relabelWorkHistory.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory>()
.eq("customer_id", app.getCustomerId()).eq("task_id", task.getId()).eq("action", "PALLET_BIND"));
for (var record : records) {
try {
var data = objectMapper.readTree(record.getDetailJson());
if (round.equals(data.path("round").asText())) data.path("cartonMarks").forEach(mark -> marks.add(mark.asText()));
} catch (JsonProcessingException failure) { throw conflict("换标组托记录无法读取,请核对作业历史"); }
}
return marks;
}
private void recordRelabelBinding(PdaWarehouseIdentity identity, RelabelMutation mutation,
Map<Long, InventoryPosition> targets) {
var app = mutation.application();
var task = relabelWorkTasks.find(app.getCustomerId(), app.getId());
if (task == null) throw conflict("换标作业任务不存在,不能记录组托");
var record = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
record.setId(IdWorker.getId()); record.setTaskId(task.getId()); record.setApplicationId(app.getId());
record.setCustomerId(app.getCustomerId()); record.setAction("PALLET_BIND");
record.setFromStatus(task.getStatus()); record.setToStatus(task.getStatus());
record.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName());
record.setCreatedAt(LocalDateTime.now());
record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"),
"cartonMarks", mutation.itemsByPosition().values().stream().flatMap(List::stream).map(RelabelItem::getCartonMark).toList(),
"targetPallets", targets.values().stream().map(InventoryPosition::getPalletNo).distinct().toList())));
relabelWorkHistory.insert(record);
}
private void requireRelabelWork(PdaWarehouseIdentity identity, RelabelApplication application) {
if (application == null || !Set.of("PROCESSING", "READY_FOR_OUTBOUND").contains(application.getStatus()))
throw conflict("当前换标状态不能组托");
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId())))
throw conflict("换标单不属于当前海外仓");
requireUnallocatedRelabel(application);
if ("PROCESSING".equals(application.getStatus())) {
var task = relabelWorkTasks.find(application.getCustomerId(), application.getId());
if (task == null || !"PROCESSING".equals(task.getStatus()) || !identity.operatorKey().equals(task.getOperatorKey()))
throw conflict("请先认领当前换标任务");
}
}
private void requireUnallocatedRelabel(RelabelApplication application) {
String fulfillment = application.getFulfillmentStatus();
if (org.springframework.util.StringUtils.hasText(application.getOutboundOrderNo())
|| (org.springframework.util.StringUtils.hasText(fulfillment)
&& !Set.of("WAIT_RELABEL", "WAIT_PRE_DISPATCH").contains(fulfillment)))
throw conflict("换标单已进入排车或出库流程,请先解除占用后再改绑托盘");
}
private void requireRelabelDestination(long warehouseId, List<RelabelItem> selected,
PalletPhysicalGateway.PalletReference target) {
Set<String> destinations = new HashSet<>();
selected.forEach(item -> destinations.add(text(item.getDestinationFbaWarehouseCode())));
if (destinations.size() != 1 || destinations.contains("")) throw conflict("请按已维护的新目的仓分批组托");
for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), target.palletId())) {
var bound = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
.eq("customer_id", row.getCustomerId()).eq("source_inventory_position_id", row.getPositionId()));
long held = 0;
for (RelabelItem item : bound) {
var app = relabelApplications.selectById(item.getApplicationId());
if (app != null && Set.of("PROCESSING", "CUSTOMER_CONFIRM_PENDING", "READY_FOR_OUTBOUND").contains(app.getStatus())) {
held += value(item.getNotifiedPackageCount()); destinations.add(text(item.getDestinationFbaWarehouseCode()));
}
}
if (value(row.getAvailablePackages()) > 0 || value(row.getAllocatedPackages()) > 0
|| value(row.getPickedPackages()) > 0 || value(row.getHoldPackages()) > held)
destinations.add(text(row.getDestinationWarehouseCode()));
}
if (destinations.size() != 1) throw conflict("目标托盘内货物与本次换标的新目的仓不同,不能合托");
}
private Map<String, PalletPhysicalGateway.PalletReference> resolvePallets(long warehouseId,
PalletOperationRequests.PalletExpectation first, List<PalletOperationRequests.PalletExpectation> rest) {
List<PalletOperationRequests.PalletExpectation> values = new ArrayList<>(); values.add(first); values.addAll(rest);
@@ -658,6 +853,7 @@ public class PdaPalletOperationService {
List<PlannedMove> moves, List<PalletChange> changes,
RelabelMutation relabelMutation) { }
private record RelabelMutation(RelabelApplication application,
Map<Long, List<RelabelItem>> itemsByPosition) { }
Map<Long, List<RelabelItem>> itemsByPosition,
Map<Long, InventoryPosition> anchors) { }
private enum InventoryBucket { AVAILABLE, HOLD }
}
@@ -36,6 +36,16 @@ public class RelabelApplicationController {
return ApiResponse.success(pdaService.returnForRework(operator(user), user.username(), relabelNo, request));
}
@PostMapping("/{relabelNo}/confirm-completion")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
public ApiResponse<RelabelResponses.Detail> confirmCompletion(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo,
@Valid @RequestBody RelabelRequests.CustomerConfirm request) {
requirePlatform(user);
String customerId = service.customerId(relabelNo);
return ApiResponse.success(service.confirmCompletion(customerId, relabelNo, operator(user), request));
}
@GetMapping("/{relabelNo}/review-task")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
public ApiResponse<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> reviewTask(
@@ -39,9 +39,9 @@ public final class RelabelRequests {
}
public record Item(
@NotNull Long sourceInventoryBalanceId,
@NotNull Long sourceInventoryPositionId,
@NotNull Long sourceInboundOrderId,
Long sourceInventoryBalanceId,
Long sourceInventoryPositionId,
Long sourceInboundOrderId,
@Size(max = 128) String skuCode,
@Size(max = 128) String productImageFileId,
@Size(max = 255) String productImageFileName,
@@ -61,8 +61,22 @@ public final class RelabelRequests {
@Size(max = 255) String fbaCartonMark,
@Size(max = 128) String destinationFbaWarehouseCode,
@Size(max = 32) String operationType,
@Size(max = 100) List<@Valid SkuItem> skuItems
@Size(max = 100) List<@Valid SkuItem> skuItems,
Long returnCartonId
) {
public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId,
String skuCode, String productImageFileId, String productImageFileName, String productImageUrl,
String productNameZh, int totalPackageCount, int notifiedPackageCount, String shipmentNo,
String trackingCode, String newSkuCode, String labelRequirement, String remark,
String relabelFileId, String relabelFileName, String relabelFileUrl, String cartonMark,
String fbaCartonMark, String destinationFbaWarehouseCode, String operationType,
List<SkuItem> skuItems) {
this(sourceInventoryBalanceId, sourceInventoryPositionId, sourceInboundOrderId, skuCode,
productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount,
notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark,
relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark,
destinationFbaWarehouseCode, operationType, skuItems, null);
}
/** 保持既有箱唛换标调用方的二进制参数形态。 */
public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId,
String skuCode, String productImageFileId, String productImageFileName, String productImageUrl,
@@ -74,7 +88,7 @@ public final class RelabelRequests {
productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount,
notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark,
relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark,
destinationFbaWarehouseCode, "CARTON_LABEL", List.of());
destinationFbaWarehouseCode, "CARTON_LABEL", List.of(), null);
}
public Item(String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount,
@@ -85,7 +99,7 @@ public final class RelabelRequests {
this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl,
productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode,
newSkuCode, labelRequirement, remark, relabelFileId, relabelFileName, relabelFileUrl,
cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of());
cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of(), null);
}
public Item(String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount,
@@ -94,7 +108,7 @@ public final class RelabelRequests {
this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh,
totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode,
labelRequirement, null, relabelFileId, relabelFileName, relabelFileUrl, skuCode, shipmentNo, "ONT8",
"CARTON_LABEL", List.of());
"CARTON_LABEL", List.of(), null);
}
}
@@ -36,7 +36,9 @@ public final class RelabelResponses {
String inboundCode, String overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName, String destinationFbaWarehouseCode,
String destinationFbaWarehouseName, String skuCode, String productNameZh,
long availablePackages, long version, List<InventoryPosition> positions) {}
long availablePackages, long version, List<InventoryPosition> positions,
String sourceType, Long returnCartonId, String cartonMark,
List<SkuItem> skuItems) {}
public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo,
String relabelOrderNo, String orderType, String status, Integer packageCount,
LocalDateTime createdAt) {}
@@ -20,7 +20,7 @@ public class HttpRelabelDocumentFileGateway implements RelabelDocumentFileGatewa
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9206}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@@ -6,4 +6,14 @@ import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RelabelItemMapper extends BaseMapper<RelabelItem> {
@org.apache.ibatis.annotations.Select("SELECT id FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} FOR UPDATE")
Long lockSourceBalance(@org.apache.ibatis.annotations.Param("customerId") String customerId,
@org.apache.ibatis.annotations.Param("balanceId") Long balanceId);
@org.apache.ibatis.annotations.Select("SELECT a.relabel_no FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id " +
"WHERE i.customer_id=#{customerId} AND a.customer_id=#{customerId} AND i.source_inventory_balance_id=#{balanceId} " +
"AND UPPER(TRIM(i.carton_mark))=UPPER(TRIM(#{mark})) AND a.status NOT IN ('CANCELLED','REJECTED') LIMIT 1")
String findExistingCarton(@org.apache.ibatis.annotations.Param("customerId") String customerId,
@org.apache.ibatis.annotations.Param("balanceId") Long balanceId,
@org.apache.ibatis.annotations.Param("mark") String mark);
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.relabel.model;
import java.util.Arrays;
/** 换标申请业务状态。 */
public enum RelabelApplicationStatus {
PENDING_PROCESSING("待处理"),
PROCESSING("处理中"),
CUSTOMER_CONFIRM_PENDING("待客户确认"),
READY_FOR_OUTBOUND("换标完成,待出库"),
COMPLETED("已完成"),
REJECTED("已拒绝"),
CANCELLED("已取消");
private final String label;
RelabelApplicationStatus(String label) { this.label = label; }
public String code() { return name(); }
public String label() { return label; }
public static RelabelApplicationStatus fromCode(String code) {
return Arrays.stream(values()).filter(value -> value.name().equals(code)).findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unsupported relabel application status: " + code));
}
}
@@ -70,6 +70,14 @@ public class PdaRelabelController {
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/skus/scan")
public ApiResponse<PdaRelabelResponses.Detail> scanSku(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.SkuScan request) {
Identity identity = identity(user);
return ApiResponse.success(service.scanSku(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@GetMapping("/{relabelNo}/documents")
public ApiResponse<List<PdaRelabelService.Document>> documents(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo) {
@@ -14,6 +14,10 @@ public final class PdaRelabelRequests {
private PdaRelabelRequests() { }
public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { }
public record SkuScan(@NotNull @Positive Long itemId, @Min(0) int skuIndex,
@NotBlank @Size(max = 255) String oldSku,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record CartonScan(@NotBlank @Size(max = 255) String cartonMark,
@NotNull @Min(0) Integer expectedVersion,
@@ -26,7 +26,8 @@ public final class PdaRelabelResponses {
String cartonMark, String fbaCartonMark, String operationType,
List<SkuItem> skuItems,
int expectedPackages, int relabelledPackages, int skippedPackages,
int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages) { }
int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages,
String destinationFbaWarehouseCode) { }
public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { }
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
@@ -15,8 +15,18 @@ public interface PdaRelabelTaskMapper extends BaseMapper<PdaRelabelTask> {
@Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1 FOR UPDATE")
PdaRelabelTask lock(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
@Select("SELECT * FROM ll_relabel_pda_task WHERE operator_key=#{operatorKey} AND status<>'COMPLETED' ORDER BY claimed_at ASC LIMIT 1 FOR UPDATE")
PdaRelabelTask lockUnfinishedByOperator(@Param("operatorKey") String operatorKey);
@Select("""
SELECT task.*
FROM ll_relabel_pda_task task
JOIN ll_relabel_application application
ON application.id=task.application_id AND application.customer_id=task.customer_id
WHERE task.operator_key=#{operatorKey}
AND task.status='PROCESSING'
AND application.status='PROCESSING'
ORDER BY task.claimed_at ASC
LIMIT 1 FOR UPDATE
""")
PdaRelabelTask lockProcessingByOperator(@Param("operatorKey") String operatorKey);
@Update("""
UPDATE ll_relabel_pda_task
@@ -51,6 +51,7 @@ import java.util.stream.Collectors;
@Service
public class PdaRelabelService {
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard;
public static final String PROCESSING = "PROCESSING";
public static final String WAIT_REVIEW = "WAIT_REVIEW";
public static final String COMPLETED = "COMPLETED";
@@ -109,7 +110,7 @@ public class PdaRelabelService {
throw conflict("Relabel application is not executable");
}
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
PdaRelabelTask activeTask = tasks.lockUnfinishedByOperator(operatorKey);
PdaRelabelTask activeTask = tasks.lockProcessingByOperator(operatorKey);
if (activeTask != null && !Objects.equals(activeTask.getApplicationId(), application.getId())) {
throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单");
}
@@ -194,6 +195,49 @@ public class PdaRelabelService {
public record Document(String scope, String fileId, String fileName, Long itemId) { }
@Transactional
public PdaRelabelResponses.Detail scanSku(String operatorKey, String operatorName, long warehouseId,
String relabelNo, PdaRelabelRequests.SkuScan request) {
UnitCommand command = new UnitCommand(relabelNo, "SCAN_SKU", request);
var replayed = replay(operatorKey, warehouseId, relabelNo, "SCAN_SKU", request.idempotencyKey(), command);
if (replayed != null) return replayed;
var app = requireApplication(relabelNo, warehouseId, true);
var task = requireOwnedTask(app, operatorKey, true);
requireVersion(task, request.expectedVersion());
var item = requireItem(app, request.itemId());
if (!Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))) throw conflict("该箱没有SKU换标任务");
if (value(item.getNotifiedPackageCount()) != 1) throw conflict("SKU逐条作业要求每个仓库唛头独立一箱");
var skus = readSkuItems(item.getSkuRelabelItemsJson());
if (request.skuIndex() < 0 || request.skuIndex() >= skus.size()) throw conflict("SKU明细不存在");
var sku = skus.get(request.skuIndex());
if (!Objects.equals(sku.skuCode(), request.oldSku().trim()) || !StringUtils.hasText(sku.newSkuCode()) || sku.quantity() <= 0)
throw conflict("旧SKU或绑定的新标签、数量不正确");
if (factList(task).stream().anyMatch(fact -> item.getId().equals(fact.getItemId()))
|| mixedStageDone(task, item.getId(), "SKU")) throw conflict("本箱SKU作业已完成");
Set<Integer> done = new java.util.HashSet<>();
for (var history : histories.selectList(new LambdaQueryWrapper<PdaRelabelHistory>()
.eq(PdaRelabelHistory::getTaskId, task.getId()).eq(PdaRelabelHistory::getCustomerId, app.getCustomerId())
.eq(PdaRelabelHistory::getAction, "SKU_SCAN"))) {
if (!"SKU_SCAN".equals(history.getAction())) continue;
try {
var data = objectMapper.readTree(history.getDetailJson());
if (mixedRound(task).equals(data.path("round").asText()) && item.getId().toString().equals(data.path("itemId").asText()))
done.add(data.path("skuIndex").asInt(-1));
} catch (Exception failure) { throw conflict("SKU扫描历史无法读取"); }
}
if (!done.add(request.skuIndex())) throw conflict("该SKU明细已处理,请勿重复扫描");
insertHistory(task, "SKU_SCAN", PROCESSING, PROCESSING, operatorKey, operatorName,
Map.of("round", mixedRound(task), "itemId", item.getId().toString(), "skuIndex", request.skuIndex(),
"oldSku", sku.skuCode(), "newSku", sku.newSkuCode(), "quantity", sku.quantity()));
if (done.size() == skus.size()) return unitAction(operatorKey, operatorName, warehouseId, relabelNo,
"SCAN_SKU", request.idempotencyKey(), request, item.getId(), item.getCartonMark(), null,
"RELABELLED", null, null, request.expectedVersion(), ignored -> { });
updateTask(task, request.expectedVersion());
var result = detail(app, task);
saveOperation(task, operatorKey, "SCAN_SKU", request.idempotencyKey(), command, result);
return result;
}
@Transactional(readOnly = true)
public List<Document> documents(long warehouseId, String relabelNo) {
RelabelApplication app = requireApplication(relabelNo, warehouseId, false);
@@ -264,6 +308,7 @@ public class PdaRelabelService {
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
requireVersion(task, request.expectedVersion());
if (!request.manualCompletion()) bindingGuard.requireBound(application);
List<RelabelItem> detailItems = itemList(application);
List<PdaRelabelUnitFact> facts = factList(task);
boolean incomplete = detailItems.stream().anyMatch(item -> facts.stream()
@@ -558,7 +603,7 @@ public class PdaRelabelService {
private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task,
String operatorKey) {
return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(),
application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(),
application.getLabelDeadline(), application.getStatus(), effectiveTaskStatus(application, task),
operationType(application), value(application.getItemCount()),
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
@@ -580,7 +625,8 @@ public class PdaRelabelService {
value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"),
count(itemFacts, "EXCEPTION"),
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0),
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0));
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0),
item.getDestinationFbaWarehouseCode());
}).toList();
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
@@ -593,7 +639,7 @@ public class PdaRelabelService {
history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList();
return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(),
application.getLabelDeadline(), application.getContactName(), application.getContactPhone(),
application.getStatus(), operationType(application), task == null ? "AVAILABLE" : task.getStatus(),
application.getStatus(), operationType(application), effectiveTaskStatus(application, task),
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
@@ -605,6 +651,13 @@ public class PdaRelabelService {
itemResponses, factResponses, historyResponses);
}
private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) {
if (task == null) return "AVAILABLE";
if (RelabelApplicationService.CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())
&& PROCESSING.equals(task.getStatus())) return WAIT_REVIEW;
return task.getStatus();
}
private void insertHistory(PdaRelabelTask task, String action, String from, String to,
String operatorKey, String operatorName, Object detail) {
PdaRelabelHistory history = new PdaRelabelHistory();
@@ -29,6 +29,10 @@ import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
@@ -84,6 +88,8 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private InventoryCommandService inventoryCommands;
@Resource private InventoryBalanceMapper inventoryBalances;
@Resource private InventoryPositionMapper inventoryPositions;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private RelabelDocumentFileGateway documentFiles;
@Resource private PdaRelabelTaskMapper pdaTasks;
@Resource private RelabelPhotoFileGateway photoFiles;
@@ -238,7 +244,7 @@ public class RelabelApplicationService implements WalletResultHandler {
.gt(InventoryBalance::getAvailablePackages, 0)
.like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode))
.orderByDesc(InventoryBalance::getUpdatedAt));
return balances.stream().map(balance -> {
List<RelabelResponses.InventoryCandidate> result = new java.util.ArrayList<>(balances.stream().map(balance -> {
List<RelabelResponses.InventoryPosition> positions = inventoryPositions.listBalance(customerId, balance.getId())
.stream().filter(position -> value(position.getAvailablePackages()) > 0)
.map(position -> new RelabelResponses.InventoryPosition(position.getId(), position.getPalletNo(),
@@ -249,8 +255,27 @@ public class RelabelApplicationService implements WalletResultHandler {
balance.getInboundOrderNo(), balance.getInboundCode(), balance.getOverseasWarehouseId(),
balance.getOverseasWarehouseCode(), balance.getOverseasWarehouseName(),
balance.getDestinationWarehouseCode(), balance.getDestinationWarehouseName(), balance.getSkuCode(),
balance.getProductNameZh(), available, value(balance.getVersion()), positions);
}).filter(candidate -> candidate.availablePackages() > 0).toList();
balance.getProductNameZh(), available, value(balance.getVersion()), positions,
"INBOUND", null, null, List.of());
}).filter(candidate -> candidate.availablePackages() > 0).toList());
for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) {
String keyword = trim(inboundCode);
if (StringUtils.hasText(keyword)
&& !containsIgnoreCase(carton.getCartonMark(), keyword)
&& !containsIgnoreCase(carton.getSourceReturnNo(), keyword)) continue;
List<RelabelResponses.SkuItem> cartonSkus = returnCartonItems.listCarton(carton.getId()).stream()
.filter(item -> value(item.getQuantity()) > 0)
.map(item -> new RelabelResponses.SkuItem(item.getSkuCode(), null,
Math.toIntExact(item.getQuantity()), null)).toList();
if (cartonSkus.isEmpty()) continue;
String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode)
.collect(Collectors.joining(","));
result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(),
null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(),
carton.getDestinationWarehouseName(), skuCodes, "退货合箱库存", 1, 0, List.of(),
"RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus));
}
return result;
}
@Transactional(readOnly = true)
@@ -280,6 +305,19 @@ public class RelabelApplicationService implements WalletResultHandler {
}
@Transactional
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
return create(customerId, operatorId, request, null);
}
@Transactional
public RelabelResponses.Detail createForExistingTailOrder(String customerId, String operatorId,
Long existingTailOrderId,
RelabelRequests.Create request) {
if (existingTailOrderId == null) throw badRequest("Existing tail order is required");
return create(customerId, operatorId, request, existingTailOrderId);
}
private RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request,
Long existingTailOrderId) {
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
String deliveryMethod = trim(request.deliveryMethod());
@@ -298,6 +336,13 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data");
return detail(customerId, replay.getRelabelNo());
}
Set<String> requestedDestinations = request.items().stream()
.filter(item -> !"SKU_LABEL".equals(operationType(item)))
.map(RelabelRequests.Item::destinationFbaWarehouseCode)
.filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet());
if (requestedDestinations.size() > 1) {
throw badRequest("同一张贴标申请只能选择一个 FBA 目的仓");
}
int total = 0;
int notified = 0;
List<SourceInventory> sources = new java.util.ArrayList<>();
@@ -312,6 +357,25 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!selectedCartonMarks.add(item.cartonMark().trim().toUpperCase())) {
throw badRequest("The same carton mark cannot create more than one relabel task");
}
if (item.returnCartonId() != null) {
ReturnInventoryCarton carton = returnCartons.lockId(item.returnCartonId());
if (carton == null || !customerId.equals(carton.getCustomerId())
|| !request.operationWarehouseId().trim().equals(carton.getOverseasWarehouseId())) {
throw conflict("所选退货箱不属于当前客户和操作仓");
}
if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) {
throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标");
}
if (!carton.getCartonMark().equalsIgnoreCase(item.cartonMark().trim())) {
throw conflict("退货箱仓库唛头已变化,请重新查询");
}
sources.add(new SourceInventory(item, null, carton));
continue;
}
if (item.sourceInventoryBalanceId() == null || item.sourceInventoryPositionId() == null
|| item.sourceInboundOrderId() == null) {
throw badRequest("正常入仓库存必须提供余额、仓位和入仓单");
}
InventoryPosition position = inventoryPositions.findTenantPosition(customerId, item.sourceInventoryPositionId());
if (position == null || !Objects.equals(position.getBalanceId(), item.sourceInventoryBalanceId())) {
throw conflict("Selected inventory position is no longer available");
@@ -325,7 +389,7 @@ public class RelabelApplicationService implements WalletResultHandler {
if (value(position.getAvailablePackages()) < item.notifiedPackageCount()) {
throw conflict("Available inventory changed for inbound " + balance.getInboundOrderNo());
}
sources.add(new SourceInventory(item, balance));
sources.add(new SourceInventory(item, balance, null));
}
LocalDateTime now = LocalDateTime.now();
long applicationId = IdWorker.getId();
@@ -335,13 +399,15 @@ public class RelabelApplicationService implements WalletResultHandler {
boolean requiresWallet = chargeAmount.signum() > 0;
int holdIndex = 0;
for (SourceInventory source : sources) {
if (source.returnCarton() != null) continue;
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
source.request().sourceInventoryPositionId(), source.request().notifiedPackageCount(), relabelNo,
null, "RELABEL:" + relabelNo, "RELABEL:" + applicationId + ":HOLD:" + holdIndex++,
"贴标申请冻结原入仓单库存"));
}
LastlegOrder tailOrder = insertTailOrder(customerId, request, requestHash, relabelNo,
operationWarehouseId, total, now);
LastlegOrder tailOrder = existingTailOrderId == null
? insertTailOrder(customerId, request, requestHash, relabelNo, operationWarehouseId, total, now)
: linkExistingTailOrder(customerId, existingTailOrderId, relabelNo, total, now);
List<LastlegOrder> tailOrders = List.of(tailOrder);
RelabelApplication application = new RelabelApplication();
@@ -372,6 +438,14 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setUpdatedAt(now);
applications.insert(application);
for (SourceInventory source : sources) {
if (source.returnCarton() == null) continue;
ReturnInventoryCarton carton = source.returnCarton();
carton.setRelabelApplicationId(applicationId); carton.setRelabelNo(relabelNo);
carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now);
returnCartons.updateById(carton);
}
if (requiresWallet) {
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
relabelNo, chargeAmount, "换标订单费用冻结", 1L);
@@ -383,8 +457,10 @@ public class RelabelApplicationService implements WalletResultHandler {
insertItem(customerId, applicationId, source, now);
}
insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now);
tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null,
order.getCurrentStatus(), "Relabel application submitted", now));
if (existingTailOrderId == null) {
tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null,
order.getCurrentStatus(), "Relabel application submitted", now));
}
return detail(customerId, relabelNo);
}
@@ -501,7 +577,8 @@ public class RelabelApplicationService implements WalletResultHandler {
}
for (LastlegOrder order : linkedOrders) {
String orderBefore = order.getCurrentStatus();
String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore;
String mainStatus = "RELABEL_APPLICATION".equals(order.getOrderType())
? "WAIT_RELABEL" : orderBefore;
String orderStatus = switch (next) {
case CUSTOMER_CONFIRM_PENDING -> mainStatus;
case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType())
@@ -606,23 +683,27 @@ public class RelabelApplicationService implements WalletResultHandler {
PdaRelabelTask reviewTask = pdaTasks.lock(customerId, application.getId());
if (reviewTask == null || !Set.of("WAIT_REVIEW", "COMPLETED").contains(reviewTask.getStatus()))
throw conflict("仓库尚未提交换标完工复核");
reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CUSTOMER_CONFIRMED");
reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认");
reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now());
reviewTask.setUpdatedAt(LocalDateTime.now());
if (pdaTasks.updateWithVersion(reviewTask, reviewTask.getVersion()) != 1) throw conflict("复核状态已变化,请刷新");
if (Boolean.TRUE.equals(application.getPrintShippingLabel())
&& !StringUtils.hasText(application.getShippingLabelFileId())) {
throw conflict("Upload the shipping label before confirming relabel completion");
}
List<RelabelItem> relabelItems = itemList(application.getId());
boolean requiresOutbound = relabelItems.stream()
.anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED");
reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认");
reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now());
reviewTask.setUpdatedAt(LocalDateTime.now());
if (pdaTasks.updateWithVersion(reviewTask, reviewTask.getVersion()) != 1) throw conflict("复核状态已变化,请刷新");
LocalDateTime now = LocalDateTime.now();
String before = application.getStatus();
application.setStatus(READY_FOR_OUTBOUND);
application.setStatus(requiresOutbound ? READY_FOR_OUTBOUND : "COMPLETED");
application.setCustomerConfirmationKey(key);
application.setCustomerConfirmedAt(now);
application.setFulfillmentStatus("TRUCK".equals(application.getDeliveryMethod())
? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION");
if (!"TRUCK".equals(application.getDeliveryMethod())) {
application.setFulfillmentStatus(requiresOutbound
? ("TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION")
: "NOT_REQUIRED");
if (requiresOutbound && !"TRUCK".equals(application.getDeliveryMethod())) {
createDirectOutbound(application, operatorId, now);
application.setFulfillmentStatus("OUTBOUND_CREATED");
}
@@ -630,7 +711,7 @@ public class RelabelApplicationService implements WalletResultHandler {
applications.updateById(application);
for (LastlegOrder order : tailOrders(application)) {
String orderBefore = order.getCurrentStatus();
order.setCurrentStatus("PENDING_ASSIGNMENT");
order.setCurrentStatus(requiresOutbound ? "PENDING_ASSIGNMENT" : "COMPLETED");
order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus());
order.setRelabelStatus("COMPLETED");
order.setUpdatedAt(now);
@@ -640,24 +721,34 @@ public class RelabelApplicationService implements WalletResultHandler {
request.remark(), now);
}
}
insertHistory(customerId, application.getId(), before, READY_FOR_OUTBOUND, operatorId,
insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId,
request.remark(), now);
// 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。
if (warehouseTransfers != null) {
WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo);
if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) {
transfer.setStatus("READY_FOR_DISPATCH");
Set<String> destinations = relabelItems.stream()
.map(RelabelItem::getDestinationFbaWarehouseCode)
.filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet());
if (!StringUtils.hasText(transfer.getFbaWarehouseCode()) && destinations.size() == 1) {
transfer.setFbaWarehouseCode(destinations.iterator().next());
}
String transferStatus = StringUtils.hasText(transfer.getFbaWarehouseCode())
? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION";
transfer.setStatus(transferStatus);
transfer.setRelabelCompletedAt(now);
transfer.setUpdatedAt(now);
warehouseTransfers.updateById(transfer);
application.setFulfillmentStatus(transferStatus);
applications.updateById(application);
// 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。
LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId());
if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) {
String transferOrderBefore = transferOrder.getCurrentStatus();
transferOrder.setCurrentStatus("READY_FOR_DISPATCH");
transferOrder.setCurrentStatus(transferStatus);
transferOrder.setFbaWarehouseCode(transfer.getFbaWarehouseCode());
transferOrder.setRelabelStatus("COMPLETED");
transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:READY_FOR_DISPATCH;RELABEL:"
+ "COMPLETED");
transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transferStatus + ";RELABEL:COMPLETED");
transferOrder.setUpdatedAt(now);
orders.updateById(transferOrder);
if (!Objects.equals(transferOrderBefore, transferOrder.getCurrentStatus())) {
@@ -683,27 +774,28 @@ public class RelabelApplicationService implements WalletResultHandler {
outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND");
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> intValue(item.getNotifiedPackageCount())).sum());
outbound.setPalletCount((int) relabelItems.stream().map(RelabelItem::getSourceInventoryPositionId).filter(Objects::nonNull).distinct().count());
outbound.setPalletCount((int) relabelItems.stream().map(this::outboundSourceKey).distinct().count());
outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound);
Map<Long, List<RelabelItem>> byPosition = relabelItems.stream().collect(Collectors.groupingBy(
RelabelItem::getSourceInventoryPositionId, LinkedHashMap::new, Collectors.toList()));
this::outboundSourceKey, LinkedHashMap::new, Collectors.toList()));
for (Map.Entry<Long, List<RelabelItem>> entry : byPosition.entrySet()) {
InventoryPosition position = inventoryPositions.selectById(entry.getKey());
if (position == null || !application.getCustomerId().equals(position.getCustomerId())) {
InventoryPosition position = entry.getKey() > 0 ? inventoryPositions.selectById(entry.getKey()) : null;
if (entry.getKey() > 0 && (position == null || !application.getCustomerId().equals(position.getCustomerId()))) {
throw conflict("Relabel source inventory position no longer exists");
}
List<RelabelItem> group = entry.getValue();
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId());
item.setCustomerId(application.getCustomerId()); item.setOutboundOrderId(outbound.getId());
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo());
item.setWarehousePalletId(position == null ? null : position.getPalletId());
item.setPalletNo(position == null ? null : position.getPalletNo());
item.setPackageCount(group.stream().mapToInt(value -> intValue(value.getNotifiedPackageCount())).sum());
item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode)
.filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), entry.getKey(), group));
item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), position == null ? null : entry.getKey(), group));
item.setCreatedAt(now); outboundItems.insert(item);
}
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId());
@@ -714,11 +806,14 @@ public class RelabelApplicationService implements WalletResultHandler {
private String writeOutboundRefs(String relabelNo, Long positionId, List<RelabelItem> values) {
try {
return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo,
"sourceInventoryPositionId", positionId, "items", values.stream().map(item -> Map.of(
Map<String, Object> refs = new LinkedHashMap<>();
refs.put("sourceType", "RELABEL_ORDER"); refs.put("relabelNo", relabelNo);
if (positionId != null) refs.put("sourceInventoryPositionId", positionId);
refs.put("items", values.stream().map(item -> Map.of(
"itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""),
"destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""),
"packages", intValue(item.getNotifiedPackageCount()))).toList()));
"packages", intValue(item.getNotifiedPackageCount()))).toList());
return objectMapper.writeValueAsString(refs);
} catch (Exception exception) { throw new IllegalStateException("Cannot save relabel outbound snapshot", exception); }
}
@@ -740,14 +835,21 @@ public class RelabelApplicationService implements WalletResultHandler {
item.setDestinationFbaWarehouseCode(trim(source.destinationFbaWarehouseCode()));
item.setOperationType(operationType(source));
item.setSkuRelabelItemsJson(writeSkuItems(source.skuItems()));
item.setSourceInventoryBalanceId(balance.getId());
item.setSourceInventoryPositionId(source.sourceInventoryPositionId());
item.setSourceInboundOrderId(balance.getInboundOrderId());
item.setSourceInboundOrderNo(balance.getInboundOrderNo());
item.setSourceInboundCode(balance.getInboundCode());
item.setSourceWarehouseId(balance.getOverseasWarehouseId());
item.setSourceWarehouseCode(balance.getOverseasWarehouseCode());
item.setSourceWarehouseName(balance.getOverseasWarehouseName());
if (balance != null) {
item.setSourceInventoryBalanceId(balance.getId());
item.setSourceInventoryPositionId(source.sourceInventoryPositionId());
item.setSourceInboundOrderId(balance.getInboundOrderId());
item.setSourceInboundOrderNo(balance.getInboundOrderNo());
item.setSourceInboundCode(balance.getInboundCode());
item.setSourceWarehouseId(balance.getOverseasWarehouseId());
item.setSourceWarehouseCode(balance.getOverseasWarehouseCode());
item.setSourceWarehouseName(balance.getOverseasWarehouseName());
} else {
ReturnInventoryCarton carton = sourceInventory.returnCarton();
item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark());
item.setSourceInboundOrderNo(carton.getSourceReturnNo());
item.setSourceWarehouseId(carton.getOverseasWarehouseId());
}
item.setCreatedAt(now); items.insert(item);
}
@@ -831,7 +933,7 @@ public class RelabelApplicationService implements WalletResultHandler {
order.setSourceInboundId(relabelNo + ":" + warehouseId);
order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId);
order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0);
order.setShippedPackageCount(0); order.setCurrentStatus("PENDING_ASSIGNMENT");
order.setShippedPackageCount(0); order.setCurrentStatus("WAIT_RELABEL");
order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING); order.setRelabelOrderNo(relabelNo);
order.setRelabelStatus(PENDING_PROCESSING); order.setCustomerRemark(trim(request.remark()));
order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo + ":" + warehouseId);
@@ -840,6 +942,17 @@ public class RelabelApplicationService implements WalletResultHandler {
return order;
}
private LastlegOrder linkExistingTailOrder(String customerId, Long orderId, String relabelNo,
int packageCount, LocalDateTime now) {
LastlegOrder order = orders.selectById(orderId);
if (order == null || !customerId.equals(order.getCustomerId())) {
throw conflict("Existing tail order does not belong to this customer");
}
order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_PROCESSING);
order.setPackageCount(packageCount); order.setUpdatedAt(now); orders.updateById(order);
return order;
}
private List<LastlegOrder> tailOrders(RelabelApplication application) {
List<LastlegOrder> linked = orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, application.getCustomerId())
@@ -850,12 +963,27 @@ public class RelabelApplicationService implements WalletResultHandler {
return legacy == null ? List.of() : List.of(legacy);
}
private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance) {}
private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance,
ReturnInventoryCarton returnCarton) {}
private static long value(Long value) { return value == null ? 0L : value; }
private static int intValue(Integer value) { return value == null ? 0 : value; }
private static boolean containsIgnoreCase(String value, String fragment) {
return value != null && fragment != null
&& value.toLowerCase(java.util.Locale.ROOT).contains(fragment.toLowerCase(java.util.Locale.ROOT));
}
private void releaseInventory(String customerId, String operatorId, RelabelApplication application) {
for (RelabelItem item : itemList(application.getId())) {
if (item.getReturnCartonId() != null) {
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
if (carton != null && customerId.equals(carton.getCustomerId())
&& Objects.equals(application.getId(), carton.getRelabelApplicationId())
&& "RELABEL_PENDING".equals(carton.getStatus())) {
carton.setRelabelApplicationId(null); carton.setRelabelNo(null); carton.setRelabelStatus(null);
carton.setStatus("AVAILABLE"); carton.setUpdatedAt(LocalDateTime.now()); returnCartons.updateById(carton);
}
continue;
}
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null) continue;
inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move(
item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), application.getRelabelNo(), null,
@@ -938,9 +1066,15 @@ public class RelabelApplicationService implements WalletResultHandler {
private static String businessRelabelStatus(String workflowStatus) {
if ("PROCESSING".equals(workflowStatus)) return "PROCESSING";
return Set.of(READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus)
return Set.of(CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus)
? "COMPLETED" : PENDING_PROCESSING;
}
private long outboundSourceKey(RelabelItem item) {
if (item.getSourceInventoryPositionId() != null) return item.getSourceInventoryPositionId();
if (item.getReturnCartonId() != null) return -item.getReturnCartonId();
throw conflict("Relabel outbound item has no physical inventory source");
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.relabel.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
/** Requires saved physical bindings, never the original accounting freeze anchor. */
@Service
public class RelabelBindingGuard {
@Resource private RelabelItemMapper items;
@Resource private PdaRelabelHistoryMapper histories;
@Resource private InventoryPositionMapper positions;
@Resource private ObjectMapper objectMapper;
public void requireBound(RelabelApplication app) {
List<RelabelItem> cartons = items.selectList(new QueryWrapper<RelabelItem>()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()))
.stream().filter(item -> !"SKU_LABEL".equals(item.getOperationType())).toList();
if (cartons.isEmpty()) return;
Map<String, Set<String>> bindings = new HashMap<>();
for (PdaRelabelHistory history : histories.selectList(new QueryWrapper<PdaRelabelHistory>()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())
.orderByAsc("created_at", "id"))) {
if ("REWORK_ARCHIVE".equals(history.getAction())) bindings.clear();
if (!"PALLET_BIND".equals(history.getAction())) continue;
try {
var data = objectMapper.readTree(history.getDetailJson());
Set<String> targets = new HashSet<>();
data.path("targetPallets").forEach(value -> targets.add(value.asText()));
data.path("cartonMarks").forEach(value -> bindings.put(value.asText(), targets));
} catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); }
}
for (RelabelItem item : cartons) {
var position = item.getSourceInventoryPositionId() == null ? null
: positions.findTenantPosition(app.getCustomerId(), item.getSourceInventoryPositionId());
if (position == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo())
|| position.getHoldPackages() == null || position.getHoldPackages() < item.getNotifiedPackageCount())
throw conflict("箱唛 " + item.getCartonMark() + " 尚未完成托盘绑定,请先在 PDA 确认组托,不能完成或排车出库");
}
}
private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -569,7 +569,7 @@ public class LastlegOrderService {
public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output)
throws IOException {
output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,客户预报箱数,实收箱数,差异箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long page = 1;
while (true) {
@@ -585,7 +585,8 @@ public class LastlegOrderService {
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId())
? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(order.getAbnormal()), csv(order.getDetained()),
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getReceivedPackageCount()),
csv(packageDifference(order)), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(relabelBusinessStatus(order.getRelabelStatus())),
csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()),
@@ -677,7 +678,8 @@ public class LastlegOrderService {
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode,
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(),
o.getReceivedPackageCount(), packageDifference(o), o.getVolumeCbm(),
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), relabelBusinessStatus(o.getRelabelStatus()),
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
@@ -686,6 +688,11 @@ public class LastlegOrderService {
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt());
}
private static Integer packageDifference(LastlegOrder order) {
if (order.getReceivedPackageCount() == null) return null;
return order.getReceivedPackageCount() - java.util.Objects.requireNonNullElse(order.getPackageCount(), 0);
}
static String displayOrderNo(LastlegOrder order) {
return "WAREHOUSE_TRANSFER".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo())
? order.getSourceOrderNo() : order.getOrderNo();
@@ -12,6 +12,14 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.MediaType;
import org.springframework.http.HttpHeaders;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.ResponseEntity;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/warehouse-transfers")
@@ -24,6 +32,30 @@ public class MerchantWarehouseTransferController {
return ApiResponse.success(service.create(customer(user), operator(user), request));
}
@PutMapping("/{transferNo}")
public ApiResponse<WarehouseTransferResponses.Detail> updateDraft(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Create request) {
return ApiResponse.success(service.updateDraft(customer(user), transferNo, operator(user), request));
}
/**
* POST 兼容入口:部分本地网关只放行 POST,草稿保存不能因此阻断提交。
* 与 PUT 更新草稿使用完全相同的服务逻辑和租户校验。
*/
@PostMapping("/{transferNo}/save-draft")
public ApiResponse<WarehouseTransferResponses.Detail> saveDraft(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Create request) {
return ApiResponse.success(service.updateDraft(customer(user), transferNo, operator(user), request));
}
@PostMapping("/{transferNo}/submit")
public ApiResponse<WarehouseTransferResponses.Detail> submit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo) {
return ApiResponse.success(service.submit(customer(user), transferNo, operator(user)));
}
@GetMapping
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid WarehouseTransferRequests.Query query) {
@@ -36,6 +68,29 @@ public class MerchantWarehouseTransferController {
return ApiResponse.success(service.detail(customer(user), transferNo));
}
@GetMapping("/{transferNo}/carton-label")
public ApiResponse<WarehouseTransferResponses.Label> cartonLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo) {
return ApiResponse.success(service.cartonLabel(customer(user), transferNo));
}
@GetMapping("/{transferNo}/measurement-photos/files")
public ApiResponse<List<WarehouseTransferResponses.MeasurementPhoto>> measurementPhotos(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) {
return ApiResponse.success(service.measurementPhotos(customer(user), transferNo));
}
@GetMapping("/{transferNo}/measurement-photos/files/{fileId}/download")
public ResponseEntity<byte[]> downloadMeasurementPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo,
@PathVariable long fileId) {
var file = service.downloadMeasurementPhoto(customer(user), transferNo, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.contentType())).body(file.content());
}
@PostMapping("/{transferNo}/confirm-measurement")
public ApiResponse<WarehouseTransferResponses.Detail> confirmMeasurement(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@@ -16,7 +16,16 @@ import org.springframework.web.bind.annotation.PostMapping;
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.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.http.MediaType;
import org.springframework.http.HttpHeaders;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@@ -32,6 +41,42 @@ public class PdaWarehouseTransferController {
return ApiResponse.success(service.pdaCandidates(identity.overseasWarehouseId()));
}
@GetMapping("/{transferNo}")
public ApiResponse<WarehouseTransferResponses.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pdaDetail(identity.overseasWarehouseId(), transferNo));
}
@PostMapping(value = "/{transferNo}/measurement-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<WarehouseTransferResponses.MeasurementPhoto> uploadMeasurementPhoto(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@RequestParam("file") MultipartFile file) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
long operatorUserId = user.userId() == null ? 0L : user.userId();
return ApiResponse.success(service.uploadMeasurementPhoto(identity.operatorKey(), operatorUserId,
identity.overseasWarehouseId(), transferNo, file));
}
@GetMapping("/{transferNo}/measurement-photos/files")
public ApiResponse<List<WarehouseTransferResponses.MeasurementPhoto>> measurementPhotos(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pdaMeasurementPhotos(identity.overseasWarehouseId(), transferNo));
}
@GetMapping("/{transferNo}/measurement-photos/files/{fileId}/download")
public ResponseEntity<byte[]> downloadMeasurementPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo,
@PathVariable long fileId) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
var file = service.downloadPdaMeasurementPhoto(identity.overseasWarehouseId(), transferNo, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.contentType())).body(file.content());
}
@PostMapping("/{transferNo}/palletize")
public ApiResponse<WarehouseTransferResponses.Detail> palletize(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@@ -40,4 +85,49 @@ public class PdaWarehouseTransferController {
return ApiResponse.success(service.palletize(identity.operatorKey(), identity.overseasWarehouseId(),
transferNo, request));
}
@PostMapping("/{transferNo}/pallets/drafts")
public ApiResponse<WarehouseTransferResponses.Detail> savePalletDraft(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.PalletDraft request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.savePalletDraft(identity.overseasWarehouseId(), transferNo, request));
}
@PostMapping("/{transferNo}/pallets/{palletId}/generate")
public ApiResponse<WarehouseTransferResponses.Detail> generatePallet(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId,
@Valid @RequestBody WarehouseTransferRequests.PalletAction request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.generatePallet(identity.overseasWarehouseId(), transferNo, palletId, request));
}
@PostMapping("/{transferNo}/pallets/{palletId}/bind")
public ApiResponse<WarehouseTransferResponses.Detail> bindPallet(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId,
@Valid @RequestBody WarehouseTransferRequests.PalletBind request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.bindPallet(identity.overseasWarehouseId(), transferNo, palletId, request));
}
@org.springframework.web.bind.annotation.DeleteMapping("/{transferNo}/pallets/{palletId}")
public ApiResponse<WarehouseTransferResponses.Detail> deletePalletDraft(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.deletePalletDraft(identity.overseasWarehouseId(), transferNo, palletId));
}
@GetMapping("/{transferNo}/pallets/{warehousePalletId}/label")
public ResponseEntity<byte[]> palletLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo,
@PathVariable long warehousePalletId,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
var file = service.printPdaPalletLabel(identity.overseasWarehouseId(), transferNo,
warehousePalletId, requestId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.contentType())).body(file.content());
}
}
@@ -24,14 +24,14 @@ public class WarehouseTransferController {
@PostMapping
public ApiResponse<WarehouseTransferResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid @RequestBody WarehouseTransferRequests.Create request) {
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
return ApiResponse.success(service.createAndSubmit(scope(user, customerId), operator(user), request));
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid WarehouseTransferRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
return ApiResponse.success(service.page(scope(user, customerId), query, false));
}
@GetMapping("/{transferNo}")
@@ -56,6 +56,17 @@ public class WarehouseTransferController {
return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request));
}
@PostMapping("/bind-destination")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
public ApiResponse<java.util.List<WarehouseTransferResponses.Detail>> bindDestination(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody WarehouseTransferRequests.BindDestination request) {
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required");
}
return ApiResponse.success(service.bindDestination(operator(user), request));
}
@PostMapping("/{transferNo}/allocations")
public ApiResponse<WarehouseTransferResponses.Detail> allocate(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
@@ -21,7 +21,17 @@ public final class WarehouseTransferRequests {
@Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds,
@NotBlank @Size(max = 128) String operatingWarehouseId,
@Size(max = 128) String fbaWarehouseCode,
@Size(max = 16) String destinationType,
@Size(max = 128) String destinationWarehouseId,
@Size(max = 128) String destinationWarehouseCode,
@Size(max = 255) String destinationWarehouseName,
@Size(max = 1000) String privateDestinationAddress,
@Size(max = 32) String privateDestinationPostalCode,
@Size(max = 128) String privateDestinationContactName,
@Size(max = 64) String privateDestinationContactPhone,
@NotBlank @Size(max = 1000) String deliveryAddress,
@Size(max = 128) String pickupContactName,
@Size(max = 64) String pickupContactPhone,
@Positive int packageCount,
@NotNull @DecimalMin(value = "0.0") BigDecimal volumeCbm,
@NotBlank @Size(max = 64) String deliveryMethod,
@@ -46,12 +56,29 @@ public final class WarehouseTransferRequests {
String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo,
String trackingNo, String fbaBoxNos, String remark, boolean needRelabel,
String idempotencyKey, List<Item> items) {
this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode, deliveryAddress,
packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo,
this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode,
null, null, null, null, null, null, null, null,
deliveryAddress, null, null, packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo,
fbaBoxNos, remark, needRelabel, null, null, null, null, null, idempotencyKey, items, null);
}
}
public record DestinationSelection(
@NotBlank @Size(max = 128) String customerId,
@NotBlank @Size(max = 64) String transferNo) {}
public record BindDestination(
@NotEmpty @Size(max = 100) List<@Valid DestinationSelection> transfers,
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "FBA|PRIVATE") String destinationType,
@Size(max = 128) String destinationWarehouseId,
@Size(max = 128) String destinationWarehouseCode,
@Size(max = 255) String destinationWarehouseName,
@Size(max = 1000) String privateDestinationAddress,
@Size(max = 32) String privateDestinationPostalCode,
@Size(max = 128) String privateDestinationContactName,
@Size(max = 64) String privateDestinationContactPhone,
@NotBlank @Size(max = 255) String idempotencyKey) {}
/** 审核通过备注选填;驳回必须提供非空原因,由服务在任何状态写入前校验。 */
public record Review(@NotBlank @Size(max = 32) String decision,
@Size(max = 1000) String remark) {}
@@ -72,19 +99,34 @@ public final class WarehouseTransferRequests {
public record Palletize(
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 100) List<@Valid Pallet> pallets
@NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds
) {}
public record PalletDraft(
Long palletId,
@NotBlank @Size(max = 255) String idempotencyKey,
@Positive int cartonCount,
@NotNull @DecimalMin("0.01") BigDecimal lengthCm,
@NotNull @DecimalMin("0.01") BigDecimal widthCm,
@NotNull @DecimalMin("0.01") BigDecimal heightCm,
@NotNull @DecimalMin("0.000001") BigDecimal volumeCbm,
@NotNull @DecimalMin("0.01") BigDecimal grossWeightKg
) {}
public record PalletAction(@NotBlank @Size(max = 255) String idempotencyKey) {}
public record PalletBind(@NotBlank @Size(max = 128) String palletCode,
@NotBlank @Size(max = 255) String idempotencyKey) {}
public record Pallet(
@Size(max = 128) String palletCode,
@Positive int cartonCount,
@NotNull @DecimalMin("0.01") BigDecimal lengthCm,
@NotNull @DecimalMin("0.01") BigDecimal widthCm,
@NotNull @DecimalMin("0.01") BigDecimal heightCm,
@NotNull @DecimalMin("0.000001") BigDecimal volumeCbm,
@NotNull @DecimalMin("0.01") BigDecimal grossWeightKg,
@NotEmpty @Size(max = 500) List<@Valid PalletItem> items
@NotNull @DecimalMin("0.01") BigDecimal grossWeightKg
) {}
public record PalletItem(@NotNull Long transferItemId, @Positive int packageCount) {}
public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
@@ -106,8 +148,8 @@ public final class WarehouseTransferRequests {
) {}
public record Item(
@NotBlank @Size(max = 128) String skuCode,
@NotBlank @Size(max = 255) String productNameZh,
@Size(max = 128) String skuCode,
@Size(max = 255) String productNameZh,
@Positive int packageCount,
@Size(max = 255) String shippingMark
) {}
@@ -115,7 +157,7 @@ public final class WarehouseTransferRequests {
public record RelabelItem(
@NotBlank @Size(max = 32) String operationType,
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> customerCartonMarks,
@NotBlank @Size(max = 128) String originalSku,
@Size(max = 128) String originalSku,
@Size(max = 128) String newSku,
@Positive int quantity,
@Size(max = 255) String fbaCartonMark,
@@ -9,12 +9,16 @@ public final class WarehouseTransferResponses {
public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark) {}
public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status) {}
public record Label(String transferNo, List<Carton> cartons, String printableHtml) {}
public record DestinationResult(String customerId, String transferNo) {}
public record PalletItem(Long transferItemId, String skuCode, int packageCount) {}
public record MeasurementPhoto(Long fileId, String fileName, String contentType, long sizeBytes,
LocalDateTime uploadedAt) {}
public record Pallet(Long id, int sequenceNo, Long warehousePalletId, String palletNo,
Long warehouseLocationId, String warehouseLocationCode, int cartonCount,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
BigDecimal grossWeightKg, BigDecimal volumeCbm, String status,
List<PalletItem> items, LocalDateTime completedAt) {}
String fbaWarehouseCode, String labelDownloadPath,
List<PalletItem> items, LocalDateTime boundAt, LocalDateTime completedAt) {}
public record RelabelBinding(String operationType, String customerCartonMark, String originalSku,
String newSku, int quantity, String fbaCartonMark,
String destinationWarehouseCode, String remark) {}
@@ -24,19 +28,28 @@ public final class WarehouseTransferResponses {
Long destinationInventoryPositionId) {}
public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId,
String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode,
String destinationType, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo,
String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {}
public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode,
Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus,
String status,
boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus,
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt,
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
String destinationType, String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String privateDestinationAddress,
String privateDestinationPostalCode,
String privateDestinationContactName, String privateDestinationContactPhone,
String pickupContactName, String pickupContactPhone,
int packageCount, BigDecimal volumeCbm, String deliveryMethod, LocalDateTime estimatedArrivalAt,
LocalDateTime estimatedShipAt,
String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark,
LocalDateTime labelDeadline, String relabelContactName, String relabelContactPhone,
String relabelDeliveryMethod,
String reviewDecision, String reviewRemark, String reviewedBy, LocalDateTime reviewedAt,
List<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons,
List<Pallet> pallets, List<RelabelBinding> relabelBindings,
List<Allocation> allocations, LocalDateTime customerConfirmedAt,
List<Allocation> allocations, List<MeasurementPhoto> measurementPhotos,
LocalDateTime customerConfirmedAt,
LocalDateTime actualShipAt, LocalDateTime createdAt) {}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.transfer.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_warehouse_transfer_measurement_photo")
public class WarehouseTransferMeasurementPhoto {
@TableId(type = IdType.INPUT) private Long id;
private Long transferOrderId;
private String customerId;
private Long fileId;
private String fileName;
private String contentType;
private Long sizeBytes;
private String uploadedBy;
private LocalDateTime uploadedAt;
private LocalDateTime submittedAt;
}
@@ -18,7 +18,17 @@ public class WarehouseTransferOrder {
private String lastlegOrderNo;
private String operatingWarehouseId;
private String fbaWarehouseCode;
private String destinationType;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private String privateDestinationAddress;
private String privateDestinationPostalCode;
private String privateDestinationContactName;
private String privateDestinationContactPhone;
private String deliveryAddress;
private String pickupContactName;
private String pickupContactPhone;
private Integer packageCount;
private String deliveryMethod;
private LocalDateTime estimatedArrivalAt;
@@ -50,6 +60,8 @@ public class WarehouseTransferOrder {
private String palletizeRequestHash;
private String confirmationIdempotencyKey;
private String confirmationRequestHash;
private String destinationBindingIdempotencyKey;
private String destinationBindingRequestHash;
private LocalDateTime customerConfirmedAt;
private String idempotencyKey;
private String requestHash;
@@ -27,6 +27,11 @@ public class WarehouseTransferPallet {
private BigDecimal grossWeightKg;
private BigDecimal volumeCbm;
private String status;
private String draftIdempotencyKey;
private String draftRequestHash;
private String generateIdempotencyKey;
private String bindIdempotencyKey;
private LocalDateTime boundAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -0,0 +1,106 @@
package com.baiyue.lastleg.transfer.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
@Component
public class HttpTransferMeasurementPhotoFileGateway implements TransferMeasurementPhotoFileGateway {
private static final String TYPE = "OVERSEAS_TRANSFER_MEASUREMENT_PHOTO";
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9206}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct void initializeClient() {
client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getRelabelPhotoFile());
}
@Override public UploadedFile upload(String customerId, long transferId, long operatorId, MultipartFile file) {
MultipartBodyBuilder body = new MultipartBodyBuilder();
try {
body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
: MediaType.parseMediaType(file.getContentType()));
} catch (IOException exception) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot read transfer measurement photo", exception);
}
MetadataEnvelope response = client.post().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}",
tenant(customerId), TYPE, transferId)
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId))
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo upload failed"); })
.body(MetadataEnvelope.class);
Metadata data = success(response, "Transfer measurement photo upload failed");
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes());
}
@Override public void validate(String customerId, long transferId, long fileId) {
long tenant = tenant(customerId);
MetadataEnvelope response = client.get().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}/{file}",
tenant, TYPE, transferId, fileId).header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo ownership is invalid"); })
.body(MetadataEnvelope.class);
Metadata data = success(response, "Transfer measurement photo ownership is invalid");
if (data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenant
|| !TYPE.equals(data.businessType()) || !String.valueOf(transferId).equals(data.businessId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Transfer measurement photo ownership is invalid");
}
}
@Override public DownloadedFile download(String customerId, long transferId, long fileId) {
validate(customerId, transferId, fileId);
ResponseEntity<byte[]> response = client.get().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}/{file}/content",
tenant(customerId), TYPE, transferId, fileId).header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo download failed"); })
.toEntity(byte[].class);
String name = response.getHeaders().getContentDisposition().getFilename();
String type = response.getHeaders().getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE
: response.getHeaders().getContentType().toString();
return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(),
name == null ? "transfer-measurement-photo" : name, type);
}
private static long tenant(String value) {
try { return Long.parseLong(value); }
catch (NumberFormatException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Customer ID must be numeric", exception); }
}
private static Metadata success(MetadataEnvelope response, String fallback) {
if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback);
if (!"200".equals(response.code())) {
HttpStatus status = switch (String.valueOf(response.code())) {
case "A0400", "A0422" -> HttpStatus.BAD_REQUEST;
case "A0403" -> HttpStatus.FORBIDDEN;
case "A0404" -> HttpStatus.NOT_FOUND;
default -> HttpStatus.SERVICE_UNAVAILABLE;
};
throw new ResponseStatusException(status,
response.message() == null || response.message().isBlank() ? fallback : response.message());
}
if (response.data() == null || response.data().fileId() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"File service returned no transfer measurement photo metadata");
}
return response.data();
}
private static ResponseStatusException failure(HttpStatusCode status, String message) {
return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, message);
}
private record MetadataEnvelope(String code, String message, Metadata data) {}
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) {}
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.transfer.gateway;
import org.springframework.web.multipart.MultipartFile;
public interface TransferMeasurementPhotoFileGateway {
UploadedFile upload(String customerId, long transferId, long operatorId, MultipartFile file);
void validate(String customerId, long transferId, long fileId);
DownloadedFile download(String customerId, long transferId, long fileId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {}
record DownloadedFile(byte[] content, String fileName, String contentType) {}
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface WarehouseTransferMeasurementPhotoMapper extends BaseMapper<WarehouseTransferMeasurementPhoto> {
@Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY uploaded_at,id")
List<WarehouseTransferMeasurementPhoto> listOrder(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
@Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND submitted_at IS NOT NULL ORDER BY submitted_at,id")
List<WarehouseTransferMeasurementPhoto> listSubmitted(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
@Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id=#{fileId} LIMIT 1")
WarehouseTransferMeasurementPhoto findFile(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId,
@Param("fileId") Long fileId);
@Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id=#{fileId} AND submitted_at IS NOT NULL LIMIT 1")
WarehouseTransferMeasurementPhoto findSubmittedFile(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId,
@Param("fileId") Long fileId);
@Update("<script>UPDATE ll_warehouse_transfer_measurement_photo SET submitted_at=#{submittedAt} WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id IN <foreach collection='fileIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
int markSubmitted(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId,
@Param("fileIds") java.util.Collection<Long> fileIds,
@Param("submittedAt") java.time.LocalDateTime submittedAt);
}
@@ -14,6 +14,8 @@ import org.apache.ibatis.annotations.Update;
WarehouseTransferOrder lockNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
WarehouseTransferOrder lockForPda(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1")
WarehouseTransferOrder findNoByWarehouse(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE")
WarehouseTransferOrder lockByRelabelNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouseId} AND status='WAIT_PDA_RECEIVING' ORDER BY created_at,id")
@@ -13,4 +13,9 @@ public interface WarehouseTransferPalletMapper extends BaseMapper<WarehouseTrans
@Select("SELECT * FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY sequence_no")
List<WarehouseTransferPallet> listOrder(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
@Select("SELECT * FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND id=#{id} FOR UPDATE")
WarehouseTransferPallet lockOne(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId,
@Param("id") Long id);
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.transfer.model;
import java.util.Arrays;
/** 仓储转运单状态。状态编码持久化到数据库,中文名称用于接口和前端展示。 */
public enum WarehouseTransferStatus {
DRAFT("草稿"),
PENDING_REVIEW("待审核"),
APPROVED("审核通过"),
REJECTED("审核驳回"),
WAIT_PDA_RECEIVING("待PDA收货"),
INBOUND_MEASURING("入库测量中"),
WAIT_CUSTOMER_CONFIRMATION("待客户确认实测"),
WAIT_RELABEL("待换标"),
WAIT_FBA_DESTINATION("待确认目的仓"),
READY_FOR_DISPATCH("待排车"),
WAIT_OUTBOUND("待出库"),
PENDING_ASSIGNMENT("待分配"),
ALLOCATED("已分配库存"),
OUTBOUNDED("已出库"),
PARTIALLY_RECEIVED("部分收货"),
RECEIVED("已收货"),
INBOUND_COMPLETED("入库完成"),
COMPLETED("已完成");
private final String label;
WarehouseTransferStatus(String label) { this.label = label; }
public String code() { return name(); }
public String label() { return label; }
public static WarehouseTransferStatus fromCode(String code) {
return Arrays.stream(values()).filter(value -> value.name().equals(code)).findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unsupported warehouse transfer status: " + code));
}
}
@@ -76,9 +76,9 @@ baiyue:
url: ${DOMESTIC_WAREHOUSE_URL:}
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
file:
url: ${FILE_SERVICE_URL:http://127.0.0.1:9205}
url: ${FILE_SERVICE_URL:http://127.0.0.1:9206}
domestic-file:
url: ${FILE_SERVICE_URL:http://127.0.0.1:9205}
url: ${FILE_SERVICE_URL:http://127.0.0.1:9206}
event-bridge:
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}}
firstleg:
@@ -75,6 +75,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
version INT NOT NULL DEFAULT 0 COMMENT '尾程订单命令乐观锁版本',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
received_package_count INT NULL COMMENT 'PDA实际入库箱数',
picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数',
shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数',
volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)',
@@ -267,14 +268,19 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号',
outbound_order_id BIGINT NOT NULL COMMENT '关联出库单ID',
outbound_no VARCHAR(64) NOT NULL COMMENT '关联出库单号',
dispatch_order_id BIGINT NOT NULL COMMENT '关联派车单ID',
dispatch_no VARCHAR(64) NOT NULL COMMENT '关联派车单号',
dispatch_order_id BIGINT NULL COMMENT '关联派车单ID,仓储转运直接出库时为空',
dispatch_no VARCHAR(64) NULL COMMENT '关联派车单号,仓储转运直接出库时为空',
overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID',
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照',
customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
destination_warehouse_id VARCHAR(128) NULL COMMENT '本派送单目的FBA仓ID快照',
destination_warehouse_code VARCHAR(128) NOT NULL COMMENT '本派送单目的FBA仓编码快照',
destination_warehouse_code VARCHAR(128) NULL COMMENT '本派送单目的FBA仓编码快照,私人仓为空',
destination_warehouse_name VARCHAR(255) NULL COMMENT '本派送单目的FBA仓名称快照',
destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或PRIVATE',
delivery_address VARCHAR(1000) NULL COMMENT '私人仓派送地址',
postal_code VARCHAR(32) NULL COMMENT '私人仓邮编',
contact_name VARCHAR(128) NULL COMMENT '私人仓联系人',
contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话',
delivery_group_key VARCHAR(255) NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键',
status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态',
waybill_no VARCHAR(128) NULL COMMENT '承运运单号',
@@ -318,6 +324,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓ID快照'' AFTER customer_name, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''本派送单目的FBA仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN delivery_group_key VARCHAR(255) NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键'' AFTER destination_warehouse_name'));
PREPARE delivery_destination_stmt FROM @ddl; EXECUTE delivery_destination_stmt; DEALLOCATE PREPARE delivery_destination_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的地类型:FBA或PRIVATE'' AFTER destination_warehouse_name, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓派送地址'' AFTER destination_type, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER delivery_address, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER contact_name'));
PREPARE delivery_private_destination_stmt FROM @ddl; EXECUTE delivery_private_destination_stmt; DEALLOCATE PREPARE delivery_private_destination_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER delivery_address'));
PREPARE delivery_postal_stmt FROM @ddl; EXECUTE delivery_postal_stmt; DEALLOCATE PREPARE delivery_postal_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''关联派车单ID,仓储转运直接出库时为空'', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT ''关联派车单号,仓储转运直接出库时为空'', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照,私人仓为空'''));
PREPARE delivery_transfer_nullable_stmt FROM @ddl; EXECUTE delivery_transfer_nullable_stmt; DEALLOCATE PREPARE delivery_transfer_nullable_stmt;
SET @old_delivery_outbound_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound');
SET @new_delivery_destination_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound_destination');
SET @ddl = IF(@new_delivery_destination_index > 0, 'SELECT 1', IF(@old_delivery_outbound_index > 0, 'ALTER TABLE ll_delivery_order DROP INDEX uk_ll_delivery_outbound, ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)', 'ALTER TABLE ll_delivery_order ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)'));
@@ -599,7 +611,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否已分配:0否1是',
merge_required TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否要求合托:0否1是',
urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否加急:0否1是',
creation_mode VARCHAR(16) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工',
creation_mode VARCHAR(32) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运',
plan_remark VARCHAR(1000) NULL COMMENT '入仓计划备注',
status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态',
line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量',
@@ -648,6 +660,10 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_line (
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照',
postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照',
contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照',
contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照',
volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)',
planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量',
PRIMARY KEY (id),
@@ -682,6 +698,11 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet (
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA或PRIVATE',
delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照',
postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照',
contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照',
contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照',
capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米',
allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)',
sequence_no INT NOT NULL COMMENT '计划内托盘序号',
@@ -721,8 +742,15 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag'));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(32) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运'' AFTER urgent_flag'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
ALTER TABLE ll_inbound_plan MODIFY COLUMN creation_mode VARCHAR(32) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运';
UPDATE ll_inbound_plan_pallet_allocation a
JOIN ll_inbound_plan p ON p.id=a.plan_id
SET a.picked_package_count=COALESCE(a.counted_package_count,a.planned_package_count,0)
WHERE p.creation_mode='WAREHOUSE_TRANSFER' AND p.status='COMPLETED'
AND COALESCE(a.picked_package_count,0)=0
AND COALESCE(a.counted_package_count,a.planned_package_count,0)>0;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode'));
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
ALTER TABLE ll_inbound_plan
@@ -1329,6 +1357,11 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码',
destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA或PRIVATE',
delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照',
postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照',
contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照',
contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)',
source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段',
@@ -1346,6 +1379,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''换标箱唛映射快照'' AFTER source_stage')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT ''来源业务类型'' AFTER carton_marks')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT ''来源业务单号'' AFTER source_business_type')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type, ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
id BIGINT NOT NULL COMMENT '主键ID',
@@ -1521,7 +1555,11 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照',
lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号',
operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码',
delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式',
destination_type VARCHAR(16) NULL COMMENT '目的类型:FBA或PRIVATE', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
private_destination_address VARCHAR(1000) NULL COMMENT '私人仓完整地址', private_destination_postal_code VARCHAR(32) NULL COMMENT '私人仓邮编', private_destination_contact_name VARCHAR(128) NULL COMMENT '私人仓联系人',
private_destination_contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话',
delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人', pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式',
estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号',
tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注',
need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号',
@@ -1559,6 +1597,10 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN estimated_ship_at DATETIME(3) NULL COMMENT ''预计发货时间'' AFTER estimated_arrival_at'));
PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT ''提货联系人'' AFTER delivery_address'));
PREPARE transfer_pickup_contact_name_stmt FROM @ddl; EXECUTE transfer_pickup_contact_name_stmt; DEALLOCATE PREPARE transfer_pickup_contact_name_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_phone VARCHAR(64) NULL COMMENT ''提货联系电话'' AFTER pickup_contact_name'));
PREPARE transfer_pickup_contact_phone_stmt FROM @ddl; EXECUTE transfer_pickup_contact_phone_stmt; DEALLOCATE PREPARE transfer_pickup_contact_phone_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='label_deadline'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN label_deadline DATETIME(3) NULL COMMENT ''换标截止时间'', ADD COLUMN relabel_contact_name VARCHAR(128) NULL COMMENT ''换标联系人'', ADD COLUMN relabel_contact_phone VARCHAR(64) NULL COMMENT ''换标联系电话'', ADD COLUMN relabel_charge_amount DECIMAL(20,2) NULL COMMENT ''换标费用CNY'', ADD COLUMN relabel_delivery_method VARCHAR(32) NULL COMMENT ''换标后派送方式'''));
PREPARE transfer_relabel_config_stmt FROM @ddl; EXECUTE transfer_relabel_config_stmt; DEALLOCATE PREPARE transfer_relabel_config_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''关联贴标完成时间'' AFTER relabel_no'));
@@ -1567,6 +1609,20 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE transfer_fulfillment_stmt FROM @ddl; EXECUTE transfer_fulfillment_stmt; DEALLOCATE PREPARE transfer_fulfillment_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='review_decision'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN review_decision VARCHAR(32) NULL COMMENT ''审核决定'' AFTER status, ADD COLUMN review_remark VARCHAR(1000) NULL COMMENT ''审核备注或驳回原因'' AFTER review_decision, ADD COLUMN reviewed_by VARCHAR(128) NULL COMMENT ''审核人ID'' AFTER review_remark, ADD COLUMN reviewed_at DATETIME(3) NULL COMMENT ''审核时间'' AFTER reviewed_by'));
PREPARE transfer_review_fact_stmt FROM @ddl; EXECUTE transfer_review_fact_stmt; DEALLOCATE PREPARE transfer_review_fact_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照'' AFTER destination_type, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN private_destination_address VARCHAR(1000) NULL COMMENT ''私人仓完整地址'' AFTER destination_warehouse_name, ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address, ADD COLUMN private_destination_contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER private_destination_postal_code, ADD COLUMN private_destination_contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER private_destination_contact_name, ADD COLUMN destination_binding_idempotency_key VARCHAR(255) NULL COMMENT ''目的地绑定幂等键'', ADD COLUMN destination_binding_request_hash CHAR(64) NULL COMMENT ''目的地绑定请求哈希'''));
PREPARE transfer_destination_stmt FROM @ddl; EXECUTE transfer_destination_stmt; DEALLOCATE PREPARE transfer_destination_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='delivery_address'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''));
PREPARE transfer_line_destination_stmt FROM @ddl; EXECUTE transfer_line_destination_stmt; DEALLOCATE PREPARE transfer_line_destination_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'', ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''));
PREPARE transfer_pallet_destination_stmt FROM @ddl; EXECUTE transfer_pallet_destination_stmt; DEALLOCATE PREPARE transfer_pallet_destination_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='private_destination_postal_code'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address'));
PREPARE transfer_postal_stmt FROM @ddl; EXECUTE transfer_postal_stmt; DEALLOCATE PREPARE transfer_postal_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address'));
PREPARE transfer_line_postal_stmt FROM @ddl; EXECUTE transfer_line_postal_stmt; DEALLOCATE PREPARE transfer_line_postal_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address'));
PREPARE transfer_pallet_postal_stmt FROM @ddl; EXECUTE transfer_pallet_postal_stmt; DEALLOCATE PREPARE transfer_pallet_postal_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address'));
PREPARE dispatch_item_postal_stmt FROM @ddl; EXECUTE dispatch_item_postal_stmt; DEALLOCATE PREPARE dispatch_item_postal_stmt;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
@@ -1603,18 +1659,22 @@ PREPARE transfer_customer_carton_stmt FROM @ddl; EXECUTE transfer_customer_carto
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet (
id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', sequence_no INT NOT NULL COMMENT '托序号',
warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码',
warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码',
warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数',
warehouse_pallet_id BIGINT NULL COMMENT '生成后的海外仓真实托盘ID', pallet_no VARCHAR(128) NULL COMMENT '生成后的真实托盘码',
warehouse_location_id BIGINT NULL COMMENT '生成后的真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码',
warehouse_resource_lock_id BIGINT NULL COMMENT '生成后的仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数',
length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm',
height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg',
volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '打托状态',
completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED',
draft_idempotency_key VARCHAR(255) NULL COMMENT '草稿保存幂等键', draft_request_hash CHAR(64) NULL COMMENT '草稿请求摘要',
generate_idempotency_key VARCHAR(255) NULL COMMENT '新托盘生成幂等键', bind_idempotency_key VARCHAR(255) NULL COMMENT '扫码绑定幂等键',
bound_at DATETIME(3) NULL COMMENT '扫码确认绑定时间', completed_at DATETIME(3) NULL COMMENT '整单打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_seq(transfer_order_id,sequence_no),
UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id),
KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status)
) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_pallet' AND column_name='draft_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_pallet MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'', MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'', MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''生成后的真实库位ID'', MODIFY COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''生成后的仓储资源锁ID'', MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''整单打托完成时间'', ADD COLUMN draft_idempotency_key VARCHAR(255) NULL COMMENT ''草稿保存幂等键'' AFTER status, ADD COLUMN draft_request_hash CHAR(64) NULL COMMENT ''草稿请求摘要'' AFTER draft_idempotency_key, ADD COLUMN generate_idempotency_key VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'' AFTER draft_request_hash, ADD COLUMN bind_idempotency_key VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'' AFTER generate_idempotency_key, ADD COLUMN bound_at DATETIME(3) NULL COMMENT ''扫码确认绑定时间'' AFTER bind_idempotency_key'));
PREPARE transfer_pallet_lifecycle_stmt FROM @ddl; EXECUTE transfer_pallet_lifecycle_stmt; DEALLOCATE PREPARE transfer_pallet_lifecycle_stmt;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
@@ -1625,6 +1685,18 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item (
KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id)
) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo (
id BIGINT NOT NULL COMMENT '测量照片引用ID', transfer_order_id BIGINT NOT NULL COMMENT '仓储转运单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', file_id BIGINT NOT NULL COMMENT '文件服务文件ID',
file_name VARCHAR(255) NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '文件媒体类型快照',
size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', uploaded_by VARCHAR(128) NULL COMMENT 'PDA上传操作人',
uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_transfer_measurement_photo_file(customer_id,transfer_order_id,file_id),
KEY idx_ll_transfer_measurement_photo_order(customer_id,transfer_order_id,uploaded_at)
) ENGINE=InnoDB COMMENT='仓储转运PDA测量现场照片引用';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_measurement_photo' AND column_name='submitted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_measurement_photo ADD COLUMN submitted_at DATETIME(3) NULL COMMENT ''随测量打托提交时间'' AFTER uploaded_at'));
PREPARE transfer_measurement_photo_submitted_stmt FROM @ddl; EXECUTE transfer_measurement_photo_submitted_stmt; DEALLOCATE PREPARE transfer_measurement_photo_submitted_stmt;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型',
@@ -1310,7 +1310,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications/inventory-candidates:
get:
summary: 查询客户可换标库存候选
description: 权限 merchant:warehouse:relabel;仅返回当前客户在指定海外中转仓仍有可用箱数的历史入仓单和库位摘要。候选按客户 Token 隔离,不接收 customerId;inboundCode 可选精确筛选。
description: 权限 merchant:warehouse:relabel;返回当前客户在指定海外中转仓仍有可用箱数的历史入仓库存,以及仓库合箱后生成的可用退货箱库存。候选按客户 Token 隔离,不接收 customerId;inboundCode 可按入仓码、退货单号或仓库唛头模糊筛选。
security: [{ bearerAuth: [] }]
parameters:
- { name: operationWarehouseId, in: query, required: true, schema: { type: string, maxLength: 128 } }
@@ -2531,6 +2531,19 @@ components:
availablePackages: { type: integer, format: int64 }
version: { type: integer, format: int64 }
positions: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryPosition' } }
sourceType: { type: string, enum: [INBOUND, RETURN_CARTON] }
returnCartonId: { type: string, nullable: true, description: 退货合箱库存主键;sourceType=RETURN_CARTON 时提供 }
cartonMark: { type: string, nullable: true, description: 仓库合箱后打印并贴箱的仓库唛头 }
skuItems:
type: array
description: 退货箱内 SKU 与件数
items:
type: object
properties:
skuCode: { type: string }
newSkuCode: { type: string, nullable: true }
quantity: { type: integer, minimum: 1 }
remark: { type: string, nullable: true }
RelabelInventoryPosition:
type: object
properties:
@@ -2559,10 +2572,10 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 }
RelabelCreate:
type: object
required: [customerOrderNo, labelDeadline, contactName, contactPhone, idempotencyKey, items]
required: [customerOrderNo, operationWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items]
properties:
customerOrderNo: { type: string, maxLength: 128 }
overseasWarehouseId: { type: string, nullable: true, maxLength: 128, description: 统一目的海外仓;明细未填写目的仓时使用 }
operationWarehouseId: { type: string, maxLength: 128, description: 当前库存所在海外操作仓 }
labelDeadline: { type: string, format: date-time }
contactName: { type: string, maxLength: 128 }
contactPhone: { type: string, maxLength: 64 }
@@ -2576,6 +2589,10 @@ components:
type: object
required: [productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, newSkuCode, cartonMark, fbaCartonMark]
properties:
sourceInventoryBalanceId: { type: string, nullable: true, description: 普通入仓库存来源;与 returnCartonId 二选一 }
sourceInventoryPositionId: { type: string, nullable: true, description: 普通入仓库存仓位;与 returnCartonId 二选一 }
sourceInboundOrderId: { type: string, nullable: true, description: 普通入仓单;与 returnCartonId 二选一 }
returnCartonId: { type: string, nullable: true, description: 仓库合箱产生的退货箱库存;与普通入仓库存字段二选一 }
skuCode: { type: string, nullable: true, maxLength: 128, description: 商品 SKU(选填) }
productImageFileId: { type: string, nullable: true, maxLength: 128 }
productImageFileName: { type: string, nullable: true, maxLength: 255 }
@@ -163,12 +163,32 @@ paths:
summary: 扫码查询托盘及库存内容
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } }
responses: { '200': { description: Token绑定仓托盘内容 }, '404': { description: 托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } } }
/api/pda/v1/last-leg/pallet-operations/relabel-sources:
post:
summary: 查询换标原入仓单在各托盘上的库存数量
description: 范围取Token冻结仓和换标单客户;返回库存组成而非历史箱唛定位。movablePackages不包括其他单据冻结、已分配或已拣货箱数。cartonMarks为同来源库存池候选,不代表这些箱历史上位于该托盘。
requestBody:
required: true
content:
application/json:
schema: { type: object, required: [relabelNo], properties: { relabelNo: { type: string, maxLength: 64 } } }
responses:
'200':
description: 原入仓单托盘组成
content:
application/json:
schema:
type: object
properties:
code: { type: string }
data: { type: array, items: { $ref: '#/components/schemas/RelabelSource' } }
'409': { description: 状态、认领人或仓库不符 }
/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label:
post:
summary: 为换标拆托幂等生成并返回可打印的新托盘标签
description: 返回空托盘编码、初始版本和打印HTML;随后可将该托盘作为relabel-disassemble的target。库存移入后仍继承原库存余额对应的客户和入库单。
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/NewRelabelPallet' } } } }
responses: { '200': { description: 新托盘及可打印标签 }, '409': { description: 换标单未确认或不属于Token绑定仓 } }
responses: { '200': { description: 新托盘及可打印标签,已认领处理中无需客户确认即可创建 }, '409': { description: 状态、认领人或Token绑定仓不符 } }
/api/pda/v1/last-leg/pallet-operations/assemble:
post:
summary: 将多个来源托盘的指定库存行组到目标托盘
@@ -182,7 +202,7 @@ paths:
/api/pda/v1/last-leg/pallet-operations/relabel-disassemble:
post:
summary: 按换标箱唛拆托并绑定目标托盘
description: PDA先扫描原托盘,再扫描换标单指定箱唛;服务端按换标单库存快照解析仓位,在同一事务中扣减原托盘并绑定目标托盘。目标托盘须先由仓储服务创建后扫码确认。
description: PDA选择原入仓单库存行并扫描实际来源托盘,输入拆出箱数,核对本批cartonMarks后扫描目标托盘。传actualSource时以其数量和版本为准,同入仓余额及批次内原子调整本单冻结记账位置,再移动冻结库存和换标明细;不新增入库、不改变库存归属、不占用其他单据冻结量。每次一个来源库存行,可分批处理多个托盘。已有出库单时不允许此数量重分配。未传actualSource的箱唛模式仍要求已有来源仓位一致。
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPalletDisassemble' } } } }
responses: { '200': { description: 箱唛级拆托绑定结果 }, '409': { description: 箱唛、库存或托盘版本冲突 } }
/api/pda/v1/last-leg/pallet-operations/swap:
@@ -218,6 +238,27 @@ paths:
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } }
responses: { '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 旧标重复、新标不符或版本冲突 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/skus/scan:
post:
summary: 仅扫描旧SKU完成当前箱的一条SKU换标明细
description: itemId与skuIndex精确指定一条明细,服务端验证旧SKU并读取已批准的新SKU及件数;同旧SKU多箱由PDA选择目标,不批量隐式完成。逐条写SKU_SCAN历史并按轮次防重,全部SKU处理后完成SKU阶段,混合单仍须完成箱唛阶段。响应history包含旧新SKU、件数及索引,供提示与隐藏已处理明细。
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [itemId, skuIndex, oldSku, expectedVersion, idempotencyKey]
properties:
itemId: { type: string, pattern: '^[0-9]+$' }
skuIndex: { type: integer, minimum: 0 }
oldSku: { type: string, maxLength: 255 }
expectedVersion: { type: integer, minimum: 0 }
idempotencyKey: { type: string, maxLength: 255 }
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: 登记一件有原因的跳过事实
@@ -260,6 +301,107 @@ paths:
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任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 状态或版本冲突 } }
/api/pda/v1/last-leg/warehouse-transfers/candidates:
get:
summary: 查询Token绑定仓待测量的仓储转运单
description: 仅返回当前PDA冻结海外仓内已审核且等待测量打托的转运单;不接收customerId或warehouseId。
responses:
'200':
description: 待测量转运单列表
content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferSummaryListResponse' } } }
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}:
get:
summary: 查询当前仓仓储转运测量详情
parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }]
responses:
'200':
description: 转运申报、已生成的新托盘及测量照片;items仅为服务端自动分摊结果,PDA不提交每托SKU。
content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailResponse' } } }
'404': { description: 当前Token绑定仓无此转运单 }
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/measurement-photos/files:
get:
summary: 查询当前转运单已上传的测量现场照片
parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }]
responses:
'200': { description: 照片元数据列表 }
post:
summary: 上传仓储转运测量现场照片
parameters: [{ name: transferNo, 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: 已绑定当前转运单的照片元数据 }
'404': { description: 当前Token绑定仓无此转运单 }
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/measurement-photos/files/{fileId}/download:
get:
summary: 鉴权下载仓储转运测量现场照片
parameters:
- { name: transferNo, in: path, required: true, schema: { type: string } }
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } }
responses:
'200': { description: 照片内容, content: { 'image/*': { schema: { type: string, format: binary } } } }
'404': { description: 照片不属于当前转运单或绑定仓 }
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/palletize:
post:
summary: 完成仓储转运整单测量打托
description: 只提交当前转运单照片和整单幂等键;所有托盘必须已逐托生成并扫码绑定,箱数必须核平。此接口不接收测量字段、托盘码或每托SKU,也绝不再次生成托盘。
parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletize' } } } }
responses:
'200':
description: WAIT_CUSTOMER_CONFIRMATION详情
content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailResponse' } } }
'409': { description: 状态、照片归属、箱数核平或幂等冲突 }
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/drafts:
post:
summary: 新建或更新一个转运测量托盘草稿
description: palletId为空时创建DRAFT;传入既有DRAFT的palletId时更新。GENERATED及以后测量字段锁定,必须拒绝修改。
parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletDraft' } } } }
responses: { '200': { description: 包含持久化草稿的最新整单详情 }, '409': { description: 草稿已生成、状态或幂等冲突 } }
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}:
delete:
summary: 删除未生成的转运托盘草稿
description: 只允许删除DRAFT;GENERATED、BOUND和COMPLETED不得删除。
parameters:
- { name: transferNo, in: path, required: true, schema: { type: string } }
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } }
responses: { '200': { description: 删除后的整单详情 }, '409': { description: 本托已经生成真实托盘 } }
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}/generate:
post:
summary: 为测量草稿生成一个全新实物托盘
description: 只允许DRAFT调用一次;服务端始终向Warehouse Overseas传空palletCode,不能复用已有托盘。有FBA则写入目的仓,无FBA则标签显示“目的仓待定”。成功后实测字段锁定且可立即下载PDF标签。
parameters:
- { name: transferNo, in: path, required: true, schema: { type: string } }
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletAction' } } } }
responses: { '200': { description: 包含GENERATED新托盘号和标签路径的最新详情 }, '409': { description: 已生成或资源生成冲突 } }
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}/bind:
post:
summary: 扫描并确认本托新生成的托盘码
description: 扫描码必须精确等于本草稿服务端生成码,同时验证Warehouse Overseas物理托盘ID和Token冻结仓一致;其他已有托盘不得冒充。
parameters:
- { name: transferNo, in: path, required: true, schema: { type: string } }
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletBind' } } } }
responses: { '200': { description: 包含BOUND事实和boundAt的最新详情 }, '409': { description: 扫描码、归属、仓库或状态不匹配 } }
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{warehousePalletId}/label:
get:
summary: 下载仓储转运实物托盘PDF标签
description: 校验Token冻结仓、转运单和托盘归属后,代理Warehouse Overseas使用统一托盘标签打印服务生成真实PDF。已知FBA显示目的仓编码,未知FBA显示“目的仓待定”。
parameters:
- { name: transferNo, in: path, required: true, schema: { type: string } }
- { name: warehousePalletId, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: X-Request-Id, in: header, required: false, schema: { type: string } }
responses:
'200': { description: 托盘标签PDF, content: { application/pdf: { schema: { type: string, format: binary } } } }
'404': { description: 转运单或托盘不属于当前Token绑定仓 }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
@@ -274,6 +416,107 @@ components:
requestId: { type: string, nullable: true }
traceId: { type: string, nullable: true }
timestamp: { type: string, format: date-time, nullable: true }
WarehouseTransferSummaryListResponse:
allOf:
- { $ref: '#/components/schemas/ApiResponse' }
- type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferSummary' } }
WarehouseTransferDetailResponse:
allOf:
- { $ref: '#/components/schemas/ApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/WarehouseTransferDetail' } }
WarehouseTransferSummary:
type: object
required: [id, transferNo, operatingWarehouseId, packageCount, deliveryMethod, needRelabel, status]
properties:
id: { type: integer, format: int64 }
transferNo: { type: string }
inboundCode: { type: string, nullable: true }
operatingWarehouseId: { type: string }
fbaWarehouseCode: { type: string, nullable: true, description: 为空表示目的仓待定 }
packageCount: { type: integer, minimum: 1 }
deliveryMethod: { type: string }
needRelabel: { type: boolean }
relabelNo: { type: string, nullable: true }
status: { type: string }
estimatedArrivalAt: { type: string, format: date-time, nullable: true }
createdAt: { type: string, format: date-time, nullable: true }
WarehouseTransferDetail:
type: object
required: [id, transferNo, operatingWarehouseId, packageCount, status, pallets, measurementPhotos]
properties:
id: { type: integer, format: int64 }
transferNo: { type: string }
operatingWarehouseId: { type: string }
fbaWarehouseCode: { type: string, nullable: true, description: 为空表示目的仓待定 }
pickupContactName: { type: string, nullable: true }
pickupContactPhone: { type: string, nullable: true }
packageCount: { type: integer, minimum: 1 }
status: { type: string }
pallets: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferPallet' } }
measurementPhotos: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferMeasurementPhoto' } }
WarehouseTransferPalletize:
type: object
required: [idempotencyKey, photoFileIds]
properties:
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
photoFileIds:
type: array
minItems: 1
maxItems: 20
uniqueItems: true
items: { type: integer, format: int64, minimum: 1 }
WarehouseTransferPalletDraft:
type: object
required: [idempotencyKey, cartonCount, lengthCm, widthCm, heightCm, volumeCbm, grossWeightKg]
properties:
palletId: { type: integer, format: int64, nullable: true, description: 更新现有DRAFT时传入;创建时不传 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
cartonCount: { type: integer, minimum: 1 }
lengthCm: { type: number, format: double, minimum: 0.01 }
widthCm: { type: number, format: double, minimum: 0.01 }
heightCm: { type: number, format: double, minimum: 0.01 }
volumeCbm: { type: number, format: double, minimum: 0.000001 }
grossWeightKg: { type: number, format: double, minimum: 0.01 }
WarehouseTransferPalletAction:
type: object
required: [idempotencyKey]
properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } }
WarehouseTransferPalletBind:
type: object
required: [palletCode, idempotencyKey]
properties:
palletCode: { type: string, minLength: 1, maxLength: 128 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
WarehouseTransferPallet:
type: object
required: [id, sequenceNo, cartonCount, lengthCm, widthCm, heightCm, volumeCbm, grossWeightKg, status]
properties:
id: { type: integer, format: int64 }
sequenceNo: { type: integer, minimum: 1 }
warehousePalletId: { type: integer, format: int64, nullable: true }
palletNo: { type: string, nullable: true, description: GENERATED后由Warehouse Overseas为本次测量新生成 }
cartonCount: { type: integer, minimum: 1 }
lengthCm: { type: number, format: double }
widthCm: { type: number, format: double }
heightCm: { type: number, format: double }
volumeCbm: { type: number, format: double }
grossWeightKg: { type: number, format: double }
status: { type: string, enum: [DRAFT, GENERATED, BOUND, COMPLETED] }
fbaWarehouseCode: { type: string, nullable: true, description: 为空时标签显示目的仓待定 }
labelDownloadPath: { type: string, nullable: true, description: GENERATED后返回当前PDA鉴权下的真实PDF下载路径 }
boundAt: { type: string, format: date-time, nullable: true }
WarehouseTransferMeasurementPhoto:
type: object
required: [fileId, sizeBytes, uploadedAt]
properties:
fileId: { type: integer, format: int64 }
fileName: { type: string, nullable: true }
contentType: { type: string, nullable: true }
sizeBytes: { type: integer, format: int64, minimum: 0 }
uploadedAt: { type: string, format: date-time }
StocktakeSummaryPageResponse:
allOf:
- { $ref: '#/components/schemas/ApiResponse' }
@@ -732,7 +975,19 @@ components:
source: { $ref: '#/components/schemas/PalletExpectation' }
target: { $ref: '#/components/schemas/PalletExpectation' }
cartonMarks: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string, maxLength: 255 } }
actualSource: { $ref: '#/components/schemas/PalletMoveLine' }
reason: { type: string, maxLength: 500 }
RelabelSource:
type: object
properties:
positionId: { type: string, pattern: '^[0-9]+$' }
positionVersion: { type: integer, format: int64 }
palletCode: { type: string }
locationCode: { type: string, nullable: true }
inboundCode: { type: string }
stockPackages: { type: integer, format: int64 }
movablePackages: { type: integer, format: int64 }
cartonMarks: { type: array, items: { type: string } }
PalletSwap:
type: object
required: [idempotencyKey, source, target]
@@ -67,6 +67,7 @@ class DeliveryLongIdJsonContractTest {
null, null, "PS-1", null, null, null, null, "CREATED", null,
null, null, null, null, null, null, null, null, null, null, null,
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null,
com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null);
com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null,
null, null, null, null, null);
}
}
@@ -19,6 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
class DeliveryCompletedOutboxServiceTest {
@Test
@@ -48,6 +49,23 @@ class DeliveryCompletedOutboxServiceTest {
.containsExactly("ONT8", "ACY2");
}
@Test
void skipsRelabelApplicationWithoutDomesticSourceOrder() {
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService();
ReflectionTestUtils.setField(service, "outbox", mapper);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().registerModule(new JavaTimeModule()));
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(1L); delivery.setDeliveryNo("DL-RELABEL"); delivery.setVersion(2L);
LastlegOrder relabel = new LastlegOrder();
relabel.setId(21L); relabel.setOrderType("RELABEL_APPLICATION");
relabel.setCustomerId("88"); relabel.setSourceInboundId("RL-1:WH-1");
service.publish(delivery, List.of(relabel), Instant.parse("2026-09-15T08:00:00Z"));
verifyNoInteractions(mapper);
}
private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) {
LastlegOrder value = new LastlegOrder();
value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88");
@@ -15,7 +15,8 @@ class DispatchCandidateMapperContractTest {
"planNo", "orderType", "volumeWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt",
"overseasWarehouseId", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo",
"destinationWarehouseId", "destinationWarehouseCode", "destinationWarehouseName", "fbaWarehouseCode",
"packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks"
"packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks",
"destinationType", "deliveryAddress", "postalCode", "contactName", "contactPhone"
);
@Test
@@ -37,6 +38,29 @@ class DispatchCandidateMapperContractTest {
assertTrue(sql.contains("pos.available_packages &gt; 0"));
}
@Test
void candidatesWithoutFbaWarehouseAreExcludedFromReadAndWriteQueries() throws Exception {
for (Method method : List.of(
DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class,
String.class, String.class, String.class),
DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertTrue(sql.contains("NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL"));
}
}
@Test
void candidateKeywordSearchIncludesSourceInboundCode() throws Exception {
Method available = DispatchCandidateMapper.class.getMethod("available", String.class, List.class,
String.class, String.class, String.class, String.class);
String availableSql = String.join(" ", available.getAnnotation(Select.class).value());
assertTrue(availableSql.contains("o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%')"));
Method relabel = DispatchCandidateMapper.class.getMethod("relabelAvailable", String.class, String.class, String.class);
String relabelSql = String.join(" ", relabel.getAnnotation(Select.class).value());
assertTrue(relabelSql.contains("ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%')"));
}
@Test
void candidateWarehouseComesFromPhysicalInventoryOrFrozenInboundPlan() throws Exception {
for (Method method : List.of(
@@ -190,6 +190,22 @@ class DispatchServiceTest {
verify(orders, never()).insert(any(DispatchOrder.class));
}
@Test
void preArrangeRejectsPalletWithoutFbaWarehouse() {
DispatchPalletCandidate missingFba = new DispatchPalletCandidate(1L, 10L, 20L, 2L,
"WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", "LIP1-P001",
null, null, null, null, 12, new BigDecimal("1.2000"), "RECEIVED");
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(missingFba));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.preArrange("100", "7",
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)));
assertEquals(409, exception.getStatusCode().value());
assertEquals("所选托盘尚未设置 FBA 仓,不能智能排车", exception.getReason());
verify(orders, never()).insert(any(DispatchOrder.class));
}
@Test
void removingPalletFromOwnedDraftImmediatelyReleasesLock() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
@@ -307,6 +323,14 @@ class DispatchServiceTest {
verify(candidates).available(null, null, null, null, "RECEIVED", "draft-1");
}
@Test
void relabelSourceDoesNotRequireOrQueryAnInboundPlan() {
when(candidates.relabelAvailable(null, "RL-1", null)).thenReturn(List.of());
org.junit.jupiter.api.Assertions.assertTrue(service.platformCandidates("RL-1", null, "RELABEL", "draft-1").isEmpty());
verify(candidates).relabelAvailable(null, "RL-1", null);
verify(candidates, never()).available(any(), any(), any(), any(), any(), any());
}
@Test
void resolvesCustomerFromSelectedPlansAndPalletsInMultiCustomerDeployment() {
when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100"));
@@ -29,7 +29,8 @@ class InboundPlanSchemaContractTest {
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)"));
assertTrue(schema.contains("expected_loading_at"));
assertTrue(schema.contains("planned_arrival_date"));
assertTrue(schema.contains("creation_mode"));
assertTrue(schema.contains("creation_mode varchar(32)"));
assertTrue(schema.contains("modify column creation_mode varchar(32)"));
assertTrue(schema.contains("pallet_type"));
assertTrue(schema.contains("create table if not exists ll_order_command_record"));
assertTrue(schema.contains("create table if not exists ll_order_hold_history"));
@@ -25,7 +25,7 @@ class PdaPalletOperationControllerContractTest {
.filter(PostMapping.class::isInstance).map(PostMapping.class::cast)
.flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet());
assertEquals(Set.of("/pallets/resolve", "/relabel-pallet-label", "/assemble", "/disassemble",
"/relabel-disassemble", "/swap", "/unbind"), paths);
"/relabel-disassemble", "/relabel-sources", "/swap", "/unbind"), paths);
}
@Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception {
@@ -42,6 +42,17 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class PdaPalletOperationServiceTest {
@Test void relabelRebindingRejectsDispatchAndOutboundOccupation() {
RelabelApplication app = new RelabelApplication();
for (String status : List.of("PRE_DISPATCH_CREATED", "OUTBOUND_CREATED", "OUTBOUNDED")) {
app.setFulfillmentStatus(status);
assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app));
}
app.setFulfillmentStatus("WAIT_PRE_DISPATCH");
assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app));
app.setOutboundOrderNo("OUT-1");
assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app));
}
@Mock private PdaPalletOperationMapper mapper;
@Mock private InventoryPositionMapper positions;
@Mock private InventoryBalanceMapper balances;
@@ -185,6 +196,13 @@ class PdaPalletOperationServiceTest {
}
@Test void relabelDisassembleMovesOnlySelectedHeldCartonWithoutRequiringLocations() {
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
ReflectionTestUtils.setField(service, "relabelWorkHistory", histories);
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask();
task.setId(900L); task.setStatus("COMPLETED");
when(tasks.find("100", 600L)).thenReturn(task);
var sourcePallet = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, null,
"OCCUPIED", null, 1);
var targetPallet = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null,
@@ -196,7 +214,7 @@ class PdaPalletOperationServiceTest {
RelabelApplication application = new RelabelApplication(); application.setId(600L);
application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND");
application.setOverseasWarehouseId("88"); application.setOutboundOrderNo("OUT-1");
application.setOverseasWarehouseId("88"); application.setFulfillmentStatus("WAIT_PRE_DISPATCH");
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(application);
when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(application);
RelabelItem selected = relabelItem(701L, "CARTON-A", 100L);
@@ -266,6 +284,94 @@ class PdaPalletOperationServiceTest {
relabelPalletMutations);
}
@Test void actualSourceReconcilesOnlySelectedHoldAndKeepsOriginalInboundBalance() {
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
ReflectionTestUtils.setField(service, "relabelWorkHistory", histories);
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(900L); task.setStatus("COMPLETED");
when(tasks.find("100", 600L)).thenReturn(task);
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setRelabelNo("RL-1");
app.setCustomerId("100"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND");
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app);
when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(app);
RelabelItem item = relabelItem(701L, "CARTON-A", 101L);
when(relabelItems.selectList(any())).thenReturn(List.of(item));
when(relabelItems.selectById(701L)).thenReturn(item);
InventoryPosition anchor = position(101L, 30L, "ACCOUNTING", 2L, 3L);
InventoryPosition actual = position(100L, 20L, "SOURCE", 0L, 4L); actual.setAvailablePackages(5L);
AtomicReference<InventoryPosition> target = new AtomicReference<>();
when(positions.selectById(anyLong())).thenAnswer(invocation -> {
long id = invocation.getArgument(0); return id == 101L ? anchor : id == 100L ? actual : target.get();
});
doAnswer(invocation -> { target.set(invocation.getArgument(0)); return 1; }).when(positions).insert(any(InventoryPosition.class));
when(mapper.lockPosition(eq("88"), anyLong())).thenAnswer(invocation -> {
InventoryPosition p = ((Long) invocation.getArgument(1)) == 101L ? anchor : actual;
PalletInventoryRow row = row(p.getId(), p.getPalletId(), p.getVersion(), p.getAvailablePackages());
row.setHoldPackages(p.getHoldPackages()); return row;
});
var src = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, null, "OCCUPIED", null, 1);
var dst = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, "IDLE", null, 2);
when(physical.resolve("SOURCE")).thenReturn(src); when(physical.resolve("TARGET")).thenReturn(dst);
when(physical.apply(eq(88L), any(), anyString(), isNull(), anyString(), any()))
.thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2)));
when(mapper.content("88", 10L)).thenReturn(List.of());
when(mapper.palletPackages("88", 20L)).thenAnswer(invocation -> actual.getAvailablePackages() + actual.getHoldPackages());
when(mapper.palletPackages("88", 10L)).thenAnswer(invocation -> target.get() == null ? 0L : target.get().getHoldPackages());
InventoryBalance balance = new InventoryBalance(); balance.setId(200L); balance.setCustomerId("100");
balance.setOverseasWarehouseId("88"); balance.setInboundCode("ORIGINAL-INBOUND");
when(balances.selectById(200L)).thenReturn(balance);
AtomicReference<PdaPalletOperation> op = new AtomicReference<>();
doAnswer(invocation -> { op.set(invocation.getArgument(0)); return 1; }).when(mapper).insert(any(PdaPalletOperation.class));
when(mapper.selectById(anyLong())).thenAnswer(invocation -> op.get());
var result = service.relabelDisassemble(identity(), new PalletOperationRequests.RelabelDisassemble(
"qty-1", "RL-1", new PalletOperationRequests.PalletExpectation("SOURCE", 1),
new PalletOperationRequests.PalletExpectation("TARGET", 2), List.of("CARTON-A"), "quantity",
new PalletOperationRequests.MoveLine(100L, 1L, 4L)));
assertEquals("COMPLETED", result.status());
assertEquals(1L, anchor.getHoldPackages()); // Other order's hold is not moved.
assertEquals(1L, anchor.getAvailablePackages());
assertEquals(4L, actual.getAvailablePackages()); assertEquals(0L, actual.getHoldPackages());
assertEquals(1L, target.get().getHoldPackages()); assertEquals(200L, target.get().getBalanceId());
assertEquals(7L, anchor.getAvailablePackages() + anchor.getHoldPackages() + actual.getAvailablePackages()
+ actual.getHoldPackages() + target.get().getHoldPackages());
verify(relabelPalletMutations).moveSelectedItems(eq(app), argThat(values -> values.get(100L).equals(List.of(item))), anyMap());
ArgumentCaptor<InventoryLedger> entries = ArgumentCaptor.forClass(InventoryLedger.class);
verify(ledgers, times(4)).insert(entries.capture());
assertTrue(entries.getAllValues().stream().allMatch(entry -> "ORIGINAL-INBOUND".equals(entry.getInboundCode())));
verify(balances, never()).updateById(any(InventoryBalance.class));
}
@Test void boundMarksSurviveReloadButDoNotBlockANewReworkRound() {
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
ReflectionTestUtils.setField(service, "relabelWorkHistory", histories);
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100");
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(900L);
when(tasks.find("100", 600L)).thenReturn(task);
var history = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
history.setDetailJson("{\"round\":\"INITIAL\",\"cartonMarks\":[\"CARTON-A\"]}");
when(histories.selectList(any())).thenReturn(List.of(history));
assertEquals(java.util.Set.of("CARTON-A"), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app));
task.setReviewedAt(LocalDateTime.of(2026, 9, 15, 12, 0));
assertEquals(java.util.Set.of(), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app));
}
@Test void createsPalletBeforeCustomerConfirmationOnlyForClaimingOperator() {
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setRelabelNo("RL-1");
app.setCustomerId("100"); app.setOverseasWarehouseId("88"); app.setStatus("PROCESSING");
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app);
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setStatus("PROCESSING"); task.setOperatorKey("7");
when(tasks.find("100", 600L)).thenReturn(task);
when(physical.ensureRelabelPallet(88L, "RL-1:before-confirm")).thenReturn(pallet(30L, "NEW", 88L, 0));
assertEquals("NEW", service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "before-confirm")).palletCode());
task.setOperatorKey("other");
assertThrows(ResponseStatusException.class, () -> service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "different")));
}
private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); }
private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) {
return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id,
@@ -61,6 +61,7 @@ class PdaRelabelServiceTest {
void setUp() {
service = new PdaRelabelService();
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "bindingGuard", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class));
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "tasks", tasks);
ReflectionTestUtils.setField(service, "unitFacts", unitFacts);
@@ -103,7 +104,7 @@ class PdaRelabelServiceTest {
PdaRelabelTask active = task("PROCESSING", 0);
active.setApplicationId(99L);
active.setRelabelNo("FBR-ACTIVE");
when(tasks.lockUnfinishedByOperator("7")).thenReturn(active);
when(tasks.lockProcessingByOperator("7")).thenReturn(active);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.claim("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Claim("claim-other")));
@@ -205,6 +206,36 @@ class PdaRelabelServiceTest {
assertEquals(1, result.version());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"SKU_LABEL", "CARTON_AND_SKU"})
void oldSkuOnlyScanPersistsEachRowAndCompletesBoxOnlyWhenRequiredStagesFinish(String type) {
application.setStatus("PROCESSING");
item.setOperationType(type); item.setCartonMark("BOX-1"); item.setFbaCartonMark("FBA-1");
item.setNotifiedPackageCount(1);
item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3},{\"skuCode\":\"OLD-B\",\"newSkuCode\":\"NEW-B\",\"quantity\":2}]");
var task = task("PROCESSING", 0);
var saved = new ArrayList<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory>();
when(histories.selectList(any())).thenAnswer(call -> List.copyOf(saved));
when(histories.insert(any(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class))).thenAnswer(call -> { saved.add(call.getArgument(0)); return 1; });
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
lenient().when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectOne(any())).thenReturn(item);
var first = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 0, "sku-a"));
assertEquals(0, first.relabelledPackages()); assertEquals(1, first.version());
assertTrue(saved.stream().anyMatch(entry -> "SKU_SCAN".equals(entry.getAction()) && entry.getDetailJson().contains("NEW-A")));
assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 1, "duplicate")));
assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "WRONG", 1, "wrong")));
var second = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "OLD-B", 1, "sku-b"));
assertEquals(1, second.items().get(0).skuProcessedPackages());
if ("CARTON_AND_SKU".equals(type)) {
assertEquals(0, second.relabelledPackages());
var last = service.scanCarton("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.CartonScan("BOX-1", 2, "carton-last"));
assertEquals(1, last.relabelledPackages());
} else assertEquals(1, second.relabelledPackages());
assertEquals(1, persistedFacts.size());
}
@Test
void duplicateOldLabelAndStaleVersionFailBeforeWritingAnotherFact() {
application.setStatus("PROCESSING");
@@ -117,6 +117,7 @@ class RelabelApplicationServiceTest {
lenient().when(feeSources.publishApproved(any(), any(), any())).thenReturn(new OverseasFeeSource());
}
@Test
void createBuildsPendingRelabelTailOrderItemsAndBothHistories() {
RelabelRequests.Create request = request("key-1", 10, 6);
@@ -144,7 +145,7 @@ class RelabelApplicationServiceTest {
verify(orderHistories).insert(tailHistory.capture());
assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType());
assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus());
assertEquals("WAIT_RELABEL", tailOrder.getValue().getCurrentStatus());
assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus());
assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion());
assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}"));
@@ -155,10 +156,36 @@ class RelabelApplicationServiceTest {
assertEquals(6, application.getValue().getNotifiedPackageCount());
assertEquals("SKU-A", relabelItem.getValue().getSkuCode());
assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus());
assertEquals("PENDING_ASSIGNMENT", tailHistory.getValue().getToStatus());
assertEquals("WAIT_RELABEL", tailHistory.getValue().getToStatus());
assertEquals("FBR-1", result.application().relabelNo());
}
@Test
void warehouseTransferRelabelReusesExistingTailOrder() {
RelabelRequests.Create request = request("key-transfer", 10, 6);
RelabelApplication persisted = application(11L, "FBR-TRANSFER", "10001", "key-transfer", null,
RelabelApplicationService.PENDING_PROCESSING);
persisted.setLastlegOrderId(21L);
LastlegOrder transferOrder = order(21L, "10001", "WT-WH-20260916-560449", "INBOUND_COMPLETED");
transferOrder.setOrderType("WAREHOUSE_TRANSFER");
when(applications.findNo(eq("10001"), any())).thenReturn(persisted);
when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 10, 6)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(transferOrder);
service.createForExistingTailOrder("10001", "operator-1", 21L, request);
ArgumentCaptor<RelabelApplication> application = ArgumentCaptor.forClass(RelabelApplication.class);
verify(applications).insert(application.capture());
verify(orders, never()).insert(any(LastlegOrder.class));
verify(orderHistories, never()).insert(any(LastlegOrderStatusHistory.class));
assertEquals(21L, application.getValue().getLastlegOrderId());
assertEquals(application.getValue().getRelabelNo(), transferOrder.getRelabelOrderNo());
assertEquals(RelabelApplicationService.PENDING_PROCESSING, transferOrder.getRelabelStatus());
assertEquals("WAREHOUSE_TRANSFER", transferOrder.getOrderType());
assertEquals("INBOUND_COMPLETED", transferOrder.getCurrentStatus());
}
@Test
void createWithoutChargeSkipsWalletFreeze() {
RelabelRequests.Create charged = request("key-free", 10, 6);
@@ -242,27 +269,19 @@ class RelabelApplicationServiceTest {
}
@Test
void createKeepsOneOperationWarehouseWhenItemsHaveDifferentFbaDestinations() {
void createRejectsDifferentFbaDestinationsInOneApplication() {
RelabelRequests.Item west = carton("SKU-A", "BOX-1", "FBA-1", "ONT8");
RelabelRequests.Item east = carton("SKU-B", "BOX-2", "FBA-2", "ABE8");
RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", "WH-1",
LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", BigDecimal.ONE, null, "key-multi",
List.of(west, east));
RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-multi", null,
RelabelApplicationService.PENDING_PROCESSING);
persisted.setLastlegOrderId(21L);
when(applications.findNo(eq("10001"), any())).thenReturn(persisted);
when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 1, 1)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1",
RelabelApplicationService.PENDING_PROCESSING));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.create("10001", "operator-1", request));
service.create("10001", "operator-1", request);
ArgumentCaptor<LastlegOrder> created = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders).insert(created.capture());
assertEquals("WH-1", created.getValue().getOverseasWarehouseId());
assertEquals(2, created.getValue().getPackageCount());
assertEquals(400, exception.getStatusCode().value());
assertEquals("同一张贴标申请只能选择一个 FBA 目的仓", exception.getReason());
verify(orders, never()).insert(any(LastlegOrder.class));
verify(applications, never()).insert(any(RelabelApplication.class));
}
@Test
@@ -363,6 +382,9 @@ class RelabelApplicationServiceTest {
application.setLastlegOrderId(21L);
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
stubDetail(application);
LastlegOrder relabelOrder = order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT");
relabelOrder.setOrderType("RELABEL_APPLICATION");
when(orders.selectById(21L)).thenReturn(relabelOrder);
RelabelResponses.Detail result = service.changeStatus("10001", "FBR-1", "operator-2",
new RelabelRequests.ChangeStatus("processing", "started"));
@@ -372,12 +394,33 @@ class RelabelApplicationServiceTest {
verify(applications).updateById(application);
ArgumentCaptor<LastlegOrder> updatedOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders).updateById(updatedOrder.capture());
assertEquals("PENDING_ASSIGNMENT", updatedOrder.getValue().getCurrentStatus());
assertEquals("WAIT_RELABEL", updatedOrder.getValue().getCurrentStatus());
assertEquals("RELABEL:PROCESSING", updatedOrder.getValue().getSubOrderStatus());
verify(histories).insert(any(RelabelStatusHistory.class));
verify(orderHistories).insert(any(LastlegOrderStatusHistory.class));
}
@Test
void reviewStageIsReportedAsCompletedOnLinkedCustomerAndPlatformOrder() {
RelabelApplication application = application(11L, "FBR-REVIEW", "10001", "key-review", "hash",
"PROCESSING");
application.setLastlegOrderId(21L);
LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT");
when(applications.lockNo("10001", "FBR-REVIEW")).thenReturn(application);
when(applications.findNo("10001", "FBR-REVIEW")).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 1, 1)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order);
service.changeStatus("10001", "FBR-REVIEW", "operator-2",
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, "submitted"));
assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, application.getStatus());
assertEquals("WAIT_CUSTOMER_CONFIRMATION", application.getFulfillmentStatus());
assertEquals("COMPLETED", order.getRelabelStatus());
assertEquals("RELABEL:CUSTOMER_CONFIRM_PENDING", order.getSubOrderStatus());
}
@Test
void completedApplicationCannotReturnToProcessingAndOtherTenantCannotReadIt() {
RelabelApplication completed = application(11L, "FBR-1", "10001", "key-1", "hash", "COMPLETED");
@@ -466,7 +509,7 @@ class RelabelApplicationServiceTest {
var review = ArgumentCaptor.forClass(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask.class);
verify(pdaTasks).updateWithVersion(review.capture(), eq(1));
assertEquals("COMPLETED", review.getValue().getStatus());
assertEquals("CUSTOMER_CONFIRMED", review.getValue().getReviewDecision());
assertEquals("CONFIRMED", review.getValue().getReviewDecision());
assertNull(application.getOutboundOrderNo());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
@@ -475,12 +518,36 @@ class RelabelApplicationServiceTest {
verify(applications, times(1)).updateById(application);
}
@Test
void skuOnlyCustomerConfirmationCompletesWithoutPalletOrOutboundFulfillment() {
RelabelApplication application = confirmationApplication(null, false);
RelabelItem skuOnly = item(31L, application.getId(), "SKU-A", 1, 1);
skuOnly.setOperationType("SKU_LABEL");
LastlegOrder linked = order(21L, "10001", "LMO-RELABEL-1", "WAIT_RELABEL");
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
when(applications.findNo("10001", "FBR-1")).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(skuOnly));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(linked);
service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("confirm-sku", "accepted"));
assertEquals("COMPLETED", application.getStatus());
assertEquals("NOT_REQUIRED", application.getFulfillmentStatus());
assertEquals("COMPLETED", linked.getCurrentStatus());
assertNull(application.getOutboundOrderNo());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
verify(outboundItems, never()).insert(any(OutboundPalletItem.class));
verify(outboundTasks, never()).insert(any(OutboundPdaTask.class));
}
@Test
void transferRelabelConfirmationReleasesTransferForDispatch() {
RelabelApplication application = confirmationApplication("TRUCK", false);
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1");
transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L);
transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); transfer.setFbaWarehouseCode("ONT8");
LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL");
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
stubDetail(application);
@@ -498,6 +565,55 @@ class RelabelApplicationServiceTest {
verify(orders).updateById(transferOrder);
}
@Test
void transferRelabelConfirmationWithoutDestinationWaitsForFbaInsteadOfDispatch() {
RelabelApplication application = confirmationApplication("TRUCK", false);
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1");
transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L);
LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL");
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
stubDetail(application);
when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer);
when(orders.selectById(88L)).thenReturn(transferOrder);
service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("confirm-transfer-no-fba", "accepted"));
assertEquals("WAIT_FBA_DESTINATION", transfer.getStatus());
assertEquals("WAIT_FBA_DESTINATION", transferOrder.getCurrentStatus());
assertEquals("WAREHOUSE_TRANSFER:WAIT_FBA_DESTINATION;RELABEL:COMPLETED",
transferOrder.getSubOrderStatus());
assertEquals("WAIT_FBA_DESTINATION", application.getFulfillmentStatus());
}
@Test
void transferRelabelConfirmationDerivesUniqueDestinationFromRelabelItems() {
RelabelApplication application = confirmationApplication("TRUCK", false);
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1");
transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L);
LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL");
RelabelItem relabelItem = item(31L, application.getId(), "SKU-A", 10, 6);
relabelItem.setDestinationFbaWarehouseCode("ONT8");
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
when(applications.findNo("10001", "FBR-1")).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(relabelItem));
when(histories.selectList(any())).thenReturn(List.of());
when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer);
when(orders.selectById(88L)).thenReturn(transferOrder);
when(orders.selectById(application.getLastlegOrderId())).thenReturn(
order(application.getLastlegOrderId(), "10001", "LMO-RELABEL-1", application.getStatus()));
service.confirmCompletion("10001", "FBR-1", "customer-1",
new RelabelRequests.CustomerConfirm("confirm-transfer-derived-fba", "accepted"));
assertEquals("ONT8", transfer.getFbaWarehouseCode());
assertEquals("READY_FOR_DISPATCH", transfer.getStatus());
assertEquals("ONT8", transferOrder.getFbaWarehouseCode());
assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus());
}
@Test
void expressCustomerConfirmationRequiresShippingLabelWhenWarehousePrintingIsSelected() {
RelabelApplication application = confirmationApplication("EXPRESS", true);
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.relabel.entity.*;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static org.junit.jupiter.api.Assertions.*;
class RelabelBindingGuardTest {
@Test void physicalBindingMustMatchLatestTargetAndCannotSurviveRework() {
var guard = new RelabelBindingGuard();
var items = mock(RelabelItemMapper.class);
var history = mock(PdaRelabelHistoryMapper.class);
var positions = mock(InventoryPositionMapper.class);
ReflectionTestUtils.setField(guard, "items", items);
ReflectionTestUtils.setField(guard, "histories", history);
ReflectionTestUtils.setField(guard, "positions", positions);
ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper());
var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2");
var item = new RelabelItem(); item.setCartonMark("A"); item.setOperationType("CARTON_LABEL");
item.setSourceInventoryPositionId(3L); item.setNotifiedPackageCount(1);
when(items.selectList(any())).thenReturn(List.of(item));
var position = new InventoryPosition(); position.setPalletNo("OLD"); position.setHoldPackages(1L);
when(positions.findTenantPosition("2", 3L)).thenReturn(position);
when(history.selectList(any())).thenReturn(List.of());
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
var binding = new PdaRelabelHistory(); binding.setAction("PALLET_BIND");
binding.setDetailJson("{\"cartonMarks\":[\"A\"],\"targetPallets\":[\"NEW\"]}");
when(history.selectList(any())).thenReturn(List.of(binding));
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
position.setPalletNo("NEW");
assertDoesNotThrow(() -> guard.requireBound(app));
var rework = new PdaRelabelHistory(); rework.setAction("REWORK_ARCHIVE");
when(history.selectList(any())).thenReturn(List.of(binding, rework));
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
item.setOperationType("SKU_LABEL");
assertDoesNotThrow(() -> guard.requireBound(app));
}
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class RelabelReturnCartonCandidateTest {
@Test
void candidatesIncludeAvailableMergedReturnCartonByWarehouseMark() {
RelabelApplicationService service = new RelabelApplicationService();
InventoryBalanceMapper balances = mock(InventoryBalanceMapper.class);
InventoryPositionMapper positions = mock(InventoryPositionMapper.class);
ReturnInventoryCartonMapper cartons = mock(ReturnInventoryCartonMapper.class);
ReturnInventoryCartonItemMapper cartonItems = mock(ReturnInventoryCartonItemMapper.class);
ReflectionTestUtils.setField(service, "inventoryBalances", balances);
ReflectionTestUtils.setField(service, "inventoryPositions", positions);
ReflectionTestUtils.setField(service, "returnCartons", cartons);
ReflectionTestUtils.setField(service, "returnCartonItems", cartonItems);
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setId(9001L); carton.setCustomerId("10001"); carton.setOverseasWarehouseId("WH-1");
carton.setCartonMark("RC-WH-0001"); carton.setSourceReturnNo("RT-1,RT-2");
carton.setStatus("AVAILABLE"); carton.setTotalQuantity(12L);
ReturnInventoryCartonItem cartonItem = new ReturnInventoryCartonItem();
cartonItem.setCartonId(9001L); cartonItem.setSkuCode("SKU-RETURN");
cartonItem.setProductNameZh("退货商品"); cartonItem.setQuantity(12L);
when(cartons.findAvailable("10001", "WH-1")).thenReturn(List.of(carton));
when(cartonItems.listCarton(9001L)).thenReturn(List.of(cartonItem));
List<RelabelResponses.InventoryCandidate> result = service.candidates("10001", "WH-1", "RC-WH");
assertEquals(1, result.size());
assertEquals("RETURN_CARTON", result.get(0).sourceType());
assertEquals(9001L, result.get(0).returnCartonId());
assertEquals("RC-WH-0001", result.get(0).cartonMark());
assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode());
assertEquals(12, result.get(0).skuItems().get(0).quantity());
}
}
@@ -11,6 +11,8 @@ class WarehouseTransferSchemaContractTest {
void baselineAndIncrementalContainTransferAndHistoricalOrderBindingTables() throws Exception {
String baseline = Files.readString(Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase();
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase();
String deployment = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"),
StandardCharsets.UTF_8).toLowerCase();
for (String table : new String[]{"ll_warehouse_transfer_order", "ll_warehouse_transfer_source_order",
"ll_warehouse_transfer_item", "ll_warehouse_transfer_inventory_allocation",
"ll_warehouse_transfer_receipt"}) {
@@ -21,5 +23,25 @@ class WarehouseTransferSchemaContractTest {
assertTrue(migration.contains("uk_ll_wh_transfer_source"));
assertTrue(migration.contains("uk_ll_wh_transfer_active_position"));
assertTrue(migration.contains("uk_ll_wh_transfer_receipt_source"));
assertTrue(baseline.contains("pickup_contact_name varchar(128) null"));
assertTrue(baseline.contains("pickup_contact_phone varchar(64) null"));
assertTrue(migration.contains("column_name='pickup_contact_name'"));
assertTrue(migration.contains("column_name='pickup_contact_phone'"));
assertTrue(baseline.contains("create table if not exists ll_warehouse_transfer_measurement_photo"));
assertTrue(migration.contains("create table if not exists ll_warehouse_transfer_measurement_photo"));
assertTrue(baseline.contains("received_package_count int null comment 'pda实际入库箱数'"));
assertTrue(migration.contains("column_name='received_package_count'"));
assertTrue(migration.contains("o.current_status='inbound_completed'"));
for (String field : new String[]{"pickup_contact_name", "review_decision", "destination_type",
"destination_binding_idempotency_key", "private_destination_address",
"private_destination_postal_code", "draft_idempotency_key", "bind_idempotency_key"}) {
assertTrue(deployment.contains(field), () -> "deployment SQL must include " + field);
}
for (String table : new String[]{"ll_warehouse_transfer_measurement_photo", "ll_inbound_plan_line",
"ll_inbound_plan_pallet", "ll_dispatch_item", "ll_delivery_order"}) {
assertTrue(deployment.contains(table), () -> "deployment SQL must include " + table);
}
assertTrue(deployment.contains("assert_relabel_transfer_latest_schema_ready"));
assertTrue(deployment.contains("贴标申请与仓储转运最新表结构已完整部署"));
}
}
@@ -31,6 +31,25 @@ class MerchantWarehouseTransferControllerTest {
verify(service).detail("100", "TR001");
}
@Test
void merchantSubmitUsesOnlyAuthenticatedTenant() {
controller.submit(user(100L, "MERCHANT"), "TR001");
verify(service).submit("100", "TR001", "1");
}
@Test
void merchantUpdateUsesOnlyAuthenticatedTenant() {
WarehouseTransferRequests.Create request = mock(WarehouseTransferRequests.Create.class);
controller.updateDraft(user(100L, "MERCHANT"), "TR001", request);
verify(service).updateDraft("100", "TR001", "1", request);
}
@Test
void merchantPrintsSubmittedCartonLabelsWithinAuthenticatedTenant() {
controller.cartonLabel(user(100L, "MERCHANT"), "TR001");
verify(service).cartonLabel("100", "TR001");
}
@Test
void merchantEndpointRejectsPlatformIdentity() {
assertThrows(ResponseStatusException.class,
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.transfer.controller;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.multipart.MultipartFile;
import static org.assertj.core.api.Assertions.assertThat;
class PdaWarehouseTransferControllerContractTest {
@Test
void detailUsesOnlyAuthenticatedPdaWarehouseScopeAndTransferNumber() throws Exception {
var method = PdaWarehouseTransferController.class
.getMethod("detail", AuthenticatedUser.class, String.class);
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/{transferNo}");
assertThat(method.getParameterCount()).isEqualTo(2);
}
@Test
void measurementPhotoUploadUsesThePublicPdaRouteAndFileMultipartPart() throws Exception {
var method = PdaWarehouseTransferController.class.getMethod("uploadMeasurementPhoto",
AuthenticatedUser.class, String.class, MultipartFile.class);
var mapping = method.getAnnotation(PostMapping.class);
assertThat(mapping.value()).containsExactly("/{transferNo}/measurement-photos/files");
assertThat(mapping.consumes()).containsExactly("multipart/form-data");
assertThat(method.getParameters()[2].getAnnotation(RequestParam.class).value()).isEqualTo("file");
}
@Test
void palletLabelUsesThePdaScopedTransferAndGeneratedWarehousePallet() throws Exception {
var method = PdaWarehouseTransferController.class.getMethod("palletLabel",
AuthenticatedUser.class, String.class, long.class, String.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/{transferNo}/pallets/{warehousePalletId}/label");
assertThat(method.getParameters()[3].getAnnotation(RequestHeader.class).value()).isEqualTo("X-Request-Id");
}
@Test
void palletLifecycleExposesDraftGenerateBindAndDraftDeleteRoutes() throws Exception {
var draft = PdaWarehouseTransferController.class.getMethod("savePalletDraft",
AuthenticatedUser.class, String.class, WarehouseTransferRequests.PalletDraft.class);
var generate = PdaWarehouseTransferController.class.getMethod("generatePallet",
AuthenticatedUser.class, String.class, long.class, WarehouseTransferRequests.PalletAction.class);
var bind = PdaWarehouseTransferController.class.getMethod("bindPallet",
AuthenticatedUser.class, String.class, long.class, WarehouseTransferRequests.PalletBind.class);
var delete = PdaWarehouseTransferController.class.getMethod("deletePalletDraft",
AuthenticatedUser.class, String.class, long.class);
assertThat(draft.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/drafts");
assertThat(generate.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}/generate");
assertThat(bind.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}/bind");
assertThat(delete.getAnnotation(DeleteMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}");
}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.transfer.dto;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WarehouseTransferCargoValidationTest {
@Test
void transferCargoAllowsSkuAndProductNameToBeOmitted() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var violations = factory.getValidator().validate(
new WarehouseTransferRequests.Item(null, null, 1, null));
assertTrue(violations.isEmpty());
}
}
@Test
void cartonRelabelAllowsOriginalSkuToBeOmitted() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var violations = factory.getValidator().validate(new WarehouseTransferRequests.RelabelItem(
"CARTON_LABEL", List.of("CUSTOMER-CARTON-1"), null, null, 1,
"FBA-CARTON-1", "ONT8", null));
assertTrue(violations.isEmpty());
}
}
}
@@ -11,12 +11,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class WarehouseTransferPalletValidationTest {
@Test
void palletAcceptsDirectTotalVolumeAndWeightWithoutCartonDimensions() {
void palletAcceptsMeasuredDimensionsVolumeAndWeight() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5,
new BigDecimal("1.250000"), new BigDecimal("420.50"),
List.of(new WarehouseTransferRequests.PalletItem(101L, 5)));
var pallet = new WarehouseTransferRequests.Pallet(5,
new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("125"),
new BigDecimal("1.250000"), new BigDecimal("420.50"));
assertTrue(validator.validate(pallet).isEmpty());
assertEquals(new BigDecimal("1.250000"), pallet.volumeCbm());
@@ -28,11 +28,23 @@ class WarehouseTransferPalletValidationTest {
void palletRejectsNonPositiveTotalVolume() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5,
BigDecimal.ZERO, new BigDecimal("420.50"),
List.of(new WarehouseTransferRequests.PalletItem(101L, 5)));
var pallet = new WarehouseTransferRequests.Pallet(5,
new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("125"),
BigDecimal.ZERO, new BigDecimal("420.50"));
assertEquals(1, validator.validate(pallet).size());
}
}
@Test
void palletRejectsMissingOrNonPositiveDimensions() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var pallet = new WarehouseTransferRequests.Pallet(5,
BigDecimal.ZERO, null, new BigDecimal("125"),
new BigDecimal("1.250000"), new BigDecimal("420.50"));
assertEquals(2, validator.validate(pallet).size());
}
}
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.transfer.gateway;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import static org.assertj.core.api.Assertions.assertThat;
class HttpTransferMeasurementPhotoFileGatewayContractTest {
@Test
void defaultsToTheFileServicesActualLocalPort() throws Exception {
var field = HttpTransferMeasurementPhotoFileGateway.class.getDeclaredField("baseUrl");
assertThat(field.getAnnotation(Value.class).value())
.isEqualTo("${baiyue.clients.file.url:http://127.0.0.1:9206}");
}
}
@@ -0,0 +1,94 @@
package com.baiyue.lastleg.transfer.service;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class WarehouseTransferPalletAllocationTest {
@Test
void allocatesMultipleDeclaredSkusAcrossPalletsInStableDeclaredOrder() {
WarehouseTransferItem first = item(11L, 3);
WarehouseTransferItem second = item(12L, 4);
var allocations = WarehouseTransferService.derivePalletAllocations(
List.of(first, second), List.of(pallet(2), pallet(5)));
assertEquals(List.of(
new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 2),
new WarehouseTransferService.DerivedPalletAllocation(1, 11L, 1),
new WarehouseTransferService.DerivedPalletAllocation(1, 12L, 4)), allocations);
}
@Test
void underReceiptConsumesDeclaredSkusInStableOrder() {
var allocations = WarehouseTransferService.derivePalletAllocations(
List.of(item(11L, 3), item(12L, 2)), List.of(pallet(4)));
assertEquals(List.of(
new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 3),
new WarehouseTransferService.DerivedPalletAllocation(0, 12L, 1)), allocations);
assertEquals(4, allocations.stream().mapToInt(value -> value.packageCount()).sum());
}
@Test
void overReceiptAssignsExcessToFinalDeclaredSkuAcrossPallets() {
var allocations = WarehouseTransferService.derivePalletAllocations(
List.of(item(11L, 3), item(12L, 2)), List.of(pallet(4), pallet(3)));
assertEquals(List.of(
new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 3),
new WarehouseTransferService.DerivedPalletAllocation(0, 12L, 1),
new WarehouseTransferService.DerivedPalletAllocation(1, 12L, 3)), allocations);
assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum());
}
@Test
void persistedOverReceiptUsesTheSameStableAllocationRule() {
WarehouseTransferPallet first = persistedPallet(4);
WarehouseTransferPallet second = persistedPallet(3);
var allocations = WarehouseTransferService.derivePersistedPalletAllocations(
List.of(item(11L, 3), item(12L, 2)), List.of(first, second));
assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum());
assertEquals(3, allocations.stream().filter(value -> value.transferItemId() == 11L)
.mapToInt(value -> value.packageCount()).sum());
assertEquals(4, allocations.stream().filter(value -> value.transferItemId() == 12L)
.mapToInt(value -> value.packageCount()).sum());
assertEquals(List.of(0, 0, 1), allocations.stream().map(value -> value.palletIndex()).toList());
}
@Test
void requestsFreshPalletNumbersAndCarriesOnlyTheKnownFbaDestination() {
var known = WarehouseTransferService.newTransferPalletRequests(81L, "WTR-1", "ONT8", 2);
var unknown = WarehouseTransferService.newTransferPalletRequests(81L, "WTR-2", null, 1);
assertEquals(List.of("WTR-1:1", "WTR-1:2"), known.stream().map(value -> value.businessKey()).toList());
assertEquals(List.of("ONT8", "ONT8"), known.stream().map(value -> value.fbaWarehouseCode()).toList());
assertEquals(true, known.stream().allMatch(value -> value.palletCode() == null));
assertEquals(null, unknown.get(0).fbaWarehouseCode());
assertEquals(null, unknown.get(0).palletCode());
}
private static WarehouseTransferItem item(long id, int packages) {
WarehouseTransferItem item = new WarehouseTransferItem();
item.setId(id); item.setPackageCount(packages);
return item;
}
private static WarehouseTransferRequests.Pallet pallet(int cartons) {
return new WarehouseTransferRequests.Pallet(cartons, BigDecimal.ONE, BigDecimal.ONE,
BigDecimal.ONE, new BigDecimal("0.000001"), BigDecimal.ONE);
}
private static com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet persistedPallet(int cartons) {
var pallet = new com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet();
pallet.setCartonCount(cartons);
return pallet;
}
}
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.transfer.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegInboundOrder;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
@@ -19,11 +20,27 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferMeasurementPhotoMapper;
import com.baiyue.lastleg.transfer.gateway.TransferMeasurementPhotoFileGateway;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -62,14 +79,36 @@ import static org.mockito.Mockito.times;
@ExtendWith(MockitoExtension.class)
class WarehouseTransferServiceTest {
@Test
void customerMainStatusFiltersExpandToInternalWorkflowStates() {
assertEquals(List.of(WarehouseTransferService.APPROVED, WarehouseTransferService.WAIT_PDA_RECEIVING),
WarehouseTransferService.customerMainStatuses(WarehouseTransferService.INBOUND_MEASURING));
assertTrue(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.INBOUND_COMPLETED)
.containsAll(List.of(WarehouseTransferService.WAIT_CUSTOMER_CONFIRMATION,
WarehouseTransferService.WAIT_FBA_DESTINATION, WarehouseTransferService.WAIT_RELABEL,
WarehouseTransferService.READY_FOR_DISPATCH, WarehouseTransferService.OUTBOUNDED)));
assertNull(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.DRAFT));
}
@Mock WarehouseTransferOrderMapper transfers;
@Mock WarehouseTransferItemMapper transferItems;
@Mock WarehouseTransferCartonMapper transferCartons;
@Mock WarehouseTransferSourceOrderMapper sourceOrders;
@Mock WarehouseTransferInventoryAllocationMapper inventoryAllocations;
@Mock WarehouseTransferReceiptMapper transferReceipts;
@Mock WarehouseTransferMeasurementPhotoMapper measurementPhotos;
@Mock WarehouseTransferPalletMapper transferPallets;
@Mock WarehouseTransferPalletItemMapper transferPalletItems;
@Mock InboundStockReceiptMapper stockReceipts;
@Mock WarehouseTransferRelabelBindingMapper relabelBindings;
@Mock WarehouseResourceGateway warehouseResources;
@Mock TransferMeasurementPhotoFileGateway measurementPhotoFiles;
@Mock InboundSnapshotMapper inboundSnapshots;
@Mock LastlegInboundOrderMapper inboundOrders;
@Mock InboundPlanMapper inboundPlans;
@Mock InboundPlanLineMapper inboundPlanLines;
@Mock InboundPlanPalletMapper inboundPlanPallets;
@Mock InboundPlanPalletAllocationMapper inboundPlanAllocations;
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper orderDetails;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@@ -88,8 +127,19 @@ class WarehouseTransferServiceTest {
ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders);
ReflectionTestUtils.setField(service, "inventoryAllocations", inventoryAllocations);
ReflectionTestUtils.setField(service, "transferReceipts", transferReceipts);
ReflectionTestUtils.setField(service, "measurementPhotos", measurementPhotos);
ReflectionTestUtils.setField(service, "transferPallets", transferPallets);
ReflectionTestUtils.setField(service, "transferPalletItems", transferPalletItems);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "relabelBindings", relabelBindings);
ReflectionTestUtils.setField(service, "warehouseResources", warehouseResources);
ReflectionTestUtils.setField(service, "measurementPhotoFiles", measurementPhotoFiles);
ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots);
ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders);
ReflectionTestUtils.setField(service, "inboundPlans", inboundPlans);
ReflectionTestUtils.setField(service, "inboundPlanLines", inboundPlanLines);
ReflectionTestUtils.setField(service, "inboundPlanPallets", inboundPlanPallets);
ReflectionTestUtils.setField(service, "inboundPlanAllocations", inboundPlanAllocations);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
@@ -103,6 +153,116 @@ class WarehouseTransferServiceTest {
lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of());
lenient().when(transferReceipts.selectList(any())).thenReturn(List.of());
lenient().when(transferCartons.listOrder(anyString(), anyLong())).thenReturn(List.of());
lenient().when(measurementPhotos.listOrder(anyString(), anyLong())).thenReturn(List.of());
lenient().when(measurementPhotos.listSubmitted(anyString(), anyLong())).thenReturn(List.of());
lenient().when(transferPalletItems.listOrder(anyString(), anyLong())).thenReturn(List.of());
lenient().when(relabelBindings.listOrder(anyString(), anyLong())).thenReturn(List.of());
}
@Test
void generatedPalletRetryWithSameKeyDoesNotGenerateAnotherPhysicalPallet() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
transfer.setOperatingWarehouseId("77");
WarehouseTransferPallet pallet = measuredPallet("GENERATED");
pallet.setGenerateIdempotencyKey("GEN-1");
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
when(transferPallets.lockOne("TENANT-A", transfer.getId(), pallet.getId())).thenReturn(pallet);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet));
service.generatePallet(77L, "WTR-1", pallet.getId(), new WarehouseTransferRequests.PalletAction("GEN-1"));
verify(warehouseResources, never()).ensurePalletBatch(any());
verify(warehouseResources, never()).confirmPalletBatch(any());
}
@Test
void scanBindingRejectsCodeOtherThanThisGeneratedPallet() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
WarehouseTransferPallet pallet = measuredPallet("GENERATED");
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
when(transferPallets.lockOne("TENANT-A", transfer.getId(), pallet.getId())).thenReturn(pallet);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.bindPallet(
77L, "WTR-1", pallet.getId(), new WarehouseTransferRequests.PalletBind("OTHER-PALLET", "BIND-1")));
assertEquals(409, exception.getStatusCode().value());
verify(warehouseResources, never()).resolvePallet(anyString());
}
@Test
void palletizeRejectsWhenAnyPersistedPalletIsNotBound() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
transfer.setOperatingWarehouseId("77");
WarehouseTransferPallet pallet = measuredPallet("GENERATED");
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
when(transferItems.selectList(any())).thenReturn(List.of(transferItem(51L, "SKU-1", 5)));
WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto();
photo.setFileId(9001L);
when(measurementPhotos.findFile("TENANT-A", transfer.getId(), 9001L)).thenReturn(photo);
when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet));
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.palletize(
"OP-1", 77L, "WTR-1", new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L))));
assertEquals(409, exception.getStatusCode().value());
verify(warehouseResources, never()).ensurePalletBatch(any());
}
@Test
void palletizeCompletesAfterEveryPalletIsBoundAndDoesNotGenerateAgain() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
transfer.setOperatingWarehouseId("77");
transfer.setInboundCode("WTR-1");
WarehouseTransferPallet pallet = measuredPallet("BOUND");
WarehouseTransferItem item = transferItem(51L, "SKU-1", 5);
LastlegOrder tail = tailOrder();
LastlegInboundOrder inbound = new LastlegInboundOrder();
inbound.setId(transfer.getInboundOrderId()); inbound.setCustomerId("TENANT-A");
InboundPlan inboundPlan = new InboundPlan(); inboundPlan.setId(201L); inboundPlan.setStatus("PDA_PENDING");
InboundPlanLine inboundLine = new InboundPlanLine(); inboundLine.setId(202L);
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transferItems.selectList(any())).thenReturn(List.of(item));
when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet));
WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto();
photo.setFileId(9001L);
when(measurementPhotos.findFile("TENANT-A", transfer.getId(), 9001L)).thenReturn(photo);
when(measurementPhotos.markSubmitted(eq("TENANT-A"), eq(transfer.getId()), any(), any())).thenReturn(1);
when(orders.selectById(transfer.getLastlegOrderId())).thenReturn(tail);
when(inboundOrders.selectById(transfer.getInboundOrderId())).thenReturn(inbound);
when(inboundPlans.findBySourceOrder("TENANT-A", transfer.getLastlegOrderId())).thenReturn(inboundPlan);
when(inboundPlanLines.selectOne(any())).thenReturn(inboundLine);
when(warehouseResources.requireActiveSharedWarehouse(77L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(77L, "WH-77", "Warehouse 77", "ACTIVE"));
when(inventory.stockIn(eq("TENANT-A"), eq("OP-1"), any())).thenReturn(
new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false));
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.palletize("OP-1", 77L, "WTR-1",
new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L)));
assertEquals(WarehouseTransferService.WAIT_CUSTOMER_CONFIRMATION, result.status());
assertEquals("COMPLETED", pallet.getStatus());
assertEquals(WarehouseTransferService.INBOUND_COMPLETED, tail.getCurrentStatus());
assertEquals(5, tail.getReceivedPackageCount());
assertEquals("RECEIVED", inbound.getStatus());
assertEquals(5, inbound.getPackageCount());
assertEquals("COMPLETED", inboundPlan.getStatus());
verify(transferPalletItems).insert(any(com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem.class));
verify(inventory).stockIn(eq("TENANT-A"), eq("OP-1"), any());
verify(stockReceipts).insert(any(com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt.class));
verify(inboundOrders).updateById(inbound);
verify(inboundPlans).updateById(inboundPlan);
verify(inboundPlanPallets).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet.class));
ArgumentCaptor<com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation> allocation =
ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation.class);
verify(inboundPlanAllocations).insert(allocation.capture());
assertEquals(5, allocation.getValue().getPickedPackageCount());
verify(warehouseResources, never()).ensurePalletBatch(any());
verify(warehouseResources, never()).confirmPalletBatch(any());
}
@Test
@@ -111,7 +271,7 @@ class WarehouseTransferServiceTest {
LastlegOrder historical = historicalOrder();
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historical));
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer());
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
@@ -120,23 +280,23 @@ class WarehouseTransferServiceTest {
ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders).insert(tailOrder.capture());
assertEquals(WarehouseTransferService.ORDER_TYPE, tailOrder.getValue().getOrderType());
assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getValue().getCurrentStatus());
assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus());
assertEquals(WarehouseTransferService.DRAFT, tailOrder.getValue().getCurrentStatus());
assertEquals("WAREHOUSE_TRANSFER:DRAFT;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus());
assertTrue(tailOrder.getValue().getOrderNo().matches("WT-WH-\\d{8}-\\d{6}"));
assertNull(tailOrder.getValue().getTransferWarehouseId());
verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class));
verify(relabelApplications, never()).insert(any(RelabelApplication.class));
assertNull(tailOrder.getValue().getRelabelOrderNo());
assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus());
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status());
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus());
assertEquals(WarehouseTransferService.DRAFT, result.status());
assertEquals(WarehouseTransferService.DRAFT, result.orderStatus());
}
@Test
void transferWithoutRelabelDoesNotCreateRelabelDocument() {
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historicalOrder()));
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer());
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
@@ -146,6 +306,27 @@ class WarehouseTransferServiceTest {
assertNull(result.relabelNo());
}
@Test
void transferRelabelRejectsMultipleDestinationWarehouses() {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
null, null, "WH-1", null, "NONE", null, null, null,
null, null, null, null, "123 Pickup Road", null, null, 2,
new BigDecimal("0.1000"), "CUSTOMER_DELIVERY", LocalDateTime.of(2026, 9, 18, 10, 0),
null, null, null, "BOX-1,BOX-2", null, true,
LocalDateTime.of(2026, 9, 19, 10, 0), "Contact", "13800000000", null, "TRUCK",
"single-destination-check", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 2, null)),
List.of(
new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of("BOX-1"), "SKU-1", null, 1, "FBA-1", "ONT8", null),
new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of("BOX-2"), "SKU-1", null, 1, "FBA-2", "ACY2", null)));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.create("TENANT-A", "USER-1", request));
assertEquals(400, exception.getStatusCode().value());
assertEquals("Warehouse transfer relabel only supports one destination warehouse per order", exception.getReason());
verifyNoInteractions(transfers);
}
@Test
void transferCanEnterTheTransitWarehouseBeforeFbaDestinationIsKnown() {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
@@ -155,7 +336,7 @@ class WarehouseTransferServiceTest {
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")));
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historicalOrder()));
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer());
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
@@ -179,7 +360,7 @@ class WarehouseTransferServiceTest {
null, "TRACK-MUST-NOT-BE-SAVED", null, "Incoming transit cargo", true, "key-incoming",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product A", 2, null),
new WarehouseTransferRequests.Item("SKU-2", "Product B", 3, null)));
when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> persistedTransfer());
when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
@@ -199,7 +380,7 @@ class WarehouseTransferServiceTest {
assertEquals(transfer.getValue().getTransferNo(), transfer.getValue().getInboundCode());
assertEquals(transfer.getValue().getTransferNo(), inbound.getValue().getInboundCode());
assertEquals("OVERSEAS", inbound.getValue().getDestinationType());
assertEquals(WarehouseTransferService.PENDING_REVIEW, inbound.getValue().getStatus());
assertEquals(WarehouseTransferService.DRAFT, inbound.getValue().getStatus());
assertNull(transfer.getValue().getTrackingNo());
assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus());
assertEquals(List.of(1, 2, 3, 4, 5), cartons.getAllValues().stream()
@@ -209,7 +390,7 @@ class WarehouseTransferServiceTest {
}
@Test
void approvedTransferWithoutRelabelEntersPendingAssignment() {
void approvedTransferWithoutRelabelEntersInboundMeasuring() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.PENDING_REVIEW);
transfer.setNeedRelabel(false);
@@ -225,14 +406,134 @@ class WarehouseTransferServiceTest {
new WarehouseTransferRequests.Review("APPROVE", null));
assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus());
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus());
assertEquals(WarehouseTransferService.INBOUND_MEASURING, tailOrder.getCurrentStatus());
assertEquals(WarehouseTransferService.APPROVED, result.status());
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus());
assertEquals(WarehouseTransferService.INBOUND_MEASURING, result.orderStatus());
verify(transfers).updateById(transfer);
verify(orders).updateById(tailOrder);
verifyNoInteractions(inventory);
}
@Test
void approvedIncomingTransferCreatesPendingInboundPlan() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setInboundCode("WTR-1"); transfer.setOperatingWarehouseId("77");
transfer.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 17, 10, 0));
LastlegOrder tailOrder = tailOrder(); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW);
LastlegInboundOrder inbound = new LastlegInboundOrder();
inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.PENDING_REVIEW);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(inboundOrders.selectById(101L)).thenReturn(inbound);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
service.review("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Review("APPROVE", null));
ArgumentCaptor<InboundPlan> plan = ArgumentCaptor.forClass(InboundPlan.class);
ArgumentCaptor<InboundPlanLine> line = ArgumentCaptor.forClass(InboundPlanLine.class);
verify(inboundPlans).insert(plan.capture());
verify(inboundPlanLines).insert(line.capture());
assertEquals("PDA_PENDING", plan.getValue().getStatus());
assertEquals("WT-WTR-1", plan.getValue().getPlanNo());
assertEquals("WTR-1", line.getValue().getInboundCode());
assertEquals(5, line.getValue().getRequiredPackageCount());
assertEquals("PENDING_INBOUND", inbound.getStatus());
assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING, transfer.getStatus());
}
@Test
void merchantSubmitsDraftForReviewBeforePrintingLabels() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.DRAFT);
transfer.setInboundCode("WTR-1");
LastlegOrder tailOrder = historicalOrder();
tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setRelabelStatus("NOT_REQUIRED");
LastlegInboundOrder inbound = new LastlegInboundOrder();
inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.DRAFT);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(inboundOrders.selectById(101L)).thenReturn(inbound);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.submit("TENANT-A", "WTR-1", "USER-1");
assertEquals(WarehouseTransferService.PENDING_REVIEW, transfer.getStatus());
assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getCurrentStatus());
assertEquals(WarehouseTransferService.PENDING_REVIEW, inbound.getStatus());
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status());
verify(transfers).updateById(transfer);
verify(orders).updateById(tailOrder);
verify(inboundOrders).updateById(inbound);
}
@Test
void merchantUpdatesDraftAndRebuildsEditableSnapshots() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.DRAFT);
LastlegOrder tailOrder = historicalOrder();
tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus(WarehouseTransferService.DRAFT);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false));
assertEquals(WarehouseTransferService.DRAFT, result.status());
assertEquals("WH-1", transfer.getOperatingWarehouseId());
assertEquals(new BigDecimal("0.2500"), tailOrder.getVolumeCbm());
verify(transfers).updateById(transfer);
verify(orders).updateById(tailOrder);
verify(transferCartons).delete(any());
verify(transferItems).delete(any());
verify(relabelBindings).delete(any());
verify(orderDetails).delete(any());
verify(transferItems).insert(any(WarehouseTransferItem.class));
verify(transferCartons, times(5)).insert(any(WarehouseTransferCarton.class));
verify(orderDetails).insert(any(LastlegOrderDetail.class));
}
@Test
void merchantCannotUpdateSubmittedTransfer() {
WarehouseTransferOrder transfer = persistedTransfer();
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false)));
assertEquals(409, error.getStatusCode().value());
verify(transfers, never()).updateById(any(WarehouseTransferOrder.class));
}
@Test
void merchantDraftUpdateIsTenantScoped() {
when(transfers.lockNo("TENANT-B", "WTR-1")).thenReturn(null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.updateDraft("TENANT-B", "WTR-1", "USER-2", request(false)));
assertEquals(404, error.getStatusCode().value());
verify(transfers, never()).updateById(any(WarehouseTransferOrder.class));
}
@Test
void draftCannotPrintCartonLabelsBeforeSubmission() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.DRAFT);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.cartonLabel("TENANT-A", "WTR-1"));
assertEquals(409, error.getStatusCode().value());
verify(transferCartons, never()).listOrder(anyString(), anyLong());
}
@Test
void rejectionRequiresNonBlankReasonBeforeAnyStateOrInventoryChange() {
for (String decision : List.of("REJECT", "REJECTED")) {
@@ -378,6 +679,67 @@ class WarehouseTransferServiceTest {
assertEquals("RESERVED", allocation.getStatus());
}
@Test
void outboundRejectsTransferWithoutFbaDestinationBeforeInventoryMutation() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
transfer.setFbaWarehouseCode(null);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.outbound("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Outbound("OUT-NO-FBA", null)));
assertEquals(409, exception.getStatusCode().value());
verifyNoInteractions(inventory);
verify(inventoryAllocations, never()).lockTransfer(anyString(), anyLong());
verify(transfers, never()).updateById(any(WarehouseTransferOrder.class));
}
@Test
void merchantMeasurementPhotosExposeOnlySubmittedSelection() {
WarehouseTransferOrder transfer = persistedTransfer();
WarehouseTransferMeasurementPhoto selected = new WarehouseTransferMeasurementPhoto();
selected.setFileId(101L); selected.setFileName("selected.jpg"); selected.setContentType("image/jpeg");
selected.setSizeBytes(12L); selected.setUploadedAt(LocalDateTime.of(2026, 9, 16, 10, 0));
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(measurementPhotos.listSubmitted("TENANT-A", 21L)).thenReturn(List.of(selected));
var result = service.measurementPhotos("TENANT-A", "WTR-1");
assertEquals(List.of(101L), result.stream().map(value -> value.fileId()).toList());
verify(measurementPhotos, never()).listOrder("TENANT-A", 21L);
}
@Test
void merchantCannotDownloadUploadedButUnsubmittedMeasurementPhoto() {
WarehouseTransferOrder transfer = persistedTransfer();
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(measurementPhotos.findSubmittedFile("TENANT-A", 21L, 102L)).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.downloadMeasurementPhoto("TENANT-A", "WTR-1", 102L));
assertEquals(404, exception.getStatusCode().value());
verifyNoInteractions(measurementPhotoFiles);
}
@Test
void pdaCanDownloadOwnWarehouseUploadedPhotoBeforeSubmission() {
WarehouseTransferOrder transfer = persistedTransfer();
WarehouseTransferMeasurementPhoto draft = new WarehouseTransferMeasurementPhoto();
draft.setFileId(102L);
var downloaded = new TransferMeasurementPhotoFileGateway.DownloadedFile(
new byte[]{1}, "draft.jpg", "image/jpeg");
when(transfers.findNoByWarehouse("WTR-1", "92")).thenReturn(transfer);
when(measurementPhotos.findFile("TENANT-A", 21L, 102L)).thenReturn(draft);
when(measurementPhotoFiles.download("TENANT-A", 21L, 102L)).thenReturn(downloaded);
var result = service.downloadPdaMeasurementPhoto(92L, "WTR-1", 102L);
assertEquals("draft.jpg", result.fileName());
verify(measurementPhotos, never()).findSubmittedFile(anyString(), anyLong(), anyLong());
}
@Test
void allocationRejectsPositionOutsideSelectedInboundOrder() {
WarehouseTransferOrder transfer = transfer("APPROVED");
@@ -427,6 +789,7 @@ class WarehouseTransferServiceTest {
transfer.setInboundOrderId(101L); transfer.setInboundCode("IN-1"); transfer.setLastlegOrderId(31L);
transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false);
transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); transfer.setPackageCount(5);
transfer.setFbaWarehouseCode("ONT8");
return transfer;
}
@@ -436,6 +799,24 @@ class WarehouseTransferServiceTest {
return transfer;
}
private static WarehouseTransferPallet measuredPallet(String status) {
WarehouseTransferPallet pallet = new WarehouseTransferPallet();
pallet.setId(61L); pallet.setTransferOrderId(21L); pallet.setCustomerId("TENANT-A");
pallet.setSequenceNo(1); pallet.setWarehousePalletId(701L); pallet.setPalletNo("NEW-PALLET-1");
pallet.setCartonCount(5); pallet.setLengthCm(new BigDecimal("100.00"));
pallet.setWidthCm(new BigDecimal("100.00")); pallet.setHeightCm(new BigDecimal("100.00"));
pallet.setVolumeCbm(new BigDecimal("1.000000")); pallet.setGrossWeightKg(new BigDecimal("50.000"));
pallet.setStatus(status);
return pallet;
}
private static WarehouseTransferItem transferItem(long id, String sku, int packages) {
WarehouseTransferItem item = new WarehouseTransferItem();
item.setId(id); item.setTransferOrderId(21L); item.setCustomerId("TENANT-A");
item.setSkuCode(sku); item.setProductNameZh("Product"); item.setPackageCount(packages);
return item;
}
private static LastlegOrder tailOrder() {
LastlegOrder order = historicalOrder();
order.setId(31L); order.setCurrentStatus("PENDING_ASSIGNMENT");
+10 -1
View File
@@ -1,5 +1,9 @@
# 后端脚本管理与全量 HTTP 验收索引
`db/relabel-wait-status-incremental.sql`:存量换标主单状态修正,显式使用 `baiyue_lastleg`。由操作者选择已授权数据库连接执行,无内置凭据;只更新未生成入库单、仍为待分配的 `RELABEL_APPLICATION` 主单为待换标/待排车/待出库,不修改库存或删除单据。事务执行、可重复运行,执行后按换标号回查订单状态;无单独报告文件。
`db/warehouse-transfer-outbound-incremental.sql`:固定指向 `baiyue_lastleg`,一次补齐仓储转运审核、提货、PDA 测量打托、现场照片、换标、FBA/私人仓目的地、入库计划、智能排车和派送所需表、租户字段及索引,并将换标新 SKU 和转运托盘生命周期字段修正为本地要求的可空结构。无内置凭据;表使用 `CREATE TABLE IF NOT EXISTS`,字段和索引通过 `information_schema` 独立判断,可重复执行,不删除或改写业务单据。完整成功时返回 `WAREHOUSE_TRANSFER_LATEST_SCHEMA_READY`;若历史数据存在同客户重复托盘号,则返回 `WAREHOUSE_TRANSFER_SCHEMA_APPLIED_WITH_INDEX_BLOCKER`,且 `inbound_pallet_unique_index` 为 `BLOCKED_BY_DUPLICATE_CUSTOMER_PALLET_NO`。其余结构仍正常补齐,脚本不会擅自修改历史托盘号。
`mixed-container-outbound-test-env.mjs` 是经明确授权的测试环境留存混柜出库续跑入口:参数为 `source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report`,凭据由显式 `node --env-file` 注入。FBA 分支实时校验既有收货数据,创建排车、出库、派送及临时 PDA 账号,执行拣货、发车、测试签收文件保存和国内状态回查;一件代发分支只走专用候选、两托四箱出库、PDA 幂等拣货和完成回查,并明确记录派送未覆盖,不返回全流程成功退出码。finally 停用临时账号,不删除业务数据。输出报告作为检查点,失败后须先核对其中业务单号和实时状态,再用同一输出文件续跑;不得删除报告后盲目重试。它不代表普通海外仓目的地转换或所有分支签收验收通过。
全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。
@@ -71,6 +75,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
| 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 |
| 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 |
| 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` |
| 本地真实 API | `relabel-closure-local-acceptance.mjs` | 仅调用本机海外 Gateway/Print/File/Lastleg,创建合成换标单并验证重复下单、三类 PDF、PDA 换标、拆托/合托、返工和换标出库;会写入本地业务数据,不代表现场照片或财务验收 |
| 测试环境 Excel 造数 | `real-order-xlsx-test-env.mjs` | 从外部源 XLSX 按入仓码归并,严格匹配客户可用渠道、税制、目的仓;`preflight` 只核对,`create` 创建草稿,`pending-review` 批量提交后停在待审核且不使用托盘/库位,`wait-split` 只供少量完整测量验收。参数、限制及重跑说明见 `real-order-xlsx-import.md` |
| 测试环境 HTTP | `dimension-ocr-pricing-test-env.ps1` | 尺寸、OCR 和渠道计价专项,上传本地图片并写测试业务数据;包含历史默认参数,只能显式传入当前目标参数后使用 |
| 测试环境 HTTP | `export-center-test-env-acceptance.ps1` | 导出中心测试环境验收,会创建导出任务和相关测试数据 |
@@ -276,6 +281,10 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行
## 海外换标定向验收补充
- `scripts/current-session-overseas-relabel-acceptance.mjs`:本地开发环境定向入口,从当前 Codex 内置浏览器本地存储读取最新平台/客户 refresh token,在内存中刷新双区域会话,自动选择当前客户具备足量库存的海外仓并临时创建海外 PDA 操作员;随后串行运行换标拆/合托、智能预排、PDA 出库与派送完成。报告写入 `reports/current-session-overseas-relabel/`,不记录 Token/密码;会消耗当前浏览器 refresh token 并保留业务验收单据,临时 PDA 在 `finally` 中停用。只允许本机开发环境且须有当前轮真实写入授权。
- `db/seed-local-relabel-ready-orders.sql`:仅供明确授权的本机测试库存准备。调用者在同一 MySQL 会话提供客户、共享海外仓、有效虚拟库位、FBA目的仓、唯一批次及ID基数;凭据由本机进程环境提供。以事务插入三单共54箱、六托及原入仓快照、计划、明细、余额、仓位和明确标记 LOCAL_TEST_FIXTURE 的库存流水;不伪造PDA照片、签收操作或HTTP验收通过,不修改原数据。重复批次失败并回滚,不用upsert重置已消耗库存;成功数据保留,结果表输出到调用者日志,不自动清理。
- `scripts/current-session-overseas-relabel-acceptance.mjs`:本地开发环境定向入口,从当前 Codex 内置浏览器本地存储读取最新平台/客户 refresh token,在内存中刷新双区域会话,自动选择当前客户具备足量库存的海外仓并临时创建海外 PDA 操作员;随后串行运行换标拆/合托、智能预排、PDA 出库与派送完成。报告写入 `reports/current-session-overseas-relabel/`,不记录 Token/密码;会消耗当前浏览器 refresh token 并保留业务验收单据,临时 PDA 在 `finally` 中停用。履约阶段中断后可显式设置 `OVERSEAS_RESUME_FULFILLMENT=true`,复用目录内已通过的 `relabel.json` 且只续跑出库派送。只允许本机开发环境且须有当前轮真实写入授权。
- `scripts/overseas-relabel-real-acceptance.mjs`:定向真实 HTTP 换标作业,会创建换标、库存及排车记录;从显式环境文件读取平台/PDA账号、网关及客户参数,输出脚本配置的报告,不自动清理。失败保留证据与已创建资源,重跑前核对报告,必须另获指定环境授权。
- 该脚本现按“扫码→组托→完工→客户确认”执行,`OVERSEAS_RELABEL_CHARGE_AMOUNT=0` 表示省略费用字段、只验仓库链路(不代表财务通过);默认 0.01 验钱包冻结。照片为有效的合成测试 PNG,不是真实现场照片。
- `scripts/relabel-pdf-local-acceptance.mjs`:仅本机 Print→File 真实 HTTP 验收,显式提供 `INTERNAL_SERVICE_TOKEN`、`OVERSEAS_TEST_CUSTOMER_ID`,可配置 `PRINT_SERVICE_URL`、`FILE_SERVICE_URL`。创建三类测试 PDF 文件元数据,验证下载哈希及跨客户拒绝,报告 `reports/relabel-closure-20260916/pdf.json`;保留文件,不清理,失败非零退出,可重跑生成新 businessId。不代表换标下单/PDA全流程通过。
- `scripts/overseas-relabel-fulfillment-acceptance.mjs`:读取 `OVERSEAS_ACCEPTANCE_SOURCE` 或指定排车/换标编号,执行 PDA 拣货、出库、派送后续真实 HTTP,会推进业务状态;凭据来自显式环境文件,报告默认 `reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT` 可覆盖)。失败保留资源,重跑前核对业务状态,不自动清理。两个定向脚本不替代 Admin 全量入口,本次合并仅做离线合同。
@@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa
const overseasSchemas = ['baiyue_event_bridge_overseas', 'baiyue_lastleg', 'baiyue_lastlag_warehouse']
test('两个入口分别只操作国内九库与海外三库,包括限定名引用', () => {
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 208], [overseas, overseasSchemas, domesticSchemas, 136]]) {
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 208], [overseas, overseasSchemas, domesticSchemas, 138]]) {
assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed)
assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed)
const body = sql.replace(/^--.*$/gm, '')
@@ -28,7 +28,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前
assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi)
assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i)
assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1)
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (208|136) AS initialized_tables;$/)
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (208|138) AS initialized_tables;$/)
}
})
@@ -113,6 +113,43 @@ async function createPda(adminToken, warehouseId, onCreated) {
throw new Error('Temporary PDA security projection did not converge')
}
async function merchantSessionForTenant(adminToken, tenantId) {
const ticket = await api(domestic, adminToken, 'POST',
`/api/platform/v1/iam/tenants/${tenantId}/impersonation`, { reason: `${marker} 海外换标真实验收` })
return api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
}
async function findRelabelScope(adminToken, preferredMerchant = null) {
const sessions = []
if (preferredMerchant?.accessToken && preferredMerchant?.overseasAccessToken) sessions.push(preferredMerchant)
if (!sessions.length) {
const tenants = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')
for (const tenant of tenants.records || []) {
try { sessions.push(await merchantSessionForTenant(adminToken, id(tenant.id))) } catch {}
}
}
for (const session of sessions) {
const customerId = jwtId(session.overseasAccessToken, 'tenant_id', 'tenantId')
if (!customerId) continue
let warehouses
try { warehouses = await api(domestic, session.accessToken, 'GET', '/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200') }
catch { continue }
for (const warehouse of warehouses.records || []) {
if (warehouse.status !== 'ACTIVE') continue
let candidates
try { candidates = await api(overseas, session.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${encodeURIComponent(id(warehouse.id))}`) }
catch { continue }
if (candidates.some((item) => String(item.inboundOrderNo || '').startsWith('DS-')
&& Number(item.availablePackages) >= 5 && item.destinationFbaWarehouseCode
&& item.positions?.some((position) => position.palletNo && position.palletNo !== 'UNBOUND'
&& Number(position.availablePackages) >= 5))) {
return { merchant: session, customerId, warehouse }
}
}
}
throw new Error('No active customer has a destination warehouse and five cartons on a physical source pallet')
}
await mkdir(outputDir, { recursive: true })
let admin
let pda
@@ -124,29 +161,22 @@ try {
}
const storedMerchantToken = storedJwt('cc-client-overseas-access-token')
const storedCustomerId = jwtId(storedMerchantToken, 'tenant_id', 'tenantId')
let merchant
let preferredMerchant = null
try {
merchant = await refreshStoredSession('cc-client-refresh-token', 'merchant')
preferredMerchant = await refreshStoredSession('cc-client-refresh-token', 'merchant')
} catch {
assert(storedCustomerId, 'Current Codex merchant session does not contain a customer id')
const ticket = await api(domestic, admin.accessToken, 'POST',
`/api/platform/v1/iam/tenants/${storedCustomerId}/impersonation`, { reason: `${marker} 当前客户海外API验收` })
merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
if (storedCustomerId) preferredMerchant = await merchantSessionForTenant(admin.accessToken, storedCustomerId)
}
assert(admin.accessToken && admin.overseasAccessToken && merchant.accessToken && merchant.overseasAccessToken, 'Refresh response did not contain both regional tokens')
const customerId = jwtId(merchant.overseasAccessToken, 'tenant_id', 'tenantId')
assert(customerId, 'Current merchant customer id is missing from the overseas token')
const warehouses = await api(domestic, merchant.accessToken, 'GET', '/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200')
let selected = null
for (const warehouse of warehouses.records || []) {
if (warehouse.status !== 'ACTIVE') continue
const candidates = await api(overseas, merchant.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${encodeURIComponent(id(warehouse.id))}`)
if (candidates.some((item) => String(item.inboundOrderNo || '').startsWith('DS-') && Number(item.availablePackages) >= 5 && item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5))) {
selected = warehouse; break
}
}
assert(selected, 'Current merchant has no active overseas warehouse with a five-carton DS inventory source')
await disableStalePdas(admin.accessToken)
assert(admin.accessToken && admin.overseasAccessToken, 'Platform refresh response did not contain both regional tokens')
const resume = process.env.OVERSEAS_RESUME_FULFILLMENT === 'true'
if (resume && (!process.env.OVERSEAS_TEST_CUSTOMER_ID || !process.env.OVERSEAS_TEST_WAREHOUSE_ID)) throw new Error('Resume requires explicit customer and warehouse from the saved run')
const scope = resume ? {
merchant: await merchantSessionForTenant(admin.accessToken, process.env.OVERSEAS_TEST_CUSTOMER_ID),
customerId: process.env.OVERSEAS_TEST_CUSTOMER_ID,
warehouse: { id: process.env.OVERSEAS_TEST_WAREHOUSE_ID, warehouseCode: process.env.OVERSEAS_TEST_WAREHOUSE_CODE || null },
} : await findRelabelScope(admin.accessToken, preferredMerchant)
const { merchant, customerId, warehouse: selected } = scope
// Do not disable accounts from another acceptance run; cleanup owns only this run's account.
pda = await createPda(admin.accessToken, id(selected.id), (created) => { pda = created })
const relabelReport = join(outputDir, 'relabel.json')
@@ -156,7 +186,10 @@ try {
PDA_OVERSEAS_ACCESS_TOKEN: pda.token, OVERSEAS_TEST_CUSTOMER_ID: customerId,
OVERSEAS_TEST_WAREHOUSE_ID: id(selected.id), OVERSEAS_ACCEPTANCE_OUTPUT: relabelReport,
OVERSEAS_ACCEPTANCE_SOURCE: relabelReport, OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport }
for (const script of ['overseas-relabel-real-acceptance.mjs', 'overseas-relabel-fulfillment-acceptance.mjs']) {
const scripts = process.env.OVERSEAS_CLOSURE_EXTRA === 'true' ? ['relabel-closure-local-acceptance.mjs'] : process.env.OVERSEAS_RESUME_FULFILLMENT === 'true'
? ['overseas-relabel-fulfillment-acceptance.mjs']
: ['overseas-relabel-real-acceptance.mjs', 'overseas-relabel-fulfillment-acceptance.mjs']
for (const script of scripts) {
const result = spawnSync(process.execPath, [resolve('scripts', script)], { cwd: resolve('.'), env: childEnv, stdio: 'inherit' })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${script} failed with exit code ${result.status}`)
@@ -17,6 +17,33 @@ BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少基础表 ll_relabel_item,请先执行海外尾程基线';
END IF;
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order') THEN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name') THEN
ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人' AFTER delivery_address;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_phone') THEN
ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话' AFTER pickup_contact_name;
END IF;
END IF;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo (
id BIGINT NOT NULL COMMENT '测量照片引用ID', transfer_order_id BIGINT NOT NULL COMMENT '仓储转运单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', file_id BIGINT NOT NULL COMMENT '文件服务文件ID',
file_name VARCHAR(255) NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '文件媒体类型快照',
size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', uploaded_by VARCHAR(128) NULL COMMENT 'PDA上传操作人',
uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_transfer_measurement_photo_file(customer_id,transfer_order_id,file_id),
KEY idx_ll_transfer_measurement_photo_order(customer_id,transfer_order_id,uploaded_at)
) ENGINE=InnoDB COMMENT='仓储转运PDA测量现场照片引用';
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_measurement_photo' AND column_name='submitted_at') THEN
ALTER TABLE ll_warehouse_transfer_measurement_photo ADD COLUMN submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间' AFTER uploaded_at;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_outbound_order')
AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='request_hash') THEN
ALTER TABLE ll_outbound_order ADD COLUMN request_hash CHAR(64) NULL COMMENT '一件代发创建请求摘要,其他出库类型为空' AFTER recipient_info;
END IF;
-- 贴标申请:FBA目的仓、换标模式、SKU组合明细及原库存快照。
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code') THEN
ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓编码(与海外操作仓分离)';
@@ -0,0 +1,25 @@
USE baiyue_lastleg;
SET @ddl = (SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'll_warehouse_transfer_pallet'
AND column_name = 'draft_idempotency_key'
),
'SELECT 1',
'ALTER TABLE ll_warehouse_transfer_pallet
MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'',
MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'',
MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''生成后的真实库位ID'',
MODIFY COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''生成后的仓储资源锁ID'',
MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''整单打托完成时间'',
ADD COLUMN draft_idempotency_key VARCHAR(255) NULL COMMENT ''草稿保存幂等键'' AFTER status,
ADD COLUMN draft_request_hash CHAR(64) NULL COMMENT ''草稿请求摘要'' AFTER draft_idempotency_key,
ADD COLUMN generate_idempotency_key VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'' AFTER draft_request_hash,
ADD COLUMN bind_idempotency_key VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'' AFTER generate_idempotency_key,
ADD COLUMN bound_at DATETIME(3) NULL COMMENT ''扫码确认绑定时间'' AFTER bind_idempotency_key'
));
PREPARE transfer_pallet_lifecycle_stmt FROM @ddl;
EXECUTE transfer_pallet_lifecycle_stmt;
DEALLOCATE PREPARE transfer_pallet_lifecycle_stmt;
+85 -4
View File
@@ -1173,7 +1173,7 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照',
lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号',
operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码',
delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式',
delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人', pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式',
estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号',
tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注',
need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号',
@@ -3963,6 +3963,8 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type'));
PREPARE ll_outbound_lx_codes_stmt FROM @ddl; EXECUTE ll_outbound_lx_codes_stmt; DEALLOCATE PREPARE ll_outbound_lx_codes_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='request_hash'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN request_hash CHAR(64) NULL COMMENT ''一件代发创建请求摘要,其他出库类型为空'' AFTER recipient_info'));
PREPARE ll_outbound_request_hash_stmt FROM @ddl; EXECUTE ll_outbound_request_hash_stmt; DEALLOCATE PREPARE ll_outbound_request_hash_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='lx_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN lx_code VARCHAR(128) NULL COMMENT ''一件代发领星入库单代码'''));
PREPARE ll_inbound_lx_code_stmt FROM @ddl; EXECUTE ll_inbound_lx_code_stmt; DEALLOCATE PREPARE ll_inbound_lx_code_stmt;
@@ -4201,14 +4203,16 @@ PREPARE return_order_lastleg_no_stmt FROM @ddl; EXECUTE return_order_lastleg_no_
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet (
id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
sequence_no INT NOT NULL COMMENT '托序号', warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码',
warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID',
sequence_no INT NOT NULL COMMENT '托序号', warehouse_pallet_id BIGINT NULL COMMENT '生成后的海外仓真实托盘ID', pallet_no VARCHAR(128) NULL COMMENT '生成后的真实托盘码',
warehouse_location_id BIGINT NULL COMMENT '生成后的真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', warehouse_resource_lock_id BIGINT NULL COMMENT '生成后的仓储资源锁ID',
carton_count INT NOT NULL COMMENT '本托实收箱数', length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm',
height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM',
status VARCHAR(32) NOT NULL COMMENT '打托状态', completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
status VARCHAR(32) NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED', draft_idempotency_key VARCHAR(255) NULL COMMENT '草稿保存幂等键', draft_request_hash CHAR(64) NULL COMMENT '草稿请求摘要', generate_idempotency_key VARCHAR(255) NULL COMMENT '新托盘生成幂等键', bind_idempotency_key VARCHAR(255) NULL COMMENT '扫码绑定幂等键', bound_at DATETIME(3) NULL COMMENT '扫码确认绑定时间', completed_at DATETIME(3) NULL COMMENT '整单打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_transfer_pallet_seq(transfer_order_id,sequence_no), UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status)
) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_pallet' AND column_name='draft_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_pallet MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'', MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'', MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''生成后的真实库位ID'', MODIFY COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''生成后的仓储资源锁ID'', MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''整单打托完成时间'', ADD COLUMN draft_idempotency_key VARCHAR(255) NULL COMMENT ''草稿保存幂等键'' AFTER status, ADD COLUMN draft_request_hash CHAR(64) NULL COMMENT ''草稿请求摘要'' AFTER draft_idempotency_key, ADD COLUMN generate_idempotency_key VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'' AFTER draft_request_hash, ADD COLUMN bind_idempotency_key VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'' AFTER generate_idempotency_key, ADD COLUMN bound_at DATETIME(3) NULL COMMENT ''扫码确认绑定时间'' AFTER bind_idempotency_key'));
PREPARE transfer_pallet_lifecycle_stmt FROM @ddl; EXECUTE transfer_pallet_lifecycle_stmt; DEALLOCATE PREPARE transfer_pallet_lifecycle_stmt;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item (
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID',
transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数',
@@ -4220,3 +4224,80 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding (
fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓', remark VARCHAR(1000) NULL COMMENT '备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark), KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id)
) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细';
-- 2026-09-16 / 039 仓储转运提货联系人独立于换标联系人。
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT ''提货联系人'' AFTER delivery_address'));
PREPARE transfer_pickup_contact_name_stmt FROM @ddl; EXECUTE transfer_pickup_contact_name_stmt; DEALLOCATE PREPARE transfer_pickup_contact_name_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_phone VARCHAR(64) NULL COMMENT ''提货联系电话'' AFTER pickup_contact_name'));
PREPARE transfer_pickup_contact_phone_stmt FROM @ddl; EXECUTE transfer_pickup_contact_phone_stmt; DEALLOCATE PREPARE transfer_pickup_contact_phone_stmt;
-- 2026-09-16 / 040 仓储转运PDA测量现场照片引用。
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo (
id BIGINT NOT NULL COMMENT '测量照片引用ID', transfer_order_id BIGINT NOT NULL COMMENT '仓储转运单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', file_id BIGINT NOT NULL COMMENT '文件服务文件ID',
file_name VARCHAR(255) NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '文件媒体类型快照',
size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', uploaded_by VARCHAR(128) NULL COMMENT 'PDA上传操作人',
uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_transfer_measurement_photo_file(customer_id,transfer_order_id,file_id),
KEY idx_ll_transfer_measurement_photo_order(customer_id,transfer_order_id,uploaded_at)
) ENGINE=InnoDB COMMENT='仓储转运PDA测量现场照片引用';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_measurement_photo' AND column_name='submitted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_measurement_photo ADD COLUMN submitted_at DATETIME(3) NULL COMMENT ''随测量打托提交时间'' AFTER uploaded_at'));
PREPARE transfer_measurement_photo_submitted_stmt FROM @ddl; EXECUTE transfer_measurement_photo_submitted_stmt; DEALLOCATE PREPARE transfer_measurement_photo_submitted_stmt;
-- 2026-09-16 / 041 仓储转运PDA实收箱数及历史完成状态回填。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='received_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN received_package_count INT NULL COMMENT ''PDA实际入库箱数'' AFTER package_count'));
PREPARE transfer_received_package_count_stmt FROM @ddl; EXECUTE transfer_received_package_count_stmt; DEALLOCATE PREPARE transfer_received_package_count_stmt;
UPDATE ll_order o
JOIN ll_warehouse_transfer_order t
ON t.customer_id=o.customer_id AND t.lastleg_order_id=o.id AND t.transfer_no=o.source_order_no
JOIN (
SELECT customer_id,transfer_order_id,SUM(carton_count) AS received_package_count
FROM ll_warehouse_transfer_pallet
WHERE status='COMPLETED'
GROUP BY customer_id,transfer_order_id
) p ON p.customer_id=t.customer_id AND p.transfer_order_id=t.id
SET o.received_package_count=p.received_package_count,
o.current_status='INBOUND_COMPLETED',
o.updated_at=CURRENT_TIMESTAMP(3)
WHERE o.order_type='WAREHOUSE_TRANSFER'
AND t.status IN ('WAIT_CUSTOMER_CONFIRMATION','WAIT_FBA_DESTINATION','WAIT_RELABEL','READY_FOR_DISPATCH','ALLOCATED','OUTBOUNDED','PARTIALLY_RECEIVED','RECEIVED');
UPDATE ll_inbound_order i
JOIN ll_warehouse_transfer_order t
ON t.customer_id=i.customer_id AND t.inbound_order_id=i.id AND t.inbound_code=t.transfer_no
JOIN (
SELECT customer_id,transfer_order_id,COUNT(*) AS pallet_count,SUM(carton_count) AS package_count,
SUM(gross_weight_kg) AS gross_weight_kg,SUM(volume_cbm) AS volume_cbm
FROM ll_warehouse_transfer_pallet
WHERE status='COMPLETED'
GROUP BY customer_id,transfer_order_id
) p ON p.customer_id=t.customer_id AND p.transfer_order_id=t.id
SET i.status='RECEIVED',i.package_count=p.package_count,i.pallet_count=p.pallet_count,
i.gross_weight_kg=p.gross_weight_kg,i.volume_cbm=p.volume_cbm,i.updated_at=CURRENT_TIMESTAMP(3)
WHERE t.status IN ('WAIT_CUSTOMER_CONFIRMATION','WAIT_FBA_DESTINATION','WAIT_RELABEL','READY_FOR_DISPATCH','ALLOCATED','OUTBOUNDED','PARTIALLY_RECEIVED','RECEIVED');
-- 2026-09-16 / 042 仓储转运直接出库的FBA/私人仓目的地快照。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的地类型:FBA或PRIVATE'' AFTER destination_warehouse_name, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓派送地址'' AFTER destination_type, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER delivery_address, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER contact_name'));
PREPARE transfer_outbound_destination_stmt FROM @ddl; EXECUTE transfer_outbound_destination_stmt; DEALLOCATE PREPARE transfer_outbound_destination_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''关联派车单ID,仓储转运直接出库时为空'', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT ''关联派车单号,仓储转运直接出库时为空'', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照,私人仓为空'''));
PREPARE transfer_outbound_nullable_stmt FROM @ddl; EXECUTE transfer_outbound_nullable_stmt; DEALLOCATE PREPARE transfer_outbound_nullable_stmt;
-- 2026-09-17 / 043 仓储转运目的地绑定及私人仓邮编快照。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照'' AFTER destination_type, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN private_destination_address VARCHAR(1000) NULL COMMENT ''私人仓完整地址'' AFTER destination_warehouse_name, ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address, ADD COLUMN private_destination_contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER private_destination_postal_code, ADD COLUMN private_destination_contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER private_destination_contact_name, ADD COLUMN destination_binding_idempotency_key VARCHAR(255) NULL COMMENT ''目的地绑定幂等键'', ADD COLUMN destination_binding_request_hash CHAR(64) NULL COMMENT ''目的地绑定请求哈希'''));
PREPARE transfer_destination_stmt FROM @ddl; EXECUTE transfer_destination_stmt; DEALLOCATE PREPARE transfer_destination_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='private_destination_postal_code'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address'));
PREPARE transfer_postal_stmt FROM @ddl; EXECUTE transfer_postal_stmt; DEALLOCATE PREPARE transfer_postal_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='delivery_address'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''));
PREPARE transfer_line_destination_stmt FROM @ddl; EXECUTE transfer_line_destination_stmt; DEALLOCATE PREPARE transfer_line_destination_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address'));
PREPARE transfer_line_postal_stmt FROM @ddl; EXECUTE transfer_line_postal_stmt; DEALLOCATE PREPARE transfer_line_postal_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'', ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''));
PREPARE transfer_pallet_destination_stmt FROM @ddl; EXECUTE transfer_pallet_destination_stmt; DEALLOCATE PREPARE transfer_pallet_destination_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address'));
PREPARE transfer_pallet_postal_stmt FROM @ddl; EXECUTE transfer_pallet_postal_stmt; DEALLOCATE PREPARE transfer_pallet_postal_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type, ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name'));
PREPARE dispatch_destination_stmt FROM @ddl; EXECUTE dispatch_destination_stmt; DEALLOCATE PREPARE dispatch_destination_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address'));
PREPARE dispatch_postal_stmt FROM @ddl; EXECUTE dispatch_postal_stmt; DEALLOCATE PREPARE dispatch_postal_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER delivery_address'));
PREPARE delivery_postal_stmt FROM @ddl; EXECUTE delivery_postal_stmt; DEALLOCATE PREPARE delivery_postal_stmt;
@@ -1,5 +1,8 @@
USE baiyue_lastleg;
-- 当前文件服务数据库;不删除或重建文件元数据。
ALTER TABLE baiyue_file.file_object MODIFY COLUMN business_type VARCHAR(64) NULL COMMENT '关联业务类型';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_id VARCHAR(128) NULL COMMENT ''箱唛总文件ID'' AFTER total_file_url'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_name VARCHAR(255) NULL COMMENT ''箱唛总文件名'' AFTER carton_total_file_id'));
@@ -0,0 +1,16 @@
-- 存量换标尾程主单状态修正:不创建入库单,不增减库存;可重复执行。
USE baiyue_lastleg;
START TRANSACTION;
UPDATE ll_order o
JOIN ll_relabel_application a ON a.relabel_no=o.relabel_order_no AND a.customer_id=o.customer_id
SET o.current_status=CASE
WHEN a.status IN ('PENDING_PROCESSING','PROCESSING','CUSTOMER_CONFIRM_PENDING') THEN 'WAIT_RELABEL'
WHEN a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' THEN 'WAIT_OUTBOUND'
WHEN a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status IN ('WAIT_OUTBOUND_CREATION','OUTBOUND_CREATED') THEN 'WAIT_OUTBOUND'
ELSE o.current_status END,
o.updated_at=CURRENT_TIMESTAMP(3)
WHERE o.order_type='RELABEL_APPLICATION' AND o.current_status='PENDING_ASSIGNMENT'
AND o.inbound_order_no IS NULL
AND (a.status IN ('PENDING_PROCESSING','PROCESSING','CUSTOMER_CONFIRM_PENDING')
OR (a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status IN ('WAIT_PRE_DISPATCH','WAIT_OUTBOUND_CREATION','OUTBOUND_CREATED')));
COMMIT;
@@ -0,0 +1,114 @@
-- LOCAL ONLY: explicit test fixtures, NOT evidence of real PDA receiving.
-- Caller must set @fixture_customer_id, @fixture_warehouse_id, @fixture_location_id,
-- @fixture_destination_id, @fixture_base and @fixture_batch in the same connection.
-- Insert-only: duplicate IDs/numbers fail; never restore or overwrite consumed stock.
SET NAMES utf8mb4;
USE baiyue_lastleg;
CREATE TEMPORARY TABLE fixture_guard (ok INT NOT NULL CHECK (ok=1));
INSERT INTO fixture_guard SELECT IF(@fixture_customer_id>0 AND @fixture_base>0
AND @fixture_batch REGEXP '^[A-Z0-9-]{1,16}$',1,0);
SET @fixture_customer_name=(SELECT tenant_name FROM baiyue_iam.iam_tenant
WHERE id=@fixture_customer_id AND deleted=0 AND status='ACTIVE');
SET @fixture_warehouse_code=(SELECT warehouse_code FROM baiyue_warehouse.wh_warehouse
WHERE id=@fixture_warehouse_id AND warehouse_type='OVERSEAS' AND status='ACTIVE' AND deleted=0);
SET @fixture_warehouse_name=(SELECT warehouse_name FROM baiyue_warehouse.wh_warehouse WHERE id=@fixture_warehouse_id);
SET @fixture_location_code=(SELECT location_code FROM baiyue_lastlag_warehouse.wh_location
WHERE id=@fixture_location_id AND warehouse_id=@fixture_warehouse_id AND location_type='VIRTUAL' AND status='ACTIVE');
SET @fixture_destination_code=(SELECT warehouse_code FROM baiyue_warehouse.wh_warehouse
WHERE id=@fixture_destination_id AND warehouse_type='FBA' AND status='ACTIVE' AND deleted=0);
INSERT INTO fixture_guard SELECT IF(@fixture_customer_name IS NOT NULL AND @fixture_warehouse_code IS NOT NULL
AND @fixture_location_code IS NOT NULL AND @fixture_destination_code IS NOT NULL,1,0);
CREATE TEMPORARY TABLE fixture_orders (seq INT PRIMARY KEY, boxes INT, order_id BIGINT, plan_id BIGINT,
balance_id BIGINT, inbound_id BIGINT, order_no VARCHAR(32), plan_no VARCHAR(32), inbound_code VARCHAR(128), sku VARCHAR(128));
INSERT INTO fixture_orders
SELECT n,6+n*6,@fixture_base+n,@fixture_base+100+n,@fixture_base+200+n,@fixture_base+300+n,
CONCAT('TL-',@fixture_batch,'-',n),CONCAT('IP-',@fixture_batch,'-',n),
CONCAT('IN-',@fixture_batch,'-',n),CONCAT('SKU-',@fixture_batch,'-',n)
FROM (SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3) numbers;
CREATE TEMPORARY TABLE fixture_pallets (seq INT, part INT, pallet_id BIGINT, position_id BIGINT,
pallet_code VARCHAR(64), boxes INT, PRIMARY KEY(seq,part));
INSERT INTO fixture_pallets SELECT f.seq,p.n,@fixture_base+400+f.seq*10+p.n,
@fixture_base+500+f.seq*10+p.n,CONCAT('PAL-',@fixture_batch,'-',f.seq,'-',p.n),f.boxes/2
FROM fixture_orders f CROSS JOIN (SELECT 1 n UNION ALL SELECT 2) p;
CREATE TEMPORARY TABLE fixture_numbers (n INT PRIMARY KEY);
INSERT INTO fixture_numbers SELECT a.n+b.n*10+1 FROM
(SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4
UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) a
CROSS JOIN (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2) b;
START TRANSACTION;
INSERT INTO baiyue_lastlag_warehouse.wh_pallet
(id,customer_id,warehouse_id,location_id,pallet_code,pallet_type,capacity_cbm,status,remark,fba_warehouse_code,version)
SELECT pallet_id,0,@fixture_warehouse_id,@fixture_location_id,pallet_code,'STANDARD',2,'OCCUPIED',
'LOCAL_TEST_FIXTURE: relabel source inventory; not real PDA acceptance',@fixture_destination_code,0 FROM fixture_pallets;
INSERT INTO ll_inbound_order
(id,customer_id,source_region,source_inbound_id,source_order_id,source_order_no,inbound_code,
destination_warehouse_id,destination_warehouse_code,destination_type,package_count,product_quantity,pallet_count,
source_event_id,source_loading_plan_id,source_loading_plan_version,handover_version,payload_hash,status)
SELECT inbound_id,@fixture_customer_id,'FIRST_LEG',CONCAT('FIXTURE-',inbound_code),CAST(order_id AS CHAR),order_no,inbound_code,
@fixture_destination_id,@fixture_destination_code,'FBA',boxes,boxes*10,2,
CONCAT('FIXTURE-',order_no),CONCAT('FIXTURE-LOAD-',order_no),1,1,SHA2(order_no,256),'RECEIVED' FROM fixture_orders;
INSERT INTO ll_order
(id,order_no,customer_id,order_type,lx_code,destination_type,inbound_code,source_region,source_inbound_id,
source_order_id,source_order_no,customer_name,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name,
fba_warehouse_code,container_no,container_status,pallet_count,package_count,product_quantity,
inbound_order_no,current_status,source_event_id,source_loading_plan_id,source_payload_hash)
SELECT order_id,order_no,@fixture_customer_id,'FIRST_LEG',CONCAT('LX-',order_no),'FBA',inbound_code,'FIRST_LEG',
CONCAT('FIXTURE-',inbound_code),CAST(order_id AS CHAR),order_no,@fixture_customer_name,
@fixture_warehouse_id,@fixture_warehouse_code,@fixture_warehouse_name,@fixture_destination_code,
CONCAT('FIXTURE-',@fixture_batch),'LOCAL_TEST_FIXTURE',2,boxes,boxes*10,plan_no,'INBOUND_COMPLETED',
CONCAT('FIXTURE-',order_no),CONCAT('FIXTURE-LOAD-',order_no),SHA2(order_no,256) FROM fixture_orders;
INSERT INTO ll_order_detail
(id,customer_id,order_id,source_inbound_detail_id,sku_code,product_name_zh,package_count,product_quantity,pallet_count,
destination_warehouse_id,destination_warehouse_code,shipping_mark)
SELECT @fixture_base+10000+f.seq*100+n.n,@fixture_customer_id,f.order_id,
CONCAT('FIXTURE-',f.inbound_code,'-',n.n),f.sku,CONCAT('贴标测试商品-',f.seq),1,10,0,
@fixture_destination_id,@fixture_destination_code,CONCAT('CM-',@fixture_batch,'-',f.seq,'-',LPAD(n.n,3,'0'))
FROM fixture_orders f JOIN fixture_numbers n ON n.n<=f.boxes;
INSERT INTO ll_inbound_plan
(id,plan_no,customer_id,source_order_id,container_no,overseas_warehouse_id,planned_arrival_date,
pallet_type,assigned_flag,creation_mode,plan_remark,status,line_count,pallet_count,total_volume_cbm,version)
SELECT plan_id,plan_no,@fixture_customer_id,order_id,CONCAT('FIXTURE-',@fixture_batch),@fixture_warehouse_id,
CURRENT_DATE,'STANDARD',1,'MANUAL','LOCAL_TEST_FIXTURE: 已入库测试准备,非真实PDA签收验收','COMPLETED',1,2,2,0 FROM fixture_orders;
INSERT INTO ll_inbound_plan_line (id,customer_id,plan_id,order_id,inbound_code,required_package_count,
planned_pallet_count,destination_type,destination_warehouse_code,fba_warehouse_code)
SELECT @fixture_base+700+seq,@fixture_customer_id,plan_id,order_id,inbound_code,boxes,2,'FBA',
@fixture_destination_code,@fixture_destination_code FROM fixture_orders;
INSERT INTO ll_inbound_plan_pallet
(id,customer_id,plan_id,pallet_no,pallet_type,destination_warehouse_code,capacity_cbm,allocated_volume_cbm,
sequence_no,warehouse_pallet_id,assignment_status,actual_volume_cbm,actual_package_count)
SELECT p.pallet_id+1000,@fixture_customer_id,f.plan_id,p.pallet_code,'STANDARD',@fixture_destination_code,
2,1,p.part,p.pallet_id,'ASSIGNED',1,p.boxes FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq;
INSERT INTO ll_inventory_balance
(id,customer_id,customer_name,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name,
destination_warehouse_code,inbound_order_id,inbound_order_no,inbound_code,sku_code,product_name_zh,
available_packages,available_quantity,version)
SELECT balance_id,@fixture_customer_id,@fixture_customer_name,@fixture_warehouse_id,@fixture_warehouse_code,
@fixture_warehouse_name,@fixture_destination_code,order_id,plan_no,inbound_code,sku,CONCAT('贴标测试商品-',seq),boxes,boxes*10,0 FROM fixture_orders;
INSERT INTO ll_inventory_position
(id,customer_id,balance_id,overseas_warehouse_id,destination_warehouse_code,sku_code,pallet_id,pallet_no,batch_no,
warehouse_location_id,warehouse_location_code,available_packages,product_quantity,status,version)
SELECT p.position_id,@fixture_customer_id,f.balance_id,@fixture_warehouse_id,@fixture_destination_code,f.sku,p.pallet_id,
p.pallet_code,CONCAT('FIXTURE-',@fixture_batch),@fixture_location_id,@fixture_location_code,p.boxes,p.boxes*10,'OCCUPIED',0
FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq;
INSERT INTO ll_inbound_plan_pallet_allocation
(id,customer_id,plan_id,line_id,pallet_id,allocated_volume_cbm,planned_package_count,
counted_package_count,picked_package_count,actual_volume_cbm,status,version)
SELECT p.pallet_id+3000,@fixture_customer_id,f.plan_id,@fixture_base+700+f.seq,p.pallet_id+1000,
1,p.boxes,p.boxes,p.boxes,1,'PICKED',0
FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq;
INSERT INTO ll_inventory_ledger
(id,customer_id,customer_name,balance_id,position_id,overseas_warehouse_id,destination_warehouse_code,
inbound_order_id,sku_code,pallet_id,pallet_no,batch_no,warehouse_location_code,business_type,business_order_no,
inbound_code,inbound_order_no,idempotency_key,status_from,status_to,before_packages,delta_packages,after_packages,remark,occurred_at)
SELECT p.position_id+2000,@fixture_customer_id,@fixture_customer_name,f.balance_id,p.position_id,@fixture_warehouse_id,
@fixture_destination_code,f.order_id,f.sku,p.pallet_id,p.pallet_code,CONCAT('FIXTURE-',@fixture_batch),@fixture_location_code,
'LOCAL_TEST_FIXTURE',f.order_no,f.inbound_code,f.plan_no,CONCAT('LOCAL_TEST_FIXTURE:',p.pallet_code),'NONE','AVAILABLE',
0,p.boxes,p.boxes,'Explicit local test fixture; not a real PDA stock-in event',NOW(3)
FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq;
INSERT INTO fixture_guard SELECT IF(COUNT(*)=6 AND SUM(p.available_packages)=54,1,0)
FROM ll_inventory_position p JOIN fixture_pallets f ON f.position_id=p.id;
COMMIT;
SELECT f.order_no,f.plan_no,f.inbound_code,f.sku,p.pallet_code,p.boxes,
CONCAT('CM-',@fixture_batch,'-',f.seq,'-001') first_carton_mark,
CONCAT('CM-',@fixture_batch,'-',f.seq,'-',LPAD(f.boxes,3,'0')) last_carton_mark
FROM fixture_orders f JOIN fixture_pallets p ON p.seq=f.seq ORDER BY f.seq,p.part;
@@ -0,0 +1,326 @@
-- 仓储转运审核、PDA测量打托、换标、目的地绑定、智能排车与派送字段。
-- 目标数据库:baiyue_lastleg
-- 可重复执行:字段和索引独立检查,补充缺失结构并修正必要的可空性;不更新业务数据。
USE `baiyue_lastleg`;
DROP PROCEDURE IF EXISTS `ensure_transfer_column`;
DROP PROCEDURE IF EXISTS `ensure_transfer_nullable_column`;
DROP PROCEDURE IF EXISTS `ensure_transfer_index`;
DROP PROCEDURE IF EXISTS `ensure_transfer_pallet_unique_index`;
DELIMITER $$
CREATE PROCEDURE `ensure_transfer_column`(
IN p_table_name VARCHAR(64),
IN p_column_name VARCHAR(64),
IN p_column_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND column_name = p_column_name
) THEN
SET @ddl = CONCAT(
'ALTER TABLE `', REPLACE(p_table_name, '`', '``'),
'` ADD COLUMN `', REPLACE(p_column_name, '`', '``'),
'` ', p_column_definition
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
CREATE PROCEDURE `ensure_transfer_nullable_column`(
IN p_table_name VARCHAR(64),
IN p_column_name VARCHAR(64),
IN p_column_definition TEXT
)
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND column_name = p_column_name
AND is_nullable = 'NO'
) THEN
SET @ddl = CONCAT(
'ALTER TABLE `', REPLACE(p_table_name, '`', '``'),
'` MODIFY COLUMN `', REPLACE(p_column_name, '`', '``'),
'` ', p_column_definition
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
CREATE PROCEDURE `ensure_transfer_index`(
IN p_table_name VARCHAR(64),
IN p_index_name VARCHAR(64),
IN p_index_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND index_name = p_index_name
) THEN
SET @ddl = CONCAT(
'ALTER TABLE `', REPLACE(p_table_name, '`', '``'),
'` ADD ', p_index_definition
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
CREATE PROCEDURE `ensure_transfer_pallet_unique_index`()
BEGIN
DECLARE v_duplicate_groups BIGINT DEFAULT 0;
IF NOT EXISTS (
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'll_inbound_plan_pallet'
AND index_name = 'uk_ll_inbound_pallet_no'
) THEN
SELECT COUNT(*)
INTO v_duplicate_groups
FROM (
SELECT 1
FROM `ll_inbound_plan_pallet`
GROUP BY `customer_id`, `pallet_no`
HAVING COUNT(*) > 1
) duplicate_groups;
IF v_duplicate_groups = 0 THEN
ALTER TABLE `ll_inbound_plan_pallet`
ADD UNIQUE KEY `uk_ll_inbound_pallet_no` (`customer_id`,`pallet_no`);
END IF;
END IF;
END$$
DELIMITER ;
-- 相关业务表统一补齐 SaaS 租户字段。默认值只用于兼容历史数据和单租户海外环境。
CALL ensure_transfer_column('ll_delivery_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE''');
CALL ensure_transfer_column('ll_dispatch_item', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE''');
CALL ensure_transfer_column('ll_inbound_plan_line', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE''');
CALL ensure_transfer_column('ll_inbound_plan_pallet', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE''');
CALL ensure_transfer_column('ll_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE''');
CALL ensure_transfer_column('ll_relabel_application', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE''');
CALL ensure_transfer_column('ll_relabel_item', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE''');
CALL ensure_transfer_column('ll_relabel_status_history', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE''');
CALL ensure_transfer_column('ll_warehouse_transfer_inventory_allocation', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE''');
CALL ensure_transfer_column('ll_warehouse_transfer_item', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE''');
CALL ensure_transfer_column('ll_warehouse_transfer_receipt', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE''');
CALL ensure_transfer_column('ll_warehouse_transfer_source_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE''');
-- 退货换标允许只更换箱唛,不强制生成新 SKU。
CALL ensure_transfer_nullable_column('ll_relabel_item', 'new_sku_code', 'VARCHAR(128) NULL COMMENT ''换标后SKU编码''');
-- 主单审核、提货、发货、换标和客户确认事实。
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'pickup_contact_name', 'VARCHAR(128) NULL COMMENT ''提货联系人''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'pickup_contact_phone', 'VARCHAR(64) NULL COMMENT ''提货联系电话''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'estimated_ship_at', 'DATETIME(3) NULL COMMENT ''预计发货时间''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'review_decision', 'VARCHAR(32) NULL COMMENT ''审核决定''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'review_remark', 'VARCHAR(1000) NULL COMMENT ''审核备注或驳回原因''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'reviewed_by', 'VARCHAR(128) NULL COMMENT ''审核人ID''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'reviewed_at', 'DATETIME(3) NULL COMMENT ''审核时间''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'label_deadline', 'DATETIME(3) NULL COMMENT ''换标截止时间''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_contact_name', 'VARCHAR(128) NULL COMMENT ''换标联系人''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_contact_phone', 'VARCHAR(64) NULL COMMENT ''换标联系电话''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_charge_amount', 'DECIMAL(20,2) NULL COMMENT ''换标费用CNY''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_delivery_method', 'VARCHAR(32) NULL COMMENT ''换标后派送方式''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_completed_at', 'DATETIME(3) NULL COMMENT ''关联贴标完成时间''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'palletize_idempotency_key', 'VARCHAR(255) NULL COMMENT ''PDA打托幂等键''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'palletize_request_hash', 'CHAR(64) NULL COMMENT ''PDA打托请求哈希''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'confirmation_idempotency_key', 'VARCHAR(255) NULL COMMENT ''客户测量确认幂等键''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'confirmation_request_hash', 'CHAR(64) NULL COMMENT ''客户测量确认请求哈希''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'customer_confirmed_at', 'DATETIME(3) NULL COMMENT ''客户确认测量时间''');
-- 仓储转运主单目的地。
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_type', 'VARCHAR(16) NULL COMMENT ''目的类型:FBA或PRIVATE''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_warehouse_id', 'VARCHAR(128) NULL COMMENT ''目的仓ID快照''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_warehouse_code', 'VARCHAR(128) NULL COMMENT ''目的仓编码快照''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_warehouse_name', 'VARCHAR(255) NULL COMMENT ''目的仓名称快照''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_address', 'VARCHAR(1000) NULL COMMENT ''私人仓完整地址''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_binding_idempotency_key', 'VARCHAR(255) NULL COMMENT ''目的地绑定幂等键''');
CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_binding_request_hash', 'CHAR(64) NULL COMMENT ''目的地绑定请求哈希''');
-- 转仓尾程主单记录PDA实收箱数。
CALL ensure_transfer_column('ll_order', 'received_package_count', 'INT NULL COMMENT ''PDA实际入库箱数''');
-- PDA逐托草稿、生成、扫码绑定和整单完成事实。
CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_pallet` (
`id` BIGINT NOT NULL COMMENT '打托事实ID',
`transfer_order_id` BIGINT NOT NULL COMMENT '转运单ID',
`customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID',
`sequence_no` INT NOT NULL COMMENT '托序号',
`warehouse_pallet_id` BIGINT NULL COMMENT '生成后的海外仓真实托盘ID',
`pallet_no` VARCHAR(128) NULL COMMENT '生成后的真实托盘码',
`warehouse_location_id` BIGINT NULL COMMENT '生成后的真实库位ID',
`warehouse_location_code` VARCHAR(128) NULL COMMENT '真实库位码',
`warehouse_resource_lock_id` BIGINT NULL COMMENT '生成后的仓储资源锁ID',
`carton_count` INT NOT NULL COMMENT '本托实收箱数',
`length_cm` DECIMAL(12,2) NOT NULL COMMENT '实测长cm',
`width_cm` DECIMAL(12,2) NOT NULL COMMENT '实测宽cm',
`height_cm` DECIMAL(12,2) NOT NULL COMMENT '实测高cm',
`gross_weight_kg` DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg',
`volume_cbm` DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM',
`status` VARCHAR(32) NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED',
`draft_idempotency_key` VARCHAR(255) NULL COMMENT '草稿保存幂等键',
`draft_request_hash` CHAR(64) NULL COMMENT '草稿请求摘要',
`generate_idempotency_key` VARCHAR(255) NULL COMMENT '新托盘生成幂等键',
`bind_idempotency_key` VARCHAR(255) NULL COMMENT '扫码绑定幂等键',
`bound_at` DATETIME(3) NULL COMMENT '扫码确认绑定时间',
`completed_at` DATETIME(3) NULL COMMENT '整单打托完成时间',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ll_transfer_pallet_seq` (`transfer_order_id`,`sequence_no`),
UNIQUE KEY `uk_ll_transfer_pallet_physical` (`customer_id`,`warehouse_pallet_id`),
KEY `idx_ll_transfer_pallet_order` (`customer_id`,`transfer_order_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运PDA实测打托事实';
CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_pallet_item` (
`id` BIGINT NOT NULL COMMENT '主键ID',
`transfer_order_id` BIGINT NOT NULL COMMENT '转运单ID',
`transfer_pallet_id` BIGINT NOT NULL COMMENT '转运托盘事实ID',
`transfer_item_id` BIGINT NOT NULL COMMENT '转运申报明细ID',
`customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID',
`package_count` INT NOT NULL COMMENT '本托该SKU实收箱数',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ll_transfer_pallet_item` (`transfer_pallet_id`,`transfer_item_id`),
KEY `idx_ll_transfer_pallet_item_order` (`customer_id`,`transfer_order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运每托SKU箱数回填';
CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_relabel_binding` (
`id` BIGINT NOT NULL COMMENT '主键ID',
`transfer_order_id` BIGINT NOT NULL COMMENT '转运单ID',
`customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID',
`operation_type` VARCHAR(32) NOT NULL COMMENT '换标类型',
`customer_carton_mark` VARCHAR(255) NOT NULL COMMENT '客户原箱唛',
`original_sku` VARCHAR(128) NOT NULL COMMENT '旧SKU',
`new_sku` VARCHAR(128) NULL COMMENT '新SKU',
`quantity` INT NOT NULL COMMENT '换标件数',
`fba_carton_mark` VARCHAR(255) NULL COMMENT '新FBA箱唛',
`destination_warehouse_code` VARCHAR(128) NULL COMMENT 'FBA目的仓',
`remark` VARCHAR(1000) NULL COMMENT '备注',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ll_transfer_relabel_mark` (`transfer_order_id`,`customer_carton_mark`),
KEY `idx_ll_transfer_relabel_order` (`customer_id`,`transfer_order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运换标预绑定明细';
CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'warehouse_pallet_id', 'BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID''');
CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'pallet_no', 'VARCHAR(128) NULL COMMENT ''生成后的真实托盘码''');
CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'warehouse_location_id', 'BIGINT NULL COMMENT ''生成后的真实库位ID''');
CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'warehouse_resource_lock_id', 'BIGINT NULL COMMENT ''生成后的仓储资源锁ID''');
CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'completed_at', 'DATETIME(3) NULL COMMENT ''整单打托完成时间''');
CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'draft_idempotency_key', 'VARCHAR(255) NULL COMMENT ''草稿保存幂等键''');
CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'draft_request_hash', 'CHAR(64) NULL COMMENT ''草稿请求摘要''');
CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'generate_idempotency_key', 'VARCHAR(255) NULL COMMENT ''新托盘生成幂等键''');
CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'bind_idempotency_key', 'VARCHAR(255) NULL COMMENT ''扫码绑定幂等键''');
CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'bound_at', 'DATETIME(3) NULL COMMENT ''扫码确认绑定时间''');
CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_measurement_photo` (
`id` BIGINT NOT NULL COMMENT '测量照片引用ID',
`transfer_order_id` BIGINT NOT NULL COMMENT '仓储转运单ID',
`customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID',
`file_id` BIGINT NOT NULL COMMENT '文件服务文件ID',
`file_name` VARCHAR(255) NULL COMMENT '文件名快照',
`content_type` VARCHAR(128) NULL COMMENT '文件媒体类型快照',
`size_bytes` BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照',
`uploaded_by` VARCHAR(128) NULL COMMENT 'PDA上传操作人',
`uploaded_at` DATETIME(3) NOT NULL COMMENT '上传时间',
`submitted_at` DATETIME(3) NULL COMMENT '随测量打托提交时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ll_transfer_measurement_photo_file` (`customer_id`,`transfer_order_id`,`file_id`),
KEY `idx_ll_transfer_measurement_photo_order` (`customer_id`,`transfer_order_id`,`uploaded_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运PDA测量现场照片引用';
CALL ensure_transfer_column('ll_warehouse_transfer_measurement_photo', 'submitted_at', 'DATETIME(3) NULL COMMENT ''随测量打托提交时间''');
CALL ensure_transfer_index('ll_warehouse_transfer_measurement_photo', 'uk_ll_transfer_measurement_photo_file',
'UNIQUE KEY `uk_ll_transfer_measurement_photo_file` (`customer_id`,`transfer_order_id`,`file_id`)');
CALL ensure_transfer_index('ll_warehouse_transfer_measurement_photo', 'idx_ll_transfer_measurement_photo_order',
'KEY `idx_ll_transfer_measurement_photo_order` (`customer_id`,`transfer_order_id`,`uploaded_at`)');
-- 入库计划行和托盘保存私人仓目的地快照。
CALL ensure_transfer_column('ll_inbound_plan_line', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照''');
CALL ensure_transfer_column('ll_inbound_plan_line', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编快照''');
CALL ensure_transfer_column('ll_inbound_plan_line', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人快照''');
CALL ensure_transfer_column('ll_inbound_plan_line', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''');
CALL ensure_transfer_column('ll_inbound_plan_pallet', 'destination_type', 'VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE''');
CALL ensure_transfer_column('ll_inbound_plan_pallet', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照''');
CALL ensure_transfer_column('ll_inbound_plan_pallet', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编快照''');
CALL ensure_transfer_column('ll_inbound_plan_pallet', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人快照''');
CALL ensure_transfer_column('ll_inbound_plan_pallet', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''');
CALL ensure_transfer_index('ll_inbound_plan_line', 'idx_ll_inbound_line_tenant_plan',
'KEY `idx_ll_inbound_line_tenant_plan` (`customer_id`,`plan_id`)');
CALL ensure_transfer_index('ll_inbound_plan_pallet', 'idx_ll_inbound_pallet_tenant_plan',
'KEY `idx_ll_inbound_pallet_tenant_plan` (`customer_id`,`plan_id`)');
CALL ensure_transfer_pallet_unique_index();
-- 智能排车候选和排车明细快照。
CALL ensure_transfer_column('ll_dispatch_item', 'destination_type', 'VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE''');
CALL ensure_transfer_column('ll_dispatch_item', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照''');
CALL ensure_transfer_column('ll_dispatch_item', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编快照''');
CALL ensure_transfer_column('ll_dispatch_item', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人快照''');
CALL ensure_transfer_column('ll_dispatch_item', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''');
-- 派送单允许私人仓目的地,并保留地址、邮编及联系人快照。
CALL ensure_transfer_column('ll_delivery_order', 'destination_type', 'VARCHAR(16) NULL COMMENT ''目的地类型:FBA或PRIVATE''');
CALL ensure_transfer_column('ll_delivery_order', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓派送地址''');
CALL ensure_transfer_column('ll_delivery_order', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编''');
CALL ensure_transfer_column('ll_delivery_order', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人''');
CALL ensure_transfer_column('ll_delivery_order', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话''');
-- 仓储转运直接出库历史兼容:这三列允许为空。
CALL ensure_transfer_nullable_column('ll_delivery_order', 'dispatch_order_id', 'BIGINT NULL COMMENT ''关联派车单ID,仓储转运直接出库时为空''');
CALL ensure_transfer_nullable_column('ll_delivery_order', 'dispatch_no', 'VARCHAR(64) NULL COMMENT ''关联派车单号,仓储转运直接出库时为空''');
CALL ensure_transfer_nullable_column('ll_delivery_order', 'destination_warehouse_code', 'VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照,私人仓为空''');
DROP PROCEDURE IF EXISTS `ensure_transfer_column`;
DROP PROCEDURE IF EXISTS `ensure_transfer_nullable_column`;
DROP PROCEDURE IF EXISTS `ensure_transfer_index`;
DROP PROCEDURE IF EXISTS `ensure_transfer_pallet_unique_index`;
SELECT DATABASE() AS target_database,
IF(
EXISTS (
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'll_inbound_plan_pallet'
AND index_name = 'uk_ll_inbound_pallet_no'
),
'WAREHOUSE_TRANSFER_LATEST_SCHEMA_READY',
'WAREHOUSE_TRANSFER_SCHEMA_APPLIED_WITH_INDEX_BLOCKER'
) AS migration_result,
IF(
EXISTS (
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'll_inbound_plan_pallet'
AND index_name = 'uk_ll_inbound_pallet_no'
),
'READY',
'BLOCKED_BY_DUPLICATE_CUSTOMER_PALLET_NO'
) AS inbound_pallet_unique_index;
@@ -22,8 +22,8 @@ function assert(value, message) { if (!value) throw new Error(message) }
async function api(base, token, method, path, body) {
const response = await fetch(`${base}${path}`, { method,
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
body: body === undefined ? undefined : JSON.stringify(body) })
...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) },
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body) })
const text = await response.text(); let value
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
if (!response.ok || String(value?.code) !== '200') {
@@ -48,12 +48,32 @@ const platformToken = directPlatformToken || platformLogin.overseasAccessToken
const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken
const pdaToken = directPdaToken || pdaLogin.overseasAccessToken
await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, {
vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000',
carrierName: '尾程验收车队', route: `${marker}-ROUTE`,
estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString(),
})
const transfer = await api(overseas, merchantToken, 'POST',
let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`)
let alreadyDeparted = task.status === 'DEPARTED'
|| (task.status === 'COMPLETED' && String(task.departBlockReason || '').includes('已发车'))
if (!alreadyDeparted && task.status === 'PENDING') {
await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, {
vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000',
carrierName: '尾程验收车队', route: `${marker}-ROUTE`,
estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString(),
})
}
const existingPage = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/delivery-orders?customerId=${customerId}&page=1&size=20&dispatchNo=${encodeURIComponent(dispatchNo)}`)
const existingDeliveries = (existingPage.records || []).filter((value) => value.dispatchNo === dispatchNo)
const existingOutboundNos = new Set(existingDeliveries.map((value) => value.outboundNo).filter(Boolean))
assert(existingOutboundNos.size <= 1, 'Dispatch is linked to multiple outbound orders')
const transfer = existingDeliveries.length > 0 ? {
outboundNo: [...existingOutboundNos][0],
deliveries: existingDeliveries.map((value) => ({
deliveryNo: value.deliveryNo, deliveryStatus: value.status,
destinationWarehouseId: value.destinationWarehouseId,
destinationWarehouseCode: value.destinationWarehouseCode,
destinationWarehouseName: value.destinationWarehouseName,
palletCount: value.palletCount, packageCount: value.packageCount,
})),
replayed: true,
} : await api(overseas, merchantToken, 'POST',
`/api/merchant/v1/last-leg/delivery-orders?dispatchNo=${encodeURIComponent(dispatchNo)}`, {
idempotencyKey: key('TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(),
deliveryMethod: 'TRUCK_PALLET', waybillNo: `${marker}-WAYBILL`, carrierName: '尾程验收车队',
@@ -62,29 +82,53 @@ const transfer = await api(overseas, merchantToken, 'POST',
appointmentAccount: 'SELF', remark: marker, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费',
direction: 'RECEIVABLE', currency: 'CNY', amount: 10, remark: marker }],
})
assert(transfer.outboundNo && transfer.deliveryNo, 'Transfer did not create outbound and delivery')
const transferDeliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : []
assert(transfer.outboundNo && transferDeliveries.length > 0,
'Transfer did not create outbound and delivery')
assert(transferDeliveries.length === 1,
`Expected one destination delivery, got ${transferDeliveries.length}`)
const deliveryNo = transferDeliveries[0].deliveryNo
let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`)
assert(Array.isArray(task.items) && task.items.length === 2, 'Expected two PDA pallet items')
for (const [itemIndex, item] of task.items.entries()) {
let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages)
for (const [positionIndex, position] of item.positions.entries()) {
if (remaining <= 0) break
const packages = Math.min(remaining, Number(position.availablePackages))
if (packages <= 0) continue
await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, {
positionId: id(position.id), packages, productQuantity: packages,
idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker,
})
remaining -= packages
if (!alreadyDeparted) {
if (task.items.every(item => Number(item.pickedPackages) === 0)) {
await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, {
vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000',
carrierName: '尾程验收车队', fbaAppointmentNo: `UAT-${marker.slice(-8)}`,
fbaAppointmentStatus: 'CONFIRMED', vehicleAppointmentStatus: 'CONFIRMED',
estimatedArrivalAt: new Date(Date.now() + 172800000).toISOString().slice(0, 19),
})
}
assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`)
for (const [itemIndex, item] of task.items.entries()) {
let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages)
for (const [positionIndex, position] of item.positions.entries()) {
if (remaining <= 0) break
const packages = Math.min(remaining, Number(position.availablePackages))
if (packages <= 0) continue
await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, {
positionId: id(position.id), packages, productQuantity: packages,
idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker,
})
remaining -= packages
}
assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`)
}
task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`)
alreadyDeparted = task.status === 'DEPARTED'
|| (task.status === 'COMPLETED' && String(task.departBlockReason || '').includes('已发车'))
}
const completed = await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') })
assert(completed.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART')
const departed = await api(overseas, pdaToken, 'POST',
if (!alreadyDeparted && task.status !== 'COMPLETED') {
const pickingCompleted = await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') })
assert(pickingCompleted.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART')
task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`)
}
assert(alreadyDeparted || task.status === 'COMPLETED', 'Dispatch did not finish picking')
const departed = alreadyDeparted ? {
status: 'DEPARTED',
shippedPackages: task.items.reduce((total, item) => total + Number(item.pickedPackages), 0),
} : await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`, { idempotencyKey: key('DEPART'), remark: marker })
assert(departed.status === 'DEPARTED', 'Dispatch did not reach DEPARTED')
@@ -92,24 +136,47 @@ const outbound = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}?page=1&size=20`)
assert(outbound.outbound.status === 'OUTBOUNDED', 'Outbound did not reach OUTBOUNDED')
let delivery = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`)
const delivering = await api(overseas, platformToken, 'PUT',
`/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/status?customerId=${customerId}`, {
status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker,
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
if (delivery.order.status === 'WAIT_DELIVERY') {
const delivering = await api(overseas, platformToken, 'PUT',
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/status?customerId=${customerId}`, {
status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker,
})
assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING')
delivery = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
}
if (delivery.order.status !== 'DELIVERED') {
assert(delivery.order.status === 'DELIVERING', `Unexpected delivery status ${delivery.order.status}`)
const documentsPath = `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents?customerId=${customerId}`
const documents = await api(overseas, platformToken, 'GET', documentsPath)
const form = new FormData()
form.append('file', new Blob([`${marker} receipt acceptance for ${deliveryNo}`], { type: 'text/plain' }),
`${marker}-${deliveryNo}-receipt.txt`)
const receipt = await api(overseas, platformToken, 'POST',
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents/RECEIPT/files?customerId=${customerId}`, form)
const existingBindings = (documents.categories || []).map((category) => ({
category: category.category,
fileIds: (category.files || []).map((file) => id(file.fileId)),
})).filter((binding) => binding.category !== 'RECEIPT' && binding.fileIds.length > 0)
await api(overseas, platformToken, 'PUT', documentsPath, {
expectedVersion: Number(documents.version), idempotencyKey: key('SAVE-RECEIPT'),
bolNo: documents.bolNo || null,
documents: [...existingBindings, { category: 'RECEIPT', fileIds: [id(receipt.fileId)] }],
})
assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING')
delivery = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`)
const delivered = await api(overseas, platformToken, 'POST',
`/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/complete?customerId=${customerId}`, {
status: 'DELIVERED', expectedVersion: Number(delivery.order.version), remark: marker,
})
assert(delivered.deliveryStatus === 'DELIVERED', 'Delivery did not reach DELIVERED')
delivery = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
}
assert(delivery.order.status === 'DELIVERED', 'Receipt did not automatically complete delivery')
const visits = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/customer-visits?page=1&size=200&customerId=${customerId}&keyword=${encodeURIComponent(deliveryNo)}`)
const visit = (visits.records || []).find((value) => value.deliveryNo === deliveryNo)
assert(visit?.status === 'PENDING', 'Delivery completion did not create a pending customer visit')
const report = { marker, scope: 'OVERSEAS_LASTLEG_ONLY', sourceReport, relabelNo: scenario.relabelNo,
dispatchNo, dispatchStatus: departed.status, outboundNo: transfer.outboundNo,
outboundStatus: outbound.outbound.status, deliveryNo: transfer.deliveryNo,
deliveryStatus: delivered.deliveryStatus, visitId: id(delivered.visitId), shippedPackages: departed.shippedPackages,
outboundStatus: outbound.outbound.status, deliveryNo,
deliveryStatus: delivery.order.status, visitId: id(visit.id), shippedPackages: departed.shippedPackages,
passed: true, finishedAt: new Date().toISOString() }
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
console.log(JSON.stringify(report, null, 2))
+63 -30
View File
@@ -13,6 +13,8 @@ const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN
const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN
const output = process.env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json'
const marker = `TAIL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
const chargeAmount = Number(process.env.OVERSEAS_RELABEL_CHARGE_AMOUNT ?? '0.01')
if (!Number.isFinite(chargeAmount) || chargeAmount < 0) throw new Error('Invalid charge amount')
for (const [name, value] of Object.entries({ customerId, warehouseId })) {
if (!value) throw new Error(`Missing environment variable: ${name}`)
@@ -21,7 +23,7 @@ if (!directPlatformToken && (!platformUser || !platformPassword)) throw new Erro
if (!directMerchantToken && (!platformUser || !platformPassword)) throw new Error('Missing merchant token or platform credentials')
if (!directPdaToken && (!pdaUser || !pdaPassword)) throw new Error('Missing PDA credentials or token')
const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', scenarios: [], passed: false }
const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', chargeAmount, photoEvidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK', scenarios: [], passed: false }
function key(value) { return `${marker}-${value}`.slice(0, 200) }
function id(value) { return value == null ? '' : String(value) }
async function api(base, token, method, path, body, headers = {}) {
@@ -70,7 +72,7 @@ async function resolvePallet(palletCode) {
return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
}
async function createAndApprove(name, cartonCount) {
async function createAndConfirm(name, cartonCount, operationType = 'CARTON_AND_SKU', prepare = null) {
const suffix = `${name}-${Date.now()}`
const items = Array.from({ length: cartonCount }, (_, index) => ({
sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId),
@@ -78,49 +80,68 @@ async function createAndApprove(name, cartonCount) {
productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1,
shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128),
labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`,
fbaCartonMark: `FBA-${suffix}-${index + 1}`, destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: source.skuCode,
fbaCartonMark: operationType === 'CARTON_AND_SKU' ? `FBA-${suffix}-${index + 1}` : null,
destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
operationType, skuItems: [{ skuCode: source.skuCode,
newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }],
}))
const created = await api(overseas, platformToken, 'POST',
`/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, {
customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId,
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
contactName: '尾程验收', contactPhone: '13800000006', chargeAmount: 0.01,
remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), deliveryMethod: 'TRUCK',
contactName: '尾程验收', contactPhone: '13800000006', ...(chargeAmount > 0 ? { chargeAmount } : {}),
remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`),
deliveryMethod: operationType === 'SKU_LABEL' ? null : 'TRUCK',
printShippingLabel: false, items,
})
const relabelNo = created.application.relabelNo
await poll(() => api(overseas, platformToken, 'GET',
if (chargeAmount > 0) await poll(() => api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/relabel-applications/${relabelNo}`),
(detail) => detail.application.walletStatus === 'FROZEN')
let detail = await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) })
for (const [index, item] of detail.items.entries()) {
if (operationType === 'CARTON_AND_SKU') {
detail = await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, {
cartonMark: item.cartonMark, expectedVersion: Number(detail.version),
idempotencyKey: key(`${name}-CARTON-${index}`),
})
const stageItem = detail.items.find((row) => id(row.itemId) === id(item.itemId))
assert(Number(stageItem?.cartonProcessedPackages) === 1
&& Number(stageItem?.skuProcessedPackages) === 0,
`${relabelNo} carton stage incorrectly locked or completed SKU stage`)
}
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, {
itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark,
itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null,
skuVerifications: (item.skuItems || []).map((sku) => ({
skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity),
})),
expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`),
})
}
const sourceBefore = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
const scenario = { name, operationType, relabelNo, items: detail.items, sourceBefore }
if (prepare) await prepare(scenario)
const form = new FormData()
form.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), `${marker}.png`)
// Valid synthetic PNG for HTTP validation; not a photograph of real warehouse work.
form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), `${marker}.png`)
const photo = await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, form)
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, {
photoFileIds: [id(photo.fileId)], expectedVersion: Number(detail.version),
idempotencyKey: key(`${name}-COMPLETE`), remark: marker,
})
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/review`, {
decision: 'APPROVE', expectedVersion: Number(detail.version),
idempotencyKey: key(`${name}-APPROVE`), remark: marker,
})
assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`)
const confirmed = await api(overseas, merchantToken, 'POST',
`/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, {
idempotencyKey: key(`${name}-CONFIRM`), remark: marker,
})
assert(confirmed.application.status === 'READY_FOR_OUTBOUND', `${relabelNo} did not reach READY_FOR_OUTBOUND`)
return { name, relabelNo, items: confirmed.items, sourceBefore: Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) }
const expectedStatus = operationType === 'SKU_LABEL' ? 'COMPLETED' : 'READY_FOR_OUTBOUND'
assert(confirmed.application.status === expectedStatus, `${relabelNo} did not reach ${expectedStatus}`)
assert(confirmed.items.every((item) => item.destinationFbaWarehouseCode === source.destinationFbaWarehouseCode),
`${relabelNo} did not preserve the destination warehouse`)
return { ...scenario, items: confirmed.items }
}
async function printPallet(relabelNo, suffix) {
@@ -141,26 +162,38 @@ async function moveCarton(scenario, item, target, index) {
return result
}
const fresh = await createAndApprove('NEW-PALLET', 1)
const sharedTarget = await printPallet(fresh.relabelNo, 'SHARED')
const printReplay = await printPallet(fresh.relabelNo, 'SHARED')
assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent')
await moveCarton(fresh, fresh.items[0], sharedTarget, 0)
fresh.targets = [sharedTarget]
const skuOnly = await createAndConfirm('SKU-ONLY', 1, 'SKU_LABEL')
skuOnly.sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
assert(skuOnly.sourceAfter === skuOnly.sourceBefore, 'SKU-only relabel must not split or regroup pallets')
skuOnly.targetPallets = []
skuOnly.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode
delete skuOnly.items
let sharedTarget
const fresh = await createAndConfirm('NEW-PALLET', 1, 'CARTON_AND_SKU', async scenario => {
sharedTarget = await printPallet(scenario.relabelNo, 'SHARED')
const printReplay = await printPallet(scenario.relabelNo, 'SHARED')
assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent')
await moveCarton(scenario, scenario.items[0], sharedTarget, 0)
scenario.targets = [sharedTarget]
})
const merged = await createAndApprove('MERGE-EXISTING', 1)
const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
await moveCarton(merged, merged.items[0], sharedTarget, 0)
const merged = await createAndConfirm('MERGE-EXISTING', 1, 'CARTON_AND_SKU', async scenario => {
await moveCarton(scenario, scenario.items[0], sharedTarget, 0)
scenario.targets = [sharedTarget]
})
const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase')
merged.targets = [sharedTarget]
const split = await createAndApprove('SPLIT-TWO-TARGETS', 2)
const splitA = await printPallet(split.relabelNo, 'SPLIT-A')
const splitB = await printPallet(split.relabelNo, 'SPLIT-B')
await moveCarton(split, split.items[0], splitA, 0)
await moveCarton(split, split.items[1], splitB, 1)
split.targets = [splitA, splitB]
const split = await createAndConfirm('SPLIT-TWO-TARGETS', 2, 'CARTON_AND_SKU', async scenario => {
const splitA = await printPallet(scenario.relabelNo, 'SPLIT-A')
const splitB = await printPallet(scenario.relabelNo, 'SPLIT-B')
await moveCarton(scenario, scenario.items[0], splitA, 0)
await moveCarton(scenario, scenario.items[1], splitB, 1)
scenario.targets = [splitA, splitB]
})
for (const scenario of [fresh, merged, split]) {
const sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
@@ -191,7 +224,7 @@ const ledgerRows = ledger.records.filter((row) => String(row.remark || '').inclu
assert(ledgerRows.some((row) => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger')
assert(ledgerRows.some((row) => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger')
assert(ledgerRows.every((row) => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason')
report.scenarios = [fresh, merged, split]
report.scenarios = [skuOnly, fresh, merged, split]
report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map((row) => Number(row.deltaPackages)) }
report.passed = true
report.finishedAt = new Date().toISOString()
@@ -0,0 +1,116 @@
import { mkdir, writeFile, readFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
const base = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
if (!['localhost', '127.0.0.1'].includes(new URL(base).hostname)) throw new Error('Local acceptance only')
const merchant = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN, pda = process.env.PDA_OVERSEAS_ACCESS_TOKEN, admin = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN
const warehouse = process.env.OVERSEAS_TEST_WAREHOUSE_ID
if (!merchant || !pda || !admin || !warehouse) throw new Error('Explicit session and warehouse required')
const marker = `CLOSE-${Date.now()}`
const report = { marker, passed: false, scope: 'REAL_HTTP_SYNTHETIC_WAREHOUSE_EVIDENCE_NO_FINANCE', scenarios: [] }
const key = () => `${marker}-${crypto.randomUUID()}`
const root = '/api/merchant/v1/last-leg/relabel-applications'
const work = '/api/pda/v1/last-leg/relabel-tasks'
const pallets = '/api/pda/v1/last-leg/pallet-operations'
const assert = (condition, message) => { if (!condition) throw new Error(message) }
async function raw(token, method, path, body) {
const response = await fetch(base + path, { method, headers: { Authorization: `Bearer ${token}`, ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}) }, body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined })
const result = await response.json()
return { ...result, http: response.status }
}
async function api(token, method, path, body) {
const result = await raw(token, method, path, body)
assert(result.http === 200 && String(result.code) === '200', `${path}: ${result.code} ${result.message}`)
return result.data
}
async function reject(token, path, body, message) {
const result = await raw(token, 'POST', path, body)
assert(String(result.code) !== '200' && message.test(result.message || ''), `Expected rejection at ${path}, got ${result.code} ${result.message}`)
}
const resolve = code => api(pda, 'POST', `${pallets}/pallets/resolve`, { code })
async function move(no, mark, from, to) {
const source = await resolve(from), target = await resolve(to)
const request = { relabelNo: no, source: { palletCode: from, expectedVersion: source.palletVersion }, target: { palletCode: to, expectedVersion: target.palletVersion }, cartonMarks: [mark], reason: marker, idempotencyKey: key() }
const result = await api(pda, 'POST', `${pallets}/relabel-disassemble`, request)
const replay = await api(pda, 'POST', `${pallets}/relabel-disassemble`, request)
assert(replay.replayed === true, 'Move replay was not idempotent')
const after = await resolve(to)
assert(Number(after.totalPackages) === Number(target.totalPackages) + 1 && after.status === 'OCCUPIED', 'Target stock/status did not update exactly once')
assert(Number((await resolve(from)).totalPackages) === Number(source.totalPackages) - 1, 'Source stock was not reduced once')
return result
}
try {
for (const deliveryMethod of ['TRUCK_PICKUP', 'EXPRESS', 'EXPRESS_PICKUP']) {
const candidates = await api(merchant, 'GET', `${root}/inventory-candidates?operationWarehouseId=${warehouse}`)
const candidate = candidates.find(value => value.destinationFbaWarehouseCode && value.positions?.some(pos => Number(pos.availablePackages) > 0 && pos.palletNo !== 'UNBOUND'))
assert(candidate, 'No available inventory for another scenario')
const position = candidate.positions.find(pos => Number(pos.availablePackages) > 0 && pos.palletNo !== 'UNBOUND')
const mark = `${marker}-${deliveryMethod}`
const request = { customerOrderNo: mark, operationWarehouseId: warehouse, labelDeadline: new Date(Date.now() + 86400000).toISOString().slice(0, 19), contactName: '流程验收', contactPhone: '13800000000', deliveryMethod, printShippingLabel: false, idempotencyKey: key(), items: [{ sourceInventoryBalanceId: candidate.inventoryBalanceId, sourceInventoryPositionId: position.positionId, sourceInboundOrderId: candidate.inboundOrderId, skuCode: candidate.skuCode || 'TEST', productNameZh: '验收测试货物', totalPackageCount: 1, notifiedPackageCount: 1, cartonMark: mark, fbaCartonMark: `FBA-${mark}`, destinationFbaWarehouseCode: candidate.destinationFbaWarehouseCode, operationType: 'CARTON_LABEL', skuItems: [] }] }
request.items[0].shipmentNo = `SHIP-${mark}`
const created = await api(merchant, 'POST', root, request)
const no = created.application.relabelNo
const scenario = { deliveryMethod, relabelNo: no, originalPallet: position.palletNo, checks: [] }
report.scenarios.push(scenario)
await reject(merchant, root, { ...request, idempotencyKey: key() }, /已用于换标单/)
scenario.checks.push('DUPLICATE_APPLICATION_REJECTED')
// Read an actual Print-generated test document from File, then upload it through the merchant API.
const pdfReport = JSON.parse(await readFile('reports/relabel-closure-20260916/pdf.json', 'utf8'))
const doc = pdfReport.files[0]
const pdfResponse = await fetch(`http://127.0.0.1:9206/internal/file/v1/business-files/${process.env.OVERSEAS_TEST_CUSTOMER_ID}/${doc.category}/${pdfReport.businessId}/${doc.fileId}/content`, { headers: { 'X-Internal-Service-Token': process.env.INTERNAL_SERVICE_TOKEN } })
const pdf = Buffer.from(await pdfResponse.arrayBuffer())
assert(pdfResponse.ok && pdf.subarray(0, 5).toString() === '%PDF-', 'Test PDF not available')
for (const scope of ['CARTON_TOTAL', 'SKU_TOTAL', 'ITEM']) {
const form = new FormData(); form.append('file', new Blob([pdf], { type: 'application/pdf' }), `${marker}.pdf`)
await api(merchant, 'POST', `${root}/${no}/files?scope=${scope}${scope === 'ITEM' ? `&itemId=${created.items[0].id}` : ''}`, form)
}
let task = await api(pda, 'POST', `${work}/${no}/claim`, { idempotencyKey: key() })
const documents = await api(pda, 'GET', `${work}/${no}/documents`)
assert(documents.length >= 3, 'PDA is missing uploaded label documents')
for (const file of documents) {
const response = await fetch(`${base}${work}/${no}/documents/${file.fileId}/download?scope=${file.scope}${file.itemId ? `&itemId=${file.itemId}` : ''}`, { headers: { Authorization: `Bearer ${pda}` } })
const downloaded = Buffer.from(await response.arrayBuffer())
assert(response.ok && createHash('sha256').update(downloaded).digest('hex') === createHash('sha256').update(pdf).digest('hex'), 'PDA document mismatch')
}
scenario.checks.push('MERCHANT_UPLOAD_PDA_DOWNLOAD_THREE_SCOPES')
task = await api(pda, 'POST', `${work}/${no}/cartons/scan`, { cartonMark: mark, expectedVersion: task.version, idempotencyKey: key() })
const photoForm = new FormData(); photoForm.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic.png')
const photo = await api(pda, 'POST', `${work}/${no}/photos/files`, photoForm)
const complete = () => ({ photoFileIds: [photo.fileId], expectedVersion: task.version, remark: marker, idempotencyKey: key() })
await reject(pda, `${work}/${no}/complete`, complete(), /托盘绑定/)
scenario.checks.push('UNBOUND_COMPLETION_REJECTED')
const first = await api(pda, 'POST', `${pallets}/relabel-pallet-label`, { relabelNo: no, idempotencyKey: key() })
await move(no, mark, position.palletNo, first.palletCode)
const second = await api(pda, 'POST', `${pallets}/relabel-pallet-label`, { relabelNo: no, idempotencyKey: key() })
await move(no, mark, first.palletCode, second.palletCode)
scenario.targetPallet = second.palletCode
scenario.checks.push('REBIND_STOCK_CONSERVED_AND_REPLAY_SAFE')
task = await api(pda, 'POST', `${work}/${no}/complete`, complete())
if (deliveryMethod === 'TRUCK_PICKUP') {
task = await api(admin, 'POST', `/api/platform/v1/last-leg/relabel-applications/${no}/rework`, { decision: 'REWORK', expectedVersion: task.version, idempotencyKey: key(), remark: '验收退回:请核对并重新贴标' })
assert(task.reviewRemark?.includes('验收退回'), 'Rework remark missing on PDA')
task = await api(pda, 'POST', `${work}/${no}/cartons/scan`, { cartonMark: mark, expectedVersion: task.version, idempotencyKey: key() })
await reject(pda, `${work}/${no}/complete`, complete(), /托盘绑定/)
await move(no, mark, second.palletCode, first.palletCode)
scenario.targetPallet = first.palletCode
task = await api(pda, 'POST', `${work}/${no}/complete`, complete())
scenario.checks.push('REWORK_RESCAN_REBIND_AND_RESUBMIT')
}
const confirmed = await api(merchant, 'POST', `${root}/${no}/confirm-completion`, { idempotencyKey: key(), remark: marker })
scenario.outboundNo = confirmed.application.outboundOrderNo
assert(scenario.outboundNo, 'Customer confirmation did not create direct outbound')
await reject(pda, `${pallets}/relabel-disassemble`, { relabelNo: no, source: { palletCode: scenario.targetPallet, expectedVersion: 0 }, target: { palletCode: position.palletNo, expectedVersion: 0 }, cartonMarks: [mark], idempotencyKey: key() }, /排车或出库/)
const resolved = await api(pda, 'GET', `/api/pda/v1/last-leg/outbound-tasks/resolve/${scenario.outboundNo}`)
const picked = await api(pda, 'POST', `/api/pda/v1/last-leg/outbound-tasks/${resolved.taskNo}/pallets/${scenario.targetPallet}/pick`, { idempotencyKey: key() })
await api(pda, 'POST', `/api/pda/v1/last-leg/outbound-tasks/${resolved.taskNo}/complete`, { idempotencyKey: key() })
const outbound = await api(admin, 'GET', `/api/platform/v1/last-leg/outbound-orders/${scenario.outboundNo}?page=1&size=20`)
assert(outbound.outbound.status === 'OUTBOUNDED', 'Direct outbound not completed')
scenario.outboundStatus = outbound.outbound.status
scenario.checks.push('DIRECT_OUTBOUND_COMPLETED', 'OUTBOUND_REBIND_REJECTED')
}
report.passed = true
} catch (error) { report.error = error.message; process.exitCode = 1 }
finally {
await mkdir('reports/relabel-closure-20260916', { recursive: true })
await writeFile('reports/relabel-closure-20260916/extra.json', JSON.stringify(report, null, 2))
console.log(JSON.stringify(report, null, 2))
}
+42
View File
@@ -0,0 +1,42 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
const token = process.env.INTERNAL_SERVICE_TOKEN
if (!token) throw new Error('INTERNAL_SERVICE_TOKEN is required')
const printBase = process.env.PRINT_SERVICE_URL || 'http://127.0.0.1:9207'
const fileBase = process.env.FILE_SERVICE_URL || 'http://127.0.0.1:9206'
for (const url of [printBase, fileBase]) if (!['localhost', '127.0.0.1'].includes(new URL(url).hostname)) throw new Error('Local services only')
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
if (!customerId) throw new Error('OVERSEAS_TEST_CUSTOMER_ID is required')
const businessId = String(Date.now())
const headers = { 'X-Internal-Service-Token': token, 'X-Operator-Id': '1' }
const report = { scope: 'LOCAL_PRINT_AND_FILE_HTTP_ONLY', businessId, passed: false, files: [] }
const ensure = (ok, message) => { if (!ok) throw new Error(message) }
try {
const rendered = await fetch(`${printBase}/internal/print/v1/pdfs/inbound-marks`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ templateCode: 'INBOUND_MARK_V1', pageSpec: { widthMm: 100, heightMm: 100 }, downloadFileName: 'relabel-acceptance.pdf', labels: [{ topCode: 'RELABEL-ACCEPTANCE', warehouseText: 'ONT8', cartonText: 'CTNS:1-1', routeText: '验收测试标签', qrContent: `ACCEPTANCE-${businessId}` }] }) })
const pdf = Buffer.from(await rendered.arrayBuffer())
ensure(rendered.ok && pdf.subarray(0, 5).toString() === '%PDF-', `Print failed: HTTP ${rendered.status}`)
report.pdfBytes = pdf.length
const hash = value => createHash('sha256').update(value).digest('hex')
for (const category of ['OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT', 'OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT', 'OVERSEAS_RELABEL_ITEM_DOCUMENT']) {
const form = new FormData(); form.append('file', new Blob([pdf], { type: 'application/pdf' }), 'relabel-acceptance.pdf')
const path = `/internal/file/v1/business-files/${customerId}/${category}/${businessId}`
const upload = await fetch(fileBase + path, { method: 'POST', headers, body: form })
const body = await upload.json()
ensure(upload.ok && String(body.code) === '200', `${category}: ${body.code} ${body.message}`)
const fileId = body.data.fileId
const downloaded = await fetch(`${fileBase}${path}/${fileId}/content`, { headers })
const bytes = Buffer.from(await downloaded.arrayBuffer())
ensure(downloaded.ok && hash(bytes) === hash(pdf), 'Downloaded PDF does not match uploaded bytes')
const wrongOwner = await fetch(`${fileBase}/internal/file/v1/business-files/1/${category}/${businessId}/${fileId}`, { headers })
const denied = await wrongOwner.json()
ensure(!wrongOwner.ok || String(denied.code) !== '200', 'File was visible across customers')
report.files.push({ category, fileId, sha256: hash(bytes), ownershipCheck: 'PASS' })
}
report.passed = true
} catch (error) { report.error = error.message; process.exitCode = 1 }
finally {
await mkdir('reports/relabel-closure-20260916', { recursive: true })
await writeFile('reports/relabel-closure-20260916/pdf.json', JSON.stringify(report, null, 2))
console.log(JSON.stringify(report, null, 2))
}

Some files were not shown because too many files have changed in this diff Show More