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.RequestMapping;
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.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
@@ -58,17 +59,20 @@ public class ReceivingContainerController {
@GetMapping("/{containerNo}")
@Operation(summary = "Get one receiving container without exposing plan or allocation IDs")
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String containerNo) {
@PathVariable String containerNo,
@RequestParam String sourceContainerBookingId) {
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")
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);
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)
@@ -62,6 +62,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@@ -142,14 +143,16 @@ public class ReceivingContainerService {
.map(value -> candidate(value, current)).toList();
}
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
requireReadyContainer(containerNo, overseasWarehouseId, false);
List<InboundPlan> containerPlans = requirePlans(containerNo);
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo,
String sourceContainerBookingId) {
requireReadyContainer(containerNo, sourceContainerBookingId, overseasWarehouseId, false);
List<InboundPlan> containerPlans = requirePlans(sourceContainerBookingId, containerNo);
requireMaterializedPlans(containerPlans);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
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");
}
ReceivingContainerSession reserved = sessions.findActiveByContainer(activeContainerKey(containerNo));
@@ -158,15 +161,17 @@ public class ReceivingContainerService {
}
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView(
operatorKey, receivingWarehouse, containerNo), containerPlans);
return snapshot(current != null && containerNo.equals(current.getContainerNo())
&& sourceContainerBookingId.equals(current.getSourceContainerBookingId()) ? current : waitingView(
operatorKey, receivingWarehouse, sourceContainerBookingId, containerNo), containerPlans);
}
@Transactional
public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo,
String sourceContainerBookingId,
ReceivingContainerRequests.Action request) {
PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey());
ContainerReadiness readiness = requireReadyContainer(containerNo, overseasWarehouseId, true);
PrepareCommand command = new PrepareCommand(sourceContainerBookingId, containerNo, request.idempotencyKey());
ContainerReadiness readiness = requireReadyContainer(containerNo, sourceContainerBookingId, overseasWarehouseId, true);
ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command);
if (replay != null) {
ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class);
@@ -175,13 +180,14 @@ public class ReceivingContainerService {
}
return result;
}
List<InboundPlan> containerPlans = requirePlans(containerNo);
List<InboundPlan> containerPlans = requirePlans(sourceContainerBookingId, containerNo);
requireMaterializedPlans(containerPlans);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
String activeOperatorKey = activeOperatorKey(operatorKey);
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
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");
}
if (active == null && sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) {
@@ -229,7 +235,7 @@ public class ReceivingContainerService {
List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request);
List<Long> photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList();
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);
LocalDateTime now = LocalDateTime.now();
session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now);
@@ -243,7 +249,7 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.Session current(String operatorKey) {
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) {
@@ -276,10 +282,11 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
ReceivingContainerRequests.CartonResolve request) {
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());
if (sourcePalletNo != null) {
List<DropshipPalletRef> palletRefs = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo);
List<DropshipPalletRef> palletRefs = dropshipPalletRefs(
session.getSourceContainerBookingId(), session.getContainerNo(), sourcePalletNo);
requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
List<InboundRef> group = palletRefs.stream().map(DropshipPalletRef::ref).distinct().toList();
InboundRef first = group.get(0);
@@ -306,14 +313,16 @@ public class ReceivingContainerService {
}
carton = new CartonRef(matches.get(0));
} 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 {
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<InboundRef> group = requireFbaPlanRefs(
session.getSourceContainerBookingId(), 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();
@@ -339,10 +348,11 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
ReceivingContainerRequests.PalletResolve request) {
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()))
&& !"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()))
? resolveReceivingPallet(ref, request.palletCode(), false) : resolveFbaGroupPallet(group, request.palletCode());
WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
@@ -369,14 +379,16 @@ public class ReceivingContainerService {
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
requirePalletCapacity(request.packageCount());
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())) {
throw conflict("One-click dropship inbound must be operated by complete source pallet");
}
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) {
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());
WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
InboundPlanPallet pallet = selection.pallet();
@@ -451,15 +463,16 @@ public class ReceivingContainerService {
ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
List<InboundRef> currentRefs = refs(session.getContainerNo());
List<InboundRef> currentRefs = refs(session.getSourceContainerBookingId(), session.getContainerNo());
if (currentRefs.stream().noneMatch(value ->
"OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count");
}
String inboundCode = cartonInboundCode(request.cartonMark());
InboundRef inboundRef = requireInbound(session.getContainerNo(), inboundCode);
InboundRef inboundRef = requireInbound(session.getSourceContainerBookingId(), session.getContainerNo(), inboundCode);
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();
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("FBA destination must use bulk count");
@@ -496,10 +509,12 @@ 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);
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");
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();
requirePalletCapacity(total);
boolean alreadyReceived = matches.stream().allMatch(match ->
@@ -538,17 +553,17 @@ public class ReceivingContainerService {
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);
List<InboundPlan> containerPlans = requirePlans(session.getSourceContainerBookingId(), session.getContainerNo());
Totals totals = totals(session, 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());
validateFbaSkuBalance(session.getSourceContainerBookingId(), session.getContainerNo());
workflow.completeContainer(containerPlans);
} else {
workflow.completeContainerWithDifferences(containerPlans, reconciledPlans(containerPlans));
workflow.completeContainerWithDifferences(containerPlans, reconciledPlans(session, containerPlans));
}
LocalDateTime now = LocalDateTime.now();
session.setStatus(hasDifference ? "COMPLETED_WITH_DIFFERENCE" : "COMPLETED");
@@ -558,23 +573,24 @@ public class ReceivingContainerService {
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()
private List<InboundPlan> reconciledPlans(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
List<InboundRef> currentRefs = refs(session.getSourceContainerBookingId(), session.getContainerNo());
Set<Long> complete = currentRefs.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()
Set<String> incompleteFbaGroups = currentRefs.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()
currentRefs.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()))
.filter(plan -> currentRefs.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();
@@ -589,22 +605,23 @@ public class ReceivingContainerService {
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>()
.eq(LastlegOrder::getContainerNo, containerNo).orderByAsc(LastlegOrder::getId);
.eq(LastlegOrder::getContainerNo, containerNo)
.eq(LastlegOrder::getSourceContainerBookingId, bookingId)
.orderByAsc(LastlegOrder::getId);
if (lock) query.last("FOR UPDATE");
List<LastlegOrder> orderFacts = orders.selectList(query);
Set<String> bookingIds = orderFacts.stream().map(LastlegOrder::getSourceContainerBookingId)
.map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet());
if (orderFacts.isEmpty() || bookingIds.size() != 1) {
throw conflict("Container must resolve exactly one sourceContainerBookingId");
}
if (orderFacts.isEmpty()) throw notFound("当前柜次来源与集装箱号不匹配");
List<InboundPlan> planFacts = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId));
List<InboundPlanLine> lineFacts = lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.in(InboundPlanLine::getOrderId, orderFacts.stream().map(LastlegOrder::getId).toList())
.orderByAsc(InboundPlanLine::getId));
ContainerReadiness value = readiness(bookingIds.iterator().next(), orderFacts, planFacts, lineFacts,
ContainerReadiness value = readiness(bookingId, orderFacts, planFacts, lineFacts,
overseasWarehouseId);
if (value == null || !value.operable()) {
throw conflict(value == null ? "当前柜已分配至其他海外接柜仓,无法在本仓操作" : value.blockReason());
@@ -614,7 +631,8 @@ public class ReceivingContainerService {
private void requireStableSessionFacts(ReceivingContainerSession session, boolean lock) {
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())
|| !Objects.equals(session.getContainerFactHash(), current.factHash())) {
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();
boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders
&& plannedOrders == totalOrders && planCount == totalOrders;
List<InboundPlan> containerPlans = planFacts.stream()
.filter(plan -> containerNo.equals(plan.getContainerNo())).toList();
boolean allPlansCompleted = !containerPlans.isEmpty()
&& containerPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
boolean allPlansCompleted = !matchedPlans.isEmpty()
&& matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
.contains(plan.getStatus()));
boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1;
boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent;
@@ -716,9 +732,12 @@ public class ReceivingContainerService {
return session;
}
private List<InboundPlan> requirePlans(String containerNo) {
List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId));
private List<InboundPlan> requirePlans(String sourceContainerBookingId, String containerNo) {
List<InboundPlan> values = refs(sourceContainerBookingId, containerNo).stream()
.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.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
throw conflict("Every inbound plan in the receiving container must have an owning customer");
@@ -730,32 +749,31 @@ public class ReceivingContainerService {
return values;
}
private List<InboundPlan> plansForContainer(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) {
private List<InboundRef> refs(String sourceContainerBookingId, String containerNo) {
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());
if (customerId == null) throw conflict("Inbound plan has no owning customer");
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId())
.orderByAsc(InboundPlanLine::getId))) {
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));
}
}
if (result.isEmpty()) throw notFound("Receiving container was not found");
return result;
}
private InboundRef requireInbound(String containerNo, String inboundCode) {
List<InboundRef> matches = refs(containerNo).stream()
private InboundRef requireInbound(String sourceContainerBookingId, String containerNo, String inboundCode) {
List<InboundRef> matches = refs(sourceContainerBookingId, containerNo).stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
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());
@@ -763,11 +781,12 @@ public class ReceivingContainerService {
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>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark));
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 -> carton.getLineId() == null || Objects.equals(value.line().getId(), carton.getLineId()))
.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");
}
private List<InboundRef> requireFbaPlanRefs(String containerNo, InboundRef selected) {
List<InboundRef> values = refs(containerNo).stream()
private List<InboundRef> requireFbaPlanRefs(String sourceContainerBookingId,
String containerNo, InboundRef selected) {
List<InboundRef> values = refs(sourceContainerBookingId, containerNo).stream()
.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()),
@@ -940,8 +960,8 @@ 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(String sourceContainerBookingId, String containerNo, String scanCode) {
return dropshipSourcePallet(refs(sourceContainerBookingId, containerNo), containerNo, 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);
}
private List<DropshipPalletRef> dropshipPalletRefs(String containerNo, String sourcePalletNo) {
private List<DropshipPalletRef> dropshipPalletRefs(String sourceContainerBookingId,
String containerNo, String sourcePalletNo) {
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;
List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
@@ -1009,7 +1030,8 @@ public class ReceivingContainerService {
InboundPlanPalletAllocation allocation) { }
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();
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).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) {
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 -> "FBA".equals(destinationType(ref.line(), ref.order())))
.anyMatch(ref -> fbaGroupHasOverReceipt(session, ref));
@@ -1073,7 +1095,7 @@ public class ReceivingContainerService {
private boolean fbaGroupHasOverReceipt(ReceivingContainerSession session, InboundRef ref) {
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();
return fbaPalletCounts.sumActual(session.getId(), fbaGroupKey(session.getContainerNo(), ref)) > planned;
}
@@ -1115,8 +1137,9 @@ public class ReceivingContainerService {
return mergeAllowed(plan) ? PALLET_MERGE_HINT : null;
}
private Totals totals(List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> values = refs(containerPlans.get(0).getContainerNo())
private Totals totals(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> values = refs(
session.getSourceContainerBookingId(), session.getContainerNo())
.stream().map(this::inbound).toList();
List<String> destinationCodes = values.stream().map(this::destinationCode)
.filter(Objects::nonNull).distinct().sorted().toList();
@@ -1246,8 +1269,8 @@ public class ReceivingContainerService {
}
}
private void validateFbaSkuBalance(String containerNo) {
for (InboundRef ref : refs(containerNo)) {
private void validateFbaSkuBalance(String sourceContainerBookingId, String containerNo) {
for (InboundRef ref : refs(sourceContainerBookingId, containerNo)) {
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue;
if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
String customerId = ref.order().getCustomerId();
@@ -1296,12 +1319,14 @@ public class ReceivingContainerService {
private ReceivingContainerSession waitingView(String operatorKey,
WarehouseResourceGateway.WarehouseReference warehouse,
String sourceContainerBookingId,
String containerNo) {
ReceivingContainerSession value = new ReceivingContainerSession();
value.setOverseasWarehouseId(warehouse.warehouseId());
value.setOverseasWarehouseCode(warehouse.warehouseCode());
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;
}
@@ -1411,5 +1436,5 @@ public class ReceivingContainerService {
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
boolean allPlansReady, boolean allPlansCompleted, boolean shippingReady, boolean operable, String blockReason,
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}:
get:
summary: 查询待签收柜号详情
description: 权限 lastleg:pda:operate;按来源柜返回跨客户的全部入仓码、目的类型、目的仓、托数和实收进度;每个入仓码仍按自身客户隔离。
description: 权限 lastleg:pda:operate;sourceContainerBookingId 与真实集装箱号共同锁定本次柜次,允许物理集装箱号在不同柜次重复使用;按来源柜返回跨客户的全部入仓码、目的类型、目的仓、托数和实收进度。
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: 当前账号已锁定其他柜号 } }
/api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare:
post:
summary: 选择当前待签收柜号
description: 权限 lastleg:pda:operate;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。
description: 权限 lastleg:pda:operate;sourceContainerBookingId 与真实集装箱号共同锁定本次柜次;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、当前作业柜全局双唯一键防止切柜或多人同时处理。
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' } } } }
responses: { '200': { description: 当前柜会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 账号或柜号已有未完成会话 } }
/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);
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")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
@@ -133,7 +133,7 @@ class ReceivingContainerServiceBehaviorTest {
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
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"));
assertThat(response.overseasWarehouseId()).isEqualTo(100L);
assertThat(response.overseasWarehouseName()).isEqualTo("Shared LAX");
@@ -145,7 +145,7 @@ class ReceivingContainerServiceBehaviorTest {
verify(operations).insert(saved.capture());
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)
.hasMessageContaining("409");
}
@@ -249,13 +249,44 @@ class ReceivingContainerServiceBehaviorTest {
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
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(detail.overseasWarehouseId()).isEqualTo(100L);
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
void prepareAtomicallyBindsUnassignedPlansToThePickerWarehouse() {
InboundPlan unassigned = plan("TSTU0195472");
@@ -269,7 +300,7 @@ class ReceivingContainerServiceBehaviorTest {
when(orders.selectById(1L)).thenReturn(order);
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"));
assertThat(result.overseasWarehouseId()).isEqualTo(100L);
@@ -285,9 +316,10 @@ class ReceivingContainerServiceBehaviorTest {
when(plans.selectList(any())).thenReturn(List.of(plan));
when(lines.selectList(any())).thenReturn(List.of(planLine));
when(orders.selectList(any())).thenReturn(List.of(order));
when(orders.selectById(1L)).thenReturn(order);
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")))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("尚未完整绑定真实托盘");
@@ -339,7 +371,7 @@ class ReceivingContainerServiceBehaviorTest {
assertThat(candidate.pendingPlanCount()).isEqualTo(1);
assertThat(candidate.operable()).isFalse();
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")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
@@ -351,12 +383,14 @@ class ReceivingContainerServiceBehaviorTest {
when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
when(orders.selectList(any())).thenReturn(List.of(
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");
other.setOperatorKey("8"); other.setActiveOperatorKey("OPERATOR:8");
other.setActiveContainerKey("CONTAINER:CONT-B");
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");
verifyNoInteractions(warehouse);
}
@@ -367,8 +401,10 @@ class ReceivingContainerServiceBehaviorTest {
when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
when(orders.selectList(any())).thenReturn(List.of(
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"));
assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B"))
assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B", "BOOKING-B"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
}
@@ -378,14 +414,15 @@ class ReceivingContainerServiceBehaviorTest {
when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
LastlegOrder order = containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1");
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"));
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);
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
org.mockito.Mockito.reset(sessions, operations);
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);
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
}
@@ -675,7 +712,8 @@ class ReceivingContainerServiceBehaviorTest {
void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() {
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
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(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
@@ -1046,9 +1084,12 @@ class ReceivingContainerServiceBehaviorTest {
InboundPlan plan = plan(containerNo);
InboundPlanLine line = line(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(plans.selectList(any())).thenReturn(List.of(plan));
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);
}
@@ -1076,6 +1117,8 @@ class ReceivingContainerServiceBehaviorTest {
private static LastlegOrder order(String destinationType) {
LastlegOrder value = new LastlegOrder();
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.setOverseasWarehouseName("Shared LAX");
value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null);
@@ -1100,6 +1143,7 @@ class ReceivingContainerServiceBehaviorTest {
private static ReceivingContainerSession session(String containerNo, String status) {
ReceivingContainerSession value = new ReceivingContainerSession();
value.setId(8L); value.setContainerNo(containerNo); value.setOperatorKey("7");
value.setSourceContainerBookingId("BOOKING-A");
value.setOverseasWarehouseId(100L); value.setOverseasWarehouseCode("SHARED-LAX");
value.setOverseasWarehouseName("Shared LAX");
value.setStatus(status); value.setVersion(0);