Merge branch 'dev' into dev-lyhfive

# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java
This commit is contained in:
2026-08-25 15:06:11 +08:00
568 changed files with 26662 additions and 2081 deletions
@@ -17,6 +17,7 @@ public class InboundPlan {
private String customerId;
private Long sourceOrderId;
private String containerNo;
private Long overseasWarehouseId;
private String status;
private Integer lineCount;
private Integer palletCount;
@@ -16,6 +16,7 @@ public class InboundPlanLine {
private Long planId;
private Long orderId;
private String inboundCode;
private String destinationType;
private String fbaWarehouseCode;
private String destinationWarehouseId;
private String destinationWarehouseCode;
@@ -6,6 +6,8 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface InboundPlanCartonMapper extends BaseMapper<InboundPlanCarton> {
@Select("""
@@ -31,4 +33,13 @@ public interface InboundPlanCartonMapper extends BaseMapper<InboundPlanCarton> {
WHERE l.plan_id=#{planId})
""")
boolean hasLabelPool(@Param("planId") Long planId);
@Select("""
SELECT DISTINCT c.* FROM ll_inbound_plan_carton c
JOIN ll_order_detail d ON d.source_inbound_detail_id=c.source_inbound_detail_id
JOIN ll_inbound_plan_line l ON l.order_id=d.order_id
WHERE l.plan_id=#{planId} AND c.customer_id=l.customer_id
ORDER BY c.id
""")
List<InboundPlanCarton> listExpected(@Param("planId") Long planId);
}
@@ -7,6 +7,7 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@@ -47,4 +48,10 @@ public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
@Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1")
InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId);
@Update("UPDATE ll_inbound_plan SET overseas_warehouse_id=#{warehouseId},updated_at=NOW(3) " +
"WHERE id=#{planId} AND customer_id=#{customerId} " +
"AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})")
int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("planId") long planId,
@Param("warehouseId") long warehouseId);
}
@@ -1,43 +0,0 @@
package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}/pallets")
public class InboundPalletAssignmentController {
@Resource private InboundPdaWorkflowService service;
@PostMapping("/{planPalletId}/assign")
public ApiResponse<Void> assign(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo, @PathVariable long planPalletId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.Assign request) {
service.assign(scope(user, customerId), planNo, planPalletId, request);
return ApiResponse.success(null);
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,136 @@
package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/pda/v1/last-leg/receiving-containers")
@Tag(name = "Overseas PDA container receiving")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class ReceivingContainerController {
public static final String OPERATE_PERMISSION = PdaWarehouseIdentity.OPERATE_PERMISSION;
@Resource private ReceivingContainerService service;
@GetMapping("/candidates")
@Operation(summary = "List pending receiving containers across customers for the authenticated warehouse picker")
public ApiResponse<List<ReceivingContainerResponses.Candidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user) {
PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId()));
}
@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) {
PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo));
}
@PostMapping("/{containerNo}/prepare")
public ApiResponse<ReceivingContainerResponses.Session> prepare(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) {
PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(),
containerNo, request));
}
@PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<ReceivingContainerResponses.Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@RequestPart("file") MultipartFile file) {
PdaWarehouseIdentity identity = currentIdentity(user);
if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required");
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
}
@PostMapping("/current/sign")
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request));
}
@GetMapping("/current")
public ApiResponse<ReceivingContainerResponses.Session> current(@AuthenticationPrincipal AuthenticatedUser user) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.current(identity.operatorKey()));
}
@PostMapping("/current/cartons/resolve")
public ApiResponse<ReceivingContainerResponses.CartonResolved> resolveCarton(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.CartonResolve request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request));
}
@PostMapping("/current/skus/resolve")
public ApiResponse<ReceivingContainerResponses.SkuResolved> resolveSku(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.SkuResolve request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.resolveSku(identity.operatorKey(), request));
}
@PostMapping("/current/pallets/resolve")
public ApiResponse<ReceivingContainerResponses.PalletResolved> resolvePallet(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.PalletResolve request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request));
}
@PostMapping("/current/receipts/bulk")
public ApiResponse<ReceivingContainerResponses.Receipt> receiveBulk(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.BulkReceive request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request));
}
@PostMapping("/current/receipts/carton")
public ApiResponse<ReceivingContainerResponses.Receipt> receiveCarton(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.CartonReceive request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request));
}
@PostMapping("/current/complete")
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Action request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.complete(identity.operatorKey(), request));
}
private static PdaWarehouseIdentity identity(AuthenticatedUser user) {
return PdaWarehouseIdentity.require(user);
}
private PdaWarehouseIdentity currentIdentity(AuthenticatedUser user) {
PdaWarehouseIdentity identity = identity(user);
service.requireCurrentWarehouse(identity.operatorKey(), identity.overseasWarehouseId());
return identity;
}
}
@@ -12,7 +12,9 @@ public final class PdaResponses {
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount,
int pickedPackageCount, String orderType, boolean wholePalletInbound,
List<DropshipPallet> dropshipPallets, String inboundMode) {
List<DropshipPallet> dropshipPallets, String inboundMode,
List<Pallet> pallets, List<Allocation> allocations,
List<ExpectedCarton> expectedCartons) {
public Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
@@ -20,7 +22,8 @@ public final class PdaResponses {
int pickedPackageCount) {
this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt,
unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt,
expectedPackageCount, pickedPackageCount, null, false, List.of(), null);
expectedPackageCount, pickedPackageCount, null, false, List.of(), null,
List.of(), List.of(), List.of());
}
public Task(long taskId, String taskNo, String planNo, String status,
@@ -36,6 +39,21 @@ public final class PdaResponses {
long packageCount, List<DropshipSku> skus) {}
public record DropshipSku(String skuCode, String productName, long packageCount, long productQuantity) {}
public record Pallet(long planPalletId, String palletNo, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
long warehousePalletId, long warehouseLocationId,
String assignmentStatus, int actualPackageCount) {}
public record Allocation(long allocationId, long lineId, long planPalletId,
long orderId, String inboundCode, String fbaWarehouseCode,
int requiredPackageCount, int plannedPackageCount,
int countedPackageCount, int pickedPackageCount,
String status, int version) {}
public record ExpectedCarton(String cartonMark, String skuCode,
String destinationWarehouseId, String destinationWarehouseCode,
long planPalletId, String status) {}
public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {}
public record Operation(String result, long allocationId, int requiredPackageCount,
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.inboundplan.pda.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.List;
public final class ReceivingContainerRequests {
private ReceivingContainerRequests() { }
public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record SignedPhoto(@NotNull ReceivingPhotoRole role, @Positive long fileId) { }
public record CartonResolve(@NotBlank String cartonMark) { }
public record SkuResolve(@NotBlank @Size(max = 128) String skuCode) { }
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
public record BulkReceive(@NotBlank String inboundCode, @NotBlank @Size(max = 128) String skuCode,
@NotBlank String palletCode,
@Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { }
public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode,
@NotBlank @Size(max = 255) String idempotencyKey) { }
}
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.inboundplan.pda.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class ReceivingContainerResponses {
private ReceivingContainerResponses() { }
public record Candidate(String containerNo, String status, int planCount, int inboundCodeCount,
List<String> destinationCodes, int plannedPalletCount,
int totalPackages, int receivedPackages, boolean currentAccount) { }
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt,
int totalPackages, int receivedPackages, int pendingPackages,
List<Inbound> inbounds) { }
public record SignedPhoto(ReceivingPhotoRole role, long fileId) { }
public record Inbound(String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
public record CartonResolved(String cartonMark, String skuCode, String inboundCode,
String destinationType, String overseasWarehouseId,
String overseasWarehouseName, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
public record SkuResolved(String skuCode, String productName, String inboundCode,
String destinationType, String overseasWarehouseId,
String overseasWarehouseName, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
public record PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode,
String destinationType, boolean compatible) { }
public record Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
}
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.inboundplan.pda.dto;
public enum ReceivingPhotoRole {
LICENSE_PLATE,
CONTAINER_SEAL,
DOOR_OPEN
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.inboundplan.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_receiving_container_operation")
public class ReceivingContainerOperation {
@TableId(type = IdType.INPUT)
private Long id;
private Long sessionId;
private String customerId;
private String operatorKey;
private String action;
private String idempotencyKey;
private String requestHash;
private String requestJson;
private String resultJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.inboundplan.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_receiving_container_session")
public class ReceivingContainerSession {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private String containerNo;
private Long overseasWarehouseId;
private String overseasWarehouseName;
private String operatorKey;
private String operatorName;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String activeOperatorKey;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String activeContainerKey;
private String status;
private String signPhotos;
private LocalDateTime preparedAt;
private LocalDateTime signedAt;
private LocalDateTime receivingAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Version
private Integer version;
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.inboundplan.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_receiving_sku_receipt")
public class ReceivingSkuReceipt {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private Long sessionId;
private Long planId;
private Long lineId;
private Long palletId;
private Long orderId;
private String inboundCode;
private String skuCode;
private String productName;
private Integer receivedPackages;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Version
private Integer version;
}
@@ -1,15 +1,19 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
@@ -17,12 +21,17 @@ import java.io.IOException;
public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
private static final String BUSINESS_TYPE = "OVERSEAS_UNLOAD_PHOTO";
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct
void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); }
void initializeClient() {
client = restClientFactory.build(restClientBuilder, baseUrl,
restClientProperties.getUnloadPhotoFile());
}
@Override
public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) {
@@ -42,10 +51,14 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId))
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Unload photo upload failed");
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"Unload photo upload failed");
}).body(MetadataEnvelope.class);
requireSuccess(response, "Unload photo upload failed");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null) throw new IllegalArgumentException("File service returned no photo metadata");
if (data == null || data.fileId() == null) throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "File service returned no photo metadata");
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(),
data.sizeBytes() == null ? 0 : data.sizeBytes());
}
@@ -58,22 +71,40 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
tenantId, BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken)
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Unload photo does not exist or its ownership is invalid");
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"Unload photo does not exist or its ownership is invalid");
}).body(MetadataEnvelope.class);
requireSuccess(response, "Unload photo does not exist or its ownership is invalid");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null || data.fileId() != fileId
|| data.tenantId() == null || data.tenantId() != tenantId
|| !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(taskId).equals(data.businessId())) {
throw new IllegalArgumentException("Unload photo metadata does not match the PDA task");
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Unload photo metadata does not match the PDA task");
}
}
private long tenantId(String customerId) {
try { return Long.parseLong(customerId); }
catch (NumberFormatException exception) { throw new IllegalArgumentException("Customer ID must be numeric", exception); }
catch (NumberFormatException exception) { throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Customer ID must be numeric", exception); }
}
private record MetadataEnvelope(Metadata data) {}
private static void requireSuccess(MetadataEnvelope response, String fallback) {
if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback);
if ("200".equals(response.code())) return;
HttpStatus status = switch (String.valueOf(response.code())) {
case "A0400", "A0422" -> HttpStatus.BAD_REQUEST;
case "A0403" -> HttpStatus.FORBIDDEN;
case "A0404" -> HttpStatus.NOT_FOUND;
default -> HttpStatus.SERVICE_UNAVAILABLE;
};
throw new ResponseStatusException(status,
response.message() == null || response.message().isBlank() ? fallback : response.message());
}
private record MetadataEnvelope(String code, String message, Metadata data) {}
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) {}
}
@@ -1,9 +1,14 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import com.fasterxml.jackson.annotation.JsonAlias;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
@@ -12,6 +17,8 @@ import java.util.Map;
@Component
public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}")
private String baseUrl;
@@ -23,7 +30,8 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
@PostConstruct
void initializeClient() {
this.client = restClientBuilder.baseUrl(baseUrl).build();
this.client = restClientFactory.build(restClientBuilder, baseUrl,
restClientProperties.getWarehouseResource());
}
@Override
@@ -35,7 +43,9 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
throw new IllegalStateException("Warehouse rejected dropship pallet: HTTP " + result.getStatusCode());
}).body(WarehousePalletResponse.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response");
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().locationId());
return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
response.data().warehouseId(), response.data().locationId(), response.data().status(),
response.data().fbaWarehouseCode(), response.data().version());
}
@Override
@@ -69,6 +79,19 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.toBodilessEntity();
}
@Override
public void release(String customerId, long resourceLockId) {
client.post()
.uri("/internal/v1/warehouse-resource-locks/{lockId}/release", resourceLockId)
.header("X-Internal-Service-Token", internalToken)
.body(Map.of("customerId", Long.parseLong(customerId)))
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
throw new IllegalStateException("Warehouse rejected pallet release: HTTP " + response.getStatusCode());
})
.toBodilessEntity();
}
@Override
public void scrapPallet(String customerId, long palletId) {
client.post()
@@ -82,8 +105,79 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.toBodilessEntity();
}
@Override
public WarehousePalletReference resolvePallet(String palletCode) {
WarehousePalletResponse response = client.get()
.uri("/internal/v1/warehouse-pallets/resolve/{palletCode}", palletCode)
.header("X-Internal-Service-Token", internalToken)
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Warehouse rejected pallet code: HTTP " + result.getStatusCode());
}).body(WarehousePalletResponse.class);
if (response == null || response.data() == null) throw new IllegalArgumentException("Warehouse returned an empty pallet response");
return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
response.data().warehouseId(), response.data().locationId(), response.data().status(),
response.data().fbaWarehouseCode(), response.data().version());
}
@Override
public WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) {
WarehouseLocationResponse response = client.get()
.uri(uri -> uri.path("/internal/v1/warehouse-resource-locks/locations/resolve/{locationCode}")
.queryParam("warehouseId", warehouseId).build(locationCode))
.header("X-Internal-Service-Token", internalToken).retrieve()
.body(WarehouseLocationResponse.class);
return location(response);
}
@Override
public WarehouseLocationReference resolveLocation(long warehouseId, long locationId) {
WarehouseLocationResponse response = client.get()
.uri(uri -> uri.path("/internal/v1/warehouse-resource-locks/locations/{locationId}")
.queryParam("warehouseId", warehouseId).build(locationId))
.header("X-Internal-Service-Token", internalToken).retrieve()
.body(WarehouseLocationResponse.class);
return location(response);
}
private WarehouseLocationReference location(WarehouseLocationResponse response) {
if (response == null || response.data() == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse location was not found");
}
return response.data();
}
@Override
public WarehouseReference requireActiveSharedWarehouse(long warehouseId) {
WarehouseReferenceResponse response = client.get()
.uri("/internal/v1/warehouse-resource-locks/shared-warehouses/{warehouseId}", warehouseId)
.header("X-Internal-Service-Token", internalToken)
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().value() == 404
? HttpStatus.NOT_FOUND : (result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE),
"Shared overseas warehouse lookup failed");
}).body(WarehouseReferenceResponse.class);
if (response == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Shared overseas warehouse service returned no response");
}
if ("A0404".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"Active shared overseas warehouse was not found");
}
if (!"200".equals(response.code()) || response.data() == null
|| response.data().warehouseId() != warehouseId || !"ACTIVE".equals(response.data().status())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Invalid shared overseas warehouse binding");
}
return response.data();
}
private record WarehousePalletResponse(WarehousePalletData data) {}
private record WarehousePalletData(long id, String palletCode, long locationId) {}
private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId,
String status, String fbaWarehouseCode, int version) {}
private record ReserveEnvelope(ReserveData data) {}
private record ReserveData(long lockId) {}
private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {}
private record WarehouseLocationResponse(String code, String message, WarehouseLocationReference data) {}
}
@@ -4,8 +4,29 @@ public interface WarehouseResourceGateway {
WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId);
long reserve(String customerId, long warehouseId, long palletId, long locationId,
String businessType, String businessId, String reservationKey);
void release(String customerId, long resourceLockId);
void scrap(String customerId, long resourceLockId);
void scrapPallet(String customerId, long palletId);
WarehousePalletReference resolvePallet(String palletCode);
default WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) {
throw new UnsupportedOperationException("Warehouse location resolution is not configured");
}
default WarehouseLocationReference resolveLocation(long warehouseId, long locationId) {
throw new UnsupportedOperationException("Warehouse location resolution is not configured");
}
default WarehouseReference requireActiveSharedWarehouse(long warehouseId) {
throw new UnsupportedOperationException("Shared warehouse resolution is not configured");
}
record WarehousePalletReference(long palletId, String palletNo, long locationId) {}
record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId,
String status, String fbaWarehouseCode, int version) {
public WarehousePalletReference(long palletId, String palletNo, long locationId) {
this(palletId, palletNo, 0L, locationId, null, null, 0);
}
}
record WarehouseReference(long warehouseId, String warehouseCode, String warehouseName,
String status) { }
record WarehouseLocationReference(long locationId, String locationCode, String locationType,
java.math.BigDecimal maxLoadKg, String status) { }
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReceivingContainerOperationMapper extends BaseMapper<ReceivingContainerOperation> {
@Select("SELECT * FROM ll_receiving_container_operation " +
"WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
ReceivingContainerOperation find(@Param("operatorKey") String operatorKey,
@Param("key") String key);
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReceivingContainerSessionMapper extends BaseMapper<ReceivingContainerSession> {
@Select("SELECT * FROM ll_receiving_container_session " +
"WHERE active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE")
ReceivingContainerSession lockActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey);
@Select("SELECT * FROM ll_receiving_container_session " +
"WHERE active_operator_key=#{activeOperatorKey} LIMIT 1")
ReceivingContainerSession findActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey);
@Select("SELECT * FROM ll_receiving_container_session " +
"WHERE active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE")
ReceivingContainerSession lockActiveByContainer(@Param("activeContainerKey") String activeContainerKey);
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReceivingSkuReceiptMapper extends BaseMapper<ReceivingSkuReceipt> {
@Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " +
"AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id FOR UPDATE")
List<ReceivingSkuReceipt> lockLineSku(@Param("customerId") String customerId,
@Param("lineId") Long lineId,
@Param("skuCode") String skuCode);
@Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " +
"AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id")
List<ReceivingSkuReceipt> listLineSku(@Param("customerId") String customerId,
@Param("lineId") Long lineId,
@Param("skuCode") String skuCode);
@Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " +
"AND plan_id=#{planId} ORDER BY line_id,pallet_id,sku_code,id")
List<ReceivingSkuReceipt> listPlan(@Param("customerId") String customerId,
@Param("planId") Long planId);
}
@@ -66,6 +66,7 @@ public class PdaWorkflowRepository {
public List<InboundPlanCarton> cartons(Long planId) {
return cartons.selectList(new LambdaQueryWrapper<InboundPlanCarton>().eq(InboundPlanCarton::getPlanId, planId));
}
public List<InboundPlanCarton> expectedCartons(Long planId) { return cartons.listExpected(planId); }
public InboundPlanLine lineForCarton(Long planId, InboundPlanCarton carton) {
return cartons.findPlanLine(planId, carton.getSourceInboundDetailId());
}
@@ -17,6 +17,7 @@ import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -36,6 +37,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
@@ -52,6 +54,7 @@ public class InboundPdaWorkflowService {
@Resource private WarehouseResourceGateway warehouse;
@Resource private InboundStockReceiptService stockReceipts;
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Resource private LastlegOrderMapper orders;
@Resource private UnloadPhotoFileGateway unloadPhotoFiles;
@Resource private DropshipOrderService dropshipOrders;
@Resource private ObjectMapper sourceObjectMapper;
@@ -142,6 +145,118 @@ public class InboundPdaWorkflowService {
return taskResponse(task, plan, planNo);
}
@Transactional
public void ensureSignedTask(String customerId, InboundPlan plan, String operatorId, String operatorName,
List<Long> photoFileIds, LocalDateTime signedAt) {
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
if (task == null) {
task = new InboundPdaTask();
task.setId(IdWorker.getId()); task.setPlanId(plan.getId()); task.setCustomerId(customerId);
task.setTaskNo("PDA" + task.getId()); task.setCreatedAt(signedAt); task.setVersion(0);
repository.insert(task);
}
if ("COMPLETED".equals(task.getStatus())) return;
task.setStatus("SIGNED"); task.setOperatorId(operatorId); task.setOperatorName(operatorName);
task.setUnloadPhotoFileIds(write(photoFileIds)); task.setUnloadPhotoUploadedAt(signedAt);
task.setEstimatedEmptyReturnAt(signedAt.plusDays(2)); task.setUpdatedAt(signedAt);
task.setVersion(value(task.getVersion()) + 1); repository.update(task);
plan.setStatus("SIGNED"); plan.setUpdatedAt(signedAt); repository.update(plan);
}
@Transactional
public Long assignResolvedPallet(String customerId, InboundPlan plan, InboundPlanPallet pallet,
WarehouseResourceGateway.WarehousePalletReference resource) {
if (pallet.getWarehouseResourceLockId() != null) {
if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return null;
throw conflict("Destination pallet is already bound to another physical pallet");
}
String destinationKey = destinationKey(pallet);
String businessId = plan.getContainerNo() + ":" + destinationKey;
String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId();
long lockId = warehouse.reserve(customerId, resource.warehouseId(), resource.palletId(), resource.locationId(),
"INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey);
try {
pallet.setWarehousePalletId(resource.palletId()); pallet.setWarehouseLocationId(resource.locationId());
pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED");
pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet);
return lockId;
} catch (RuntimeException failure) {
compensateReservation(customerId, lockId, failure);
throw failure;
}
}
public void validateBulkResolved(String customerId, InboundPlan plan, InboundPlanLine line, int packageCount) {
requireActiveTask(customerId, plan);
int received = repository.allocations(plan.getId()).stream()
.filter(value -> Objects.equals(value.getLineId(), line.getId()))
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
int pending = value(line.getRequiredPackageCount()) - received;
if (packageCount <= 0 || packageCount > pending) {
throw conflict("Received package count exceeds the inbound pending total");
}
}
public void validateCartonResolved(String customerId, InboundPlan plan, InboundPlanLine line,
InboundPlanPallet pallet, String cartonMark) {
requireActiveTask(customerId, plan);
InboundPlanCarton carton = repository.lockCarton(customerId, cartonMark);
if (carton == null || !("UNASSIGNED".equals(carton.getStatus()) || "EXPECTED".equals(carton.getStatus()))) {
throw conflict("Carton mark has already been scanned or is unavailable");
}
InboundPlanLine resolvedLine = carton.getLineId() == null
? repository.lineForCarton(plan.getId(), carton) : requireLine(plan.getId(), carton.getLineId());
if (resolvedLine == null || !Objects.equals(resolvedLine.getId(), line.getId())) {
throw conflict("Carton mark belongs to another inbound code or container");
}
if (!sameWarehouse(carton, pallet)) throw conflict("Carton and pallet destination warehouses must match");
if (repository.scannedCartons(plan.getId(), line.getId()) >= value(line.getRequiredPackageCount())) {
throw conflict("This inbound plan line has reached its required carton count");
}
}
public void compensateReservation(String customerId, Long resourceLockId, RuntimeException originalFailure) {
if (resourceLockId == null) return;
try {
warehouse.release(customerId, resourceLockId);
} catch (RuntimeException releaseFailure) {
originalFailure.addSuppressed(releaseFailure);
}
}
@Transactional
public int receiveBulkResolved(String customerId, InboundPlan plan, InboundPlanLine line,
InboundPlanPallet pallet, int packageCount,
String operatorId, String operatorName) {
InboundPdaTask task = requireActiveTask(customerId, plan);
int received = repository.allocations(plan.getId()).stream()
.filter(value -> Objects.equals(value.getLineId(), line.getId()))
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
int pending = value(line.getRequiredPackageCount()) - received;
if (packageCount > pending) throw conflict("Received package count exceeds the inbound pending total");
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
if (allocation == null) {
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId());
allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0);
allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(ZERO_VOLUME);
allocation.setStatus("RECEIVING"); allocation.setVersion(0); repository.insert(allocation);
}
BigDecimal perPackage = perPackageVolume(line);
allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + packageCount);
allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + packageCount);
allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm())
.add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP));
allocation.setStatus(allocation.getPickedPackageCount() == value(line.getRequiredPackageCount())
? "RECEIVED" : "RECEIVING"); bump(allocation); repository.update(allocation);
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + packageCount);
pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm())
.add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP));
repository.update(pallet); startPicking(task, plan, operatorId, operatorName);
return packageCount;
}
@Transactional
public PdaResponses.Operation count(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
@@ -280,7 +395,8 @@ public class InboundPdaWorkflowService {
List<InboundPlanCarton> cartonsToScan = List.of(carton);
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
if (allocation == null) {
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); allocation.setPlanId(plan.getId());
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId());
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation);
@@ -430,14 +546,61 @@ public class InboundPdaWorkflowService {
@Transactional
public PdaResponses.Task complete(String customerId, String planNo) {
InboundPlan plan = requirePlan(customerId, planNo);
return completeContainer(customerId, List.of(plan)).get(0);
}
/** Completes every plan of one physical container and emits exactly one container-level outbox event. */
@Transactional
public List<PdaResponses.Task> completeContainer(String customerId, List<InboundPlan> containerPlans) {
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
List<InboundPlan> orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList();
String containerNo = orderedPlans.get(0).getContainerNo();
if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan ->
!customerId.equals(plan.getCustomerId()) || !containerNo.equals(plan.getContainerNo()))) {
throw conflict("Inbound plans must belong to one customer and one physical container");
}
List<CompletionContext> contexts = orderedPlans.stream().map(plan -> completionContext(customerId, plan)).toList();
boolean anyCompleted = contexts.stream().anyMatch(context -> "COMPLETED".equals(context.task().getStatus()));
boolean allCompleted = contexts.stream().allMatch(context -> "COMPLETED".equals(context.task().getStatus()));
if (allCompleted) return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList();
if (anyCompleted) throw conflict("Container plans are only partially completed; manual reconciliation is required");
LocalDateTime now = LocalDateTime.now();
for (CompletionContext context : contexts) completePlan(customerId, context, now);
String sourceContainerBookingId = sourceContainerBookingId(contexts);
LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt())
.max(LocalDateTime::compareTo).orElseThrow();
long aggregateVersion = contexts.stream().map(CompletionContext::task)
.map(InboundPdaTask::getVersion).filter(Objects::nonNull).mapToLong(Integer::longValue).max().orElse(1L);
unloadCompletedOutbox.publishContainerCompleted(customerId, sourceContainerBookingId, containerNo,
now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion);
return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList();
}
private String sourceContainerBookingId(List<CompletionContext> contexts) {
List<String> bookingIds = contexts.stream().map(context -> {
LastlegOrder order = orders.selectById(context.plan().getSourceOrderId());
if (order == null || order.getSourceContainerBookingId() == null
|| order.getSourceContainerBookingId().isBlank()) {
throw new IllegalStateException("Inbound plan has no source container booking ID");
}
return order.getSourceContainerBookingId();
}).distinct().toList();
if (bookingIds.size() != 1) throw conflict("Container plans reference different source container bookings");
return bookingIds.get(0);
}
private CompletionContext completionContext(String customerId, InboundPlan plan) {
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
if (task == null) throw conflict("Push the inbound plan to PDA before operating");
if ("COMPLETED".equals(task.getStatus())) return task(task, planNo);
if (task == null) throw conflict("Push every inbound plan in the container to PDA before completing");
if ("COMPLETED".equals(task.getStatus())) {
return new CompletionContext(plan, task, List.of(), List.of(), List.of(), List.of());
}
if ("PARTIAL_INBOUND".equals(task.getStatus())) throw conflict("PDA inbound task was partially completed");
List<Long> unloadPhotoFileIds = unloadPhotoFileIds(task);
if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null
|| task.getEstimatedEmptyReturnAt() == null) {
throw conflict("Upload unload photos before completing the PDA inbound task");
throw conflict("Upload unload photos for every inbound plan before completing the container");
}
Map<Long, Integer> pickedByLine = new HashMap<>();
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
@@ -451,15 +614,24 @@ public class InboundPdaWorkflowService {
throw conflict("All line picked package totals must exactly equal required package totals");
}
}
Map<Long, String> destinationTypes = lines.stream().collect(java.util.stream.Collectors.toMap(
InboundPlanLine::getId, line -> destinationType(line)));
List<InboundPlanCarton> cartons = repository.cartons(plan.getId());
if (mode(plan) == InboundMode.OWN_OVERSEAS && !cartons.isEmpty()
&& cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) {
if (cartons.stream().anyMatch(carton -> "OVERSEAS".equals(destinationTypes.get(carton.getLineId()))
&& !"ON_PALLET".equals(carton.getStatus()))) {
throw conflict("All expected carton marks must be scanned onto a pallet before stock-in");
}
LocalDateTime now = LocalDateTime.now();
stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now);
for (InboundPlanCarton carton : cartons) {
carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now);
return new CompletionContext(plan, task, unloadPhotoFileIds, lines, allocations, cartons);
}
private void completePlan(String customerId, CompletionContext context, LocalDateTime now) {
InboundPlan plan = context.plan();
InboundPdaTask task = context.task();
stockReceipts.stockIn(customerId, plan, task, context.lines(), context.allocations(),
repository.pallets(plan.getId()), now);
for (InboundPlanCarton carton : context.cartons()) {
carton.setStatus("ON_PALLET".equals(carton.getStatus()) ? "AVAILABLE" : "BULK_RECEIVED");
carton.setReceivedAt(now); carton.setUpdatedAt(now);
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
}
task.setStatus("COMPLETED");
@@ -470,8 +642,6 @@ public class InboundPdaWorkflowService {
plan.setStatus("COMPLETED");
plan.setUpdatedAt(now);
repository.update(plan);
unloadCompletedOutbox.publish(plan, task, unloadPhotoFileIds);
return taskResponse(task, plan, planNo);
}
/** Explicit operator decision to receive only the cartons actually picked. */
@@ -562,6 +732,7 @@ public class InboundPdaWorkflowService {
if (moved == null) {
moved = new InboundPlanPalletAllocation();
moved.setId(IdWorker.getId());
moved.setCustomerId(source.getCustomerId());
moved.setPlanId(planId);
moved.setLineId(source.getLineId());
moved.setPalletId(targetPalletId);
@@ -776,16 +947,55 @@ public class InboundPdaWorkflowService {
List<PdaResponses.DropshipPallet> dropshipPallets = wholePalletInbound
? dropshipOrders.palletSnapshots(task.getCustomerId(), order.getId()).stream()
.map(pallet -> dropshipPallet(pallet, palletsByNo, allocationsByPallet)).toList() : List.of();
PdaResponses.Task workInstructions = task(task, planNo);
return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task),
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), expected, picked,
orderType, wholePalletInbound, dropshipPallets, mode(plan).name());
orderType, wholePalletInbound, dropshipPallets, mode(plan).name(),
workInstructions.pallets(), workInstructions.allocations(), workInstructions.expectedCartons());
}
private PdaResponses.Task task(InboundPdaTask task, String planNo) {
List<InboundPlanPallet> planPallets = repository.pallets(task.getPlanId());
List<InboundPlanPalletAllocation> planAllocations = repository.allocations(task.getPlanId());
Map<Long, InboundPlanLine> lines = repository.lines(task.getPlanId()).stream()
.collect(java.util.stream.Collectors.toMap(InboundPlanLine::getId, value -> value));
List<PdaResponses.Pallet> pallets = planPallets.stream().map(value -> new PdaResponses.Pallet(
value.getId(), value.getPalletNo(), value.getDestinationWarehouseId(),
value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(),
Objects.requireNonNullElse(value.getWarehousePalletId(), 0L),
Objects.requireNonNullElse(value.getWarehouseLocationId(), 0L),
value.getAssignmentStatus(), InboundPdaWorkflowService.value(value.getActualPackageCount()))).toList();
List<PdaResponses.Allocation> allocations = planAllocations.stream().map(value -> {
InboundPlanLine line = lines.get(value.getLineId());
if (line == null) throw conflict("Inbound allocation line is missing");
return new PdaResponses.Allocation(value.getId(), value.getLineId(), value.getPalletId(),
line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(),
InboundPdaWorkflowService.value(line.getRequiredPackageCount()),
InboundPdaWorkflowService.value(value.getPlannedPackageCount()),
InboundPdaWorkflowService.value(value.getCountedPackageCount()),
InboundPdaWorkflowService.value(value.getPickedPackageCount()), value.getStatus(),
InboundPdaWorkflowService.value(value.getVersion()));
}).toList();
List<PdaResponses.ExpectedCarton> expectedCartons = repository.expectedCartons(task.getPlanId()).stream()
.map(value -> new PdaResponses.ExpectedCarton(value.getCartonMark(), value.getSkuCode(),
value.getDestinationWarehouseId(), value.getDestinationWarehouseCode(),
recommendedPallet(value, planPallets), value.getStatus()))
.toList();
return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task),
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt());
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(),
lines.values().stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(),
planAllocations.stream().mapToInt(value -> InboundPdaWorkflowService.value(value.getPickedPackageCount())).sum(),
null, false, List.of(), null, pallets, allocations, expectedCartons);
}
private long recommendedPallet(InboundPlanCarton carton, List<InboundPlanPallet> pallets) {
if (carton.getPlanPalletId() != null) return carton.getPlanPalletId();
return pallets.stream().filter(value -> Objects.equals(value.getDestinationWarehouseId(), carton.getDestinationWarehouseId())
|| (carton.getDestinationWarehouseId() == null
&& Objects.equals(value.getDestinationWarehouseCode(), carton.getDestinationWarehouseCode())))
.map(InboundPlanPallet::getId).findFirst().orElse(0L);
}
private PdaResponses.DropshipPallet dropshipPallet(DropshipPalletSnapshot pallet,
@@ -857,6 +1067,13 @@ public class InboundPdaWorkflowService {
return volume(line.getVolumeCbm()).divide(BigDecimal.valueOf(required), 8, RoundingMode.HALF_UP);
}
private static String destinationType(InboundPlanLine line) {
if (line.getDestinationType() != null && !line.getDestinationType().isBlank()) {
return line.getDestinationType().trim().toUpperCase();
}
return line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank() ? "OVERSEAS" : "FBA";
}
private InboundPdaOperation replay(String customerId, String action, String key, Object request) {
InboundPdaOperation operation = repository.operation(customerId, key);
if (operation == null) return null;
@@ -911,6 +1128,11 @@ public class InboundPdaWorkflowService {
allocation.setVersion(value(allocation.getVersion()) + 1);
}
private record CompletionContext(InboundPlan plan, InboundPdaTask task, List<Long> photoFileIds,
List<InboundPlanLine> lines,
List<InboundPlanPalletAllocation> allocations,
List<InboundPlanCarton> cartons) { }
private static int value(Integer value) { return value == null ? 0 : value; }
private static boolean terminal(String status) { return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status); }
private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; }
@@ -9,7 +9,9 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
@@ -39,6 +41,7 @@ public class InboundStockReceiptService {
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderDetailMapper orderDetails;
@Resource private InboundStockReceiptMapper receipts;
@Resource private ReceivingSkuReceiptMapper receivingSkuReceipts;
@Resource private InventoryCommandService inventory;
@Resource private DropshipOrderService dropshipOrders;
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
@@ -64,6 +67,9 @@ public class InboundStockReceiptService {
.filter(allocation -> value(allocation.getPickedPackageCount()) > 0)
.sorted(Comparator.comparing(InboundPlanPalletAllocation::getId))
.collect(Collectors.groupingBy(InboundPlanPalletAllocation::getLineId));
Map<Long, List<ReceivingSkuReceipt>> skuReceiptsByLine = receivingSkuReceipts
.listPlan(customerId, plan.getId()).stream()
.collect(Collectors.groupingBy(ReceivingSkuReceipt::getLineId));
Set<Long> expectedAllocationIds = allocationsByLine.values().stream().flatMap(List::stream)
.map(InboundPlanPalletAllocation::getId).collect(Collectors.toSet());
@@ -92,6 +98,12 @@ public class InboundStockReceiptService {
|| (allowPartial && pickedPackages > value(line.getRequiredPackageCount()))) {
throw conflict("PDA picked packages must equal the inbound order package total");
}
List<ReceivingSkuReceipt> exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of());
if (!exactSkuReceipts.isEmpty()) {
created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations,
palletById, order, exactSkuReceipts, pickedPackages, receivedAt));
continue;
}
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
? List.of() : skuRemainders(order.getId(), value(line.getRequiredPackageCount()));
Map<String, DropshipPalletSnapshot> dropshipPallets = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
@@ -127,6 +139,48 @@ public class InboundStockReceiptService {
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
}
private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan,
InboundPdaTask task, InboundPlanLine line, List<InboundPlanPalletAllocation> lineAllocations,
Map<Long, InboundPlanPallet> palletById, LastlegOrder order,
List<ReceivingSkuReceipt> exactReceipts, long pickedPackages, LocalDateTime receivedAt) {
long actualPackages = exactReceipts.stream().mapToLong(value -> value(value.getReceivedPackages())).sum();
if (actualPackages != pickedPackages) throw conflict("FBA SKU receipts must equal the picked package total");
Map<String, Long> expectedBySku = skuRemainders(order.getId(), pickedPackages).stream()
.collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum,
java.util.LinkedHashMap::new));
Map<String, Long> actualBySku = exactReceipts.stream().collect(Collectors.groupingBy(
value -> value.getSkuCode().toUpperCase(java.util.Locale.ROOT), java.util.LinkedHashMap::new,
Collectors.summingLong(value -> value(value.getReceivedPackages()))));
Map<String, Long> normalizedExpected = expectedBySku.entrySet().stream().collect(Collectors.toMap(
value -> value.getKey().toUpperCase(java.util.Locale.ROOT), Map.Entry::getValue, Long::sum,
java.util.LinkedHashMap::new));
if (!actualBySku.equals(normalizedExpected)) {
throw conflict("FBA SKU receipts must exactly match the frozen SKU package snapshots");
}
Map<Long, InboundPlanPalletAllocation> allocationByPallet = lineAllocations.stream()
.collect(Collectors.toMap(InboundPlanPalletAllocation::getPalletId, value -> value));
Map<Long, List<ReceivingSkuReceipt>> receiptsByPallet = exactReceipts.stream()
.collect(Collectors.groupingBy(ReceivingSkuReceipt::getPalletId));
List<InboundStockReceipt> created = new java.util.ArrayList<>();
for (Map.Entry<Long, List<ReceivingSkuReceipt>> entry : receiptsByPallet.entrySet()) {
InboundPlanPalletAllocation allocation = allocationByPallet.get(entry.getKey());
if (allocation == null) throw conflict("FBA SKU receipt pallet has no matching allocation");
long palletPackages = entry.getValue().stream().mapToLong(value -> value(value.getReceivedPackages())).sum();
if (palletPackages != value(allocation.getPickedPackageCount())) {
throw conflict("FBA SKU receipt pallet total must match the allocation total");
}
InboundPlanPallet pallet = requirePallet(palletById, allocation);
for (ReceivingSkuReceipt exact : entry.getValue()) {
created.add(persist(customerId, plan, task, line, allocation, pallet, order,
new SkuSnapshot(exact.getSkuCode(), exact.getProductName(), null,
value(exact.getReceivedPackages())), receivedAt));
}
}
return created;
}
private void completeOrders(String customerId, InboundPlan plan, Map<Long, LastlegOrder> orderById,
LocalDateTime completedAt, boolean partial) {
String subOrderStatus = partial ? "INBOUND_PLAN:PARTIAL_INBOUND" : "INBOUND_PLAN:COMPLETED";
@@ -141,6 +195,7 @@ public class InboundStockReceiptService {
order.setUpdatedAt(completedAt);
orders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
history.setCustomerId(order.getCustomerId());
history.setOrderId(order.getId());
history.setFromStatus(before);
history.setToStatus("INBOUND_COMPLETED");
@@ -161,8 +216,11 @@ public class InboundStockReceiptService {
if (packages <= 0 || pallet.getWarehousePalletId() == null) {
throw conflict("Stock-in requires picked packages and an assigned warehouse pallet");
}
String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()),
"Destination warehouse code is required for stock-in");
String destinationCode = first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode());
if (destinationCode == null && trim(line.getDestinationWarehouseId()) == null) {
throw conflict("Destination warehouse ID or code is required for stock-in");
}
if (destinationCode == null) destinationCode = "";
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode();
String overseasWarehouseId = required(order.getOverseasWarehouseId(),
"Actual overseas warehouse is required for stock-in");
@@ -39,10 +39,7 @@ public class InboundUnloadCompletedOutboxRelay {
EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class);
EventEnvelopeValidator.requireValid(event);
var response = client.receive(internalToken, event);
if (response == null || !response.successful() || response.data() == null
|| !event.eventId().equals(response.data().eventId())) {
throw new IllegalStateException("国内事件桥未确认海外卸柜完成事件");
}
requireAcknowledgement(event, response);
repository.published(row.getId(), workerId, LocalDateTime.now());
} catch (Exception exception) {
repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)),
@@ -51,4 +48,18 @@ public class InboundUnloadCompletedOutboxRelay {
}
}
}
private void requireAcknowledgement(EventEnvelope event,
com.baiyue.common.core.api.ApiResponse<EventBridgeClient.Acknowledgement> response) {
if (response == null) throw new IllegalStateException("国内事件桥返回空响应");
if (!response.successful()) {
throw new IllegalStateException("国内事件桥拒绝海外卸柜完成事件: code=" + response.code()
+ ", message=" + response.message() + ", requestId=" + response.requestId());
}
if (response.data() == null) throw new IllegalStateException("国内事件桥成功响应缺少确认数据");
if (!event.eventId().equals(response.data().eventId())) {
throw new IllegalStateException("国内事件桥确认eventId不一致: expected=" + event.eventId()
+ ", actual=" + response.data().eventId());
}
}
}
@@ -19,11 +19,13 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.dao.DuplicateKeyException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Objects;
/** Writes the overseas-to-CN handover to the local transactional outbox. */
@Service
@@ -32,31 +34,88 @@ public class InboundUnloadCompletedOutboxService {
@Resource private LastlegOrderMapper orders;
@Resource private ObjectMapper objectMapper;
/** 兼容单计划入仓完成;多计划同柜必须改调 publishContainerCompleted,并传柜级完成时刻。 */
public void publish(InboundPlan plan, InboundPdaTask task, List<Long> photoFileIds) {
LastlegOrder sourceOrder = orders.selectById(plan.getSourceOrderId());
if (sourceOrder == null || sourceOrder.getSourceContainerBookingId() == null
|| sourceOrder.getSourceContainerBookingId().isBlank()) {
throw new IllegalStateException("Inbound plan has no source container booking ID");
}
Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC);
var payload = new OverseasContainerUnloadedV1(sourceOrder.getSourceContainerBookingId(),
plan.getContainerNo(), photoUploadedAt, photoUploadedAt, null);
if (task.getCompletedAt() == null) {
throw new IllegalStateException("Inbound task has no completion time");
}
if (task.getUnloadPhotoUploadedAt() == null) {
throw new IllegalStateException("Inbound task has no unload photo upload time");
}
publishContainerCompleted(task.getCustomerId(), sourceOrder.getSourceContainerBookingId(),
plan.getContainerNo(), task.getCompletedAt().toInstant(ZoneOffset.UTC),
task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC),
Math.max(1, task.getVersion() == null ? 1 : task.getVersion()));
}
/**
* 柜级唯一发布入口。调用方只能在同一物理柜的全部计划完成后调用一次;重复调用保留首次冻结事实。
*/
public void publishContainerCompleted(String customerId, String sourceContainerBookingId,
String containerNo, Instant unloadingCompletedAt,
Instant unloadingPhotoUploadedAt, long aggregateVersion) {
requireText(customerId, "customerId");
requireText(sourceContainerBookingId, "sourceContainerBookingId");
requireText(containerNo, "containerNo");
Objects.requireNonNull(unloadingCompletedAt, "unloadingCompletedAt");
Objects.requireNonNull(unloadingPhotoUploadedAt, "unloadingPhotoUploadedAt");
if (unloadingCompletedAt.isBefore(unloadingPhotoUploadedAt)) {
throw new IllegalArgumentException("unloadingCompletedAt cannot be before unloadingPhotoUploadedAt");
}
String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + sourceContainerBookingId;
LastlegEventOutbox existing = mapper.findByEventKey(eventKey);
if (existing != null) {
requireSameContainer(existing, customerId, sourceContainerBookingId, containerNo);
return;
}
var payload = new OverseasContainerUnloadedV1(sourceContainerBookingId,
containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt, null);
JsonNode payloadNode = objectMapper.valueToTree(payload);
long aggregateVersion = Math.max(1, task.getVersion() == null ? 1 : task.getVersion());
String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId();
String traceId = EventIdentifiers.newUuid();
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED,
1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, task.getCustomerId(), "CONTAINER_BOOKING",
sourceOrder.getSourceContainerBookingId(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode,
1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, customerId, "CONTAINER_BOOKING",
sourceContainerBookingId, Math.max(1, aggregateVersion), 1L, traceId, eventKey, null, payloadNode,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
LastlegEventOutbox event = new LastlegEventOutbox();
event.setId(IdWorker.getId()); event.setCustomerId(task.getCustomerId());
event.setId(IdWorker.getId()); event.setCustomerId(customerId);
event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId());
event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion());
event.setEventKey(eventKey); event.setPayloadJson(json(envelope)); event.setStatus("PENDING");
event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
mapper.insert(event);
try {
mapper.insert(event);
} catch (DuplicateKeyException exception) {
LastlegEventOutbox raced = mapper.findByEventKey(eventKey);
if (raced == null) throw exception;
requireSameContainer(raced, customerId, sourceContainerBookingId, containerNo);
}
}
private void requireSameContainer(LastlegEventOutbox existing, String customerId,
String sourceContainerBookingId, String containerNo) {
try {
EventEnvelope envelope = objectMapper.readValue(existing.getPayloadJson(), EventEnvelope.class);
OverseasContainerUnloadedV1 frozen = objectMapper.treeToValue(
envelope.payload(), OverseasContainerUnloadedV1.class);
if (!Objects.equals(existing.getCustomerId(), customerId)
|| !Objects.equals(existing.getAggregateId(), sourceContainerBookingId)
|| !Objects.equals(frozen.containerNo(), containerNo)) {
throw new IllegalStateException("Unload-completed event key conflicts with another container");
}
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot read existing unload-completed event", exception);
}
}
private void requireText(String value, String field) {
if (value == null || value.isBlank()) throw new IllegalArgumentException(field + " is required");
}
private String json(Object value) {
@@ -0,0 +1,760 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class ReceivingContainerService {
@Resource private InboundPlanMapper plans;
@Resource private InboundPlanLineMapper lines;
@Resource private InboundPlanPalletMapper pallets;
@Resource private InboundPlanPalletAllocationMapper allocations;
@Resource private InboundPlanCartonMapper cartons;
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderDetailMapper orderDetails;
@Resource private ReceivingContainerSessionMapper sessions;
@Resource private ReceivingContainerOperationMapper operations;
@Resource private ReceivingSkuReceiptMapper skuReceipts;
@Resource private ContainerShippingGateMapper shippingGates;
@Resource private WarehouseResourceGateway warehouse;
@Resource private UnloadPhotoFileGateway unloadPhotos;
@Resource private InboundPdaWorkflowService workflow;
@Resource private ObjectMapper sourceObjectMapper;
private ObjectMapper objectMapper;
@PostConstruct
void initializeObjectMapper() {
objectMapper = sourceObjectMapper.copy()
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
}
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
Map<String, List<InboundPlan>> grouped = new LinkedHashMap<>();
plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> {
if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) {
grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan);
}
});
return grouped.entrySet().stream().filter(entry -> eligible(entry.getValue(), overseasWarehouseId)).map(entry -> {
requireSingleCustomer(entry.getValue(), entry.getKey());
Totals totals = totals(entry.getValue());
boolean active = current != null && entry.getKey().equals(current.getContainerNo());
return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN",
entry.getValue().size(), totals.inboundCount(), totals.destinationCodes(), totals.plannedPalletCount(),
totals.total(), totals.received(), active);
}).toList();
}
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
List<InboundPlan> containerPlans = requirePlans(containerNo);
String customerId = requireSingleCustomer(containerPlans, containerNo);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse");
requireReceivingAvailable(customerId, containerNo);
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
if (current != null && !containerNo.equals(current.getContainerNo())) {
throw conflict("Current account is locked to another selected container");
}
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView(
customerId, operatorKey, receivingWarehouse, containerNo), containerPlans);
}
@Transactional
public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo,
ReceivingContainerRequests.Action request) {
PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey());
ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command);
if (replay != null) {
ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class);
if (!Objects.equals(result.overseasWarehouseId(), overseasWarehouseId)) {
throw conflict("Prepared container belongs to another overseas warehouse");
}
return result;
}
List<InboundPlan> containerPlans = requirePlans(containerNo);
String customerId = requireSingleCustomer(containerPlans, containerNo);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse");
requireReceivingAvailable(customerId, containerNo);
String activeOperatorKey = activeOperatorKey(operatorKey);
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
requireSessionWarehouse(active, overseasWarehouseId);
if (active != null) {
if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one");
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
save(active, "PREPARE", request.idempotencyKey(), command, result); return result;
}
if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) {
throw conflict("Container is already signed or prepared by another account");
}
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse);
LocalDateTime now = LocalDateTime.now();
ReceivingContainerSession session = new ReceivingContainerSession();
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
session.setOverseasWarehouseId(overseasWarehouseId);
session.setOverseasWarehouseName(receivingWarehouse.warehouseName());
session.setOperatorKey(operatorKey); session.setOperatorName(operatorName);
session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo));
session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now);
session.setVersion(0);
try { sessions.insert(session); }
catch (DuplicateKeyException duplicate) { throw conflict("Account or container already has an active receiving session"); }
ReceivingContainerResponses.Session result = snapshot(session, containerPlans);
save(session, "PREPARE", request.idempotencyKey(), command, result); return result;
}
public ReceivingContainerResponses.Photo uploadPhoto(String operatorKey, long operatorId, MultipartFile file) {
ReceivingContainerSession session = requireCurrent(operatorKey, true);
String customerId = session.getCustomerId();
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing");
UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file);
return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes());
}
@Transactional
public ReceivingContainerResponses.Session sign(String operatorKey, String operatorName,
ReceivingContainerRequests.Sign request) {
ReceivingContainerOperation replay = replay(operatorKey, "SIGN", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
ReceivingContainerSession session = requireCurrent(operatorKey, true);
String customerId = session.getCustomerId();
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed");
List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request);
List<Long> photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList();
photoFileIds.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId));
LocalDateTime now = LocalDateTime.now();
session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now);
session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session);
for (InboundPlan plan : requirePlans(customerId, session.getContainerNo())) {
workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photoFileIds, now);
}
ReceivingContainerResponses.Session result = snapshot(session, requirePlans(customerId, session.getContainerNo()));
save(session, "SIGN", request.idempotencyKey(), request, result); return result;
}
public ReceivingContainerResponses.Session current(String operatorKey) {
ReceivingContainerSession session = requireCurrent(operatorKey, false);
return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo()));
}
public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) {
requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId);
}
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
ReceivingContainerRequests.CartonResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<InboundRef> currentRefs = refs(customerId, session.getContainerNo());
if (currentRefs.stream().noneMatch(value ->
"OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count");
}
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
if (carton == null) throw notFound("Carton mark was not found");
InboundRef ref = currentRefs.stream()
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst()
.orElseThrow(() -> conflict("Carton mark belongs to another container"));
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("FBA inbound must use bulk count instead of carton scan");
}
ReceivingContainerResponses.Inbound inbound = inbound(ref);
return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(),
inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(),
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages());
}
public ReceivingContainerResponses.SkuResolved resolveSku(String operatorKey,
ReceivingContainerRequests.SkuResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<SkuRef> matches = refs(customerId, session.getContainerNo()).stream()
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.map(ref -> skuRef(ref, request.skuCode()))
.filter(Objects::nonNull)
.toList();
if (matches.isEmpty()) throw notFound("SKU was not found in the current FBA container");
if (matches.size() != 1) throw conflict("SKU belongs to multiple inbound codes in the current container");
return skuResponse(matches.get(0));
}
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
ReceivingContainerRequests.PalletResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode());
validatePallet(ref, pallet);
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true);
}
@Transactional
public ReceivingContainerResponses.Receipt receiveBulk(String operatorKey, String operatorName,
ReceivingContainerRequests.BulkReceive request) {
ReceivingContainerOperation replay = replay(operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
}
SkuRef sku = requireSku(ref, request.skuCode());
validateSkuPending(customerId, sku, request.packageCount());
workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount());
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode());
validatePallet(ref, resource);
InboundPlanPallet pallet = choosePallet(ref, resource);
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
try {
int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet,
request.packageCount(), operatorKey, operatorName);
recordSkuReceipt(session, sku, pallet, accepted);
markReceiving(session);
ReceivingContainerResponses.Inbound inbound = inbound(ref);
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted,
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus());
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
workflow.compensateReservation(customerId, resourceLockId, failure);
throw failure;
}
}
@Transactional
public ReceivingContainerResponses.Receipt receiveCarton(String operatorKey, String operatorName,
ReceivingContainerRequests.CartonReceive request) {
ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<InboundRef> currentRefs = refs(customerId, session.getContainerNo());
if (currentRefs.stream().noneMatch(value ->
"OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count");
}
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
if (carton == null) throw notFound("Carton mark was not found");
InboundRef ref = currentRefs.stream()
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst()
.orElseThrow(() -> conflict("Carton mark belongs to another container"));
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("FBA destination must use bulk count");
}
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode());
validatePallet(ref, resource);
InboundPlanPallet pallet = choosePallet(ref, resource);
workflow.validateCartonResolved(customerId, ref.plan(), ref.line(), pallet, request.cartonMark());
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
try {
workflow.scanCarton(customerId, ref.plan().getPlanNo(), new PdaRequests.ScanCarton(
request.cartonMark(), pallet.getId(), request.idempotencyKey() + ":SCAN", operatorKey, operatorName));
markReceiving(session);
ReceivingContainerResponses.Inbound inbound = inbound(ref);
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1,
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus());
save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
workflow.compensateReservation(customerId, resourceLockId, failure);
throw failure;
}
}
@Transactional(isolation = Isolation.READ_COMMITTED)
public ReceivingContainerResponses.Session complete(String operatorKey,
ReceivingContainerRequests.Action request) {
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<InboundPlan> containerPlans = requirePlans(customerId, session.getContainerNo());
Totals totals = totals(containerPlans);
if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container");
validateFbaSkuBalance(customerId, session.getContainerNo());
workflow.completeContainer(customerId, containerPlans);
LocalDateTime now = LocalDateTime.now();
session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now);
session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session);
ReceivingContainerResponses.Session result = snapshot(session, containerPlans);
save(session, "COMPLETE", request.idempotencyKey(), request, result); return result;
}
private ReceivingContainerSession requireCurrent(String operatorKey, boolean lock) {
String key = activeOperatorKey(operatorKey);
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key)
: sessions.findActiveByOperator(key);
if (session == null) throw notFound("Current receiving container session was not found");
return session;
}
private ReceivingContainerSession requireSigned(String operatorKey) {
ReceivingContainerSession session = requireCurrent(operatorKey, true);
if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) {
throw conflict("Sign the receiving container before receiving cargo");
}
return session;
}
private List<InboundPlan> requirePlans(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");
requireSingleCustomer(values, containerNo);
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
throw conflict("Receiving container is already completed");
}
return values;
}
private List<InboundPlan> requirePlans(String customerId, String containerNo) {
List<InboundPlan> values = plansForContainer(customerId, containerNo);
if (values.isEmpty()) throw notFound("Receiving container was not found");
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
throw conflict("Receiving container is already completed");
}
return values;
}
private List<InboundPlan> plansForContainer(String customerId, String containerNo) {
List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getContainerNo, containerNo)
.orderByAsc(InboundPlan::getId));
if (values.isEmpty()) throw notFound("Receiving container was not found");
return values;
}
private String requireSingleCustomer(List<InboundPlan> values, String containerNo) {
Set<String> customers = values.stream().map(InboundPlan::getCustomerId).map(ReceivingContainerService::trim)
.filter(Objects::nonNull).collect(Collectors.toCollection(java.util.LinkedHashSet::new));
if (customers.size() != 1 || values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
throw conflict("Receiving container must resolve to exactly one customer: " + containerNo);
}
return customers.iterator().next();
}
private List<InboundRef> refs(String customerId, String containerNo) {
List<InboundRef> result = new ArrayList<>();
for (InboundPlan plan : plansForContainer(customerId, containerNo)) {
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) {
LastlegOrder order = orders.selectById(line.getOrderId());
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing");
result.add(new InboundRef(plan, line, order));
}
}
return result;
}
private InboundRef requireInbound(String customerId, String containerNo, String inboundCode) {
List<InboundRef> matches = refs(customerId, containerNo).stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container");
if (matches.size() != 1) throw conflict("Inbound code is duplicated inside the current container");
return matches.get(0);
}
private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) {
List<InboundPlanPallet> candidates = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream()
.filter(value -> sameDestination(ref.line(), value))
.filter(value -> value.getWarehousePalletId() == null || Objects.equals(value.getWarehousePalletId(), resource.palletId()))
.sorted(Comparator.comparing((InboundPlanPallet value) -> !Objects.equals(value.getWarehousePalletId(), resource.palletId()))
.thenComparing(value -> value.getActualPackageCount() == null ? 0 : value.getActualPackageCount()))
.toList();
if (candidates.isEmpty()) throw conflict("Pallet does not match an available destination pallet in this inbound code");
return candidates.get(0);
}
private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) {
if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped");
long actualWarehouse;
try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); }
catch (Exception exception) { throw conflict("Inbound order has no valid receiving warehouse ID"); }
long assignedWarehouse = ref.plan().getOverseasWarehouseId() == null ? actualWarehouse : ref.plan().getOverseasWarehouseId();
if (pallet.warehouseId() != assignedWarehouse) throw conflict("Pallet belongs to another overseas warehouse");
String type = destinationType(ref.line(), ref.order());
if ("FBA".equals(type)) {
if (ref.line().getFbaWarehouseCode() == null || ref.line().getFbaWarehouseCode().isBlank()
|| !ref.line().getFbaWarehouseCode().equalsIgnoreCase(String.valueOf(pallet.fbaWarehouseCode()))) {
throw conflict("Pallet FBA destination does not match the inbound code");
}
} else {
if (pallet.fbaWarehouseCode() != null && !pallet.fbaWarehouseCode().isBlank()) {
throw conflict("FBA-designated pallet cannot receive an OVERSEAS destination carton");
}
}
}
private boolean sameDestination(InboundPlanLine line, InboundPlanPallet pallet) {
if (line.getDestinationWarehouseId() != null && !line.getDestinationWarehouseId().isBlank()) {
return line.getDestinationWarehouseId().equals(pallet.getDestinationWarehouseId());
}
return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode());
}
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> inbounds = refs(session.getCustomerId(), 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();
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(),
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds);
}
private ReceivingContainerResponses.Inbound inbound(InboundRef ref) {
int received = allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream()
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
int total = value(ref.line().getRequiredPackageCount());
return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()),
ref.plan().getOverseasWarehouseId() == null ? ref.order().getOverseasWarehouseId()
: String.valueOf(ref.plan().getOverseasWarehouseId()), ref.order().getOverseasWarehouseName(),
ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(),
ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(),
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received));
}
private Totals totals(List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> values = refs(containerPlans.get(0).getCustomerId(),
containerPlans.get(0).getContainerNo()).stream().map(this::inbound).toList();
List<String> destinationCodes = values.stream().map(this::destinationCode)
.filter(Objects::nonNull).distinct().sorted().toList();
return new Totals(values.size(), destinationCodes,
values.stream().mapToInt(ReceivingContainerResponses.Inbound::plannedPalletCount).sum(),
values.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(),
values.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum());
}
private String destinationCode(ReceivingContainerResponses.Inbound inbound) {
return trim("FBA".equals(inbound.destinationType()) ? inbound.fbaWarehouseCode()
: inbound.destinationWarehouseCode());
}
private List<ReceivingContainerResponses.SignedPhoto> validateSignPhotos(ReceivingContainerRequests.Sign request) {
if (request.photos() == null || request.photos().size() != 3) {
throw conflict("Exactly three typed unload photos are required");
}
if (request.photos().stream().anyMatch(Objects::isNull)) throw conflict("Unload photo cannot be null");
Set<com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole> roles = request.photos().stream()
.map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet());
if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once");
Set<Long> fileIds = request.photos().stream().map(ReceivingContainerRequests.SignedPhoto::fileId)
.collect(Collectors.toSet());
if (fileIds.size() != 3 || fileIds.stream().anyMatch(value -> value == null || value <= 0)) {
throw conflict("Unload photo file IDs must be positive and unique");
}
return request.photos().stream()
.sorted(Comparator.comparing(value -> value.role().ordinal()))
.map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId())).toList();
}
private SkuRef requireSku(InboundRef ref, String skuCode) {
SkuRef result = skuRef(ref, skuCode);
if (result == null) throw notFound("SKU is not part of the selected inbound code");
return result;
}
private SkuRef skuRef(InboundRef ref, String skuCode) {
String requested = trim(skuCode);
if (requested == null) return null;
List<LastlegOrderDetail> details = orderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, ref.order().getCustomerId())
.eq(LastlegOrderDetail::getOrderId, ref.order().getId()).orderByAsc(LastlegOrderDetail::getId));
List<LastlegOrderDetail> matched = details.stream()
.filter(detail -> requested.equalsIgnoreCase(trim(detail.getSkuCode()))).toList();
int expected = matched.stream().mapToInt(detail -> value(detail.getPackageCount())).sum();
if (expected <= 0) return null;
String canonicalSku = matched.stream().map(LastlegOrderDetail::getSkuCode).map(ReceivingContainerService::trim)
.filter(Objects::nonNull).findFirst().orElse(requested);
String productName = matched.stream().map(detail -> first(detail.getProductNameZh(), detail.getProductNameEn()))
.filter(Objects::nonNull).findFirst().orElse(null);
return new SkuRef(ref, canonicalSku, productName, expected);
}
private ReceivingContainerResponses.SkuResolved skuResponse(SkuRef sku) {
ReceivingContainerResponses.Inbound inbound = inbound(sku.ref());
int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode());
return new ReceivingContainerResponses.SkuResolved(sku.skuCode(), sku.productName(), inbound.inboundCode(),
inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(),
inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(),
inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(),
sku.expectedPackages(), received, Math.max(0, sku.expectedPackages() - received));
}
private void validateSkuPending(String customerId, SkuRef sku, int packageCount) {
int received = receivedSkuPackages(customerId, sku.ref().line().getId(), sku.skuCode());
if (packageCount <= 0 || packageCount > sku.expectedPackages() - received) {
throw conflict("Received package count exceeds the SKU pending total");
}
}
private int receivedSkuPackages(String customerId, Long lineId, String skuCode) {
return skuReceipts.listLineSku(customerId, lineId, skuCode).stream()
.mapToInt(value -> value(value.getReceivedPackages())).sum();
}
private void recordSkuReceipt(ReceivingContainerSession session, SkuRef sku, InboundPlanPallet pallet, int packages) {
List<ReceivingSkuReceipt> existing = skuReceipts.lockLineSku(session.getCustomerId(),
sku.ref().line().getId(), sku.skuCode());
ReceivingSkuReceipt receipt = existing.stream()
.filter(value -> Objects.equals(value.getPalletId(), pallet.getId())).findFirst().orElse(null);
LocalDateTime now = LocalDateTime.now();
if (receipt == null) {
receipt = new ReceivingSkuReceipt(); receipt.setId(IdWorker.getId());
receipt.setCustomerId(session.getCustomerId()); receipt.setSessionId(session.getId());
receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId());
receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId());
receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode());
receipt.setProductName(sku.productName()); receipt.setReceivedPackages(packages);
receipt.setCreatedAt(now); receipt.setUpdatedAt(now); receipt.setVersion(0);
skuReceipts.insert(receipt);
} else {
receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + packages);
receipt.setUpdatedAt(now);
if (skuReceipts.updateById(receipt) != 1) throw conflict("SKU receiving progress changed concurrently");
}
}
private void validateFbaSkuBalance(String customerId, String containerNo) {
for (InboundRef ref : refs(customerId, containerNo)) {
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue;
List<LastlegOrderDetail> details = orderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, ref.order().getId()));
Set<String> skuCodes = details.stream().map(LastlegOrderDetail::getSkuCode)
.map(ReceivingContainerService::trim).filter(Objects::nonNull)
.map(value -> value.toUpperCase(java.util.Locale.ROOT))
.collect(Collectors.toCollection(java.util.LinkedHashSet::new));
int expected = 0;
int received = 0;
for (String skuCode : skuCodes) {
SkuRef sku = requireSku(ref, skuCode);
int skuReceived = receivedSkuPackages(customerId, ref.line().getId(), sku.skuCode());
if (skuReceived != sku.expectedPackages()) throw conflict("Every FBA SKU must be fully received before completing the container");
expected += sku.expectedPackages(); received += skuReceived;
}
if (skuCodes.isEmpty() || expected != value(ref.line().getRequiredPackageCount()) || received != expected) {
throw conflict("FBA SKU package snapshots must exactly match the inbound code total");
}
}
}
private String destinationType(InboundPlanLine line, LastlegOrder order) {
String value = line.getDestinationType();
if (value == null || value.isBlank()) value = order.getDestinationType();
if (value == null || value.isBlank()) value = line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank()
? "OVERSEAS" : "FBA";
value = value.trim().toUpperCase();
if (!List.of("FBA", "OVERSEAS").contains(value)) throw conflict("Unsupported destination type");
return value;
}
private void markReceiving(ReceivingContainerSession session) {
if (!"RECEIVING".equals(session.getStatus())) {
session.setStatus("RECEIVING"); session.setReceivingAt(LocalDateTime.now());
}
session.setUpdatedAt(LocalDateTime.now()); update(session);
}
private void update(ReceivingContainerSession session) {
if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently");
}
private ReceivingContainerSession waitingView(String customerId, String operatorKey,
WarehouseResourceGateway.WarehouseReference warehouse,
String containerNo) {
ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId);
value.setOverseasWarehouseId(warehouse.warehouseId());
value.setOverseasWarehouseName(warehouse.warehouseName());
value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0);
return value;
}
private boolean eligible(List<InboundPlan> containerPlans, long overseasWarehouseId) {
String customerId = requireSingleCustomer(containerPlans, containerPlans.get(0).getContainerNo());
return warehouseEligible(containerPlans, overseasWarehouseId)
&& shippingGates.receivingAvailable(customerId, containerPlans.get(0).getContainerNo()) == 1;
}
private boolean warehouseEligible(List<InboundPlan> containerPlans, long overseasWarehouseId) {
Set<Long> assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId)
.filter(Objects::nonNull).collect(Collectors.toSet());
if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream()
.anyMatch(plan -> plan.getOverseasWarehouseId() == null))) {
throw conflict("Container plans have inconsistent receiving warehouse assignments");
}
return assigned.isEmpty() || assigned.contains(overseasWarehouseId);
}
private void requireReceivingAvailable(String customerId, String containerNo) {
if (shippingGates.receivingAvailable(customerId, containerNo) != 1) {
throw conflict("Container has not reached PICKUP_READY and is not available for overseas receiving");
}
}
private void claimReceivingWarehouse(String customerId, List<InboundPlan> containerPlans,
WarehouseResourceGateway.WarehouseReference warehouse) {
long overseasWarehouseId = warehouse.warehouseId();
String warehouseId = String.valueOf(overseasWarehouseId);
for (InboundPlan plan : containerPlans) {
int claimed = plans.claimReceivingWarehouse(customerId, plan.getId(), overseasWarehouseId);
InboundPlan actual = plans.selectById(plan.getId());
if ((claimed == 0 && actual == null) || actual == null
|| !Objects.equals(actual.getOverseasWarehouseId(), overseasWarehouseId)) {
throw conflict("Container was claimed by another overseas warehouse");
}
plan.setOverseasWarehouseId(overseasWarehouseId);
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) {
orders.claimReceivingWarehouse(customerId, line.getOrderId(), warehouseId,
warehouse.warehouseName());
LastlegOrder order = orders.selectById(line.getOrderId());
if (order == null || !warehouseId.equals(order.getOverseasWarehouseId())) {
throw conflict("Inbound order was claimed by another overseas warehouse");
}
}
}
}
private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) {
if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) {
throw conflict("Current container belongs to another overseas warehouse");
}
}
private ReceivingContainerOperation replay(String operatorKey, String action, String key, Object request) {
key = idempotencyKey(key);
ReceivingContainerOperation value = operations.find(operatorKey, key);
if (value == null) return null;
if (!action.equals(value.getAction()) || !hash(request).equals(value.getRequestHash())) {
throw conflict("Idempotency key was already used with a different request");
}
return value;
}
private void save(ReceivingContainerSession session, String action, String key, Object request, Object result) {
key = idempotencyKey(key);
ReceivingContainerOperation value = new ReceivingContainerOperation(); value.setId(IdWorker.getId());
value.setSessionId(session.getId()); value.setCustomerId(session.getCustomerId());
value.setOperatorKey(session.getOperatorKey()); value.setAction(action); value.setIdempotencyKey(key);
value.setRequestHash(hash(request)); value.setRequestJson(write(request)); value.setResultJson(write(result));
value.setCreatedAt(LocalDateTime.now()); operations.insert(value);
}
private List<ReceivingContainerResponses.SignedPhoto> signPhotos(ReceivingContainerSession session) {
if (session.getSignPhotos() == null || session.getSignPhotos().isBlank()) return List.of();
try { return objectMapper.readValue(session.getSignPhotos(),
new TypeReference<List<ReceivingContainerResponses.SignedPhoto>>() { }); }
catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read signed receiving photos", exception); }
}
private String hash(Object value) {
try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException(exception); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException(exception); }
}
private <T> T read(ReceivingContainerOperation operation, Class<T> type) {
try { return objectMapper.readValue(operation.getResultJson(), type); }
catch (JsonProcessingException exception) { throw new IllegalStateException(exception); }
}
private static String activeOperatorKey(String operatorKey) { return "OPERATOR:" + operatorKey; }
private static String activeContainerKey(String containerNo) { return "CONTAINER:" + containerNo.trim().toUpperCase(java.util.Locale.ROOT); }
private static String idempotencyKey(String value) {
if (value == null || value.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency key is required");
value = value.trim();
if (value.length() > 255) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency key exceeds 255 characters");
return value;
}
private static int value(Integer value) { return value == null ? 0 : value; }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { }
private record SkuRef(InboundRef ref, String skuCode, String productName, int expectedPackages) { }
private record Totals(int inboundCount, List<String> destinationCodes, int plannedPalletCount,
int total, int received) { }
private record PrepareCommand(String containerNo, String idempotencyKey) { }
}
@@ -228,7 +228,8 @@ public class InboundPlanService {
.eq(InboundPlanPallet::getPlanId, plan.getId())).stream().map(InboundPlanPallet::getSequenceNo)
.filter(Objects::nonNull).max(Integer::compareTo).orElse(0) + 1;
InboundPlanPallet pallet = new InboundPlanPallet();
pallet.setId(IdWorker.getId()); pallet.setPlanId(plan.getId()); pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", sequence));
pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId());
pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", sequence));
pallet.setDestinationWarehouseId(request.destinationWarehouseId()); pallet.setDestinationWarehouseCode(request.destinationWarehouseCode());
pallet.setDestinationWarehouseName(request.destinationWarehouseName()); pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM);
pallet.setAllocatedVolumeCbm(BigDecimal.ZERO); pallet.setSequenceNo(sequence); pallet.setAssignmentStatus("UNASSIGNED");
@@ -249,6 +250,8 @@ public class InboundPlanService {
line.setPlanId(plan.getId());
line.setOrderId(order.orderId());
line.setInboundCode(order.inboundCode());
line.setDestinationType(order.fbaWarehouseCode() == null || order.fbaWarehouseCode().isBlank()
? "OVERSEAS" : "FBA");
line.setFbaWarehouseCode(order.fbaWarehouseCode());
line.setDestinationWarehouseId(order.destinationWarehouseId());
line.setDestinationWarehouseCode(order.destinationWarehouseCode());
@@ -315,7 +318,8 @@ public class InboundPlanService {
private void validateOrders(List<SourceOrderSnapshot> orders) {
for (SourceOrderSnapshot order : orders) {
if (!"PENDING_ASSIGNMENT".equals(order.currentStatus())) {
if (!("PENDING_ASSIGNMENT".equals(order.currentStatus())
|| "DESTINATION_ASSIGNED".equals(order.currentStatus()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Inbound code " + order.inboundCode() + " is not ready for an inbound plan");
}