feat: complete finance and overseas receiving workflow

This commit is contained in:
2026-09-10 22:43:11 +08:00
parent e531ae9407
commit 56635ad4e4
66 changed files with 1601 additions and 417 deletions
@@ -8,7 +8,11 @@ public final class DropshipRequests {
private DropshipRequests() {}
public record Receive(@NotBlank String customerId, @NotNull Long orderId, @NotNull Long warehouseId,
@NotBlank String idempotencyKey, @NotEmpty List<@Valid Pallet> pallets) {}
public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages) {}
public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages,
@NotNull @Positive Integer manifestVersion,
@NotNull @Positive Integer manifestPackageCount,
@NotNull @Positive Integer manifestFragmentCount,
@NotBlank @Size(max = 64) String manifestHash) {}
public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount,
@Positive long productQuantity,
@NotEmpty List<@NotBlank String> cartonMarks) {}
@@ -13,6 +13,10 @@ public class DropshipOrderProfile {
private String palletNo;
/** Immutable pallet number snapshot supplied by the first-leg handover event. */
private String sourcePalletNo;
private Integer manifestVersion;
private Integer manifestPackageCount;
private Integer manifestFragmentCount;
private String manifestHash;
private Long inventoryPositionId;
private String status;
private String idempotencyKey;
@@ -17,6 +17,14 @@ public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> {
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence")
List<DropshipCarton> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("""
SELECT DISTINCT p.source_pallet_no FROM ll_dropship_carton c
JOIN ll_dropship_order_profile p ON p.id=c.profile_id AND p.customer_id=c.customer_id
JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id
WHERE o.container_no=#{containerNo} AND c.carton_mark=#{cartonMark}
""")
List<String> sourcePalletsInContainer(@Param("containerNo") String containerNo,
@Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND status='AVAILABLE' ORDER BY carton_sequence LIMIT #{packages} FOR UPDATE")
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages);
@Update("UPDATE ll_dropship_carton SET status='AVAILABLE', outbound_order_no=NULL, updated_at=NOW(3) WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} AND status='SHIPPED'")
@@ -39,6 +39,14 @@ public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProf
List<DropshipOrderProfile> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} LIMIT 1")
DropshipOrderProfile findPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Select("""
SELECT p.* FROM ll_dropship_order_profile p
JOIN ll_order o ON o.id=p.order_id AND o.customer_id=p.customer_id
WHERE o.container_no=#{containerNo} AND p.source_pallet_no=#{sourcePalletNo}
ORDER BY p.customer_id,p.order_id,p.id
""")
List<DropshipOrderProfile> listContainerPallet(@Param("containerNo") String containerNo,
@Param("sourcePalletNo") String sourcePalletNo);
@Select("<script>" + OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE +
"<if test='warehouseId != null and warehouseId != \"\"'>AND pos.overseas_warehouse_id=#{warehouseId} </if>" +
@@ -51,6 +51,17 @@ public class DropshipOrderService {
Set<String> requestedPallets = request.pallets().stream().map(value -> value.palletNo().trim())
.collect(java.util.stream.Collectors.toSet());
if (!existingPallets.equals(requestedPallets)) throw conflict("Dropship order pallet snapshot conflicts with existing data");
Map<String, DropshipOrderProfile> byPallet = existing.stream().collect(Collectors.toMap(
DropshipOrderProfile::getSourcePalletNo, value -> value));
for (DropshipRequests.Pallet requested : request.pallets()) {
DropshipOrderProfile stored = byPallet.get(requested.palletNo().trim());
if (!java.util.Objects.equals(stored.getManifestVersion(), requested.manifestVersion())
|| !java.util.Objects.equals(stored.getManifestPackageCount(), requested.manifestPackageCount())
|| !java.util.Objects.equals(stored.getManifestFragmentCount(), requested.manifestFragmentCount())
|| !java.util.Objects.equals(stored.getManifestHash(), requested.manifestHash())) {
throw conflict("Dropship pallet manifest conflicts with existing data: " + requested.palletNo());
}
}
return Math.toIntExact(totalPackages);
}
@@ -104,6 +115,10 @@ public class DropshipOrderService {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(IdWorker.getId()); profile.setCustomerId(request.customerId()); profile.setOrderId(order.getId());
profile.setSourcePalletNo(input.palletNo().trim());
profile.setManifestVersion(input.manifestVersion());
profile.setManifestPackageCount(input.manifestPackageCount());
profile.setManifestFragmentCount(input.manifestFragmentCount());
profile.setManifestHash(input.manifestHash().trim().toLowerCase(java.util.Locale.ROOT));
profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L);
profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim());
profile.setCreatedAt(now); profile.setUpdatedAt(now);
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Data;
import java.math.BigDecimal;
@@ -31,4 +32,6 @@ public class InboundPlanPallet {
private LocalDateTime lockedAt;
private BigDecimal actualVolumeCbm;
private Integer actualPackageCount;
@Version
private Integer version;
}
@@ -144,7 +144,7 @@ public class ReceivingContainerController {
@PostMapping("/current/complete")
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Action request) {
@Valid @RequestBody ReceivingContainerRequests.Complete request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.complete(identity.operatorKey(), request));
}
@@ -13,14 +13,22 @@ public final class ReceivingContainerRequests {
private ReceivingContainerRequests() { }
public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Complete(boolean allowDifference,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record SignedPhoto(@NotNull ReceivingPhotoRole role,
@NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { }
public record CartonResolve(@NotBlank String cartonMark) { }
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
/** packageCount 是该托当前累计实收箱数,不是本次增量。 */
public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode,
@Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { }
@Positive int packageCount, Integer expectedPalletVersion,
@NotBlank @Size(max = 255) String idempotencyKey) {
public BulkReceive(String inboundCode, String palletCode, int packageCount, String idempotencyKey) {
this(inboundCode, palletCode, packageCount, null, idempotencyKey);
}
}
public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record WholePalletReceive(@NotBlank String sourcePalletNo,
@@ -35,7 +35,7 @@ public final class ReceivingContainerResponses {
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt,
int totalPackages, int receivedPackages, int pendingPackages,
List<Inbound> inbounds) {
boolean hasDifference, List<Inbound> inbounds) {
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
String status, String operatorName, int version, List<SignedPhoto> photos,
LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt,
@@ -43,7 +43,17 @@ public final class ReceivingContainerResponses {
int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
version, photos, preparedAt, signedAt, receivingAt, completedAt, totalPackages,
receivedPackages, pendingPackages, inbounds);
receivedPackages, pendingPackages, false, inbounds);
}
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName, String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages,
int receivedPackages, int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
status, operatorName, version, photos, preparedAt, signedAt, receivingAt,
completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds);
}
}
@@ -65,7 +75,20 @@ public final class ReceivingContainerResponses {
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages,
boolean assigned, boolean mergeAllowed, boolean urgent,
String mergeHint) {
String mergeHint, String operationMode, String groupKey,
List<String> inboundCodes, String sourcePalletNo) {
public CartonResolved(String cartonMark, String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode,
int plannedPalletCount, int totalPackages, int receivedPackages,
int pendingPackages, boolean assigned, boolean mergeAllowed,
boolean urgent, String mergeHint) {
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages, pendingPackages,
assigned, mergeAllowed, urgent, mergeHint, null, null, List.of(inboundCode), null);
}
public CartonResolved(String cartonMark, String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId, String destinationWarehouseCode,
@@ -75,24 +98,41 @@ public final class ReceivingContainerResponses {
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages,
pendingPackages, false, false, false, null);
pendingPackages, false, false, false, null, null, null, List.of(inboundCode), null);
}
}
public record PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode,
String destinationType, boolean compatible) { }
String destinationType, boolean compatible, int version,
int currentReceivedPackages) {
public PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode,
String destinationType, boolean compatible) {
this(palletCode, warehouseId, status, fbaWarehouseCode, inboundCode,
destinationType, compatible, 0, 0);
}
}
public record Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus,
boolean assigned, boolean mergeAllowed, boolean urgent,
String mergeHint) {
String mergeHint, String groupKey, List<String> inboundCodes) {
public Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus,
boolean assigned, boolean mergeAllowed, boolean urgent, String mergeHint) {
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
receivedPackages, pendingPackages, sessionStatus, assigned, mergeAllowed, urgent,
mergeHint, null, List.of(inboundCode));
}
public Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus) {
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
receivedPackages, pendingPackages, sessionStatus, false, false, false, null);
receivedPackages, pendingPackages, sessionStatus, false, false, false, null,
null, List.of(inboundCode));
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.inboundplan.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_receiving_fba_pallet_count")
public class ReceivingFbaPalletCount {
@TableId(type = IdType.INPUT)
private Long id;
private Long sessionId;
private String groupKey;
private String palletCode;
private Integer actualPackages;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReceivingFbaPalletCountMapper extends BaseMapper<ReceivingFbaPalletCount> {
@Select("SELECT * FROM ll_receiving_fba_pallet_count WHERE session_id=#{sessionId} " +
"AND group_key=#{groupKey} AND pallet_code=#{palletCode} LIMIT 1")
ReceivingFbaPalletCount find(@Param("sessionId") long sessionId, @Param("groupKey") String groupKey,
@Param("palletCode") String palletCode);
@Select("SELECT COALESCE(SUM(actual_packages),0) FROM ll_receiving_fba_pallet_count " +
"WHERE session_id=#{sessionId} AND group_key=#{groupKey}")
int sumActual(@Param("sessionId") long sessionId, @Param("groupKey") String groupKey);
}
@@ -23,6 +23,7 @@ import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
import java.util.List;
import java.util.Set;
@Repository
public class PdaWorkflowRepository {
@@ -91,6 +92,11 @@ public class PdaWorkflowRepository {
return pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>().eq(InboundPlanPallet::getPlanId, planId));
}
/** FBA destination groups may allocate several customers onto one logical pallet owned by another plan. */
public List<InboundPlanPallet> palletsByIds(Set<Long> palletIds) {
return palletIds == null || palletIds.isEmpty() ? List.of() : pallets.selectBatchIds(palletIds);
}
public List<InboundPlanPalletAllocation> allocations(Long planId) {
return allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, planId));
@@ -45,6 +45,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class InboundPdaWorkflowService {
@@ -578,6 +579,58 @@ public class InboundPdaWorkflowService {
return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList();
}
/**
* 现场允许带差异结束:核平计划正常入库;差异计划只冻结为待核平,继续占用实物托盘和库位,
* 不生成客户可用库存。柜级卸货事实仍只发布一次。
*/
@Transactional
public void completeContainerWithDifferences(List<InboundPlan> containerPlans,
List<InboundPlan> reconciledPlans) {
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
Set<Long> reconciledIds = reconciledPlans.stream().map(InboundPlan::getId).collect(java.util.stream.Collectors.toSet());
LocalDateTime now = LocalDateTime.now();
List<InboundPdaTask> allTasks = new ArrayList<>();
for (InboundPlan plan : containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList()) {
InboundPdaTask task = repository.lockTask(plan.getCustomerId(), plan.getId());
if (task == null || unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null) {
throw conflict("Every plan must have the signed unload evidence before onsite completion");
}
if (reconciledIds.contains(plan.getId())) {
CompletionContext context = completionContext(plan.getCustomerId(), plan);
completePlan(plan.getCustomerId(), context, now);
allTasks.add(context.task());
} else {
task.setStatus("PENDING_RECONCILIATION"); task.setCompletedAt(now); task.setUpdatedAt(now);
task.setVersion(value(task.getVersion()) + 1); repository.update(task);
plan.setStatus("PENDING_RECONCILIATION"); plan.setUpdatedAt(now); repository.update(plan);
allTasks.add(task);
}
}
String containerNo = containerPlans.get(0).getContainerNo();
if (containerPlans.stream().anyMatch(plan -> !Objects.equals(containerNo, plan.getContainerNo()))) {
throw conflict("Inbound plans must belong to one physical container");
}
String sourceContainerBookingId = sourceContainerBookingIdForPlans(containerPlans);
LocalDateTime photoUploadedAt = allTasks.stream().map(InboundPdaTask::getUnloadPhotoUploadedAt)
.max(LocalDateTime::compareTo).orElseThrow();
long aggregateVersion = allTasks.stream().map(InboundPdaTask::getVersion).filter(Objects::nonNull)
.mapToLong(Integer::longValue).max().orElse(1L);
unloadCompletedOutbox.publishContainerCompleted(sourceContainerBookingId, containerNo,
now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion);
}
private String sourceContainerBookingIdForPlans(List<InboundPlan> plans) {
List<String> values = plans.stream().map(plan -> {
LastlegOrder order = orders.selectById(plan.getSourceOrderId());
if (order == null || order.getSourceContainerBookingId() == null || order.getSourceContainerBookingId().isBlank()) {
throw new IllegalStateException("Inbound plan has no source container booking ID");
}
return order.getSourceContainerBookingId();
}).distinct().toList();
if (values.size() != 1) throw conflict("Container plans reference different source container bookings");
return values.get(0);
}
private String sourceContainerBookingId(List<CompletionContext> contexts) {
List<String> bookingIds = contexts.stream().map(context -> {
LastlegOrder order = orders.selectById(context.plan().getSourceOrderId());
@@ -628,8 +681,15 @@ public class InboundPdaWorkflowService {
private void completePlan(String customerId, CompletionContext context, LocalDateTime now) {
InboundPlan plan = context.plan();
InboundPdaTask task = context.task();
List<InboundPlanPallet> receiptPallets = new ArrayList<>(repository.pallets(plan.getId()));
Set<Long> presentPalletIds = receiptPallets.stream().map(InboundPlanPallet::getId)
.collect(java.util.stream.Collectors.toSet());
Set<Long> sharedPalletIds = context.allocations().stream().map(InboundPlanPalletAllocation::getPalletId)
.filter(Objects::nonNull).filter(id -> !presentPalletIds.contains(id))
.collect(java.util.stream.Collectors.toSet());
receiptPallets.addAll(repository.palletsByIds(sharedPalletIds));
stockReceipts.stockIn(customerId, plan, task, context.lines(), context.allocations(),
repository.pallets(plan.getId()), now);
receiptPallets, now);
for (InboundPlanCarton carton : context.cartons()) {
carton.setStatus("ON_PALLET".equals(carton.getStatus()) ? "AVAILABLE" : "BULK_RECEIVED");
carton.setReceivedAt(now); carton.setUpdatedAt(now);
@@ -1164,7 +1224,10 @@ public class InboundPdaWorkflowService {
List<InboundPlanCarton> cartons) { }
private static int value(Integer value) { return value == null ? 0 : value; }
private static boolean terminal(String status) { return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status); }
private static boolean terminal(String status) {
return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status)
|| "PENDING_RECONCILIATION".equals(status);
}
private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; }
private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -20,15 +20,20 @@ import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.MapperFeature;
@@ -74,8 +79,11 @@ public class ReceivingContainerService {
@Resource private LastlegOrderDetailMapper orderDetails;
@Resource private ReceivingContainerSessionMapper sessions;
@Resource private ReceivingContainerOperationMapper operations;
@Resource private ReceivingFbaPalletCountMapper fbaPalletCounts;
@Resource private ReceivingSkuReceiptMapper skuReceipts;
@Resource private ContainerShippingGateMapper shippingGates;
@Resource private DropshipCartonMapper dropshipCartons;
@Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private WarehouseResourceGateway warehouse;
@Resource private UnloadPhotoFileGateway unloadPhotos;
@Resource private InboundPdaWorkflowService workflow;
@@ -246,13 +254,34 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
ReceivingContainerRequests.CartonResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
List<InboundRef> currentRefs = refs(session.getContainerNo());
String sourcePalletNo = dropshipSourcePallet(currentRefs, session.getContainerNo(), request.cartonMark());
if (sourcePalletNo != null) {
List<DropshipPalletRef> palletRefs = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo);
requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
List<InboundRef> group = palletRefs.stream().map(DropshipPalletRef::ref).distinct().toList();
InboundRef first = group.get(0);
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
int total = palletRefs.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
int received = palletRefs.stream().mapToInt(value -> value(value.allocation().getPickedPackageCount())).sum();
ReceivingContainerResponses.Inbound inbound = inbound(first);
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(),
inboundCodes.get(0), inbound.destinationType(), inbound.overseasWarehouseId(),
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(),
inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), 1, total, received,
Math.max(0, total-received), true, false, urgent(first.plan()), null,
"DROPSHIP_PALLET", "DP:" + sourcePalletNo, inboundCodes, sourcePalletNo);
}
String inboundCode = cartonInboundCode(request.cartonMark());
List<InboundRef> matches = refs(session.getContainerNo()).stream()
List<InboundRef> matches = currentRefs.stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
CartonRef carton;
if (matches.size() > 1) {
throw conflict("Carton mark inbound code is ambiguous in the current container");
} else if (matches.size() == 1 && "FBA".equals(destinationType(matches.get(0).line(), matches.get(0).order()))) {
if (request.cartonMark().trim().equals(inboundCode)) {
throw conflict("Scan one FBA carton code, not the inbound code");
}
carton = new CartonRef(matches.get(0));
} else if (matches.size() == 1) {
carton = requireCartonRef(matches.get(0).order().getCustomerId(), session.getContainerNo(), request.cartonMark());
@@ -260,6 +289,22 @@ public class ReceivingContainerService {
carton = requireUnmatchedFbaCartonPrefix(inboundCode);
}
InboundRef ref = carton.ref();
if ("FBA".equals(destinationType(ref.line(), ref.order()))
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
List<InboundRef> group = requireFbaPlanRefs(session.getContainerNo(), ref);
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
int total = group.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
int received = group.stream().mapToInt(this::receivedLinePackages).sum();
ReceivingContainerResponses.Inbound inbound = inbound(ref);
int palletCount = (int) group.stream().flatMap(value -> pallets.selectList(
new LambdaQueryWrapper<InboundPlanPallet>().eq(InboundPlanPallet::getPlanId, value.plan().getId())).stream())
.map(InboundPlanPallet::getWarehousePalletId).filter(Objects::nonNull).distinct().count();
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(), inboundCodes.get(0),
"FBA", inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
palletCount, total, received, Math.max(0,total-received), true, false, urgent(ref.plan()), null,
"FBA_GROUP", fbaGroupKey(session.getContainerNo(), ref), inboundCodes, null);
}
ReceivingContainerResponses.Inbound inbound = inbound(ref);
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(),
inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(),
@@ -273,10 +318,26 @@ public class ReceivingContainerService {
ReceivingContainerRequests.PalletResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), false);
List<InboundRef> group = "FBA".equals(destinationType(ref.line(), ref.order()))
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())
? requireFbaPlanRefs(session.getContainerNo(), ref) : List.of(ref);
PalletSelection selection = group.size() == 1 && !"FBA".equals(destinationType(ref.line(), ref.order()))
? resolveReceivingPallet(ref, request.palletCode(), false) : resolveFbaGroupPallet(group, request.palletCode());
WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
int received = selection.pallet() == null ? 0 : group.stream().mapToInt(value -> allocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, value.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, value.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, selection.pallet().getId())).stream()
.mapToInt(item -> value(item.getPickedPackageCount())).sum()).sum();
if (selection.pallet() != null && "FBA".equals(destinationType(ref.line(), ref.order()))) {
ReceivingFbaPalletCount physical = fbaPalletCounts.find(session.getId(),
fbaGroupKey(session.getContainerNo(), ref), pallet.palletNo());
if (physical != null) received = value(physical.getActualPackages());
}
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true);
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true,
selection.pallet() == null ? 0 : value(selection.pallet().getVersion()), received);
}
@Transactional
@@ -286,7 +347,6 @@ public class ReceivingContainerService {
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
String customerId = ref.order().getCustomerId();
if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
throw conflict("One-click dropship inbound must be operated by complete source pallet");
}
@@ -294,31 +354,70 @@ public class ReceivingContainerService {
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
}
List<InboundRef> fbaPlanRefs = requireFbaPlanRefs(session.getContainerNo(), ref);
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount());
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(),
allocation.ref().line(), allocation.packages()));
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true);
PalletSelection selection = resolveFbaGroupPallet(fbaPlanRefs, request.palletCode());
WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
InboundPlanPallet pallet = selection.pallet();
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
if (request.expectedPalletVersion() != null && !Objects.equals(request.expectedPalletVersion(), pallet.getVersion())) {
throw conflict("Pallet receiving version changed; refresh the group before saving");
}
int allocatedOnPallet = fbaPlanRefs.stream().mapToInt(value -> allocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, value.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, value.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId())).stream()
.mapToInt(item -> value(item.getPickedPackageCount())).sum()).sum();
String groupKey = fbaGroupKey(session.getContainerNo(), ref);
ReceivingFbaPalletCount physicalCount = fbaPalletCounts.find(session.getId(), groupKey, resource.palletNo());
int previous = physicalCount == null ? allocatedOnPallet : value(physicalCount.getActualPackages());
if (request.packageCount() < previous) {
throw conflict("Cumulative pallet quantity cannot be reduced after saving; use correction operation");
}
int physicalDelta = request.packageCount() - previous;
List<String> inboundCodes = fbaPlanRefs.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
if (physicalDelta == 0) {
int total = fbaPlanRefs.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("ALREADY_RECEIVED",
session.getContainerNo(), inboundCodes.get(0), "FBA", resource.palletNo(), 0,
fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum(),
Math.max(0,total-fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum()), session.getStatus(),
true, mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()), groupKey, inboundCodes);
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result;
}
int groupTotal = fbaPlanRefs.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
int groupReceived = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum();
int allocatable = Math.min(physicalDelta, Math.max(0, groupTotal - groupReceived));
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(fbaPlanRefs, allocatable);
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(allocation.ref().order().getCustomerId(), allocation.ref().plan(),
allocation.ref().line(), allocation.packages()));
String palletCustomerId = pallet.getCustomerId();
InboundRef palletOwner = fbaPlanRefs.stream().filter(value -> Objects.equals(value.plan().getId(), pallet.getPlanId()))
.findFirst().orElseThrow(() -> conflict("FBA pallet does not belong to the current destination group"));
Long resourceLockId = allocatable == 0 ? null
: workflow.assignResolvedPallet(palletCustomerId, palletOwner.plan(), pallet, resource);
try {
int accepted = 0;
for (FbaSkuAllocation allocation : skuAllocations) {
accepted += workflow.receiveBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), pallet,
String allocationCustomerId = allocation.ref().order().getCustomerId();
accepted += workflow.receiveBulkResolved(allocationCustomerId, allocation.ref().plan(), allocation.ref().line(), pallet,
allocation.packages(), operatorKey, operatorName);
recordSkuReceipt(session, allocation, pallet);
}
savePhysicalFbaCount(session.getId(), groupKey, resource.palletNo(), request.packageCount(), physicalCount);
markReceiving(session);
int received = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum();
int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum();
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted,
boolean overReceived = fbaPalletCounts.sumActual(session.getId(), groupKey) > total;
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt(
overReceived ? "DIFFERENCE_RECORDED" : "RECEIVED",
session.getContainerNo(), inboundCodes.get(0), "FBA", resource.palletNo(), physicalDelta,
received, Math.max(0, total - received), session.getStatus(), assigned(ref.plan()),
mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()));
mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()),
groupKey, inboundCodes);
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
workflow.compensateReservation(customerId, resourceLockId, failure);
workflow.compensateReservation(palletCustomerId, resourceLockId, failure);
throw failure;
}
}
@@ -370,59 +469,89 @@ public class ReceivingContainerService {
ReceivingContainerOperation replay = replay(operatorKey, "PALLET_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
List<DropshipPalletRef> matches = new ArrayList<>();
for (InboundRef ref : refs(session.getContainerNo())) {
if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
String customerId = ref.order().getCustomerId();
List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, customerId)
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
.eq(InboundPlanPallet::getSourcePalletNo, request.sourcePalletNo()));
for (InboundPlanPallet pallet : sourcePallets) {
List<InboundPlanPalletAllocation> palletAllocations = allocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getCustomerId, customerId)
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId()));
palletAllocations.forEach(allocation -> matches.add(new DropshipPalletRef(ref, pallet, allocation)));
String sourcePalletNo = dropshipSourcePallet(session.getContainerNo(), request.sourcePalletNo());
if (sourcePalletNo == null) throw notFound("DP or carton is not part of the current container");
requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
List<DropshipPalletRef> matches = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo);
boolean alreadyReceived = matches.stream().allMatch(match ->
value(match.allocation().getPickedPackageCount()) == value(match.allocation().getPlannedPackageCount()));
int accepted = 0;
if (!alreadyReceived) {
if (matches.stream().anyMatch(match -> value(match.allocation().getPickedPackageCount()) != 0)) {
throw conflict("DP pallet is only partially received; reconcile it before retrying");
}
for (DropshipPalletRef match : matches) {
int packages = value(match.allocation().getPlannedPackageCount());
workflow.pickWholePallet(match.ref().order().getCustomerId(), match.ref().plan().getPlanNo(),
match.allocation().getId(), new PdaRequests.WholePalletOperation(
request.idempotencyKey() + ":PICK:" + match.allocation().getId(), operatorKey, operatorName));
accepted += packages;
match.allocation().setPickedPackageCount(packages);
}
}
if (matches.isEmpty()) throw notFound("Source pallet is not part of the current container");
if (matches.size() != 1) throw conflict("Source pallet is duplicated inside the current container");
DropshipPalletRef match = matches.get(0);
String customerId = match.ref().order().getCustomerId();
int accepted = value(match.allocation().getPlannedPackageCount());
workflow.pickWholePallet(customerId, match.ref().plan().getPlanNo(), match.allocation().getId(),
new PdaRequests.WholePalletOperation(request.idempotencyKey() + ":PICK", operatorKey, operatorName));
markReceiving(session);
ReceivingContainerResponses.Inbound inbound = inbound(match.ref());
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), match.pallet().getPalletNo(),
accepted, inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(),
inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint());
List<InboundRef> group = matches.stream().map(DropshipPalletRef::ref).distinct().toList();
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
int received = matches.stream().mapToInt(value -> value(value.allocation().getPickedPackageCount())).sum();
InboundRef first = group.get(0);
ReceivingContainerResponses.Inbound inbound = inbound(first);
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt(
alreadyReceived ? "ALREADY_RECEIVED" : "RECEIVED", session.getContainerNo(), inboundCodes.get(0),
inbound.destinationType(), sourcePalletNo, accepted, received, Math.max(0,total-received), session.getStatus(),
true, false, urgent(first.plan()), null, "DP:" + sourcePalletNo, inboundCodes);
save(session, "PALLET_RECEIVE", request.idempotencyKey(), request, result);
return result;
}
@Transactional(isolation = Isolation.READ_COMMITTED)
public ReceivingContainerResponses.Session complete(String operatorKey,
ReceivingContainerRequests.Action request) {
ReceivingContainerRequests.Complete request) {
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
ReceivingContainerSession session = requireSigned(operatorKey);
List<InboundPlan> containerPlans = requirePlans(session.getContainerNo());
Totals totals = totals(containerPlans);
if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container");
validateFbaSkuBalance(session.getContainerNo());
workflow.completeContainer(containerPlans);
boolean hasDifference = totals.total() != totals.received() || hasFbaOverReceipt(session, containerPlans);
if (hasDifference && !request.allowDifference()) {
throw conflict("Container has a receiving difference; confirm onsite completion with difference");
}
if (!hasDifference) {
validateFbaSkuBalance(session.getContainerNo());
workflow.completeContainer(containerPlans);
} else {
workflow.completeContainerWithDifferences(containerPlans, reconciledPlans(containerPlans));
}
LocalDateTime now = LocalDateTime.now();
session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now);
session.setStatus(hasDifference ? "COMPLETED_WITH_DIFFERENCE" : "COMPLETED");
session.setCompletedAt(now); session.setUpdatedAt(now);
session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session);
ReceivingContainerResponses.Session result = snapshot(session, containerPlans);
save(session, "COMPLETE", request.idempotencyKey(), request, result); return result;
}
private List<InboundPlan> reconciledPlans(List<InboundPlan> containerPlans) {
Set<Long> complete = refs(containerPlans.get(0).getContainerNo()).stream()
.collect(Collectors.groupingBy(value -> value.plan().getId(), LinkedHashMap::new, Collectors.toList()))
.entrySet().stream().filter(entry -> entry.getValue().stream().allMatch(ref ->
receivedLinePackages(ref) == value(ref.line().getRequiredPackageCount())))
.map(Map.Entry::getKey).collect(Collectors.toSet());
Set<String> incompleteFbaGroups = refs(containerPlans.get(0).getContainerNo()).stream()
.filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()))
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.filter(ref -> !complete.contains(ref.plan().getId()))
.map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).collect(Collectors.toSet());
refs(containerPlans.get(0).getContainerNo()).stream()
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.filter(ref -> fbaGroupHasOverReceipt(findSession(containerPlans.get(0).getContainerNo()), ref))
.map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).forEach(incompleteFbaGroups::add);
return containerPlans.stream().filter(plan -> complete.contains(plan.getId()))
.filter(plan -> refs(plan.getContainerNo()).stream().filter(ref -> Objects.equals(ref.plan().getId(), plan.getId()))
.noneMatch(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))
&& incompleteFbaGroups.contains(normalizedFba(ref.line().getFbaWarehouseCode()))))
.toList();
}
private ReceivingContainerSession requireCurrent(String operatorKey, boolean lock) {
String key = activeOperatorKey(operatorKey);
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key)
@@ -503,7 +632,8 @@ public class ReceivingContainerService {
List<InboundPlan> containerPlans = planFacts.stream()
.filter(plan -> containerNo.equals(plan.getContainerNo())).toList();
boolean allPlansCompleted = !containerPlans.isEmpty()
&& containerPlans.stream().allMatch(plan -> "COMPLETED".equals(plan.getStatus()));
&& containerPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
.contains(plan.getStatus()));
boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1;
boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent;
int pending = Math.max(0, totalOrders - plannedOrders);
@@ -565,7 +695,8 @@ public class ReceivingContainerService {
if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
throw conflict("Every inbound plan in the receiving container must have an owning customer");
}
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
if (values.stream().allMatch(value -> List.of("COMPLETED", "PENDING_RECONCILIATION")
.contains(value.getStatus()))) {
throw conflict("Receiving container is already completed");
}
return values;
@@ -634,16 +765,20 @@ public class ReceivingContainerService {
private List<InboundRef> requireFbaPlanRefs(String containerNo, InboundRef selected) {
List<InboundRef> values = refs(containerNo).stream()
.filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId()))
.sorted(Comparator.comparing(value -> value.line().getId())).toList();
.filter(value -> !"ONE_CLICK_DROPSHIP".equals(value.order().getOrderType()))
.filter(value -> "FBA".equals(destinationType(value.line(), value.order())))
.filter(value -> Objects.equals(normalizedFba(value.line().getFbaWarehouseCode()),
normalizedFba(selected.line().getFbaWarehouseCode())))
.sorted(Comparator.comparing((InboundRef value) -> value.line().getInboundCode())
.thenComparing(value -> value.line().getId())).toList();
if (values.isEmpty() || values.stream().anyMatch(value ->
!"FBA".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA bulk receiving requires every line in the selected inbound plan to be FBA");
throw conflict("FBA receiving group is invalid");
}
Set<String> destinations = values.stream().map(value -> trim(value.line().getFbaWarehouseCode()))
.collect(Collectors.toSet());
if (destinations.size() != 1 || destinations.contains(null)) {
throw conflict("FBA bulk receiving requires one destination warehouse per inbound plan");
throw conflict("FBA receiving group requires one destination warehouse");
}
return values;
}
@@ -694,6 +829,29 @@ public class ReceivingContainerService {
return new PalletSelection(pallet, resource);
}
private PalletSelection resolveFbaGroupPallet(List<InboundRef> refs, String palletCode) {
String normalized = palletCode.trim();
List<InboundPlanPallet> matches = refs.stream().flatMap(ref -> pallets.selectList(
new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream())
.filter(value -> matchesPlannedPalletCode(value, normalized)).filter(this::hasConfirmedReservation)
.collect(Collectors.toMap(InboundPlanPallet::getId, value -> value, (left,right)->left,
LinkedHashMap::new)).values().stream().toList();
if (matches.isEmpty()) throw notFound("Pallet code is not part of the current FBA destination group");
if (matches.size() != 1) throw conflict("Pallet code is duplicated in the current FBA destination group");
InboundPlanPallet pallet = matches.get(0);
InboundRef owner = refs.stream().filter(ref -> Objects.equals(ref.plan().getId(), pallet.getPlanId()))
.findFirst().orElseThrow(() -> conflict("Pallet owner plan is not part of the FBA destination group"));
long warehouseId = owner.plan().getOverseasWarehouseId() == null
? Long.parseLong(owner.order().getOverseasWarehouseId()) : owner.plan().getOverseasWarehouseId();
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolveReservedPallet(
pallet.getPalletNo(), owner.order().getCustomerId(), warehouseId, pallet.getWarehousePalletId(),
pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
validatePallet(owner, resource);
return new PalletSelection(pallet, resource);
}
private boolean matchesPlannedPalletCode(InboundPlanPallet pallet, String scannedCode) {
return scannedCode.equals(trim(pallet.getPalletNo()))
|| scannedCode.equals(trim(pallet.getSourcePalletNo()));
@@ -750,6 +908,68 @@ public class ReceivingContainerService {
return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode());
}
private String dropshipSourcePallet(String containerNo, String scanCode) {
return dropshipSourcePallet(refs(containerNo), containerNo, scanCode);
}
private String dropshipSourcePallet(List<InboundRef> currentRefs, String containerNo, String scanCode) {
String normalized = trim(scanCode);
if (normalized == null) return null;
List<InboundRef> dropshipRefs = currentRefs.stream()
.filter(ref -> "ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())).toList();
if (dropshipRefs.isEmpty()) return null;
boolean direct = normalized.toUpperCase(java.util.Locale.ROOT).startsWith("DP") && dropshipRefs.stream()
.anyMatch(ref -> pallets.selectCount(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
.eq(InboundPlanPallet::getSourcePalletNo, normalized)) > 0);
if (direct) return normalized;
List<String> resolved = dropshipCartons.sourcePalletsInContainer(containerNo, normalized);
if (resolved.size() > 1) throw conflict("Carton belongs to more than one DP in the current container");
return resolved.isEmpty() ? null : resolved.get(0);
}
private List<DropshipPalletRef> dropshipPalletRefs(String containerNo, String sourcePalletNo) {
List<DropshipPalletRef> result = new ArrayList<>();
for (InboundRef ref : refs(containerNo)) {
if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
.eq(InboundPlanPallet::getSourcePalletNo, sourcePalletNo));
for (InboundPlanPallet pallet : sourcePallets) {
allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId()))
.forEach(allocation -> result.add(new DropshipPalletRef(ref, pallet, allocation)));
}
}
if (result.isEmpty()) throw notFound("DP is not part of the current container");
Set<Long> physicalPallets = result.stream().map(value -> value.pallet().getWarehousePalletId())
.filter(Objects::nonNull).collect(Collectors.toSet());
if (physicalPallets.size() != 1) throw conflict("DP customer fragments do not share one physical pallet");
return result;
}
private void requireCompleteManifest(String containerNo, String sourcePalletNo) {
List<DropshipOrderProfile> profiles = dropshipProfiles.listContainerPallet(containerNo, sourcePalletNo);
if (profiles.isEmpty()) throw notFound("DP manifest was not found in the current container");
Set<Integer> versions = profiles.stream().map(DropshipOrderProfile::getManifestVersion).collect(Collectors.toSet());
Set<Integer> totals = profiles.stream().map(DropshipOrderProfile::getManifestPackageCount).collect(Collectors.toSet());
Set<Integer> fragments = profiles.stream().map(DropshipOrderProfile::getManifestFragmentCount).collect(Collectors.toSet());
Set<String> hashes = profiles.stream().map(DropshipOrderProfile::getManifestHash).collect(Collectors.toSet());
if (versions.size()!=1 || totals.size()!=1 || fragments.size()!=1 || hashes.size()!=1) {
throw conflict("DP manifest fragments are inconsistent");
}
long customerFragments = profiles.stream().map(DropshipOrderProfile::getCustomerId).distinct().count();
long cartons = profiles.stream().mapToLong(profile ->
dropshipCartons.listProfile(profile.getCustomerId(), profile.getId()).size()).sum();
if (customerFragments != fragments.iterator().next() || cartons != totals.iterator().next()) {
throw conflict("DP manifest fragments are incomplete; wait for all customer fragments");
}
}
private record PalletSelection(InboundPlanPallet pallet,
WarehouseResourceGateway.WarehousePalletReference resource) { }
@@ -764,7 +984,40 @@ public class ReceivingContainerService {
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(),
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds);
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received),
hasFbaOverReceipt(session, containerPlans), inbounds);
}
private void savePhysicalFbaCount(long sessionId, String groupKey, String palletCode, int actualPackages,
ReceivingFbaPalletCount existing) {
LocalDateTime now = LocalDateTime.now();
if (existing == null) {
ReceivingFbaPalletCount value = new ReceivingFbaPalletCount();
value.setId(IdWorker.getId()); value.setSessionId(sessionId); value.setGroupKey(groupKey);
value.setPalletCode(palletCode); value.setActualPackages(actualPackages);
value.setCreatedAt(now); value.setUpdatedAt(now); fbaPalletCounts.insert(value);
} else {
existing.setActualPackages(actualPackages); existing.setUpdatedAt(now); fbaPalletCounts.updateById(existing);
}
}
private boolean hasFbaOverReceipt(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
if (session == null || containerPlans == null || containerPlans.isEmpty()) return false;
return refs(session.getContainerNo()).stream()
.filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()))
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.anyMatch(ref -> fbaGroupHasOverReceipt(session, ref));
}
private boolean fbaGroupHasOverReceipt(ReceivingContainerSession session, InboundRef ref) {
if (session == null || session.getId() == null) return false;
int planned = requireFbaPlanRefs(session.getContainerNo(), ref).stream()
.mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
return fbaPalletCounts.sumActual(session.getId(), fbaGroupKey(session.getContainerNo(), ref)) > planned;
}
private ReceivingContainerSession findSession(String containerNo) {
return sessions.findActiveByContainer(activeContainerKey(containerNo));
}
private ReceivingContainerResponses.Inbound inbound(InboundRef ref) {
@@ -844,10 +1097,11 @@ public class ReceivingContainerService {
throw conflict("Unload photo file ID must be a positive decimal string");
}
private List<FbaSkuAllocation> allocateFbaSkuReceipts(String customerId, List<InboundRef> refs, int packageCount) {
private List<FbaSkuAllocation> allocateFbaSkuReceipts(List<InboundRef> refs, int packageCount) {
int remaining = packageCount;
List<FbaSkuAllocation> result = new ArrayList<>();
for (InboundRef ref : refs) {
String customerId = ref.order().getCustomerId();
int linePending = value(ref.line().getRequiredPackageCount()) - receivedLinePackages(ref);
List<SkuExpectation> expectations = skuExpectations(ref);
int skuPending = expectations.stream().mapToInt(expectation -> expectation.expectedPackages()
@@ -958,6 +1212,15 @@ public class ReceivingContainerService {
return value;
}
private String fbaGroupKey(String containerNo, InboundRef ref) {
return "FBA:" + containerNo + ":" + normalizedFba(ref.line().getFbaWarehouseCode());
}
private String normalizedFba(String value) {
String normalized = trim(value);
return normalized == null ? null : normalized.toUpperCase(java.util.Locale.ROOT);
}
private void markReceiving(ReceivingContainerSession session) {
if (!"RECEIVING".equals(session.getStatus())) {
session.setStatus("RECEIVING"); session.setReceivingAt(LocalDateTime.now());
@@ -253,7 +253,7 @@ public class ContainerInboundPlanConversionCoordinator {
throw new ResponseStatusException(HttpStatus.CONFLICT, "计划托数不能少于已生成的头程托盘码数量");
}
String fbaCode = trimToNull(order.fbaWarehouseCode());
if (fbaCode != null) {
if (fbaCode != null && !"ONE_CLICK_DROPSHIP".equals(order.orderType())) {
for (String palletCode : sourcePalletNos) {
PalletCodeParts parts = palletCodeParts(preview.containerNo(), palletCode);
String previousPrefix = prefixByFba.putIfAbsent(fbaCode, parts.prefix());
@@ -329,10 +329,7 @@ public class ContainerInboundPlanConversionCoordinator {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次包含重复业务键");
}
}
if (response.items().stream().map(WarehouseResourceGateway.PalletBatchItem::palletId).distinct().count() != response.items().size()
|| response.items().stream().map(WarehouseResourceGateway.PalletBatchItem::resourceLockId).distinct().count() != response.items().size()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次包含重复托盘或资源锁");
}
validateSharedPallets(preparation.items(), byKey);
return preparation.items().stream().collect(Collectors.groupingBy(PreparedItem::orderId)).values().stream()
.map(items -> {
PreparedItem first = items.get(0);
@@ -356,6 +353,22 @@ public class ContainerInboundPlanConversionCoordinator {
return value;
}
private void validateSharedPallets(List<PreparedItem> expected,
Map<String, WarehouseResourceGateway.PalletBatchItem> actual) {
Map<Long, List<PreparedItem>> byPallet = expected.stream().collect(Collectors.groupingBy(item ->
requiredReservation(actual.get(item.businessKey()), item).palletId()));
for (List<PreparedItem> values : byPallet.values()) {
if (values.size() == 1) continue;
Set<String> codes = values.stream().map(PreparedItem::palletCode).collect(Collectors.toSet());
Set<Long> locks = values.stream().map(item -> requiredReservation(actual.get(item.businessKey()), item).resourceLockId())
.collect(Collectors.toSet());
if (codes.size() != 1 || codes.iterator().next() == null
|| !codes.iterator().next().toUpperCase(java.util.Locale.ROOT).startsWith("DP") || locks.size() != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "只有同一码的一件代发 DP 才能共享实物托盘");
}
}
}
private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size,
String... acceptedStatuses) {
if (response == null || response.batchId() <= 0 || response.attemptNo() <= 0
@@ -842,7 +842,9 @@ public class InboundPlanService {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE"));
if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
if ("COMPLETED".equals(plan.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets");
if (List.of("COMPLETED", "PENDING_RECONCILIATION").contains(plan.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets");
}
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId()));
boolean destinationExists = lines.stream().anyMatch(line -> request.destinationWarehouseId().equals(line.getDestinationWarehouseId()));
@@ -23,8 +23,8 @@ public interface InventoryCommandMapper {
@Param("balanceId") Long balanceId);
@Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " +
"FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId}")
long palletPackages(@Param("customerId") String customerId, @Param("palletId") Long palletId);
"FROM ll_inventory_position WHERE pallet_id=#{palletId}")
long palletPackages(@Param("palletId") Long palletId);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} " +
@@ -239,7 +239,7 @@ public class InventoryCommandService {
balance.setPickedPackages(value(balance.getPickedPackages()) - request.packages());
touch(balance);
balances.updateById(balance);
boolean palletEmpty = commands.palletPackages(customerId, position.getPalletId()) == 0;
boolean palletEmpty = commands.palletPackages(position.getPalletId()) == 0;
if (palletEmpty) warehouseGateway.scrapPallet(customerId, position.getPalletId());
ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "SHIP",
"PICKED", "SHIPPED", before, -request.packages(), before - request.packages(),
@@ -459,7 +459,10 @@ public class LoadingCompletedConsumer {
private List<DropshipRequests.Pallet> pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
if (source.pallets() != null && !source.pallets().isEmpty()) {
return source.pallets().stream().map(pallet -> {
List<DropshipRequests.Pallet> result = new ArrayList<>();
JsonNode rawPallets = rawSource.path("pallets");
for (int palletIndex = 0; palletIndex < source.pallets().size(); palletIndex++) {
FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet = source.pallets().get(palletIndex);
Map<String, DropshipRequests.SkuPackages> inventorySkus = new java.util.LinkedHashMap<>();
for (var sku : pallet.skuPackages()) {
inventorySkus.merge(sku.skuCode(), new DropshipRequests.SkuPackages(sku.skuCode(),
@@ -470,8 +473,13 @@ public class LoadingCompletedConsumer {
Math.addExact(left.productQuantity(), right.productQuantity()), List.copyOf(marks));
});
}
return new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()));
}).toList();
JsonNode rawPallet = rawPallets.isArray() && palletIndex < rawPallets.size()
? rawPallets.get(palletIndex) : objectMapper.createObjectNode();
result.add(new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()),
positiveManifest(rawPallet, "manifestVersion"), positiveManifest(rawPallet, "manifestPackageCount"),
positiveManifest(rawPallet, "manifestFragmentCount"), requiredManifestHash(rawPallet)));
}
return List.copyOf(result);
}
return legacyPallets(rawSource);
}
@@ -484,7 +492,9 @@ public class LoadingCompletedConsumer {
String palletNo = pallet.path("palletNo").asText(pallet.path("sourcePalletNo").asText(null));
List<DropshipRequests.SkuPackages> skuRows = skuPackages(pallet);
if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) {
result.add(new DropshipRequests.Pallet(palletNo, skuRows));
result.add(new DropshipRequests.Pallet(palletNo, skuRows,
positiveManifest(pallet, "manifestVersion"), positiveManifest(pallet, "manifestPackageCount"),
positiveManifest(pallet, "manifestFragmentCount"), requiredManifestHash(pallet)));
}
}
return result;
@@ -492,11 +502,27 @@ public class LoadingCompletedConsumer {
String palletNo = source.path("palletNo").asText(source.path("sourcePalletNo").asText(null));
List<DropshipRequests.SkuPackages> skuRows = skuPackages(source);
if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) {
result.add(new DropshipRequests.Pallet(palletNo, skuRows));
result.add(new DropshipRequests.Pallet(palletNo, skuRows,
positiveManifest(source, "manifestVersion"), positiveManifest(source, "manifestPackageCount"),
positiveManifest(source, "manifestFragmentCount"), requiredManifestHash(source)));
}
return result;
}
private int positiveManifest(JsonNode source, String field) {
int value = source.path(field).asInt(0);
if (value <= 0) throw new IllegalArgumentException("One-click dropship pallet is missing " + field);
return value;
}
private String requiredManifestHash(JsonNode source) {
String value = source.path("manifestHash").asText(null);
if (value == null || !value.matches("(?i)[0-9a-f]{64}")) {
throw new IllegalArgumentException("One-click dropship pallet manifestHash is invalid");
}
return value.toLowerCase(java.util.Locale.ROOT);
}
private List<DropshipRequests.SkuPackages> skuPackages(JsonNode source) {
List<DropshipRequests.SkuPackages> result = new ArrayList<>();
JsonNode nodes = source.path("skuPackages");