This commit is contained in:
2026-09-18 16:48:30 +08:00
parent 7d6ca17c02
commit fd27a0ed21
4 changed files with 177 additions and 100 deletions
@@ -24,6 +24,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -58,17 +59,20 @@ public class ReceivingContainerController {
@GetMapping("/{containerNo}") @GetMapping("/{containerNo}")
@Operation(summary = "Get one receiving container without exposing plan or allocation IDs") @Operation(summary = "Get one receiving container without exposing plan or allocation IDs")
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String containerNo) { @PathVariable String containerNo,
@RequestParam String sourceContainerBookingId) {
PdaWarehouseIdentity identity = identity(user); PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo)); return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(),
containerNo, sourceContainerBookingId));
} }
@PostMapping("/{containerNo}/prepare") @PostMapping("/{containerNo}/prepare")
public ApiResponse<ReceivingContainerResponses.Session> prepare(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<ReceivingContainerResponses.Session> prepare(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) { @PathVariable String containerNo, @RequestParam String sourceContainerBookingId,
@Valid @RequestBody ReceivingContainerRequests.Action request) {
PdaWarehouseIdentity identity = identity(user); PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(), return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(),
containerNo, request)); containerNo, sourceContainerBookingId, request));
} }
@PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -62,6 +62,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@@ -142,14 +143,16 @@ public class ReceivingContainerService {
.map(value -> candidate(value, current)).toList(); .map(value -> candidate(value, current)).toList();
} }
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo,
requireReadyContainer(containerNo, overseasWarehouseId, false); String sourceContainerBookingId) {
List<InboundPlan> containerPlans = requirePlans(containerNo); requireReadyContainer(containerNo, sourceContainerBookingId, overseasWarehouseId, false);
List<InboundPlan> containerPlans = requirePlans(sourceContainerBookingId, containerNo);
requireMaterializedPlans(containerPlans); requireMaterializedPlans(containerPlans);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId); requireSessionWarehouse(current, overseasWarehouseId);
if (current != null && !containerNo.equals(current.getContainerNo())) { if (current != null && (!containerNo.equals(current.getContainerNo())
|| !sourceContainerBookingId.equals(current.getSourceContainerBookingId()))) {
throw conflict("Current account is locked to another selected container"); throw conflict("Current account is locked to another selected container");
} }
ReceivingContainerSession reserved = sessions.findActiveByContainer(activeContainerKey(containerNo)); ReceivingContainerSession reserved = sessions.findActiveByContainer(activeContainerKey(containerNo));
@@ -158,15 +161,17 @@ public class ReceivingContainerService {
} }
WarehouseResourceGateway.WarehouseReference receivingWarehouse = WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId); warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( return snapshot(current != null && containerNo.equals(current.getContainerNo())
operatorKey, receivingWarehouse, containerNo), containerPlans); && sourceContainerBookingId.equals(current.getSourceContainerBookingId()) ? current : waitingView(
operatorKey, receivingWarehouse, sourceContainerBookingId, containerNo), containerPlans);
} }
@Transactional @Transactional
public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo, public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo,
String sourceContainerBookingId,
ReceivingContainerRequests.Action request) { ReceivingContainerRequests.Action request) {
PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey()); PrepareCommand command = new PrepareCommand(sourceContainerBookingId, containerNo, request.idempotencyKey());
ContainerReadiness readiness = requireReadyContainer(containerNo, overseasWarehouseId, true); ContainerReadiness readiness = requireReadyContainer(containerNo, sourceContainerBookingId, overseasWarehouseId, true);
ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command); ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command);
if (replay != null) { if (replay != null) {
ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class); ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class);
@@ -175,13 +180,14 @@ public class ReceivingContainerService {
} }
return result; return result;
} }
List<InboundPlan> containerPlans = requirePlans(containerNo); List<InboundPlan> containerPlans = requirePlans(sourceContainerBookingId, containerNo);
requireMaterializedPlans(containerPlans); requireMaterializedPlans(containerPlans);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
String activeOperatorKey = activeOperatorKey(operatorKey); String activeOperatorKey = activeOperatorKey(operatorKey);
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
requireSessionWarehouse(active, overseasWarehouseId); requireSessionWarehouse(active, overseasWarehouseId);
if (active != null && !containerNo.equals(active.getContainerNo())) { if (active != null && (!containerNo.equals(active.getContainerNo())
|| !sourceContainerBookingId.equals(active.getSourceContainerBookingId()))) {
throw conflict("Finish the current container before selecting another one"); throw conflict("Finish the current container before selecting another one");
} }
if (active == null && sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) { if (active == null && sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) {
@@ -229,7 +235,7 @@ public class ReceivingContainerService {
List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request); List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request);
List<Long> photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList(); List<Long> photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList();
photoFileIds.forEach(fileId -> unloadPhotos.validate(PLATFORM_SCOPE, session.getId(), fileId)); photoFileIds.forEach(fileId -> unloadPhotos.validate(PLATFORM_SCOPE, session.getId(), fileId));
List<InboundPlan> containerPlans = requirePlans(session.getContainerNo()); List<InboundPlan> containerPlans = requirePlans(session.getSourceContainerBookingId(), session.getContainerNo());
requireMaterializedPlans(containerPlans); requireMaterializedPlans(containerPlans);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now);
@@ -243,7 +249,7 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.Session current(String operatorKey) { public ReceivingContainerResponses.Session current(String operatorKey) {
ReceivingContainerSession session = requireCurrent(operatorKey, false); ReceivingContainerSession session = requireCurrent(operatorKey, false);
return snapshot(session, requirePlans(session.getContainerNo())); return snapshot(session, requirePlans(session.getSourceContainerBookingId(), session.getContainerNo()));
} }
public UnloadPhotoFileGateway.DownloadedFile downloadCurrentPhoto(String operatorKey, String fileId) { public UnloadPhotoFileGateway.DownloadedFile downloadCurrentPhoto(String operatorKey, String fileId) {
@@ -276,10 +282,11 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey, public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
ReceivingContainerRequests.CartonResolve request) { ReceivingContainerRequests.CartonResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
List<InboundRef> currentRefs = refs(session.getContainerNo()); List<InboundRef> currentRefs = refs(session.getSourceContainerBookingId(), session.getContainerNo());
String sourcePalletNo = dropshipSourcePallet(currentRefs, session.getContainerNo(), request.cartonMark()); String sourcePalletNo = dropshipSourcePallet(currentRefs, session.getContainerNo(), request.cartonMark());
if (sourcePalletNo != null) { if (sourcePalletNo != null) {
List<DropshipPalletRef> palletRefs = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo); List<DropshipPalletRef> palletRefs = dropshipPalletRefs(
session.getSourceContainerBookingId(), session.getContainerNo(), sourcePalletNo);
requireCompleteManifest(session.getContainerNo(), sourcePalletNo); requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
List<InboundRef> group = palletRefs.stream().map(DropshipPalletRef::ref).distinct().toList(); List<InboundRef> group = palletRefs.stream().map(DropshipPalletRef::ref).distinct().toList();
InboundRef first = group.get(0); InboundRef first = group.get(0);
@@ -306,14 +313,16 @@ public class ReceivingContainerService {
} }
carton = new CartonRef(matches.get(0)); carton = new CartonRef(matches.get(0));
} else if (matches.size() == 1) { } else if (matches.size() == 1) {
carton = requireCartonRef(matches.get(0).order().getCustomerId(), session.getContainerNo(), request.cartonMark()); carton = requireCartonRef(matches.get(0).order().getCustomerId(),
session.getSourceContainerBookingId(), session.getContainerNo(), request.cartonMark());
} else { } else {
carton = requireUnmatchedFbaCartonPrefix(inboundCode); carton = requireUnmatchedFbaCartonPrefix(inboundCode);
} }
InboundRef ref = carton.ref(); InboundRef ref = carton.ref();
if ("FBA".equals(destinationType(ref.line(), ref.order())) if ("FBA".equals(destinationType(ref.line(), ref.order()))
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) { && !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
List<InboundRef> group = requireFbaPlanRefs(session.getContainerNo(), ref); List<InboundRef> group = requireFbaPlanRefs(
session.getSourceContainerBookingId(), session.getContainerNo(), ref);
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList(); 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 total = group.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
int received = group.stream().mapToInt(this::receivedLinePackages).sum(); int received = group.stream().mapToInt(this::receivedLinePackages).sum();
@@ -339,10 +348,11 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
ReceivingContainerRequests.PalletResolve request) { ReceivingContainerRequests.PalletResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode()); InboundRef ref = requireInbound(session.getSourceContainerBookingId(),
session.getContainerNo(), request.inboundCode());
List<InboundRef> group = "FBA".equals(destinationType(ref.line(), ref.order())) List<InboundRef> group = "FBA".equals(destinationType(ref.line(), ref.order()))
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()) && !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())
? requireFbaPlanRefs(session.getContainerNo(), ref) : List.of(ref); ? requireFbaPlanRefs(session.getSourceContainerBookingId(), session.getContainerNo(), ref) : List.of(ref);
PalletSelection selection = group.size() == 1 && !"FBA".equals(destinationType(ref.line(), ref.order())) PalletSelection selection = group.size() == 1 && !"FBA".equals(destinationType(ref.line(), ref.order()))
? resolveReceivingPallet(ref, request.palletCode(), false) : resolveFbaGroupPallet(group, request.palletCode()); ? resolveReceivingPallet(ref, request.palletCode(), false) : resolveFbaGroupPallet(group, request.palletCode());
WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource(); WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
@@ -369,14 +379,16 @@ public class ReceivingContainerService {
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
requirePalletCapacity(request.packageCount()); requirePalletCapacity(request.packageCount());
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode()); InboundRef ref = requireInbound(session.getSourceContainerBookingId(),
session.getContainerNo(), request.inboundCode());
if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) { if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
throw conflict("One-click dropship inbound must be operated by complete source pallet"); throw conflict("One-click dropship inbound must be operated by complete source pallet");
} }
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { if (!"FBA".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
} }
List<InboundRef> fbaPlanRefs = requireFbaPlanRefs(session.getContainerNo(), ref); List<InboundRef> fbaPlanRefs = requireFbaPlanRefs(
session.getSourceContainerBookingId(), session.getContainerNo(), ref);
PalletSelection selection = resolveFbaGroupPallet(fbaPlanRefs, request.palletCode()); PalletSelection selection = resolveFbaGroupPallet(fbaPlanRefs, request.palletCode());
WarehouseResourceGateway.WarehousePalletReference resource = selection.resource(); WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
InboundPlanPallet pallet = selection.pallet(); InboundPlanPallet pallet = selection.pallet();
@@ -451,15 +463,16 @@ public class ReceivingContainerService {
ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request); ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
List<InboundRef> currentRefs = refs(session.getContainerNo()); List<InboundRef> currentRefs = refs(session.getSourceContainerBookingId(), session.getContainerNo());
if (currentRefs.stream().noneMatch(value -> if (currentRefs.stream().noneMatch(value ->
"OVERSEAS".equals(destinationType(value.line(), value.order())))) { "OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count"); throw conflict("FBA destination must use bulk count");
} }
String inboundCode = cartonInboundCode(request.cartonMark()); String inboundCode = cartonInboundCode(request.cartonMark());
InboundRef inboundRef = requireInbound(session.getContainerNo(), inboundCode); InboundRef inboundRef = requireInbound(session.getSourceContainerBookingId(), session.getContainerNo(), inboundCode);
String customerId = inboundRef.order().getCustomerId(); String customerId = inboundRef.order().getCustomerId();
CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); CartonRef carton = requireCartonRef(customerId, session.getSourceContainerBookingId(),
session.getContainerNo(), request.cartonMark());
InboundRef ref = carton.ref(); InboundRef ref = carton.ref();
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("FBA destination must use bulk count"); throw conflict("FBA destination must use bulk count");
@@ -496,10 +509,12 @@ public class ReceivingContainerService {
ReceivingContainerOperation replay = replay(operatorKey, "PALLET_RECEIVE", request.idempotencyKey(), request); ReceivingContainerOperation replay = replay(operatorKey, "PALLET_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
String sourcePalletNo = dropshipSourcePallet(session.getContainerNo(), request.sourcePalletNo()); String sourcePalletNo = dropshipSourcePallet(
session.getSourceContainerBookingId(), session.getContainerNo(), request.sourcePalletNo());
if (sourcePalletNo == null) throw notFound("DP or carton is not part of the current container"); if (sourcePalletNo == null) throw notFound("DP or carton is not part of the current container");
requireCompleteManifest(session.getContainerNo(), sourcePalletNo); requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
List<DropshipPalletRef> matches = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo); List<DropshipPalletRef> matches = dropshipPalletRefs(
session.getSourceContainerBookingId(), session.getContainerNo(), sourcePalletNo);
int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum(); int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
requirePalletCapacity(total); requirePalletCapacity(total);
boolean alreadyReceived = matches.stream().allMatch(match -> boolean alreadyReceived = matches.stream().allMatch(match ->
@@ -538,17 +553,17 @@ public class ReceivingContainerService {
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
List<InboundPlan> containerPlans = requirePlans(session.getContainerNo()); List<InboundPlan> containerPlans = requirePlans(session.getSourceContainerBookingId(), session.getContainerNo());
Totals totals = totals(containerPlans); Totals totals = totals(session, containerPlans);
boolean hasDifference = totals.total() != totals.received() || hasFbaOverReceipt(session, containerPlans); boolean hasDifference = totals.total() != totals.received() || hasFbaOverReceipt(session, containerPlans);
if (hasDifference && !request.allowDifference()) { if (hasDifference && !request.allowDifference()) {
throw conflict("Container has a receiving difference; confirm onsite completion with difference"); throw conflict("Container has a receiving difference; confirm onsite completion with difference");
} }
if (!hasDifference) { if (!hasDifference) {
validateFbaSkuBalance(session.getContainerNo()); validateFbaSkuBalance(session.getSourceContainerBookingId(), session.getContainerNo());
workflow.completeContainer(containerPlans); workflow.completeContainer(containerPlans);
} else { } else {
workflow.completeContainerWithDifferences(containerPlans, reconciledPlans(containerPlans)); workflow.completeContainerWithDifferences(containerPlans, reconciledPlans(session, containerPlans));
} }
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
session.setStatus(hasDifference ? "COMPLETED_WITH_DIFFERENCE" : "COMPLETED"); session.setStatus(hasDifference ? "COMPLETED_WITH_DIFFERENCE" : "COMPLETED");
@@ -558,23 +573,24 @@ public class ReceivingContainerService {
save(session, "COMPLETE", request.idempotencyKey(), request, result); return result; save(session, "COMPLETE", request.idempotencyKey(), request, result); return result;
} }
private List<InboundPlan> reconciledPlans(List<InboundPlan> containerPlans) { private List<InboundPlan> reconciledPlans(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
Set<Long> complete = refs(containerPlans.get(0).getContainerNo()).stream() List<InboundRef> currentRefs = refs(session.getSourceContainerBookingId(), session.getContainerNo());
Set<Long> complete = currentRefs.stream()
.collect(Collectors.groupingBy(value -> value.plan().getId(), LinkedHashMap::new, Collectors.toList())) .collect(Collectors.groupingBy(value -> value.plan().getId(), LinkedHashMap::new, Collectors.toList()))
.entrySet().stream().filter(entry -> entry.getValue().stream().allMatch(ref -> .entrySet().stream().filter(entry -> entry.getValue().stream().allMatch(ref ->
receivedLinePackages(ref) == value(ref.line().getRequiredPackageCount()))) receivedLinePackages(ref) == value(ref.line().getRequiredPackageCount())))
.map(Map.Entry::getKey).collect(Collectors.toSet()); .map(Map.Entry::getKey).collect(Collectors.toSet());
Set<String> incompleteFbaGroups = refs(containerPlans.get(0).getContainerNo()).stream() Set<String> incompleteFbaGroups = currentRefs.stream()
.filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) .filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()))
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.filter(ref -> !complete.contains(ref.plan().getId())) .filter(ref -> !complete.contains(ref.plan().getId()))
.map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).collect(Collectors.toSet()); .map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).collect(Collectors.toSet());
refs(containerPlans.get(0).getContainerNo()).stream() currentRefs.stream()
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.filter(ref -> fbaGroupHasOverReceipt(findSession(containerPlans.get(0).getContainerNo()), ref)) .filter(ref -> fbaGroupHasOverReceipt(findSession(containerPlans.get(0).getContainerNo()), ref))
.map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).forEach(incompleteFbaGroups::add); .map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).forEach(incompleteFbaGroups::add);
return containerPlans.stream().filter(plan -> complete.contains(plan.getId())) return containerPlans.stream().filter(plan -> complete.contains(plan.getId()))
.filter(plan -> refs(plan.getContainerNo()).stream().filter(ref -> Objects.equals(ref.plan().getId(), plan.getId())) .filter(plan -> currentRefs.stream().filter(ref -> Objects.equals(ref.plan().getId(), plan.getId()))
.noneMatch(ref -> "FBA".equals(destinationType(ref.line(), ref.order())) .noneMatch(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))
&& incompleteFbaGroups.contains(normalizedFba(ref.line().getFbaWarehouseCode())))) && incompleteFbaGroups.contains(normalizedFba(ref.line().getFbaWarehouseCode()))))
.toList(); .toList();
@@ -589,22 +605,23 @@ public class ReceivingContainerService {
return session; return session;
} }
private ContainerReadiness requireReadyContainer(String containerNo, long overseasWarehouseId, boolean lock) { private ContainerReadiness requireReadyContainer(String containerNo, String sourceContainerBookingId,
long overseasWarehouseId, boolean lock) {
String bookingId = trim(sourceContainerBookingId);
if (bookingId == null) throw conflict("sourceContainerBookingId is required");
LambdaQueryWrapper<LastlegOrder> query = new LambdaQueryWrapper<LastlegOrder>() LambdaQueryWrapper<LastlegOrder> query = new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getContainerNo, containerNo).orderByAsc(LastlegOrder::getId); .eq(LastlegOrder::getContainerNo, containerNo)
.eq(LastlegOrder::getSourceContainerBookingId, bookingId)
.orderByAsc(LastlegOrder::getId);
if (lock) query.last("FOR UPDATE"); if (lock) query.last("FOR UPDATE");
List<LastlegOrder> orderFacts = orders.selectList(query); List<LastlegOrder> orderFacts = orders.selectList(query);
Set<String> bookingIds = orderFacts.stream().map(LastlegOrder::getSourceContainerBookingId) if (orderFacts.isEmpty()) throw notFound("当前柜次来源与集装箱号不匹配");
.map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet());
if (orderFacts.isEmpty() || bookingIds.size() != 1) {
throw conflict("Container must resolve exactly one sourceContainerBookingId");
}
List<InboundPlan> planFacts = plans.selectList(new LambdaQueryWrapper<InboundPlan>() List<InboundPlan> planFacts = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId));
List<InboundPlanLine> lineFacts = lines.selectList(new LambdaQueryWrapper<InboundPlanLine>() List<InboundPlanLine> lineFacts = lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.in(InboundPlanLine::getOrderId, orderFacts.stream().map(LastlegOrder::getId).toList()) .in(InboundPlanLine::getOrderId, orderFacts.stream().map(LastlegOrder::getId).toList())
.orderByAsc(InboundPlanLine::getId)); .orderByAsc(InboundPlanLine::getId));
ContainerReadiness value = readiness(bookingIds.iterator().next(), orderFacts, planFacts, lineFacts, ContainerReadiness value = readiness(bookingId, orderFacts, planFacts, lineFacts,
overseasWarehouseId); overseasWarehouseId);
if (value == null || !value.operable()) { if (value == null || !value.operable()) {
throw conflict(value == null ? "当前柜已分配至其他海外接柜仓,无法在本仓操作" : value.blockReason()); throw conflict(value == null ? "当前柜已分配至其他海外接柜仓,无法在本仓操作" : value.blockReason());
@@ -614,7 +631,8 @@ public class ReceivingContainerService {
private void requireStableSessionFacts(ReceivingContainerSession session, boolean lock) { private void requireStableSessionFacts(ReceivingContainerSession session, boolean lock) {
if (trim(session.getContainerFactHash()) == null) return; if (trim(session.getContainerFactHash()) == null) return;
ContainerReadiness current = requireReadyContainer(session.getContainerNo(), session.getOverseasWarehouseId(), lock); ContainerReadiness current = requireReadyContainer(session.getContainerNo(),
session.getSourceContainerBookingId(), session.getOverseasWarehouseId(), lock);
if (!Objects.equals(session.getSourceContainerBookingId(), current.sourceContainerBookingId()) if (!Objects.equals(session.getSourceContainerBookingId(), current.sourceContainerBookingId())
|| !Objects.equals(session.getContainerFactHash(), current.factHash())) { || !Objects.equals(session.getContainerFactHash(), current.factHash())) {
throw conflict("Container orders or inbound plans changed after prepare; restart the receiving session"); throw conflict("Container orders or inbound plans changed after prepare; restart the receiving session");
@@ -657,10 +675,8 @@ public class ReceivingContainerService {
int planCount = matchedPlans.size(); int planCount = matchedPlans.size();
boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders
&& plannedOrders == totalOrders && planCount == totalOrders; && plannedOrders == totalOrders && planCount == totalOrders;
List<InboundPlan> containerPlans = planFacts.stream() boolean allPlansCompleted = !matchedPlans.isEmpty()
.filter(plan -> containerNo.equals(plan.getContainerNo())).toList(); && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
boolean allPlansCompleted = !containerPlans.isEmpty()
&& containerPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
.contains(plan.getStatus())); .contains(plan.getStatus()));
boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1; boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1;
boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent; boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent;
@@ -716,9 +732,12 @@ public class ReceivingContainerService {
return session; return session;
} }
private List<InboundPlan> requirePlans(String containerNo) { private List<InboundPlan> requirePlans(String sourceContainerBookingId, String containerNo) {
List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>() List<InboundPlan> values = refs(sourceContainerBookingId, containerNo).stream()
.eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); .map(InboundRef::plan)
.collect(Collectors.toMap(InboundPlan::getId, Function.identity(), (left, right) -> left,
LinkedHashMap::new))
.values().stream().toList();
if (values.isEmpty()) throw notFound("Receiving container was not found"); if (values.isEmpty()) throw notFound("Receiving container was not found");
if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
throw conflict("Every inbound plan in the receiving container must have an owning customer"); throw conflict("Every inbound plan in the receiving container must have an owning customer");
@@ -730,32 +749,31 @@ public class ReceivingContainerService {
return values; return values;
} }
private List<InboundPlan> plansForContainer(String containerNo) { private List<InboundRef> refs(String sourceContainerBookingId, String containerNo) {
List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getContainerNo, containerNo)
.orderByAsc(InboundPlan::getId));
if (values.isEmpty()) throw notFound("Receiving container was not found");
return values;
}
private List<InboundRef> refs(String containerNo) {
List<InboundRef> result = new ArrayList<>(); List<InboundRef> result = new ArrayList<>();
for (InboundPlan plan : plansForContainer(containerNo)) { List<InboundPlan> containerPlans = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId));
for (InboundPlan plan : containerPlans) {
String customerId = trim(plan.getCustomerId()); String customerId = trim(plan.getCustomerId());
if (customerId == null) throw conflict("Inbound plan has no owning customer"); if (customerId == null) throw conflict("Inbound plan has no owning customer");
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>() for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()) .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId())
.orderByAsc(InboundPlanLine::getId))) { .orderByAsc(InboundPlanLine::getId))) {
LastlegOrder order = orders.selectById(line.getOrderId()); LastlegOrder order = orders.selectById(line.getOrderId());
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing"); if (order == null || !customerId.equals(order.getCustomerId())
|| !Objects.equals(containerNo, trim(order.getContainerNo()))) {
throw conflict("Inbound line order is missing or belongs to another cabinet occurrence");
}
if (!Objects.equals(sourceContainerBookingId, trim(order.getSourceContainerBookingId()))) continue;
result.add(new InboundRef(plan, line, order)); result.add(new InboundRef(plan, line, order));
} }
} }
if (result.isEmpty()) throw notFound("Receiving container was not found");
return result; return result;
} }
private InboundRef requireInbound(String containerNo, String inboundCode) { private InboundRef requireInbound(String sourceContainerBookingId, String containerNo, String inboundCode) {
List<InboundRef> matches = refs(containerNo).stream() List<InboundRef> matches = refs(sourceContainerBookingId, containerNo).stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container"); if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container");
Set<Long> planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet()); Set<Long> planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet());
@@ -763,11 +781,12 @@ public class ReceivingContainerService {
return matches.stream().min(Comparator.comparing(value -> value.line().getId())).orElseThrow(); return matches.stream().min(Comparator.comparing(value -> value.line().getId())).orElseThrow();
} }
private CartonRef requireCartonRef(String customerId, String containerNo, String cartonMark) { private CartonRef requireCartonRef(String customerId, String sourceContainerBookingId,
String containerNo, String cartonMark) {
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>() InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark)); .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark));
if (carton == null) throw notFound("Carton mark was not found"); if (carton == null) throw notFound("Carton mark was not found");
List<InboundRef> matches = refs(containerNo).stream() List<InboundRef> matches = refs(sourceContainerBookingId, containerNo).stream()
.filter(value -> customerId.equals(value.order().getCustomerId())) .filter(value -> customerId.equals(value.order().getCustomerId()))
.filter(value -> carton.getLineId() == null || Objects.equals(value.line().getId(), carton.getLineId())) .filter(value -> carton.getLineId() == null || Objects.equals(value.line().getId(), carton.getLineId()))
.filter(value -> carton.getPlanId() == null || Objects.equals(value.plan().getId(), carton.getPlanId())) .filter(value -> carton.getPlanId() == null || Objects.equals(value.plan().getId(), carton.getPlanId()))
@@ -791,8 +810,9 @@ public class ReceivingContainerService {
throw notFound("Carton mark was not found"); throw notFound("Carton mark was not found");
} }
private List<InboundRef> requireFbaPlanRefs(String containerNo, InboundRef selected) { private List<InboundRef> requireFbaPlanRefs(String sourceContainerBookingId,
List<InboundRef> values = refs(containerNo).stream() String containerNo, InboundRef selected) {
List<InboundRef> values = refs(sourceContainerBookingId, containerNo).stream()
.filter(value -> !"ONE_CLICK_DROPSHIP".equals(value.order().getOrderType())) .filter(value -> !"ONE_CLICK_DROPSHIP".equals(value.order().getOrderType()))
.filter(value -> "FBA".equals(destinationType(value.line(), value.order()))) .filter(value -> "FBA".equals(destinationType(value.line(), value.order())))
.filter(value -> Objects.equals(normalizedFba(value.line().getFbaWarehouseCode()), .filter(value -> Objects.equals(normalizedFba(value.line().getFbaWarehouseCode()),
@@ -940,8 +960,8 @@ public class ReceivingContainerService {
return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode()); return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode());
} }
private String dropshipSourcePallet(String containerNo, String scanCode) { private String dropshipSourcePallet(String sourceContainerBookingId, String containerNo, String scanCode) {
return dropshipSourcePallet(refs(containerNo), containerNo, scanCode); return dropshipSourcePallet(refs(sourceContainerBookingId, containerNo), containerNo, scanCode);
} }
private String dropshipSourcePallet(List<InboundRef> currentRefs, String containerNo, String scanCode) { private String dropshipSourcePallet(List<InboundRef> currentRefs, String containerNo, String scanCode) {
@@ -960,9 +980,10 @@ public class ReceivingContainerService {
return resolved.isEmpty() ? null : resolved.get(0); return resolved.isEmpty() ? null : resolved.get(0);
} }
private List<DropshipPalletRef> dropshipPalletRefs(String containerNo, String sourcePalletNo) { private List<DropshipPalletRef> dropshipPalletRefs(String sourceContainerBookingId,
String containerNo, String sourcePalletNo) {
List<DropshipPalletRef> result = new ArrayList<>(); List<DropshipPalletRef> result = new ArrayList<>();
for (InboundRef ref : refs(containerNo)) { for (InboundRef ref : refs(sourceContainerBookingId, containerNo)) {
if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue; if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>() List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
@@ -1009,7 +1030,8 @@ public class ReceivingContainerService {
InboundPlanPalletAllocation allocation) { } InboundPlanPalletAllocation allocation) { }
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) { private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> inbounds = refs(session.getContainerNo()).stream() List<ReceivingContainerResponses.Inbound> inbounds = refs(
session.getSourceContainerBookingId(), session.getContainerNo()).stream()
.map(this::inbound).toList(); .map(this::inbound).toList();
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
@@ -1065,7 +1087,7 @@ public class ReceivingContainerService {
private boolean hasFbaOverReceipt(ReceivingContainerSession session, List<InboundPlan> containerPlans) { private boolean hasFbaOverReceipt(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
if (session == null || containerPlans == null || containerPlans.isEmpty()) return false; if (session == null || containerPlans == null || containerPlans.isEmpty()) return false;
return refs(session.getContainerNo()).stream() return refs(session.getSourceContainerBookingId(), session.getContainerNo()).stream()
.filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) .filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()))
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.anyMatch(ref -> fbaGroupHasOverReceipt(session, ref)); .anyMatch(ref -> fbaGroupHasOverReceipt(session, ref));
@@ -1073,7 +1095,7 @@ public class ReceivingContainerService {
private boolean fbaGroupHasOverReceipt(ReceivingContainerSession session, InboundRef ref) { private boolean fbaGroupHasOverReceipt(ReceivingContainerSession session, InboundRef ref) {
if (session == null || session.getId() == null) return false; if (session == null || session.getId() == null) return false;
int planned = requireFbaPlanRefs(session.getContainerNo(), ref).stream() int planned = requireFbaPlanRefs(session.getSourceContainerBookingId(), session.getContainerNo(), ref).stream()
.mapToInt(value -> value(value.line().getRequiredPackageCount())).sum(); .mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
return fbaPalletCounts.sumActual(session.getId(), fbaGroupKey(session.getContainerNo(), ref)) > planned; return fbaPalletCounts.sumActual(session.getId(), fbaGroupKey(session.getContainerNo(), ref)) > planned;
} }
@@ -1115,8 +1137,9 @@ public class ReceivingContainerService {
return mergeAllowed(plan) ? PALLET_MERGE_HINT : null; return mergeAllowed(plan) ? PALLET_MERGE_HINT : null;
} }
private Totals totals(List<InboundPlan> containerPlans) { private Totals totals(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> values = refs(containerPlans.get(0).getContainerNo()) List<ReceivingContainerResponses.Inbound> values = refs(
session.getSourceContainerBookingId(), session.getContainerNo())
.stream().map(this::inbound).toList(); .stream().map(this::inbound).toList();
List<String> destinationCodes = values.stream().map(this::destinationCode) List<String> destinationCodes = values.stream().map(this::destinationCode)
.filter(Objects::nonNull).distinct().sorted().toList(); .filter(Objects::nonNull).distinct().sorted().toList();
@@ -1246,8 +1269,8 @@ public class ReceivingContainerService {
} }
} }
private void validateFbaSkuBalance(String containerNo) { private void validateFbaSkuBalance(String sourceContainerBookingId, String containerNo) {
for (InboundRef ref : refs(containerNo)) { for (InboundRef ref : refs(sourceContainerBookingId, containerNo)) {
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue;
if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue; if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
String customerId = ref.order().getCustomerId(); String customerId = ref.order().getCustomerId();
@@ -1296,12 +1319,14 @@ public class ReceivingContainerService {
private ReceivingContainerSession waitingView(String operatorKey, private ReceivingContainerSession waitingView(String operatorKey,
WarehouseResourceGateway.WarehouseReference warehouse, WarehouseResourceGateway.WarehouseReference warehouse,
String sourceContainerBookingId,
String containerNo) { String containerNo) {
ReceivingContainerSession value = new ReceivingContainerSession(); ReceivingContainerSession value = new ReceivingContainerSession();
value.setOverseasWarehouseId(warehouse.warehouseId()); value.setOverseasWarehouseId(warehouse.warehouseId());
value.setOverseasWarehouseCode(warehouse.warehouseCode()); value.setOverseasWarehouseCode(warehouse.warehouseCode());
value.setOverseasWarehouseName(warehouse.warehouseName()); value.setOverseasWarehouseName(warehouse.warehouseName());
value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0); value.setOperatorKey(operatorKey); value.setSourceContainerBookingId(sourceContainerBookingId);
value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0);
return value; return value;
} }
@@ -1411,5 +1436,5 @@ public class ReceivingContainerService {
int planCount, int plannedInboundCodeCount, int pendingPlanCount, int planCount, int plannedInboundCodeCount, int pendingPlanCount,
boolean allPlansReady, boolean allPlansCompleted, boolean shippingReady, boolean operable, String blockReason, boolean allPlansReady, boolean allPlansCompleted, boolean shippingReady, boolean operable, String blockReason,
LocalDateTime plannedArrivalAt, String factHash, Totals totals) { } LocalDateTime plannedArrivalAt, String factHash, Totals totals) { }
private record PrepareCommand(String containerNo, String idempotencyKey) { } private record PrepareCommand(String sourceContainerBookingId, String containerNo, String idempotencyKey) { }
} }
@@ -42,16 +42,20 @@ paths:
/api/pda/v1/last-leg/receiving-containers/{containerNo}: /api/pda/v1/last-leg/receiving-containers/{containerNo}:
get: get:
summary: 查询待签收柜号详情 summary: 查询待签收柜号详情
description: 权限 lastleg:pda:operate;按来源柜返回跨客户的全部入仓码、目的类型、目的仓、托数和实收进度;每个入仓码仍按自身客户隔离。 description: 权限 lastleg:pda:operate;sourceContainerBookingId 与真实集装箱号共同锁定本次柜次,允许物理集装箱号在不同柜次重复使用;按来源柜返回跨客户的全部入仓码、目的类型、目的仓、托数和实收进度。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] parameters:
- { name: containerNo, in: path, required: true, schema: { type: string } }
- { name: sourceContainerBookingId, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } } responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } }
/api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare: /api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare:
post: post:
summary: 选择当前待签收柜号 summary: 选择当前待签收柜号
description: 权限 lastleg:pda:operate;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。 description: 权限 lastleg:pda:operate;sourceContainerBookingId 与真实集装箱号共同锁定本次柜次;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、当前作业柜全局双唯一键防止切柜或多人同时处理。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] parameters:
- { name: containerNo, in: path, required: true, schema: { type: string } }
- { name: sourceContainerBookingId, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } }
responses: { '200': { description: 当前柜会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 账号或柜号已有未完成会话 } } responses: { '200': { description: 当前柜会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 账号或柜号已有未完成会话 } }
/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files: /api/pda/v1/last-leg/receiving-containers/current/unload-photos/files:
@@ -117,7 +117,7 @@ class ReceivingContainerServiceBehaviorTest {
when(shippingGates.receivingAvailable("BOOKING-A", "CONT-A")).thenReturn(0); when(shippingGates.receivingAvailable("BOOKING-A", "CONT-A")).thenReturn(0);
assertThat(service.candidates("7", 100L)).isEmpty(); assertThat(service.candidates("7", 100L)).isEmpty();
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", "BOOKING-A",
new ReceivingContainerRequests.Action("not-ready"))) new ReceivingContainerRequests.Action("not-ready")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse); verifyNoInteractions(warehouse);
@@ -133,7 +133,7 @@ class ReceivingContainerServiceBehaviorTest {
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1); when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
var response = service.prepare("7", "receiver", 100L, "CONT-A", var response = service.prepare("7", "receiver", 100L, "CONT-A", "BOOKING-A",
new ReceivingContainerRequests.Action("same-key")); new ReceivingContainerRequests.Action("same-key"));
assertThat(response.overseasWarehouseId()).isEqualTo(100L); assertThat(response.overseasWarehouseId()).isEqualTo(100L);
assertThat(response.overseasWarehouseName()).isEqualTo("Shared LAX"); assertThat(response.overseasWarehouseName()).isEqualTo("Shared LAX");
@@ -145,7 +145,7 @@ class ReceivingContainerServiceBehaviorTest {
verify(operations).insert(saved.capture()); verify(operations).insert(saved.capture());
when(operations.find("7", "same-key")).thenReturn(saved.getValue()); when(operations.find("7", "same-key")).thenReturn(saved.getValue());
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", "BOOKING-A",
new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class) new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409"); .hasMessageContaining("409");
} }
@@ -249,13 +249,44 @@ class ReceivingContainerServiceBehaviorTest {
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
var candidate = service.candidates("7", 100L).get(0); var candidate = service.candidates("7", 100L).get(0);
var detail = service.detail("7", 100L, "TSTU0000204"); var detail = service.detail("7", 100L, "TSTU0000204", "BOOKING-204");
assertThat(candidate.operable()).isTrue(); assertThat(candidate.operable()).isTrue();
assertThat(detail.overseasWarehouseId()).isEqualTo(100L); assertThat(detail.overseasWarehouseId()).isEqualTo(100L);
assertThat(detail.overseasWarehouseCode()).isEqualTo("SHARED-LAX"); assertThat(detail.overseasWarehouseCode()).isEqualTo("SHARED-LAX");
} }
@Test
void detailUsesBookingOccurrenceWhenPhysicalContainerNumberIsReused() {
InboundPlan historicalPlan = plan("VAXU2748524");
historicalPlan.setId(2L); historicalPlan.setPlanNo("PLAN-OLD"); historicalPlan.setStatus("COMPLETED");
InboundPlan currentPlan = plan("VAXU2748524");
currentPlan.setId(3L); currentPlan.setPlanNo("PLAN-CURRENT");
InboundPlanLine historicalLine = line("FBA");
historicalLine.setId(10L); historicalLine.setPlanId(2L); historicalLine.setOrderId(1L);
historicalLine.setInboundCode("IN-OLD");
InboundPlanLine currentLine = line("FBA");
currentLine.setId(11L); currentLine.setPlanId(3L); currentLine.setOrderId(2L);
currentLine.setInboundCode("IN-CURRENT");
LastlegOrder historicalOrder = containerOrder(
1L, "100", "VAXU2748524", "BOOKING-OLD", "IN-OLD");
LastlegOrder currentOrder = containerOrder(
2L, "100", "VAXU2748524", "BOOKING-CURRENT", "IN-CURRENT");
when(plans.selectList(any())).thenReturn(List.of(historicalPlan, currentPlan));
when(lines.selectList(any())).thenReturn(
List.of(currentLine),
List.of(historicalLine), List.of(currentLine),
List.of(historicalLine), List.of(currentLine));
when(orders.selectList(any())).thenReturn(List.of(currentOrder));
when(orders.selectById(1L)).thenReturn(historicalOrder);
when(orders.selectById(2L)).thenReturn(currentOrder);
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
var detail = service.detail("7", 100L, "VAXU2748524", "BOOKING-CURRENT");
assertThat(detail.inbounds()).extracting("inboundCode").containsExactly("IN-CURRENT");
}
@Test @Test
void prepareAtomicallyBindsUnassignedPlansToThePickerWarehouse() { void prepareAtomicallyBindsUnassignedPlansToThePickerWarehouse() {
InboundPlan unassigned = plan("TSTU0195472"); InboundPlan unassigned = plan("TSTU0195472");
@@ -269,7 +300,7 @@ class ReceivingContainerServiceBehaviorTest {
when(orders.selectById(1L)).thenReturn(order); when(orders.selectById(1L)).thenReturn(order);
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
var result = service.prepare("7", "receiver", 100L, "TSTU0195472", var result = service.prepare("7", "receiver", 100L, "TSTU0195472", "BOOKING-472",
new ReceivingContainerRequests.Action("bind-warehouse")); new ReceivingContainerRequests.Action("bind-warehouse"));
assertThat(result.overseasWarehouseId()).isEqualTo(100L); assertThat(result.overseasWarehouseId()).isEqualTo(100L);
@@ -285,9 +316,10 @@ class ReceivingContainerServiceBehaviorTest {
when(plans.selectList(any())).thenReturn(List.of(plan)); when(plans.selectList(any())).thenReturn(List.of(plan));
when(lines.selectList(any())).thenReturn(List.of(planLine)); when(lines.selectList(any())).thenReturn(List.of(planLine));
when(orders.selectList(any())).thenReturn(List.of(order)); when(orders.selectList(any())).thenReturn(List.of(order));
when(orders.selectById(1L)).thenReturn(order);
when(pallets.selectCount(any())).thenReturn(0L); when(pallets.selectCount(any())).thenReturn(0L);
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "TSTU0195472", assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "TSTU0195472", "BOOKING-472",
new ReceivingContainerRequests.Action("missing-real-pallets"))) new ReceivingContainerRequests.Action("missing-real-pallets")))
.isInstanceOf(ResponseStatusException.class) .isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("尚未完整绑定真实托盘"); .hasMessageContaining("尚未完整绑定真实托盘");
@@ -339,7 +371,7 @@ class ReceivingContainerServiceBehaviorTest {
assertThat(candidate.pendingPlanCount()).isEqualTo(1); assertThat(candidate.pendingPlanCount()).isEqualTo(1);
assertThat(candidate.operable()).isFalse(); assertThat(candidate.operable()).isFalse();
assertThat(candidate.blockReason()).isEqualTo("1个入仓码已生成计划,1个订单未操作/缺入仓码"); assertThat(candidate.blockReason()).isEqualTo("1个入仓码已生成计划,1个订单未操作/缺入仓码");
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", "BOOKING-A",
new ReceivingContainerRequests.Action("partial"))) new ReceivingContainerRequests.Action("partial")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(sessions, never()).insert(any(ReceivingContainerSession.class)); verify(sessions, never()).insert(any(ReceivingContainerSession.class));
@@ -351,12 +383,14 @@ class ReceivingContainerServiceBehaviorTest {
when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
when(orders.selectList(any())).thenReturn(List.of( when(orders.selectList(any())).thenReturn(List.of(
containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"))); containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1")));
when(orders.selectById(1L)).thenReturn(
containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"));
ReceivingContainerSession other = session("CONT-B", "SIGNED"); ReceivingContainerSession other = session("CONT-B", "SIGNED");
other.setOperatorKey("8"); other.setActiveOperatorKey("OPERATOR:8"); other.setOperatorKey("8"); other.setActiveOperatorKey("OPERATOR:8");
other.setActiveContainerKey("CONTAINER:CONT-B"); other.setActiveContainerKey("CONTAINER:CONT-B");
when(sessions.findActiveByContainer("CONTAINER:CONT-B")).thenReturn(other); when(sessions.findActiveByContainer("CONTAINER:CONT-B")).thenReturn(other);
assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B")) assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B", "BOOKING-B"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse); verifyNoInteractions(warehouse);
} }
@@ -367,8 +401,10 @@ class ReceivingContainerServiceBehaviorTest {
when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
when(orders.selectList(any())).thenReturn(List.of( when(orders.selectList(any())).thenReturn(List.of(
containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"))); containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1")));
when(orders.selectById(1L)).thenReturn(
containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"));
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B")) assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B", "BOOKING-B"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
} }
@@ -378,14 +414,15 @@ class ReceivingContainerServiceBehaviorTest {
when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
LastlegOrder order = containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"); LastlegOrder order = containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1");
when(orders.selectList(any())).thenReturn(List.of(order)); when(orders.selectList(any())).thenReturn(List.of(order));
when(orders.selectById(1L)).thenReturn(order);
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", "BOOKING-B",
new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class); new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class);
verify(sessions, never()).insert(any(ReceivingContainerSession.class)); verify(sessions, never()).insert(any(ReceivingContainerSession.class));
org.mockito.Mockito.reset(sessions, operations); org.mockito.Mockito.reset(sessions, operations);
when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN")); when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", "BOOKING-B",
new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class); new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class);
verify(sessions, never()).insert(any(ReceivingContainerSession.class)); verify(sessions, never()).insert(any(ReceivingContainerSession.class));
} }
@@ -675,7 +712,8 @@ class ReceivingContainerServiceBehaviorTest {
void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() { void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() {
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
when(lines.selectList(any())).thenReturn(List.of()); when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
when(orders.selectById(1L)).thenReturn(order("FBA"));
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
@@ -1046,9 +1084,12 @@ class ReceivingContainerServiceBehaviorTest {
InboundPlan plan = plan(containerNo); InboundPlan plan = plan(containerNo);
InboundPlanLine line = line(destinationType); InboundPlanLine line = line(destinationType);
LastlegOrder order = order(destinationType); LastlegOrder order = order(destinationType);
order.setContainerNo(containerNo); order.setSourceContainerBookingId("BOOKING-A");
order.setInboundCode("IN-1");
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED")); when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED"));
when(plans.selectList(any())).thenReturn(List.of(plan)); when(plans.selectList(any())).thenReturn(List.of(plan));
when(lines.selectList(any())).thenReturn(List.of(line)); when(lines.selectList(any())).thenReturn(List.of(line));
org.mockito.Mockito.lenient().when(orders.selectList(any())).thenReturn(List.of(order));
when(orders.selectById(1L)).thenReturn(order); when(orders.selectById(1L)).thenReturn(order);
} }
@@ -1076,6 +1117,8 @@ class ReceivingContainerServiceBehaviorTest {
private static LastlegOrder order(String destinationType) { private static LastlegOrder order(String destinationType) {
LastlegOrder value = new LastlegOrder(); LastlegOrder value = new LastlegOrder();
value.setId(1L); value.setCustomerId("100"); value.setDestinationType(destinationType); value.setId(1L); value.setCustomerId("100"); value.setDestinationType(destinationType);
value.setContainerNo("CONT-A"); value.setSourceContainerBookingId("BOOKING-A");
value.setInboundCode("IN-1");
value.setOverseasWarehouseId("100"); value.setOverseasWarehouseCode("SHARED-LAX"); value.setOverseasWarehouseId("100"); value.setOverseasWarehouseCode("SHARED-LAX");
value.setOverseasWarehouseName("Shared LAX"); value.setOverseasWarehouseName("Shared LAX");
value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null); value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null);
@@ -1100,6 +1143,7 @@ class ReceivingContainerServiceBehaviorTest {
private static ReceivingContainerSession session(String containerNo, String status) { private static ReceivingContainerSession session(String containerNo, String status) {
ReceivingContainerSession value = new ReceivingContainerSession(); ReceivingContainerSession value = new ReceivingContainerSession();
value.setId(8L); value.setContainerNo(containerNo); value.setOperatorKey("7"); value.setId(8L); value.setContainerNo(containerNo); value.setOperatorKey("7");
value.setSourceContainerBookingId("BOOKING-A");
value.setOverseasWarehouseId(100L); value.setOverseasWarehouseCode("SHARED-LAX"); value.setOverseasWarehouseId(100L); value.setOverseasWarehouseCode("SHARED-LAX");
value.setOverseasWarehouseName("Shared LAX"); value.setOverseasWarehouseName("Shared LAX");
value.setStatus(status); value.setVersion(0); value.setStatus(status); value.setVersion(0);