feat: complete finance and overseas receiving workflow
This commit is contained in:
@@ -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) {}
|
||||
|
||||
+4
@@ -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;
|
||||
|
||||
+8
@@ -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'")
|
||||
|
||||
+8
@@ -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>" +
|
||||
|
||||
+15
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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));
|
||||
}
|
||||
|
||||
+9
-1
@@ -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,
|
||||
|
||||
+47
-7
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -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;
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+6
@@ -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));
|
||||
|
||||
+65
-2
@@ -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); }
|
||||
|
||||
+318
-55
@@ -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());
|
||||
|
||||
+18
-5
@@ -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
|
||||
|
||||
+3
-1
@@ -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()));
|
||||
|
||||
+2
-2
@@ -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} " +
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+31
-5
@@ -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' }
|
||||
|
||||
+1
-1
@@ -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))));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+10
-4
@@ -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"));
|
||||
|
||||
+19
@@ -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");
|
||||
|
||||
+91
-28
@@ -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);
|
||||
|
||||
+1
-1
@@ -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));
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+5
-3
@@ -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)); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user