feat: complete finance and overseas receiving workflow

This commit is contained in:
2026-09-10 22:43:11 +08:00
parent e531ae9407
commit 56635ad4e4
66 changed files with 1601 additions and 417 deletions
+7
View File
@@ -9,6 +9,13 @@
## 快速索引
退货预报、签收、库存、合箱与退货出库:
- 客户预报 `returnreceipt/controller/MerchantReturnOrderController.java` → `returnreceipt/service/ReturnReceiptService.java`,路径 `/api/merchant/v1/last-leg/return-orders`;客户范围取认证身份,预报按 SKU 保存期望数量。状态 `PENDING_RECEIPT → PARTIALLY_RECEIVED → COMPLETED` 表示签收进度,不等于换标或出库完成。
- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量,幂等重放不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。
- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。
- 退货出库继续进入 `delivery/service/DeliveryTransferService.java` 的 `RETURN_OUTBOUND`,按退货库存可用箱唛创建与拣货;不能与 `exceptionorder/` 的派送退回恢复库存混用。契约见 `src/main/resources/openapi/overseas-business-v1.yaml`,表入口 `src/main/resources/db/lastleg.sql` 的 `ll_return_*`。
人工计划自动生成托盘与资源重试:`inboundplan/controller/InboundPlanController.java` → `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;保存单票后重读全柜预览,全员为 `PLANNED/RESOURCE_PENDING`、预览就绪且存在待绑定资源时自动调用柜级 `confirm`。一票柜立即触发,多票柜在最后一票齐全后触发;资源异常沿 `resourcePendingPlanCount` 和原柜级预览/确认入口重试,不新增独立正常人工步骤。回归入口为 `src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java`。
头程来源托盘快照:`service/LoadingCompletedConsumer.java`、`mapper/LastlegSourcePalletMapper.java`;来源标签随装柜完成事件落入 `ll_order_source_pallet`,后续由 `inboundplan/service/InboundPlanService.java` 与 `ContainerInboundPlanConversionCoordinator.java` 传递给海外资源批量预留。排查标签跨区丢失时沿此链路核对,不能在尾程页面重新生成头程托盘码。
@@ -8,7 +8,11 @@ public final class DropshipRequests {
private DropshipRequests() {}
public record Receive(@NotBlank String customerId, @NotNull Long orderId, @NotNull Long warehouseId,
@NotBlank String idempotencyKey, @NotEmpty List<@Valid Pallet> pallets) {}
public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages) {}
public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages,
@NotNull @Positive Integer manifestVersion,
@NotNull @Positive Integer manifestPackageCount,
@NotNull @Positive Integer manifestFragmentCount,
@NotBlank @Size(max = 64) String manifestHash) {}
public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount,
@Positive long productQuantity,
@NotEmpty List<@NotBlank String> cartonMarks) {}
@@ -13,6 +13,10 @@ public class DropshipOrderProfile {
private String palletNo;
/** Immutable pallet number snapshot supplied by the first-leg handover event. */
private String sourcePalletNo;
private Integer manifestVersion;
private Integer manifestPackageCount;
private Integer manifestFragmentCount;
private String manifestHash;
private Long inventoryPositionId;
private String status;
private String idempotencyKey;
@@ -17,6 +17,14 @@ public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> {
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence")
List<DropshipCarton> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("""
SELECT DISTINCT p.source_pallet_no FROM ll_dropship_carton c
JOIN ll_dropship_order_profile p ON p.id=c.profile_id AND p.customer_id=c.customer_id
JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id
WHERE o.container_no=#{containerNo} AND c.carton_mark=#{cartonMark}
""")
List<String> sourcePalletsInContainer(@Param("containerNo") String containerNo,
@Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND status='AVAILABLE' ORDER BY carton_sequence LIMIT #{packages} FOR UPDATE")
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages);
@Update("UPDATE ll_dropship_carton SET status='AVAILABLE', outbound_order_no=NULL, updated_at=NOW(3) WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} AND status='SHIPPED'")
@@ -39,6 +39,14 @@ public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProf
List<DropshipOrderProfile> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} LIMIT 1")
DropshipOrderProfile findPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Select("""
SELECT p.* FROM ll_dropship_order_profile p
JOIN ll_order o ON o.id=p.order_id AND o.customer_id=p.customer_id
WHERE o.container_no=#{containerNo} AND p.source_pallet_no=#{sourcePalletNo}
ORDER BY p.customer_id,p.order_id,p.id
""")
List<DropshipOrderProfile> listContainerPallet(@Param("containerNo") String containerNo,
@Param("sourcePalletNo") String sourcePalletNo);
@Select("<script>" + OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE +
"<if test='warehouseId != null and warehouseId != \"\"'>AND pos.overseas_warehouse_id=#{warehouseId} </if>" +
@@ -51,6 +51,17 @@ public class DropshipOrderService {
Set<String> requestedPallets = request.pallets().stream().map(value -> value.palletNo().trim())
.collect(java.util.stream.Collectors.toSet());
if (!existingPallets.equals(requestedPallets)) throw conflict("Dropship order pallet snapshot conflicts with existing data");
Map<String, DropshipOrderProfile> byPallet = existing.stream().collect(Collectors.toMap(
DropshipOrderProfile::getSourcePalletNo, value -> value));
for (DropshipRequests.Pallet requested : request.pallets()) {
DropshipOrderProfile stored = byPallet.get(requested.palletNo().trim());
if (!java.util.Objects.equals(stored.getManifestVersion(), requested.manifestVersion())
|| !java.util.Objects.equals(stored.getManifestPackageCount(), requested.manifestPackageCount())
|| !java.util.Objects.equals(stored.getManifestFragmentCount(), requested.manifestFragmentCount())
|| !java.util.Objects.equals(stored.getManifestHash(), requested.manifestHash())) {
throw conflict("Dropship pallet manifest conflicts with existing data: " + requested.palletNo());
}
}
return Math.toIntExact(totalPackages);
}
@@ -104,6 +115,10 @@ public class DropshipOrderService {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(IdWorker.getId()); profile.setCustomerId(request.customerId()); profile.setOrderId(order.getId());
profile.setSourcePalletNo(input.palletNo().trim());
profile.setManifestVersion(input.manifestVersion());
profile.setManifestPackageCount(input.manifestPackageCount());
profile.setManifestFragmentCount(input.manifestFragmentCount());
profile.setManifestHash(input.manifestHash().trim().toLowerCase(java.util.Locale.ROOT));
profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L);
profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim());
profile.setCreatedAt(now); profile.setUpdatedAt(now);
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Data;
import java.math.BigDecimal;
@@ -31,4 +32,6 @@ public class InboundPlanPallet {
private LocalDateTime lockedAt;
private BigDecimal actualVolumeCbm;
private Integer actualPackageCount;
@Version
private Integer version;
}
@@ -144,7 +144,7 @@ public class ReceivingContainerController {
@PostMapping("/current/complete")
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Action request) {
@Valid @RequestBody ReceivingContainerRequests.Complete request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.complete(identity.operatorKey(), request));
}
@@ -13,14 +13,22 @@ public final class ReceivingContainerRequests {
private ReceivingContainerRequests() { }
public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Complete(boolean allowDifference,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record SignedPhoto(@NotNull ReceivingPhotoRole role,
@NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { }
public record CartonResolve(@NotBlank String cartonMark) { }
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
/** packageCount 是该托当前累计实收箱数,不是本次增量。 */
public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode,
@Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { }
@Positive int packageCount, Integer expectedPalletVersion,
@NotBlank @Size(max = 255) String idempotencyKey) {
public BulkReceive(String inboundCode, String palletCode, int packageCount, String idempotencyKey) {
this(inboundCode, palletCode, packageCount, null, idempotencyKey);
}
}
public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record WholePalletReceive(@NotBlank String sourcePalletNo,
@@ -35,7 +35,7 @@ public final class ReceivingContainerResponses {
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt,
int totalPackages, int receivedPackages, int pendingPackages,
List<Inbound> inbounds) {
boolean hasDifference, List<Inbound> inbounds) {
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
String status, String operatorName, int version, List<SignedPhoto> photos,
LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt,
@@ -43,7 +43,17 @@ public final class ReceivingContainerResponses {
int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
version, photos, preparedAt, signedAt, receivingAt, completedAt, totalPackages,
receivedPackages, pendingPackages, inbounds);
receivedPackages, pendingPackages, false, inbounds);
}
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName, String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages,
int receivedPackages, int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
status, operatorName, version, photos, preparedAt, signedAt, receivingAt,
completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds);
}
}
@@ -65,7 +75,20 @@ public final class ReceivingContainerResponses {
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages,
boolean assigned, boolean mergeAllowed, boolean urgent,
String mergeHint) {
String mergeHint, String operationMode, String groupKey,
List<String> inboundCodes, String sourcePalletNo) {
public CartonResolved(String cartonMark, String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode,
int plannedPalletCount, int totalPackages, int receivedPackages,
int pendingPackages, boolean assigned, boolean mergeAllowed,
boolean urgent, String mergeHint) {
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages, pendingPackages,
assigned, mergeAllowed, urgent, mergeHint, null, null, List.of(inboundCode), null);
}
public CartonResolved(String cartonMark, String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId, String destinationWarehouseCode,
@@ -75,24 +98,41 @@ public final class ReceivingContainerResponses {
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages,
pendingPackages, false, false, false, null);
pendingPackages, false, false, false, null, null, null, List.of(inboundCode), null);
}
}
public record PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode,
String destinationType, boolean compatible) { }
String destinationType, boolean compatible, int version,
int currentReceivedPackages) {
public PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode,
String destinationType, boolean compatible) {
this(palletCode, warehouseId, status, fbaWarehouseCode, inboundCode,
destinationType, compatible, 0, 0);
}
}
public record Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus,
boolean assigned, boolean mergeAllowed, boolean urgent,
String mergeHint) {
String mergeHint, String groupKey, List<String> inboundCodes) {
public Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus,
boolean assigned, boolean mergeAllowed, boolean urgent, String mergeHint) {
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
receivedPackages, pendingPackages, sessionStatus, assigned, mergeAllowed, urgent,
mergeHint, null, List.of(inboundCode));
}
public Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus) {
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
receivedPackages, pendingPackages, sessionStatus, false, false, false, null);
receivedPackages, pendingPackages, sessionStatus, false, false, false, null,
null, List.of(inboundCode));
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.inboundplan.pda.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_receiving_fba_pallet_count")
public class ReceivingFbaPalletCount {
@TableId(type = IdType.INPUT)
private Long id;
private Long sessionId;
private String groupKey;
private String palletCode;
private Integer actualPackages;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReceivingFbaPalletCountMapper extends BaseMapper<ReceivingFbaPalletCount> {
@Select("SELECT * FROM ll_receiving_fba_pallet_count WHERE session_id=#{sessionId} " +
"AND group_key=#{groupKey} AND pallet_code=#{palletCode} LIMIT 1")
ReceivingFbaPalletCount find(@Param("sessionId") long sessionId, @Param("groupKey") String groupKey,
@Param("palletCode") String palletCode);
@Select("SELECT COALESCE(SUM(actual_packages),0) FROM ll_receiving_fba_pallet_count " +
"WHERE session_id=#{sessionId} AND group_key=#{groupKey}")
int sumActual(@Param("sessionId") long sessionId, @Param("groupKey") String groupKey);
}
@@ -23,6 +23,7 @@ import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
import java.util.List;
import java.util.Set;
@Repository
public class PdaWorkflowRepository {
@@ -91,6 +92,11 @@ public class PdaWorkflowRepository {
return pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>().eq(InboundPlanPallet::getPlanId, planId));
}
/** FBA destination groups may allocate several customers onto one logical pallet owned by another plan. */
public List<InboundPlanPallet> palletsByIds(Set<Long> palletIds) {
return palletIds == null || palletIds.isEmpty() ? List.of() : pallets.selectBatchIds(palletIds);
}
public List<InboundPlanPalletAllocation> allocations(Long planId) {
return allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, planId));
@@ -45,6 +45,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class InboundPdaWorkflowService {
@@ -578,6 +579,58 @@ public class InboundPdaWorkflowService {
return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList();
}
/**
* 现场允许带差异结束:核平计划正常入库;差异计划只冻结为待核平,继续占用实物托盘和库位,
* 不生成客户可用库存。柜级卸货事实仍只发布一次。
*/
@Transactional
public void completeContainerWithDifferences(List<InboundPlan> containerPlans,
List<InboundPlan> reconciledPlans) {
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
Set<Long> reconciledIds = reconciledPlans.stream().map(InboundPlan::getId).collect(java.util.stream.Collectors.toSet());
LocalDateTime now = LocalDateTime.now();
List<InboundPdaTask> allTasks = new ArrayList<>();
for (InboundPlan plan : containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList()) {
InboundPdaTask task = repository.lockTask(plan.getCustomerId(), plan.getId());
if (task == null || unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null) {
throw conflict("Every plan must have the signed unload evidence before onsite completion");
}
if (reconciledIds.contains(plan.getId())) {
CompletionContext context = completionContext(plan.getCustomerId(), plan);
completePlan(plan.getCustomerId(), context, now);
allTasks.add(context.task());
} else {
task.setStatus("PENDING_RECONCILIATION"); task.setCompletedAt(now); task.setUpdatedAt(now);
task.setVersion(value(task.getVersion()) + 1); repository.update(task);
plan.setStatus("PENDING_RECONCILIATION"); plan.setUpdatedAt(now); repository.update(plan);
allTasks.add(task);
}
}
String containerNo = containerPlans.get(0).getContainerNo();
if (containerPlans.stream().anyMatch(plan -> !Objects.equals(containerNo, plan.getContainerNo()))) {
throw conflict("Inbound plans must belong to one physical container");
}
String sourceContainerBookingId = sourceContainerBookingIdForPlans(containerPlans);
LocalDateTime photoUploadedAt = allTasks.stream().map(InboundPdaTask::getUnloadPhotoUploadedAt)
.max(LocalDateTime::compareTo).orElseThrow();
long aggregateVersion = allTasks.stream().map(InboundPdaTask::getVersion).filter(Objects::nonNull)
.mapToLong(Integer::longValue).max().orElse(1L);
unloadCompletedOutbox.publishContainerCompleted(sourceContainerBookingId, containerNo,
now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion);
}
private String sourceContainerBookingIdForPlans(List<InboundPlan> plans) {
List<String> values = plans.stream().map(plan -> {
LastlegOrder order = orders.selectById(plan.getSourceOrderId());
if (order == null || order.getSourceContainerBookingId() == null || order.getSourceContainerBookingId().isBlank()) {
throw new IllegalStateException("Inbound plan has no source container booking ID");
}
return order.getSourceContainerBookingId();
}).distinct().toList();
if (values.size() != 1) throw conflict("Container plans reference different source container bookings");
return values.get(0);
}
private String sourceContainerBookingId(List<CompletionContext> contexts) {
List<String> bookingIds = contexts.stream().map(context -> {
LastlegOrder order = orders.selectById(context.plan().getSourceOrderId());
@@ -628,8 +681,15 @@ public class InboundPdaWorkflowService {
private void completePlan(String customerId, CompletionContext context, LocalDateTime now) {
InboundPlan plan = context.plan();
InboundPdaTask task = context.task();
List<InboundPlanPallet> receiptPallets = new ArrayList<>(repository.pallets(plan.getId()));
Set<Long> presentPalletIds = receiptPallets.stream().map(InboundPlanPallet::getId)
.collect(java.util.stream.Collectors.toSet());
Set<Long> sharedPalletIds = context.allocations().stream().map(InboundPlanPalletAllocation::getPalletId)
.filter(Objects::nonNull).filter(id -> !presentPalletIds.contains(id))
.collect(java.util.stream.Collectors.toSet());
receiptPallets.addAll(repository.palletsByIds(sharedPalletIds));
stockReceipts.stockIn(customerId, plan, task, context.lines(), context.allocations(),
repository.pallets(plan.getId()), now);
receiptPallets, now);
for (InboundPlanCarton carton : context.cartons()) {
carton.setStatus("ON_PALLET".equals(carton.getStatus()) ? "AVAILABLE" : "BULK_RECEIVED");
carton.setReceivedAt(now); carton.setUpdatedAt(now);
@@ -1164,7 +1224,10 @@ public class InboundPdaWorkflowService {
List<InboundPlanCarton> cartons) { }
private static int value(Integer value) { return value == null ? 0 : value; }
private static boolean terminal(String status) { return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status); }
private static boolean terminal(String status) {
return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status)
|| "PENDING_RECONCILIATION".equals(status);
}
private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; }
private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -20,15 +20,20 @@ import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.MapperFeature;
@@ -74,8 +79,11 @@ public class ReceivingContainerService {
@Resource private LastlegOrderDetailMapper orderDetails;
@Resource private ReceivingContainerSessionMapper sessions;
@Resource private ReceivingContainerOperationMapper operations;
@Resource private ReceivingFbaPalletCountMapper fbaPalletCounts;
@Resource private ReceivingSkuReceiptMapper skuReceipts;
@Resource private ContainerShippingGateMapper shippingGates;
@Resource private DropshipCartonMapper dropshipCartons;
@Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private WarehouseResourceGateway warehouse;
@Resource private UnloadPhotoFileGateway unloadPhotos;
@Resource private InboundPdaWorkflowService workflow;
@@ -246,13 +254,34 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
ReceivingContainerRequests.CartonResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
List<InboundRef> currentRefs = refs(session.getContainerNo());
String sourcePalletNo = dropshipSourcePallet(currentRefs, session.getContainerNo(), request.cartonMark());
if (sourcePalletNo != null) {
List<DropshipPalletRef> palletRefs = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo);
requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
List<InboundRef> group = palletRefs.stream().map(DropshipPalletRef::ref).distinct().toList();
InboundRef first = group.get(0);
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
int total = palletRefs.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
int received = palletRefs.stream().mapToInt(value -> value(value.allocation().getPickedPackageCount())).sum();
ReceivingContainerResponses.Inbound inbound = inbound(first);
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(),
inboundCodes.get(0), inbound.destinationType(), inbound.overseasWarehouseId(),
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(),
inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), 1, total, received,
Math.max(0, total-received), true, false, urgent(first.plan()), null,
"DROPSHIP_PALLET", "DP:" + sourcePalletNo, inboundCodes, sourcePalletNo);
}
String inboundCode = cartonInboundCode(request.cartonMark());
List<InboundRef> matches = refs(session.getContainerNo()).stream()
List<InboundRef> matches = currentRefs.stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
CartonRef carton;
if (matches.size() > 1) {
throw conflict("Carton mark inbound code is ambiguous in the current container");
} else if (matches.size() == 1 && "FBA".equals(destinationType(matches.get(0).line(), matches.get(0).order()))) {
if (request.cartonMark().trim().equals(inboundCode)) {
throw conflict("Scan one FBA carton code, not the inbound code");
}
carton = new CartonRef(matches.get(0));
} else if (matches.size() == 1) {
carton = requireCartonRef(matches.get(0).order().getCustomerId(), session.getContainerNo(), request.cartonMark());
@@ -260,6 +289,22 @@ public class ReceivingContainerService {
carton = requireUnmatchedFbaCartonPrefix(inboundCode);
}
InboundRef ref = carton.ref();
if ("FBA".equals(destinationType(ref.line(), ref.order()))
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
List<InboundRef> group = requireFbaPlanRefs(session.getContainerNo(), ref);
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
int total = group.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
int received = group.stream().mapToInt(this::receivedLinePackages).sum();
ReceivingContainerResponses.Inbound inbound = inbound(ref);
int palletCount = (int) group.stream().flatMap(value -> pallets.selectList(
new LambdaQueryWrapper<InboundPlanPallet>().eq(InboundPlanPallet::getPlanId, value.plan().getId())).stream())
.map(InboundPlanPallet::getWarehousePalletId).filter(Objects::nonNull).distinct().count();
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(), inboundCodes.get(0),
"FBA", inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
palletCount, total, received, Math.max(0,total-received), true, false, urgent(ref.plan()), null,
"FBA_GROUP", fbaGroupKey(session.getContainerNo(), ref), inboundCodes, null);
}
ReceivingContainerResponses.Inbound inbound = inbound(ref);
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(),
inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(),
@@ -273,10 +318,26 @@ public class ReceivingContainerService {
ReceivingContainerRequests.PalletResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), false);
List<InboundRef> group = "FBA".equals(destinationType(ref.line(), ref.order()))
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())
? requireFbaPlanRefs(session.getContainerNo(), ref) : List.of(ref);
PalletSelection selection = group.size() == 1 && !"FBA".equals(destinationType(ref.line(), ref.order()))
? resolveReceivingPallet(ref, request.palletCode(), false) : resolveFbaGroupPallet(group, request.palletCode());
WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
int received = selection.pallet() == null ? 0 : group.stream().mapToInt(value -> allocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, value.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, value.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, selection.pallet().getId())).stream()
.mapToInt(item -> value(item.getPickedPackageCount())).sum()).sum();
if (selection.pallet() != null && "FBA".equals(destinationType(ref.line(), ref.order()))) {
ReceivingFbaPalletCount physical = fbaPalletCounts.find(session.getId(),
fbaGroupKey(session.getContainerNo(), ref), pallet.palletNo());
if (physical != null) received = value(physical.getActualPackages());
}
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true);
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true,
selection.pallet() == null ? 0 : value(selection.pallet().getVersion()), received);
}
@Transactional
@@ -286,7 +347,6 @@ public class ReceivingContainerService {
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
String customerId = ref.order().getCustomerId();
if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
throw conflict("One-click dropship inbound must be operated by complete source pallet");
}
@@ -294,31 +354,70 @@ public class ReceivingContainerService {
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
}
List<InboundRef> fbaPlanRefs = requireFbaPlanRefs(session.getContainerNo(), ref);
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount());
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(),
allocation.ref().line(), allocation.packages()));
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true);
PalletSelection selection = resolveFbaGroupPallet(fbaPlanRefs, request.palletCode());
WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
InboundPlanPallet pallet = selection.pallet();
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
if (request.expectedPalletVersion() != null && !Objects.equals(request.expectedPalletVersion(), pallet.getVersion())) {
throw conflict("Pallet receiving version changed; refresh the group before saving");
}
int allocatedOnPallet = fbaPlanRefs.stream().mapToInt(value -> allocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, value.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, value.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId())).stream()
.mapToInt(item -> value(item.getPickedPackageCount())).sum()).sum();
String groupKey = fbaGroupKey(session.getContainerNo(), ref);
ReceivingFbaPalletCount physicalCount = fbaPalletCounts.find(session.getId(), groupKey, resource.palletNo());
int previous = physicalCount == null ? allocatedOnPallet : value(physicalCount.getActualPackages());
if (request.packageCount() < previous) {
throw conflict("Cumulative pallet quantity cannot be reduced after saving; use correction operation");
}
int physicalDelta = request.packageCount() - previous;
List<String> inboundCodes = fbaPlanRefs.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
if (physicalDelta == 0) {
int total = fbaPlanRefs.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("ALREADY_RECEIVED",
session.getContainerNo(), inboundCodes.get(0), "FBA", resource.palletNo(), 0,
fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum(),
Math.max(0,total-fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum()), session.getStatus(),
true, mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()), groupKey, inboundCodes);
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result;
}
int groupTotal = fbaPlanRefs.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
int groupReceived = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum();
int allocatable = Math.min(physicalDelta, Math.max(0, groupTotal - groupReceived));
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(fbaPlanRefs, allocatable);
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(allocation.ref().order().getCustomerId(), allocation.ref().plan(),
allocation.ref().line(), allocation.packages()));
String palletCustomerId = pallet.getCustomerId();
InboundRef palletOwner = fbaPlanRefs.stream().filter(value -> Objects.equals(value.plan().getId(), pallet.getPlanId()))
.findFirst().orElseThrow(() -> conflict("FBA pallet does not belong to the current destination group"));
Long resourceLockId = allocatable == 0 ? null
: workflow.assignResolvedPallet(palletCustomerId, palletOwner.plan(), pallet, resource);
try {
int accepted = 0;
for (FbaSkuAllocation allocation : skuAllocations) {
accepted += workflow.receiveBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), pallet,
String allocationCustomerId = allocation.ref().order().getCustomerId();
accepted += workflow.receiveBulkResolved(allocationCustomerId, allocation.ref().plan(), allocation.ref().line(), pallet,
allocation.packages(), operatorKey, operatorName);
recordSkuReceipt(session, allocation, pallet);
}
savePhysicalFbaCount(session.getId(), groupKey, resource.palletNo(), request.packageCount(), physicalCount);
markReceiving(session);
int received = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum();
int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum();
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted,
boolean overReceived = fbaPalletCounts.sumActual(session.getId(), groupKey) > total;
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt(
overReceived ? "DIFFERENCE_RECORDED" : "RECEIVED",
session.getContainerNo(), inboundCodes.get(0), "FBA", resource.palletNo(), physicalDelta,
received, Math.max(0, total - received), session.getStatus(), assigned(ref.plan()),
mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()));
mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()),
groupKey, inboundCodes);
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
workflow.compensateReservation(customerId, resourceLockId, failure);
workflow.compensateReservation(palletCustomerId, resourceLockId, failure);
throw failure;
}
}
@@ -370,59 +469,89 @@ public class ReceivingContainerService {
ReceivingContainerOperation replay = replay(operatorKey, "PALLET_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
List<DropshipPalletRef> matches = new ArrayList<>();
for (InboundRef ref : refs(session.getContainerNo())) {
if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
String customerId = ref.order().getCustomerId();
List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, customerId)
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
.eq(InboundPlanPallet::getSourcePalletNo, request.sourcePalletNo()));
for (InboundPlanPallet pallet : sourcePallets) {
List<InboundPlanPalletAllocation> palletAllocations = allocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getCustomerId, customerId)
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId()));
palletAllocations.forEach(allocation -> matches.add(new DropshipPalletRef(ref, pallet, allocation)));
String sourcePalletNo = dropshipSourcePallet(session.getContainerNo(), request.sourcePalletNo());
if (sourcePalletNo == null) throw notFound("DP or carton is not part of the current container");
requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
List<DropshipPalletRef> matches = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo);
boolean alreadyReceived = matches.stream().allMatch(match ->
value(match.allocation().getPickedPackageCount()) == value(match.allocation().getPlannedPackageCount()));
int accepted = 0;
if (!alreadyReceived) {
if (matches.stream().anyMatch(match -> value(match.allocation().getPickedPackageCount()) != 0)) {
throw conflict("DP pallet is only partially received; reconcile it before retrying");
}
for (DropshipPalletRef match : matches) {
int packages = value(match.allocation().getPlannedPackageCount());
workflow.pickWholePallet(match.ref().order().getCustomerId(), match.ref().plan().getPlanNo(),
match.allocation().getId(), new PdaRequests.WholePalletOperation(
request.idempotencyKey() + ":PICK:" + match.allocation().getId(), operatorKey, operatorName));
accepted += packages;
match.allocation().setPickedPackageCount(packages);
}
}
if (matches.isEmpty()) throw notFound("Source pallet is not part of the current container");
if (matches.size() != 1) throw conflict("Source pallet is duplicated inside the current container");
DropshipPalletRef match = matches.get(0);
String customerId = match.ref().order().getCustomerId();
int accepted = value(match.allocation().getPlannedPackageCount());
workflow.pickWholePallet(customerId, match.ref().plan().getPlanNo(), match.allocation().getId(),
new PdaRequests.WholePalletOperation(request.idempotencyKey() + ":PICK", operatorKey, operatorName));
markReceiving(session);
ReceivingContainerResponses.Inbound inbound = inbound(match.ref());
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), match.pallet().getPalletNo(),
accepted, inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(),
inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint());
List<InboundRef> group = matches.stream().map(DropshipPalletRef::ref).distinct().toList();
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
int received = matches.stream().mapToInt(value -> value(value.allocation().getPickedPackageCount())).sum();
InboundRef first = group.get(0);
ReceivingContainerResponses.Inbound inbound = inbound(first);
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt(
alreadyReceived ? "ALREADY_RECEIVED" : "RECEIVED", session.getContainerNo(), inboundCodes.get(0),
inbound.destinationType(), sourcePalletNo, accepted, received, Math.max(0,total-received), session.getStatus(),
true, false, urgent(first.plan()), null, "DP:" + sourcePalletNo, inboundCodes);
save(session, "PALLET_RECEIVE", request.idempotencyKey(), request, result);
return result;
}
@Transactional(isolation = Isolation.READ_COMMITTED)
public ReceivingContainerResponses.Session complete(String operatorKey,
ReceivingContainerRequests.Action request) {
ReceivingContainerRequests.Complete request) {
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
ReceivingContainerSession session = requireSigned(operatorKey);
List<InboundPlan> containerPlans = requirePlans(session.getContainerNo());
Totals totals = totals(containerPlans);
if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container");
validateFbaSkuBalance(session.getContainerNo());
workflow.completeContainer(containerPlans);
boolean hasDifference = totals.total() != totals.received() || hasFbaOverReceipt(session, containerPlans);
if (hasDifference && !request.allowDifference()) {
throw conflict("Container has a receiving difference; confirm onsite completion with difference");
}
if (!hasDifference) {
validateFbaSkuBalance(session.getContainerNo());
workflow.completeContainer(containerPlans);
} else {
workflow.completeContainerWithDifferences(containerPlans, reconciledPlans(containerPlans));
}
LocalDateTime now = LocalDateTime.now();
session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now);
session.setStatus(hasDifference ? "COMPLETED_WITH_DIFFERENCE" : "COMPLETED");
session.setCompletedAt(now); session.setUpdatedAt(now);
session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session);
ReceivingContainerResponses.Session result = snapshot(session, containerPlans);
save(session, "COMPLETE", request.idempotencyKey(), request, result); return result;
}
private List<InboundPlan> reconciledPlans(List<InboundPlan> containerPlans) {
Set<Long> complete = refs(containerPlans.get(0).getContainerNo()).stream()
.collect(Collectors.groupingBy(value -> value.plan().getId(), LinkedHashMap::new, Collectors.toList()))
.entrySet().stream().filter(entry -> entry.getValue().stream().allMatch(ref ->
receivedLinePackages(ref) == value(ref.line().getRequiredPackageCount())))
.map(Map.Entry::getKey).collect(Collectors.toSet());
Set<String> incompleteFbaGroups = refs(containerPlans.get(0).getContainerNo()).stream()
.filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()))
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.filter(ref -> !complete.contains(ref.plan().getId()))
.map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).collect(Collectors.toSet());
refs(containerPlans.get(0).getContainerNo()).stream()
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.filter(ref -> fbaGroupHasOverReceipt(findSession(containerPlans.get(0).getContainerNo()), ref))
.map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).forEach(incompleteFbaGroups::add);
return containerPlans.stream().filter(plan -> complete.contains(plan.getId()))
.filter(plan -> refs(plan.getContainerNo()).stream().filter(ref -> Objects.equals(ref.plan().getId(), plan.getId()))
.noneMatch(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))
&& incompleteFbaGroups.contains(normalizedFba(ref.line().getFbaWarehouseCode()))))
.toList();
}
private ReceivingContainerSession requireCurrent(String operatorKey, boolean lock) {
String key = activeOperatorKey(operatorKey);
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key)
@@ -503,7 +632,8 @@ public class ReceivingContainerService {
List<InboundPlan> containerPlans = planFacts.stream()
.filter(plan -> containerNo.equals(plan.getContainerNo())).toList();
boolean allPlansCompleted = !containerPlans.isEmpty()
&& containerPlans.stream().allMatch(plan -> "COMPLETED".equals(plan.getStatus()));
&& containerPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
.contains(plan.getStatus()));
boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1;
boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent;
int pending = Math.max(0, totalOrders - plannedOrders);
@@ -565,7 +695,8 @@ public class ReceivingContainerService {
if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
throw conflict("Every inbound plan in the receiving container must have an owning customer");
}
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
if (values.stream().allMatch(value -> List.of("COMPLETED", "PENDING_RECONCILIATION")
.contains(value.getStatus()))) {
throw conflict("Receiving container is already completed");
}
return values;
@@ -634,16 +765,20 @@ public class ReceivingContainerService {
private List<InboundRef> requireFbaPlanRefs(String containerNo, InboundRef selected) {
List<InboundRef> values = refs(containerNo).stream()
.filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId()))
.sorted(Comparator.comparing(value -> value.line().getId())).toList();
.filter(value -> !"ONE_CLICK_DROPSHIP".equals(value.order().getOrderType()))
.filter(value -> "FBA".equals(destinationType(value.line(), value.order())))
.filter(value -> Objects.equals(normalizedFba(value.line().getFbaWarehouseCode()),
normalizedFba(selected.line().getFbaWarehouseCode())))
.sorted(Comparator.comparing((InboundRef value) -> value.line().getInboundCode())
.thenComparing(value -> value.line().getId())).toList();
if (values.isEmpty() || values.stream().anyMatch(value ->
!"FBA".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA bulk receiving requires every line in the selected inbound plan to be FBA");
throw conflict("FBA receiving group is invalid");
}
Set<String> destinations = values.stream().map(value -> trim(value.line().getFbaWarehouseCode()))
.collect(Collectors.toSet());
if (destinations.size() != 1 || destinations.contains(null)) {
throw conflict("FBA bulk receiving requires one destination warehouse per inbound plan");
throw conflict("FBA receiving group requires one destination warehouse");
}
return values;
}
@@ -694,6 +829,29 @@ public class ReceivingContainerService {
return new PalletSelection(pallet, resource);
}
private PalletSelection resolveFbaGroupPallet(List<InboundRef> refs, String palletCode) {
String normalized = palletCode.trim();
List<InboundPlanPallet> matches = refs.stream().flatMap(ref -> pallets.selectList(
new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream())
.filter(value -> matchesPlannedPalletCode(value, normalized)).filter(this::hasConfirmedReservation)
.collect(Collectors.toMap(InboundPlanPallet::getId, value -> value, (left,right)->left,
LinkedHashMap::new)).values().stream().toList();
if (matches.isEmpty()) throw notFound("Pallet code is not part of the current FBA destination group");
if (matches.size() != 1) throw conflict("Pallet code is duplicated in the current FBA destination group");
InboundPlanPallet pallet = matches.get(0);
InboundRef owner = refs.stream().filter(ref -> Objects.equals(ref.plan().getId(), pallet.getPlanId()))
.findFirst().orElseThrow(() -> conflict("Pallet owner plan is not part of the FBA destination group"));
long warehouseId = owner.plan().getOverseasWarehouseId() == null
? Long.parseLong(owner.order().getOverseasWarehouseId()) : owner.plan().getOverseasWarehouseId();
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolveReservedPallet(
pallet.getPalletNo(), owner.order().getCustomerId(), warehouseId, pallet.getWarehousePalletId(),
pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
validatePallet(owner, resource);
return new PalletSelection(pallet, resource);
}
private boolean matchesPlannedPalletCode(InboundPlanPallet pallet, String scannedCode) {
return scannedCode.equals(trim(pallet.getPalletNo()))
|| scannedCode.equals(trim(pallet.getSourcePalletNo()));
@@ -750,6 +908,68 @@ public class ReceivingContainerService {
return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode());
}
private String dropshipSourcePallet(String containerNo, String scanCode) {
return dropshipSourcePallet(refs(containerNo), containerNo, scanCode);
}
private String dropshipSourcePallet(List<InboundRef> currentRefs, String containerNo, String scanCode) {
String normalized = trim(scanCode);
if (normalized == null) return null;
List<InboundRef> dropshipRefs = currentRefs.stream()
.filter(ref -> "ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())).toList();
if (dropshipRefs.isEmpty()) return null;
boolean direct = normalized.toUpperCase(java.util.Locale.ROOT).startsWith("DP") && dropshipRefs.stream()
.anyMatch(ref -> pallets.selectCount(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
.eq(InboundPlanPallet::getSourcePalletNo, normalized)) > 0);
if (direct) return normalized;
List<String> resolved = dropshipCartons.sourcePalletsInContainer(containerNo, normalized);
if (resolved.size() > 1) throw conflict("Carton belongs to more than one DP in the current container");
return resolved.isEmpty() ? null : resolved.get(0);
}
private List<DropshipPalletRef> dropshipPalletRefs(String containerNo, String sourcePalletNo) {
List<DropshipPalletRef> result = new ArrayList<>();
for (InboundRef ref : refs(containerNo)) {
if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
.eq(InboundPlanPallet::getSourcePalletNo, sourcePalletNo));
for (InboundPlanPallet pallet : sourcePallets) {
allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId()))
.forEach(allocation -> result.add(new DropshipPalletRef(ref, pallet, allocation)));
}
}
if (result.isEmpty()) throw notFound("DP is not part of the current container");
Set<Long> physicalPallets = result.stream().map(value -> value.pallet().getWarehousePalletId())
.filter(Objects::nonNull).collect(Collectors.toSet());
if (physicalPallets.size() != 1) throw conflict("DP customer fragments do not share one physical pallet");
return result;
}
private void requireCompleteManifest(String containerNo, String sourcePalletNo) {
List<DropshipOrderProfile> profiles = dropshipProfiles.listContainerPallet(containerNo, sourcePalletNo);
if (profiles.isEmpty()) throw notFound("DP manifest was not found in the current container");
Set<Integer> versions = profiles.stream().map(DropshipOrderProfile::getManifestVersion).collect(Collectors.toSet());
Set<Integer> totals = profiles.stream().map(DropshipOrderProfile::getManifestPackageCount).collect(Collectors.toSet());
Set<Integer> fragments = profiles.stream().map(DropshipOrderProfile::getManifestFragmentCount).collect(Collectors.toSet());
Set<String> hashes = profiles.stream().map(DropshipOrderProfile::getManifestHash).collect(Collectors.toSet());
if (versions.size()!=1 || totals.size()!=1 || fragments.size()!=1 || hashes.size()!=1) {
throw conflict("DP manifest fragments are inconsistent");
}
long customerFragments = profiles.stream().map(DropshipOrderProfile::getCustomerId).distinct().count();
long cartons = profiles.stream().mapToLong(profile ->
dropshipCartons.listProfile(profile.getCustomerId(), profile.getId()).size()).sum();
if (customerFragments != fragments.iterator().next() || cartons != totals.iterator().next()) {
throw conflict("DP manifest fragments are incomplete; wait for all customer fragments");
}
}
private record PalletSelection(InboundPlanPallet pallet,
WarehouseResourceGateway.WarehousePalletReference resource) { }
@@ -764,7 +984,40 @@ public class ReceivingContainerService {
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(),
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds);
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received),
hasFbaOverReceipt(session, containerPlans), inbounds);
}
private void savePhysicalFbaCount(long sessionId, String groupKey, String palletCode, int actualPackages,
ReceivingFbaPalletCount existing) {
LocalDateTime now = LocalDateTime.now();
if (existing == null) {
ReceivingFbaPalletCount value = new ReceivingFbaPalletCount();
value.setId(IdWorker.getId()); value.setSessionId(sessionId); value.setGroupKey(groupKey);
value.setPalletCode(palletCode); value.setActualPackages(actualPackages);
value.setCreatedAt(now); value.setUpdatedAt(now); fbaPalletCounts.insert(value);
} else {
existing.setActualPackages(actualPackages); existing.setUpdatedAt(now); fbaPalletCounts.updateById(existing);
}
}
private boolean hasFbaOverReceipt(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
if (session == null || containerPlans == null || containerPlans.isEmpty()) return false;
return refs(session.getContainerNo()).stream()
.filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()))
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.anyMatch(ref -> fbaGroupHasOverReceipt(session, ref));
}
private boolean fbaGroupHasOverReceipt(ReceivingContainerSession session, InboundRef ref) {
if (session == null || session.getId() == null) return false;
int planned = requireFbaPlanRefs(session.getContainerNo(), ref).stream()
.mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
return fbaPalletCounts.sumActual(session.getId(), fbaGroupKey(session.getContainerNo(), ref)) > planned;
}
private ReceivingContainerSession findSession(String containerNo) {
return sessions.findActiveByContainer(activeContainerKey(containerNo));
}
private ReceivingContainerResponses.Inbound inbound(InboundRef ref) {
@@ -844,10 +1097,11 @@ public class ReceivingContainerService {
throw conflict("Unload photo file ID must be a positive decimal string");
}
private List<FbaSkuAllocation> allocateFbaSkuReceipts(String customerId, List<InboundRef> refs, int packageCount) {
private List<FbaSkuAllocation> allocateFbaSkuReceipts(List<InboundRef> refs, int packageCount) {
int remaining = packageCount;
List<FbaSkuAllocation> result = new ArrayList<>();
for (InboundRef ref : refs) {
String customerId = ref.order().getCustomerId();
int linePending = value(ref.line().getRequiredPackageCount()) - receivedLinePackages(ref);
List<SkuExpectation> expectations = skuExpectations(ref);
int skuPending = expectations.stream().mapToInt(expectation -> expectation.expectedPackages()
@@ -958,6 +1212,15 @@ public class ReceivingContainerService {
return value;
}
private String fbaGroupKey(String containerNo, InboundRef ref) {
return "FBA:" + containerNo + ":" + normalizedFba(ref.line().getFbaWarehouseCode());
}
private String normalizedFba(String value) {
String normalized = trim(value);
return normalized == null ? null : normalized.toUpperCase(java.util.Locale.ROOT);
}
private void markReceiving(ReceivingContainerSession session) {
if (!"RECEIVING".equals(session.getStatus())) {
session.setStatus("RECEIVING"); session.setReceivingAt(LocalDateTime.now());
@@ -253,7 +253,7 @@ public class ContainerInboundPlanConversionCoordinator {
throw new ResponseStatusException(HttpStatus.CONFLICT, "计划托数不能少于已生成的头程托盘码数量");
}
String fbaCode = trimToNull(order.fbaWarehouseCode());
if (fbaCode != null) {
if (fbaCode != null && !"ONE_CLICK_DROPSHIP".equals(order.orderType())) {
for (String palletCode : sourcePalletNos) {
PalletCodeParts parts = palletCodeParts(preview.containerNo(), palletCode);
String previousPrefix = prefixByFba.putIfAbsent(fbaCode, parts.prefix());
@@ -329,10 +329,7 @@ public class ContainerInboundPlanConversionCoordinator {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次包含重复业务键");
}
}
if (response.items().stream().map(WarehouseResourceGateway.PalletBatchItem::palletId).distinct().count() != response.items().size()
|| response.items().stream().map(WarehouseResourceGateway.PalletBatchItem::resourceLockId).distinct().count() != response.items().size()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次包含重复托盘或资源锁");
}
validateSharedPallets(preparation.items(), byKey);
return preparation.items().stream().collect(Collectors.groupingBy(PreparedItem::orderId)).values().stream()
.map(items -> {
PreparedItem first = items.get(0);
@@ -356,6 +353,22 @@ public class ContainerInboundPlanConversionCoordinator {
return value;
}
private void validateSharedPallets(List<PreparedItem> expected,
Map<String, WarehouseResourceGateway.PalletBatchItem> actual) {
Map<Long, List<PreparedItem>> byPallet = expected.stream().collect(Collectors.groupingBy(item ->
requiredReservation(actual.get(item.businessKey()), item).palletId()));
for (List<PreparedItem> values : byPallet.values()) {
if (values.size() == 1) continue;
Set<String> codes = values.stream().map(PreparedItem::palletCode).collect(Collectors.toSet());
Set<Long> locks = values.stream().map(item -> requiredReservation(actual.get(item.businessKey()), item).resourceLockId())
.collect(Collectors.toSet());
if (codes.size() != 1 || codes.iterator().next() == null
|| !codes.iterator().next().toUpperCase(java.util.Locale.ROOT).startsWith("DP") || locks.size() != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "只有同一码的一件代发 DP 才能共享实物托盘");
}
}
}
private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size,
String... acceptedStatuses) {
if (response == null || response.batchId() <= 0 || response.attemptNo() <= 0
@@ -842,7 +842,9 @@ public class InboundPlanService {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE"));
if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
if ("COMPLETED".equals(plan.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets");
if (List.of("COMPLETED", "PENDING_RECONCILIATION").contains(plan.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets");
}
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId()));
boolean destinationExists = lines.stream().anyMatch(line -> request.destinationWarehouseId().equals(line.getDestinationWarehouseId()));
@@ -23,8 +23,8 @@ public interface InventoryCommandMapper {
@Param("balanceId") Long balanceId);
@Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " +
"FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId}")
long palletPackages(@Param("customerId") String customerId, @Param("palletId") Long palletId);
"FROM ll_inventory_position WHERE pallet_id=#{palletId}")
long palletPackages(@Param("palletId") Long palletId);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} " +
@@ -239,7 +239,7 @@ public class InventoryCommandService {
balance.setPickedPackages(value(balance.getPickedPackages()) - request.packages());
touch(balance);
balances.updateById(balance);
boolean palletEmpty = commands.palletPackages(customerId, position.getPalletId()) == 0;
boolean palletEmpty = commands.palletPackages(position.getPalletId()) == 0;
if (palletEmpty) warehouseGateway.scrapPallet(customerId, position.getPalletId());
ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "SHIP",
"PICKED", "SHIPPED", before, -request.packages(), before - request.packages(),
@@ -459,7 +459,10 @@ public class LoadingCompletedConsumer {
private List<DropshipRequests.Pallet> pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
if (source.pallets() != null && !source.pallets().isEmpty()) {
return source.pallets().stream().map(pallet -> {
List<DropshipRequests.Pallet> result = new ArrayList<>();
JsonNode rawPallets = rawSource.path("pallets");
for (int palletIndex = 0; palletIndex < source.pallets().size(); palletIndex++) {
FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet = source.pallets().get(palletIndex);
Map<String, DropshipRequests.SkuPackages> inventorySkus = new java.util.LinkedHashMap<>();
for (var sku : pallet.skuPackages()) {
inventorySkus.merge(sku.skuCode(), new DropshipRequests.SkuPackages(sku.skuCode(),
@@ -470,8 +473,13 @@ public class LoadingCompletedConsumer {
Math.addExact(left.productQuantity(), right.productQuantity()), List.copyOf(marks));
});
}
return new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()));
}).toList();
JsonNode rawPallet = rawPallets.isArray() && palletIndex < rawPallets.size()
? rawPallets.get(palletIndex) : objectMapper.createObjectNode();
result.add(new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()),
positiveManifest(rawPallet, "manifestVersion"), positiveManifest(rawPallet, "manifestPackageCount"),
positiveManifest(rawPallet, "manifestFragmentCount"), requiredManifestHash(rawPallet)));
}
return List.copyOf(result);
}
return legacyPallets(rawSource);
}
@@ -484,7 +492,9 @@ public class LoadingCompletedConsumer {
String palletNo = pallet.path("palletNo").asText(pallet.path("sourcePalletNo").asText(null));
List<DropshipRequests.SkuPackages> skuRows = skuPackages(pallet);
if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) {
result.add(new DropshipRequests.Pallet(palletNo, skuRows));
result.add(new DropshipRequests.Pallet(palletNo, skuRows,
positiveManifest(pallet, "manifestVersion"), positiveManifest(pallet, "manifestPackageCount"),
positiveManifest(pallet, "manifestFragmentCount"), requiredManifestHash(pallet)));
}
}
return result;
@@ -492,11 +502,27 @@ public class LoadingCompletedConsumer {
String palletNo = source.path("palletNo").asText(source.path("sourcePalletNo").asText(null));
List<DropshipRequests.SkuPackages> skuRows = skuPackages(source);
if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) {
result.add(new DropshipRequests.Pallet(palletNo, skuRows));
result.add(new DropshipRequests.Pallet(palletNo, skuRows,
positiveManifest(source, "manifestVersion"), positiveManifest(source, "manifestPackageCount"),
positiveManifest(source, "manifestFragmentCount"), requiredManifestHash(source)));
}
return result;
}
private int positiveManifest(JsonNode source, String field) {
int value = source.path(field).asInt(0);
if (value <= 0) throw new IllegalArgumentException("One-click dropship pallet is missing " + field);
return value;
}
private String requiredManifestHash(JsonNode source) {
String value = source.path("manifestHash").asText(null);
if (value == null || !value.matches("(?i)[0-9a-f]{64}")) {
throw new IllegalArgumentException("One-click dropship pallet manifestHash is invalid");
}
return value.toLowerCase(java.util.Locale.ROOT);
}
private List<DropshipRequests.SkuPackages> skuPackages(JsonNode source) {
List<DropshipRequests.SkuPackages> result = new ArrayList<>();
JsonNode nodes = source.path("skuPackages");
@@ -660,8 +660,9 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet (
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 '计划内托盘序号',
version INT NOT NULL DEFAULT 0 COMMENT '收货累计值乐观锁版本',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no),
KEY idx_ll_inbound_pallet_no (pallet_no),
UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id, sequence_no),
KEY idx_ll_inbound_pallet_plan (plan_id),
KEY idx_ll_inbound_pallet_customer_plan (customer_id, plan_id)
@@ -996,7 +997,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', overseas_warehouse_code VARCHAR(128) NOT NULL COMMENT '海外接柜仓业务编码快照', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED、COMPLETED_WITH_DIFFERENCE',
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间',
completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -1019,6 +1020,16 @@ CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id)
) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度';
CREATE TABLE IF NOT EXISTS ll_receiving_fba_pallet_count (
id BIGINT NOT NULL COMMENT 'FBA托盘现场累计箱数ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
group_key VARCHAR(255) NOT NULL COMMENT '柜号与FBA仓组成的收货组键', pallet_code VARCHAR(128) NOT NULL COMMENT '现有FBA托盘码',
actual_packages INT NOT 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_receiving_fba_count(session_id,group_key,pallet_code),
KEY idx_ll_receiving_fba_group(session_id,group_key)
) ENGINE=InnoDB COMMENT='海外PDA普通FBA托盘现场累计与超收标记';
CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
@@ -1799,11 +1810,14 @@ CREATE TABLE IF NOT EXISTS ll_dropship_order_profile (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号',
source_pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照',
manifest_version INT NOT NULL COMMENT '整托清单版本', manifest_package_count INT NOT NULL COMMENT '整托总箱数',
manifest_fragment_count INT NOT NULL COMMENT '整托客户分片总数', manifest_hash CHAR(64) NOT NULL COMMENT '整托完整清单摘要',
inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', version BIGINT NOT NULL DEFAULT 0 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_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no),
UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id)
UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id),
KEY idx_ll_dropship_profile_manifest(source_pallet_no,manifest_hash)
) ENGINE=InnoDB COMMENT='一件代发托盘档案';
CREATE TABLE IF NOT EXISTS ll_dropship_carton (
@@ -96,24 +96,24 @@ paths:
/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve:
post:
summary: 解析当前柜箱唛
description: 权限 lastleg:pda:operate;FBA 从完整 cartonMark 的第一个 # 前提取固定入仓码,并精确匹配当前锁定柜唯一 FBA line;不要求逐箱 manifest,不校验 SKU 或箱序号。OVERSEAS 必须命中真实 manifest。两者均不返回 SKU,也不登记收货。
description: 权限 lastleg:pda:operate;一件代发扫描 DP 或任一箱码解析整托并校验跨客户完整清单;普通 FBA 扫任一箱码后按当前柜+FBA目的仓聚合所有入仓码;海外仓中转仍逐箱解析。响应不返回客户信息。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [cartonMark], properties: { cartonMark: { type: string } } } } } }
responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '404': { description: 入仓码未命中当前柜,或 OVERSEAS 箱码未命中真实 manifest }, '409': { description: '缺少 #、空入仓码前缀、跨柜或当前柜多重命中' } }
/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve:
post:
summary: 解析当前柜实物托盘码
description: 权限 lastleg:pda:operate;托盘固定从 Warehouse Overseas 公共资源作用域解析并校验实际仓与目的仓。
description: 权限 lastleg:pda:operate;普通 FBA 只解析当前柜该 FBA 目的仓已经生成的“柜号-FBA仓-序号”托盘,返回版本与该托现场累计箱数。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [palletCode, inboundCode], properties: { palletCode: { type: string }, inboundCode: { type: string } } } } } }
responses: { '200': { description: 可用托盘与目的匹配结果, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingPalletResolvedApiResponse' } } } }, '409': { description: 仓库、目的仓或托盘状态不符 } }
/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk:
post:
summary: FBA 入仓码按箱数收货
description: 权限 lastleg:pda:operate;只允许 destinationType=FBA,OVERSEAS 在服务端硬拒绝;服务端按当前入仓码所属计划的 line.id ASC 和冻结 SKU 明细顺序拆分箱数并记录真实 SKU 收货;不接收 SKU、customerId、planNo、allocationId、warehouseId、locationId。
description: 权限 lastleg:pda:operate;只允许普通 FBA。packageCount 是该托现场累计箱数,服务端按当前柜+FBA目的仓稳定分摊到全部关联入仓码;超出计划部分只记差异,不生成可用库存。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } }
responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 海外仓批量、超收或托盘不匹配 } }
responses: { '200': { description: 本次与累计实收进度,超收返回 DIFFERENCE_RECORDED, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 非普通FBA、累计数回退、版本变化或托盘不匹配 } }
/api/pda/v1/last-leg/receiving-containers/current/receipts/carton:
post:
summary: 海外仓入仓码逐箱收货
@@ -124,17 +124,17 @@ paths:
/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet:
post:
summary: 一件代发按完整来源托盘收货
description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描国内封托后冻结的来源托盘号,一次接收该托全部箱和 SKU 快照;不允许逐箱或拆分接收。
description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描 DP 或该托任一箱码,一次接收跨客户完整 DP;所有客户事件分片清单一致且齐全后才允许收货。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWholePallet' } } } }
responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '404': { description: 来源托盘不属于当前柜 }, '409': { description: 非一件代发、来源托盘重复或已操作 } }
/api/pda/v1/last-leg/receiving-containers/current/complete:
post:
summary: 完成当前柜收货并入库存
description: 权限 lastleg:pda:operate;仅柜内每个入仓码实收总数精确相等后完成,库存、计划、尾程状态与卸柜 Outbox 同事务落地。
description: 权限 lastleg:pda:operate;无差异时正常入库;allowDifference=true 时允许短少或超收现场结束,差异计划进入 PENDING_RECONCILIATION 且不生成客户可用库存。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } }
responses: { '200': { description: COMPLETED 柜号会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 数量未核平或状态冲突 } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingComplete' } } } }
responses: { '200': { description: COMPLETED 或 COMPLETED_WITH_DIFFERENCE 柜号会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 未确认差异或状态冲突 } }
/api/pda/v1/last-leg/inventory/resolve:
post:
summary: PDA按SKU、箱唛或入仓码扫码查询库存
@@ -1781,6 +1781,12 @@ components:
type: object
required: [idempotencyKey]
properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } }
ReceivingComplete:
type: object
required: [allowDifference, idempotencyKey]
properties:
allowDifference: { type: boolean }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
ReceivingSign:
type: object
required: [photos, idempotencyKey]
@@ -1807,6 +1813,7 @@ components:
inboundCode: { type: string, minLength: 1 }
palletCode: { type: string, minLength: 1 }
packageCount: { type: integer, minimum: 1 }
expectedPalletVersion: { type: integer, minimum: 0, nullable: true }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
ReceivingCarton:
type: object
@@ -1883,13 +1890,13 @@ components:
currentAccount: { type: boolean }
ReceivingSession:
type: object
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds]
properties:
containerNo: { type: string }
overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID }
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码快照 }
overseasWarehouseName: { type: string, description: Warehouse Overseas服务校验后的共享实际海外收货仓名称快照 }
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED, COMPLETED_WITH_DIFFERENCE] }
operatorName: { type: string, nullable: true }
version: { type: integer }
photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedSessionPhoto' } }
@@ -1900,6 +1907,7 @@ components:
totalPackages: { type: integer }
receivedPackages: { type: integer }
pendingPackages: { type: integer }
hasDifference: { type: boolean, description: 是否存在短少或普通FBA超收差异 }
inbounds: { type: array, items: { $ref: '#/components/schemas/ReceivingInbound' } }
ReceivingPhoto:
type: object
@@ -1913,12 +1921,16 @@ components:
allOf:
- { $ref: '#/components/schemas/ReceivingInbound' }
- type: object
required: [cartonMark]
required: [cartonMark, operationMode, groupKey, inboundCodes]
properties:
cartonMark: { type: string }
operationMode: { type: string, enum: [DROPSHIP_PALLET, FBA_GROUP], nullable: true }
groupKey: { type: string, nullable: true }
inboundCodes: { type: array, items: { type: string } }
sourcePalletNo: { type: string, nullable: true }
ReceivingPalletResolved:
type: object
required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible]
required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible, version, currentReceivedPackages]
properties:
palletCode: { type: string }
warehouseId: { type: integer, format: int64 }
@@ -1927,6 +1939,8 @@ components:
inboundCode: { type: string }
destinationType: { type: string, enum: [FBA, OVERSEAS] }
compatible: { type: boolean }
version: { type: integer, minimum: 0 }
currentReceivedPackages: { type: integer, minimum: 0 }
ReceivingReceipt:
type: object
required: [result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, receivedPackages, pendingPackages, sessionStatus, assigned, mergeAllowed, urgent]
@@ -1946,6 +1960,8 @@ components:
mergeHint: { type: string, nullable: true }
orderType: { type: string, nullable: true }
wholePalletInbound: { type: boolean, description: 一件代发必须按完整来源托盘扫描收货 }
groupKey: { type: string, nullable: true }
inboundCodes: { type: array, items: { type: string } }
ReceivingCandidateListApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
@@ -69,6 +69,6 @@ class DropshipOrderControllerContractTest {
return new DropshipRequests.Receive("100", 10L, 20L, "event-1",
List.of(new DropshipRequests.Pallet("PALLET-1",
List.of(new DropshipRequests.SkuPackages("SKU-1", "Product", 1, 2,
List.of("CARTON-1"))))));
List.of("CARTON-1"))), 1, 1, 1, "0".repeat(64))));
}
}
@@ -52,10 +52,10 @@ class DropshipOrderServiceTest {
List.of(
new DropshipRequests.Pallet("SOURCE-P-1", List.of(
new DropshipRequests.SkuPackages("SKU-A", "Product A", 2, 20,
List.of("MARK-1", "MARK-2")))),
List.of("MARK-1", "MARK-2"))), 1, 2, 1, "0".repeat(64)),
new DropshipRequests.Pallet("SOURCE-P-2", List.of(
new DropshipRequests.SkuPackages("SKU-A", "Product A", 1, 10,
List.of("MARK-3"))))));
List.of("MARK-3"))), 1, 1, 1, "1".repeat(64))));
assertThat(service.receive(request)).isEqualTo(3);
verifyNoInteractions(warehouse);
@@ -38,8 +38,9 @@ class ReceivingContainerContractTest {
.map(RecordComponent::getName).collect(Collectors.toSet());
assertTrue(java.util.Collections.disjoint(fields, FORBIDDEN_CALLER_SCOPE), type.getSimpleName());
}
for (Class<?> type : Set.of(ReceivingContainerRequests.Action.class, ReceivingContainerRequests.Sign.class,
ReceivingContainerRequests.BulkReceive.class, ReceivingContainerRequests.CartonReceive.class)) {
for (Class<?> type : Set.of(ReceivingContainerRequests.Action.class, ReceivingContainerRequests.Complete.class,
ReceivingContainerRequests.Sign.class, ReceivingContainerRequests.BulkReceive.class,
ReceivingContainerRequests.CartonReceive.class, ReceivingContainerRequests.WholePalletReceive.class)) {
RecordComponent key = Arrays.stream(type.getRecordComponents())
.filter(component -> "idempotencyKey".equals(component.getName())).findFirst().orElseThrow();
Size size = key.getAccessor().getAnnotation(Size.class);
@@ -67,7 +68,7 @@ class ReceivingContainerContractTest {
@Test
void inventoryCreatingEntryTransactionsAvoidMissingDimensionGapLockDeadlocks() throws NoSuchMethodException {
Transactional pda = ReceivingContainerService.class
.getMethod("complete", String.class, ReceivingContainerRequests.Action.class)
.getMethod("complete", String.class, ReceivingContainerRequests.Complete.class)
.getAnnotation(Transactional.class);
Transactional bridge = LoadingCompletedConsumer.class.getMethod("consume", EventEnvelope.class)
.getAnnotation(Transactional.class);
@@ -103,6 +104,10 @@ class ReceivingContainerContractTest {
assertTrue(unified.contains("alter table ll_container_shipping_gate drop column customer_id"));
assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt"));
assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)"));
assertTrue(schema.contains("create table if not exists ll_receiving_fba_pallet_count"));
assertTrue(schema.contains("unique key uk_ll_receiving_fba_count(session_id,group_key,pallet_code)"));
assertTrue(tableBlock(schema, "ll_inbound_plan_pallet").contains("key idx_ll_inbound_pallet_no (pallet_no)"));
assertFalse(tableBlock(schema, "ll_inbound_plan_pallet").contains("unique key uk_ll_inbound_pallet_no"));
String order = tableBlock(schema, "ll_order");
assertTrue(order.contains("overseas_warehouse_id varchar(128) null"));
assertTrue(order.contains("overseas_warehouse_code varchar(128) null"));
@@ -113,6 +118,7 @@ class ReceivingContainerContractTest {
assertTrue(receivingSession.contains("overseas_warehouse_name varchar(255) not null"));
assertFalse(receivingSession.contains("photo_file_ids json"));
assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt"));
assertTrue(unified.contains("create table if not exists ll_receiving_fba_pallet_count"));
assertTrue(unified.contains("column_name='sign_photos'"));
assertTrue(schema.contains("wait_sign、signed、receiving、completed"));
assertTrue(unified.contains("customer_id is null or l.customer_id=''"));
@@ -120,7 +126,7 @@ class ReceivingContainerContractTest {
assertTrue(unified.contains("customer_id is null or a.customer_id=''"));
assertTrue(unified.contains("signal sqlstate '45000'"));
assertTrue(unified.contains("assert_receiving_global_locks_migratable"));
assertTrue(unified.contains("where status='completed' and (active_operator_key is not null or active_container_key is not null)"));
assertTrue(unified.contains("where status in ('completed','completed_with_difference') and (active_operator_key is not null or active_container_key is not null)"));
assertTrue(unified.contains("concat('operator:',operator_key)"));
assertTrue(unified.contains("concat('container:',upper(trim(container_no)))"));
assertTrue(unified.contains("assert_receiving_operation_global_key_migratable"));
@@ -30,6 +30,7 @@ import org.springframework.mock.web.MockMultipartFile;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -596,6 +597,24 @@ class InboundPdaWorkflowServiceTest {
any(), any(), anyLong());
}
@Test
void completionLoadsSharedFbaPalletOwnedByAnotherCustomerPlan() {
activeTask();
target.setPickedPackageCount(5);
InboundPlanPallet sharedPallet = pallet(40L, "WH-A", 5, "1.0000", 400L);
sharedPallet.setPlanId(99L);
target.setPalletId(sharedPallet.getId());
when(repository.allocations(plan.getId())).thenReturn(List.of(target));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
when(repository.pallets(plan.getId())).thenReturn(List.of());
when(repository.palletsByIds(Set.of(sharedPallet.getId()))).thenReturn(List.of(sharedPallet));
service.complete("100", "LIP1");
verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)),
eq(List.of(target)), eq(List.of(sharedPallet)), any());
}
@Test
void completedTaskReplayDoesNotRunStockInAgain() {
task.setStatus("COMPLETED");
@@ -16,11 +16,17 @@ import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
@@ -58,8 +64,11 @@ class ReceivingContainerServiceBehaviorTest {
@Mock LastlegOrderDetailMapper orderDetails;
@Mock ReceivingContainerSessionMapper sessions;
@Mock ReceivingContainerOperationMapper operations;
@Mock ReceivingFbaPalletCountMapper fbaPalletCounts;
@Mock ReceivingSkuReceiptMapper skuReceipts;
@Mock ContainerShippingGateMapper shippingGates;
@Mock DropshipCartonMapper dropshipCartons;
@Mock DropshipOrderProfileMapper dropshipProfiles;
@Mock WarehouseResourceGateway warehouse;
@Mock UnloadPhotoFileGateway unloadPhotos;
@Mock InboundPdaWorkflowService workflow;
@@ -77,8 +86,11 @@ class ReceivingContainerServiceBehaviorTest {
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "sessions", sessions);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "fbaPalletCounts", fbaPalletCounts);
ReflectionTestUtils.setField(service, "skuReceipts", skuReceipts);
ReflectionTestUtils.setField(service, "shippingGates", shippingGates);
ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons);
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos);
ReflectionTestUtils.setField(service, "workflow", workflow);
@@ -414,11 +426,10 @@ class ReceivingContainerServiceBehaviorTest {
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow);
stubCurrent("FBA", "CONT-A");
stubSku();
when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(101L, "FBA-1"));
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "pallet-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(404));
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@@ -426,7 +437,7 @@ class ReceivingContainerServiceBehaviorTest {
void overReceiptAndDuplicateCartonFailBeforeRemoteReservation() {
stubCurrent("FBA", "CONT-A");
stubSku();
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
stubReservedFbaPallet();
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
@@ -442,6 +453,30 @@ class ReceivingContainerServiceBehaviorTest {
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@Test
void fbaOverReceiptAllocatesOnlyPlannedBalanceAndRecordsPhysicalDifference() {
stubCurrent("FBA", "CONT-A");
stubSku();
stubReservedFbaPallet();
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
.thenAnswer(invocation -> invocation.getArgument(4));
when(fbaPalletCounts.insert(any(ReceivingFbaPalletCount.class))).thenReturn(1);
when(fbaPalletCounts.sumActual(8L, "FBA:CONT-A:FBA-1")).thenReturn(3);
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
var result = service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 3, "over-mark-key"));
assertThat(result.result()).isEqualTo("DIFFERENCE_RECORDED");
assertThat(result.acceptedPackages()).isEqualTo(3);
verify(workflow).receiveBulkResolved(any(), any(), any(), any(),
org.mockito.ArgumentMatchers.eq(2), any(), any());
ArgumentCaptor<ReceivingFbaPalletCount> count = ArgumentCaptor.forClass(ReceivingFbaPalletCount.class);
verify(fbaPalletCounts).insert(count.capture());
assertThat(count.getValue().getActualPackages()).isEqualTo(3);
}
@Test
void overseasPalletUsesActualReceivingWarehouseInsteadOfBusinessDestinationId() {
stubCurrent("OVERSEAS", "CONT-A");
@@ -464,7 +499,7 @@ class ReceivingContainerServiceBehaviorTest {
InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation();
partial.setLineId(10L); partial.setPickedPackageCount(1);
when(allocations.selectList(any())).thenReturn(List.of(partial));
assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key")))
assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Complete(false, "complete-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(workflow, never()).completeContainer(any());
}
@@ -480,7 +515,7 @@ class ReceivingContainerServiceBehaviorTest {
when(allocations.selectList(any())).thenReturn(List.of(received));
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
var completed = service.complete("7", new ReceivingContainerRequests.Action("dropship-complete"));
var completed = service.complete("7", new ReceivingContainerRequests.Complete(false, "dropship-complete"));
assertThat(completed.status()).isEqualTo("COMPLETED");
verifyNoInteractions(skuReceipts);
@@ -491,11 +526,11 @@ class ReceivingContainerServiceBehaviorTest {
void completedRequestReplaysWithoutAnActiveCustomerSession() {
ReceivingContainerOperation operation = new ReceivingContainerOperation();
operation.setOperatorKey("7"); operation.setAction("COMPLETE"); operation.setIdempotencyKey("complete-replay");
operation.setRequestHash(hash(new ReceivingContainerRequests.Action("complete-replay")));
operation.setRequestHash(hash(new ReceivingContainerRequests.Complete(false, "complete-replay")));
operation.setResultJson("{\"containerNo\":\"CONT-A\",\"status\":\"COMPLETED\",\"operatorName\":\"receiver\",\"version\":1,\"photos\":[],\"totalPackages\":2,\"receivedPackages\":2,\"pendingPackages\":0,\"inbounds\":[]}");
when(operations.find("7", "complete-replay")).thenReturn(operation);
assertThat(service.complete("7", new ReceivingContainerRequests.Action("complete-replay")).status())
assertThat(service.complete("7", new ReceivingContainerRequests.Complete(false, "complete-replay")).status())
.isEqualTo("COMPLETED");
verifyNoInteractions(sessions);
}
@@ -504,8 +539,7 @@ class ReceivingContainerServiceBehaviorTest {
void localFailureAfterRemoteReservationTriggersReleaseCompensation() {
stubCurrent("FBA", "CONT-A");
stubSku();
when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
stubReservedFbaPallet();
when(workflow.assignResolvedPallet(any(), any(), any(), any())).thenReturn(55L);
ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race");
doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any());
@@ -675,9 +709,10 @@ class ReceivingContainerServiceBehaviorTest {
}
@Test
void bulkRejectsAggregateSkuOverReceiptBeforeWarehouseLookup() {
void bulkRejectsInconsistentFrozenSkuProgressBeforeAnyReceiptWrite() {
stubCurrent("FBA", "CONT-A");
stubSku();
stubReservedFbaPallet();
com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt received =
new com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt();
received.setReceivedPackages(2);
@@ -686,7 +721,7 @@ class ReceivingContainerServiceBehaviorTest {
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "sku-over")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@Test
@@ -707,7 +742,7 @@ class ReceivingContainerServiceBehaviorTest {
}
@Test
void cartonResolutionReturnsAssignmentOptionalMergeAndUrgencyFromInboundPlan() {
void fbaGroupResolutionKeepsUrgencyWithoutPerPlanMergeChoices() {
stubCurrent("FBA", "CONT-A");
InboundPlan flagged = plan("CONT-A");
flagged.setAssignedFlag(true);
@@ -719,9 +754,9 @@ class ReceivingContainerServiceBehaviorTest {
assertThat(resolved.inboundCode()).isEqualTo("IN-1");
assertThat(resolved.assigned()).isTrue();
assertThat(resolved.mergeAllowed()).isTrue();
assertThat(resolved.mergeAllowed()).isFalse();
assertThat(resolved.urgent()).isTrue();
assertThat(resolved.mergeHint()).contains("可选择", "同目的仓", "新托盘码", "不强制");
assertThat(resolved.mergeHint()).isNull();
}
@Test
@@ -788,8 +823,7 @@ class ReceivingContainerServiceBehaviorTest {
when(orders.selectById(2L)).thenReturn(secondOrder);
when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-A", 1), detail("SKU-B", 1)),
List.of(detail("SKU-C", 2)));
when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
stubReservedFbaPallet();
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
.thenAnswer(invocation -> invocation.getArgument(4));
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
@@ -813,22 +847,27 @@ class ReceivingContainerServiceBehaviorTest {
}
@Test
void overseasDropshipResolvesOnlyFrozenCartonsAndNeverCreatesLabelsWhileResolving() {
void dropshipCartonResolvesItsCompleteDpInsteadOfAPlanOrCustomer() {
stubCurrent("OVERSEAS", "CONT-A");
LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectById(1L)).thenReturn(dropship);
InboundPlanCarton frozen = carton(1L, "IN-1#2-1");
frozen.setCustomerId("100"); frozen.setSourceInboundDetailId("d-1"); frozen.setSkuCode("SKU-1");
when(cartons.selectOne(any())).thenReturn(frozen);
InboundPlanPallet pallet = planPallet(); pallet.setSourcePalletNo("DP-260909-0001");
pallet.setWarehousePalletId(30L);
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation();
allocation.setId(30L); allocation.setPlanId(2L); allocation.setLineId(10L);
allocation.setPalletId(20L); allocation.setPlannedPackageCount(2); allocation.setPickedPackageCount(0);
when(dropshipCartons.sourcePalletsInContainer("CONT-A", "IN-1#2-1"))
.thenReturn(List.of("DP-260909-0001"));
when(pallets.selectList(any())).thenReturn(List.of(pallet));
when(allocations.selectList(any())).thenReturn(List.of(allocation));
stubCompleteManifest("DP-260909-0001", 2);
var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-1"));
assertThat(resolved.inboundCode()).isEqualTo("IN-1");
assertThat(resolved.destinationType()).isEqualTo("OVERSEAS");
assertThat(resolved.operationMode()).isEqualTo("DROPSHIP_PALLET");
assertThat(resolved.sourcePalletNo()).isEqualTo("DP-260909-0001");
assertThat(resolved.totalPackages()).isEqualTo(2);
verify(cartons, never()).insert(any(InboundPlanCarton.class));
verifyNoInteractions(warehouse);
when(cartons.selectOne(any())).thenReturn(null);
assertThatThrownBy(() -> service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-9")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("404");
verify(cartons, never()).insert(any(InboundPlanCarton.class));
}
@Test
@@ -837,7 +876,8 @@ class ReceivingContainerServiceBehaviorTest {
LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectById(1L)).thenReturn(dropship);
InboundPlanPallet pallet = planPallet();
pallet.setPalletNo("PAL-PHYSICAL-1"); pallet.setSourcePalletNo("DP-260909-0001");
pallet.setPalletNo("DP-260909-0001"); pallet.setSourcePalletNo("DP-260909-0001");
pallet.setWarehousePalletId(30L);
InboundPlanPalletAllocation pending = new InboundPlanPalletAllocation();
pending.setId(30L); pending.setCustomerId("100"); pending.setPlanId(2L);
pending.setLineId(10L); pending.setPalletId(20L); pending.setPlannedPackageCount(2);
@@ -847,6 +887,7 @@ class ReceivingContainerServiceBehaviorTest {
received.setPickedPackageCount(2);
when(pallets.selectList(any())).thenReturn(List.of(pallet));
when(allocations.selectList(any())).thenReturn(List.of(pending), List.of(received));
stubCompleteManifest("DP-260909-0001", 2);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
@@ -856,7 +897,7 @@ class ReceivingContainerServiceBehaviorTest {
assertThat(result.acceptedPackages()).isEqualTo(2);
assertThat(result.receivedPackages()).isEqualTo(2);
assertThat(result.pendingPackages()).isZero();
assertThat(result.palletCode()).isEqualTo("PAL-PHYSICAL-1");
assertThat(result.palletCode()).isEqualTo("DP-260909-0001");
verify(workflow).pickWholePallet(org.mockito.ArgumentMatchers.eq("100"),
org.mockito.ArgumentMatchers.eq("PLAN-1"), org.mockito.ArgumentMatchers.eq(30L),
any(com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests.WholePalletOperation.class));
@@ -940,6 +981,28 @@ class ReceivingContainerServiceBehaviorTest {
return value;
}
private InboundPlanPallet stubReservedFbaPallet() {
InboundPlanPallet value = planPallet();
value.setPalletNo("PAL-1"); value.setWarehousePalletId(30L); value.setWarehouseLocationId(40L);
value.setWarehouseResourceLockId(50L); value.setAssignmentStatus("RESERVED"); value.setVersion(0);
when(pallets.selectList(any())).thenReturn(List.of(value));
when(warehouse.resolveReservedPallet("PAL-1", "100", 100L, 30L, 40L, 50L))
.thenReturn(new WarehouseResourceGateway.WarehousePalletReference(
30L, "PAL-1", 100L, 40L, "OCCUPIED", "FBA-1", 0));
return value;
}
private void stubCompleteManifest(String sourcePalletNo, int packages) {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(60L); profile.setCustomerId("100"); profile.setSourcePalletNo(sourcePalletNo);
profile.setManifestVersion(1); profile.setManifestPackageCount(packages);
profile.setManifestFragmentCount(1); profile.setManifestHash("manifest-hash");
when(dropshipProfiles.listContainerPallet("CONT-A", sourcePalletNo)).thenReturn(List.of(profile));
List<DropshipCarton> values = new java.util.ArrayList<>();
for (int index = 0; index < packages; index++) values.add(new DropshipCarton());
when(dropshipCartons.listProfile("100", 60L)).thenReturn(values);
}
private static WarehouseResourceGateway.WarehousePalletReference resource(long warehouseId, String fbaCode) {
return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L,
"AVAILABLE", fbaCode, 0);
@@ -304,7 +304,7 @@ class InventoryCommandServiceTest {
position.setPickedPackages(5L);
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
when(commands.palletPackages("100", 300L)).thenReturn(0L);
when(commands.palletPackages(300L)).thenReturn(0L);
var result = service.ship("100", "7", new InventoryCommandRequests.Move(
201L, 5, "OUT-1", "OUT-1", null, "ship-1", null));
@@ -32,7 +32,7 @@ class Phase5OverseasUpgradeSqlContractTest {
assertThat(overseas.indexOf(table)).as(table + " first reference")
.isEqualTo(overseas.indexOf(create) + "CREATE TABLE IF NOT EXISTS ".length());
}
assertThat(overseas).contains("<> 24", "五期完整业务表集合未部署");
assertThat(overseas).contains("<> 23", "五期完整业务表集合未部署");
}
@Test
@@ -26,7 +26,7 @@ class LoadingCompletedConsumerTest {
assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id"));
}
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void freezesReceivingWarehouseInInitialInTransitInventory(){
service.consume(event());
ArgumentCaptor<InventoryCommandRequests.InTransit> captured=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class);
@@ -160,6 +160,7 @@ class LoadingCompletedConsumerTest {
secondSku.put("sourceInboundDetailId","d-2");
var marks=secondSku.putArray("cartonMarks"); for(int n=6;n<=10;n++)marks.add("RK1#10-"+n);
skus.add(secondSku);
addManifest(order.path("pallets").get(0),10);
service.consume(rehash(original,node));
var captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class);
verify(cartons,times(10)).insert(captured.capture());
@@ -193,7 +194,7 @@ class LoadingCompletedConsumerTest {
sourceOrder.put("orderType","ONE_CLICK_DROPSHIP").put("destinationType","OVERSEAS");
var sku=json.createObjectNode().put("sourceInboundDetailId","d-1").put("skuCode","SKU-1").put("productName","产品").put("packageCount",5).put("productQuantity",50);
var marks=sku.putArray("cartonMarks"); for(int n=1;n<=5;n++)marks.add("RK1#5-"+n);
var pallet=json.createObjectNode().put("palletNo","DP-260909-0143"); pallet.putArray("skuPackages").add(sku);
var pallet=json.createObjectNode().put("palletNo","DP-260909-0143"); pallet.putArray("skuPackages").add(sku); addManifest(pallet,5);
sourceOrder.putArray("pallets").add(pallet);
if(duplicateSku) ((com.fasterxml.jackson.databind.node.ArrayNode)sourceOrder.path("items")).add(sourceOrder.path("items").get(0).deepCopy());
node.putArray("orders").add(sourceOrder);
@@ -202,5 +203,6 @@ class LoadingCompletedConsumerTest {
}
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
private JsonNode v2Payload(EventEnvelope source){ com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); node.set("sourceContainerBookingId",node.remove("containerBookingId")); return node; }
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); for(JsonNode order:node.path("orders")) for(JsonNode pallet:order.path("pallets")) addManifest(pallet,order.path("packageCount").asInt()); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
private void addManifest(JsonNode pallet,int packageCount){ var object=(com.fasterxml.jackson.databind.node.ObjectNode)pallet; object.put("manifestVersion",1).put("manifestPackageCount",packageCount).put("manifestFragmentCount",1).put("manifestHash","a".repeat(64)); }
}